@fiale-plus/pi-rogue 0.2.2 → 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/node_modules/@fiale-plus/pi-core/src/context-broker.ts +4 -0
- package/node_modules/@fiale-plus/pi-rogue-context-broker/README.md +20 -2
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/extension.test.ts +81 -3
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/extension.ts +72 -10
- 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 +2 -0
- package/node_modules/@fiale-plus/pi-rogue-router/src/checkpoints.ts +9 -1
- package/node_modules/@fiale-plus/pi-rogue-router/src/config-extension.test.ts +36 -4
- package/node_modules/@fiale-plus/pi-rogue-router/src/config.ts +34 -9
- package/node_modules/@fiale-plus/pi-rogue-router/src/git-features.ts +27 -12
- package/node_modules/@fiale-plus/pi-rogue-router/src/observe.ts +11 -4
- package/node_modules/@fiale-plus/pi-rogue-router/src/v1-telemetry.test.ts +5 -1
- package/package.json +1 -1
|
@@ -72,6 +72,8 @@ export interface ContextBrokerStatus {
|
|
|
72
72
|
coldBytes: number;
|
|
73
73
|
maxRecords: number;
|
|
74
74
|
maxBytes: number;
|
|
75
|
+
globalMaxRecords: number;
|
|
76
|
+
globalMaxBytes: number;
|
|
75
77
|
}
|
|
76
78
|
|
|
77
79
|
export interface ContextBrokerOptions {
|
|
@@ -89,6 +91,8 @@ export interface ContextBrokerOptions {
|
|
|
89
91
|
hotMaxBytes?: number;
|
|
90
92
|
warmMaxBytes?: number;
|
|
91
93
|
coldMaxBytes?: number;
|
|
94
|
+
hotToWarmMs?: number;
|
|
95
|
+
warmToColdMs?: number;
|
|
92
96
|
summaryBytes?: number;
|
|
93
97
|
briefBytes?: number;
|
|
94
98
|
}
|
|
@@ -8,6 +8,7 @@ This package contains the executable in-memory bounded broker implementation:
|
|
|
8
8
|
- Lookups support handle, session, kind, tag, path, command prefix, branch, tier, and text filters.
|
|
9
9
|
- Omitted summaries become metadata-only placeholders, keeping raw payloads out of prompt briefs by default.
|
|
10
10
|
- Artifacts are classified as hot/warm/cold on publish; prompt briefs render hot first, warm second, and exclude cold unless explicitly queried.
|
|
11
|
+
- Aging cools unpinned artifacts from hot to warm and from warm to cold; compaction remains cleanup/removal, not a separate cooling trigger.
|
|
11
12
|
- Pruning enforces per-session record/byte caps, optional global (cross-session) record/byte caps, tier-specific caps, TTL expiry on reads, and pinned-artifact retention.
|
|
12
13
|
|
|
13
14
|
It is registered by default in the bundle, with an explicit env kill switch.
|
|
@@ -29,14 +30,30 @@ When active, the bundle registers:
|
|
|
29
30
|
- `/context export <handle>` — write full payload to a temp file without dumping it into prompt.
|
|
30
31
|
- `/context prune` — run TTL/cap pruning immediately.
|
|
31
32
|
|
|
32
|
-
The command includes autocomplete for subcommands and known artifact handles. Exact handle lookup returns clipped payload text; text search returns a smaller clipped excerpt, and truncation is marked explicitly.
|
|
33
|
+
The command includes autocomplete for subcommands and known artifact handles. Exact handle lookup returns clipped payload text; text search returns a smaller clipped excerpt, and truncation is marked explicitly. Exact-handle misses and text/filter misses use distinct messages, and `/context status` reports exact/text miss counters.
|
|
33
34
|
|
|
34
|
-
Optional durability is available with `PI_CONTEXT_BROKER_DURABLE=true` or `PI_CONTEXT_BROKER_STORE_DIR=/path/to/store`. Durable mode now defaults to SQLite (`artifacts.sqlite`) with an FTS index for text lookup, so exact handles, tier, and pin state survive restarts without replay reconstruction. Set `PI_CONTEXT_BROKER_BACKEND=jsonl` to use the legacy JSONL/blob backend.
|
|
35
|
+
Optional durability is available with `PI_CONTEXT_BROKER_DURABLE=true` or `PI_CONTEXT_BROKER_STORE_DIR=/path/to/store`. Durable mode now defaults to SQLite (`artifacts.sqlite`) with an FTS index for text lookup, so exact handles, tier, and pin state survive restarts without replay reconstruction. Set `PI_CONTEXT_BROKER_BACKEND=jsonl` to use the legacy JSONL/blob backend. Durable mode applies default global retention caps when env caps are not set: 2,048 records and 256 MiB across sessions.
|
|
35
36
|
|
|
36
37
|
- `PI_CONTEXT_BROKER_REWRITE_THRESHOLD_BYTES` controls when large `toolResult` / `bashExecution` payloads are rewritten in-context. The default is `8192` bytes, so small tool evidence remains inline while larger outputs are replaced by handles.
|
|
38
|
+
- `PI_CONTEXT_BROKER_HOT_TO_WARM_MS` controls unpinned artifact cooling from hot to warm. The default is 2 hours.
|
|
39
|
+
- `PI_CONTEXT_BROKER_WARM_TO_COLD_MS` controls unpinned artifact cooling from warm/hot to cold. The default is 12 hours.
|
|
37
40
|
|
|
38
41
|
For more aggressive prompt reduction, set `PI_CONTEXT_BROKER_REWRITE_THRESHOLD_BYTES=0`. For quieter sessions, set it to a higher value to only rewrite larger outputs.
|
|
39
42
|
|
|
43
|
+
## Tier lifecycle policy
|
|
44
|
+
|
|
45
|
+
- Publish-time classification remains deterministic: explicit `tier`, `hot`/`warm`/`cold` tags, error tags, completed/archive tags, and artifact kind choose the base tier.
|
|
46
|
+
- Cooling only retiers unpinned artifacts for prompt visibility and cap pressure; it does not delete payloads.
|
|
47
|
+
- Pinned artifacts stay hot and are retained through compaction cleanup.
|
|
48
|
+
- Cold artifacts remain retrievable by exact handle/search, but are excluded from the default prompt brief unless explicitly queried.
|
|
49
|
+
- `/compact` / `session_compact` cleanup purges unpinned artifacts for the session; cooling is age-based and separate from compaction cleanup.
|
|
50
|
+
|
|
51
|
+
## Payload display policy
|
|
52
|
+
|
|
53
|
+
- Hostile/binary payloads are unsafe or control-heavy. They are stored/exportable but omitted from prompt lookup output with `/context export` guidance.
|
|
54
|
+
- Opaque payloads are printable but low-value/high-token, such as large base64-like blobs, hex dumps, minified single-line output, or compressed-looking text. They are also stored/exportable but omitted from prompt lookup output with `/context export` guidance.
|
|
55
|
+
- Normal code, logs, and test output remain visible subject to normal byte clipping.
|
|
56
|
+
|
|
40
57
|
|
|
41
58
|
## Session behavior and limits
|
|
42
59
|
|
|
@@ -51,4 +68,5 @@ For more aggressive prompt reduction, set `PI_CONTEXT_BROKER_REWRITE_THRESHOLD_B
|
|
|
51
68
|
- Optional global caps can be configured via env vars:
|
|
52
69
|
- `PI_CONTEXT_BROKER_GLOBAL_MAX_RECORDS`
|
|
53
70
|
- `PI_CONTEXT_BROKER_GLOBAL_MAX_BYTES`
|
|
71
|
+
- Durable mode defaults to global caps of 2,048 records and 256 MiB when those env vars are unset; in-memory mode remains per-session capped unless global caps are explicitly provided.
|
|
54
72
|
- Rollback is immediate: set `PI_CONTEXT_BROKER_ENABLED=false` and `/reload` or restart Pi. Disable durable writes by unsetting `PI_CONTEXT_BROKER_DURABLE` and `PI_CONTEXT_BROKER_STORE_DIR`.
|
|
@@ -287,6 +287,57 @@ lookupBytes: 80, searchBytes: 50,
|
|
|
287
287
|
expect(commandMessage).not.toContain("\u0000");
|
|
288
288
|
});
|
|
289
289
|
|
|
290
|
+
it("omits opaque printable payloads from lookup output and suggests export", async () => {
|
|
291
|
+
const { pi, handlers, commands } = createPiMock();
|
|
292
|
+
registerContextBrokerBeta(pi, { rewriteThresholdBytes: 1 });
|
|
293
|
+
const { ctx, notifications } = createCtx();
|
|
294
|
+
const payload = "A".repeat(6000);
|
|
295
|
+
|
|
296
|
+
await runHandlers(handlers, "session_start", { type: "session_start" }, ctx);
|
|
297
|
+
await runHandlers(handlers, "tool_result", {
|
|
298
|
+
type: "tool_result",
|
|
299
|
+
toolCallId: "call-opaque",
|
|
300
|
+
toolName: "bash",
|
|
301
|
+
input: { command: "printf opaque" },
|
|
302
|
+
content: [{ type: "text", text: payload }],
|
|
303
|
+
isError: false,
|
|
304
|
+
}, ctx);
|
|
305
|
+
|
|
306
|
+
const lookupCompletion = commands.get("context").getArgumentCompletions("lookup ")?.[0];
|
|
307
|
+
const lookupHandle = lookupCompletion?.value.replace(/^lookup /, "");
|
|
308
|
+
|
|
309
|
+
await commands.get("context").handler(`lookup ${lookupHandle}`, ctx);
|
|
310
|
+
const commandMessage = notifications.at(-1)?.message ?? "";
|
|
311
|
+
expect(commandMessage).toContain("payload omitted from prompt because it appears opaque/high-token");
|
|
312
|
+
expect(commandMessage).toContain("/context export");
|
|
313
|
+
expect(commandMessage).not.toContain(payload.slice(0, 200));
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("does not classify normal multiline code as opaque", async () => {
|
|
317
|
+
const { pi, handlers, commands } = createPiMock();
|
|
318
|
+
registerContextBrokerBeta(pi, { lookupBytes: 8000, rewriteThresholdBytes: 1 });
|
|
319
|
+
const { ctx, notifications } = createCtx();
|
|
320
|
+
const payload = Array.from({ length: 240 }, (_, index) => `export function fn${index}() { return ${index}; }`).join("\n");
|
|
321
|
+
|
|
322
|
+
await runHandlers(handlers, "session_start", { type: "session_start" }, ctx);
|
|
323
|
+
await runHandlers(handlers, "tool_result", {
|
|
324
|
+
type: "tool_result",
|
|
325
|
+
toolCallId: "call-code",
|
|
326
|
+
toolName: "read",
|
|
327
|
+
input: { path: "src/generated.ts" },
|
|
328
|
+
content: [{ type: "text", text: payload }],
|
|
329
|
+
isError: false,
|
|
330
|
+
}, ctx);
|
|
331
|
+
|
|
332
|
+
const lookupCompletion = commands.get("context").getArgumentCompletions("lookup ")?.[0];
|
|
333
|
+
const lookupHandle = lookupCompletion?.value.replace(/^lookup /, "");
|
|
334
|
+
|
|
335
|
+
await commands.get("context").handler(`lookup ${lookupHandle}`, ctx);
|
|
336
|
+
const commandMessage = notifications.at(-1)?.message ?? "";
|
|
337
|
+
expect(commandMessage).toContain("export function fn0");
|
|
338
|
+
expect(commandMessage).not.toContain("payload omitted from prompt because it appears opaque/high-token");
|
|
339
|
+
});
|
|
340
|
+
|
|
290
341
|
it("text search lookup returns a smaller byte-clipped excerpt", async () => {
|
|
291
342
|
const { pi, handlers, commands } = createPiMock();
|
|
292
343
|
registerContextBrokerBeta(pi, {
|
|
@@ -360,6 +411,27 @@ lookupBytes: 500,
|
|
|
360
411
|
expect(result.content[0].text).toContain("exact evidence payload");
|
|
361
412
|
});
|
|
362
413
|
|
|
414
|
+
it("distinguishes exact-handle and text lookup misses", async () => {
|
|
415
|
+
const { pi, handlers, commands, tools } = createPiMock();
|
|
416
|
+
registerContextBrokerBeta(pi, { rewriteThresholdBytes: 1 });
|
|
417
|
+
const { ctx, notifications } = createCtx();
|
|
418
|
+
|
|
419
|
+
await runHandlers(handlers, "session_start", { type: "session_start" }, ctx);
|
|
420
|
+
const toolExactMiss = await tools.get("context_lookup").execute("lookup-missing-handle", { handle: "ctx://missing/handle" }, undefined, undefined, ctx);
|
|
421
|
+
const toolTextMiss = await tools.get("context_lookup").execute("lookup-missing-text", { text: "definitely absent" }, undefined, undefined, ctx);
|
|
422
|
+
await commands.get("context").handler("lookup ctx://missing/handle", ctx);
|
|
423
|
+
await commands.get("context").handler("lookup definitely absent", ctx);
|
|
424
|
+
await commands.get("context").handler("status", ctx);
|
|
425
|
+
|
|
426
|
+
expect(toolExactMiss.content[0].text).toContain("exact handle");
|
|
427
|
+
expect(toolTextMiss.content[0].text).toContain("text/filter query");
|
|
428
|
+
expect(notifications.at(-4)?.message).toContain("exact handle");
|
|
429
|
+
expect(notifications.at(-3)?.message).toContain("text/filter query");
|
|
430
|
+
const telemetry = notifications.at(-1)?.message ?? "";
|
|
431
|
+
expect(telemetry).toContain("exactMisses=1");
|
|
432
|
+
expect(telemetry).toContain("textMisses=1");
|
|
433
|
+
});
|
|
434
|
+
|
|
363
435
|
it("reports routing telemetry in /context status", async () => {
|
|
364
436
|
const { pi, handlers, commands, tools } = createPiMock();
|
|
365
437
|
registerContextBrokerBeta(pi, { rewriteThresholdBytes: 1, lookupBytes: 500, searchBytes: 500 });
|
|
@@ -390,6 +462,8 @@ lookupBytes: 500,
|
|
|
390
462
|
expect(handle).toBeTruthy();
|
|
391
463
|
expect(toolResult.content[0].text).toContain("telemetry_payload_");
|
|
392
464
|
expect(result).toBeDefined();
|
|
465
|
+
const statusMessage = notifications.at(-2)?.message ?? "";
|
|
466
|
+
expect(statusMessage).toContain("globalCaps=records:unbounded bytes:unbounded");
|
|
393
467
|
const telemetry = notifications.at(-1)?.message ?? "";
|
|
394
468
|
expect(telemetry).toContain("Context broker routing telemetry:");
|
|
395
469
|
expect(telemetry).toContain("rewriteSavings rawBytes=");
|
|
@@ -398,6 +472,8 @@ lookupBytes: 500,
|
|
|
398
472
|
expect(telemetry).toMatch(/savedBytes=[1-9]\d*/);
|
|
399
473
|
expect(telemetry).toContain("contextLookupHistoryOmitted=");
|
|
400
474
|
expect(telemetry).toContain("lookups tool(calls=");
|
|
475
|
+
expect(telemetry).toContain("exact=");
|
|
476
|
+
expect(telemetry).toContain("textMisses=");
|
|
401
477
|
expect(telemetry).toContain("lookups slash(calls=");
|
|
402
478
|
expect(telemetry).toContain("exports=");
|
|
403
479
|
expect(telemetry).toContain("pins=");
|
|
@@ -736,6 +812,7 @@ maxRecords: 1,
|
|
|
736
812
|
const secondHandle = second.commands.get("context").getArgumentCompletions("lookup ")?.[0].value.replace(/^lookup /, "");
|
|
737
813
|
await second.commands.get("context").handler(`lookup ${handle}`, secondRun.ctx);
|
|
738
814
|
await second.commands.get("context").handler("brief", secondRun.ctx);
|
|
815
|
+
await second.commands.get("context").handler("status", secondRun.ctx);
|
|
739
816
|
|
|
740
817
|
const third = createPiMock();
|
|
741
818
|
const thirdRun = createCtx();
|
|
@@ -744,9 +821,10 @@ maxRecords: 1,
|
|
|
744
821
|
await third.commands.get("context").handler(`lookup ${secondHandle}`, thirdRun.ctx);
|
|
745
822
|
await third.commands.get("context").handler("brief", thirdRun.ctx);
|
|
746
823
|
|
|
747
|
-
expect(secondRun.notifications.at(-
|
|
748
|
-
expect(secondRun.notifications.at(-
|
|
749
|
-
expect(secondRun.notifications.at(-
|
|
824
|
+
expect(secondRun.notifications.at(-4)?.message).toContain("durable payload");
|
|
825
|
+
expect(secondRun.notifications.at(-3)?.message).toContain("tier=hot");
|
|
826
|
+
expect(secondRun.notifications.at(-3)?.message).toContain("pinned");
|
|
827
|
+
expect(secondRun.notifications.at(-2)?.message).toContain("globalCaps=records:2048 bytes:268435456");
|
|
750
828
|
expect(thirdRun.notifications.at(-2)?.message).toContain("durable payload");
|
|
751
829
|
expect(thirdRun.notifications.at(-1)?.message).toContain("tier=hot");
|
|
752
830
|
expect(thirdRun.notifications.at(-1)?.message).toContain("pinned");
|
|
@@ -20,6 +20,8 @@ export interface ContextBrokerBetaOptions {
|
|
|
20
20
|
lookupBytes?: number;
|
|
21
21
|
searchBytes?: number;
|
|
22
22
|
rewriteThresholdBytes?: number;
|
|
23
|
+
hotToWarmMs?: number;
|
|
24
|
+
warmToColdMs?: number;
|
|
23
25
|
durable?: boolean;
|
|
24
26
|
storeDir?: string;
|
|
25
27
|
}
|
|
@@ -32,6 +34,10 @@ const DEFAULT_LOOKUP_BYTES = 12_000;
|
|
|
32
34
|
const DEFAULT_SEARCH_BYTES = 2_000;
|
|
33
35
|
const DEFAULT_REWRITE_THRESHOLD_BYTES = 8 * 1024;
|
|
34
36
|
const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
|
|
37
|
+
const DEFAULT_HOT_TO_WARM_MS = 2 * 60 * 60 * 1000;
|
|
38
|
+
const DEFAULT_WARM_TO_COLD_MS = 12 * 60 * 60 * 1000;
|
|
39
|
+
const DEFAULT_DURABLE_GLOBAL_MAX_RECORDS = 2_048;
|
|
40
|
+
const DEFAULT_DURABLE_GLOBAL_MAX_BYTES = 256 * 1024 * 1024;
|
|
35
41
|
const ENABLED_VALUES = new Set(["1", "true", "yes", "on"]);
|
|
36
42
|
|
|
37
43
|
function envFlag(name: string): boolean {
|
|
@@ -89,6 +95,28 @@ function isHostilePayload(payload: string): boolean {
|
|
|
89
95
|
return hasHostileText(payload);
|
|
90
96
|
}
|
|
91
97
|
|
|
98
|
+
function isOpaquePayload(payload: string): boolean {
|
|
99
|
+
return hasOpaqueText(payload);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function hasOpaqueText(text: string): boolean {
|
|
103
|
+
const value = String(text ?? "");
|
|
104
|
+
const bytes = Buffer.byteLength(value, "utf8");
|
|
105
|
+
if (bytes < 4096) return false;
|
|
106
|
+
|
|
107
|
+
const compacted = value.replace(/\s+/g, "");
|
|
108
|
+
if (compacted.length >= 4096 && /^[A-Za-z0-9+/=_-]+$/.test(compacted) && compacted.length / Math.max(1, value.length) > 0.85) return true;
|
|
109
|
+
if (/\b(?:[A-Fa-f0-9]{2}){2048,}\b/.test(value)) return true;
|
|
110
|
+
|
|
111
|
+
const lines = value.split(/\r?\n/);
|
|
112
|
+
const longestLine = lines.reduce((max, line) => Math.max(max, line.length), 0);
|
|
113
|
+
const whitespace = (value.match(/\s/g) ?? []).length;
|
|
114
|
+
const whitespaceRatio = whitespace / Math.max(1, value.length);
|
|
115
|
+
if (longestLine >= 4096 && whitespaceRatio < 0.03) return true;
|
|
116
|
+
if (longestLine >= 2048 && whitespaceRatio < 0.02 && /[{};:,]/.test(value)) return true;
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
|
|
92
120
|
function hasHostileText(text: string): boolean {
|
|
93
121
|
let suspicious = 0;
|
|
94
122
|
let scanned = 0;
|
|
@@ -117,12 +145,24 @@ function hasHostileValue(value: unknown): boolean {
|
|
|
117
145
|
return false;
|
|
118
146
|
}
|
|
119
147
|
|
|
148
|
+
function hasOpaqueValue(value: unknown): boolean {
|
|
149
|
+
if (typeof value === "string") return hasOpaqueText(value);
|
|
150
|
+
if (Array.isArray(value)) return value.some(hasOpaqueValue);
|
|
151
|
+
if (value && typeof value === "object") {
|
|
152
|
+
return Object.values(value as Record<string, unknown>).some((entry) => hasOpaqueValue(entry));
|
|
153
|
+
}
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
|
|
120
157
|
function renderLookupOutput(item: ContextArtifact, payloadLimit: number): string {
|
|
121
158
|
const isBinary = item.tags.includes("hostile") || item.tags.includes("binary");
|
|
122
|
-
const
|
|
159
|
+
const isOpaque = item.tags.includes("opaque");
|
|
160
|
+
const payloadLines = isBinary || isOpaque
|
|
123
161
|
? [
|
|
124
162
|
"payload:",
|
|
125
|
-
|
|
163
|
+
isOpaque && !isBinary
|
|
164
|
+
? "[payload omitted from prompt because it appears opaque/high-token; use /context export"
|
|
165
|
+
: "[payload intentionally omitted from prompt for safety; use /context export",
|
|
126
166
|
sanitizeForPrompt(item.handle),
|
|
127
167
|
"for full content]",
|
|
128
168
|
]
|
|
@@ -176,6 +216,16 @@ function compact(value: string, max = 120): string {
|
|
|
176
216
|
return truncateUtf8(value.replace(/\s+/g, " ").trim(), max);
|
|
177
217
|
}
|
|
178
218
|
|
|
219
|
+
function capText(value: number): string {
|
|
220
|
+
return Number.isFinite(value) ? String(value) : "unbounded";
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function lookupMissMessage(exact: boolean): string {
|
|
224
|
+
return exact
|
|
225
|
+
? "No context artifact matched that exact handle. The artifact may be missing, expired, pruned, or from a non-durable prior session."
|
|
226
|
+
: "No context artifacts matched that text/filter query. Try an exact ctx:// handle, narrower path/tag/kind/tier filters, or a more specific search term.";
|
|
227
|
+
}
|
|
228
|
+
|
|
179
229
|
function utf8Bytes(text: string): number {
|
|
180
230
|
return Buffer.byteLength(text, "utf8");
|
|
181
231
|
}
|
|
@@ -293,14 +343,16 @@ export async function registerContextBrokerBeta(pi: ExtensionAPI, options: Conte
|
|
|
293
343
|
options.rewriteThresholdBytes
|
|
294
344
|
?? envNonNegativeInt("PI_CONTEXT_BROKER_REWRITE_THRESHOLD_BYTES")
|
|
295
345
|
?? DEFAULT_REWRITE_THRESHOLD_BYTES;
|
|
346
|
+
const durable = options.durable ?? (envFlag("PI_CONTEXT_BROKER_DURABLE") || Boolean(options.storeDir ?? process.env.PI_CONTEXT_BROKER_STORE_DIR));
|
|
296
347
|
const brokerOptions = {
|
|
297
348
|
maxRecords: options.maxRecords ?? 64,
|
|
298
349
|
maxBytes: options.maxBytes ?? 8 * 1024 * 1024,
|
|
299
|
-
globalMaxRecords: options.globalMaxRecords ?? envNonNegativeInt("PI_CONTEXT_BROKER_GLOBAL_MAX_RECORDS"),
|
|
300
|
-
globalMaxBytes: options.globalMaxBytes ?? envNonNegativeInt("PI_CONTEXT_BROKER_GLOBAL_MAX_BYTES"),
|
|
350
|
+
globalMaxRecords: options.globalMaxRecords ?? envNonNegativeInt("PI_CONTEXT_BROKER_GLOBAL_MAX_RECORDS") ?? (durable ? DEFAULT_DURABLE_GLOBAL_MAX_RECORDS : undefined),
|
|
351
|
+
globalMaxBytes: options.globalMaxBytes ?? envNonNegativeInt("PI_CONTEXT_BROKER_GLOBAL_MAX_BYTES") ?? (durable ? DEFAULT_DURABLE_GLOBAL_MAX_BYTES : undefined),
|
|
352
|
+
hotToWarmMs: options.hotToWarmMs ?? envNonNegativeInt("PI_CONTEXT_BROKER_HOT_TO_WARM_MS") ?? DEFAULT_HOT_TO_WARM_MS,
|
|
353
|
+
warmToColdMs: options.warmToColdMs ?? envNonNegativeInt("PI_CONTEXT_BROKER_WARM_TO_COLD_MS") ?? DEFAULT_WARM_TO_COLD_MS,
|
|
301
354
|
briefBytes,
|
|
302
355
|
};
|
|
303
|
-
const durable = options.durable ?? (envFlag("PI_CONTEXT_BROKER_DURABLE") || Boolean(options.storeDir ?? process.env.PI_CONTEXT_BROKER_STORE_DIR));
|
|
304
356
|
const durableBackend = String(process.env.PI_CONTEXT_BROKER_BACKEND ?? "sqlite").trim().toLowerCase();
|
|
305
357
|
const broker = durable
|
|
306
358
|
? durableBackend === "jsonl"
|
|
@@ -331,11 +383,15 @@ export async function registerContextBrokerBeta(pi: ExtensionAPI, options: Conte
|
|
|
331
383
|
toolLookupTextCalls: 0,
|
|
332
384
|
toolLookupHits: 0,
|
|
333
385
|
toolLookupMisses: 0,
|
|
386
|
+
toolLookupExactMisses: 0,
|
|
387
|
+
toolLookupTextMisses: 0,
|
|
334
388
|
commandLookupCalls: 0,
|
|
335
389
|
commandLookupExactCalls: 0,
|
|
336
390
|
commandLookupTextCalls: 0,
|
|
337
391
|
commandLookupHits: 0,
|
|
338
392
|
commandLookupMisses: 0,
|
|
393
|
+
commandLookupExactMisses: 0,
|
|
394
|
+
commandLookupTextMisses: 0,
|
|
339
395
|
exportCalls: 0,
|
|
340
396
|
pinCalls: 0,
|
|
341
397
|
statusCalls: 0,
|
|
@@ -357,8 +413,8 @@ export async function registerContextBrokerBeta(pi: ExtensionAPI, options: Conte
|
|
|
357
413
|
`toolResults seen=${routingTelemetry.contextHookToolResults} rewritten=${routingTelemetry.contextHookToolResultRewrites} hostile=${routingTelemetry.contextHookToolResultHostile}`,
|
|
358
414
|
`bash seen=${routingTelemetry.contextHookBash} rewritten=${routingTelemetry.contextHookBashRewrites} hostile=${routingTelemetry.contextHookBashHostile}`,
|
|
359
415
|
`rewriteSavings rawBytes=${routingTelemetry.contextHookRewriteRawBytes} replacementBytes=${routingTelemetry.contextHookRewriteReplacementBytes} savedBytes=${savedBytes} savedPct=${savedPct}% contextLookupHistoryOmitted=${routingTelemetry.contextHookContextLookupHistoryOmissions}`,
|
|
360
|
-
`lookups tool(calls=${routingTelemetry.toolLookupCalls}, hits=${routingTelemetry.toolLookupHits}, misses=${routingTelemetry.toolLookupMisses})`,
|
|
361
|
-
`lookups slash(calls=${routingTelemetry.commandLookupCalls}, hits=${routingTelemetry.commandLookupHits}, misses=${routingTelemetry.commandLookupMisses})`,
|
|
416
|
+
`lookups tool(calls=${routingTelemetry.toolLookupCalls}, exact=${routingTelemetry.toolLookupExactCalls}, text=${routingTelemetry.toolLookupTextCalls}, hits=${routingTelemetry.toolLookupHits}, misses=${routingTelemetry.toolLookupMisses}, exactMisses=${routingTelemetry.toolLookupExactMisses}, textMisses=${routingTelemetry.toolLookupTextMisses})`,
|
|
417
|
+
`lookups slash(calls=${routingTelemetry.commandLookupCalls}, exact=${routingTelemetry.commandLookupExactCalls}, text=${routingTelemetry.commandLookupTextCalls}, hits=${routingTelemetry.commandLookupHits}, misses=${routingTelemetry.commandLookupMisses}, exactMisses=${routingTelemetry.commandLookupExactMisses}, textMisses=${routingTelemetry.commandLookupTextMisses})`,
|
|
362
418
|
`exports=${routingTelemetry.exportCalls}`,
|
|
363
419
|
`pins=${routingTelemetry.pinCalls}`,
|
|
364
420
|
`pruneCalls=${routingTelemetry.pruneCalls}`,
|
|
@@ -400,6 +456,7 @@ export async function registerContextBrokerBeta(pi: ExtensionAPI, options: Conte
|
|
|
400
456
|
const payload = toolPayload(sanitizedEvent);
|
|
401
457
|
const bytes = Buffer.byteLength(payload, "utf8");
|
|
402
458
|
const hostilePayload = isHostilePayload(payload) || hasHostileValue(sanitizedEvent);
|
|
459
|
+
const opaquePayload = !hostilePayload && (isOpaquePayload(payload) || hasOpaqueValue(sanitizedEvent));
|
|
403
460
|
const artifact = broker.publish({
|
|
404
461
|
sessionId: activeSessionId,
|
|
405
462
|
kind: "tool_output",
|
|
@@ -410,6 +467,7 @@ export async function registerContextBrokerBeta(pi: ExtensionAPI, options: Conte
|
|
|
410
467
|
event.isError ? "error" : "ok",
|
|
411
468
|
event.sourceId ? "session-backfill" : "live",
|
|
412
469
|
...(hostilePayload ? ["hostile", "binary"] : []),
|
|
470
|
+
...(opaquePayload ? ["opaque"] : []),
|
|
413
471
|
],
|
|
414
472
|
command: event.toolName === "bash" && typeof sanitizedEvent.input?.command === "string" ? sanitizedEvent.input.command : undefined,
|
|
415
473
|
paths: typeof sanitizedEvent.input?.path === "string" ? [sanitizedEvent.input.path] : [],
|
|
@@ -765,7 +823,9 @@ export async function registerContextBrokerBeta(pi: ExtensionAPI, options: Conte
|
|
|
765
823
|
});
|
|
766
824
|
if (!results.length) {
|
|
767
825
|
routingTelemetry.toolLookupMisses += 1;
|
|
768
|
-
|
|
826
|
+
if (exact) routingTelemetry.toolLookupExactMisses += 1;
|
|
827
|
+
else routingTelemetry.toolLookupTextMisses += 1;
|
|
828
|
+
return textResult(lookupMissMessage(exact));
|
|
769
829
|
}
|
|
770
830
|
routingTelemetry.toolLookupHits += 1;
|
|
771
831
|
return textResult(results.map((item) => renderLookupOutput(item, exact ? lookupBytes : searchBytes)).join("\n\n---\n\n"));
|
|
@@ -784,7 +844,7 @@ export async function registerContextBrokerBeta(pi: ExtensionAPI, options: Conte
|
|
|
784
844
|
routingTelemetry.statusCalls += 1;
|
|
785
845
|
const status = broker.status();
|
|
786
846
|
ctx.ui.notify(
|
|
787
|
-
`Context broker: enabled, session=${activeSessionId}, records=${status.records}, bytes=${status.bytes}/${status.maxBytes}, tiers=hot:${status.hotRecords}/${status.hotBytes} warm:${status.warmRecords}/${status.warmBytes} cold:${status.coldRecords}/${status.coldBytes}, pinned=${status.pinnedRecords}/${status.pinnedBytes} bytes`,
|
|
847
|
+
`Context broker: enabled, session=${activeSessionId}, records=${status.records}/${status.maxRecords}, bytes=${status.bytes}/${status.maxBytes}, globalCaps=records:${capText(status.globalMaxRecords)} bytes:${capText(status.globalMaxBytes)}, tiers=hot:${status.hotRecords}/${status.hotBytes} warm:${status.warmRecords}/${status.warmBytes} cold:${status.coldRecords}/${status.coldBytes}, pinned=${status.pinnedRecords}/${status.pinnedBytes} bytes`,
|
|
788
848
|
"info",
|
|
789
849
|
);
|
|
790
850
|
ctx.ui.notify(formatRoutingTelemetry(), "info");
|
|
@@ -810,8 +870,10 @@ export async function registerContextBrokerBeta(pi: ExtensionAPI, options: Conte
|
|
|
810
870
|
routingTelemetry.commandLookupHits += 1;
|
|
811
871
|
} else {
|
|
812
872
|
routingTelemetry.commandLookupMisses += 1;
|
|
873
|
+
if (exact) routingTelemetry.commandLookupExactMisses += 1;
|
|
874
|
+
else routingTelemetry.commandLookupTextMisses += 1;
|
|
813
875
|
}
|
|
814
|
-
ctx.ui.notify(results.length ? results.map((item) => renderLookupOutput(item, exact ? lookupBytes : searchBytes)).join("\n\n---\n\n") :
|
|
876
|
+
ctx.ui.notify(results.length ? results.map((item) => renderLookupOutput(item, exact ? lookupBytes : searchBytes)).join("\n\n---\n\n") : lookupMissMessage(exact), "info");
|
|
815
877
|
return;
|
|
816
878
|
}
|
|
817
879
|
|
|
@@ -193,6 +193,38 @@ describe("createInMemoryContextBroker", () => {
|
|
|
193
193
|
expect(broker.lookup({ sessionId: "s", tier: "cold" }).map((artifact) => artifact.id)).toEqual([explicit.id, archive.id]);
|
|
194
194
|
});
|
|
195
195
|
|
|
196
|
+
it("cools old artifacts across hot, warm, and cold tiers without deleting them", () => {
|
|
197
|
+
const broker = createInMemoryContextBroker({ briefBytes: 1200, defaultTtlMs: 0, hotToWarmMs: 100, warmToColdMs: 200 });
|
|
198
|
+
const now = Date.now();
|
|
199
|
+
const oldHot = broker.publish({ sessionId: "s", kind: "tool_output", payload: "old-hot", summary: "old hot", tier: "hot", createdAt: now - 300 });
|
|
200
|
+
const oldWarm = broker.publish({ sessionId: "s", kind: "tool_output", payload: "old-warm", summary: "old warm", tier: "warm", createdAt: now - 300 });
|
|
201
|
+
const freshHot = broker.publish({ sessionId: "s", kind: "tool_output", payload: "fresh-hot", summary: "fresh hot", tier: "hot", createdAt: now - 50 });
|
|
202
|
+
const pinned = broker.publish({ sessionId: "s", kind: "tool_output", payload: "pinned", summary: "pinned hot", tier: "hot", pinned: true, createdAt: now - 300 });
|
|
203
|
+
|
|
204
|
+
broker.prune(now);
|
|
205
|
+
|
|
206
|
+
expect(broker.lookup({ handle: oldHot.handle })[0]?.tier).toBe("cold");
|
|
207
|
+
expect(broker.lookup({ handle: oldWarm.handle })[0]?.tier).toBe("cold");
|
|
208
|
+
expect(broker.lookup({ handle: freshHot.handle })[0]?.tier).toBe("hot");
|
|
209
|
+
expect(broker.lookup({ handle: pinned.handle })[0]?.tier).toBe("hot");
|
|
210
|
+
const brief = broker.renderBrief({ sessionId: "s" });
|
|
211
|
+
expect(brief).not.toContain(oldHot.handle);
|
|
212
|
+
expect(brief).not.toContain(oldWarm.handle);
|
|
213
|
+
expect(brief).toContain(freshHot.handle);
|
|
214
|
+
expect(brief).toContain(pinned.handle);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("cools protected new artifacts before enforcing tier caps", () => {
|
|
218
|
+
const broker = createInMemoryContextBroker({ defaultTtlMs: 0, maxRecords: 10, hotMaxRecords: 1, hotToWarmMs: 10_000, warmToColdMs: 20_000 });
|
|
219
|
+
const now = Date.now();
|
|
220
|
+
const fresh = broker.publish({ sessionId: "s", kind: "tool_output", payload: "fresh", summary: "fresh", tier: "hot", createdAt: now - 1_000 });
|
|
221
|
+
const aged = broker.publish({ sessionId: "s", kind: "tool_output", payload: "aged", summary: "aged", tier: "hot", createdAt: now - 30_000 });
|
|
222
|
+
|
|
223
|
+
expect(aged.tier).toBe("cold");
|
|
224
|
+
expect(broker.lookup({ handle: fresh.handle })[0]?.tier).toBe("hot");
|
|
225
|
+
expect(broker.lookup({ handle: aged.handle })[0]?.tier).toBe("cold");
|
|
226
|
+
});
|
|
227
|
+
|
|
196
228
|
it("renders prompt briefs hot-first, warm-second, and excludes cold unless explicit", () => {
|
|
197
229
|
const broker = createInMemoryContextBroker({ briefBytes: 900, defaultTtlMs: 0 });
|
|
198
230
|
const cold = broker.publish({ sessionId: "s", kind: "tool_output", payload: "cold", summary: "cold archive", tier: "cold", createdAt: 1 });
|
|
@@ -32,6 +32,10 @@ const DEFAULT_BRIEF_BYTES = 2_000;
|
|
|
32
32
|
const TIER_ORDER: Record<ContextArtifactTier, number> = { hot: 0, warm: 1, cold: 2 };
|
|
33
33
|
const TIER_REMOVAL_ORDER: Record<ContextArtifactTier, number> = { cold: 0, warm: 1, hot: 2 };
|
|
34
34
|
|
|
35
|
+
function optionMs(value: number | undefined): number {
|
|
36
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : Number.POSITIVE_INFINITY;
|
|
37
|
+
}
|
|
38
|
+
|
|
35
39
|
function normalizeList(values: string[] | undefined): string[] {
|
|
36
40
|
return [...new Set((values ?? []).map((value) => String(value || "").trim()).filter(Boolean))];
|
|
37
41
|
}
|
|
@@ -149,11 +153,32 @@ export function createInMemoryContextBroker(options: ContextBrokerOptions = {}):
|
|
|
149
153
|
warm: Math.max(1, Math.floor(options.warmMaxBytes ?? maxBytes)),
|
|
150
154
|
cold: Math.max(1, Math.floor(options.coldMaxBytes ?? maxBytes)),
|
|
151
155
|
};
|
|
156
|
+
const hotToWarmMs = optionMs(options.hotToWarmMs);
|
|
157
|
+
const warmToColdMs = optionMs(options.warmToColdMs);
|
|
152
158
|
const summaryBytes = Math.max(16, Math.floor(options.summaryBytes ?? DEFAULT_SUMMARY_BYTES));
|
|
153
159
|
const defaultBriefBytes = Math.max(64, Math.floor(options.briefBytes ?? DEFAULT_BRIEF_BYTES));
|
|
154
160
|
let artifacts: Array<ContextArtifact & { sequence: number; baseTier: ContextArtifactTier }> = [];
|
|
155
161
|
let sequence = 0;
|
|
156
162
|
|
|
163
|
+
function cooledTier(artifact: ContextArtifact & { baseTier: ContextArtifactTier }, now = Date.now()): ContextArtifactTier {
|
|
164
|
+
if (artifact.pinned) return "hot";
|
|
165
|
+
if (artifact.baseTier === "cold") return "cold";
|
|
166
|
+
const age = Math.max(0, now - artifact.createdAt);
|
|
167
|
+
if (age >= warmToColdMs) return "cold";
|
|
168
|
+
if (artifact.baseTier === "hot" && age >= hotToWarmMs) return "warm";
|
|
169
|
+
return artifact.baseTier;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function applyCooling(now = Date.now(), _protectedIds = new Set<string>()): void {
|
|
173
|
+
for (const artifact of artifacts) {
|
|
174
|
+
const nextTier = cooledTier(artifact, now);
|
|
175
|
+
if (artifact.tier !== nextTier) {
|
|
176
|
+
artifact.tier = nextTier;
|
|
177
|
+
artifact.updatedAt = now;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
157
182
|
function currentStatus(): ContextBrokerStatus {
|
|
158
183
|
const bytes = artifacts.reduce((sum, artifact) => sum + artifact.bytes, 0);
|
|
159
184
|
const pinned = artifacts.filter((artifact) => artifact.pinned);
|
|
@@ -174,6 +199,8 @@ export function createInMemoryContextBroker(options: ContextBrokerOptions = {}):
|
|
|
174
199
|
coldBytes: cold.reduce((sum, artifact) => sum + artifact.bytes, 0),
|
|
175
200
|
maxRecords,
|
|
176
201
|
maxBytes,
|
|
202
|
+
globalMaxRecords,
|
|
203
|
+
globalMaxBytes,
|
|
177
204
|
};
|
|
178
205
|
}
|
|
179
206
|
|
|
@@ -225,6 +252,7 @@ export function createInMemoryContextBroker(options: ContextBrokerOptions = {}):
|
|
|
225
252
|
|
|
226
253
|
function prune(now = Date.now(), protectedIds = new Set<string>()): ContextBrokerStatus {
|
|
227
254
|
dropExpired(now, protectedIds);
|
|
255
|
+
applyCooling(now, protectedIds);
|
|
228
256
|
|
|
229
257
|
for (const sessionId of new Set(artifacts.map((artifact) => artifact.sessionId))) {
|
|
230
258
|
for (const tier of ["cold", "warm", "hot"] as ContextArtifactTier[]) {
|
|
@@ -253,11 +281,13 @@ export function createInMemoryContextBroker(options: ContextBrokerOptions = {}):
|
|
|
253
281
|
|
|
254
282
|
function status(): ContextBrokerStatus {
|
|
255
283
|
dropExpired();
|
|
284
|
+
applyCooling();
|
|
256
285
|
return currentStatus();
|
|
257
286
|
}
|
|
258
287
|
|
|
259
288
|
function purge(options: ContextPurgeOptions = {}): ContextBrokerStatus {
|
|
260
289
|
dropExpired();
|
|
290
|
+
applyCooling();
|
|
261
291
|
const keepPinned = options.keepPinned ?? true;
|
|
262
292
|
artifacts = artifacts.filter((artifact) => {
|
|
263
293
|
if (options.sessionId && artifact.sessionId !== options.sessionId) return true;
|
|
@@ -305,12 +335,13 @@ export function createInMemoryContextBroker(options: ContextBrokerOptions = {}):
|
|
|
305
335
|
};
|
|
306
336
|
|
|
307
337
|
artifacts = [artifact, ...artifacts];
|
|
308
|
-
prune(now, new Set([artifact.id]));
|
|
338
|
+
prune(Date.now(), new Set([artifact.id]));
|
|
309
339
|
return artifact;
|
|
310
340
|
}
|
|
311
341
|
|
|
312
342
|
function lookup(query: ContextLookupQuery = {}): ContextArtifact[] {
|
|
313
343
|
dropExpired();
|
|
344
|
+
applyCooling();
|
|
314
345
|
const limit = Math.max(1, Math.floor(query.limit ?? (artifacts.length || 1)));
|
|
315
346
|
return artifacts
|
|
316
347
|
.filter((artifact) => artifactMatches(artifact, query))
|
|
@@ -40,6 +40,43 @@ describe("createSqliteContextBroker", () => {
|
|
|
40
40
|
}
|
|
41
41
|
});
|
|
42
42
|
|
|
43
|
+
it("persists age-based tier cooling without deleting artifacts", () => {
|
|
44
|
+
const dir = mkdtempSync(join(tmpdir(), "ctx-sqlite-test-"));
|
|
45
|
+
try {
|
|
46
|
+
const path = join(dir, "artifacts.sqlite");
|
|
47
|
+
let broker = createSqliteContextBroker({ path, defaultTtlMs: 0, hotToWarmMs: 100, warmToColdMs: 200, briefBytes: 900 });
|
|
48
|
+
const now = Date.now();
|
|
49
|
+
const oldHot = broker.publish({ sessionId: "s", kind: "tool_output", payload: "old hot", summary: "old hot", tier: "hot", createdAt: now - 300 });
|
|
50
|
+
const pinned = broker.publish({ sessionId: "s", kind: "tool_output", payload: "pinned", summary: "pinned", tier: "hot", pinned: true, createdAt: now - 300 });
|
|
51
|
+
|
|
52
|
+
broker.prune(now);
|
|
53
|
+
broker = createSqliteContextBroker({ path, defaultTtlMs: 0, hotToWarmMs: 100, warmToColdMs: 200, briefBytes: 900 });
|
|
54
|
+
|
|
55
|
+
expect(broker.lookup({ handle: oldHot.handle })[0]?.tier).toBe("cold");
|
|
56
|
+
expect(broker.lookup({ handle: pinned.handle })[0]?.tier).toBe("hot");
|
|
57
|
+
expect(broker.renderBrief({ sessionId: "s" })).not.toContain(oldHot.handle);
|
|
58
|
+
expect(broker.renderBrief({ sessionId: "s" })).toContain(pinned.handle);
|
|
59
|
+
} finally {
|
|
60
|
+
rmSync(dir, { recursive: true, force: true });
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("cools protected new artifacts before enforcing durable tier caps", () => {
|
|
65
|
+
const dir = mkdtempSync(join(tmpdir(), "ctx-sqlite-test-"));
|
|
66
|
+
try {
|
|
67
|
+
const now = Date.now();
|
|
68
|
+
const broker = createSqliteContextBroker({ path: join(dir, "artifacts.sqlite"), defaultTtlMs: 0, maxRecords: 10, hotMaxRecords: 1, hotToWarmMs: 10_000, warmToColdMs: 20_000 });
|
|
69
|
+
const fresh = broker.publish({ sessionId: "s", kind: "tool_output", payload: "fresh", summary: "fresh", tier: "hot", createdAt: now - 1_000 });
|
|
70
|
+
const aged = broker.publish({ sessionId: "s", kind: "tool_output", payload: "aged", summary: "aged", tier: "hot", createdAt: now - 30_000 });
|
|
71
|
+
|
|
72
|
+
expect(aged.tier).toBe("cold");
|
|
73
|
+
expect(broker.lookup({ handle: fresh.handle })[0]?.tier).toBe("hot");
|
|
74
|
+
expect(broker.lookup({ handle: aged.handle })[0]?.tier).toBe("cold");
|
|
75
|
+
} finally {
|
|
76
|
+
rmSync(dir, { recursive: true, force: true });
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
43
80
|
it("dedupes replayed source artifacts so durable handles survive caps", () => {
|
|
44
81
|
const dir = mkdtempSync(join(tmpdir(), "ctx-sqlite-test-"));
|
|
45
82
|
try {
|
|
@@ -29,6 +29,10 @@ const DEFAULT_BRIEF_BYTES = 2_000;
|
|
|
29
29
|
const TIER_ORDER: Record<ContextArtifactTier, number> = { hot: 0, warm: 1, cold: 2 };
|
|
30
30
|
const TIER_REMOVAL_ORDER: Record<ContextArtifactTier, number> = { cold: 0, warm: 1, hot: 2 };
|
|
31
31
|
|
|
32
|
+
function optionMs(value: number | undefined): number {
|
|
33
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : Number.POSITIVE_INFINITY;
|
|
34
|
+
}
|
|
35
|
+
|
|
32
36
|
function defaultStoreDir(): string {
|
|
33
37
|
return join(homedir(), ".pi", "agent", "fiale-plus", "context-broker");
|
|
34
38
|
}
|
|
@@ -226,6 +230,8 @@ export function createSqliteContextBroker(options: SqliteContextBrokerOptions =
|
|
|
226
230
|
warm: Math.max(1, Math.floor(options.warmMaxBytes ?? maxBytes)),
|
|
227
231
|
cold: Math.max(1, Math.floor(options.coldMaxBytes ?? maxBytes)),
|
|
228
232
|
};
|
|
233
|
+
const hotToWarmMs = optionMs(options.hotToWarmMs);
|
|
234
|
+
const warmToColdMs = optionMs(options.warmToColdMs);
|
|
229
235
|
const summaryBytes = Math.max(16, Math.floor(options.summaryBytes ?? DEFAULT_SUMMARY_BYTES));
|
|
230
236
|
const defaultBriefBytes = Math.max(64, Math.floor(options.briefBytes ?? DEFAULT_BRIEF_BYTES));
|
|
231
237
|
|
|
@@ -241,6 +247,31 @@ export function createSqliteContextBroker(options: SqliteContextBrokerOptions =
|
|
|
241
247
|
db.prepare("DELETE FROM artifacts WHERE id = ?").run(id);
|
|
242
248
|
}
|
|
243
249
|
|
|
250
|
+
function cooledTier(artifact: ContextArtifact & { baseTier: ContextArtifactTier }, now = Date.now()): ContextArtifactTier {
|
|
251
|
+
if (artifact.pinned) return "hot";
|
|
252
|
+
if (artifact.baseTier === "cold") return "cold";
|
|
253
|
+
const age = Math.max(0, now - artifact.createdAt);
|
|
254
|
+
if (age >= warmToColdMs) return "cold";
|
|
255
|
+
if (artifact.baseTier === "hot" && age >= hotToWarmMs) return "warm";
|
|
256
|
+
return artifact.baseTier;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function applyCooling(now = Date.now(), _protectedIds = new Set<string>()): void {
|
|
260
|
+
const rows = db.prepare("SELECT id, createdAt, tier, baseTier, pinned FROM artifacts WHERE pinned = 0").all();
|
|
261
|
+
const update = db.prepare("UPDATE artifacts SET tier = ?, updatedAt = ? WHERE id = ?");
|
|
262
|
+
for (const row of rows) {
|
|
263
|
+
const artifact = {
|
|
264
|
+
id: String(row.id),
|
|
265
|
+
createdAt: Number(row.createdAt),
|
|
266
|
+
tier: String(row.tier) as ContextArtifactTier,
|
|
267
|
+
baseTier: String(row.baseTier ?? row.tier) as ContextArtifactTier,
|
|
268
|
+
pinned: Boolean(row.pinned),
|
|
269
|
+
};
|
|
270
|
+
const nextTier = cooledTier(artifact as ContextArtifact & { baseTier: ContextArtifactTier }, now);
|
|
271
|
+
if (artifact.tier !== nextTier) update.run(nextTier, now, artifact.id);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
244
275
|
function currentStatus(): ContextBrokerStatus {
|
|
245
276
|
const row = db.prepare(`
|
|
246
277
|
SELECT
|
|
@@ -269,6 +300,8 @@ export function createSqliteContextBroker(options: SqliteContextBrokerOptions =
|
|
|
269
300
|
coldBytes: Number(row.coldBytes ?? 0),
|
|
270
301
|
maxRecords,
|
|
271
302
|
maxBytes,
|
|
303
|
+
globalMaxRecords,
|
|
304
|
+
globalMaxBytes,
|
|
272
305
|
};
|
|
273
306
|
}
|
|
274
307
|
|
|
@@ -329,6 +362,7 @@ export function createSqliteContextBroker(options: SqliteContextBrokerOptions =
|
|
|
329
362
|
|
|
330
363
|
function prune(now = Date.now(), protectedIds = new Set<string>()): ContextBrokerStatus {
|
|
331
364
|
dropExpired(now, protectedIds);
|
|
365
|
+
applyCooling(now, protectedIds);
|
|
332
366
|
const sessions = db.prepare("SELECT DISTINCT sessionId FROM artifacts").all().map((row) => String(row.sessionId));
|
|
333
367
|
for (const sessionId of sessions) {
|
|
334
368
|
for (const tier of ["cold", "warm", "hot"] as ContextArtifactTier[]) {
|
|
@@ -356,11 +390,13 @@ export function createSqliteContextBroker(options: SqliteContextBrokerOptions =
|
|
|
356
390
|
|
|
357
391
|
function status(): ContextBrokerStatus {
|
|
358
392
|
dropExpired();
|
|
393
|
+
applyCooling();
|
|
359
394
|
return currentStatus();
|
|
360
395
|
}
|
|
361
396
|
|
|
362
397
|
function purge(options: ContextPurgeOptions = {}): ContextBrokerStatus {
|
|
363
398
|
dropExpired();
|
|
399
|
+
applyCooling();
|
|
364
400
|
const keepPinned = options.keepPinned ?? true;
|
|
365
401
|
const clauses: string[] = [];
|
|
366
402
|
const params: Array<string | number> = [];
|
|
@@ -468,12 +504,13 @@ export function createSqliteContextBroker(options: SqliteContextBrokerOptions =
|
|
|
468
504
|
throw error;
|
|
469
505
|
}
|
|
470
506
|
|
|
471
|
-
prune(now, new Set([artifact.id]));
|
|
472
|
-
return artifact;
|
|
507
|
+
prune(Date.now(), new Set([artifact.id]));
|
|
508
|
+
return lookup({ id: artifact.id })[0] ?? artifact;
|
|
473
509
|
}
|
|
474
510
|
|
|
475
511
|
function lookup(query: ContextLookupQuery = {}): ContextArtifact[] {
|
|
476
512
|
dropExpired();
|
|
513
|
+
applyCooling();
|
|
477
514
|
const storedCount = Number(db.prepare("SELECT COUNT(*) AS count FROM artifacts").get()?.count ?? 1) || 1;
|
|
478
515
|
const limit = Math.max(1, Math.floor(query.limit ?? storedCount));
|
|
479
516
|
const clauses: string[] = [];
|
|
@@ -25,6 +25,8 @@ npm run router:shadow -- --checkpoint-file .pi/router/checkpoints.jsonl --ledger
|
|
|
25
25
|
|
|
26
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
27
|
|
|
28
|
+
Live config is repo-global at `.pi/router/config.json`, while mutable live state and route ledgers are isolated per Pi session under `.pi/router/sessions/<session-key>/state.json` and `events.jsonl`.
|
|
29
|
+
|
|
28
30
|
- 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
31
|
- Error fingerprints normalize paths, line numbers, timestamps, UUIDs, ports, and object ids before hashing.
|
|
30
32
|
- `router:teacher-requests` writes local JSONL requests for an explicit teacher model; imported teacher decisions are still required before labels become training truth.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { closeSync, mkdirSync, openSync, writeSync } from "node:fs";
|
|
2
2
|
import { dirname, resolve } from "node:path";
|
|
3
3
|
import { hashMaybe, hashText, normalizeText } from "./hash.js";
|
|
4
|
+
import { routerSessionKey } from "./config.js";
|
|
4
5
|
import { diffChurnScore, EMPTY_DIFF_STATS, readGitDiffStats } from "./git-features.js";
|
|
5
6
|
import { touchedFileHashesFromEvent } from "./progress.js";
|
|
6
7
|
import { readPiSession, sessionIdFromPath, streamPiSessionEvents, type PiSession, type RawPiSessionEvent } from "./session-reader.js";
|
|
@@ -321,7 +322,14 @@ export async function writeSessionCheckpointsJsonl(sessionPaths: string[], outpu
|
|
|
321
322
|
? (() => {
|
|
322
323
|
const session = readPiSession(sessionPaths[0]);
|
|
323
324
|
const routerDir = session.cwd ? resolve(session.cwd, ".pi", "router") : undefined;
|
|
324
|
-
const
|
|
325
|
+
const routerSessionDir = routerDir ? resolve(routerDir, "sessions", routerSessionKey(session.path)) : undefined;
|
|
326
|
+
const routerArtifacts = routerDir ? [
|
|
327
|
+
routerDir,
|
|
328
|
+
resolve(routerDir, "config.json"),
|
|
329
|
+
resolve(routerDir, "state.json"),
|
|
330
|
+
resolve(routerDir, "events.jsonl"),
|
|
331
|
+
...(routerSessionDir ? [resolve(routerSessionDir, "state.json"), resolve(routerSessionDir, "events.jsonl")] : []),
|
|
332
|
+
] : [];
|
|
325
333
|
return applyWorkspaceDiffToLatest(buildCheckpoints(session), session.cwd, [session.path, resolved, ...routerArtifacts]);
|
|
326
334
|
})()
|
|
327
335
|
: null;
|
|
@@ -1,20 +1,21 @@
|
|
|
1
|
-
import { mkdtempSync, readFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { describe, expect, it } from "vitest";
|
|
5
5
|
import { routerArgumentCompletions } from "./completions.js";
|
|
6
|
-
import { activeProfile, cycleRouterProfile, ensureRouterConfig, loadRouterConfig, routerConfigPath, setRouterProfile } from "./config.js";
|
|
6
|
+
import { activeProfile, cycleRouterProfile, ensureRouterConfig, loadRouterConfig, routerConfigPath, routerEventsPath, routerSessionDir, routerStatePath, saveRouterConfig, setRouterProfile } from "./config.js";
|
|
7
7
|
import { registerRouter } from "./extension.js";
|
|
8
8
|
import { decideRoute } from "./decision.js";
|
|
9
|
-
import { summarizeRouterDecision } from "./observe.js";
|
|
9
|
+
import { observeRouterTurn, summarizeRouterDecision } from "./observe.js";
|
|
10
10
|
import type { RouterCheckpoint } from "./types.js";
|
|
11
11
|
|
|
12
|
-
function ctxMock() {
|
|
12
|
+
function ctxMock(sessionPath?: string) {
|
|
13
13
|
const cwd = mkdtempSync(join(tmpdir(), "pi-router-ext-"));
|
|
14
14
|
const notifications: Array<{ text: string; level: string }> = [];
|
|
15
15
|
return {
|
|
16
16
|
cwd,
|
|
17
17
|
notifications,
|
|
18
|
+
sessionManager: sessionPath ? { getSessionFile: () => sessionPath } : undefined,
|
|
18
19
|
ui: {
|
|
19
20
|
notify(text: string, level: string) {
|
|
20
21
|
notifications.push({ text, level });
|
|
@@ -23,6 +24,15 @@ function ctxMock() {
|
|
|
23
24
|
};
|
|
24
25
|
}
|
|
25
26
|
|
|
27
|
+
function writeSessionFixture(dir: string, name: string): string {
|
|
28
|
+
const path = join(dir, name);
|
|
29
|
+
writeFileSync(path, [
|
|
30
|
+
JSON.stringify({ type: "session", id: name, cwd: dir }),
|
|
31
|
+
JSON.stringify({ type: "message", id: `${name}-user`, message: { role: "user", content: [{ type: "text", text: "please implement a small fix" }] } }),
|
|
32
|
+
].join("\n") + "\n");
|
|
33
|
+
return path;
|
|
34
|
+
}
|
|
35
|
+
|
|
26
36
|
function piMock() {
|
|
27
37
|
const commands = new Map<string, any>();
|
|
28
38
|
const shortcuts = new Map<string, any>();
|
|
@@ -98,6 +108,28 @@ describe("router config profiles", () => {
|
|
|
98
108
|
expect(routerArgumentCompletions("")?.map((item) => item.value)).toEqual(expect.arrayContaining(["on", "off", "status", "profile"]));
|
|
99
109
|
expect(routerArgumentCompletions("profile s")?.map((item) => item.value)).toEqual(["profile spark-smart"]);
|
|
100
110
|
});
|
|
111
|
+
|
|
112
|
+
it("keeps config repo-global while state and live events are session-scoped", async () => {
|
|
113
|
+
const cwd = mkdtempSync(join(tmpdir(), "pi-router-sessions-"));
|
|
114
|
+
const firstSession = writeSessionFixture(cwd, "session-a.jsonl");
|
|
115
|
+
const secondSession = writeSessionFixture(cwd, "session-b.jsonl");
|
|
116
|
+
const firstCtx = { ...ctxMock(firstSession), cwd };
|
|
117
|
+
const secondCtx = { ...ctxMock(secondSession), cwd };
|
|
118
|
+
saveRouterConfig(firstCtx, { ...loadRouterConfig(firstCtx), enabled: true, print: "all" });
|
|
119
|
+
|
|
120
|
+
expect(routerConfigPath(firstCtx)).toBe(routerConfigPath(secondCtx));
|
|
121
|
+
expect(routerStatePath(firstCtx, firstSession)).not.toBe(routerStatePath(secondCtx, secondSession));
|
|
122
|
+
expect(routerEventsPath(firstCtx, firstSession)).not.toBe(routerEventsPath(secondCtx, secondSession));
|
|
123
|
+
expect(routerSessionDir(firstCtx, firstSession)).toContain("session-a");
|
|
124
|
+
|
|
125
|
+
await observeRouterTurn(firstCtx);
|
|
126
|
+
await observeRouterTurn(secondCtx);
|
|
127
|
+
|
|
128
|
+
expect(existsSync(routerStatePath(firstCtx, firstSession))).toBe(true);
|
|
129
|
+
expect(existsSync(routerStatePath(secondCtx, secondSession))).toBe(true);
|
|
130
|
+
expect(readFileSync(routerEventsPath(firstCtx, firstSession), "utf8").trim().split("\n")).toHaveLength(1);
|
|
131
|
+
expect(readFileSync(routerEventsPath(secondCtx, secondSession), "utf8").trim().split("\n")).toHaveLength(1);
|
|
132
|
+
});
|
|
101
133
|
});
|
|
102
134
|
|
|
103
135
|
describe("router extension", () => {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { dirname, join, resolve } from "node:path";
|
|
2
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
3
|
+
import { hashText } from "./hash.js";
|
|
3
4
|
|
|
4
5
|
export type RouterMode = "observe";
|
|
5
6
|
export type RouterPrintMode = "all" | "mismatch_only" | "off";
|
|
@@ -83,12 +84,36 @@ export function routerConfigPath(ctx: any): string {
|
|
|
83
84
|
return join(routerDir(ctx), "config.json");
|
|
84
85
|
}
|
|
85
86
|
|
|
86
|
-
|
|
87
|
-
|
|
87
|
+
function sessionPathFromCtx(ctx: any): string | undefined {
|
|
88
|
+
const value = ctx?.sessionManager?.getSessionFile?.();
|
|
89
|
+
return value ? String(value) : undefined;
|
|
88
90
|
}
|
|
89
91
|
|
|
90
|
-
|
|
91
|
-
return
|
|
92
|
+
function safeSegment(value: string): string {
|
|
93
|
+
return value.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 96) || "session";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function routerSessionKey(sessionPath: string): string {
|
|
97
|
+
const resolved = resolve(sessionPath);
|
|
98
|
+
const name = safeSegment(basename(resolved).replace(/\.jsonl$/i, ""));
|
|
99
|
+
return `${name}-${hashText(resolved).slice(0, 8)}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function routerSessionsDir(ctx: any): string {
|
|
103
|
+
return join(routerDir(ctx), "sessions");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function routerSessionDir(ctx: any, sessionPath = sessionPathFromCtx(ctx)): string {
|
|
107
|
+
const key = sessionPath ? routerSessionKey(sessionPath) : "no-session";
|
|
108
|
+
return join(routerSessionsDir(ctx), key);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function routerStatePath(ctx: any, sessionPath = sessionPathFromCtx(ctx)): string {
|
|
112
|
+
return join(routerSessionDir(ctx, sessionPath), "state.json");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function routerEventsPath(ctx: any, sessionPath = sessionPathFromCtx(ctx)): string {
|
|
116
|
+
return join(routerSessionDir(ctx, sessionPath), "events.jsonl");
|
|
92
117
|
}
|
|
93
118
|
|
|
94
119
|
function readJson<T>(path: string, fallback: T): T {
|
|
@@ -135,12 +160,12 @@ export function ensureRouterConfig(ctx: any): RouterConfig {
|
|
|
135
160
|
return config;
|
|
136
161
|
}
|
|
137
162
|
|
|
138
|
-
export function loadRouterState(ctx: any): RouterState {
|
|
139
|
-
return readJson<RouterState>(routerStatePath(ctx), {});
|
|
163
|
+
export function loadRouterState(ctx: any, sessionPath?: string): RouterState {
|
|
164
|
+
return readJson<RouterState>(routerStatePath(ctx, sessionPath), {});
|
|
140
165
|
}
|
|
141
166
|
|
|
142
|
-
export function saveRouterState(ctx: any, state: RouterState): void {
|
|
143
|
-
const path = routerStatePath(ctx);
|
|
167
|
+
export function saveRouterState(ctx: any, state: RouterState, sessionPath?: string): void {
|
|
168
|
+
const path = routerStatePath(ctx, sessionPath);
|
|
144
169
|
mkdirSync(dirname(path), { recursive: true });
|
|
145
170
|
writeFileSync(path, `${JSON.stringify(state, null, 2)}\n`);
|
|
146
171
|
}
|
|
@@ -16,7 +16,16 @@ function git(cwd: string, args: string[]): string {
|
|
|
16
16
|
return execFileSync("git", args, { cwd: resolve(cwd), encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
interface GitExcludes {
|
|
20
|
+
files: Set<string>;
|
|
21
|
+
prefixes: string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function isExcluded(file: string, excludes: GitExcludes): boolean {
|
|
25
|
+
return excludes.files.has(file) || excludes.prefixes.some((prefix) => file.startsWith(prefix));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function parseNumstat(output: string, excludes: GitExcludes = { files: new Set(), prefixes: [] }): Pick<DiffStats, "filesChanged" | "linesAdded" | "linesDeleted" | "totalLines" | "fileHashes"> {
|
|
20
29
|
let rows = 0;
|
|
21
30
|
let linesAdded = 0;
|
|
22
31
|
let linesDeleted = 0;
|
|
@@ -26,7 +35,7 @@ function parseNumstat(output: string, excludeFiles = new Set<string>()): Pick<Di
|
|
|
26
35
|
if (!line.trim()) continue;
|
|
27
36
|
const [added, deleted, ...fileParts] = line.split("\t");
|
|
28
37
|
const file = fileParts.join("\t").trim();
|
|
29
|
-
if (file &&
|
|
38
|
+
if (file && isExcluded(file, excludes)) continue;
|
|
30
39
|
rows++;
|
|
31
40
|
if (file) fileHashes.add(hashText(file));
|
|
32
41
|
const add = Number(added);
|
|
@@ -38,13 +47,13 @@ function parseNumstat(output: string, excludeFiles = new Set<string>()): Pick<Di
|
|
|
38
47
|
return { filesChanged: fileHashes.size || rows, linesAdded, linesDeleted, totalLines: linesAdded + linesDeleted, fileHashes: [...fileHashes].sort() };
|
|
39
48
|
}
|
|
40
49
|
|
|
41
|
-
function untrackedFiles(cwd: string,
|
|
50
|
+
function untrackedFiles(cwd: string, excludes: GitExcludes = { files: new Set(), prefixes: [] }): { hashes: string[]; linesAdded: number } {
|
|
42
51
|
try {
|
|
43
52
|
let linesAdded = 0;
|
|
44
53
|
const hashes: string[] = [];
|
|
45
54
|
for (const raw of git(cwd, ["ls-files", "--others", "--exclude-standard"]).split("\n")) {
|
|
46
55
|
const file = raw.trim();
|
|
47
|
-
if (!file ||
|
|
56
|
+
if (!file || isExcluded(file, excludes)) continue;
|
|
48
57
|
hashes.push(hashText(file));
|
|
49
58
|
try {
|
|
50
59
|
const path = resolve(cwd, file);
|
|
@@ -63,39 +72,45 @@ function untrackedFiles(cwd: string, excludeFiles = new Set<string>()): { hashes
|
|
|
63
72
|
}
|
|
64
73
|
}
|
|
65
74
|
|
|
66
|
-
function excludeFilesFromPaths(root: string, paths: string[] | undefined):
|
|
75
|
+
function excludeFilesFromPaths(root: string, paths: string[] | undefined): GitExcludes {
|
|
67
76
|
const files = new Set<string>();
|
|
77
|
+
const prefixes: string[] = [];
|
|
68
78
|
const realRoot = realpathSync(root);
|
|
69
79
|
for (const path of paths ?? []) {
|
|
70
80
|
const absolute = isAbsolute(path) ? path : resolve(root, path);
|
|
71
81
|
let rel = relative(root, absolute);
|
|
82
|
+
let isDirectory = false;
|
|
72
83
|
try {
|
|
73
|
-
|
|
84
|
+
const realAbsolute = realpathSync(absolute);
|
|
85
|
+
rel = relative(realRoot, realAbsolute);
|
|
86
|
+
isDirectory = statSync(realAbsolute).isDirectory();
|
|
74
87
|
} catch {
|
|
75
88
|
// Output paths may not exist yet; fall back to lexical repo-relative path.
|
|
76
89
|
}
|
|
77
|
-
if (rel
|
|
90
|
+
if (!rel || rel.startsWith("..")) continue;
|
|
91
|
+
if (isDirectory) prefixes.push(rel.endsWith("/") ? rel : `${rel}/`);
|
|
92
|
+
else files.add(rel);
|
|
78
93
|
}
|
|
79
|
-
return files;
|
|
94
|
+
return { files, prefixes };
|
|
80
95
|
}
|
|
81
96
|
|
|
82
97
|
export function readGitDiffStats(cwd?: string, options: { excludePaths?: string[] } = {}): DiffStats {
|
|
83
98
|
if (!cwd) return EMPTY_DIFF_STATS;
|
|
84
99
|
try {
|
|
85
100
|
const root = git(cwd, ["rev-parse", "--show-toplevel"]).trim() || cwd;
|
|
86
|
-
const
|
|
87
|
-
const untracked = untrackedFiles(root,
|
|
101
|
+
const excludes = excludeFilesFromPaths(root, options.excludePaths);
|
|
102
|
+
const untracked = untrackedFiles(root, excludes);
|
|
88
103
|
let parsed: Pick<DiffStats, "filesChanged" | "linesAdded" | "linesDeleted" | "totalLines" | "fileHashes"> = EMPTY_DIFF_STATS;
|
|
89
104
|
let shortStat = "";
|
|
90
105
|
try {
|
|
91
|
-
parsed = parseNumstat(git(root, ["diff", "--numstat", "HEAD"]),
|
|
106
|
+
parsed = parseNumstat(git(root, ["diff", "--numstat", "HEAD"]), excludes);
|
|
92
107
|
shortStat = git(root, ["diff", "--shortstat", "HEAD"]).trim();
|
|
93
108
|
} catch {
|
|
94
109
|
// Repositories without an initial commit have no HEAD; include staged files plus untracked counts.
|
|
95
110
|
try {
|
|
96
111
|
const cachedNumstat = git(root, ["diff", "--cached", "--numstat"]);
|
|
97
112
|
const worktreeNumstat = git(root, ["diff", "--numstat"]);
|
|
98
|
-
parsed = parseNumstat(`${cachedNumstat}\n${worktreeNumstat}`,
|
|
113
|
+
parsed = parseNumstat(`${cachedNumstat}\n${worktreeNumstat}`, excludes);
|
|
99
114
|
shortStat = `${git(root, ["diff", "--cached", "--shortstat"]).trim()} ${git(root, ["diff", "--shortstat"]).trim()}`.trim();
|
|
100
115
|
} catch {
|
|
101
116
|
// Still report untracked-file counts/hashes below.
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
loadRouterConfig,
|
|
7
7
|
loadRouterState,
|
|
8
8
|
routerConfigPath,
|
|
9
|
+
routerDir,
|
|
9
10
|
routerEventsPath,
|
|
10
11
|
routerStatePath,
|
|
11
12
|
saveRouterState,
|
|
@@ -100,18 +101,24 @@ export async function observeRouterTurn(ctx: any): Promise<RouterObserveSummary
|
|
|
100
101
|
if (!sessionPath) return null;
|
|
101
102
|
const checkpoint = await latestCheckpointFromSession(String(sessionPath));
|
|
102
103
|
if (!checkpoint) return null;
|
|
103
|
-
const state = loadRouterState(ctx);
|
|
104
|
+
const state = loadRouterState(ctx, String(sessionPath));
|
|
104
105
|
if (state.lastObservedCheckpointId === checkpoint.checkpointId) return null;
|
|
105
106
|
|
|
106
|
-
const liveCheckpoint = checkpointWithDiffStats(checkpoint, ctx?.cwd, [
|
|
107
|
+
const liveCheckpoint = checkpointWithDiffStats(checkpoint, ctx?.cwd, [
|
|
108
|
+
String(sessionPath),
|
|
109
|
+
routerConfigPath(ctx),
|
|
110
|
+
routerDir(ctx),
|
|
111
|
+
routerStatePath(ctx, String(sessionPath)),
|
|
112
|
+
routerEventsPath(ctx, String(sessionPath)),
|
|
113
|
+
]);
|
|
107
114
|
const decision = decideRoute(liveCheckpoint);
|
|
108
115
|
const summary = summarizeRouterDecision(liveCheckpoint, decision, config);
|
|
109
|
-
appendRouteEvent(routerEventsPath(ctx), buildRouteEvent(liveCheckpoint, decision));
|
|
116
|
+
appendRouteEvent(routerEventsPath(ctx, String(sessionPath)), buildRouteEvent(liveCheckpoint, decision));
|
|
110
117
|
saveRouterState(ctx, {
|
|
111
118
|
lastObservedCheckpointId: checkpoint.checkpointId,
|
|
112
119
|
lastDecisionAction: decision.action,
|
|
113
120
|
lastSummary: summary.text,
|
|
114
|
-
});
|
|
121
|
+
}, String(sessionPath));
|
|
115
122
|
|
|
116
123
|
if (config.print === "mismatch_only" && summary.match !== false) return summary;
|
|
117
124
|
ctx.ui?.notify?.(summary.text, summary.match === false ? "warning" : "info");
|
|
@@ -91,14 +91,18 @@ describe("router v1 outcome and feature telemetry", () => {
|
|
|
91
91
|
writeFileSync(join(repo, "new-file.txt"), "secret-ish content\n");
|
|
92
92
|
|
|
93
93
|
const stats = readGitDiffStats(repo);
|
|
94
|
+
execFileSync("mkdir", ["-p", ".pi/router/sessions/other"], { cwd: repo });
|
|
95
|
+
writeFileSync(join(repo, ".pi/router/sessions/other/events.jsonl"), "{}\n");
|
|
94
96
|
const excluded = readGitDiffStats(repo, { excludePaths: [join(repo, "new-file.txt")] });
|
|
97
|
+
const excludedRouterDir = readGitDiffStats(repo, { excludePaths: [join(repo, ".pi/router")] });
|
|
95
98
|
|
|
96
99
|
expect(stats.filesChanged).toBeGreaterThanOrEqual(2);
|
|
97
100
|
expect(stats.linesAdded).toBeGreaterThanOrEqual(1);
|
|
98
101
|
expect(stats.fileHashes).toHaveLength(stats.filesChanged);
|
|
99
102
|
expect(JSON.stringify(stats)).not.toContain("tracked.txt");
|
|
100
103
|
expect(JSON.stringify(stats)).not.toContain("new-file.txt");
|
|
101
|
-
expect(excluded.filesChanged).toBe(
|
|
104
|
+
expect(excluded.filesChanged).toBe(2);
|
|
105
|
+
expect(excludedRouterDir.filesChanged).toBe(2);
|
|
102
106
|
});
|
|
103
107
|
|
|
104
108
|
it("reads untracked files from repo root when launched in a subdirectory", () => {
|