@fiale-plus/pi-rogue 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/node_modules/@fiale-plus/pi-rogue-advisor/src/extension.ts +75 -31
- package/node_modules/@fiale-plus/pi-rogue-advisor/src/loop-convergence.test.ts +2 -2
- package/node_modules/@fiale-plus/pi-rogue-advisor/src/state-versioning.test.ts +25 -4
- package/node_modules/@fiale-plus/pi-rogue-context-broker/README.md +4 -3
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/extension.test.ts +38 -4
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/extension.ts +52 -6
- package/node_modules/@fiale-plus/pi-rogue-orchestration/src/advisor-checkins.test.ts +10 -0
- package/node_modules/@fiale-plus/pi-rogue-orchestration/src/advisor-checkins.ts +17 -2
- package/node_modules/@fiale-plus/pi-rogue-orchestration/src/goal.ts +2 -2
- package/node_modules/@fiale-plus/pi-rogue-orchestration/src/internal.ts +11 -2
- package/node_modules/@fiale-plus/pi-rogue-router/README.md +32 -0
- package/node_modules/@fiale-plus/pi-rogue-router/package.json +30 -0
- package/node_modules/@fiale-plus/pi-rogue-router/src/checkpoints.test.ts +84 -0
- package/node_modules/@fiale-plus/pi-rogue-router/src/checkpoints.ts +355 -0
- package/node_modules/@fiale-plus/pi-rogue-router/src/cli.ts +277 -0
- package/node_modules/@fiale-plus/pi-rogue-router/src/completions.ts +34 -0
- package/node_modules/@fiale-plus/pi-rogue-router/src/config-extension.test.ts +133 -0
- package/node_modules/@fiale-plus/pi-rogue-router/src/config.ts +168 -0
- package/node_modules/@fiale-plus/pi-rogue-router/src/dataset.ts +154 -0
- package/node_modules/@fiale-plus/pi-rogue-router/src/decision-ledger.test.ts +148 -0
- package/node_modules/@fiale-plus/pi-rogue-router/src/decision.ts +138 -0
- package/node_modules/@fiale-plus/pi-rogue-router/src/extension.ts +139 -0
- package/node_modules/@fiale-plus/pi-rogue-router/src/git-features.ts +119 -0
- package/node_modules/@fiale-plus/pi-rogue-router/src/hash.ts +19 -0
- package/node_modules/@fiale-plus/pi-rogue-router/src/index.ts +15 -0
- package/node_modules/@fiale-plus/pi-rogue-router/src/learning.test.ts +241 -0
- package/node_modules/@fiale-plus/pi-rogue-router/src/learning.ts +382 -0
- package/node_modules/@fiale-plus/pi-rogue-router/src/ledger.ts +94 -0
- package/node_modules/@fiale-plus/pi-rogue-router/src/observe.ts +119 -0
- package/node_modules/@fiale-plus/pi-rogue-router/src/outcomes.ts +128 -0
- package/node_modules/@fiale-plus/pi-rogue-router/src/progress.ts +93 -0
- package/node_modules/@fiale-plus/pi-rogue-router/src/session-reader.ts +217 -0
- package/node_modules/@fiale-plus/pi-rogue-router/src/subagents.ts +178 -0
- package/node_modules/@fiale-plus/pi-rogue-router/src/types.ts +150 -0
- package/node_modules/@fiale-plus/pi-rogue-router/src/v1-telemetry.test.ts +293 -0
- package/package.json +5 -3
- package/src/extension.test.ts +1 -0
- package/src/extension.ts +2 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
|
+
import { sessionKey } from "./internal.js";
|
|
4
5
|
|
|
5
6
|
type AdvisorConfig = Record<string, unknown> & { checkins?: "mid-hour" | "off"; checkinStartedAt?: number };
|
|
6
7
|
type AdvisorState = Record<string, unknown> & {
|
|
@@ -34,6 +35,15 @@ const ADVISOR_DIR = join(homedir(), ".pi", "agent", "pi-rogue", "advisor");
|
|
|
34
35
|
const ADVISOR_CONFIG_PATH = join(homedir(), ".pi", "agent", "pi-rogue", "advisor", "config.json");
|
|
35
36
|
const ADVISOR_STATE_PATH = join(ADVISOR_DIR, "state.json");
|
|
36
37
|
|
|
38
|
+
function safeSessionKey(key: string): string {
|
|
39
|
+
const safe = String(key || "session").replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
40
|
+
return safe || "session";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function advisorSessionStatePath(ctx: any): string {
|
|
44
|
+
return join(ADVISOR_DIR, "sessions", safeSessionKey(sessionKey(ctx)), "state.json");
|
|
45
|
+
}
|
|
46
|
+
|
|
37
47
|
function readJson<T>(file: string): T {
|
|
38
48
|
if (!existsSync(file)) return {} as T;
|
|
39
49
|
try {
|
|
@@ -63,9 +73,14 @@ export function setAdvisorCheckinsEnabled(enabled: boolean, configPath = ADVISOR
|
|
|
63
73
|
}
|
|
64
74
|
|
|
65
75
|
export function resetAdvisorSessionContext(
|
|
66
|
-
|
|
67
|
-
|
|
76
|
+
ctxOrConfigPath?: any,
|
|
77
|
+
configPathOrStatePath = ADVISOR_STATE_PATH,
|
|
78
|
+
explicitStatePath?: string,
|
|
68
79
|
): { config: AdvisorConfig; state: AdvisorState } {
|
|
80
|
+
const configPath = typeof ctxOrConfigPath === "string" ? ctxOrConfigPath : ADVISOR_CONFIG_PATH;
|
|
81
|
+
const statePath = typeof ctxOrConfigPath === "string"
|
|
82
|
+
? configPathOrStatePath
|
|
83
|
+
: explicitStatePath || (ctxOrConfigPath ? advisorSessionStatePath(ctxOrConfigPath) : ADVISOR_STATE_PATH);
|
|
69
84
|
const currentConfig = cleanAdvisorConfig(readJson<AdvisorConfig>(configPath));
|
|
70
85
|
const nextConfig: AdvisorConfig = {
|
|
71
86
|
...currentConfig,
|
|
@@ -54,7 +54,7 @@ export function setGoal(ctx: any, goal: string, options: { restartDuplicate?: bo
|
|
|
54
54
|
}
|
|
55
55
|
clearLoop(ctx, { clearResearch: true, preserveCheckins: true });
|
|
56
56
|
writeText(sessionFile(FEATURE, ctx, CURRENT_FILE), note ? `${note}\n` : "");
|
|
57
|
-
resetAdvisorSessionContext();
|
|
57
|
+
resetAdvisorSessionContext(ctx);
|
|
58
58
|
if (note) {
|
|
59
59
|
setAdvisorCheckinsEnabled(true);
|
|
60
60
|
}
|
|
@@ -68,7 +68,7 @@ export function setGoal(ctx: any, goal: string, options: { restartDuplicate?: bo
|
|
|
68
68
|
|
|
69
69
|
export function clearGoal(ctx: any): void {
|
|
70
70
|
writeText(sessionFile(FEATURE, ctx, CURRENT_FILE), "");
|
|
71
|
-
resetAdvisorSessionContext();
|
|
71
|
+
resetAdvisorSessionContext(ctx);
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
function goalBlock(goal: string): string {
|
|
@@ -77,10 +77,19 @@ export function featureDir(feature: string): string {
|
|
|
77
77
|
return dir;
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
function safeSessionKey(key: string): string {
|
|
81
|
+
const safe = String(key || "session").replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
82
|
+
return safe || "session";
|
|
83
|
+
}
|
|
84
|
+
|
|
80
85
|
export function sessionKey(ctx: any): string {
|
|
81
86
|
const sessionFile = ctx?.sessionManager?.getSessionFile?.();
|
|
82
|
-
if (
|
|
83
|
-
|
|
87
|
+
if (typeof sessionFile === "string" && sessionFile.length > 0) {
|
|
88
|
+
return safeSessionKey(basename(String(sessionFile)).replace(/\.[^.]+$/, ""));
|
|
89
|
+
}
|
|
90
|
+
const sessionId = ctx?.session?.id || process.env.PI_ROGUE_SESSION_ID;
|
|
91
|
+
if (typeof sessionId === "string" && sessionId.length > 0) return safeSessionKey(sessionId);
|
|
92
|
+
return "session";
|
|
84
93
|
}
|
|
85
94
|
|
|
86
95
|
export function sessionDir(feature: string, ctx: any): string {
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Pi-Rogue Router
|
|
2
|
+
|
|
3
|
+
Local-only offline trajectory router experiments for Pi-Rogue.
|
|
4
|
+
|
|
5
|
+
This package intentionally does **not** change live advisor or orchestration behavior. It reads existing Pi session JSONL files, derives compact checkpoints, and computes cheap progress/loop signals without copying raw transcript content into derived artifacts.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm run router:rebuild -- --session ~/.pi/agent/sessions/.../session.jsonl --output .pi/router/checkpoints.jsonl
|
|
9
|
+
npm run router:rebuild -- --session-dir ~/.pi/agent/sessions/... --output .pi/router/checkpoints.jsonl
|
|
10
|
+
npm run router:rebuild -- --session ./current-session.jsonl --workspace-diff --output .pi/router/checkpoints-with-live-diff.jsonl
|
|
11
|
+
npm run router:decide -- --checkpoint-file .pi/router/checkpoints.jsonl --ledger .pi/router/events.jsonl
|
|
12
|
+
npm run router:cards -- --events .pi/router/events.jsonl --output .pi/router/model-cards.jsonl
|
|
13
|
+
npm run router:outcomes -- --checkpoint-file .pi/router/checkpoints.jsonl --events .pi/router/events.jsonl --output .pi/router/outcomes.jsonl
|
|
14
|
+
npm run router:teacher-requests -- --checkpoint-file .pi/router/checkpoints.jsonl --output .pi/router/teacher-requests.jsonl --teacher openai-codex/gpt-5.5
|
|
15
|
+
npm run router:reflect -- --checkpoint-file .pi/router/checkpoints.jsonl --labels .pi/router/labels/teacher-labels.jsonl --reflection .pi/router/reflections/session.md --teacher local-rule
|
|
16
|
+
npm run router:dataset -- --checkpoint-file .pi/router/checkpoints.jsonl --events .pi/router/events.jsonl --outcomes .pi/router/outcomes.jsonl --labels .pi/router/labels/teacher-labels.jsonl --output .pi/router/training.jsonl
|
|
17
|
+
npm run router:shadow -- --checkpoint-file .pi/router/checkpoints.jsonl --ledger .pi/router/events.jsonl --output .pi/router/shadow-report.json
|
|
18
|
+
|
|
19
|
+
# Live observe-only extension commands:
|
|
20
|
+
# /router on|off|status|profile|profiles|models|configure|cycle
|
|
21
|
+
# ctrl+alt+p cycles router profiles (Ctrl-P is reserved by Pi model cycling).
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## V1 telemetry notes
|
|
25
|
+
|
|
26
|
+
Router v1 is still observe-only. It adds outcome skeletons, stronger diff/error fingerprints, teacher-label request export, binary gate dataset export, and subagent-aware telemetry schemas. It does not switch models, spawn agents, or promote policies automatically.
|
|
27
|
+
|
|
28
|
+
- Diff telemetry stores counts and hashes from `git diff`, not raw patches. Offline rebuilds remain deterministic by default; use `--workspace-diff` only with one current live session/worktree snapshot.
|
|
29
|
+
- Error fingerprints normalize paths, line numbers, timestamps, UUIDs, ports, and object ids before hashing.
|
|
30
|
+
- `router:teacher-requests` writes local JSONL requests for an explicit teacher model; imported teacher decisions are still required before labels become training truth.
|
|
31
|
+
- `router:dataset` excludes `local-rule` labels by default so a future model does not merely imitate the current rules.
|
|
32
|
+
- Subagent route/ledger schemas describe parent-child evidence flow, but live autonomous spawning remains out of scope.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@fiale-plus/pi-rogue-router",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Local-only offline trajectory router experiments for Pi-Rogue.",
|
|
5
|
+
"private": true,
|
|
6
|
+
"type": "module",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"check": "tsc -p ../../tsconfig.json --noEmit",
|
|
10
|
+
"test": "cd ../.. && vitest run packages/router/src/*.test.ts"
|
|
11
|
+
},
|
|
12
|
+
"main": "./src/index.ts",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": "./src/index.ts",
|
|
15
|
+
"./extension": "./src/extension.ts"
|
|
16
|
+
},
|
|
17
|
+
"pi": {
|
|
18
|
+
"extensions": [
|
|
19
|
+
"./src/extension.ts"
|
|
20
|
+
]
|
|
21
|
+
},
|
|
22
|
+
"peerDependencies": {
|
|
23
|
+
"@earendil-works/pi-coding-agent": "^0.74.0"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"src",
|
|
27
|
+
"README.md",
|
|
28
|
+
"package.json"
|
|
29
|
+
]
|
|
30
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import { buildCheckpoints, rebuildCheckpointsFromSession, streamCheckpointsFromSessionPath, writeCheckpointsJsonl } from "./checkpoints.js";
|
|
6
|
+
import { readPiSession } from "./session-reader.js";
|
|
7
|
+
|
|
8
|
+
function writeFixture(lines: Array<Record<string, unknown>>): string {
|
|
9
|
+
const dir = mkdtempSync(join(tmpdir(), "pi-router-"));
|
|
10
|
+
const path = join(dir, "2026-06-12T00-00-00Z_fixture.jsonl");
|
|
11
|
+
writeFileSync(path, lines.map((line) => JSON.stringify(line)).join("\n") + "\n");
|
|
12
|
+
return path;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function fixtureSession(): string {
|
|
16
|
+
return writeFixture([
|
|
17
|
+
{ type: "session", version: 1, id: "session-1", timestamp: "2026-06-12T00:00:00.000Z", cwd: "/repo/example" },
|
|
18
|
+
{ type: "model_change", id: "m1", timestamp: "2026-06-12T00:00:01.000Z", provider: "local", modelId: "qwen-local" },
|
|
19
|
+
{ type: "message", id: "u1", timestamp: "2026-06-12T00:00:02.000Z", message: { role: "user", content: [{ type: "text", text: "please fix the failing tests" }] } },
|
|
20
|
+
{ type: "message", id: "a1", timestamp: "2026-06-12T00:00:03.000Z", message: { role: "assistant", provider: "local", model: "qwen-local", usage: { inputTokens: 1234 }, content: [{ type: "toolCall", id: "call-1", name: "bash", arguments: { command: "npm test -- --runInBand src/foo.test.ts" } }] } },
|
|
21
|
+
{ type: "message", id: "t1", timestamp: "2026-06-12T00:00:04.000Z", message: { role: "toolResult", toolCallId: "call-1", toolName: "bash", isError: true, content: [{ type: "text", text: "FAIL src/foo.test.ts\nError: boom" }] } },
|
|
22
|
+
{ type: "message", id: "a2", timestamp: "2026-06-12T00:00:05.000Z", message: { role: "assistant", provider: "local", model: "qwen-local", content: [{ type: "toolCall", id: "call-2", name: "bash", arguments: { command: "npm test -- --runInBand src/foo.test.ts" } }] } },
|
|
23
|
+
{ type: "message", id: "t2", timestamp: "2026-06-12T00:00:06.000Z", message: { role: "toolResult", toolCallId: "call-2", toolName: "bash", isError: true, content: [{ type: "text", text: "FAIL src/foo.test.ts\nError: boom" }] } },
|
|
24
|
+
]);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("trajectory router checkpoint rebuild", () => {
|
|
28
|
+
it("reads Pi session JSONL and extracts command/tool metadata", () => {
|
|
29
|
+
const session = readPiSession(fixtureSession());
|
|
30
|
+
|
|
31
|
+
expect(session.id).toBe("2026-06-12T00-00-00Z_fixture");
|
|
32
|
+
expect(session.cwd).toBe("/repo/example");
|
|
33
|
+
expect(session.events).toHaveLength(7);
|
|
34
|
+
expect(session.events[3].commandEvents[0]).toMatchObject({ toolName: "bash", isVerifier: true });
|
|
35
|
+
expect(session.events[4].toolResult).toMatchObject({ toolName: "bash", isError: true });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("builds compact derived checkpoints without raw transcript content", () => {
|
|
39
|
+
const checkpoints = rebuildCheckpointsFromSession(fixtureSession());
|
|
40
|
+
const last = checkpoints.at(-1);
|
|
41
|
+
|
|
42
|
+
expect(last?.schema).toBe("pi-router.checkpoint.v1");
|
|
43
|
+
expect(last?.rawSessionRef).toMatchObject({ schema: "pi-router.raw-session-ref.v1", fromEvent: 0, toEvent: 6 });
|
|
44
|
+
expect(last?.activeModel).toBe("qwen-local");
|
|
45
|
+
expect(last?.provider).toBe("local");
|
|
46
|
+
expect(last?.phase).toBe("debug");
|
|
47
|
+
expect(last?.features.contextTokensApprox).toBe(1234);
|
|
48
|
+
expect(last?.features.sameCommandRepeatedCount).toBe(2);
|
|
49
|
+
expect(last?.features.sameErrorRepeatedCount).toBe(2);
|
|
50
|
+
expect(last?.features.verifierUsed).toBe(true);
|
|
51
|
+
expect(last?.features.loopScore).toBeGreaterThan(0);
|
|
52
|
+
expect(last?.recent.lastCommandHash).toBeTruthy();
|
|
53
|
+
expect(last?.recent.lastErrorHash).toBeTruthy();
|
|
54
|
+
expect(last?.recent.touchedFileHashes).toHaveLength(1);
|
|
55
|
+
|
|
56
|
+
const serialized = JSON.stringify(last);
|
|
57
|
+
expect(serialized).not.toContain("please fix the failing tests");
|
|
58
|
+
expect(serialized).not.toContain("npm test");
|
|
59
|
+
expect(serialized).not.toContain("Error: boom");
|
|
60
|
+
expect(serialized).not.toContain("src/foo.test.ts");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("streams checkpoints equivalent to the sync fixture API", async () => {
|
|
64
|
+
const path = fixtureSession();
|
|
65
|
+
const sync = rebuildCheckpointsFromSession(path).map((checkpoint) => checkpoint.checkpointId);
|
|
66
|
+
const streamed: string[] = [];
|
|
67
|
+
|
|
68
|
+
for await (const checkpoint of streamCheckpointsFromSessionPath(path)) streamed.push(checkpoint.checkpointId);
|
|
69
|
+
|
|
70
|
+
expect(streamed).toEqual(sync);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("writes checkpoints as JSONL", () => {
|
|
74
|
+
const session = readPiSession(fixtureSession());
|
|
75
|
+
const checkpoints = buildCheckpoints(session);
|
|
76
|
+
const output = join(mkdtempSync(join(tmpdir(), "pi-router-out-")), "checkpoints.jsonl");
|
|
77
|
+
|
|
78
|
+
writeCheckpointsJsonl(checkpoints, output);
|
|
79
|
+
|
|
80
|
+
const lines = readFileSync(output, "utf8").trim().split("\n");
|
|
81
|
+
expect(lines).toHaveLength(checkpoints.length);
|
|
82
|
+
expect(JSON.parse(lines.at(-1) || "{}").checkpointId).toBe(checkpoints.at(-1)?.checkpointId);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import { closeSync, mkdirSync, openSync, writeSync } from "node:fs";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { hashMaybe, hashText, normalizeText } from "./hash.js";
|
|
4
|
+
import { diffChurnScore, EMPTY_DIFF_STATS, readGitDiffStats } from "./git-features.js";
|
|
5
|
+
import { touchedFileHashesFromEvent } from "./progress.js";
|
|
6
|
+
import { readPiSession, sessionIdFromPath, streamPiSessionEvents, type PiSession, type RawPiSessionEvent } from "./session-reader.js";
|
|
7
|
+
import { RAW_SESSION_REF_SCHEMA, ROUTER_CHECKPOINT_SCHEMA, type ProgressSignals, type RawSessionRef, type RouterCheckpoint, type SessionCommandEvent, type SessionToolResultEvent } from "./types.js";
|
|
8
|
+
|
|
9
|
+
function textFromEvent(event: RawPiSessionEvent): string {
|
|
10
|
+
const message = event.raw.message;
|
|
11
|
+
if (!message || typeof message !== "object") return "";
|
|
12
|
+
const content = (message as { content?: unknown }).content;
|
|
13
|
+
if (!Array.isArray(content)) return "";
|
|
14
|
+
return content.flatMap((item) => {
|
|
15
|
+
if (!item || typeof item !== "object") return [];
|
|
16
|
+
const text = (item as Record<string, unknown>).text;
|
|
17
|
+
return typeof text === "string" ? [text] : [];
|
|
18
|
+
}).join("\n");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function phaseFromText(text: string): RouterCheckpoint["phase"] {
|
|
22
|
+
const normalized = normalizeText(text);
|
|
23
|
+
if (/\b(debug|bug|error|fail(?:ed|ing|ure)?|broken|crash|traceback|stack)\b/.test(normalized)) return "debug";
|
|
24
|
+
if (/\b(review|diff|pr|pull request|audit|looks good)\b/.test(normalized)) return "review";
|
|
25
|
+
if (/\b(research|docs?|look up|what is|compare|benchmark)\b/.test(normalized)) return "research";
|
|
26
|
+
if (/\b(install|config|configure|status|logs?|deploy|environment|shell)\b/.test(normalized)) return "ops";
|
|
27
|
+
if (/\b(plan|design|architecture|strategy|scope)\b/.test(normalized)) return "planning";
|
|
28
|
+
if (/\b(implement|build|add|edit|refactor|fix|write|change)\b/.test(normalized)) return "implementation";
|
|
29
|
+
return "unknown";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function contextTokensFromUsage(usage: Record<string, unknown> | undefined): number | null {
|
|
33
|
+
if (!usage) return null;
|
|
34
|
+
const candidates = ["inputTokens", "input_tokens", "promptTokens", "prompt_tokens", "totalTokens", "total_tokens"];
|
|
35
|
+
for (const key of candidates) {
|
|
36
|
+
const value = usage[key];
|
|
37
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface SessionContext {
|
|
43
|
+
id: string;
|
|
44
|
+
path: string;
|
|
45
|
+
cwd?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function rawSessionRef(session: SessionContext, refEvents: RawPiSessionEvent[], last: RawPiSessionEvent | undefined): RawSessionRef {
|
|
49
|
+
const first = refEvents[0];
|
|
50
|
+
const fromByte = first?.byteStart ?? 0;
|
|
51
|
+
const toByte = last?.byteEnd ?? 0;
|
|
52
|
+
return {
|
|
53
|
+
schema: RAW_SESSION_REF_SCHEMA,
|
|
54
|
+
path: session.path,
|
|
55
|
+
fromEvent: first?.index ?? 0,
|
|
56
|
+
toEvent: last?.index ?? 0,
|
|
57
|
+
fromByte,
|
|
58
|
+
toByte,
|
|
59
|
+
contentHash: hashText(...refEvents.map((event) => event.rawLineHash)),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function repoHashFromCwd(cwd?: string): string | undefined {
|
|
64
|
+
return cwd ? hashText(resolve(cwd)) : undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function clamp01(value: number): number {
|
|
68
|
+
return Math.max(0, Math.min(1, value));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const RAW_REF_EVENT_WINDOW = 30;
|
|
72
|
+
|
|
73
|
+
interface BuildState {
|
|
74
|
+
activeModel?: string;
|
|
75
|
+
provider?: string;
|
|
76
|
+
contextTokensApprox: number | null;
|
|
77
|
+
lastUserGoalHash?: string;
|
|
78
|
+
phase: RouterCheckpoint["phase"];
|
|
79
|
+
lastCommandHash?: string;
|
|
80
|
+
sameCommandRepeatedCount: number;
|
|
81
|
+
lastErrorHash?: string;
|
|
82
|
+
previousErrorHash?: string;
|
|
83
|
+
lastErrorFingerprintHash?: string;
|
|
84
|
+
previousErrorFingerprintHash?: string;
|
|
85
|
+
sameErrorRepeatedCount: number;
|
|
86
|
+
verifierUsed: boolean;
|
|
87
|
+
commandCount: number;
|
|
88
|
+
recentCommands: string[];
|
|
89
|
+
touchedFileHashes: Set<string>;
|
|
90
|
+
diffStats: import("./types.js").DiffStats;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function updateCommandState(state: BuildState, command: SessionCommandEvent): void {
|
|
94
|
+
if (command.normalizedCommandHash) {
|
|
95
|
+
if (state.lastCommandHash === command.normalizedCommandHash) state.sameCommandRepeatedCount++;
|
|
96
|
+
else state.sameCommandRepeatedCount = 1;
|
|
97
|
+
state.lastCommandHash = command.normalizedCommandHash;
|
|
98
|
+
state.recentCommands.push(command.normalizedCommandHash);
|
|
99
|
+
state.recentCommands = state.recentCommands.slice(-10);
|
|
100
|
+
}
|
|
101
|
+
state.commandCount++;
|
|
102
|
+
state.verifierUsed = state.verifierUsed || command.isVerifier;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function updateToolResultState(state: BuildState, result: SessionToolResultEvent): void {
|
|
106
|
+
const errorKey = result.errorFingerprintHash ?? result.errorHash;
|
|
107
|
+
if (!errorKey) return;
|
|
108
|
+
state.previousErrorHash = state.lastErrorHash;
|
|
109
|
+
state.previousErrorFingerprintHash = state.lastErrorFingerprintHash;
|
|
110
|
+
if ((state.lastErrorFingerprintHash ?? state.lastErrorHash) === errorKey) state.sameErrorRepeatedCount++;
|
|
111
|
+
else state.sameErrorRepeatedCount = 1;
|
|
112
|
+
state.lastErrorHash = result.errorHash;
|
|
113
|
+
state.lastErrorFingerprintHash = result.errorFingerprintHash;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function signalsFromState(state: BuildState): ProgressSignals {
|
|
117
|
+
const uniqueRecentCommands = new Set(state.recentCommands);
|
|
118
|
+
const commandRepeatPressure = clamp01((state.sameCommandRepeatedCount - 1) / 3);
|
|
119
|
+
const errorRepeatPressure = clamp01((state.sameErrorRepeatedCount - 1) / 3);
|
|
120
|
+
const toolThrashScore = state.recentCommands.length === 0 ? 0 : clamp01(1 - uniqueRecentCommands.size / state.recentCommands.length);
|
|
121
|
+
const changedFiles = state.touchedFileHashes.size + state.diffStats.filesChanged;
|
|
122
|
+
const phaseWantsVerifier = state.phase === "implementation" || state.phase === "debug" || state.phase === "review";
|
|
123
|
+
const noVerifierUsed = phaseWantsVerifier && changedFiles > 0 && state.commandCount >= 4 && !state.verifierUsed;
|
|
124
|
+
const noVerifierPressure = noVerifierUsed ? 0.2 : 0;
|
|
125
|
+
const loopScore = clamp01(commandRepeatPressure * 0.35 + errorRepeatPressure * 0.4 + toolThrashScore * 0.2 + noVerifierPressure);
|
|
126
|
+
const progressScore = clamp01(1 - loopScore - (noVerifierUsed ? 0.1 : 0));
|
|
127
|
+
return {
|
|
128
|
+
sameCommandRepeatedCount: state.sameCommandRepeatedCount,
|
|
129
|
+
sameErrorRepeatedCount: state.sameErrorRepeatedCount,
|
|
130
|
+
errorChanged: Boolean(
|
|
131
|
+
(state.lastErrorFingerprintHash ?? state.lastErrorHash)
|
|
132
|
+
&& (state.previousErrorFingerprintHash ?? state.previousErrorHash)
|
|
133
|
+
&& (state.lastErrorFingerprintHash ?? state.lastErrorHash) !== (state.previousErrorFingerprintHash ?? state.previousErrorHash),
|
|
134
|
+
),
|
|
135
|
+
testsImproved: null,
|
|
136
|
+
filesTouched: state.touchedFileHashes.size,
|
|
137
|
+
diffLines: state.diffStats.totalLines,
|
|
138
|
+
diffFilesChanged: state.diffStats.filesChanged,
|
|
139
|
+
diffLinesAdded: state.diffStats.linesAdded,
|
|
140
|
+
diffLinesDeleted: state.diffStats.linesDeleted,
|
|
141
|
+
diffChurnScore: diffChurnScore(state.diffStats),
|
|
142
|
+
toolThrashScore,
|
|
143
|
+
goalDriftScore: 0,
|
|
144
|
+
loopScore,
|
|
145
|
+
progressScore,
|
|
146
|
+
verifierUsed: state.verifierUsed,
|
|
147
|
+
noVerifierUsed,
|
|
148
|
+
toolCallsLast10Turns: state.recentCommands.length,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function checkpointFromState(session: SessionContext, event: RawPiSessionEvent, refEvents: RawPiSessionEvent[], state: BuildState): RouterCheckpoint {
|
|
153
|
+
const signals = signalsFromState(state);
|
|
154
|
+
return {
|
|
155
|
+
schema: ROUTER_CHECKPOINT_SCHEMA,
|
|
156
|
+
sessionId: session.id,
|
|
157
|
+
checkpointId: `${session.id}:event-${event.index}`,
|
|
158
|
+
createdAt: new Date().toISOString(),
|
|
159
|
+
rawSessionRef: rawSessionRef(session, refEvents, event),
|
|
160
|
+
harness: "pi",
|
|
161
|
+
repoHash: repoHashFromCwd(session.cwd),
|
|
162
|
+
goalHash: state.lastUserGoalHash,
|
|
163
|
+
phase: state.phase,
|
|
164
|
+
activeModel: state.activeModel,
|
|
165
|
+
provider: state.provider,
|
|
166
|
+
features: {
|
|
167
|
+
...signals,
|
|
168
|
+
turnIndex: event.index,
|
|
169
|
+
contextTokensApprox: state.contextTokensApprox,
|
|
170
|
+
gitDirty: null,
|
|
171
|
+
},
|
|
172
|
+
recent: {
|
|
173
|
+
lastUserGoalHash: state.lastUserGoalHash,
|
|
174
|
+
lastCommandHash: state.lastCommandHash,
|
|
175
|
+
lastErrorHash: state.lastErrorHash,
|
|
176
|
+
lastErrorFingerprintHash: state.lastErrorFingerprintHash,
|
|
177
|
+
touchedFileHashes: [...state.touchedFileHashes].sort(),
|
|
178
|
+
diffFileHashes: state.diffStats.fileHashes,
|
|
179
|
+
},
|
|
180
|
+
sourceEvent: event.pointer,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function initialBuildState(): BuildState {
|
|
185
|
+
return {
|
|
186
|
+
contextTokensApprox: null,
|
|
187
|
+
phase: "unknown",
|
|
188
|
+
sameCommandRepeatedCount: 0,
|
|
189
|
+
sameErrorRepeatedCount: 0,
|
|
190
|
+
verifierUsed: false,
|
|
191
|
+
commandCount: 0,
|
|
192
|
+
recentCommands: [],
|
|
193
|
+
touchedFileHashes: new Set(),
|
|
194
|
+
diffStats: EMPTY_DIFF_STATS,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function updateStateFromEvent(state: BuildState, event: RawPiSessionEvent): void {
|
|
199
|
+
state.activeModel = event.model ?? state.activeModel;
|
|
200
|
+
state.provider = event.provider ?? state.provider;
|
|
201
|
+
state.contextTokensApprox = contextTokensFromUsage(event.usage) ?? state.contextTokensApprox;
|
|
202
|
+
|
|
203
|
+
if (event.role === "user") {
|
|
204
|
+
const text = textFromEvent(event);
|
|
205
|
+
state.lastUserGoalHash = hashMaybe(text);
|
|
206
|
+
state.phase = phaseFromText(text);
|
|
207
|
+
}
|
|
208
|
+
for (const fileHash of touchedFileHashesFromEvent(event)) state.touchedFileHashes.add(fileHash);
|
|
209
|
+
for (const command of event.commandEvents) updateCommandState(state, command);
|
|
210
|
+
if (event.toolResult) updateToolResultState(state, event.toolResult);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function isCheckpointEvent(event: RawPiSessionEvent): boolean {
|
|
214
|
+
return event.role === "user" || event.role === "assistant" || event.role === "toolResult";
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function pushRefWindow(refEvents: RawPiSessionEvent[], event: RawPiSessionEvent): void {
|
|
218
|
+
refEvents.push(event);
|
|
219
|
+
if (refEvents.length > RAW_REF_EVENT_WINDOW) refEvents.shift();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function* iterateCheckpoints(session: PiSession): Generator<RouterCheckpoint> {
|
|
223
|
+
const state = initialBuildState();
|
|
224
|
+
const refEvents: RawPiSessionEvent[] = [];
|
|
225
|
+
for (const event of session.events) {
|
|
226
|
+
pushRefWindow(refEvents, event);
|
|
227
|
+
updateStateFromEvent(state, event);
|
|
228
|
+
if (!isCheckpointEvent(event)) continue;
|
|
229
|
+
yield checkpointFromState(session, event, refEvents, state);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export async function* streamCheckpointsFromSessionPath(sessionPath: string): AsyncGenerator<RouterCheckpoint> {
|
|
234
|
+
const session: SessionContext = { id: sessionIdFromPath(resolve(sessionPath)), path: resolve(sessionPath) };
|
|
235
|
+
const state = initialBuildState();
|
|
236
|
+
const refEvents: RawPiSessionEvent[] = [];
|
|
237
|
+
for await (const event of streamPiSessionEvents(session.path)) {
|
|
238
|
+
if (event.raw.type === "session" && typeof event.raw.cwd === "string") session.cwd = event.raw.cwd;
|
|
239
|
+
pushRefWindow(refEvents, event);
|
|
240
|
+
updateStateFromEvent(state, event);
|
|
241
|
+
if (!isCheckpointEvent(event)) continue;
|
|
242
|
+
yield checkpointFromState(session, event, refEvents, state);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function clampFeature(value: number): number {
|
|
247
|
+
return Math.max(0, Math.min(1, Number(value.toFixed(3))));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function checkpointWithDiffStats(checkpoint: RouterCheckpoint, cwd?: string, excludePaths: string[] = []): RouterCheckpoint {
|
|
251
|
+
const stats = readGitDiffStats(cwd, { excludePaths });
|
|
252
|
+
if (stats.filesChanged === 0) return checkpoint;
|
|
253
|
+
const phaseWantsVerifier = checkpoint.phase === "implementation" || checkpoint.phase === "debug" || checkpoint.phase === "review";
|
|
254
|
+
const noVerifierUsed = checkpoint.features.noVerifierUsed
|
|
255
|
+
|| (phaseWantsVerifier && !checkpoint.features.verifierUsed && checkpoint.features.toolCallsLast10Turns >= 4);
|
|
256
|
+
const loopScore = noVerifierUsed && !checkpoint.features.noVerifierUsed
|
|
257
|
+
? clampFeature(checkpoint.features.loopScore + 0.2)
|
|
258
|
+
: checkpoint.features.loopScore;
|
|
259
|
+
const progressScore = noVerifierUsed && !checkpoint.features.noVerifierUsed
|
|
260
|
+
? clampFeature(checkpoint.features.progressScore - 0.1)
|
|
261
|
+
: checkpoint.features.progressScore;
|
|
262
|
+
return {
|
|
263
|
+
...checkpoint,
|
|
264
|
+
features: {
|
|
265
|
+
...checkpoint.features,
|
|
266
|
+
diffLines: stats.totalLines,
|
|
267
|
+
diffFilesChanged: stats.filesChanged,
|
|
268
|
+
diffLinesAdded: stats.linesAdded,
|
|
269
|
+
diffLinesDeleted: stats.linesDeleted,
|
|
270
|
+
diffChurnScore: diffChurnScore(stats),
|
|
271
|
+
noVerifierUsed,
|
|
272
|
+
loopScore,
|
|
273
|
+
progressScore,
|
|
274
|
+
},
|
|
275
|
+
recent: { ...checkpoint.recent, diffFileHashes: stats.fileHashes },
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function applyWorkspaceDiffToLatest(checkpoints: RouterCheckpoint[], cwd?: string, excludePaths: string[] = []): RouterCheckpoint[] {
|
|
280
|
+
if (checkpoints.length === 0) return checkpoints;
|
|
281
|
+
const next = [...checkpoints];
|
|
282
|
+
next[next.length - 1] = checkpointWithDiffStats(next[next.length - 1], cwd, excludePaths);
|
|
283
|
+
return next;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export function buildCheckpoints(session: PiSession): RouterCheckpoint[] {
|
|
287
|
+
return [...iterateCheckpoints(session)];
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export function rebuildCheckpointsFromSession(sessionPath: string): RouterCheckpoint[] {
|
|
291
|
+
return buildCheckpoints(readPiSession(sessionPath));
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export function writeCheckpointsJsonl(checkpoints: RouterCheckpoint[], outputPath: string): void {
|
|
295
|
+
const resolved = resolve(outputPath);
|
|
296
|
+
mkdirSync(dirname(resolved), { recursive: true });
|
|
297
|
+
const fd = openSync(resolved, "w");
|
|
298
|
+
try {
|
|
299
|
+
for (const checkpoint of checkpoints) writeSync(fd, `${JSON.stringify(checkpoint)}\n`);
|
|
300
|
+
} finally {
|
|
301
|
+
closeSync(fd);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export interface SessionCheckpointWriteSummary {
|
|
306
|
+
sessions: string[];
|
|
307
|
+
output: string;
|
|
308
|
+
checkpoints: number;
|
|
309
|
+
firstCheckpointId?: string;
|
|
310
|
+
lastCheckpointId?: string;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export async function writeSessionCheckpointsJsonl(sessionPaths: string[], outputPath: string, options: { workspaceDiff?: boolean } = {}): Promise<SessionCheckpointWriteSummary> {
|
|
314
|
+
if (options.workspaceDiff && sessionPaths.length !== 1) {
|
|
315
|
+
throw new Error("--workspace-diff can only be used with exactly one current session");
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const resolved = resolve(outputPath);
|
|
319
|
+
// Compute live workspace diff before opening/truncating the output so the output artifact cannot count itself.
|
|
320
|
+
const workspaceDiffCheckpoints = options.workspaceDiff
|
|
321
|
+
? (() => {
|
|
322
|
+
const session = readPiSession(sessionPaths[0]);
|
|
323
|
+
const routerDir = session.cwd ? resolve(session.cwd, ".pi", "router") : undefined;
|
|
324
|
+
const routerArtifacts = routerDir ? [resolve(routerDir, "config.json"), resolve(routerDir, "state.json"), resolve(routerDir, "events.jsonl")] : [];
|
|
325
|
+
return applyWorkspaceDiffToLatest(buildCheckpoints(session), session.cwd, [session.path, resolved, ...routerArtifacts]);
|
|
326
|
+
})()
|
|
327
|
+
: null;
|
|
328
|
+
mkdirSync(dirname(resolved), { recursive: true });
|
|
329
|
+
const fd = openSync(resolved, "w");
|
|
330
|
+
let checkpoints = 0;
|
|
331
|
+
let firstCheckpointId: string | undefined;
|
|
332
|
+
let lastCheckpointId: string | undefined;
|
|
333
|
+
try {
|
|
334
|
+
if (workspaceDiffCheckpoints) {
|
|
335
|
+
for (const checkpoint of workspaceDiffCheckpoints) {
|
|
336
|
+
firstCheckpointId ??= checkpoint.checkpointId;
|
|
337
|
+
lastCheckpointId = checkpoint.checkpointId;
|
|
338
|
+
checkpoints++;
|
|
339
|
+
writeSync(fd, `${JSON.stringify(checkpoint)}\n`);
|
|
340
|
+
}
|
|
341
|
+
} else {
|
|
342
|
+
for (const sessionPath of sessionPaths) {
|
|
343
|
+
for await (const checkpoint of streamCheckpointsFromSessionPath(sessionPath)) {
|
|
344
|
+
firstCheckpointId ??= checkpoint.checkpointId;
|
|
345
|
+
lastCheckpointId = checkpoint.checkpointId;
|
|
346
|
+
checkpoints++;
|
|
347
|
+
writeSync(fd, `${JSON.stringify(checkpoint)}\n`);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
} finally {
|
|
352
|
+
closeSync(fd);
|
|
353
|
+
}
|
|
354
|
+
return { sessions: sessionPaths, output: resolved, checkpoints, firstCheckpointId, lastCheckpointId };
|
|
355
|
+
}
|