mirror of
https://github.com/harivansh-afk/clanker-agent.git
synced 2026-04-15 07:04:45 +00:00
grind mode baby
This commit is contained in:
parent
aa70afbd7e
commit
ff6e39dd10
17 changed files with 1232 additions and 8 deletions
33
package-lock.json
generated
33
package-lock.json
generated
|
|
@ -5819,6 +5819,10 @@
|
||||||
"integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
|
"integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/pi-grind": {
|
||||||
|
"resolved": "packages/pi-grind",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/pi-teams": {
|
"node_modules/pi-teams": {
|
||||||
"resolved": "packages/pi-teams",
|
"resolved": "packages/pi-teams",
|
||||||
"link": true
|
"link": true
|
||||||
|
|
@ -7595,6 +7599,35 @@
|
||||||
"@sinclair/typebox": "*"
|
"@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": {
|
"packages/pi-runtime-daemon": {
|
||||||
"name": "@local/pi-runtime-daemon",
|
"name": "@local/pi-runtime-daemon",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@
|
||||||
process.title = "pi";
|
process.title = "pi";
|
||||||
|
|
||||||
import { setBedrockProviderModule } from "@mariozechner/pi-ai";
|
import { setBedrockProviderModule } from "@mariozechner/pi-ai";
|
||||||
import { bedrockProviderModule } from "@mariozechner/pi-ai/bedrock-provider";
|
|
||||||
import { EnvHttpProxyAgent, setGlobalDispatcher } from "undici";
|
import { EnvHttpProxyAgent, setGlobalDispatcher } from "undici";
|
||||||
|
import { bedrockProviderModule } from "../../ai/src/bedrock-provider.js";
|
||||||
import { main } from "./main.js";
|
import { main } from "./main.js";
|
||||||
|
|
||||||
setGlobalDispatcher(new EnvHttpProxyAgent());
|
setGlobalDispatcher(new EnvHttpProxyAgent());
|
||||||
|
|
|
||||||
|
|
@ -995,12 +995,15 @@ export interface RegisteredCommand {
|
||||||
// Extension API
|
// Extension API
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
type ExtensionHandlerResult<R> = [R] extends [undefined]
|
||||||
|
? Promise<void> | void
|
||||||
|
: Promise<R | undefined> | R | undefined;
|
||||||
|
|
||||||
/** Handler function type for events */
|
/** Handler function type for events */
|
||||||
// biome-ignore lint/suspicious/noConfusingVoidType: void allows bare return statements
|
|
||||||
export type ExtensionHandler<E, R = undefined> = (
|
export type ExtensionHandler<E, R = undefined> = (
|
||||||
event: E,
|
event: E,
|
||||||
ctx: ExtensionContext,
|
ctx: ExtensionContext,
|
||||||
) => Promise<R | void> | R | void;
|
) => ExtensionHandlerResult<R>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ExtensionAPI passed to extension factory functions.
|
* ExtensionAPI passed to extension factory functions.
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,10 @@ import { join } from "node:path";
|
||||||
import { URL } from "node:url";
|
import { URL } from "node:url";
|
||||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||||
import type { AgentSession, AgentSessionEvent } from "../agent-session.js";
|
import type { AgentSession, AgentSessionEvent } from "../agent-session.js";
|
||||||
|
import type { Settings } from "../settings-manager.js";
|
||||||
import { extractMessageText, getLastAssistantText } from "./helpers.js";
|
import { extractMessageText, getLastAssistantText } from "./helpers.js";
|
||||||
import {
|
import {
|
||||||
type GatewayEvent,
|
type GatewayEvent,
|
||||||
type GatewayQueuedMessage,
|
|
||||||
HttpError,
|
HttpError,
|
||||||
type ManagedGatewaySession,
|
type ManagedGatewaySession,
|
||||||
} from "./internal-types.js";
|
} from "./internal-types.js";
|
||||||
|
|
@ -29,7 +29,6 @@ import type {
|
||||||
HistoryPart,
|
HistoryPart,
|
||||||
ModelInfo,
|
ModelInfo,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
import type { Settings } from "../settings-manager.js";
|
|
||||||
import {
|
import {
|
||||||
createVercelStreamListener,
|
createVercelStreamListener,
|
||||||
errorVercelStream,
|
errorVercelStream,
|
||||||
|
|
|
||||||
|
|
@ -836,10 +836,10 @@ export class ToolExecutionComponent extends Container {
|
||||||
.join("\n");
|
.join("\n");
|
||||||
if (remaining > 0) {
|
if (remaining > 0) {
|
||||||
text +=
|
text +=
|
||||||
theme.fg(
|
`${theme.fg(
|
||||||
"muted",
|
"muted",
|
||||||
`\n... (${remaining} more lines, ${totalLines} total,`,
|
`\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 {
|
import {
|
||||||
createVercelStreamListener,
|
createVercelStreamListener,
|
||||||
extractUserText,
|
extractUserText,
|
||||||
} from "../src/core/vercel-ai-stream.js";
|
} from "../src/core/gateway/vercel-ai-stream.js";
|
||||||
|
|
||||||
describe("extractUserText", () => {
|
describe("extractUserText", () => {
|
||||||
it("extracts text from useChat v5+ format with parts", () => {
|
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": "OpenAI",
|
||||||
|
"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");
|
||||||
|
}
|
||||||
132
packages/pi-grind/src/state.ts
Normal file
132
packages/pi-grind/src/state.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
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,
|
||||||
|
consecutiveControllerFailures: 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.consecutiveControllerFailures === "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,
|
||||||
|
consecutiveControllerFailures: 0,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function withControllerFailure(state: GrindRunState, note: string): GrindRunState {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
status: "blocked",
|
||||||
|
lastCheckpoint: note,
|
||||||
|
lastNextAction: null,
|
||||||
|
pendingRepair: false,
|
||||||
|
consecutiveControllerFailures: state.consecutiveControllerFailures + 1,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
103
packages/pi-grind/src/time.ts
Normal file
103
packages/pi-grind/src/time.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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);
|
||||||
|
}
|
||||||
53
packages/pi-grind/src/types.ts
Normal file
53
packages/pi-grind/src/types.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
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 | null;
|
||||||
|
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;
|
||||||
|
consecutiveControllerFailures: number;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
44
packages/pi-grind/test/parser.test.ts
Normal file
44
packages/pi-grind/test/parser.test.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
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 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue