@fiale-plus/pi-rogue-bundle 0.1.16 → 0.1.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -30,7 +30,8 @@ npm install
30
30
  - **Lab / internal helpers are excluded from this bundle.**
31
31
  - The beta context-broker runtime is bundled for opt-in experiments but is not registered/enabled by default.
32
32
  - Opt-in consumers can import the runtime through the bundle subpath: `@fiale-plus/pi-rogue-bundle/context-broker`.
33
- - Set `PI_CONTEXT_BROKER_ENABLED=true` before starting Pi to register the beta `/context` command surface.
33
+ - Set `PI_CONTEXT_BROKER_ENABLED=true` before starting Pi to register the beta `/context` command surface and prompt-load rewriting.
34
+ - Optional durable broker storage can be enabled with `PI_CONTEXT_BROKER_DURABLE=true` or `PI_CONTEXT_BROKER_STORE_DIR=/path/to/store`; it defaults to SQLite/FTS and supports `PI_CONTEXT_BROKER_BACKEND=jsonl` for the legacy JSONL/blob backend.
34
35
  - `@fiale-plus/pi-rogue-bundle` is the only published surface for the logic.
35
36
  - Internal helper packages (`@fiale-plus/pi-rogue-guardrails`, `@fiale-plus/pi-rogue-brain`, `@fiale-plus/pi-rogue-repo-arch`) are maintained separately in the lab section and not published.
36
37
 
@@ -6,6 +6,8 @@ export type ContextArtifactKind =
6
6
  | "advisor_brief"
7
7
  | "memory_note";
8
8
 
9
+ export type ContextArtifactTier = "hot" | "warm" | "cold";
10
+
9
11
  export interface ContextArtifactInput {
10
12
  sessionId: string;
11
13
  kind: ContextArtifactKind;
@@ -15,6 +17,7 @@ export interface ContextArtifactInput {
15
17
  paths?: string[];
16
18
  command?: string;
17
19
  branch?: string;
20
+ tier?: ContextArtifactTier;
18
21
  ttlMs?: number;
19
22
  pinned?: boolean;
20
23
  parentIds?: string[];
@@ -36,6 +39,7 @@ export interface ContextArtifact {
36
39
  paths: string[];
37
40
  command?: string;
38
41
  branch?: string;
42
+ tier: ContextArtifactTier;
39
43
  expiresAt?: number;
40
44
  pinned: boolean;
41
45
  parentIds: string[];
@@ -50,6 +54,7 @@ export interface ContextLookupQuery {
50
54
  path?: string;
51
55
  commandPrefix?: string;
52
56
  branch?: string;
57
+ tier?: ContextArtifactTier;
53
58
  text?: string;
54
59
  limit?: number;
55
60
  }
@@ -59,6 +64,12 @@ export interface ContextBrokerStatus {
59
64
  bytes: number;
60
65
  pinnedRecords: number;
61
66
  pinnedBytes: number;
67
+ hotRecords: number;
68
+ hotBytes: number;
69
+ warmRecords: number;
70
+ warmBytes: number;
71
+ coldRecords: number;
72
+ coldBytes: number;
62
73
  maxRecords: number;
63
74
  maxBytes: number;
64
75
  }
@@ -67,6 +78,15 @@ export interface ContextBrokerOptions {
67
78
  maxRecords?: number;
68
79
  maxBytes?: number;
69
80
  defaultTtlMs?: number;
81
+ hotTtlMs?: number;
82
+ warmTtlMs?: number;
83
+ coldTtlMs?: number;
84
+ hotMaxRecords?: number;
85
+ warmMaxRecords?: number;
86
+ coldMaxRecords?: number;
87
+ hotMaxBytes?: number;
88
+ warmMaxBytes?: number;
89
+ coldMaxBytes?: number;
70
90
  summaryBytes?: number;
71
91
  briefBytes?: number;
72
92
  }
@@ -283,6 +283,15 @@ function brief(s: SessionState): string {
283
283
  return lines.join("\n").slice(0, 1200);
284
284
  }
285
285
 
286
+ function contextBrokerBrief(pi: ExtensionAPI): string {
287
+ try {
288
+ const text = (pi as any).__piRogueContextBroker?.renderBrief?.();
289
+ return typeof text === "string" && text.includes("ctx://") ? sanitizeAdvisorText(text).slice(0, 2400) : "";
290
+ } catch {
291
+ return "";
292
+ }
293
+ }
294
+
286
295
  const CLIPBOARD_IMAGE_PATH_RE = /(?:\/(?:private\/)?var\/folders\/[^\s"'`<>]+\/T|\/(?:tmp|var\/tmp))\/clipboard-\d{4}-\d{2}-\d{2}-[A-Za-z0-9-]+\.(?:png|jpe?g|gif|webp)\b/g;
287
296
 
288
297
  export function sanitizeAdvisorText(text: unknown): string {
@@ -881,12 +890,18 @@ async function askAdvisor(pi: ExtensionAPI, ctx: any, question: string, scope: s
881
890
  const state = loadState();
882
891
  if (!question.trim()) return { text: "Ask a question.", error: "empty" };
883
892
 
884
- const ck = hash("adv", config.model ?? "auto", squish(question, 300), includeWork ? brief(state) : "");
893
+ const brokerBrief = includeWork ? contextBrokerBrief(pi) : "";
894
+ const ck = hash("adv", config.model ?? "auto", squish(question, 300), includeWork ? brief(state) : "", brokerBrief);
885
895
  const cache = loadCache();
886
896
  if (cache[ck]) { state.cacheHits++; saveState(state); return { text: cache[ck], cached: true }; }
887
897
 
888
898
  const msgs = [
889
- { role: "user", content: [ `Question: ${question}`, scope ? `Scope: ${scope}` : "", includeWork && brief(state) ? `Session:\n${brief(state)}` : "" ].filter(Boolean).join("\n"), timestamp: new Date().toISOString() },
899
+ { role: "user", content: [
900
+ `Question: ${question}`,
901
+ scope ? `Scope: ${scope}` : "",
902
+ includeWork && brief(state) ? `Session:\n${brief(state)}` : "",
903
+ brokerBrief ? `Context broker brief:\n${brokerBrief}` : "",
904
+ ].filter(Boolean).join("\n"), timestamp: new Date().toISOString() },
890
905
  ] as any[];
891
906
 
892
907
  const completed = await completeWithModelFallback(ctx, config, ADVISOR_SYSTEM, msgs, { maxTokens: 600, reasoning: "medium" as ThinkingLevel });
@@ -984,7 +999,8 @@ async function doReview(pi: ExtensionAPI, ctx: any, trigger: string, delta: stri
984
999
  }
985
1000
 
986
1001
  const b = brief(state);
987
- if (!b) {
1002
+ const brokerBrief = contextBrokerBrief(pi);
1003
+ if (!b && !brokerBrief) {
988
1004
  finalDecision = "defer";
989
1005
  finalReason = "missing brief context";
990
1006
  markReviewApplied(state, signature, trigger, finalDecision, finalReason, true);
@@ -993,7 +1009,7 @@ async function doReview(pi: ExtensionAPI, ctx: any, trigger: string, delta: stri
993
1009
  return;
994
1010
  }
995
1011
 
996
- const rk = hash("rev", trigger, b, delta, String(meta.fileChanged), String(meta.failed), String(meta.isAgentEnd), String(reviewRoute.label), signature);
1012
+ const rk = hash("rev", trigger, b, brokerBrief, delta, String(meta.fileChanged), String(meta.failed), String(meta.isAgentEnd), String(reviewRoute.label), signature);
997
1013
  const cache = loadCache();
998
1014
  if (cache[rk]) {
999
1015
  finalDecision = "defer";
@@ -1011,7 +1027,8 @@ async function doReview(pi: ExtensionAPI, ctx: any, trigger: string, delta: stri
1011
1027
  `Delta: ${delta || "(none)"}`,
1012
1028
  `Files: ${meta.fileChanged} Errors: ${meta.failed}`,
1013
1029
  `Route: ${summarizeRoute(reviewRoute)}`,
1014
- `Brief:\n${b}`,
1030
+ b ? `Brief:\n${b}` : "",
1031
+ brokerBrief ? `Context broker brief:\n${brokerBrief}` : "",
1015
1032
  ].join("\n"), timestamp: new Date().toISOString() },
1016
1033
  ] as any[];
1017
1034
  const completed = await completeWithModelFallback(ctx, config, REVIEW_SYSTEM, msgs, { maxTokens: 400, reasoning: "low" as ThinkingLevel });
@@ -1140,13 +1157,14 @@ export function registerAdvisor(pi: ExtensionAPI): void {
1140
1157
  const prompt = typeof event.prompt === "string" && event.prompt.trim() ? squish(event.prompt, 1000) : "";
1141
1158
  if (prompt) state.lastTask = prompt;
1142
1159
  const briefText = brief(state);
1160
+ const brokerBrief = contextBrokerBrief(pi);
1143
1161
  const intent = prompt ? classifyIntent(prompt) : "";
1144
1162
  const mode = prompt ? classifyMode(prompt) : "";
1145
1163
  const intentTag = intent ? `Intent: ${intent}` : "";
1146
1164
  const modeTag = mode ? `Mode: ${mode}` : "";
1147
1165
  // Enrich preflight text with session context so the binary gate has more signal
1148
- const enrichedText = [prompt, event.systemPrompt || "", briefText ? `Brief: ${briefText}` : "", intentTag, modeTag].filter(Boolean).join(" ");
1149
- const routeInput: AdvisorRouteInput = { phase: "preflight", text: enrichedText || prompt || event.systemPrompt || briefText || intentTag || modeTag || "", brief: briefText };
1166
+ const enrichedText = [prompt, event.systemPrompt || "", briefText ? `Brief: ${briefText}` : "", brokerBrief ? `Context broker: ${brokerBrief}` : "", intentTag, modeTag].filter(Boolean).join(" ");
1167
+ const routeInput: AdvisorRouteInput = { phase: "preflight", text: enrichedText || prompt || event.systemPrompt || briefText || brokerBrief || intentTag || modeTag || "", brief: [briefText, brokerBrief].filter(Boolean).join("\n\n") };
1150
1168
 
1151
1169
  // Binary gate model — fast local classifier for continue/escalate decisions
1152
1170
  const gatePrediction = binaryGatePredict(routeInput.text);
@@ -1191,6 +1209,7 @@ export function registerAdvisor(pi: ExtensionAPI): void {
1191
1209
  note,
1192
1210
  controlTag,
1193
1211
  briefText ? `Brief (cache-aware):\n${briefText}` : "",
1212
+ brokerBrief ? `Context broker brief (lookup-first):\n${brokerBrief}` : "",
1194
1213
  ].filter(Boolean).join("\n\n"),
1195
1214
  };
1196
1215
  });
@@ -93,6 +93,7 @@ describe("advisor two-agent convergence", () => {
93
93
  let messageRenderers: MessageRendererMap;
94
94
  let sendMessageMock: ReturnType<typeof vi.fn>;
95
95
  let completeSimpleMock: ReturnType<typeof vi.fn>;
96
+ let piMock: any;
96
97
  let priorState: string | null = null;
97
98
  let priorConfig: string | null = null;
98
99
  let priorCache: string | null = null;
@@ -107,6 +108,7 @@ describe("advisor two-agent convergence", () => {
107
108
  commands = setup.commands;
108
109
  messageRenderers = setup.messageRenderers;
109
110
  sendMessageMock = setup.sendMessage;
111
+ piMock = setup.pi;
110
112
 
111
113
  mkdirSync(dirname(ADVISOR_STATE_PATH), { recursive: true });
112
114
  writeFileSync(ADVISOR_CONFIG_PATH, JSON.stringify({ mode: "auto", review: "light", checkins: "off", checkinIntervalMinutes: 30 }, null, 2), "utf8");
@@ -377,6 +379,21 @@ describe("advisor two-agent convergence", () => {
377
379
  );
378
380
  });
379
381
 
382
+ it("includes broker briefs in manual advisor context when available", async () => {
383
+ expect(commands.advisor).toBeTruthy();
384
+ piMock.__piRogueContextBroker = {
385
+ renderBrief: () => "## Context Broker\nHot:\n- ctx://session/s/tool_output/abc/ctx-1 summary=\"npm test passed\"",
386
+ };
387
+ completeSimpleMock.mockResolvedValue({ content: [{ type: "text", text: "Use the broker handle as evidence." }] });
388
+
389
+ await commands.advisor.handler("should we use broker context", ctx);
390
+
391
+ const messages = completeSimpleMock.mock.calls.at(-1)?.[1]?.messages;
392
+ const promptText = JSON.stringify(messages ?? completeSimpleMock.mock.calls.at(-1));
393
+ expect(promptText).toContain("Context broker brief");
394
+ expect(promptText).toContain("ctx://session/s/tool_output/abc/ctx-1");
395
+ });
396
+
380
397
  it("does not re-run advisory review on repeated agent-end material snapshots", async () => {
381
398
  const preflight = handlers.before_agent_start;
382
399
  const agentEnd = handlers.agent_end;
@@ -5,9 +5,10 @@ Beta context broker runtime for Pi-Rogue.
5
5
  This package contains the executable in-memory bounded broker implementation:
6
6
 
7
7
  - `createInMemoryContextBroker()` stores artifacts behind stable `ctx://...` handles.
8
- - Lookups support handle, session, kind, tag, path, command prefix, branch, and text filters.
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
- - Pruning enforces per-session record/byte caps, TTL expiry on reads, and pinned-artifact retention.
10
+ - Artifacts are classified as hot/warm/cold on publish; prompt briefs render hot first, warm second, and exclude cold unless explicitly queried.
11
+ - Pruning enforces per-session record/byte caps, tier-specific record/byte caps, TTL expiry on reads, and pinned-artifact retention.
11
12
 
12
13
  It is intentionally disabled by default in the bundle.
13
14
 
@@ -19,7 +20,7 @@ Set `PI_CONTEXT_BROKER_ENABLED=true` before starting Pi with the bundle installe
19
20
  PI_CONTEXT_BROKER_ENABLED=true pi
20
21
  ```
21
22
 
22
- When enabled, the bundle registers `/context` commands:
23
+ When enabled, the bundle registers a `context_lookup` LLM tool plus `/context` commands:
23
24
 
24
25
  - `/context status` — enabled state, record/byte counts, pinned counts.
25
26
  - `/context brief` — bounded prompt-safe broker brief with handles and summaries.
@@ -29,10 +30,15 @@ When enabled, the bundle registers `/context` commands:
29
30
 
30
31
  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.
31
32
 
33
+ 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.
34
+
32
35
  ## Session behavior and limits
33
36
 
34
37
  - On session start/reload, the beta backfills the current Pi session branch from `toolResult` and prompt-visible `bashExecution` entries.
35
38
  - Backfill is idempotent by session entry id, skips malformed entries instead of failing the session, and honors Pi's `excludeFromContext` bash entries.
36
- - The current implementation remains in-memory. Restarting Pi loses broker state until the current branch is backfilled again.
37
- - Prompt integration injects only a bounded broker brief and lookup guidance. It does not yet rewrite existing raw tool-result messages out of Pi's transcript context; that deeper prompt-load reduction remains a follow-up.
38
- - Rollback is immediate: unset `PI_CONTEXT_BROKER_ENABLED` and `/reload` or restart Pi.
39
+ - Without durable mode, restarting Pi loses broker state until the current branch is backfilled again.
40
+ - Prompt integration injects a bounded, tier-aware broker brief and lookup guidance; the LLM also gets a `context_lookup` tool for exact handle dereferencing.
41
+ - The `context` hook rewrites large `toolResult` and prompt-visible `bashExecution` payloads in the LLM-bound message copy to broker handles and summaries, reducing prompt load while preserving exact `/context lookup` rehydration.
42
+ - Pi `excludeFromContext` bash entries are not backfilled or rewritten into broker prompts.
43
+ - Basic secret redaction runs before broker storage and display for common token/password/API-key patterns.
44
+ - Rollback is immediate: unset `PI_CONTEXT_BROKER_ENABLED` and `/reload` or restart Pi. Disable durable writes by unsetting `PI_CONTEXT_BROKER_DURABLE` and `PI_CONTEXT_BROKER_STORE_DIR`.
@@ -15,10 +15,13 @@
15
15
  "main": "./src/index.ts",
16
16
  "exports": {
17
17
  ".": "./src/index.ts",
18
- "./extension": "./src/extension.ts"
18
+ "./extension": "./src/extension.ts",
19
+ "./file": "./src/file.ts",
20
+ "./sqlite": "./src/sqlite.ts"
19
21
  },
20
22
  "dependencies": {
21
- "@fiale-plus/pi-core": "^0.1.0"
23
+ "@fiale-plus/pi-core": "^0.1.0",
24
+ "typebox": "^1.1.24"
22
25
  },
23
26
  "files": [
24
27
  "src",
@@ -1,9 +1,13 @@
1
+ import { mkdtempSync, rmSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
1
4
  import { afterEach, describe, expect, it } from "vitest";
2
5
  import { registerContextBrokerBeta, shouldEnableContextBrokerBeta } from "./extension.js";
3
6
 
4
7
  function createPiMock() {
5
8
  const handlers = new Map<string, any[]>();
6
9
  const commands = new Map<string, any>();
10
+ const tools = new Map<string, any>();
7
11
  const pi: any = {
8
12
  on(name: string, handler: any) {
9
13
  handlers.set(name, [...(handlers.get(name) ?? []), handler]);
@@ -11,8 +15,11 @@ function createPiMock() {
11
15
  registerCommand(name: string, options: any) {
12
16
  commands.set(name, options);
13
17
  },
18
+ registerTool(tool: any) {
19
+ tools.set(tool.name, tool);
20
+ },
14
21
  };
15
- return { pi, handlers, commands };
22
+ return { pi, handlers, commands, tools };
16
23
  }
17
24
 
18
25
  function createCtx(entries: any[] = []) {
@@ -61,12 +68,13 @@ describe("context broker beta enablement", () => {
61
68
  expect(shouldEnableContextBrokerBeta()).toBe(true);
62
69
  });
63
70
 
64
- it("registers /context with command completions", () => {
65
- const { pi, commands } = createPiMock();
71
+ it("registers /context with command completions and the context_lookup tool", () => {
72
+ const { pi, commands, tools } = createPiMock();
66
73
  registerContextBrokerBeta(pi);
67
74
 
68
75
  const command = commands.get("context");
69
76
  expect(command).toBeTruthy();
77
+ expect(tools.has("context_lookup")).toBe(true);
70
78
  expect(command.getArgumentCompletions("")?.map((item: any) => item.value.trim())).toEqual([
71
79
  "status",
72
80
  "brief",
@@ -196,6 +204,257 @@ describe("context broker beta enablement", () => {
196
204
  expect(Buffer.byteLength(payload, "utf8")).toBeLessThanOrEqual(50);
197
205
  });
198
206
 
207
+ it("context_lookup tool dereferences handles for exact evidence", async () => {
208
+ const { pi, handlers, commands, tools } = createPiMock();
209
+ registerContextBrokerBeta(pi, { lookupBytes: 500 });
210
+ const { ctx } = createCtx();
211
+
212
+ await runHandlers(handlers, "session_start", { type: "session_start" }, ctx);
213
+ await runHandlers(handlers, "tool_result", {
214
+ type: "tool_result",
215
+ toolCallId: "call-tool-lookup",
216
+ toolName: "bash",
217
+ input: { command: "echo evidence" },
218
+ content: [{ type: "text", text: "exact evidence payload" }],
219
+ isError: false,
220
+ }, ctx);
221
+ const handle = commands.get("context").getArgumentCompletions("lookup ")?.[0].value.replace(/^lookup /, "");
222
+ const result = await tools.get("context_lookup").execute("lookup-call", { handle }, undefined, undefined, ctx);
223
+
224
+ expect(result.content[0].text).toContain(handle);
225
+ expect(result.content[0].text).toContain("exact evidence payload");
226
+ });
227
+
228
+ it("context_lookup refuses empty unfocused payload-dumping calls", async () => {
229
+ const { pi, handlers, tools } = createPiMock();
230
+ registerContextBrokerBeta(pi, { lookupBytes: 500 });
231
+ const { ctx } = createCtx();
232
+
233
+ await runHandlers(handlers, "session_start", { type: "session_start" }, ctx);
234
+ await runHandlers(handlers, "tool_result", {
235
+ type: "tool_result",
236
+ toolCallId: "call-empty-lookup",
237
+ toolName: "bash",
238
+ input: { command: "echo hidden" },
239
+ content: [{ type: "text", text: "payload must not dump" }],
240
+ isError: false,
241
+ }, ctx);
242
+
243
+ const result = await tools.get("context_lookup").execute("lookup-call", {}, undefined, undefined, ctx);
244
+
245
+ expect(result.content[0].text).toContain("requires a focused filter");
246
+ expect(result.content[0].text).not.toContain("payload must not dump");
247
+ });
248
+
249
+ it("rewrites large historical tool results in context to live broker handles", async () => {
250
+ const { pi, handlers, commands } = createPiMock();
251
+ registerContextBrokerBeta(pi, { rewriteThresholdBytes: 40, lookupBytes: 500 });
252
+ const { ctx, notifications } = createCtx();
253
+ const raw = "RAW_TOOL_OUTPUT_" + "x".repeat(100);
254
+
255
+ await runHandlers(handlers, "session_start", { type: "session_start" }, ctx);
256
+ const result = await handlers.get("context")?.[0]({
257
+ type: "context",
258
+ messages: [
259
+ { role: "assistant", content: [{ type: "toolCall", id: "call-large", name: "bash", arguments: { command: "printf raw" } }] },
260
+ { role: "toolResult", toolCallId: "call-large", toolName: "bash", content: [{ type: "text", text: raw }], isError: false, timestamp: 1 },
261
+ ],
262
+ }, ctx);
263
+
264
+ const text = result.messages[1].content[0].text;
265
+ const handle = text.match(/ctx:\/\/\S+/)?.[0];
266
+ expect(text).toContain("Context broker artifact: ctx://");
267
+ expect(text).toContain("Raw payload omitted from prompt");
268
+ expect(text).not.toContain(raw);
269
+
270
+ await commands.get("context").handler(`lookup ${handle}`, ctx);
271
+ expect(notifications.at(-1)?.message).toContain("RAW_TOOL_OUTPUT_");
272
+ });
273
+
274
+ it("leaves small tool results and excluded bash outputs unchanged in context", async () => {
275
+ const { pi, handlers } = createPiMock();
276
+ registerContextBrokerBeta(pi, { rewriteThresholdBytes: 40 });
277
+ const { ctx } = createCtx();
278
+ const secret = "SECRET_TOKEN=" + "z".repeat(80);
279
+
280
+ await runHandlers(handlers, "session_start", { type: "session_start" }, ctx);
281
+ const result = await handlers.get("context")?.[0]({
282
+ type: "context",
283
+ messages: [
284
+ { role: "toolResult", toolCallId: "small", toolName: "read", content: [{ type: "text", text: "small" }], isError: false, timestamp: 1 },
285
+ { role: "bashExecution", command: "echo secret", output: secret, exitCode: 0, cancelled: false, truncated: false, excludeFromContext: true, timestamp: 2 },
286
+ ],
287
+ }, ctx);
288
+
289
+ expect(result).toBeUndefined();
290
+ });
291
+
292
+ it("does not collapse repeated bash rewrites for the same command and timestamp", async () => {
293
+ const { pi, handlers, commands } = createPiMock();
294
+ registerContextBrokerBeta(pi, { rewriteThresholdBytes: 20 });
295
+ const { ctx, notifications } = createCtx();
296
+ const firstRaw = "FIRST_RAW_" + "x".repeat(80);
297
+ const secondRaw = "SECOND_RAW_" + "y".repeat(80);
298
+ const sameTimestamp = Date.now();
299
+
300
+ await runHandlers(handlers, "session_start", { type: "session_start" }, ctx);
301
+ const result = await handlers.get("context")?.[0]({
302
+ type: "context",
303
+ messages: [
304
+ { role: "bashExecution", command: "npm test", output: firstRaw, exitCode: 0, cancelled: false, truncated: false, timestamp: sameTimestamp },
305
+ { role: "bashExecution", command: "npm test", output: secondRaw, exitCode: 0, cancelled: false, truncated: false, timestamp: sameTimestamp },
306
+ ],
307
+ }, ctx);
308
+
309
+ const firstHandle = result.messages[0].output.match(/ctx:\/\/\S+/)?.[0];
310
+ const secondHandle = result.messages[1].output.match(/ctx:\/\/\S+/)?.[0];
311
+ expect(firstHandle).toBeTruthy();
312
+ expect(secondHandle).toBeTruthy();
313
+ expect(firstHandle).not.toBe(secondHandle);
314
+
315
+ await commands.get("context").handler(`lookup ${secondHandle}`, ctx);
316
+ expect(notifications.at(-1)?.message).toContain("SECOND_RAW_");
317
+ expect(notifications.at(-1)?.message).not.toContain("FIRST_RAW_");
318
+ });
319
+
320
+ it("does not emit dead handles when one context pass exceeds retention caps", async () => {
321
+ const { pi, handlers, commands } = createPiMock();
322
+ registerContextBrokerBeta(pi, { rewriteThresholdBytes: 1, maxRecords: 2, lookupBytes: 500 });
323
+ const { ctx, notifications } = createCtx();
324
+
325
+ await runHandlers(handlers, "session_start", { type: "session_start" }, ctx);
326
+ const result = await handlers.get("context")?.[0]({
327
+ type: "context",
328
+ messages: [0, 1, 2].map((index) => ({
329
+ role: "toolResult",
330
+ toolCallId: `call-${index}`,
331
+ toolName: "bash",
332
+ content: [{ type: "text", text: `RAW_${index}_` + "x".repeat(20) }],
333
+ isError: false,
334
+ timestamp: Date.now() + index,
335
+ })),
336
+ }, ctx);
337
+
338
+ const handles = result.messages
339
+ .map((message: any) => String(message.content?.[0]?.text ?? "").match(/ctx:\/\/\S+/)?.[0])
340
+ .filter(Boolean);
341
+ expect(handles.length).toBeLessThanOrEqual(2);
342
+ expect(result.messages.some((message: any) => String(message.content?.[0]?.text ?? "").includes("RAW_0_"))).toBe(true);
343
+
344
+ for (const handle of handles) {
345
+ await commands.get("context").handler(`lookup ${handle}`, ctx);
346
+ expect(notifications.at(-1)?.message).not.toContain("No context artifacts matched");
347
+ expect(notifications.at(-1)?.message).toContain("RAW_");
348
+ }
349
+ });
350
+
351
+ it("redacts secrets before storing and displaying payloads", async () => {
352
+ const { pi, handlers, commands } = createPiMock();
353
+ registerContextBrokerBeta(pi);
354
+ const { ctx, notifications } = createCtx();
355
+
356
+ await runHandlers(handlers, "session_start", { type: "session_start" }, ctx);
357
+ await runHandlers(handlers, "tool_result", {
358
+ type: "tool_result",
359
+ toolCallId: "secret-call",
360
+ toolName: "bash",
361
+ input: { command: "echo token=abc123456789", password: "hunter2" },
362
+ content: [{ type: "text", text: "OPENAI_API_KEY=sk-abcdefghijklmnop" }],
363
+ details: { nested: { apiKey: "object-secret-value" } },
364
+ isError: false,
365
+ }, ctx);
366
+
367
+ const lookupCompletion = commands.get("context").getArgumentCompletions("lookup ")?.[0];
368
+ await commands.get("context").handler(lookupCompletion.value, ctx);
369
+
370
+ expect(notifications.at(-1)?.message).not.toContain("abc123456789");
371
+ expect(notifications.at(-1)?.message).not.toContain("hunter2");
372
+ expect(notifications.at(-1)?.message).not.toContain("object-secret-value");
373
+ expect(notifications.at(-1)?.message).not.toContain("sk-abcdefghijklmnop");
374
+ expect(notifications.at(-1)?.message).toContain("[REDACTED");
375
+ });
376
+
377
+ it("re-publishes stale source handles instead of restoring raw prompt payloads", async () => {
378
+ const { pi, handlers, commands } = createPiMock();
379
+ registerContextBrokerBeta(pi, { maxRecords: 1, rewriteThresholdBytes: 20 });
380
+ const { ctx } = createCtx();
381
+ const raw = "STALE_RAW_PAYLOAD_" + "x".repeat(100);
382
+
383
+ await runHandlers(handlers, "session_start", { type: "session_start" }, ctx);
384
+ await runHandlers(handlers, "tool_result", {
385
+ type: "tool_result",
386
+ toolCallId: "stale-call",
387
+ toolName: "bash",
388
+ input: { command: "echo stale" },
389
+ content: [{ type: "text", text: raw }],
390
+ isError: false,
391
+ timestamp: 1,
392
+ }, ctx);
393
+ await runHandlers(handlers, "tool_result", {
394
+ type: "tool_result",
395
+ toolCallId: "newer-call",
396
+ toolName: "bash",
397
+ input: { command: "echo newer" },
398
+ content: [{ type: "text", text: "newer" }],
399
+ isError: false,
400
+ timestamp: 2,
401
+ }, ctx);
402
+ await commands.get("context").handler("prune", ctx);
403
+
404
+ const result = await handlers.get("context")?.[0]({
405
+ type: "context",
406
+ messages: [{ role: "toolResult", toolCallId: "stale-call", toolName: "bash", content: [{ type: "text", text: raw }], isError: false, timestamp: 1 }],
407
+ }, ctx);
408
+
409
+ expect(result.messages[0].content[0].text).toContain("Context broker artifact: ctx://");
410
+ expect(result.messages[0].content[0].text).not.toContain(raw);
411
+ });
412
+
413
+ it("can reload artifacts and pin state from durable blob storage", async () => {
414
+ const dir = mkdtempSync(join(tmpdir(), "ctx-broker-test-"));
415
+ try {
416
+ const first = createPiMock();
417
+ registerContextBrokerBeta(first.pi, { durable: true, storeDir: dir });
418
+ const { ctx } = createCtx();
419
+ await runHandlers(first.handlers, "session_start", { type: "session_start" }, ctx);
420
+ await runHandlers(first.handlers, "tool_result", {
421
+ type: "tool_result",
422
+ toolCallId: "durable-call",
423
+ toolName: "bash",
424
+ input: { command: "echo durable" },
425
+ content: [{ type: "text", text: "durable payload" }],
426
+ isError: false,
427
+ timestamp: 100,
428
+ }, ctx);
429
+ const handle = first.commands.get("context").getArgumentCompletions("lookup ")?.[0].value.replace(/^lookup /, "");
430
+ await first.commands.get("context").handler(`pin ${handle}`, ctx);
431
+
432
+ const second = createPiMock();
433
+ const secondRun = createCtx();
434
+ registerContextBrokerBeta(second.pi, { durable: true, storeDir: dir });
435
+ await runHandlers(second.handlers, "session_start", { type: "session_start" }, secondRun.ctx);
436
+ const secondHandle = second.commands.get("context").getArgumentCompletions("lookup ")?.[0].value.replace(/^lookup /, "");
437
+ await second.commands.get("context").handler(`lookup ${handle}`, secondRun.ctx);
438
+ await second.commands.get("context").handler("brief", secondRun.ctx);
439
+
440
+ const third = createPiMock();
441
+ const thirdRun = createCtx();
442
+ registerContextBrokerBeta(third.pi, { durable: true, storeDir: dir });
443
+ await runHandlers(third.handlers, "session_start", { type: "session_start" }, thirdRun.ctx);
444
+ await third.commands.get("context").handler(`lookup ${secondHandle}`, thirdRun.ctx);
445
+ await third.commands.get("context").handler("brief", thirdRun.ctx);
446
+
447
+ expect(secondRun.notifications.at(-2)?.message).toContain("durable payload");
448
+ expect(secondRun.notifications.at(-1)?.message).toContain("tier=hot");
449
+ expect(secondRun.notifications.at(-1)?.message).toContain("pinned");
450
+ expect(thirdRun.notifications.at(-2)?.message).toContain("durable payload");
451
+ expect(thirdRun.notifications.at(-1)?.message).toContain("tier=hot");
452
+ expect(thirdRun.notifications.at(-1)?.message).toContain("pinned");
453
+ } finally {
454
+ rmSync(dir, { recursive: true, force: true });
455
+ }
456
+ });
457
+
199
458
  it("injects a bounded broker brief without raw payload text", async () => {
200
459
  const { pi, handlers } = createPiMock();
201
460
  registerContextBrokerBeta(pi, { briefBytes: 220 });