@fiale-plus/pi-rogue 0.2.2 → 0.2.4

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 (42) hide show
  1. package/node_modules/@fiale-plus/pi-core/src/context-broker.ts +4 -0
  2. package/node_modules/@fiale-plus/pi-rogue-advisor/README.md +1 -0
  3. package/node_modules/@fiale-plus/pi-rogue-advisor/src/binary-gate-features.test.ts +8 -0
  4. package/node_modules/@fiale-plus/pi-rogue-advisor/src/binary-gate-features.ts +7 -0
  5. package/node_modules/@fiale-plus/pi-rogue-advisor/src/router.test.ts +26 -0
  6. package/node_modules/@fiale-plus/pi-rogue-advisor/src/router.ts +10 -1
  7. package/node_modules/@fiale-plus/pi-rogue-context-broker/README.md +20 -2
  8. package/node_modules/@fiale-plus/pi-rogue-context-broker/src/extension.test.ts +81 -3
  9. package/node_modules/@fiale-plus/pi-rogue-context-broker/src/extension.ts +72 -10
  10. package/node_modules/@fiale-plus/pi-rogue-context-broker/src/index.test.ts +32 -0
  11. package/node_modules/@fiale-plus/pi-rogue-context-broker/src/index.ts +32 -1
  12. package/node_modules/@fiale-plus/pi-rogue-context-broker/src/sqlite.test.ts +37 -0
  13. package/node_modules/@fiale-plus/pi-rogue-context-broker/src/sqlite.ts +39 -2
  14. package/node_modules/@fiale-plus/pi-rogue-orchestration/README.md +3 -3
  15. package/node_modules/@fiale-plus/pi-rogue-orchestration/package.json +3 -0
  16. package/node_modules/@fiale-plus/pi-rogue-orchestration/skills/orchestration/SKILL.md +3 -2
  17. package/node_modules/@fiale-plus/pi-rogue-orchestration/src/goal.test.ts +65 -2
  18. package/node_modules/@fiale-plus/pi-rogue-orchestration/src/goal.ts +84 -4
  19. package/node_modules/@fiale-plus/pi-rogue-orchestration/src/loop.ts +3 -0
  20. package/node_modules/@fiale-plus/pi-rogue-orchestration/src/novelty-guard.test.ts +43 -0
  21. package/node_modules/@fiale-plus/pi-rogue-orchestration/src/novelty-guard.ts +96 -11
  22. package/node_modules/@fiale-plus/pi-rogue-router/README.md +46 -5
  23. package/node_modules/@fiale-plus/pi-rogue-router/src/binary-gate.test.ts +88 -0
  24. package/node_modules/@fiale-plus/pi-rogue-router/src/binary-gate.ts +232 -0
  25. package/node_modules/@fiale-plus/pi-rogue-router/src/checkpoints.ts +9 -1
  26. package/node_modules/@fiale-plus/pi-rogue-router/src/cli.ts +123 -9
  27. package/node_modules/@fiale-plus/pi-rogue-router/src/completions.ts +39 -16
  28. package/node_modules/@fiale-plus/pi-rogue-router/src/config-extension.test.ts +145 -6
  29. package/node_modules/@fiale-plus/pi-rogue-router/src/config.ts +51 -11
  30. package/node_modules/@fiale-plus/pi-rogue-router/src/extension.ts +67 -7
  31. package/node_modules/@fiale-plus/pi-rogue-router/src/git-features.ts +27 -12
  32. package/node_modules/@fiale-plus/pi-rogue-router/src/index.ts +4 -0
  33. package/node_modules/@fiale-plus/pi-rogue-router/src/observe.ts +87 -9
  34. package/node_modules/@fiale-plus/pi-rogue-router/src/outcomes.ts +130 -6
  35. package/node_modules/@fiale-plus/pi-rogue-router/src/reports.test.ts +92 -0
  36. package/node_modules/@fiale-plus/pi-rogue-router/src/reports.ts +116 -0
  37. package/node_modules/@fiale-plus/pi-rogue-router/src/sharpening.test.ts +223 -0
  38. package/node_modules/@fiale-plus/pi-rogue-router/src/sharpening.ts +344 -0
  39. package/node_modules/@fiale-plus/pi-rogue-router/src/teacher-runner.test.ts +126 -0
  40. package/node_modules/@fiale-plus/pi-rogue-router/src/teacher-runner.ts +238 -0
  41. package/node_modules/@fiale-plus/pi-rogue-router/src/v1-telemetry.test.ts +59 -2
  42. package/package.json +1 -1
@@ -72,6 +72,8 @@ export interface ContextBrokerStatus {
72
72
  coldBytes: number;
73
73
  maxRecords: number;
74
74
  maxBytes: number;
75
+ globalMaxRecords: number;
76
+ globalMaxBytes: number;
75
77
  }
76
78
 
77
79
  export interface ContextBrokerOptions {
@@ -89,6 +91,8 @@ export interface ContextBrokerOptions {
89
91
  hotMaxBytes?: number;
90
92
  warmMaxBytes?: number;
91
93
  coldMaxBytes?: number;
94
+ hotToWarmMs?: number;
95
+ warmToColdMs?: number;
92
96
  summaryBytes?: number;
93
97
  briefBytes?: number;
94
98
  }
@@ -8,6 +8,7 @@ Strategic advisor for Pi sessions with low-overhead preflight/post-review routin
8
8
 
9
9
  - SOTA-first model fallback: `gpt-5.5`/`claude-opus-4-6`/`claude-sonnet-4-6` where available.
10
10
  - Keeps command-level behavior simple and explicit.
11
+ - Router/binary-gate policy escalates architecture/refactor/tradeoff/security/high-uncertainty and material stuck/no-progress work, while tiny edits, direct answers, docs/formatting cleanup, and other low-risk reactive tasks continue without advisor noise.
11
12
 
12
13
  ## Install
13
14
 
@@ -16,4 +16,12 @@ describe("binary gate feature extraction", () => {
16
16
  expect(features.get("safety:production")).toBe(1);
17
17
  expect(features.get("safety:deploy")).toBe(1);
18
18
  });
19
+
20
+ it("emits stuck/no-progress cues for the binary gate", () => {
21
+ const features = extractBinaryGateFeatureCounts("goal loop stuck with repeated planning and no concrete progress");
22
+
23
+ expect(features.get("stuck:stuck")).toBe(1);
24
+ expect(features.get("stuck:repeated_planning")).toBe(1);
25
+ expect(features.get("stuck:no_concrete_progress")).toBe(1);
26
+ });
19
27
  });
@@ -160,6 +160,13 @@ export function extractBinaryGateFeatureCounts(text: string): Map<string, number
160
160
  }
161
161
  }
162
162
 
163
+ const stuckWords = ["stuck", "looping", "spinning", "no progress", "no concrete progress", "same failure", "repeated failure", "repeated planning", "self talk", "forever thinking", "alternative action", "blocked"];
164
+ for (const stuckWord of stuckWords) {
165
+ if (lower.includes(stuckWord)) {
166
+ inc(counts, `stuck:${replaceSpaces(stuckWord)}`);
167
+ }
168
+ }
169
+
163
170
  const contextWords = ["need more context", "missing context", "clarify", "not enough info", "unspecified", "unknown", "ambiguous"];
164
171
  for (const contextWord of contextWords) {
165
172
  if (lower.includes(contextWord)) {
@@ -36,6 +36,32 @@ describe("advisor router heuristics", () => {
36
36
  expect(routeNote(route)).toMatch(/^\[advisor:rules: review, reason: [a-z0-9 ,.'-]+\]$/);
37
37
  });
38
38
 
39
+ it("escalates material stuck/no-progress prompts", () => {
40
+ const input: AdvisorRouteInput = { phase: "preflight", text: "the goal loop is stuck with repeated planning and no concrete progress after several turns" };
41
+ const route = heuristicRoute(input);
42
+
43
+ expect(route.label).toBe("escalate_to_advisor");
44
+ expect(route.escalate).toBe(true);
45
+ expect(route.reason).toContain("no-progress");
46
+ });
47
+
48
+ it("escalates stuck evidence even when low-risk edit cues are present", () => {
49
+ const input: AdvisorRouteInput = { phase: "preflight", text: "small README edit is stuck with no concrete progress after several turns" };
50
+ const route = heuristicRoute(input);
51
+
52
+ expect(route.label).toBe("escalate_to_advisor");
53
+ expect(route.reason).toContain("no-progress");
54
+ });
55
+
56
+ it("keeps routine docs cleanup out of advisor escalation", () => {
57
+ const input: AdvisorRouteInput = { phase: "preflight", text: "routine docs and formatting cleanup in README" };
58
+ const route = heuristicRoute(input);
59
+
60
+ expect(route.label).toBe("continue");
61
+ expect(route.escalate).toBe(false);
62
+ expect(route.review).toBe("off");
63
+ });
64
+
39
65
  it("flags safety-sensitive prompts", () => {
40
66
  const input: AdvisorRouteInput = { phase: "preflight", text: "run rm -rf on prod" };
41
67
  const route = heuristicRoute(input);
@@ -125,6 +125,7 @@ const QUICK_EDIT_RE = /\b(quick edit|small edit|tiny edit|rename|format(?:ting)?
125
125
  const ROUTINE_CLEANUP_RE = /\b(routine docs?|docs? and formatting|formatting cleanup|generated changes|large diff|docs?\/formatting)\b/i;
126
126
  const COMPLEX_RE = /\b(architecture|architectural|refactor|design|trade[- ]?off|concurrency|security|auth|migration|performance|scale|scalability|framework|system design|schema|data model|protocol|advisor routing|advisor flow|router logic|call vs skip|skip vs call|compare|recommend|benchmark|evaluate|experiment|train|strategy|choose|make sense|worth(?: it)?|kpi|kpis|how it works|where it comes from|what would you choose|what do you think|next step|pick between|buy|usage|sustained speed|available models|running model kpis)\b/i;
127
127
  const DEBUG_RE = /\b(debug|bug|error|stack trace|traceback|fail(?:ed|ure)?|broken|investigate|why is|cannot|can't|crash|regression)\b/i;
128
+ const STUCK_RE = /\b(stuck|looping|spinning|no[- ]?progress|no concrete progress|same failure|repeated failure|repeated planning|self[- ]?talk|forever thinking|strategy change|alternative action|blocked)\b/i;
128
129
  const CONTEXT_RE = /\b(need more context|missing context|clarify|not enough info|unspecified|unknown|ambiguous)\b/i;
129
130
  const SAFETY_RE = /\b(rm\s+-rf|sudo\b|shutdown\b|reboot\b|mkfs(?:\.[\w-]+)?\b|chmod\s+-R\b|chown\b|git\s+push\b[\s\S]*--force(?:-with-lease)?|curl\b[\s\S]*\|\s*(?:sh|bash)\b|wget\b[\s\S]*\|\s*(?:sh|bash)\b|drop\s+table\b|delete\s+database\b|credential\b|password\b|secret\b)\b/i;
130
131
  const COMPACTION_RE = /\b(compact(?:ed|ion)?|missing history|history might flip|prior constraint|resume(?:d)? after compaction)\b/i;
@@ -293,6 +294,11 @@ function hasComplexSignal(text: string): boolean {
293
294
  return COMPLEX_RE.test(text) || DEBUG_RE.test(text);
294
295
  }
295
296
 
297
+ function hasMaterialStuckSignal(text: string): boolean {
298
+ if (!STUCK_RE.test(text)) return false;
299
+ return /\b(goal|loop|autoresearch|tool|test|command|failure|failed|turns?|again|same|repeated|concrete|progress|blocked|alternative|recovery)\b/i.test(text);
300
+ }
301
+
296
302
  function hasCompactionLowRiskSignal(text: string): boolean {
297
303
  return COMPACTION_RE.test(text) && /\blow[- ]?risk\b/i.test(text);
298
304
  }
@@ -342,6 +348,9 @@ function preflightSignals(input: AdvisorRouteInput): { label: PreflightLabel; co
342
348
  if (isSafetySensitive(text)) {
343
349
  return { label: "escalate_to_advisor", confidence: 0.98, reason: "Safety-sensitive keywords detected.", safety: true };
344
350
  }
351
+ if (hasMaterialStuckSignal(text)) {
352
+ return { label: "escalate_to_advisor", confidence: 0.86, reason: "Material stuck/no-progress signal detected.", safety: false };
353
+ }
345
354
  if (hasRoutineCleanupSignal(text) || (hasQuickEditSignal(text) && !hasComplexSignal(text))) {
346
355
  return { label: "continue", confidence: 0.9, reason: "Small-edit or routine-cleanup signal detected.", safety: false };
347
356
  }
@@ -424,7 +433,7 @@ export function buildRouterPrompt(input: AdvisorRouteInput): string {
424
433
  input.failed !== undefined ? `Failed: ${String(input.failed)}` : "",
425
434
  phase === "preflight"
426
435
  ? [
427
- "Guidance: continue for tiny edits and direct answers; escalate_to_advisor for architecture, design, tradeoffs, security, or high uncertainty; need_more_context when underspecified; low_confidence when mixed signals.",
436
+ "Guidance: continue for tiny edits, direct answers, docs/formatting cleanup, and other low-risk reactive tasks; escalate_to_advisor for architecture, refactors, design, tradeoffs, security, irreversible actions, high uncertainty, or material stuck/no-progress evidence; need_more_context when underspecified; low_confidence when mixed signals. If advisor guidance conflicts with local evidence, the working model must reconcile explicitly rather than blindly follow it.",
428
437
  ].join(" ")
429
438
  : [
430
439
  "Guidance: on_track for clearly complete work; course_correct for partial work that needs changes; not_done when incomplete or failing; abstain when there is not enough signal.",
@@ -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))