@fiale-plus/pi-rogue 0.2.1 → 0.2.3
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-core/src/context-broker.ts +4 -0
- package/node_modules/@fiale-plus/pi-rogue-context-broker/README.md +24 -5
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/extension.test.ts +119 -7
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/extension.ts +124 -16
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/index.test.ts +32 -0
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/index.ts +32 -1
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/sqlite.test.ts +37 -0
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/sqlite.ts +39 -2
- package/node_modules/@fiale-plus/pi-rogue-router/README.md +34 -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 +363 -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 +165 -0
- package/node_modules/@fiale-plus/pi-rogue-router/src/config.ts +193 -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 +134 -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 +126 -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 +297 -0
- package/package.json +5 -3
- package/src/extension.test.ts +1 -0
- package/src/extension.ts +2 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { hashText } from "./hash.js";
|
|
4
|
+
import { readCheckpointJsonl } from "./decision.js";
|
|
5
|
+
import { readRouteEvents, type RouteEvent } from "./ledger.js";
|
|
6
|
+
import type { RouterCheckpoint, TaskStatus, TaskType } from "./types.js";
|
|
7
|
+
|
|
8
|
+
export const ROUTER_OUTCOME_SCHEMA = "pi-router.outcome.v1" as const;
|
|
9
|
+
|
|
10
|
+
export interface RouterOutcome {
|
|
11
|
+
schema: typeof ROUTER_OUTCOME_SCHEMA;
|
|
12
|
+
outcomeId: string;
|
|
13
|
+
recordedAt: string;
|
|
14
|
+
sessionId: string;
|
|
15
|
+
checkpointId?: string;
|
|
16
|
+
routeEventId?: string;
|
|
17
|
+
taskType: TaskType;
|
|
18
|
+
taskStatus: TaskStatus;
|
|
19
|
+
testsPassedAfter: boolean | null;
|
|
20
|
+
verifierImproved: boolean | null;
|
|
21
|
+
acceptedDiff: boolean | null;
|
|
22
|
+
userInterrupted: boolean;
|
|
23
|
+
userOverrodeDecision: boolean;
|
|
24
|
+
finalFilesTouched: number;
|
|
25
|
+
finalDiffLines: number;
|
|
26
|
+
wallTimeMs: number | null;
|
|
27
|
+
cloudCostUsd: number | null;
|
|
28
|
+
frontierCalls: number;
|
|
29
|
+
localTurns: number;
|
|
30
|
+
reworkTurns: number;
|
|
31
|
+
evidence: {
|
|
32
|
+
source: "inferred" | "manual";
|
|
33
|
+
rawSessionRef?: RouterCheckpoint["rawSessionRef"];
|
|
34
|
+
routeEventId?: string;
|
|
35
|
+
notesHash?: string;
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface OutcomeWriteSummary {
|
|
40
|
+
schema: "pi-router.outcomes-summary.v1";
|
|
41
|
+
output: string;
|
|
42
|
+
outcomes: number;
|
|
43
|
+
inferred: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function roundStatus(event: RouteEvent, checkpoint?: RouterCheckpoint): TaskStatus {
|
|
47
|
+
if (event.decision.action === "stop_and_ask_user") return "unknown";
|
|
48
|
+
if (checkpoint?.features.verifierUsed && checkpoint.features.progressScore >= 0.75) return "partial";
|
|
49
|
+
return "unknown";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function taskTypeFromCheckpoint(checkpoint?: RouterCheckpoint): TaskType {
|
|
53
|
+
const phase = checkpoint?.phase ?? "unknown";
|
|
54
|
+
return phase === "implementation" || phase === "debug" || phase === "review" || phase === "research" || phase === "ops" || phase === "planning"
|
|
55
|
+
? phase
|
|
56
|
+
: "unknown";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isFrontierModel(model?: string): boolean {
|
|
60
|
+
return Boolean(model && /(gpt-5|gpt-4|claude|gemini|opus|sonnet)/i.test(model));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isLocalModel(model?: string): boolean {
|
|
64
|
+
return Boolean(model && /(qwen|llama|mlx|ollama|local)/i.test(model));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function buildUnknownOutcome(event: RouteEvent, checkpoint?: RouterCheckpoint, recordedAt = new Date().toISOString()): RouterOutcome {
|
|
68
|
+
const model = event.runtime.activeModel;
|
|
69
|
+
return {
|
|
70
|
+
schema: ROUTER_OUTCOME_SCHEMA,
|
|
71
|
+
outcomeId: hashText("outcome", event.eventId, checkpoint?.rawSessionRef.contentHash ?? event.rawSessionRef.contentHash),
|
|
72
|
+
recordedAt,
|
|
73
|
+
sessionId: event.sessionId,
|
|
74
|
+
checkpointId: event.checkpointId,
|
|
75
|
+
routeEventId: event.eventId,
|
|
76
|
+
taskType: taskTypeFromCheckpoint(checkpoint),
|
|
77
|
+
taskStatus: roundStatus(event, checkpoint),
|
|
78
|
+
testsPassedAfter: null,
|
|
79
|
+
verifierImproved: null,
|
|
80
|
+
acceptedDiff: null,
|
|
81
|
+
userInterrupted: event.decision.action === "stop_and_ask_user",
|
|
82
|
+
userOverrodeDecision: Boolean(event.observed.overriddenBy),
|
|
83
|
+
finalFilesTouched: checkpoint ? ((checkpoint.features.diffFilesChanged ?? 0) > 0 ? (checkpoint.features.diffFilesChanged ?? 0) : checkpoint.features.filesTouched) : 0,
|
|
84
|
+
finalDiffLines: checkpoint?.features.diffLines ?? 0,
|
|
85
|
+
wallTimeMs: null,
|
|
86
|
+
cloudCostUsd: null,
|
|
87
|
+
frontierCalls: isFrontierModel(model) ? 1 : 0,
|
|
88
|
+
localTurns: isLocalModel(model) ? 1 : 0,
|
|
89
|
+
reworkTurns: checkpoint?.features.sameErrorRepeatedCount && checkpoint.features.sameErrorRepeatedCount > 1 ? checkpoint.features.sameErrorRepeatedCount - 1 : 0,
|
|
90
|
+
evidence: {
|
|
91
|
+
source: "inferred",
|
|
92
|
+
rawSessionRef: checkpoint?.rawSessionRef ?? event.rawSessionRef,
|
|
93
|
+
routeEventId: event.eventId,
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function inferOutcomes(events: RouteEvent[], checkpoints: RouterCheckpoint[], recordedAt = new Date().toISOString()): RouterOutcome[] {
|
|
99
|
+
const byCheckpoint = new Map(checkpoints.map((checkpoint) => [checkpoint.checkpointId, checkpoint]));
|
|
100
|
+
return events.map((event) => buildUnknownOutcome(event, byCheckpoint.get(event.checkpointId), recordedAt));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function readOutcomes(path?: string): RouterOutcome[] {
|
|
104
|
+
if (!path) return [];
|
|
105
|
+
const resolved = resolve(path);
|
|
106
|
+
if (!existsSync(resolved)) throw new Error(`outcomes file not found: ${path}`);
|
|
107
|
+
return readFileSync(resolved, "utf8")
|
|
108
|
+
.split("\n")
|
|
109
|
+
.filter((line) => line.trim())
|
|
110
|
+
.flatMap((line) => {
|
|
111
|
+
try { return [JSON.parse(line) as RouterOutcome]; } catch { return []; }
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function writeOutcomesJsonl(outcomes: RouterOutcome[], path: string): void {
|
|
116
|
+
const resolved = resolve(path);
|
|
117
|
+
mkdirSync(dirname(resolved), { recursive: true });
|
|
118
|
+
writeFileSync(resolved, outcomes.map((outcome) => JSON.stringify(outcome)).join("\n") + (outcomes.length ? "\n" : ""));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function writeInferredOutcomes(options: { checkpointPath: string; eventsPath: string; outputPath: string }): OutcomeWriteSummary {
|
|
122
|
+
if (!existsSync(resolve(options.eventsPath))) throw new Error(`required route events file not found: ${options.eventsPath}`);
|
|
123
|
+
const checkpoints = readCheckpointJsonl(options.checkpointPath);
|
|
124
|
+
const events = readRouteEvents(options.eventsPath);
|
|
125
|
+
const outcomes = inferOutcomes(events, checkpoints);
|
|
126
|
+
writeOutcomesJsonl(outcomes, options.outputPath);
|
|
127
|
+
return { schema: "pi-router.outcomes-summary.v1", output: resolve(options.outputPath), outcomes: outcomes.length, inferred: outcomes.length };
|
|
128
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { hashText } from "./hash.js";
|
|
2
|
+
import type { RawPiSessionEvent } from "./session-reader.js";
|
|
3
|
+
import type { ProgressSignals } from "./types.js";
|
|
4
|
+
|
|
5
|
+
const FILE_PATH_RE = /(?:^|\s)([\w./-]+\.(?:ts|tsx|js|jsx|json|md|yaml|yml|py|rs|go|css|scss|html|sh))(?:\s|$|:)/g;
|
|
6
|
+
|
|
7
|
+
export function touchedFileHashesFromEvent(event: RawPiSessionEvent): string[] {
|
|
8
|
+
const files = new Set<string>();
|
|
9
|
+
const rawMessage = event.raw.message;
|
|
10
|
+
if (!rawMessage || typeof rawMessage !== "object") return [];
|
|
11
|
+
const content = (rawMessage as { content?: unknown }).content;
|
|
12
|
+
if (!Array.isArray(content)) return [];
|
|
13
|
+
for (const item of content) {
|
|
14
|
+
if (!item || typeof item !== "object") continue;
|
|
15
|
+
const record = item as Record<string, unknown>;
|
|
16
|
+
const args = record.arguments;
|
|
17
|
+
const command = args && typeof args === "object" ? (args as Record<string, unknown>).command : undefined;
|
|
18
|
+
if (typeof command !== "string") continue;
|
|
19
|
+
for (const match of command.matchAll(FILE_PATH_RE)) files.add(match[1]);
|
|
20
|
+
}
|
|
21
|
+
return [...files].sort().map((file) => hashText(file));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function touchedFileHashes(events: RawPiSessionEvent[]): string[] {
|
|
25
|
+
const files = new Set<string>();
|
|
26
|
+
for (const event of events) {
|
|
27
|
+
for (const fileHash of touchedFileHashesFromEvent(event)) files.add(fileHash);
|
|
28
|
+
}
|
|
29
|
+
return [...files].sort();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function consecutiveRepeatCount<T>(items: T[], valueOf: (item: T) => string | undefined): number {
|
|
33
|
+
let last: string | undefined;
|
|
34
|
+
let count = 0;
|
|
35
|
+
for (let index = items.length - 1; index >= 0; index--) {
|
|
36
|
+
const value = valueOf(items[index]);
|
|
37
|
+
if (!value) continue;
|
|
38
|
+
if (last === undefined) {
|
|
39
|
+
last = value;
|
|
40
|
+
count = 1;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (value !== last) break;
|
|
44
|
+
count++;
|
|
45
|
+
}
|
|
46
|
+
return count;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function clamp01(value: number): number {
|
|
50
|
+
return Math.max(0, Math.min(1, value));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function computeProgressSignals(events: RawPiSessionEvent[]): ProgressSignals {
|
|
54
|
+
const commandEvents = events.flatMap((event) => event.commandEvents);
|
|
55
|
+
const toolResults = events.flatMap((event) => event.toolResult ? [event.toolResult] : []);
|
|
56
|
+
const errorResults = toolResults.filter((result) => result.isError && result.errorHash);
|
|
57
|
+
const sameCommandRepeatedCount = consecutiveRepeatCount(commandEvents, (event) => event.normalizedCommandHash);
|
|
58
|
+
const sameErrorRepeatedCount = consecutiveRepeatCount(errorResults, (event) => event.errorHash);
|
|
59
|
+
const verifierUsed = commandEvents.some((event) => event.isVerifier);
|
|
60
|
+
const recentCommands = commandEvents.slice(-10);
|
|
61
|
+
const uniqueRecentCommands = new Set(recentCommands.map((event) => event.normalizedCommandHash).filter(Boolean));
|
|
62
|
+
const fileHashes = touchedFileHashes(events);
|
|
63
|
+
|
|
64
|
+
const commandRepeatPressure = clamp01((sameCommandRepeatedCount - 1) / 3);
|
|
65
|
+
const errorRepeatPressure = clamp01((sameErrorRepeatedCount - 1) / 3);
|
|
66
|
+
const toolThrashScore = recentCommands.length === 0 ? 0 : clamp01(1 - uniqueRecentCommands.size / recentCommands.length);
|
|
67
|
+
const noVerifierUsed = fileHashes.length > 0 && commandEvents.length >= 4 && !verifierUsed;
|
|
68
|
+
const noVerifierPressure = noVerifierUsed ? 0.2 : 0;
|
|
69
|
+
const loopScore = clamp01(commandRepeatPressure * 0.35 + errorRepeatPressure * 0.4 + toolThrashScore * 0.2 + noVerifierPressure);
|
|
70
|
+
const progressScore = clamp01(1 - loopScore - (noVerifierUsed ? 0.1 : 0));
|
|
71
|
+
const lastError = errorResults.at(-1)?.errorHash;
|
|
72
|
+
const previousError = errorResults.at(-2)?.errorHash;
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
sameCommandRepeatedCount,
|
|
76
|
+
sameErrorRepeatedCount,
|
|
77
|
+
errorChanged: Boolean(lastError && previousError && lastError !== previousError),
|
|
78
|
+
testsImproved: null,
|
|
79
|
+
filesTouched: fileHashes.length,
|
|
80
|
+
diffLines: 0,
|
|
81
|
+
diffFilesChanged: 0,
|
|
82
|
+
diffLinesAdded: 0,
|
|
83
|
+
diffLinesDeleted: 0,
|
|
84
|
+
diffChurnScore: 0,
|
|
85
|
+
toolThrashScore,
|
|
86
|
+
goalDriftScore: 0,
|
|
87
|
+
loopScore,
|
|
88
|
+
progressScore,
|
|
89
|
+
verifierUsed,
|
|
90
|
+
noVerifierUsed,
|
|
91
|
+
toolCallsLast10Turns: recentCommands.length,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { basename, resolve } from "node:path";
|
|
2
|
+
import { createReadStream, readFileSync } from "node:fs";
|
|
3
|
+
import { createInterface } from "node:readline/promises";
|
|
4
|
+
import { hashMaybe, hashText, normalizeText } from "./hash.js";
|
|
5
|
+
import type { SessionCommandEvent, SessionEventPointer, SessionRole, SessionToolResultEvent } from "./types.js";
|
|
6
|
+
|
|
7
|
+
export interface RawPiSessionEvent {
|
|
8
|
+
index: number;
|
|
9
|
+
byteStart: number;
|
|
10
|
+
byteEnd: number;
|
|
11
|
+
raw: Record<string, unknown>;
|
|
12
|
+
rawLineHash: string;
|
|
13
|
+
pointer: SessionEventPointer;
|
|
14
|
+
role: SessionRole;
|
|
15
|
+
textHash?: string;
|
|
16
|
+
normalizedTextHash?: string;
|
|
17
|
+
commandEvents: SessionCommandEvent[];
|
|
18
|
+
toolResult?: SessionToolResultEvent;
|
|
19
|
+
provider?: string;
|
|
20
|
+
model?: string;
|
|
21
|
+
usage?: Record<string, unknown>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface PiSession {
|
|
25
|
+
id: string;
|
|
26
|
+
path: string;
|
|
27
|
+
cwd?: string;
|
|
28
|
+
events: RawPiSessionEvent[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
32
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function asArray(value: unknown): unknown[] {
|
|
36
|
+
return Array.isArray(value) ? value : [];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function roleOf(raw: Record<string, unknown>): SessionRole {
|
|
40
|
+
const message = asRecord(raw.message);
|
|
41
|
+
const role = String(message?.role ?? "");
|
|
42
|
+
if (role === "user" || role === "assistant" || role === "toolResult" || role === "system") return role;
|
|
43
|
+
return "unknown";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function textFromContent(content: unknown): string {
|
|
47
|
+
const parts: string[] = [];
|
|
48
|
+
for (const item of asArray(content)) {
|
|
49
|
+
const record = asRecord(item);
|
|
50
|
+
if (!record) continue;
|
|
51
|
+
if (typeof record.text === "string") parts.push(record.text);
|
|
52
|
+
}
|
|
53
|
+
return parts.join("\n");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function commandFromToolCallArgs(args: unknown): string | undefined {
|
|
57
|
+
if (typeof args === "string") return args;
|
|
58
|
+
const record = asRecord(args);
|
|
59
|
+
const command = record?.command;
|
|
60
|
+
return typeof command === "string" ? command : undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isVerifierCommand(command: string): boolean {
|
|
64
|
+
return /\b(npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:test|check|lint|build)\b|\b(vitest|pytest|cargo\s+test|go\s+test|make\s+test|tsc\b)/i.test(command);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function normalizedErrorFingerprint(text: string): string {
|
|
68
|
+
return String(text ?? "")
|
|
69
|
+
.toLowerCase()
|
|
70
|
+
.replace(/\b[0-9a-f]{8}-[0-9a-f-]{27,}\b/g, " <uuid> ")
|
|
71
|
+
.replace(/0x[0-9a-f]+/g, " <addr> ")
|
|
72
|
+
.replace(/\b\d{4}-\d{2}-\d{2}t\d{2}:\d{2}:\d{2}(?:\.\d+)?z?\b/g, " <timestamp> ")
|
|
73
|
+
.replace(/\b[\w./-]+\.(?:test\.)?(?:ts|tsx|js|jsx|json|md|yaml|yml|py|rs|go|css|scss|html|sh)(?::\d+)*(?:\b|$)/g, " <file> ")
|
|
74
|
+
.replace(/(?:file:\/\/)?(?:\/[\w .-]+)+/g, " <path> ")
|
|
75
|
+
.replace(/\bline\s+\d+\b/g, " line <n> ")
|
|
76
|
+
.replace(/\bcolumn\s+\d+\b/g, " column <n> ")
|
|
77
|
+
.replace(/\b\d+ms\b/g, " <duration> ")
|
|
78
|
+
.replace(/\bport\s+\d+\b/g, " port <n> ")
|
|
79
|
+
.replace(/\b\d+\b/g, " <n> ")
|
|
80
|
+
.replace(/\s+/g, " ")
|
|
81
|
+
.trim();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function exitCodeFromText(text: string): number | undefined {
|
|
85
|
+
const match = text.match(/\b(?:exit(?:ed)?(?: with)? code|code)\s+([1-9]\d*)\b/i);
|
|
86
|
+
return match ? Number(match[1]) : undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function failingTestHashFromText(text: string): string | undefined {
|
|
90
|
+
const match = text.match(/(?:FAIL|FAILED)\s+([^\n\r]+)/i) ?? text.match(/(?:test|it)\(["']([^"']+)["']/i);
|
|
91
|
+
return match?.[1] ? hashText(normalizeText(match[1])) : undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function commandEventsFromMessage(eventIndex: number, raw: Record<string, unknown>): SessionCommandEvent[] {
|
|
95
|
+
const message = asRecord(raw.message);
|
|
96
|
+
if (message?.role !== "assistant") return [];
|
|
97
|
+
|
|
98
|
+
return asArray(message.content).flatMap((item) => {
|
|
99
|
+
const record = asRecord(item);
|
|
100
|
+
if (!record || record.type !== "toolCall") return [];
|
|
101
|
+
const toolName = typeof record.name === "string" ? record.name : "unknown";
|
|
102
|
+
const command = toolName === "bash" ? commandFromToolCallArgs(record.arguments) : undefined;
|
|
103
|
+
return [{
|
|
104
|
+
eventIndex,
|
|
105
|
+
toolCallId: typeof record.id === "string" ? record.id : undefined,
|
|
106
|
+
toolName,
|
|
107
|
+
commandHash: command ? hashText(command) : undefined,
|
|
108
|
+
normalizedCommandHash: command ? hashMaybe(command) : undefined,
|
|
109
|
+
isVerifier: command ? isVerifierCommand(command) : false,
|
|
110
|
+
} satisfies SessionCommandEvent];
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function toolResultFromMessage(eventIndex: number, raw: Record<string, unknown>): SessionToolResultEvent | undefined {
|
|
115
|
+
const message = asRecord(raw.message);
|
|
116
|
+
if (message?.role !== "toolResult") return undefined;
|
|
117
|
+
const text = textFromContent(message.content);
|
|
118
|
+
const isError = Boolean(message.isError) || /\b(error|failed|failure|traceback|exception|not found|enoent|command exited with code [1-9])\b/i.test(text);
|
|
119
|
+
const fingerprint = isError && text ? normalizedErrorFingerprint(text) : "";
|
|
120
|
+
return {
|
|
121
|
+
eventIndex,
|
|
122
|
+
toolCallId: typeof message.toolCallId === "string" ? message.toolCallId : undefined,
|
|
123
|
+
toolName: typeof message.toolName === "string" ? message.toolName : undefined,
|
|
124
|
+
isError,
|
|
125
|
+
outputHash: text ? hashText(text) : undefined,
|
|
126
|
+
normalizedOutputHash: text ? hashMaybe(text) : undefined,
|
|
127
|
+
errorHash: isError && text ? hashMaybe(text) : undefined,
|
|
128
|
+
errorFingerprintHash: fingerprint ? hashText(fingerprint) : undefined,
|
|
129
|
+
exitCode: text ? exitCodeFromText(text) : undefined,
|
|
130
|
+
failingTestHash: text ? failingTestHashFromText(text) : undefined,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function textHashes(raw: Record<string, unknown>): { textHash?: string; normalizedTextHash?: string } {
|
|
135
|
+
const message = asRecord(raw.message);
|
|
136
|
+
if (!message) return {};
|
|
137
|
+
const text = textFromContent(message.content);
|
|
138
|
+
if (!text) return {};
|
|
139
|
+
return { textHash: hashText(text), normalizedTextHash: hashText(normalizeText(text)) };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function modelFrom(raw: Record<string, unknown>): { provider?: string; model?: string; usage?: Record<string, unknown> } {
|
|
143
|
+
const message = asRecord(raw.message);
|
|
144
|
+
return {
|
|
145
|
+
provider: typeof raw.provider === "string" ? raw.provider : typeof message?.provider === "string" ? message.provider : undefined,
|
|
146
|
+
model: typeof raw.modelId === "string" ? raw.modelId : typeof message?.model === "string" ? message.model : undefined,
|
|
147
|
+
usage: asRecord(message?.usage),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function sessionIdFromPath(path: string): string {
|
|
152
|
+
return basename(path).replace(/\.jsonl$/i, "");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function parsePiSessionLine(line: string, index: number, byteStart: number, byteEnd: number): RawPiSessionEvent {
|
|
156
|
+
const raw = JSON.parse(line) as Record<string, unknown>;
|
|
157
|
+
const role = roleOf(raw);
|
|
158
|
+
const model = modelFrom(raw);
|
|
159
|
+
return {
|
|
160
|
+
index,
|
|
161
|
+
byteStart,
|
|
162
|
+
byteEnd,
|
|
163
|
+
raw,
|
|
164
|
+
rawLineHash: hashText(line),
|
|
165
|
+
pointer: {
|
|
166
|
+
index,
|
|
167
|
+
byteStart,
|
|
168
|
+
byteEnd,
|
|
169
|
+
id: typeof raw.id === "string" ? raw.id : undefined,
|
|
170
|
+
timestamp: typeof raw.timestamp === "string" ? raw.timestamp : undefined,
|
|
171
|
+
type: typeof raw.type === "string" ? raw.type : "unknown",
|
|
172
|
+
role,
|
|
173
|
+
},
|
|
174
|
+
role,
|
|
175
|
+
...textHashes(raw),
|
|
176
|
+
commandEvents: commandEventsFromMessage(index, raw),
|
|
177
|
+
toolResult: toolResultFromMessage(index, raw),
|
|
178
|
+
...model,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export async function* streamPiSessionEvents(path: string): AsyncGenerator<RawPiSessionEvent> {
|
|
183
|
+
const input = createReadStream(resolve(path), { encoding: "utf8" });
|
|
184
|
+
const lines = createInterface({ input, crlfDelay: Infinity });
|
|
185
|
+
let index = 0;
|
|
186
|
+
let byteStart = 0;
|
|
187
|
+
for await (const line of lines) {
|
|
188
|
+
const byteEnd = byteStart + Buffer.byteLength(`${line}\n`);
|
|
189
|
+
if (line.trim()) {
|
|
190
|
+
yield parsePiSessionLine(line, index, byteStart, byteEnd);
|
|
191
|
+
index++;
|
|
192
|
+
}
|
|
193
|
+
byteStart = byteEnd;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function readPiSession(path: string): PiSession {
|
|
198
|
+
const resolved = resolve(path);
|
|
199
|
+
const rawBuffer = readFileSync(resolved);
|
|
200
|
+
const text = rawBuffer.toString("utf8");
|
|
201
|
+
const events: RawPiSessionEvent[] = [];
|
|
202
|
+
let byteStart = 0;
|
|
203
|
+
let cwd: string | undefined;
|
|
204
|
+
|
|
205
|
+
for (const line of text.split(/(?<=\n)/)) {
|
|
206
|
+
const withoutNewline = line.endsWith("\n") ? line.slice(0, -1) : line;
|
|
207
|
+
const byteEnd = byteStart + Buffer.byteLength(line);
|
|
208
|
+
if (withoutNewline.trim()) {
|
|
209
|
+
const event = parsePiSessionLine(withoutNewline, events.length, byteStart, byteEnd);
|
|
210
|
+
if (event.raw.type === "session" && typeof event.raw.cwd === "string") cwd = event.raw.cwd;
|
|
211
|
+
events.push(event);
|
|
212
|
+
}
|
|
213
|
+
byteStart = byteEnd;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return { id: sessionIdFromPath(resolved), path: resolved, cwd, events };
|
|
217
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { hashText } from "./hash.js";
|
|
4
|
+
import type {
|
|
5
|
+
ContextPolicy,
|
|
6
|
+
RouterCheckpoint,
|
|
7
|
+
SubagentReturnContract,
|
|
8
|
+
SubagentRole,
|
|
9
|
+
SubagentToolPolicy,
|
|
10
|
+
} from "./types.js";
|
|
11
|
+
|
|
12
|
+
export const SUBAGENT_DECISION_SCHEMA = "pi-router.subagent-decision.v1" as const;
|
|
13
|
+
export const SUBAGENT_LEDGER_EVENT_SCHEMA = "pi-router.subagent-ledger-event.v1" as const;
|
|
14
|
+
export const EVIDENCE_SUMMARY_SCHEMA = "pi-router.evidence-summary.v1" as const;
|
|
15
|
+
|
|
16
|
+
export interface EvidenceSummaryItem {
|
|
17
|
+
kind: "file" | "command" | "session" | "manual";
|
|
18
|
+
ref: string;
|
|
19
|
+
reason: string;
|
|
20
|
+
outputFingerprint?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface EvidenceSummaryContract {
|
|
24
|
+
schema: typeof EVIDENCE_SUMMARY_SCHEMA;
|
|
25
|
+
status: "success" | "partial" | "failed";
|
|
26
|
+
confidence: number;
|
|
27
|
+
recommendedNextAction: string;
|
|
28
|
+
evidence: EvidenceSummaryItem[];
|
|
29
|
+
findings: string[];
|
|
30
|
+
risks: string[];
|
|
31
|
+
minimalPayloadForParent: string;
|
|
32
|
+
rawTraceRef?: {
|
|
33
|
+
childSessionId?: string;
|
|
34
|
+
path?: string;
|
|
35
|
+
fromEvent?: number;
|
|
36
|
+
toEvent?: number;
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface SubagentRouteDecision {
|
|
41
|
+
schema: typeof SUBAGENT_DECISION_SCHEMA;
|
|
42
|
+
parentCheckpointId: string;
|
|
43
|
+
action: "spawn_subagent";
|
|
44
|
+
subagentRole: SubagentRole;
|
|
45
|
+
targetModel: string;
|
|
46
|
+
toolPolicy: SubagentToolPolicy;
|
|
47
|
+
contextPolicy: ContextPolicy | "goal_only" | "focused_files" | "repo_card_plus_goal";
|
|
48
|
+
returnContract: SubagentReturnContract;
|
|
49
|
+
maxSteps: number;
|
|
50
|
+
maxTokens: number | null;
|
|
51
|
+
confidence: number;
|
|
52
|
+
reason: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface SubagentLedgerEvent {
|
|
56
|
+
schema: typeof SUBAGENT_LEDGER_EVENT_SCHEMA;
|
|
57
|
+
eventId: string;
|
|
58
|
+
recordedAt: string;
|
|
59
|
+
parentSessionId: string;
|
|
60
|
+
childSessionId: string;
|
|
61
|
+
parentCheckpointId: string;
|
|
62
|
+
subagentRole: SubagentRole;
|
|
63
|
+
model: string;
|
|
64
|
+
toolPolicy: SubagentToolPolicy;
|
|
65
|
+
contextPolicy: SubagentRouteDecision["contextPolicy"];
|
|
66
|
+
inputSummaryHash: string;
|
|
67
|
+
outputSummaryHash: string;
|
|
68
|
+
acceptedIntoParent: boolean | null;
|
|
69
|
+
useful: boolean | null;
|
|
70
|
+
causedRework: boolean | null;
|
|
71
|
+
returnContract: SubagentReturnContract;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function clampConfidence(value: number): number {
|
|
75
|
+
return Math.max(0, Math.min(1, Number(value.toFixed(3))));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function recommendSubagentDecision(
|
|
79
|
+
checkpoint: RouterCheckpoint,
|
|
80
|
+
profile: Partial<Record<SubagentRole | "worker" | "smart" | "reviewer", string>>,
|
|
81
|
+
): SubagentRouteDecision | null {
|
|
82
|
+
if (checkpoint.phase === "research" || (checkpoint.phase === "unknown" && checkpoint.features.toolCallsLast10Turns === 0)) {
|
|
83
|
+
return buildSubagentDecision(checkpoint, {
|
|
84
|
+
subagentRole: "explore",
|
|
85
|
+
targetModel: profile.explore ?? profile.worker ?? "unknown",
|
|
86
|
+
toolPolicy: "read_only",
|
|
87
|
+
contextPolicy: "goal_only",
|
|
88
|
+
confidence: 0.68,
|
|
89
|
+
reason: "repo area or question appears exploratory; a read-only fresh-context scout can reduce main-context pollution",
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
if (checkpoint.phase === "debug" && checkpoint.features.sameErrorRepeatedCount >= 2) {
|
|
93
|
+
return buildSubagentDecision(checkpoint, {
|
|
94
|
+
subagentRole: "debug_diagnose",
|
|
95
|
+
targetModel: profile.debug_diagnose ?? profile.smart ?? "unknown",
|
|
96
|
+
toolPolicy: "read_only",
|
|
97
|
+
contextPolicy: "focused_error_and_diff",
|
|
98
|
+
confidence: clampConfidence(0.72 + Math.min(checkpoint.features.sameErrorRepeatedCount, 4) * 0.04),
|
|
99
|
+
reason: "repeated failure fingerprint; ask a focused diagnostic subagent for evidence-backed root cause",
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
if (checkpoint.phase === "review" && checkpoint.features.diffLines >= 250) {
|
|
103
|
+
return buildSubagentDecision(checkpoint, {
|
|
104
|
+
subagentRole: "review",
|
|
105
|
+
targetModel: profile.review ?? profile.reviewer ?? profile.smart ?? "unknown",
|
|
106
|
+
toolPolicy: "read_only",
|
|
107
|
+
contextPolicy: "diff_only",
|
|
108
|
+
confidence: 0.74,
|
|
109
|
+
reason: "large review diff; spawn read-only reviewer with diff-only context and evidence contract",
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
if (checkpoint.features.noVerifierUsed) {
|
|
113
|
+
return buildSubagentDecision(checkpoint, {
|
|
114
|
+
subagentRole: "verify",
|
|
115
|
+
targetModel: profile.verify ?? profile.worker ?? "unknown",
|
|
116
|
+
toolPolicy: "test_only",
|
|
117
|
+
contextPolicy: "focused_files",
|
|
118
|
+
confidence: 0.7,
|
|
119
|
+
reason: "implementation/debug work changed files without verifier; run bounded verification outside main context",
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function buildSubagentDecision(
|
|
126
|
+
checkpoint: RouterCheckpoint,
|
|
127
|
+
options: Omit<SubagentRouteDecision, "schema" | "parentCheckpointId" | "action" | "returnContract" | "maxSteps" | "maxTokens"> & Partial<Pick<SubagentRouteDecision, "maxSteps" | "maxTokens" | "returnContract">>,
|
|
128
|
+
): SubagentRouteDecision {
|
|
129
|
+
return {
|
|
130
|
+
schema: SUBAGENT_DECISION_SCHEMA,
|
|
131
|
+
parentCheckpointId: checkpoint.checkpointId,
|
|
132
|
+
action: "spawn_subagent",
|
|
133
|
+
subagentRole: options.subagentRole,
|
|
134
|
+
targetModel: options.targetModel,
|
|
135
|
+
toolPolicy: options.toolPolicy,
|
|
136
|
+
contextPolicy: options.contextPolicy,
|
|
137
|
+
returnContract: options.returnContract ?? "evidence_summary_v1",
|
|
138
|
+
maxSteps: options.maxSteps ?? 8,
|
|
139
|
+
maxTokens: options.maxTokens ?? null,
|
|
140
|
+
confidence: clampConfidence(options.confidence),
|
|
141
|
+
reason: options.reason,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function buildSubagentLedgerEvent(options: Omit<SubagentLedgerEvent, "schema" | "eventId" | "recordedAt"> & { recordedAt?: string }): SubagentLedgerEvent {
|
|
146
|
+
const recordedAt = options.recordedAt ?? new Date().toISOString();
|
|
147
|
+
return {
|
|
148
|
+
schema: SUBAGENT_LEDGER_EVENT_SCHEMA,
|
|
149
|
+
eventId: hashText("subagent", options.parentSessionId, options.childSessionId, options.parentCheckpointId, options.subagentRole, options.outputSummaryHash),
|
|
150
|
+
recordedAt,
|
|
151
|
+
parentSessionId: options.parentSessionId,
|
|
152
|
+
childSessionId: options.childSessionId,
|
|
153
|
+
parentCheckpointId: options.parentCheckpointId,
|
|
154
|
+
subagentRole: options.subagentRole,
|
|
155
|
+
model: options.model,
|
|
156
|
+
toolPolicy: options.toolPolicy,
|
|
157
|
+
contextPolicy: options.contextPolicy,
|
|
158
|
+
inputSummaryHash: options.inputSummaryHash,
|
|
159
|
+
outputSummaryHash: options.outputSummaryHash,
|
|
160
|
+
acceptedIntoParent: options.acceptedIntoParent,
|
|
161
|
+
useful: options.useful,
|
|
162
|
+
causedRework: options.causedRework,
|
|
163
|
+
returnContract: options.returnContract,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function appendSubagentLedgerEvent(path: string, event: SubagentLedgerEvent): void {
|
|
168
|
+
const resolved = resolve(path);
|
|
169
|
+
mkdirSync(dirname(resolved), { recursive: true });
|
|
170
|
+
writeFileSync(resolved, `${JSON.stringify(event)}\n`, { flag: "a" });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function readSubagentLedgerEvents(path: string): SubagentLedgerEvent[] {
|
|
174
|
+
return readFileSync(resolve(path), "utf8")
|
|
175
|
+
.split("\n")
|
|
176
|
+
.filter((line) => line.trim())
|
|
177
|
+
.map((line) => JSON.parse(line) as SubagentLedgerEvent);
|
|
178
|
+
}
|