mirror of
https://github.com/harivansh-afk/clanker-agent.git
synced 2026-04-15 04:03:27 +00:00
Merge pull request #272 from getcompanion-ai/grind
Add durable grind mode and non-mutating Companion OS checks
This commit is contained in:
commit
f58ff785ce
19 changed files with 1252 additions and 9 deletions
33
package-lock.json
generated
33
package-lock.json
generated
|
|
@ -5819,6 +5819,10 @@
|
|||
"integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pi-grind": {
|
||||
"resolved": "packages/pi-grind",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/pi-teams": {
|
||||
"resolved": "packages/pi-teams",
|
||||
"link": true
|
||||
|
|
@ -7595,6 +7599,35 @@
|
|||
"@sinclair/typebox": "*"
|
||||
}
|
||||
},
|
||||
"packages/pi-grind": {
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.3.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@mariozechner/pi-coding-agent": "*"
|
||||
}
|
||||
},
|
||||
"packages/pi-grind/node_modules/@types/node": {
|
||||
"version": "24.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz",
|
||||
"integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
},
|
||||
"packages/pi-grind/node_modules/undici-types": {
|
||||
"version": "7.16.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"packages/pi-runtime-daemon": {
|
||||
"name": "@local/pi-runtime-daemon",
|
||||
"version": "0.0.1",
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@
|
|||
"build": "cd packages/tui && npm run build && cd ../ai && npm run build && cd ../agent && npm run build && cd ../coding-agent && npm run build",
|
||||
"dev": "concurrently --names \"ai,agent,coding-agent,tui\" --prefix-colors \"cyan,yellow,red,magenta\" \"cd packages/ai && npm run dev\" \"cd packages/agent && npm run dev\" \"cd packages/coding-agent && npm run dev\" \"cd packages/tui && npm run dev\"",
|
||||
"dev:tsc": "cd packages/ai && npm run dev:tsc",
|
||||
"check": "biome check --write --error-on-warnings . && tsgo --noEmit && npm run check:browser-smoke",
|
||||
"check": "biome lint --error-on-warnings . && tsgo --noEmit && npm run check:browser-smoke",
|
||||
"check:browser-smoke": "sh -c 'esbuild scripts/browser-smoke-entry.ts --bundle --platform=browser --format=esm --log-limit=0 --outfile=/tmp/pi-browser-smoke.js > /tmp/pi-browser-smoke-errors.log 2>&1 || { echo \"Browser smoke check failed. See /tmp/pi-browser-smoke-errors.log\"; exit 1; }'",
|
||||
"test": "npm run test --workspaces --if-present",
|
||||
"version:patch": "npm version patch -ws --no-git-tag-version && node scripts/sync-versions.js && shx rm -rf node_modules packages/*/node_modules package-lock.json && npm install",
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@
|
|||
*/
|
||||
process.title = "pi";
|
||||
|
||||
import { setBedrockProviderModule } from "@mariozechner/pi-ai";
|
||||
import { bedrockProviderModule } from "@mariozechner/pi-ai/bedrock-provider";
|
||||
import { setBedrockProviderModule } from "@mariozechner/pi-ai";
|
||||
import { EnvHttpProxyAgent, setGlobalDispatcher } from "undici";
|
||||
import { main } from "./main.js";
|
||||
|
||||
|
|
|
|||
|
|
@ -995,12 +995,15 @@ export interface RegisteredCommand {
|
|||
// Extension API
|
||||
// ============================================================================
|
||||
|
||||
type ExtensionHandlerResult<R> = [R] extends [undefined]
|
||||
? Promise<void> | void
|
||||
: Promise<R | undefined> | R | undefined;
|
||||
|
||||
/** Handler function type for events */
|
||||
// biome-ignore lint/suspicious/noConfusingVoidType: void allows bare return statements
|
||||
export type ExtensionHandler<E, R = undefined> = (
|
||||
event: E,
|
||||
ctx: ExtensionContext,
|
||||
) => Promise<R | void> | R | void;
|
||||
) => ExtensionHandlerResult<R>;
|
||||
|
||||
/**
|
||||
* ExtensionAPI passed to extension factory functions.
|
||||
|
|
|
|||
|
|
@ -8,10 +8,10 @@ import { rm } from "node:fs/promises";
|
|||
import { join } from "node:path";
|
||||
import { URL } from "node:url";
|
||||
import type { AgentSession, AgentSessionEvent } from "../agent-session.js";
|
||||
import type { Settings } from "../settings-manager.js";
|
||||
import { extractMessageText, getLastAssistantText } from "./helpers.js";
|
||||
import {
|
||||
type GatewayEvent,
|
||||
type GatewayQueuedMessage,
|
||||
HttpError,
|
||||
type ManagedGatewaySession,
|
||||
} from "./internal-types.js";
|
||||
|
|
@ -29,7 +29,6 @@ import type {
|
|||
HistoryPart,
|
||||
ModelInfo,
|
||||
} from "./types.js";
|
||||
import type { Settings } from "../settings-manager.js";
|
||||
import {
|
||||
createVercelStreamListener,
|
||||
errorVercelStream,
|
||||
|
|
|
|||
|
|
@ -836,10 +836,10 @@ export class ToolExecutionComponent extends Container {
|
|||
.join("\n");
|
||||
if (remaining > 0) {
|
||||
text +=
|
||||
theme.fg(
|
||||
`${theme.fg(
|
||||
"muted",
|
||||
`\n... (${remaining} more lines, ${totalLines} total,`,
|
||||
) + ` ${keyHint("expandTools", "to expand")})`;
|
||||
)} ${keyHint("expandTools", "to expand")})`;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import type { AgentSessionEvent } from "../src/core/agent-session.js";
|
|||
import {
|
||||
createVercelStreamListener,
|
||||
extractUserText,
|
||||
} from "../src/core/vercel-ai-stream.js";
|
||||
} from "../src/core/gateway/vercel-ai-stream.js";
|
||||
|
||||
describe("extractUserText", () => {
|
||||
it("extracts text from useChat v5+ format with parts", () => {
|
||||
|
|
|
|||
45
packages/pi-grind/README.md
Normal file
45
packages/pi-grind/README.md
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
# pi-grind
|
||||
|
||||
Explicit grind mode for Pi.
|
||||
|
||||
Features:
|
||||
|
||||
- Auto-activates only when the user uses explicit grind cues in a prompt
|
||||
- Persists run state in session custom entries
|
||||
- Continues work on a heartbeat while running in `pi daemon`
|
||||
- Pauses automatically when the user sends a normal prompt
|
||||
|
||||
Example prompts:
|
||||
|
||||
- `Keep going on this until 5pm`
|
||||
- `Don't stop until the refactor is done`
|
||||
|
||||
Commands:
|
||||
|
||||
- `/grind start --until "5pm" Ship the refactor`
|
||||
- `/grind status`
|
||||
- `/grind pause`
|
||||
- `/grind resume`
|
||||
- `/grind stop`
|
||||
|
||||
Settings:
|
||||
|
||||
```json
|
||||
{
|
||||
"pi-grind": {
|
||||
"enabled": true,
|
||||
"pollIntervalMs": 30000,
|
||||
"cueMode": "explicit-only",
|
||||
"requireDaemon": true,
|
||||
"userIntervention": "pause",
|
||||
"cuePatterns": [
|
||||
"don't stop",
|
||||
"keep going",
|
||||
"keep running",
|
||||
"run until",
|
||||
"until done",
|
||||
"stay on this until"
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
35
packages/pi-grind/package.json
Normal file
35
packages/pi-grind/package.json
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"name": "pi-grind",
|
||||
"version": "0.1.0",
|
||||
"description": "Explicit grind mode for pi with durable follow-up continuation in daemon mode",
|
||||
"type": "module",
|
||||
"keywords": [
|
||||
"pi-package"
|
||||
],
|
||||
"license": "MIT",
|
||||
"author": "Mario Zechner",
|
||||
"main": "./src/index.ts",
|
||||
"files": [
|
||||
"src",
|
||||
"test",
|
||||
"package.json",
|
||||
"README.md"
|
||||
],
|
||||
"pi": {
|
||||
"extensions": [
|
||||
"./src/index.ts"
|
||||
]
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@mariozechner/pi-agent-core": "*",
|
||||
"@mariozechner/pi-coding-agent": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.3.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "vitest --run"
|
||||
}
|
||||
}
|
||||
64
packages/pi-grind/src/config.ts
Normal file
64
packages/pi-grind/src/config.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { getAgentDir, SettingsManager } from "@mariozechner/pi-coding-agent";
|
||||
import { DEFAULT_POLL_INTERVAL_MS, GRIND_SETTINGS_KEY, type GrindConfig } from "./types.js";
|
||||
|
||||
const DEFAULT_CUE_PATTERNS = [
|
||||
"don't stop",
|
||||
"keep going",
|
||||
"keep running",
|
||||
"run until",
|
||||
"until done",
|
||||
"stay on this until",
|
||||
];
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object";
|
||||
}
|
||||
|
||||
function asStringArray(value: unknown): string[] | undefined {
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const strings = value.filter((item): item is string => typeof item === "string");
|
||||
return strings.length > 0 ? strings : undefined;
|
||||
}
|
||||
|
||||
export function loadConfig(cwd: string): GrindConfig {
|
||||
const settingsManager = SettingsManager.create(cwd, getAgentDir());
|
||||
const globalSettings = settingsManager.getGlobalSettings() as Record<string, unknown>;
|
||||
const projectSettings = settingsManager.getProjectSettings() as Record<string, unknown>;
|
||||
|
||||
const globalConfig = isRecord(globalSettings[GRIND_SETTINGS_KEY]) ? globalSettings[GRIND_SETTINGS_KEY] : {};
|
||||
const projectConfig = isRecord(projectSettings[GRIND_SETTINGS_KEY]) ? projectSettings[GRIND_SETTINGS_KEY] : {};
|
||||
|
||||
const cuePatterns =
|
||||
asStringArray(projectConfig.cuePatterns) ?? asStringArray(globalConfig.cuePatterns) ?? DEFAULT_CUE_PATTERNS;
|
||||
|
||||
const pollIntervalMsRaw =
|
||||
typeof projectConfig.pollIntervalMs === "number"
|
||||
? projectConfig.pollIntervalMs
|
||||
: typeof globalConfig.pollIntervalMs === "number"
|
||||
? globalConfig.pollIntervalMs
|
||||
: DEFAULT_POLL_INTERVAL_MS;
|
||||
|
||||
return {
|
||||
enabled:
|
||||
typeof projectConfig.enabled === "boolean"
|
||||
? projectConfig.enabled
|
||||
: typeof globalConfig.enabled === "boolean"
|
||||
? globalConfig.enabled
|
||||
: true,
|
||||
pollIntervalMs:
|
||||
Number.isFinite(pollIntervalMsRaw) && pollIntervalMsRaw > 0
|
||||
? Math.max(1_000, Math.floor(pollIntervalMsRaw))
|
||||
: DEFAULT_POLL_INTERVAL_MS,
|
||||
cueMode: "explicit-only",
|
||||
requireDaemon:
|
||||
typeof projectConfig.requireDaemon === "boolean"
|
||||
? projectConfig.requireDaemon
|
||||
: typeof globalConfig.requireDaemon === "boolean"
|
||||
? globalConfig.requireDaemon
|
||||
: true,
|
||||
userIntervention: "pause",
|
||||
cuePatterns,
|
||||
};
|
||||
}
|
||||
480
packages/pi-grind/src/index.ts
Normal file
480
packages/pi-grind/src/index.ts
Normal file
|
|
@ -0,0 +1,480 @@
|
|||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import type { ExtensionAPI, ExtensionContext, RegisteredCommand } from "@mariozechner/pi-coding-agent";
|
||||
import { loadConfig } from "./config.js";
|
||||
import { parseAutoActivation, parseGrindStatus, parseStopCondition } from "./parser.js";
|
||||
import { buildContinuationPrompt, buildRepairPrompt, buildSystemPromptAddon } from "./prompts.js";
|
||||
import { createRunState, getLatestRunState, withLoopStatus, withStatus } from "./state.js";
|
||||
import {
|
||||
DEFAULT_COMPLETION_CRITERION,
|
||||
GRIND_STATE_ENTRY_TYPE,
|
||||
type GrindConfig,
|
||||
type GrindRunState,
|
||||
MAX_PARSE_FAILURES,
|
||||
} from "./types.js";
|
||||
|
||||
function isDaemonRuntime(): boolean {
|
||||
if (process.env.PI_GRIND_FORCE_DAEMON === "1") {
|
||||
return true;
|
||||
}
|
||||
if (process.env.PI_GRIND_FORCE_DAEMON === "0") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
process.argv.includes("daemon") ||
|
||||
process.argv.includes("gateway") ||
|
||||
Boolean(process.env.PI_GATEWAY_BIND) ||
|
||||
Boolean(process.env.PI_GATEWAY_PORT) ||
|
||||
Boolean(process.env.PI_GATEWAY_TOKEN)
|
||||
);
|
||||
}
|
||||
|
||||
function getAssistantText(message: AgentMessage): string {
|
||||
if (message.role !== "assistant") {
|
||||
return "";
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
for (const part of message.content) {
|
||||
if (part.type === "text") {
|
||||
parts.push(part.text);
|
||||
}
|
||||
}
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
function isGrindCommand(text: string): boolean {
|
||||
return text.trim().toLowerCase().startsWith("/grind");
|
||||
}
|
||||
|
||||
function note(ctx: ExtensionContext, message: string): void {
|
||||
if (ctx.hasUI) {
|
||||
ctx.ui.notify(message, "info");
|
||||
}
|
||||
}
|
||||
|
||||
function readState(ctx: ExtensionContext): GrindRunState | null {
|
||||
return getLatestRunState(ctx.sessionManager.getEntries());
|
||||
}
|
||||
|
||||
function persistState(pi: ExtensionAPI, ctx: ExtensionContext, state: GrindRunState): GrindRunState {
|
||||
pi.appendEntry(GRIND_STATE_ENTRY_TYPE, state);
|
||||
if (ctx.hasUI) {
|
||||
ctx.ui.setStatus("pi-grind", state.status === "active" ? "GRIND" : state.status.toUpperCase());
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
function clearUiStatus(ctx: ExtensionContext): void {
|
||||
if (ctx.hasUI) {
|
||||
ctx.ui.setStatus("pi-grind", "");
|
||||
}
|
||||
}
|
||||
|
||||
function maybeExpireRun(pi: ExtensionAPI, ctx: ExtensionContext, state: GrindRunState): GrindRunState | null {
|
||||
if (!state.deadlineAt) {
|
||||
return state;
|
||||
}
|
||||
|
||||
if (Date.parse(state.deadlineAt) > Date.now()) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const expired = withStatus(state, "expired", {
|
||||
lastCheckpoint: "Grind run expired at the configured deadline.",
|
||||
lastNextAction: null,
|
||||
pendingRepair: false,
|
||||
});
|
||||
persistState(pi, ctx, expired);
|
||||
note(ctx, "Grind mode stopped: deadline reached.");
|
||||
return expired;
|
||||
}
|
||||
|
||||
function tokenizeArgs(input: string): string[] {
|
||||
const tokens: string[] = [];
|
||||
let current = "";
|
||||
let quote: '"' | "'" | null = null;
|
||||
|
||||
for (let index = 0; index < input.length; index += 1) {
|
||||
const char = input[index];
|
||||
if ((char === '"' || char === "'") && !quote) {
|
||||
quote = char;
|
||||
continue;
|
||||
}
|
||||
if (quote && char === quote) {
|
||||
quote = null;
|
||||
continue;
|
||||
}
|
||||
if (!quote && /\s/.test(char)) {
|
||||
if (current) {
|
||||
tokens.push(current);
|
||||
current = "";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
current += char;
|
||||
}
|
||||
|
||||
if (current) {
|
||||
tokens.push(current);
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function parseStartCommandArgs(args: string): {
|
||||
goal: string | null;
|
||||
until: string | null;
|
||||
criterion: string | null;
|
||||
} {
|
||||
const tokens = tokenizeArgs(args);
|
||||
const goalParts: string[] = [];
|
||||
let until: string | null = null;
|
||||
let criterion: string | null = null;
|
||||
|
||||
for (let index = 0; index < tokens.length; index += 1) {
|
||||
const token = tokens[index];
|
||||
if (token === "--until") {
|
||||
until = tokens[index + 1] ?? null;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (token.startsWith("--until=")) {
|
||||
until = token.slice("--until=".length) || null;
|
||||
continue;
|
||||
}
|
||||
if (token === "--criterion") {
|
||||
criterion = tokens[index + 1] ?? null;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (token.startsWith("--criterion=")) {
|
||||
criterion = token.slice("--criterion=".length) || null;
|
||||
continue;
|
||||
}
|
||||
|
||||
goalParts.push(token);
|
||||
}
|
||||
|
||||
return {
|
||||
goal: goalParts.length > 0 ? goalParts.join(" ") : null,
|
||||
until,
|
||||
criterion,
|
||||
};
|
||||
}
|
||||
|
||||
function startRun(
|
||||
pi: ExtensionAPI,
|
||||
ctx: ExtensionContext,
|
||||
config: GrindConfig,
|
||||
input: {
|
||||
activation: "explicit" | "command";
|
||||
goal: string;
|
||||
sourcePrompt: string;
|
||||
deadlineAt: string | null;
|
||||
completionCriterion: string | null;
|
||||
},
|
||||
): GrindRunState | null {
|
||||
if (!config.enabled) {
|
||||
note(ctx, "Grind mode is disabled in settings.");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (config.requireDaemon && !isDaemonRuntime()) {
|
||||
note(ctx, "Durable grind mode requires `pi daemon`.");
|
||||
return null;
|
||||
}
|
||||
|
||||
const nextState = createRunState(input);
|
||||
persistState(pi, ctx, nextState);
|
||||
note(ctx, "Grind mode activated.");
|
||||
return nextState;
|
||||
}
|
||||
|
||||
export default function grind(pi: ExtensionAPI) {
|
||||
let config: GrindConfig | null = null;
|
||||
let state: GrindRunState | null = null;
|
||||
let heartbeat: NodeJS.Timeout | null = null;
|
||||
|
||||
const getConfig = (cwd: string): GrindConfig => {
|
||||
config = loadConfig(cwd);
|
||||
return config;
|
||||
};
|
||||
|
||||
const stopHeartbeat = () => {
|
||||
if (heartbeat) {
|
||||
clearInterval(heartbeat);
|
||||
heartbeat = null;
|
||||
}
|
||||
};
|
||||
|
||||
const ensureHeartbeat = (ctx: ExtensionContext) => {
|
||||
stopHeartbeat();
|
||||
heartbeat = setInterval(() => {
|
||||
if (!config || !state || state.status !== "active") {
|
||||
return;
|
||||
}
|
||||
if (config.requireDaemon && !isDaemonRuntime()) {
|
||||
return;
|
||||
}
|
||||
if (!ctx.isIdle() || ctx.hasPendingMessages()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const expired = maybeExpireRun(pi, ctx, state);
|
||||
state = expired;
|
||||
if (!state || state.status !== "active") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.pendingRepair) {
|
||||
pi.sendUserMessage(buildRepairPrompt(state), {
|
||||
deliverAs: "followUp",
|
||||
});
|
||||
} else {
|
||||
pi.sendUserMessage(buildContinuationPrompt(state), {
|
||||
deliverAs: "followUp",
|
||||
});
|
||||
}
|
||||
}, config?.pollIntervalMs ?? 30_000);
|
||||
heartbeat.unref?.();
|
||||
};
|
||||
|
||||
const registerCommand = (name: string, command: Omit<RegisteredCommand, "name">) => {
|
||||
pi.registerCommand(name, command);
|
||||
};
|
||||
|
||||
registerCommand("grind", {
|
||||
description: "Manage grind mode: /grind [start|status|pause|resume|stop]",
|
||||
getArgumentCompletions: (prefix: string) =>
|
||||
["start", "status", "pause", "resume", "stop"]
|
||||
.filter((value) => value.startsWith(prefix.trim().toLowerCase()))
|
||||
.map((value) => ({ value, label: value })),
|
||||
handler: async (args, ctx) => {
|
||||
const currentConfig = getConfig(ctx.cwd);
|
||||
const trimmed = args?.trim() ?? "";
|
||||
const [command] = tokenizeArgs(trimmed);
|
||||
const subcommand = command?.toLowerCase() ?? "status";
|
||||
const restArgs = trimmed.slice(command?.length ?? 0).trim();
|
||||
|
||||
if (subcommand === "start") {
|
||||
const parsed = parseStartCommandArgs(restArgs);
|
||||
if (!parsed.goal) {
|
||||
note(ctx, "Usage: /grind start [--until <time>] [--criterion <text>] <goal>");
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedUntil = parsed.until
|
||||
? parseStopCondition(`until ${parsed.until}`)
|
||||
: { deadlineAt: null, completionCriterion: null };
|
||||
|
||||
const nextState = startRun(pi, ctx, currentConfig, {
|
||||
activation: "command",
|
||||
goal: parsed.goal,
|
||||
sourcePrompt: parsed.goal,
|
||||
deadlineAt: parsedUntil.deadlineAt,
|
||||
completionCriterion: parsed.criterion ?? parsedUntil.completionCriterion ?? DEFAULT_COMPLETION_CRITERION,
|
||||
});
|
||||
state = nextState;
|
||||
if (state) {
|
||||
pi.sendUserMessage(parsed.goal, { deliverAs: "followUp" });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (subcommand === "status") {
|
||||
const currentState = state ?? readState(ctx);
|
||||
if (!currentState) {
|
||||
note(ctx, "Grind mode is not active in this session.");
|
||||
return;
|
||||
}
|
||||
|
||||
note(
|
||||
ctx,
|
||||
[
|
||||
`status=${currentState.status}`,
|
||||
`goal=${currentState.goal}`,
|
||||
currentState.deadlineAt ? `deadline=${currentState.deadlineAt}` : null,
|
||||
currentState.completionCriterion ? `criterion=${currentState.completionCriterion}` : null,
|
||||
currentState.lastCheckpoint ? `checkpoint=${currentState.lastCheckpoint}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (subcommand === "pause") {
|
||||
if (!state) {
|
||||
state = readState(ctx);
|
||||
}
|
||||
if (!state) {
|
||||
note(ctx, "No grind run to pause.");
|
||||
return;
|
||||
}
|
||||
state = persistState(pi, ctx, withStatus(state, "paused", { pendingRepair: false }));
|
||||
note(ctx, "Grind mode paused.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (subcommand === "resume") {
|
||||
if (!state) {
|
||||
state = readState(ctx);
|
||||
}
|
||||
if (!state) {
|
||||
note(ctx, "No grind run to resume.");
|
||||
return;
|
||||
}
|
||||
if (currentConfig.requireDaemon && !isDaemonRuntime()) {
|
||||
note(ctx, "Durable grind mode requires `pi daemon`.");
|
||||
return;
|
||||
}
|
||||
state = persistState(pi, ctx, withStatus(state, "active"));
|
||||
note(ctx, "Grind mode resumed.");
|
||||
pi.sendUserMessage(buildContinuationPrompt(state), {
|
||||
deliverAs: "followUp",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (subcommand === "stop") {
|
||||
if (!state) {
|
||||
state = readState(ctx);
|
||||
}
|
||||
if (!state) {
|
||||
note(ctx, "No grind run to stop.");
|
||||
return;
|
||||
}
|
||||
state = persistState(
|
||||
pi,
|
||||
ctx,
|
||||
withStatus(state, "stopped", {
|
||||
pendingRepair: false,
|
||||
lastNextAction: null,
|
||||
}),
|
||||
);
|
||||
note(ctx, "Grind mode stopped.");
|
||||
clearUiStatus(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
note(ctx, `Unknown grind command: ${subcommand}`);
|
||||
},
|
||||
});
|
||||
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
config = loadConfig(ctx.cwd);
|
||||
state = readState(ctx);
|
||||
if (state && ctx.hasUI) {
|
||||
ctx.ui.setStatus("pi-grind", state.status === "active" ? "GRIND" : state.status.toUpperCase());
|
||||
}
|
||||
if (config.enabled) {
|
||||
ensureHeartbeat(ctx);
|
||||
}
|
||||
});
|
||||
|
||||
pi.on("session_shutdown", async () => {
|
||||
stopHeartbeat();
|
||||
});
|
||||
|
||||
pi.on("input", async (event, ctx) => {
|
||||
const currentConfig = getConfig(ctx.cwd);
|
||||
if (!currentConfig.enabled || event.source === "extension") {
|
||||
return { action: "continue" } as const;
|
||||
}
|
||||
|
||||
const currentState = state ?? readState(ctx);
|
||||
|
||||
if (currentState && currentState.status === "active" && !isGrindCommand(event.text)) {
|
||||
const activation = parseAutoActivation(event.text, currentConfig.cuePatterns);
|
||||
if (!activation) {
|
||||
if (currentConfig.userIntervention === "pause") {
|
||||
state = persistState(pi, ctx, withStatus(currentState, "paused", { pendingRepair: false }));
|
||||
note(ctx, "Grind mode paused for manual input.");
|
||||
}
|
||||
return { action: "continue" } as const;
|
||||
}
|
||||
}
|
||||
|
||||
const activation = parseAutoActivation(event.text, currentConfig.cuePatterns);
|
||||
if (!activation) {
|
||||
return { action: "continue" } as const;
|
||||
}
|
||||
|
||||
state = startRun(pi, ctx, currentConfig, {
|
||||
activation: "explicit",
|
||||
goal: event.text,
|
||||
sourcePrompt: event.text,
|
||||
deadlineAt: activation.stopCondition.deadlineAt,
|
||||
completionCriterion: activation.stopCondition.completionCriterion,
|
||||
});
|
||||
|
||||
return { action: "continue" } as const;
|
||||
});
|
||||
|
||||
pi.on("before_agent_start", async (event, ctx) => {
|
||||
state = state ?? readState(ctx);
|
||||
if (!state || state.status !== "active") {
|
||||
return;
|
||||
}
|
||||
|
||||
const expired = maybeExpireRun(pi, ctx, state);
|
||||
state = expired;
|
||||
if (!state || state.status !== "active") {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
systemPrompt: `${event.systemPrompt}\n\n${buildSystemPromptAddon(state)}`,
|
||||
};
|
||||
});
|
||||
|
||||
pi.on("turn_end", async (event, ctx) => {
|
||||
state = state ?? readState(ctx);
|
||||
if (!state || state.status !== "active") {
|
||||
return;
|
||||
}
|
||||
|
||||
const expired = maybeExpireRun(pi, ctx, state);
|
||||
state = expired;
|
||||
if (!state || state.status !== "active") {
|
||||
return;
|
||||
}
|
||||
|
||||
const text = getAssistantText(event.message);
|
||||
const parsed = parseGrindStatus(text);
|
||||
|
||||
if (!parsed) {
|
||||
if (state.consecutiveParseFailures + 1 >= MAX_PARSE_FAILURES) {
|
||||
state = persistState(
|
||||
pi,
|
||||
ctx,
|
||||
withStatus(state, "blocked", {
|
||||
pendingRepair: false,
|
||||
consecutiveParseFailures: state.consecutiveParseFailures + 1,
|
||||
lastCheckpoint: "Blocked: assistant failed to return a valid <grind_status> trailer.",
|
||||
lastNextAction: null,
|
||||
}),
|
||||
);
|
||||
note(ctx, "Grind mode blocked: invalid grind status trailer.");
|
||||
return;
|
||||
}
|
||||
|
||||
state = persistState(pi, ctx, {
|
||||
...state,
|
||||
pendingRepair: true,
|
||||
consecutiveParseFailures: state.consecutiveParseFailures + 1,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
state = persistState(pi, ctx, withLoopStatus(state, parsed));
|
||||
if (state.status !== "active") {
|
||||
note(ctx, `Grind mode ${state.status}.`);
|
||||
if (state.status !== "paused") {
|
||||
clearUiStatus(ctx);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
101
packages/pi-grind/src/parser.ts
Normal file
101
packages/pi-grind/src/parser.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import { parseDeadline } from "./time.js";
|
||||
import {
|
||||
DEFAULT_COMPLETION_CRITERION,
|
||||
type GrindStatusPayload,
|
||||
type ParsedAutoActivation,
|
||||
type ParsedStopCondition,
|
||||
} from "./types.js";
|
||||
|
||||
const GRIND_STATUS_PATTERN = /<grind_status>\s*(\{[\s\S]*?\})\s*<\/grind_status>/i;
|
||||
|
||||
function normalizeText(value: string): string {
|
||||
return value.replace(/\s+/g, " ").trim().toLowerCase();
|
||||
}
|
||||
|
||||
function extractUntilClause(text: string): string | null {
|
||||
const match = text.match(/\buntil\b([\s\S]+)$/i);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const clause = match[1]?.trim();
|
||||
return clause ? clause : null;
|
||||
}
|
||||
|
||||
export function detectCue(text: string, cuePatterns: readonly string[]): string | null {
|
||||
const normalized = normalizeText(text);
|
||||
for (const pattern of cuePatterns) {
|
||||
if (normalized.includes(normalizeText(pattern))) {
|
||||
return pattern;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseStopCondition(text: string, now: Date = new Date()): ParsedStopCondition {
|
||||
const clause = extractUntilClause(text);
|
||||
if (!clause) {
|
||||
return {
|
||||
deadlineAt: null,
|
||||
completionCriterion: DEFAULT_COMPLETION_CRITERION,
|
||||
};
|
||||
}
|
||||
|
||||
const deadline = parseDeadline(clause, now);
|
||||
if (deadline) {
|
||||
return {
|
||||
deadlineAt: deadline.toISOString(),
|
||||
completionCriterion: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
deadlineAt: null,
|
||||
completionCriterion: clause,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseAutoActivation(
|
||||
text: string,
|
||||
cuePatterns: readonly string[],
|
||||
now: Date = new Date(),
|
||||
): ParsedAutoActivation | null {
|
||||
const matchedCue = detectCue(text, cuePatterns);
|
||||
if (!matchedCue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
matchedCue,
|
||||
stopCondition: parseStopCondition(text, now),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseGrindStatus(text: string): GrindStatusPayload | null {
|
||||
const match = text.match(GRIND_STATUS_PATTERN);
|
||||
if (!match?.[1]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(match[1]) as Record<string, unknown>;
|
||||
const state = parsed.state;
|
||||
const summary = parsed.summary;
|
||||
const nextAction = parsed.nextAction;
|
||||
|
||||
if (
|
||||
(state !== "continue" && state !== "done" && state !== "blocked") ||
|
||||
typeof summary !== "string" ||
|
||||
summary.trim().length === 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
summary: summary.trim(),
|
||||
nextAction: typeof nextAction === "string" ? nextAction.trim() : undefined,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
60
packages/pi-grind/src/prompts.ts
Normal file
60
packages/pi-grind/src/prompts.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import type { GrindRunState } from "./types.js";
|
||||
|
||||
function describeStopCondition(state: GrindRunState): string {
|
||||
const parts: string[] = [];
|
||||
if (state.deadlineAt) {
|
||||
parts.push(`Hard deadline: ${state.deadlineAt}.`);
|
||||
}
|
||||
if (state.completionCriterion) {
|
||||
parts.push(`Completion criterion: ${state.completionCriterion}.`);
|
||||
}
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
export function buildSystemPromptAddon(state: GrindRunState): string {
|
||||
const lines = ["Grind mode is active for this session.", `Primary goal: ${state.goal}`];
|
||||
|
||||
const stopCondition = describeStopCondition(state);
|
||||
if (stopCondition) {
|
||||
lines.push(stopCondition);
|
||||
}
|
||||
|
||||
lines.push("Keep working until the task is done, blocked, or the deadline passes.");
|
||||
lines.push(
|
||||
'Every response must end with <grind_status>{"state":"continue|done|blocked","summary":"...","nextAction":"..."}</grind_status>.',
|
||||
);
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function buildContinuationPrompt(state: GrindRunState): string {
|
||||
const parts = ["Continue the active grind run.", `Goal: ${state.goal}`];
|
||||
|
||||
const stopCondition = describeStopCondition(state);
|
||||
if (stopCondition) {
|
||||
parts.push(stopCondition);
|
||||
}
|
||||
if (state.lastCheckpoint) {
|
||||
parts.push(`Last checkpoint: ${state.lastCheckpoint}`);
|
||||
}
|
||||
if (state.lastNextAction) {
|
||||
parts.push(`Planned next action: ${state.lastNextAction}`);
|
||||
}
|
||||
|
||||
parts.push(
|
||||
'End with <grind_status>{"state":"continue|done|blocked","summary":"...","nextAction":"..."}</grind_status>.',
|
||||
);
|
||||
|
||||
return parts.join("\n\n");
|
||||
}
|
||||
|
||||
export function buildRepairPrompt(state: GrindRunState): string {
|
||||
return [
|
||||
"Your previous grind-mode response did not include a valid <grind_status> trailer.",
|
||||
"Do not restart the task from scratch.",
|
||||
state.lastCheckpoint ? `Latest known checkpoint: ${state.lastCheckpoint}` : "",
|
||||
'Reply with a corrected trailer only, or a very short update plus <grind_status>{"state":"continue|done|blocked","summary":"...","nextAction":"..."}</grind_status>.',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
}
|
||||
117
packages/pi-grind/src/state.ts
Normal file
117
packages/pi-grind/src/state.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import type { SessionEntry } from "@mariozechner/pi-coding-agent";
|
||||
import {
|
||||
GRIND_STATE_ENTRY_TYPE,
|
||||
type GrindActivation,
|
||||
type GrindRunState,
|
||||
type GrindRunStatus,
|
||||
type GrindStatusPayload,
|
||||
} from "./types.js";
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object";
|
||||
}
|
||||
|
||||
function isGrindRunStatus(value: unknown): value is GrindRunStatus {
|
||||
return (
|
||||
value === "active" ||
|
||||
value === "paused" ||
|
||||
value === "done" ||
|
||||
value === "blocked" ||
|
||||
value === "expired" ||
|
||||
value === "stopped"
|
||||
);
|
||||
}
|
||||
|
||||
function isGrindActivation(value: unknown): value is GrindActivation {
|
||||
return value === "explicit" || value === "command";
|
||||
}
|
||||
|
||||
function isNullableString(value: unknown): value is string | null {
|
||||
return typeof value === "string" || value === null;
|
||||
}
|
||||
|
||||
export function createRunState(input: {
|
||||
activation: GrindActivation;
|
||||
goal: string;
|
||||
sourcePrompt: string;
|
||||
deadlineAt: string | null;
|
||||
completionCriterion: string | null;
|
||||
}): GrindRunState {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
version: 1,
|
||||
runId: randomUUID(),
|
||||
activation: input.activation,
|
||||
status: "active",
|
||||
goal: input.goal,
|
||||
sourcePrompt: input.sourcePrompt,
|
||||
deadlineAt: input.deadlineAt,
|
||||
completionCriterion: input.completionCriterion,
|
||||
lastCheckpoint: null,
|
||||
lastNextAction: null,
|
||||
pendingRepair: false,
|
||||
consecutiveParseFailures: 0,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
export function isValidRunState(value: unknown): value is GrindRunState {
|
||||
if (!isRecord(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
value.version === 1 &&
|
||||
typeof value.runId === "string" &&
|
||||
isGrindActivation(value.activation) &&
|
||||
isGrindRunStatus(value.status) &&
|
||||
typeof value.goal === "string" &&
|
||||
typeof value.sourcePrompt === "string" &&
|
||||
isNullableString(value.deadlineAt) &&
|
||||
isNullableString(value.completionCriterion) &&
|
||||
isNullableString(value.lastCheckpoint) &&
|
||||
isNullableString(value.lastNextAction) &&
|
||||
typeof value.pendingRepair === "boolean" &&
|
||||
typeof value.consecutiveParseFailures === "number" &&
|
||||
typeof value.updatedAt === "string"
|
||||
);
|
||||
}
|
||||
|
||||
export function getLatestRunState(entries: readonly SessionEntry[]): GrindRunState | null {
|
||||
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
||||
const entry = entries[index];
|
||||
if (entry.type !== "custom" || entry.customType !== GRIND_STATE_ENTRY_TYPE) {
|
||||
continue;
|
||||
}
|
||||
if (isValidRunState(entry.data)) {
|
||||
return entry.data;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function withStatus(
|
||||
state: GrindRunState,
|
||||
status: GrindRunStatus,
|
||||
overrides: Partial<GrindRunState> = {},
|
||||
): GrindRunState {
|
||||
return {
|
||||
...state,
|
||||
...overrides,
|
||||
status,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function withLoopStatus(state: GrindRunState, payload: GrindStatusPayload): GrindRunState {
|
||||
return {
|
||||
...state,
|
||||
status: payload.state === "continue" ? "active" : payload.state === "done" ? "done" : "blocked",
|
||||
lastCheckpoint: payload.summary,
|
||||
lastNextAction: payload.nextAction ?? null,
|
||||
pendingRepair: false,
|
||||
consecutiveParseFailures: 0,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
128
packages/pi-grind/src/time.ts
Normal file
128
packages/pi-grind/src/time.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
function setTimeParts(base: Date, hours: number, minutes: number, seconds = 0): Date {
|
||||
const next = new Date(base);
|
||||
next.setHours(hours, minutes, seconds, 0);
|
||||
return next;
|
||||
}
|
||||
|
||||
function parseClockValue(input: string): { hours: number; minutes: number } | null {
|
||||
const normalized = input
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^at\s+/, "")
|
||||
.replace(/^by\s+/, "");
|
||||
const match = normalized.match(/^(\d{1,2})(?::(\d{2}))?\s*(am|pm)?$/i);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let hours = Number(match[1]);
|
||||
const minutes = match[2] ? Number(match[2]) : 0;
|
||||
const meridiem = match[3]?.toLowerCase();
|
||||
|
||||
if (minutes < 0 || minutes > 59) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (meridiem) {
|
||||
if (hours < 1 || hours > 12) {
|
||||
return null;
|
||||
}
|
||||
if (meridiem === "pm" && hours !== 12) {
|
||||
hours += 12;
|
||||
}
|
||||
if (meridiem === "am" && hours === 12) {
|
||||
hours = 0;
|
||||
}
|
||||
} else if (hours > 23) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { hours, minutes };
|
||||
}
|
||||
|
||||
function stripTrailingContinuation(text: string): string {
|
||||
return text
|
||||
.trim()
|
||||
.replace(/\s+(?:and|then)\b[\s\S]*$/i, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function parseLocalDateOnly(candidate: string): Date | null {
|
||||
const match = candidate.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const year = Number(match[1]);
|
||||
const month = Number(match[2]);
|
||||
const day = Number(match[3]);
|
||||
const local = new Date(year, month - 1, day, 0, 0, 0, 0);
|
||||
if (
|
||||
local.getFullYear() !== year ||
|
||||
local.getMonth() !== month - 1 ||
|
||||
local.getDate() !== day
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return local;
|
||||
}
|
||||
|
||||
export function parseDeadline(raw: string, now: Date = new Date()): Date | null {
|
||||
const candidate = raw.trim();
|
||||
if (!candidate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = candidate
|
||||
.toLowerCase()
|
||||
.replace(/^until\s+/, "")
|
||||
.replace(/^by\s+/, "")
|
||||
.trim();
|
||||
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tomorrowMatch = normalized.match(/^tomorrow\s+(.+)$/);
|
||||
if (tomorrowMatch) {
|
||||
const time = parseClockValue(stripTrailingContinuation(tomorrowMatch[1]));
|
||||
if (!time) {
|
||||
return null;
|
||||
}
|
||||
const base = new Date(now);
|
||||
base.setDate(base.getDate() + 1);
|
||||
return setTimeParts(base, time.hours, time.minutes);
|
||||
}
|
||||
|
||||
const todayMatch = normalized.match(/^today\s+(.+)$/);
|
||||
if (todayMatch) {
|
||||
const time = parseClockValue(stripTrailingContinuation(todayMatch[1]));
|
||||
if (!time) {
|
||||
return null;
|
||||
}
|
||||
return setTimeParts(now, time.hours, time.minutes);
|
||||
}
|
||||
|
||||
const time = parseClockValue(stripTrailingContinuation(normalized));
|
||||
if (!time) {
|
||||
const localDateOnly = parseLocalDateOnly(candidate);
|
||||
if (localDateOnly) {
|
||||
return localDateOnly;
|
||||
}
|
||||
const direct = new Date(candidate);
|
||||
if (!Number.isNaN(direct.getTime())) {
|
||||
return direct;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const sameDay = setTimeParts(now, time.hours, time.minutes);
|
||||
if (sameDay.getTime() > now.getTime()) {
|
||||
return sameDay;
|
||||
}
|
||||
|
||||
const nextDay = new Date(now);
|
||||
nextDay.setDate(nextDay.getDate() + 1);
|
||||
return setTimeParts(nextDay, time.hours, time.minutes);
|
||||
}
|
||||
52
packages/pi-grind/src/types.ts
Normal file
52
packages/pi-grind/src/types.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
export const GRIND_SETTINGS_KEY = "pi-grind";
|
||||
export const GRIND_STATE_ENTRY_TYPE = "pi-grind/state";
|
||||
export const DEFAULT_COMPLETION_CRITERION = "finish the requested task";
|
||||
export const DEFAULT_POLL_INTERVAL_MS = 30_000;
|
||||
export const MAX_PARSE_FAILURES = 2;
|
||||
|
||||
export type GrindActivation = "explicit" | "command";
|
||||
export type GrindCueMode = "explicit-only";
|
||||
export type GrindInterventionMode = "pause";
|
||||
export type GrindRunStatus = "active" | "paused" | "done" | "blocked" | "expired" | "stopped";
|
||||
export type GrindLoopState = "continue" | "done" | "blocked";
|
||||
|
||||
export interface GrindConfig {
|
||||
enabled: boolean;
|
||||
pollIntervalMs: number;
|
||||
cueMode: GrindCueMode;
|
||||
requireDaemon: boolean;
|
||||
userIntervention: GrindInterventionMode;
|
||||
cuePatterns: string[];
|
||||
}
|
||||
|
||||
export interface ParsedStopCondition {
|
||||
deadlineAt: string | null;
|
||||
completionCriterion: string | null;
|
||||
}
|
||||
|
||||
export interface ParsedAutoActivation {
|
||||
matchedCue: string;
|
||||
stopCondition: ParsedStopCondition;
|
||||
}
|
||||
|
||||
export interface GrindStatusPayload {
|
||||
state: GrindLoopState;
|
||||
summary: string;
|
||||
nextAction?: string;
|
||||
}
|
||||
|
||||
export interface GrindRunState {
|
||||
version: 1;
|
||||
runId: string;
|
||||
activation: GrindActivation;
|
||||
status: GrindRunStatus;
|
||||
goal: string;
|
||||
sourcePrompt: string;
|
||||
deadlineAt: string | null;
|
||||
completionCriterion: string | null;
|
||||
lastCheckpoint: string | null;
|
||||
lastNextAction: string | null;
|
||||
pendingRepair: boolean;
|
||||
consecutiveParseFailures: number;
|
||||
updatedAt: string;
|
||||
}
|
||||
51
packages/pi-grind/test/parser.test.ts
Normal file
51
packages/pi-grind/test/parser.test.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { detectCue, parseAutoActivation, parseGrindStatus, parseStopCondition } from "../src/parser.js";
|
||||
|
||||
describe("pi-grind parser", () => {
|
||||
const now = new Date(2026, 2, 9, 9, 0, 0);
|
||||
const cues = ["don't stop", "keep going", "run until"];
|
||||
|
||||
it("detects explicit grind cues", () => {
|
||||
expect(detectCue("Please keep going on this", cues)).toBe("keep going");
|
||||
expect(detectCue("Normal prompt", cues)).toBeNull();
|
||||
});
|
||||
|
||||
it("parses time-based stop conditions", () => {
|
||||
const result = parseStopCondition("keep going until 5pm", now);
|
||||
expect(result.deadlineAt).toBe(new Date(2026, 2, 9, 17, 0, 0).toISOString());
|
||||
expect(result.completionCriterion).toBeNull();
|
||||
});
|
||||
|
||||
it("parses criterion-based stop conditions when no time is found", () => {
|
||||
const result = parseStopCondition("don't stop until the migration is finished", now);
|
||||
expect(result.deadlineAt).toBeNull();
|
||||
expect(result.completionCriterion).toBe("the migration is finished");
|
||||
});
|
||||
|
||||
it("parses full auto activation payloads", () => {
|
||||
const result = parseAutoActivation("run until tomorrow 5:30pm and finish the report", cues, now);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.matchedCue).toBe("run until");
|
||||
expect(result?.stopCondition.deadlineAt).toBe(new Date(2026, 2, 10, 17, 30, 0).toISOString());
|
||||
});
|
||||
|
||||
it("parses bare ISO dates in local time instead of UTC midnight", () => {
|
||||
const result = parseStopCondition("keep going until 2026-03-10", now);
|
||||
|
||||
expect(result.deadlineAt).toBe(new Date(2026, 2, 10, 0, 0, 0).toISOString());
|
||||
expect(result.completionCriterion).toBeNull();
|
||||
});
|
||||
|
||||
it("parses grind status trailers", () => {
|
||||
const payload = parseGrindStatus(
|
||||
'Work done.\n<grind_status>{"state":"continue","summary":"half done","nextAction":"finish tests"}</grind_status>',
|
||||
);
|
||||
|
||||
expect(payload).toEqual({
|
||||
state: "continue",
|
||||
summary: "half done",
|
||||
nextAction: "finish tests",
|
||||
});
|
||||
});
|
||||
});
|
||||
72
packages/pi-grind/test/state.test.ts
Normal file
72
packages/pi-grind/test/state.test.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import type { SessionEntry } from "@mariozechner/pi-coding-agent";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createRunState, getLatestRunState, withLoopStatus, withStatus } from "../src/state.js";
|
||||
import { GRIND_STATE_ENTRY_TYPE } from "../src/types.js";
|
||||
|
||||
describe("pi-grind state", () => {
|
||||
it("creates active run state", () => {
|
||||
const state = createRunState({
|
||||
activation: "explicit",
|
||||
goal: "Ship the refactor",
|
||||
sourcePrompt: "Don't stop until this is done",
|
||||
deadlineAt: null,
|
||||
completionCriterion: "this is done",
|
||||
});
|
||||
|
||||
expect(state.status).toBe("active");
|
||||
expect(state.goal).toBe("Ship the refactor");
|
||||
expect(state.completionCriterion).toBe("this is done");
|
||||
});
|
||||
|
||||
it("hydrates latest persisted state from session entries", () => {
|
||||
const older = createRunState({
|
||||
activation: "command",
|
||||
goal: "older",
|
||||
sourcePrompt: "older",
|
||||
deadlineAt: null,
|
||||
completionCriterion: null,
|
||||
});
|
||||
const newer = withStatus(older, "paused");
|
||||
|
||||
const entries = [
|
||||
{
|
||||
type: "custom",
|
||||
id: "a",
|
||||
parentId: null,
|
||||
timestamp: new Date().toISOString(),
|
||||
customType: GRIND_STATE_ENTRY_TYPE,
|
||||
data: older,
|
||||
},
|
||||
{
|
||||
type: "custom",
|
||||
id: "b",
|
||||
parentId: "a",
|
||||
timestamp: new Date().toISOString(),
|
||||
customType: GRIND_STATE_ENTRY_TYPE,
|
||||
data: newer,
|
||||
},
|
||||
] satisfies SessionEntry[];
|
||||
|
||||
expect(getLatestRunState(entries)?.status).toBe("paused");
|
||||
});
|
||||
|
||||
it("applies loop results", () => {
|
||||
const state = createRunState({
|
||||
activation: "explicit",
|
||||
goal: "Ship it",
|
||||
sourcePrompt: "Ship it",
|
||||
deadlineAt: null,
|
||||
completionCriterion: null,
|
||||
});
|
||||
|
||||
const next = withLoopStatus(state, {
|
||||
state: "done",
|
||||
summary: "finished",
|
||||
nextAction: "none",
|
||||
});
|
||||
|
||||
expect(next.status).toBe("done");
|
||||
expect(next.lastCheckpoint).toBe("finished");
|
||||
expect(next.lastNextAction).toBe("none");
|
||||
});
|
||||
});
|
||||
|
|
@ -5,6 +5,9 @@
|
|||
"paths": {
|
||||
"*": ["./*"],
|
||||
"@mariozechner/pi-ai": ["./packages/ai/src/index.ts"],
|
||||
"@mariozechner/pi-ai/bedrock-provider": [
|
||||
"./packages/ai/src/bedrock-provider.ts"
|
||||
],
|
||||
"@mariozechner/pi-ai/oauth": ["./packages/ai/src/oauth.ts"],
|
||||
"@mariozechner/pi-ai/*": ["./packages/ai/src/*"],
|
||||
"@mariozechner/pi-ai/dist/*": ["./packages/ai/src/*"],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue