@fiale-plus/pi-rogue 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +2 -1
  2. package/node_modules/@fiale-plus/pi-core/src/context-broker.ts +4 -0
  3. package/node_modules/@fiale-plus/pi-rogue-context-broker/README.md +24 -5
  4. package/node_modules/@fiale-plus/pi-rogue-context-broker/src/extension.test.ts +119 -7
  5. package/node_modules/@fiale-plus/pi-rogue-context-broker/src/extension.ts +124 -16
  6. package/node_modules/@fiale-plus/pi-rogue-context-broker/src/index.test.ts +32 -0
  7. package/node_modules/@fiale-plus/pi-rogue-context-broker/src/index.ts +32 -1
  8. package/node_modules/@fiale-plus/pi-rogue-context-broker/src/sqlite.test.ts +37 -0
  9. package/node_modules/@fiale-plus/pi-rogue-context-broker/src/sqlite.ts +39 -2
  10. package/node_modules/@fiale-plus/pi-rogue-router/README.md +34 -0
  11. package/node_modules/@fiale-plus/pi-rogue-router/package.json +30 -0
  12. package/node_modules/@fiale-plus/pi-rogue-router/src/checkpoints.test.ts +84 -0
  13. package/node_modules/@fiale-plus/pi-rogue-router/src/checkpoints.ts +363 -0
  14. package/node_modules/@fiale-plus/pi-rogue-router/src/cli.ts +277 -0
  15. package/node_modules/@fiale-plus/pi-rogue-router/src/completions.ts +34 -0
  16. package/node_modules/@fiale-plus/pi-rogue-router/src/config-extension.test.ts +165 -0
  17. package/node_modules/@fiale-plus/pi-rogue-router/src/config.ts +193 -0
  18. package/node_modules/@fiale-plus/pi-rogue-router/src/dataset.ts +154 -0
  19. package/node_modules/@fiale-plus/pi-rogue-router/src/decision-ledger.test.ts +148 -0
  20. package/node_modules/@fiale-plus/pi-rogue-router/src/decision.ts +138 -0
  21. package/node_modules/@fiale-plus/pi-rogue-router/src/extension.ts +139 -0
  22. package/node_modules/@fiale-plus/pi-rogue-router/src/git-features.ts +134 -0
  23. package/node_modules/@fiale-plus/pi-rogue-router/src/hash.ts +19 -0
  24. package/node_modules/@fiale-plus/pi-rogue-router/src/index.ts +15 -0
  25. package/node_modules/@fiale-plus/pi-rogue-router/src/learning.test.ts +241 -0
  26. package/node_modules/@fiale-plus/pi-rogue-router/src/learning.ts +382 -0
  27. package/node_modules/@fiale-plus/pi-rogue-router/src/ledger.ts +94 -0
  28. package/node_modules/@fiale-plus/pi-rogue-router/src/observe.ts +126 -0
  29. package/node_modules/@fiale-plus/pi-rogue-router/src/outcomes.ts +128 -0
  30. package/node_modules/@fiale-plus/pi-rogue-router/src/progress.ts +93 -0
  31. package/node_modules/@fiale-plus/pi-rogue-router/src/session-reader.ts +217 -0
  32. package/node_modules/@fiale-plus/pi-rogue-router/src/subagents.ts +178 -0
  33. package/node_modules/@fiale-plus/pi-rogue-router/src/types.ts +150 -0
  34. package/node_modules/@fiale-plus/pi-rogue-router/src/v1-telemetry.test.ts +297 -0
  35. package/package.json +5 -3
  36. package/src/extension.test.ts +1 -0
  37. package/src/extension.ts +2 -0
package/README.md CHANGED
@@ -8,6 +8,7 @@ It stitches together (and bundles for a true single-package install):
8
8
  - `@fiale-plus/pi-rogue-advisor` (logic; direct releases paused)
9
9
  - `@fiale-plus/pi-rogue-context-broker` (context-broker runtime; registered by default with an env kill switch)
10
10
  - `@fiale-plus/pi-rogue-orchestration` (logic; direct releases paused)
11
+ - `@fiale-plus/pi-rogue-router` (observe-only trajectory-router lab; direct releases paused)
11
12
 
12
13
  Direct installs of the advisor/orchestration packages are paused (marked private). All users and future releases go through the bundle. See `docs/release.md` and root `AGENTS.md` / `README.md` for the release policy.
13
14
 
@@ -37,7 +38,7 @@ npm install
37
38
 
38
39
  ## Command surface
39
40
 
40
- - Default: `/advisor`, `/goal`, `/loop`, `/autoresearch`, `/autoresearch-lab` plus status/config/command paths (all provided via the bundle).
41
+ - Default: `/advisor`, `/goal`, `/loop`, `/autoresearch`, `/autoresearch-lab`, `/router` plus status/config/command paths (all provided via the bundle).
41
42
  - Context broker: enabled by default; `PI_CONTEXT_BROKER_ENABLED=false` disables `/context status`, `/context brief`, `/context lookup <handle|text>`, `/context pin <handle>`, `/context export <handle>`, and `/context prune` with autocomplete.
42
43
 
43
44
  ## Status
@@ -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.
@@ -22,20 +23,36 @@ PI_CONTEXT_BROKER_ENABLED=false pi
22
23
 
23
24
  When active, the bundle registers:
24
25
 
25
- - `/context status` — enabled state, record/byte counts, pinned counts, and routing telemetry.
26
+ - `/context status` — enabled state, record/byte counts, pinned counts, routing telemetry, and prompt rewrite savings bytes.
26
27
  - `/context brief` — bounded prompt-safe broker brief with handles and summaries.
27
28
  - `/context lookup <handle|text>` — exact handle rehydration or current-session text search.
28
29
  - `/context pin <handle>` — protect an artifact from normal TTL/cap pruning.
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
- - `PI_CONTEXT_BROKER_REWRITE_THRESHOLD_BYTES` controls when large `toolResult` / `bashExecution` payloads are rewritten in-context. The default is `0` (rewritten), so raw tool evidence is replaced by handles by default.
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
- For quieter sessions, set `PI_CONTEXT_BROKER_REWRITE_THRESHOLD_BYTES` to a higher value to only rewrite larger outputs.
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.
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.
39
56
 
40
57
 
41
58
  ## Session behavior and limits
@@ -45,9 +62,11 @@ For quieter sessions, set `PI_CONTEXT_BROKER_REWRITE_THRESHOLD_BYTES` to a highe
45
62
  - Without durable mode, restarting Pi loses broker state until the current branch is backfilled again.
46
63
  - Prompt integration injects a bounded, tier-aware broker brief and lookup guidance; the LLM also gets a `context_lookup` tool for exact handle dereferencing. Payloads that hit hostile-binary heuristics are represented in prompt as handles plus short guidance to export the full content.
47
64
  - The `context` hook rewrites prompt-visible `toolResult` and `bashExecution` payloads in the LLM-bound message copy to broker handles and summaries, reducing prompt load while preserving exact `/context lookup` rehydration.
65
+ - Current-turn `context_lookup` results are left visible so the model can consume requested exact evidence once. Historical `context_lookup` results that already have a later assistant response are omitted from later prompt assembly to avoid recursive prompt growth.
48
66
  - Pi `excludeFromContext` bash entries are not backfilled or rewritten into broker prompts.
49
67
  - Basic secret redaction runs before broker storage and display for common token/password/API-key patterns.
50
68
  - Optional global caps can be configured via env vars:
51
69
  - `PI_CONTEXT_BROKER_GLOBAL_MAX_RECORDS`
52
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.
53
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 });
@@ -382,7 +454,7 @@ lookupBytes: 500,
382
454
  await commands.get("context").handler(`export ${handle}`, ctx);
383
455
  const result = await handlers.get("context")?.[0]({
384
456
  type: "context",
385
- messages: [{ role: "toolResult", toolCallId: "tool-result-telemetry", toolName: "bash", content: [{ type: "text", text: "telemetry_payload_" + "y".repeat(150) }], isError: false, timestamp: 1 }],
457
+ messages: [{ role: "toolResult", toolCallId: "tool-result-telemetry", toolName: "bash", content: [{ type: "text", text: "telemetry_payload_" + "y".repeat(1000) }], isError: false, timestamp: 1 }],
386
458
  }, ctx);
387
459
 
388
460
  await commands.get("context").handler("status", ctx);
@@ -390,17 +462,52 @@ 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:");
469
+ expect(telemetry).toContain("rewriteSavings rawBytes=");
470
+ expect(telemetry).toContain("replacementBytes=");
471
+ expect(telemetry).toContain("savedBytes=");
472
+ expect(telemetry).toMatch(/savedBytes=[1-9]\d*/);
473
+ expect(telemetry).toContain("contextLookupHistoryOmitted=");
395
474
  expect(telemetry).toContain("lookups tool(calls=");
475
+ expect(telemetry).toContain("exact=");
476
+ expect(telemetry).toContain("textMisses=");
396
477
  expect(telemetry).toContain("lookups slash(calls=");
397
478
  expect(telemetry).toContain("exports=");
398
479
  expect(telemetry).toContain("pins=");
399
480
  });
400
481
 
401
- it("does not broker context_lookup results recursively", async () => {
482
+ it("keeps current context_lookup results visible before the model consumes them", async () => {
402
483
  const { pi, handlers, commands, tools } = createPiMock();
403
- registerContextBrokerBeta(pi, { lookupBytes: 500, rewriteThresholdBytes: 1 });
484
+ registerContextBrokerBeta(pi, { lookupBytes: 1000, rewriteThresholdBytes: 1 });
485
+ const { ctx } = createCtx();
486
+
487
+ await runHandlers(handlers, "session_start", { type: "session_start" }, ctx);
488
+ await runHandlers(handlers, "tool_result", {
489
+ type: "tool_result",
490
+ toolCallId: "call-current-lookup-source",
491
+ toolName: "bash",
492
+ input: { command: "printf current-lookup" },
493
+ content: [{ type: "text", text: "CURRENT_LOOKUP_EVIDENCE_" + "x".repeat(120) }],
494
+ isError: false,
495
+ }, ctx);
496
+ const handle = commands.get("context").getArgumentCompletions("lookup ")?.[0].value.replace(/^lookup /, "");
497
+ const lookupResult = await tools.get("context_lookup").execute("lookup-current", { handle }, undefined, undefined, ctx);
498
+
499
+ const contextResult = await handlers.get("context")?.[0]({
500
+ type: "context",
501
+ messages: [{ role: "toolResult", toolCallId: "lookup-current", toolName: "context_lookup", content: lookupResult.content, isError: false }],
502
+ }, ctx);
503
+
504
+ expect(contextResult).toBeUndefined();
505
+ expect(lookupResult.content[0].text).toContain("CURRENT_LOOKUP_EVIDENCE_");
506
+ });
507
+
508
+ it("does not broker historical context_lookup results recursively", async () => {
509
+ const { pi, handlers, commands, tools } = createPiMock();
510
+ registerContextBrokerBeta(pi, { lookupBytes: 500 });
404
511
  const { ctx, notifications } = createCtx();
405
512
 
406
513
  await runHandlers(handlers, "session_start", { type: "session_start" }, ctx);
@@ -425,7 +532,10 @@ lookupBytes: 500,
425
532
  }, ctx);
426
533
  const contextResult = await handlers.get("context")?.[0]({
427
534
  type: "context",
428
- messages: [{ role: "toolResult", toolCallId: "lookup-call", toolName: "context_lookup", content: lookupResult.content, isError: false }],
535
+ messages: [
536
+ { role: "toolResult", toolCallId: "lookup-call", toolName: "context_lookup", content: lookupResult.content, isError: false },
537
+ { role: "assistant", content: [{ type: "text", text: "I consumed the lookup." }] },
538
+ ],
429
539
  }, ctx);
430
540
  await commands.get("context").handler("brief", ctx);
431
541
 
@@ -702,6 +812,7 @@ maxRecords: 1,
702
812
  const secondHandle = second.commands.get("context").getArgumentCompletions("lookup ")?.[0].value.replace(/^lookup /, "");
703
813
  await second.commands.get("context").handler(`lookup ${handle}`, secondRun.ctx);
704
814
  await second.commands.get("context").handler("brief", secondRun.ctx);
815
+ await second.commands.get("context").handler("status", secondRun.ctx);
705
816
 
706
817
  const third = createPiMock();
707
818
  const thirdRun = createCtx();
@@ -710,9 +821,10 @@ maxRecords: 1,
710
821
  await third.commands.get("context").handler(`lookup ${secondHandle}`, thirdRun.ctx);
711
822
  await third.commands.get("context").handler("brief", thirdRun.ctx);
712
823
 
713
- expect(secondRun.notifications.at(-2)?.message).toContain("durable payload");
714
- expect(secondRun.notifications.at(-1)?.message).toContain("tier=hot");
715
- 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");
716
828
  expect(thirdRun.notifications.at(-2)?.message).toContain("durable payload");
717
829
  expect(thirdRun.notifications.at(-1)?.message).toContain("tier=hot");
718
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,26 @@ 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
+
229
+ function utf8Bytes(text: string): number {
230
+ return Buffer.byteLength(text, "utf8");
231
+ }
232
+
233
+ function promptPayloadBytes(message: any): number {
234
+ if (message?.role === "bashExecution") return utf8Bytes(String(message.output ?? ""));
235
+ if (message?.role === "toolResult") return utf8Bytes(contentText(message.content));
236
+ return utf8Bytes(toText(message));
237
+ }
238
+
179
239
  function stableHash(value: string): string {
180
240
  return createHash("sha256").update(value).digest("hex").slice(0, 16);
181
241
  }
@@ -283,14 +343,16 @@ export async function registerContextBrokerBeta(pi: ExtensionAPI, options: Conte
283
343
  options.rewriteThresholdBytes
284
344
  ?? envNonNegativeInt("PI_CONTEXT_BROKER_REWRITE_THRESHOLD_BYTES")
285
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));
286
347
  const brokerOptions = {
287
348
  maxRecords: options.maxRecords ?? 64,
288
349
  maxBytes: options.maxBytes ?? 8 * 1024 * 1024,
289
- globalMaxRecords: options.globalMaxRecords ?? envNonNegativeInt("PI_CONTEXT_BROKER_GLOBAL_MAX_RECORDS"),
290
- 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,
291
354
  briefBytes,
292
355
  };
293
- const durable = options.durable ?? (envFlag("PI_CONTEXT_BROKER_DURABLE") || Boolean(options.storeDir ?? process.env.PI_CONTEXT_BROKER_STORE_DIR));
294
356
  const durableBackend = String(process.env.PI_CONTEXT_BROKER_BACKEND ?? "sqlite").trim().toLowerCase();
295
357
  const broker = durable
296
358
  ? durableBackend === "jsonl"
@@ -308,6 +370,9 @@ export async function registerContextBrokerBeta(pi: ExtensionAPI, options: Conte
308
370
  contextHookBash: 0,
309
371
  contextHookBashRewrites: 0,
310
372
  contextHookBashHostile: 0,
373
+ contextHookRewriteRawBytes: 0,
374
+ contextHookRewriteReplacementBytes: 0,
375
+ contextHookContextLookupHistoryOmissions: 0,
311
376
  toolResultEvents: 0,
312
377
  toolResultArtifacts: 0,
313
378
  backfillScans: 0,
@@ -318,24 +383,38 @@ export async function registerContextBrokerBeta(pi: ExtensionAPI, options: Conte
318
383
  toolLookupTextCalls: 0,
319
384
  toolLookupHits: 0,
320
385
  toolLookupMisses: 0,
386
+ toolLookupExactMisses: 0,
387
+ toolLookupTextMisses: 0,
321
388
  commandLookupCalls: 0,
322
389
  commandLookupExactCalls: 0,
323
390
  commandLookupTextCalls: 0,
324
391
  commandLookupHits: 0,
325
392
  commandLookupMisses: 0,
393
+ commandLookupExactMisses: 0,
394
+ commandLookupTextMisses: 0,
326
395
  exportCalls: 0,
327
396
  pinCalls: 0,
328
397
  statusCalls: 0,
329
398
  pruneCalls: 0,
330
399
  };
331
400
 
401
+ function recordContextRewrite(rawBytes: number, replacementBytes: number): void {
402
+ routingTelemetry.contextHookRewriteRawBytes += Math.max(0, rawBytes);
403
+ routingTelemetry.contextHookRewriteReplacementBytes += Math.max(0, replacementBytes);
404
+ }
405
+
332
406
  function formatRoutingTelemetry(): string {
407
+ const savedBytes = Math.max(0, routingTelemetry.contextHookRewriteRawBytes - routingTelemetry.contextHookRewriteReplacementBytes);
408
+ const savedPct = routingTelemetry.contextHookRewriteRawBytes > 0
409
+ ? ((savedBytes / routingTelemetry.contextHookRewriteRawBytes) * 100).toFixed(1)
410
+ : "0.0";
333
411
  const line = [
334
412
  `contextHook calls=${routingTelemetry.contextHookCalls}`,
335
413
  `toolResults seen=${routingTelemetry.contextHookToolResults} rewritten=${routingTelemetry.contextHookToolResultRewrites} hostile=${routingTelemetry.contextHookToolResultHostile}`,
336
414
  `bash seen=${routingTelemetry.contextHookBash} rewritten=${routingTelemetry.contextHookBashRewrites} hostile=${routingTelemetry.contextHookBashHostile}`,
337
- `lookups tool(calls=${routingTelemetry.toolLookupCalls}, hits=${routingTelemetry.toolLookupHits}, misses=${routingTelemetry.toolLookupMisses})`,
338
- `lookups slash(calls=${routingTelemetry.commandLookupCalls}, hits=${routingTelemetry.commandLookupHits}, misses=${routingTelemetry.commandLookupMisses})`,
415
+ `rewriteSavings rawBytes=${routingTelemetry.contextHookRewriteRawBytes} replacementBytes=${routingTelemetry.contextHookRewriteReplacementBytes} savedBytes=${savedBytes} savedPct=${savedPct}% contextLookupHistoryOmitted=${routingTelemetry.contextHookContextLookupHistoryOmissions}`,
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})`,
339
418
  `exports=${routingTelemetry.exportCalls}`,
340
419
  `pins=${routingTelemetry.pinCalls}`,
341
420
  `pruneCalls=${routingTelemetry.pruneCalls}`,
@@ -377,6 +456,7 @@ export async function registerContextBrokerBeta(pi: ExtensionAPI, options: Conte
377
456
  const payload = toolPayload(sanitizedEvent);
378
457
  const bytes = Buffer.byteLength(payload, "utf8");
379
458
  const hostilePayload = isHostilePayload(payload) || hasHostileValue(sanitizedEvent);
459
+ const opaquePayload = !hostilePayload && (isOpaquePayload(payload) || hasOpaqueValue(sanitizedEvent));
380
460
  const artifact = broker.publish({
381
461
  sessionId: activeSessionId,
382
462
  kind: "tool_output",
@@ -387,6 +467,7 @@ export async function registerContextBrokerBeta(pi: ExtensionAPI, options: Conte
387
467
  event.isError ? "error" : "ok",
388
468
  event.sourceId ? "session-backfill" : "live",
389
469
  ...(hostilePayload ? ["hostile", "binary"] : []),
470
+ ...(opaquePayload ? ["opaque"] : []),
390
471
  ],
391
472
  command: event.toolName === "bash" && typeof sanitizedEvent.input?.command === "string" ? sanitizedEvent.input.command : undefined,
392
473
  paths: typeof sanitizedEvent.input?.path === "string" ? [sanitizedEvent.input.path] : [],
@@ -572,19 +653,35 @@ export async function registerContextBrokerBeta(pi: ExtensionAPI, options: Conte
572
653
  activeSessionId = sessionIdFor(ctx);
573
654
  routingTelemetry.contextHookCalls += 1;
574
655
  const toolInputs = collectToolInputs(event.messages);
575
- const drafts = event.messages.map((message: any): { original: any; replacement?: any; artifact?: ContextArtifact; rewrite?: (artifact: ContextArtifact) => any; safeFallback?: any } => {
656
+ type RewriteDraft = {
657
+ original: any;
658
+ replacement?: any;
659
+ rawBytes?: number;
660
+ artifact?: ContextArtifact;
661
+ rewrite?: (artifact: ContextArtifact) => any;
662
+ safeFallback?: any;
663
+ };
664
+ const drafts = event.messages.map((message: any, index: number): RewriteDraft => {
576
665
  if (message?.role === "toolResult") {
577
666
  routingTelemetry.contextHookToolResults += 1;
578
667
  const raw = contentText(message.content);
668
+ const rawBytes = utf8Bytes(raw);
579
669
  const toolInput = typeof message.toolCallId === "string" ? toolInputs.get(message.toolCallId) : undefined;
580
670
  const toolName = String(message.toolName ?? toolInput?.toolName ?? "tool");
581
671
  const hostile = hasHostileText(raw) || hasHostileValue(message.content);
582
672
  if (hostile) routingTelemetry.contextHookToolResultHostile += 1;
583
- const shouldRewrite = Buffer.byteLength(raw, "utf8") > rewriteThresholdBytes || hostile;
584
- if (!shouldRewrite) return { original: message };
585
673
  if (!shouldBrokerToolName(toolName)) {
586
- return { original: message, replacement: { ...message, content: [{ type: "text", text: contextLookupHistoryPlaceholder() }] } };
674
+ const hasLaterAssistant = event.messages.slice(index + 1).some((candidate: any) => candidate?.role === "assistant");
675
+ if (!hasLaterAssistant) return { original: message };
676
+ routingTelemetry.contextHookContextLookupHistoryOmissions += 1;
677
+ return {
678
+ original: message,
679
+ rawBytes,
680
+ replacement: { ...message, content: [{ type: "text", text: contextLookupHistoryPlaceholder() }] },
681
+ };
587
682
  }
683
+ const shouldRewrite = rawBytes > rewriteThresholdBytes || hostile;
684
+ if (!shouldRewrite) return { original: message };
588
685
  const artifact = publishToolArtifact({
589
686
  toolName,
590
687
  input: message.input ?? toolInput?.input,
@@ -599,6 +696,7 @@ export async function registerContextBrokerBeta(pi: ExtensionAPI, options: Conte
599
696
  routingTelemetry.contextHookToolResultRewrites += 1;
600
697
  return {
601
698
  original: message,
699
+ rawBytes,
602
700
  artifact,
603
701
  rewrite: (live) => ({ ...message, content: [{ type: "text", text: brokerPlaceholder(live) }] }),
604
702
  safeFallback: { ...message, content: [{ type: "text", text: prunedPayloadPlaceholder(hostile) }] },
@@ -608,9 +706,10 @@ export async function registerContextBrokerBeta(pi: ExtensionAPI, options: Conte
608
706
  if (message?.role === "bashExecution" && message.excludeFromContext !== true) {
609
707
  routingTelemetry.contextHookBash += 1;
610
708
  const raw = String(message.output ?? "");
709
+ const rawBytes = utf8Bytes(raw);
611
710
  const hostile = hasHostileText(raw) || hasHostileValue(message.output);
612
711
  if (hostile) routingTelemetry.contextHookBashHostile += 1;
613
- const shouldRewrite = Buffer.byteLength(raw, "utf8") > rewriteThresholdBytes || hostile;
712
+ const shouldRewrite = rawBytes > rewriteThresholdBytes || hostile;
614
713
  if (!shouldRewrite) return { original: message };
615
714
  const sourceId = typeof message.timestamp === "number"
616
715
  ? `bash:${message.timestamp}:${stableHash([message.command ?? "", raw, message.exitCode ?? "", message.cancelled ?? ""].join("\n"))}`
@@ -634,6 +733,7 @@ export async function registerContextBrokerBeta(pi: ExtensionAPI, options: Conte
634
733
  routingTelemetry.contextHookBashRewrites += 1;
635
734
  return {
636
735
  original: message,
736
+ rawBytes,
637
737
  artifact,
638
738
  rewrite: (live) => ({ ...message, output: brokerPlaceholder(live), truncated: true }),
639
739
  safeFallback: { ...message, output: prunedPayloadPlaceholder(hostile), truncated: true },
@@ -647,6 +747,7 @@ export async function registerContextBrokerBeta(pi: ExtensionAPI, options: Conte
647
747
  const messages = drafts.map((draft) => {
648
748
  if (draft.replacement) {
649
749
  changed = true;
750
+ recordContextRewrite(draft.rawBytes ?? promptPayloadBytes(draft.original), promptPayloadBytes(draft.replacement));
650
751
  return draft.replacement;
651
752
  }
652
753
  if (!draft.artifact || !draft.rewrite) return draft.original;
@@ -655,12 +756,15 @@ export async function registerContextBrokerBeta(pi: ExtensionAPI, options: Conte
655
756
  for (const parentId of draft.artifact.parentIds) sourceHandles.delete(parentId);
656
757
  if (draft.safeFallback) {
657
758
  changed = true;
759
+ recordContextRewrite(draft.rawBytes ?? promptPayloadBytes(draft.original), promptPayloadBytes(draft.safeFallback));
658
760
  return draft.safeFallback;
659
761
  }
660
762
  return draft.original;
661
763
  }
662
764
  changed = true;
663
- return draft.rewrite(live);
765
+ const replacement = draft.rewrite(live);
766
+ recordContextRewrite(draft.rawBytes ?? promptPayloadBytes(draft.original), promptPayloadBytes(replacement));
767
+ return replacement;
664
768
  });
665
769
 
666
770
  return changed ? { messages } : undefined;
@@ -719,7 +823,9 @@ export async function registerContextBrokerBeta(pi: ExtensionAPI, options: Conte
719
823
  });
720
824
  if (!results.length) {
721
825
  routingTelemetry.toolLookupMisses += 1;
722
- 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));
723
829
  }
724
830
  routingTelemetry.toolLookupHits += 1;
725
831
  return textResult(results.map((item) => renderLookupOutput(item, exact ? lookupBytes : searchBytes)).join("\n\n---\n\n"));
@@ -738,7 +844,7 @@ export async function registerContextBrokerBeta(pi: ExtensionAPI, options: Conte
738
844
  routingTelemetry.statusCalls += 1;
739
845
  const status = broker.status();
740
846
  ctx.ui.notify(
741
- `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`,
742
848
  "info",
743
849
  );
744
850
  ctx.ui.notify(formatRoutingTelemetry(), "info");
@@ -764,8 +870,10 @@ export async function registerContextBrokerBeta(pi: ExtensionAPI, options: Conte
764
870
  routingTelemetry.commandLookupHits += 1;
765
871
  } else {
766
872
  routingTelemetry.commandLookupMisses += 1;
873
+ if (exact) routingTelemetry.commandLookupExactMisses += 1;
874
+ else routingTelemetry.commandLookupTextMisses += 1;
767
875
  }
768
- 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");
769
877
  return;
770
878
  }
771
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 });