@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.
@@ -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(-2)?.message).toContain("durable payload");
748
- expect(secondRun.notifications.at(-1)?.message).toContain("tier=hot");
749
- expect(secondRun.notifications.at(-1)?.message).toContain("pinned");
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 payloadLines = isBinary
159
+ const isOpaque = item.tags.includes("opaque");
160
+ const payloadLines = isBinary || isOpaque
123
161
  ? [
124
162
  "payload:",
125
- "[payload intentionally omitted from prompt for safety; use /context export",
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
- return textResult("No context artifacts matched. Missing or expired handles should be reported explicitly.");
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") : "No context artifacts matched.", "info");
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 routerArtifacts = routerDir ? [resolve(routerDir, "config.json"), resolve(routerDir, "state.json"), resolve(routerDir, "events.jsonl")] : [];
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
- export function routerStatePath(ctx: any): string {
87
- return join(routerDir(ctx), "state.json");
87
+ function sessionPathFromCtx(ctx: any): string | undefined {
88
+ const value = ctx?.sessionManager?.getSessionFile?.();
89
+ return value ? String(value) : undefined;
88
90
  }
89
91
 
90
- export function routerEventsPath(ctx: any): string {
91
- return join(routerDir(ctx), "events.jsonl");
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
- function parseNumstat(output: string, excludeFiles = new Set<string>()): Pick<DiffStats, "filesChanged" | "linesAdded" | "linesDeleted" | "totalLines" | "fileHashes"> {
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 && excludeFiles.has(file)) continue;
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, excludeFiles = new Set<string>()): { hashes: string[]; linesAdded: number } {
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 || excludeFiles.has(file)) continue;
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): Set<string> {
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
- rel = relative(realRoot, realpathSync(absolute));
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 && !rel.startsWith("..")) files.add(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 excludeFiles = excludeFilesFromPaths(root, options.excludePaths);
87
- const untracked = untrackedFiles(root, excludeFiles);
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"]), excludeFiles);
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}`, excludeFiles);
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, [sessionPath, routerConfigPath(ctx), routerStatePath(ctx), routerEventsPath(ctx)]);
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(1);
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", () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fiale-plus/pi-rogue",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "Public Pi-Rogue package for bundled advisor, orchestration, and context broker logic.",
5
5
  "type": "module",
6
6
  "license": "MIT",