@fiale-plus/pi-rogue-bundle 0.1.19 → 0.1.20

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
@@ -6,7 +6,7 @@ It stitches together (and bundles for a true single-package install):
6
6
 
7
7
  - `@fiale-plus/pi-core` (shared contracts/helpers)
8
8
  - `@fiale-plus/pi-rogue-advisor` (logic; direct releases paused)
9
- - `@fiale-plus/pi-rogue-context-broker` (beta context-broker runtime; disabled by default)
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
11
 
12
12
  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.
@@ -28,9 +28,9 @@ npm install
28
28
  ## Scope boundaries
29
29
 
30
30
  - **Lab / internal helpers are excluded from this bundle.**
31
- - The beta context-broker runtime is bundled for opt-in experiments but is not registered/enabled by default.
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 and prompt-load rewriting.
31
+ - The context-broker runtime is bundled and registered by default in the bundle.
32
+ - Consumers can import the runtime through the bundle subpath: `@fiale-plus/pi-rogue-bundle/context-broker`.
33
+ - Set `PI_CONTEXT_BROKER_ENABLED=false` before starting Pi to disable the `/context` command surface and prompt-load rewriting.
34
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.
35
35
  - `@fiale-plus/pi-rogue-bundle` is the only published surface for the logic.
36
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.
@@ -38,7 +38,7 @@ npm install
38
38
  ## Command surface
39
39
 
40
40
  - Default: `/advisor`, `/goal`, `/loop`, `/autoresearch`, `/autoresearch-lab` plus status/config/command paths (all provided via the bundle).
41
- - Opt-in beta: `PI_CONTEXT_BROKER_ENABLED=true` adds `/context status`, `/context brief`, `/context lookup <handle|text>`, `/context pin <handle>`, and `/context prune` with autocomplete.
41
+ - 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
42
 
43
43
  ## Status
44
44
 
@@ -77,6 +77,8 @@ export interface ContextBrokerStatus {
77
77
  export interface ContextBrokerOptions {
78
78
  maxRecords?: number;
79
79
  maxBytes?: number;
80
+ globalMaxRecords?: number;
81
+ globalMaxBytes?: number;
80
82
  defaultTtlMs?: number;
81
83
  hotTtlMs?: number;
82
84
  warmTtlMs?: number;
@@ -8,37 +8,46 @@ 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
- - Pruning enforces per-session record/byte caps, tier-specific record/byte caps, TTL expiry on reads, and pinned-artifact retention.
11
+ - 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
12
 
13
- It is intentionally disabled by default in the bundle.
13
+ It is registered by default in the bundle, with an explicit env kill switch.
14
14
 
15
- ## Opt-in beta extension
15
+ ## Mainline extension
16
16
 
17
- Set `PI_CONTEXT_BROKER_ENABLED=true` before starting Pi with the bundle installed to enable the beta extension:
17
+ The bundle registers a `context_lookup` LLM tool plus `/context` commands by default. To disable the runtime for rollback:
18
18
 
19
19
  ```bash
20
- PI_CONTEXT_BROKER_ENABLED=true pi
20
+ PI_CONTEXT_BROKER_ENABLED=false pi
21
21
  ```
22
22
 
23
- When enabled, the bundle registers a `context_lookup` LLM tool plus `/context` commands:
23
+ When active, the bundle registers:
24
24
 
25
- - `/context status` — enabled state, record/byte counts, pinned counts.
25
+ - `/context status` — enabled state, record/byte counts, pinned counts, and routing telemetry.
26
26
  - `/context brief` — bounded prompt-safe broker brief with handles and summaries.
27
27
  - `/context lookup <handle|text>` — exact handle rehydration or current-session text search.
28
28
  - `/context pin <handle>` — protect an artifact from normal TTL/cap pruning.
29
+ - `/context export <handle>` — write full payload to a temp file without dumping it into prompt.
29
30
  - `/context prune` — run TTL/cap pruning immediately.
30
31
 
31
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.
32
33
 
33
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.
34
35
 
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
+
38
+ For quieter sessions, set `PI_CONTEXT_BROKER_REWRITE_THRESHOLD_BYTES` to a higher value to only rewrite larger outputs.
39
+
40
+
35
41
  ## Session behavior and limits
36
42
 
37
- - On session start/reload, the beta backfills the current Pi session branch from `toolResult` and prompt-visible `bashExecution` entries.
43
+ - On session start/reload, the runtime backfills the current Pi session branch from `toolResult` and prompt-visible `bashExecution` entries.
38
44
  - Backfill is idempotent by session entry id, skips malformed entries instead of failing the session, and honors Pi's `excludeFromContext` bash entries.
39
45
  - 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.
46
+ - 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
+ - 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.
42
48
  - Pi `excludeFromContext` bash entries are not backfilled or rewritten into broker prompts.
43
49
  - 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`.
50
+ - Optional global caps can be configured via env vars:
51
+ - `PI_CONTEXT_BROKER_GLOBAL_MAX_RECORDS`
52
+ - `PI_CONTEXT_BROKER_GLOBAL_MAX_BYTES`
53
+ - 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`.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@fiale-plus/pi-rogue-context-broker",
3
3
  "version": "0.1.0",
4
- "description": "Beta context broker runtime for Pi-Rogue. In-memory bounded broker implementation behind explicit opt-in.",
4
+ "description": "Context broker runtime for Pi-Rogue with bounded handle-first prompt payload storage.",
5
5
  "private": true,
6
6
  "type": "module",
7
7
  "license": "MIT",
@@ -52,7 +52,7 @@ async function runHandlers(handlers: Map<string, any[]>, name: string, event: an
52
52
  }
53
53
  }
54
54
 
55
- describe("context broker beta enablement", () => {
55
+ describe("context broker extension enablement", () => {
56
56
  const oldEnv = process.env.PI_CONTEXT_BROKER_ENABLED;
57
57
 
58
58
  afterEach(() => {
@@ -117,7 +117,7 @@ describe("context broker beta enablement", () => {
117
117
 
118
118
  expect(notifications[0].message).toContain("Backfilled 2/2");
119
119
  expect(notifications[1].message).toContain("Backfilled 0/2");
120
- expect(notifications.at(-2)?.message).toContain("records=2");
120
+ expect(notifications.find((entry) => entry.message.includes("Context broker: enabled"))?.message).toContain("records=2");
121
121
  expect(notifications.at(-1)?.message).toContain("README.md");
122
122
  });
123
123
 
@@ -251,6 +251,33 @@ describe("context broker beta enablement", () => {
251
251
  rmSync(exportPath);
252
252
  });
253
253
 
254
+ it("omits hostile payloads from lookup output and suggests export", async () => {
255
+ const { pi, handlers, commands } = createPiMock();
256
+ registerContextBrokerBeta(pi);
257
+ const { ctx, notifications } = createCtx();
258
+ const payload = `safe\u0000binary${"\u0007".repeat(12)}tail`;
259
+
260
+ await runHandlers(handlers, "session_start", { type: "session_start" }, ctx);
261
+ await runHandlers(handlers, "tool_result", {
262
+ type: "tool_result",
263
+ toolCallId: "call-hostile",
264
+ toolName: "bash",
265
+ input: { command: "printf host" },
266
+ content: [{ type: "text", text: payload }],
267
+ isError: false,
268
+ }, ctx);
269
+
270
+ const lookupCompletion = commands.get("context").getArgumentCompletions("lookup ")?.[0];
271
+ expect(lookupCompletion?.value.startsWith("lookup ctx://")).toBe(true);
272
+ const lookupHandle = lookupCompletion?.value.replace(/^lookup /, "");
273
+
274
+ await commands.get("context").handler(`lookup ${lookupHandle}`, ctx);
275
+ const commandMessage = notifications.at(-1)?.message ?? "";
276
+ expect(commandMessage).toContain("payload intentionally omitted from prompt");
277
+ expect(commandMessage).toContain("/context export");
278
+ expect(commandMessage).not.toContain("\u0000");
279
+ });
280
+
254
281
  it("text search lookup returns a smaller byte-clipped excerpt", async () => {
255
282
  const { pi, handlers, commands } = createPiMock();
256
283
  registerContextBrokerBeta(pi, { lookupBytes: 80, searchBytes: 50 });
@@ -277,7 +304,7 @@ describe("context broker beta enablement", () => {
277
304
  const { pi, handlers, commands } = createPiMock();
278
305
  registerContextBrokerBeta(pi);
279
306
  const { ctx, notifications } = createCtx();
280
- const rawPayload = `${"SAFE"}\u0000${"\x1B"}[31mBLOCK\u0000`;
307
+ const rawPayload = `${"SAFE"}\u0000${"x".repeat(220)}`;
281
308
 
282
309
  await runHandlers(handlers, "session_start", { type: "session_start" }, ctx);
283
310
  await runHandlers(handlers, "tool_result", {
@@ -294,7 +321,6 @@ describe("context broker beta enablement", () => {
294
321
 
295
322
  const message = notifications.at(-1)?.message ?? "";
296
323
  expect(message).toContain("\\u0000");
297
- expect(message).toContain("\\u001b");
298
324
  expect(message).not.toContain(String.fromCharCode(0));
299
325
  });
300
326
 
@@ -319,6 +345,44 @@ describe("context broker beta enablement", () => {
319
345
  expect(result.content[0].text).toContain("exact evidence payload");
320
346
  });
321
347
 
348
+ it("reports routing telemetry in /context status", async () => {
349
+ const { pi, handlers, commands, tools } = createPiMock();
350
+ registerContextBrokerBeta(pi, { rewriteThresholdBytes: 1, lookupBytes: 500, searchBytes: 500 });
351
+ const { ctx, notifications } = createCtx();
352
+
353
+ await runHandlers(handlers, "session_start", { type: "session_start" }, ctx);
354
+ await runHandlers(handlers, "tool_result", {
355
+ type: "tool_result",
356
+ toolCallId: "call-tool-telemetry",
357
+ toolName: "bash",
358
+ input: { command: "echo telemetry" },
359
+ content: [{ type: "text", text: "telemetry_payload_" + "x".repeat(200) }],
360
+ isError: false,
361
+ }, ctx);
362
+
363
+ const handle = commands.get("context").getArgumentCompletions("lookup ")?.[0].value.replace(/^lookup /, "");
364
+ const toolResult = await tools.get("context_lookup").execute("lookup-call", { handle }, undefined, undefined, ctx);
365
+ await commands.get("context").handler(`lookup ${handle}`, ctx);
366
+ await commands.get("context").handler(`pin ${handle}`, ctx);
367
+ await commands.get("context").handler(`export ${handle}`, ctx);
368
+ const result = await handlers.get("context")?.[0]({
369
+ type: "context",
370
+ messages: [{ role: "toolResult", toolCallId: "tool-result-telemetry", toolName: "bash", content: [{ type: "text", text: "telemetry_payload_" + "y".repeat(150) }], isError: false, timestamp: 1 }],
371
+ }, ctx);
372
+
373
+ await commands.get("context").handler("status", ctx);
374
+
375
+ expect(handle).toBeTruthy();
376
+ expect(toolResult.content[0].text).toContain("telemetry_payload_");
377
+ expect(result).toBeDefined();
378
+ const telemetry = notifications.at(-1)?.message ?? "";
379
+ expect(telemetry).toContain("Context broker routing telemetry:");
380
+ expect(telemetry).toContain("lookups tool(calls=");
381
+ expect(telemetry).toContain("lookups slash(calls=");
382
+ expect(telemetry).toContain("exports=");
383
+ expect(telemetry).toContain("pins=");
384
+ });
385
+
322
386
  it("does not broker context_lookup results recursively", async () => {
323
387
  const { pi, handlers, commands, tools } = createPiMock();
324
388
  registerContextBrokerBeta(pi, { lookupBytes: 500, rewriteThresholdBytes: 1 });
@@ -429,9 +493,9 @@ describe("context broker beta enablement", () => {
429
493
  expect(notifications.at(-1)?.message).toContain("RAW_TOOL_OUTPUT_");
430
494
  });
431
495
 
432
- it("leaves small tool results and excluded bash outputs unchanged in context", async () => {
496
+ it("rewrites small tool results and leaves excluded bash outputs unchanged in context", async () => {
433
497
  const { pi, handlers } = createPiMock();
434
- registerContextBrokerBeta(pi, { rewriteThresholdBytes: 40 });
498
+ registerContextBrokerBeta(pi);
435
499
  const { ctx } = createCtx();
436
500
  const secret = "SECRET_TOKEN=" + "z".repeat(80);
437
501
 
@@ -444,7 +508,8 @@ describe("context broker beta enablement", () => {
444
508
  ],
445
509
  }, ctx);
446
510
 
447
- expect(result).toBeUndefined();
511
+ expect(result?.messages[0].content?.[0]?.text).toContain("Context broker artifact");
512
+ expect(result?.messages[1]).toMatchObject({ role: "bashExecution", output: secret });
448
513
  });
449
514
 
450
515
  it("does not collapse repeated bash rewrites for the same command and timestamp", async () => {
@@ -497,7 +562,8 @@ describe("context broker beta enablement", () => {
497
562
  .map((message: any) => String(message.content?.[0]?.text ?? "").match(/ctx:\/\/\S+/)?.[0])
498
563
  .filter(Boolean);
499
564
  expect(handles.length).toBeLessThanOrEqual(2);
500
- expect(result.messages.some((message: any) => String(message.content?.[0]?.text ?? "").includes("RAW_0_"))).toBe(true);
565
+ expect(result.messages[0].content[0].text).toContain("Context broker artifact pruned before prompt assembly");
566
+ expect(result.messages[0].content[0].text).not.toContain("RAW_0_");
501
567
 
502
568
  for (const handle of handles) {
503
569
  await commands.get("context").handler(`lookup ${handle}`, ctx);
@@ -506,6 +572,27 @@ describe("context broker beta enablement", () => {
506
572
  }
507
573
  });
508
574
 
575
+ it("does not restore pruned hostile payloads into prompt context", async () => {
576
+ const { pi, handlers } = createPiMock();
577
+ registerContextBrokerBeta(pi, { maxRecords: 1 });
578
+ const { ctx } = createCtx();
579
+ const hostile = `HOSTILE_RAW\u0000${"\u0007".repeat(20)}`;
580
+
581
+ await runHandlers(handlers, "session_start", { type: "session_start" }, ctx);
582
+ const result = await handlers.get("context")?.[0]({
583
+ type: "context",
584
+ messages: [
585
+ { role: "toolResult", toolCallId: "hostile-one", toolName: "bash", content: [{ type: "text", text: hostile }], isError: false, timestamp: 1 },
586
+ { role: "toolResult", toolCallId: "hostile-two", toolName: "bash", content: [{ type: "text", text: `SECOND\u0000${"\u0007".repeat(20)}` }], isError: false, timestamp: 2 },
587
+ ],
588
+ }, ctx);
589
+
590
+ const firstText = result.messages[0].content[0].text;
591
+ expect(firstText).toContain("Raw hostile/binary payload omitted from prompt");
592
+ expect(firstText).not.toContain("HOSTILE_RAW");
593
+ expect(firstText).not.toContain("\u0000");
594
+ });
595
+
509
596
  it("redacts secrets before storing and displaying payloads", async () => {
510
597
  const { pi, handlers, commands } = createPiMock();
511
598
  registerContextBrokerBeta(pi);
@@ -572,7 +659,7 @@ describe("context broker beta enablement", () => {
572
659
  const dir = mkdtempSync(join(tmpdir(), "ctx-broker-test-"));
573
660
  try {
574
661
  const first = createPiMock();
575
- registerContextBrokerBeta(first.pi, { durable: true, storeDir: dir });
662
+ await registerContextBrokerBeta(first.pi, { durable: true, storeDir: dir });
576
663
  const { ctx } = createCtx();
577
664
  await runHandlers(first.handlers, "session_start", { type: "session_start" }, ctx);
578
665
  await runHandlers(first.handlers, "tool_result", {
@@ -589,7 +676,7 @@ describe("context broker beta enablement", () => {
589
676
 
590
677
  const second = createPiMock();
591
678
  const secondRun = createCtx();
592
- registerContextBrokerBeta(second.pi, { durable: true, storeDir: dir });
679
+ await registerContextBrokerBeta(second.pi, { durable: true, storeDir: dir });
593
680
  await runHandlers(second.handlers, "session_start", { type: "session_start" }, secondRun.ctx);
594
681
  const secondHandle = second.commands.get("context").getArgumentCompletions("lookup ")?.[0].value.replace(/^lookup /, "");
595
682
  await second.commands.get("context").handler(`lookup ${handle}`, secondRun.ctx);
@@ -597,7 +684,7 @@ describe("context broker beta enablement", () => {
597
684
 
598
685
  const third = createPiMock();
599
686
  const thirdRun = createCtx();
600
- registerContextBrokerBeta(third.pi, { durable: true, storeDir: dir });
687
+ await registerContextBrokerBeta(third.pi, { durable: true, storeDir: dir });
601
688
  await runHandlers(third.handlers, "session_start", { type: "session_start" }, thirdRun.ctx);
602
689
  await third.commands.get("context").handler(`lookup ${secondHandle}`, thirdRun.ctx);
603
690
  await third.commands.get("context").handler("brief", thirdRun.ctx);
@@ -9,12 +9,13 @@ import type { ExtensionAPI, ExtensionContext, ToolResultEvent } from "@earendil-
9
9
  import type { ContextArtifact } from "@fiale-plus/pi-core";
10
10
  import { createFileContextBroker } from "./file.js";
11
11
  import { createInMemoryContextBroker } from "./index.js";
12
- import { createSqliteContextBroker } from "./sqlite.js";
13
12
 
14
13
  export interface ContextBrokerBetaOptions {
15
14
  enabled?: boolean;
16
15
  maxRecords?: number;
17
16
  maxBytes?: number;
17
+ globalMaxRecords?: number;
18
+ globalMaxBytes?: number;
18
19
  briefBytes?: number;
19
20
  lookupBytes?: number;
20
21
  searchBytes?: number;
@@ -29,7 +30,7 @@ type SessionContextLike = Pick<ExtensionContext, "cwd" | "sessionManager"> & { u
29
30
  const DEFAULT_BRIEF_BYTES = 1_800;
30
31
  const DEFAULT_LOOKUP_BYTES = 12_000;
31
32
  const DEFAULT_SEARCH_BYTES = 2_000;
32
- const DEFAULT_REWRITE_THRESHOLD_BYTES = 2_000;
33
+ const DEFAULT_REWRITE_THRESHOLD_BYTES = 0;
33
34
  const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
34
35
  const ENABLED_VALUES = new Set(["1", "true", "yes", "on"]);
35
36
 
@@ -37,6 +38,14 @@ function envFlag(name: string): boolean {
37
38
  return ENABLED_VALUES.has(String(process.env[name] ?? "").trim().toLowerCase());
38
39
  }
39
40
 
41
+ function envNonNegativeInt(name: string): number | undefined {
42
+ const raw = process.env[name];
43
+ if (!raw) return undefined;
44
+ const value = Number.parseInt(raw, 10);
45
+ if (!Number.isFinite(value) || value < 0) return undefined;
46
+ return value;
47
+ }
48
+
40
49
  function isEnvEnabled(): boolean {
41
50
  return envFlag("PI_CONTEXT_BROKER_ENABLED");
42
51
  }
@@ -76,13 +85,57 @@ function sanitizeForPrompt(text: string): string {
76
85
  return String(text).replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g, (char) => `\\x${char.charCodeAt(0).toString(16).padStart(2, "0")}`);
77
86
  }
78
87
 
88
+ function isHostilePayload(payload: string): boolean {
89
+ return hasHostileText(payload);
90
+ }
91
+
92
+ function hasHostileText(text: string): boolean {
93
+ let suspicious = 0;
94
+ let scanned = 0;
95
+ for (const char of text.slice(0, 4096)) {
96
+ const code = char.codePointAt(0) ?? 0;
97
+ scanned += 1;
98
+ if (
99
+ code === 0x00
100
+ || (code >= 0x01 && code <= 0x08)
101
+ || (code >= 0x0E && code <= 0x1F)
102
+ || (code >= 0x7F && code <= 0x9F)
103
+ ) {
104
+ suspicious += 1;
105
+ }
106
+ }
107
+ if (scanned < 12) return suspicious > 0;
108
+ return suspicious / scanned >= 0.05;
109
+ }
110
+
111
+ function hasHostileValue(value: unknown): boolean {
112
+ if (typeof value === "string") return hasHostileText(value);
113
+ if (Array.isArray(value)) return value.some(hasHostileValue);
114
+ if (value && typeof value === "object") {
115
+ return Object.values(value as Record<string, unknown>).some((entry) => hasHostileValue(entry));
116
+ }
117
+ return false;
118
+ }
119
+
79
120
  function renderLookupOutput(item: ContextArtifact, payloadLimit: number): string {
121
+ const isBinary = item.tags.includes("hostile") || item.tags.includes("binary");
122
+ const payloadLines = isBinary
123
+ ? [
124
+ "payload:",
125
+ "[payload intentionally omitted from prompt for safety; use /context export",
126
+ sanitizeForPrompt(item.handle),
127
+ "for full content]",
128
+ ]
129
+ : [
130
+ "payload:",
131
+ truncateUtf8(sanitizeForPrompt(item.payload), payloadLimit),
132
+ ];
133
+
80
134
  return [
81
135
  sanitizeForPrompt(item.handle),
82
136
  `tier=${item.tier} kind=${item.kind} bytes=${item.bytes}`,
83
137
  `summary=${sanitizeForPrompt(item.summary)}`,
84
- "payload:",
85
- truncateUtf8(sanitizeForPrompt(item.payload), payloadLimit),
138
+ ...payloadLines,
86
139
  ].join("\n");
87
140
  }
88
141
 
@@ -180,11 +233,20 @@ function contextLookupHistoryPlaceholder(): string {
180
233
  ].join("\n");
181
234
  }
182
235
 
183
- function summarizeTool(event: { toolName: string; input?: any; isError?: boolean }, bytes: number): string {
236
+ function prunedPayloadPlaceholder(hostile = false): string {
237
+ return [
238
+ "Context broker artifact pruned before prompt assembly.",
239
+ hostile ? "Raw hostile/binary payload omitted from prompt for safety." : "Raw payload omitted from prompt to avoid restoring pruned broker evidence.",
240
+ "Re-run the originating command or use a retained ctx:// handle if exact evidence is still needed.",
241
+ ].join("\n");
242
+ }
243
+
244
+ function summarizeTool(event: { toolName: string; input?: any; isError?: boolean }, bytes: number, hostile = false): string {
184
245
  const command = event.toolName === "bash" ? event.input?.command : undefined;
185
246
  const path = event.input?.path;
186
247
  const target = command ? ` command=${compact(String(command), 120)}` : path ? ` path=${path}` : "";
187
- return `${event.isError ? "failed" : "completed"} ${event.toolName}${target}; payload=${bytes} bytes`;
248
+ const marker = hostile ? "; payload marked hostile; use /context export for full content" : "";
249
+ return `${event.isError ? "failed" : "completed"} ${event.toolName}${target}; payload=${bytes} bytes${marker}`;
188
250
  }
189
251
 
190
252
  const NON_BROKERED_TOOL_NAMES = new Set(["context_lookup"]);
@@ -198,7 +260,7 @@ function ttlFromNowFor(createdAt: number | undefined): number | undefined {
198
260
  return Math.max(DEFAULT_TTL_MS, Date.now() - createdAt + DEFAULT_TTL_MS);
199
261
  }
200
262
 
201
- export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrokerBetaOptions = {}): void {
263
+ export async function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrokerBetaOptions = {}): Promise<void> {
202
264
  const p = pi as any;
203
265
  if (p.__piRogueContextBrokerBetaRegistered) return;
204
266
  p.__piRogueContextBrokerBetaRegistered = true;
@@ -206,10 +268,15 @@ export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrok
206
268
  const briefBytes = options.briefBytes ?? DEFAULT_BRIEF_BYTES;
207
269
  const lookupBytes = options.lookupBytes ?? DEFAULT_LOOKUP_BYTES;
208
270
  const searchBytes = options.searchBytes ?? DEFAULT_SEARCH_BYTES;
209
- const rewriteThresholdBytes = options.rewriteThresholdBytes ?? DEFAULT_REWRITE_THRESHOLD_BYTES;
271
+ const rewriteThresholdBytes =
272
+ options.rewriteThresholdBytes
273
+ ?? envNonNegativeInt("PI_CONTEXT_BROKER_REWRITE_THRESHOLD_BYTES")
274
+ ?? DEFAULT_REWRITE_THRESHOLD_BYTES;
210
275
  const brokerOptions = {
211
276
  maxRecords: options.maxRecords ?? 64,
212
277
  maxBytes: options.maxBytes ?? 8 * 1024 * 1024,
278
+ globalMaxRecords: options.globalMaxRecords ?? envNonNegativeInt("PI_CONTEXT_BROKER_GLOBAL_MAX_RECORDS"),
279
+ globalMaxBytes: options.globalMaxBytes ?? envNonNegativeInt("PI_CONTEXT_BROKER_GLOBAL_MAX_BYTES"),
213
280
  briefBytes,
214
281
  };
215
282
  const durable = options.durable ?? (envFlag("PI_CONTEXT_BROKER_DURABLE") || Boolean(options.storeDir ?? process.env.PI_CONTEXT_BROKER_STORE_DIR));
@@ -217,22 +284,55 @@ export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrok
217
284
  const broker = durable
218
285
  ? durableBackend === "jsonl"
219
286
  ? createFileContextBroker({ ...brokerOptions, dir: options.storeDir ?? process.env.PI_CONTEXT_BROKER_STORE_DIR })
220
- : createSqliteContextBroker({ ...brokerOptions, dir: options.storeDir ?? process.env.PI_CONTEXT_BROKER_STORE_DIR })
287
+ : (await import("./sqlite.js")).createSqliteContextBroker({ ...brokerOptions, dir: options.storeDir ?? process.env.PI_CONTEXT_BROKER_STORE_DIR })
221
288
  : createInMemoryContextBroker(brokerOptions);
222
289
  const seenSourceIds = new Set<string>();
223
290
  const sourceHandles = new Map<string, string>();
224
291
  let activeSessionId = process.cwd();
292
+ const routingTelemetry = {
293
+ contextHookCalls: 0,
294
+ contextHookToolResults: 0,
295
+ contextHookToolResultRewrites: 0,
296
+ contextHookToolResultHostile: 0,
297
+ contextHookBash: 0,
298
+ contextHookBashRewrites: 0,
299
+ contextHookBashHostile: 0,
300
+ toolResultEvents: 0,
301
+ toolResultArtifacts: 0,
302
+ backfillScans: 0,
303
+ backfillAdded: 0,
304
+ backfillErrors: 0,
305
+ toolLookupCalls: 0,
306
+ toolLookupExactCalls: 0,
307
+ toolLookupTextCalls: 0,
308
+ toolLookupHits: 0,
309
+ toolLookupMisses: 0,
310
+ commandLookupCalls: 0,
311
+ commandLookupExactCalls: 0,
312
+ commandLookupTextCalls: 0,
313
+ commandLookupHits: 0,
314
+ commandLookupMisses: 0,
315
+ exportCalls: 0,
316
+ pinCalls: 0,
317
+ statusCalls: 0,
318
+ pruneCalls: 0,
319
+ };
225
320
 
226
- function currentBrief(): string {
227
- return broker.renderBrief({ sessionId: activeSessionId, budgetBytes: briefBytes });
321
+ function formatRoutingTelemetry(): string {
322
+ const line = [
323
+ `contextHook calls=${routingTelemetry.contextHookCalls}`,
324
+ `toolResults seen=${routingTelemetry.contextHookToolResults} rewritten=${routingTelemetry.contextHookToolResultRewrites} hostile=${routingTelemetry.contextHookToolResultHostile}`,
325
+ `bash seen=${routingTelemetry.contextHookBash} rewritten=${routingTelemetry.contextHookBashRewrites} hostile=${routingTelemetry.contextHookBashHostile}`,
326
+ `lookups tool(calls=${routingTelemetry.toolLookupCalls}, hits=${routingTelemetry.toolLookupHits}, misses=${routingTelemetry.toolLookupMisses})`,
327
+ `lookups slash(calls=${routingTelemetry.commandLookupCalls}, hits=${routingTelemetry.commandLookupHits}, misses=${routingTelemetry.commandLookupMisses})`,
328
+ `exports=${routingTelemetry.exportCalls}`,
329
+ `pins=${routingTelemetry.pinCalls}`,
330
+ `pruneCalls=${routingTelemetry.pruneCalls}`,
331
+ `backfill scans=${routingTelemetry.backfillScans} added=${routingTelemetry.backfillAdded} errors=${routingTelemetry.backfillErrors}`,
332
+ ];
333
+ return `Context broker routing telemetry: ${line.join(", ")}`;
228
334
  }
229
335
 
230
- p.__piRogueContextBroker = {
231
- renderBrief: currentBrief,
232
- lookup: broker.lookup,
233
- status: broker.status,
234
- };
235
-
236
336
  function publishToolArtifact(event: {
237
337
  toolName: string;
238
338
  input?: any;
@@ -265,18 +365,25 @@ export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrok
265
365
  };
266
366
  const payload = toolPayload(sanitizedEvent);
267
367
  const bytes = Buffer.byteLength(payload, "utf8");
368
+ const hostilePayload = isHostilePayload(payload) || hasHostileValue(sanitizedEvent);
268
369
  const artifact = broker.publish({
269
370
  sessionId: activeSessionId,
270
371
  kind: "tool_output",
271
372
  payload,
272
- summary: summarizeTool(sanitizedEvent, bytes),
273
- tags: [event.toolName, event.isError ? "error" : "ok", event.sourceId ? "session-backfill" : "live"],
373
+ summary: summarizeTool(sanitizedEvent, bytes, hostilePayload),
374
+ tags: [
375
+ event.toolName,
376
+ event.isError ? "error" : "ok",
377
+ event.sourceId ? "session-backfill" : "live",
378
+ ...(hostilePayload ? ["hostile", "binary"] : []),
379
+ ],
274
380
  command: event.toolName === "bash" && typeof sanitizedEvent.input?.command === "string" ? sanitizedEvent.input.command : undefined,
275
381
  paths: typeof sanitizedEvent.input?.path === "string" ? [sanitizedEvent.input.path] : [],
276
382
  ttlMs: event.ttlMs ?? DEFAULT_TTL_MS,
277
383
  parentIds: event.sourceId ? [event.sourceId] : [],
278
384
  createdAt: event.createdAt,
279
385
  });
386
+ if (artifact) routingTelemetry.toolResultArtifacts += 1;
280
387
  if (event.sourceId) sourceHandles.set(event.sourceId, artifact.handle);
281
388
  return artifact;
282
389
  }
@@ -317,6 +424,7 @@ export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrok
317
424
 
318
425
  if (entry?.type === "message" && entry.message?.role === "toolResult") {
319
426
  scanned += 1;
427
+ routingTelemetry.backfillScans += 1;
320
428
  const sourceId = typeof entry.message.toolCallId === "string" ? entry.message.toolCallId : entryId;
321
429
  const toolInput = sourceId ? toolInputs.get(sourceId) : undefined;
322
430
  const alreadySeen = sourceId ? seenSourceIds.has(sourceId) || sourceHandles.has(sourceId) : false;
@@ -329,12 +437,16 @@ export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrok
329
437
  sourceId,
330
438
  createdAt,
331
439
  ttlMs: ttlFromNowFor(createdAt),
332
- }) && !alreadySeen) added += 1;
440
+ }) && !alreadySeen) {
441
+ added += 1;
442
+ routingTelemetry.backfillAdded += 1;
443
+ }
333
444
  }
334
445
 
335
446
  if (entry?.type === "message" && entry.message?.role === "bashExecution") {
336
447
  if (entry.message.excludeFromContext === true) continue;
337
448
  scanned += 1;
449
+ routingTelemetry.backfillScans += 1;
338
450
  const sourceId = entryId;
339
451
  const alreadySeen = sourceId ? seenSourceIds.has(sourceId) || sourceHandles.has(sourceId) : false;
340
452
  if (publishToolArtifact({
@@ -351,16 +463,30 @@ export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrok
351
463
  sourceId,
352
464
  createdAt,
353
465
  ttlMs: ttlFromNowFor(createdAt),
354
- }) && !alreadySeen) added += 1;
466
+ }) && !alreadySeen) {
467
+ added += 1;
468
+ routingTelemetry.backfillAdded += 1;
469
+ }
355
470
  }
356
471
  } catch {
357
472
  errors += 1;
473
+ routingTelemetry.backfillErrors += 1;
358
474
  }
359
475
  }
360
476
 
361
477
  return { added, scanned, errors };
362
478
  }
363
479
 
480
+ function currentBrief(): string {
481
+ return broker.renderBrief({ sessionId: activeSessionId, budgetBytes: briefBytes });
482
+ }
483
+
484
+ p.__piRogueContextBroker = {
485
+ renderBrief: currentBrief,
486
+ lookup: broker.lookup,
487
+ status: broker.status,
488
+ };
489
+
364
490
  const contextActions: AutocompleteItem[] = [
365
491
  { value: "status", label: "status", description: "Show broker record, byte, and pinned counts" },
366
492
  { value: "brief", label: "brief", description: "Show the bounded broker brief" },
@@ -408,9 +534,9 @@ export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrok
408
534
 
409
535
  pi.on("session_start", async (_event, ctx) => {
410
536
  const { added, scanned, errors } = backfillSessionArtifacts(ctx);
411
- ctx.ui.setStatus?.("context-broker", "ctx:on beta");
537
+ ctx.ui.setStatus?.("context-broker", "ctx:on");
412
538
  ctx.ui.notify(
413
- `Context broker beta enabled. Backfilled ${added}/${scanned} current-branch tool artifacts${errors ? ` (${errors} malformed skipped)` : ""}. Use /context status or /context brief.`,
539
+ `Context broker enabled. Backfilled ${added}/${scanned} current-branch tool artifacts${errors ? ` (${errors} malformed skipped)` : ""}. Use /context status or /context brief.`,
414
540
  errors ? "warning" : "info",
415
541
  );
416
542
  });
@@ -427,18 +553,24 @@ export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrok
427
553
 
428
554
  pi.on("tool_result", async (event: ToolResultEvent, ctx) => {
429
555
  activeSessionId = sessionIdFor(ctx);
556
+ routingTelemetry.toolResultEvents += 1;
430
557
  publishToolArtifact({ ...event, sourceId: event.toolCallId });
431
558
  });
432
559
 
433
560
  pi.on("context", async (event, ctx) => {
434
561
  activeSessionId = sessionIdFor(ctx);
562
+ routingTelemetry.contextHookCalls += 1;
435
563
  const toolInputs = collectToolInputs(event.messages);
436
- const drafts = event.messages.map((message: any): { original: any; replacement?: any; artifact?: ContextArtifact; rewrite?: (artifact: ContextArtifact) => any } => {
564
+ const drafts = event.messages.map((message: any): { original: any; replacement?: any; artifact?: ContextArtifact; rewrite?: (artifact: ContextArtifact) => any; safeFallback?: any } => {
437
565
  if (message?.role === "toolResult") {
566
+ routingTelemetry.contextHookToolResults += 1;
438
567
  const raw = contentText(message.content);
439
- if (Buffer.byteLength(raw, "utf8") <= rewriteThresholdBytes) return { original: message };
440
568
  const toolInput = typeof message.toolCallId === "string" ? toolInputs.get(message.toolCallId) : undefined;
441
569
  const toolName = String(message.toolName ?? toolInput?.toolName ?? "tool");
570
+ const hostile = hasHostileText(raw) || hasHostileValue(message.content);
571
+ if (hostile) routingTelemetry.contextHookToolResultHostile += 1;
572
+ const shouldRewrite = Buffer.byteLength(raw, "utf8") > rewriteThresholdBytes || hostile;
573
+ if (!shouldRewrite) return { original: message };
442
574
  if (!shouldBrokerToolName(toolName)) {
443
575
  return { original: message, replacement: { ...message, content: [{ type: "text", text: contextLookupHistoryPlaceholder() }] } };
444
576
  }
@@ -453,12 +585,22 @@ export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrok
453
585
  ttlMs: ttlFromNowFor(typeof message.timestamp === "number" ? message.timestamp : undefined),
454
586
  });
455
587
  if (!artifact) return { original: message };
456
- return { original: message, artifact, rewrite: (live) => ({ ...message, content: [{ type: "text", text: brokerPlaceholder(live) }] }) };
588
+ routingTelemetry.contextHookToolResultRewrites += 1;
589
+ return {
590
+ original: message,
591
+ artifact,
592
+ rewrite: (live) => ({ ...message, content: [{ type: "text", text: brokerPlaceholder(live) }] }),
593
+ safeFallback: { ...message, content: [{ type: "text", text: prunedPayloadPlaceholder(hostile) }] },
594
+ };
457
595
  }
458
596
 
459
597
  if (message?.role === "bashExecution" && message.excludeFromContext !== true) {
598
+ routingTelemetry.contextHookBash += 1;
460
599
  const raw = String(message.output ?? "");
461
- if (Buffer.byteLength(raw, "utf8") <= rewriteThresholdBytes) return { original: message };
600
+ const hostile = hasHostileText(raw) || hasHostileValue(message.output);
601
+ if (hostile) routingTelemetry.contextHookBashHostile += 1;
602
+ const shouldRewrite = Buffer.byteLength(raw, "utf8") > rewriteThresholdBytes || hostile;
603
+ if (!shouldRewrite) return { original: message };
462
604
  const sourceId = typeof message.timestamp === "number"
463
605
  ? `bash:${message.timestamp}:${stableHash([message.command ?? "", raw, message.exitCode ?? "", message.cancelled ?? ""].join("\n"))}`
464
606
  : `bash:${stableHash([message.command ?? "", raw, message.exitCode ?? "", message.cancelled ?? ""].join("\n"))}`;
@@ -478,7 +620,13 @@ export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrok
478
620
  ttlMs: ttlFromNowFor(typeof message.timestamp === "number" ? message.timestamp : undefined),
479
621
  });
480
622
  if (!artifact) return { original: message };
481
- return { original: message, artifact, rewrite: (live) => ({ ...message, output: brokerPlaceholder(live), truncated: true }) };
623
+ routingTelemetry.contextHookBashRewrites += 1;
624
+ return {
625
+ original: message,
626
+ artifact,
627
+ rewrite: (live) => ({ ...message, output: brokerPlaceholder(live), truncated: true }),
628
+ safeFallback: { ...message, output: prunedPayloadPlaceholder(hostile), truncated: true },
629
+ };
482
630
  }
483
631
 
484
632
  return { original: message };
@@ -494,6 +642,10 @@ export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrok
494
642
  const live = broker.lookup({ handle: draft.artifact.handle })[0];
495
643
  if (!live) {
496
644
  for (const parentId of draft.artifact.parentIds) sourceHandles.delete(parentId);
645
+ if (draft.safeFallback) {
646
+ changed = true;
647
+ return draft.safeFallback;
648
+ }
497
649
  return draft.original;
498
650
  }
499
651
  changed = true;
@@ -510,7 +662,7 @@ export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrok
510
662
  systemPrompt: [
511
663
  event.systemPrompt,
512
664
  brief,
513
- "Context broker beta rule: use /context lookup <handle> for exact evidence when a broker handle is relevant. Broker briefs are bounded summaries and never raw payload dumps.",
665
+ "Context broker rule: use /context lookup <handle> for exact evidence when a broker handle is relevant. Broker briefs are bounded summaries and never raw payload dumps.",
514
666
  ].join("\n\n"),
515
667
  };
516
668
  });
@@ -535,8 +687,11 @@ export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrok
535
687
  }),
536
688
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
537
689
  activeSessionId = sessionIdFor(ctx);
690
+ routingTelemetry.toolLookupCalls += 1;
538
691
  const p = params as { handle?: string; text?: string; path?: string; tag?: string; kind?: any; tier?: any; limit?: number };
539
692
  const exact = typeof p.handle === "string" && p.handle.startsWith("ctx://");
693
+ routingTelemetry.toolLookupExactCalls += exact ? 1 : 0;
694
+ routingTelemetry.toolLookupTextCalls += exact ? 0 : 1;
540
695
  const focused = exact || Boolean(p.text?.trim() || p.path?.trim() || p.tag?.trim() || p.kind || p.tier);
541
696
  if (!focused) {
542
697
  return textResult("context_lookup requires a focused filter: handle, text, path, tag, kind, or tier. Empty lookups are refused to avoid dumping brokered payloads into the prompt.");
@@ -551,13 +706,17 @@ export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrok
551
706
  tier: p.tier,
552
707
  limit: Math.min(10, Math.max(1, Math.floor(p.limit ?? (exact ? 1 : 5)))),
553
708
  });
554
- if (!results.length) return textResult("No context artifacts matched. Missing or expired handles should be reported explicitly.");
709
+ if (!results.length) {
710
+ routingTelemetry.toolLookupMisses += 1;
711
+ return textResult("No context artifacts matched. Missing or expired handles should be reported explicitly.");
712
+ }
713
+ routingTelemetry.toolLookupHits += 1;
555
714
  return textResult(results.map((item) => renderLookupOutput(item, exact ? lookupBytes : searchBytes)).join("\n\n---\n\n"));
556
715
  },
557
716
  });
558
717
 
559
718
  pi.registerCommand("context", {
560
- description: "Inspect the beta context broker: status | brief | lookup <handle-or-text> | pin <handle-or-id> | export <handle-or-id> | prune",
719
+ description: "Inspect the context broker: status | brief | lookup <handle-or-text> | pin <handle-or-id> | export <handle-or-id> | prune",
561
720
  getArgumentCompletions: contextArgumentCompletions,
562
721
  handler: async (args, ctx) => {
563
722
  activeSessionId = sessionIdFor(ctx);
@@ -565,11 +724,13 @@ export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrok
565
724
  const query = rest.join(" ");
566
725
 
567
726
  if (action === "status") {
727
+ routingTelemetry.statusCalls += 1;
568
728
  const status = broker.status();
569
729
  ctx.ui.notify(
570
- `Context broker beta: 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`,
730
+ `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`,
571
731
  "info",
572
732
  );
733
+ ctx.ui.notify(formatRoutingTelemetry(), "info");
573
734
  return;
574
735
  }
575
736
 
@@ -583,8 +744,16 @@ export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrok
583
744
  ctx.ui.notify("Usage: /context lookup <ctx://handle-or-text>", "warning");
584
745
  return;
585
746
  }
747
+ routingTelemetry.commandLookupCalls += 1;
586
748
  const exact = query.startsWith("ctx://");
749
+ routingTelemetry.commandLookupExactCalls += exact ? 1 : 0;
750
+ routingTelemetry.commandLookupTextCalls += exact ? 0 : 1;
587
751
  const results = broker.lookup(exact ? { handle: query } : { sessionId: activeSessionId, text: query, limit: 5 });
752
+ if (results.length) {
753
+ routingTelemetry.commandLookupHits += 1;
754
+ } else {
755
+ routingTelemetry.commandLookupMisses += 1;
756
+ }
588
757
  ctx.ui.notify(results.length ? results.map((item) => renderLookupOutput(item, exact ? lookupBytes : searchBytes)).join("\n\n---\n\n") : "No context artifacts matched.", "info");
589
758
  return;
590
759
  }
@@ -595,6 +764,7 @@ export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrok
595
764
  return;
596
765
  }
597
766
  const pinned = broker.pin(query, true);
767
+ routingTelemetry.pinCalls += 1;
598
768
  ctx.ui.notify(pinned ? `Pinned ${pinned.handle}` : "No artifact matched that handle/id.", pinned ? "info" : "warning");
599
769
  return;
600
770
  }
@@ -608,18 +778,20 @@ export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrok
608
778
  const exact = query.startsWith("ctx://");
609
779
  const artifact = exact ? broker.lookup({ handle: query })[0] : broker.lookup({ id: query })[0];
610
780
  if (!artifact) {
611
- ctx.ui.notify("No artifact matched that handle/id.", "warning");
781
+ ctx.ui.notify("No artifact matched that handle-or-id.", "warning");
612
782
  return;
613
783
  }
614
784
 
615
785
  const exportDir = mkdtempSync(join(tmpdir(), "pi-context-broker-export-"));
616
786
  const exportPath = join(exportDir, `${artifact.id}.txt`);
617
787
  writeFileSync(exportPath, artifact.payload, "utf8");
788
+ routingTelemetry.exportCalls += 1;
618
789
  ctx.ui.notify(`Exported full payload for ${sanitizeForPrompt(artifact.handle)} (${artifact.bytes} bytes) to ${exportPath}`, "info");
619
790
  return;
620
791
  }
621
792
 
622
793
  if (action === "prune") {
794
+ routingTelemetry.pruneCalls += 1;
623
795
  const status = broker.prune();
624
796
  ctx.ui.notify(`Pruned. ${status.records} records, ${status.bytes} bytes remain.`, "info");
625
797
  return;
@@ -287,4 +287,16 @@ describe("createInMemoryContextBroker", () => {
287
287
  expect(broker.lookup({ handle: pinned.handle })[0]?.payload).toBe("keep");
288
288
  expect(broker.lookup({ handle: other.handle })[0]?.payload).toBe("other");
289
289
  });
290
+
291
+ it("enforces optional global caps across sessions", () => {
292
+ const broker = createInMemoryContextBroker({ maxRecords: 8, globalMaxRecords: 2 });
293
+ const first = broker.publish({ sessionId: "s1", kind: "tool_output", payload: "alpha", summary: "alpha" });
294
+ const second = broker.publish({ sessionId: "s2", kind: "tool_output", payload: "bravo", summary: "bravo" });
295
+ const pinned = broker.publish({ sessionId: "s3", kind: "tool_output", payload: "charlie", summary: "charlie", pinned: true });
296
+ broker.publish({ sessionId: "s1", kind: "tool_output", payload: "delta", summary: "delta" });
297
+
298
+ expect(broker.lookup({ handle: first.handle })).toEqual([]);
299
+ expect(broker.lookup({ handle: second.handle })).toEqual([]);
300
+ expect(broker.lookup({ handle: pinned.handle })[0]?.payload).toBe("charlie");
301
+ });
290
302
  });
@@ -127,6 +127,12 @@ function tierLine(artifact: ContextArtifact): string {
127
127
  export function createInMemoryContextBroker(options: ContextBrokerOptions = {}): BoundedContextBroker {
128
128
  const maxRecords = Math.max(1, Math.floor(options.maxRecords ?? DEFAULT_MAX_RECORDS));
129
129
  const maxBytes = Math.max(1, Math.floor(options.maxBytes ?? DEFAULT_MAX_BYTES));
130
+ const globalMaxRecords = typeof options.globalMaxRecords === "number" && Number.isFinite(options.globalMaxRecords)
131
+ ? Math.max(1, Math.floor(options.globalMaxRecords))
132
+ : Number.POSITIVE_INFINITY;
133
+ const globalMaxBytes = typeof options.globalMaxBytes === "number" && Number.isFinite(options.globalMaxBytes)
134
+ ? Math.max(1, Math.floor(options.globalMaxBytes))
135
+ : Number.POSITIVE_INFINITY;
130
136
  const defaultTtlMs = Math.max(0, Math.floor(options.defaultTtlMs ?? DEFAULT_TTL_MS));
131
137
  const tierTtlMs: Record<ContextArtifactTier, number> = {
132
138
  hot: Math.max(0, Math.floor(options.hotTtlMs ?? defaultTtlMs)),
@@ -190,6 +196,19 @@ export function createInMemoryContextBroker(options: ContextBrokerOptions = {}):
190
196
  });
191
197
  }
192
198
 
199
+ function removalCandidatesGlobal(protectedIds: Set<string>, tier?: ContextArtifactTier): Array<{ artifact: ContextArtifact & { sequence: number; baseTier: ContextArtifactTier }; index: number }> {
200
+ return artifacts
201
+ .map((artifact, index) => ({ artifact, index }))
202
+ .filter(({ artifact }) => !artifact.pinned && !protectedIds.has(artifact.id) && (!tier || artifact.tier === tier))
203
+ .sort((a, b) => {
204
+ if (!tier && TIER_REMOVAL_ORDER[a.artifact.tier] !== TIER_REMOVAL_ORDER[b.artifact.tier]) {
205
+ return TIER_REMOVAL_ORDER[a.artifact.tier] - TIER_REMOVAL_ORDER[b.artifact.tier];
206
+ }
207
+ if (a.artifact.createdAt !== b.artifact.createdAt) return a.artifact.createdAt - b.artifact.createdAt;
208
+ return a.artifact.sequence - b.artifact.sequence;
209
+ });
210
+ }
211
+
193
212
  function withinCaps(sessionId: string, tier?: ContextArtifactTier): boolean {
194
213
  const sessionArtifacts = artifacts.filter((artifact) => artifact.sessionId === sessionId && (!tier || artifact.tier === tier));
195
214
  const recordsCap = tier ? tierMaxRecords[tier] : maxRecords;
@@ -197,6 +216,13 @@ export function createInMemoryContextBroker(options: ContextBrokerOptions = {}):
197
216
  return sessionArtifacts.length <= recordsCap && sessionArtifacts.reduce((sum, artifact) => sum + artifact.bytes, 0) <= bytesCap;
198
217
  }
199
218
 
219
+ function withinGlobalCaps(): boolean {
220
+ if (globalMaxRecords === Number.POSITIVE_INFINITY && globalMaxBytes === Number.POSITIVE_INFINITY) return true;
221
+ const records = artifacts.length;
222
+ const bytes = artifacts.reduce((sum, artifact) => sum + artifact.bytes, 0);
223
+ return records <= globalMaxRecords && bytes <= globalMaxBytes;
224
+ }
225
+
200
226
  function prune(now = Date.now(), protectedIds = new Set<string>()): ContextBrokerStatus {
201
227
  dropExpired(now, protectedIds);
202
228
 
@@ -216,6 +242,12 @@ export function createInMemoryContextBroker(options: ContextBrokerOptions = {}):
216
242
  }
217
243
  }
218
244
 
245
+ while (!withinGlobalCaps()) {
246
+ const candidate = removalCandidatesGlobal(protectedIds)[0];
247
+ if (!candidate) break;
248
+ artifacts.splice(candidate.index, 1);
249
+ }
250
+
219
251
  return currentStatus();
220
252
  }
221
253
 
@@ -95,4 +95,28 @@ describe("createSqliteContextBroker", () => {
95
95
  rmSync(dir, { recursive: true, force: true });
96
96
  }
97
97
  });
98
+
99
+ it("enforces optional global caps across sessions", () => {
100
+ const dir = mkdtempSync(join(tmpdir(), "ctx-sqlite-test-"));
101
+ try {
102
+ const path = join(dir, "artifacts.sqlite");
103
+ const broker = createSqliteContextBroker({
104
+ path,
105
+ defaultTtlMs: 0,
106
+ globalMaxBytes: 10,
107
+ globalMaxRecords: Number.POSITIVE_INFINITY,
108
+ });
109
+ const one = broker.publish({ sessionId: "s1", kind: "tool_output", payload: "aaa", summary: "first" });
110
+ const two = broker.publish({ sessionId: "s2", kind: "tool_output", payload: "bbb", summary: "second" });
111
+ const pinned = broker.publish({ sessionId: "s3", kind: "tool_output", payload: "ccc", summary: "third", pinned: true });
112
+ const four = broker.publish({ sessionId: "s1", kind: "tool_output", payload: "ddd", summary: "fourth" });
113
+
114
+ expect(broker.lookup({ handle: one.handle })).toEqual([]);
115
+ expect(broker.lookup({ handle: two.handle })[0]?.payload).toBe("bbb");
116
+ expect(broker.lookup({ handle: pinned.handle })[0]?.payload).toBe("ccc");
117
+ expect(broker.lookup({ handle: four.handle })[0]?.payload).toBe("ddd");
118
+ } finally {
119
+ rmSync(dir, { recursive: true, force: true });
120
+ }
121
+ });
98
122
  });
@@ -204,6 +204,12 @@ export function createSqliteContextBroker(options: SqliteContextBrokerOptions =
204
204
 
205
205
  const maxRecords = Math.max(1, Math.floor(options.maxRecords ?? DEFAULT_MAX_RECORDS));
206
206
  const maxBytes = Math.max(1, Math.floor(options.maxBytes ?? DEFAULT_MAX_BYTES));
207
+ const globalMaxRecords = typeof options.globalMaxRecords === "number" && Number.isFinite(options.globalMaxRecords)
208
+ ? Math.max(1, Math.floor(options.globalMaxRecords))
209
+ : Number.POSITIVE_INFINITY;
210
+ const globalMaxBytes = typeof options.globalMaxBytes === "number" && Number.isFinite(options.globalMaxBytes)
211
+ ? Math.max(1, Math.floor(options.globalMaxBytes))
212
+ : Number.POSITIVE_INFINITY;
207
213
  const defaultTtlMs = Math.max(0, Math.floor(options.defaultTtlMs ?? DEFAULT_TTL_MS));
208
214
  const tierTtlMs: Record<ContextArtifactTier, number> = {
209
215
  hot: Math.max(0, Math.floor(options.hotTtlMs ?? defaultTtlMs)),
@@ -286,6 +292,17 @@ export function createSqliteContextBroker(options: SqliteContextBrokerOptions =
286
292
  return stats.records <= (tier ? tierMaxRecords[tier] : maxRecords) && stats.bytes <= (tier ? tierMaxBytes[tier] : maxBytes);
287
293
  }
288
294
 
295
+ function globalStats(): { records: number; bytes: number } {
296
+ const row = db.prepare("SELECT COUNT(*) AS records, COALESCE(SUM(bytes), 0) AS bytes FROM artifacts").get();
297
+ return { records: Number(row?.records ?? 0), bytes: Number(row?.bytes ?? 0) };
298
+ }
299
+
300
+ function withinGlobalCaps(): boolean {
301
+ if (globalMaxRecords === Number.POSITIVE_INFINITY && globalMaxBytes === Number.POSITIVE_INFINITY) return true;
302
+ const { records, bytes } = globalStats();
303
+ return records <= globalMaxRecords && bytes <= globalMaxBytes;
304
+ }
305
+
289
306
  function removalCandidate(sessionId: string, protectedIds: Set<string>, tier?: ContextArtifactTier): string | undefined {
290
307
  const protectedList = [...protectedIds];
291
308
  const protectedClause = protectedList.length ? `AND id NOT IN (${protectedList.map(() => "?").join(",")})` : "";
@@ -296,6 +313,20 @@ export function createSqliteContextBroker(options: SqliteContextBrokerOptions =
296
313
  return row?.id == null ? undefined : String(row.id);
297
314
  }
298
315
 
316
+ function removalCandidateGlobal(protectedIds: Set<string>, tier?: ContextArtifactTier): string | undefined {
317
+ const protectedList = [...protectedIds];
318
+ const protectedClause = protectedList.length ? `AND id NOT IN (${protectedList.map(() => "?").join(",")})` : "";
319
+ const tierClause = tier ? "AND tier = ?" : "";
320
+ const order = tier
321
+ ? "createdAt ASC, rowid ASC"
322
+ : "CASE tier WHEN 'cold' THEN 0 WHEN 'warm' THEN 1 ELSE 2 END ASC, createdAt ASC, rowid ASC";
323
+ const params = tier ? [tier, ...protectedList] : [...protectedList];
324
+ const row = db.prepare(
325
+ `SELECT id FROM artifacts WHERE pinned = 0 ${tierClause} ${protectedClause} ORDER BY ${order} LIMIT 1`,
326
+ ).get(...params);
327
+ return row?.id == null ? undefined : String(row.id);
328
+ }
329
+
299
330
  function prune(now = Date.now(), protectedIds = new Set<string>()): ContextBrokerStatus {
300
331
  dropExpired(now, protectedIds);
301
332
  const sessions = db.prepare("SELECT DISTINCT sessionId FROM artifacts").all().map((row) => String(row.sessionId));
@@ -314,6 +345,12 @@ export function createSqliteContextBroker(options: SqliteContextBrokerOptions =
314
345
  deleteArtifact(id);
315
346
  }
316
347
  }
348
+
349
+ while (!withinGlobalCaps()) {
350
+ const id = removalCandidateGlobal(protectedIds);
351
+ if (!id) break;
352
+ deleteArtifact(id);
353
+ }
317
354
  return currentStatus();
318
355
  }
319
356
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@fiale-plus/pi-rogue-bundle",
3
- "version": "0.1.19",
4
- "description": "Public Pi-Rogue bundle for advisor, orchestration, and beta context broker. Single consolidated artefact (leaf releases paused; private packages are bundled here).",
3
+ "version": "0.1.20",
4
+ "description": "Public Pi-Rogue bundle for advisor, orchestration, and context broker. Single consolidated artefact (leaf releases paused; private packages are bundled here).",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "repository": {
@@ -33,27 +33,27 @@ describe("bundle extension defaults", () => {
33
33
  else process.env.PI_CONTEXT_BROKER_ENABLED = oldEnv;
34
34
  });
35
35
 
36
- it("does not register the beta context broker by default", async () => {
36
+ it("registers the context broker by default", async () => {
37
37
  delete process.env.PI_CONTEXT_BROKER_ENABLED;
38
38
  const { pi, commands } = createPiMock();
39
39
 
40
40
  await registerBundle(pi);
41
41
 
42
- expect(commands.has("context")).toBe(false);
42
+ expect(commands.has("context")).toBe(true);
43
43
  });
44
44
 
45
- it("registers the beta context broker only when explicitly enabled", async () => {
46
- process.env.PI_CONTEXT_BROKER_ENABLED = "true";
45
+ it("keeps an explicit env kill switch for context broker rollout", async () => {
46
+ process.env.PI_CONTEXT_BROKER_ENABLED = "false";
47
47
  const { pi, commands } = createPiMock();
48
48
 
49
49
  await registerBundle(pi);
50
50
 
51
- expect(commands.has("context")).toBe(true);
51
+ expect(commands.has("context")).toBe(false);
52
52
  });
53
53
  });
54
54
 
55
55
  describe("bundle context-broker export", () => {
56
- it("exposes the beta context broker runtime for explicit opt-in", () => {
56
+ it("exposes the context broker runtime through a bundle subpath", () => {
57
57
  const broker = createInMemoryContextBroker({ defaultTtlMs: 0 });
58
58
  const artifact = broker.publish({ sessionId: "bundle-test", kind: "memory_note", payload: "hello" });
59
59
 
package/src/extension.ts CHANGED
@@ -2,10 +2,10 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { registerAdvisor } from "@fiale-plus/pi-rogue-advisor";
3
3
  import { registerOrchestration } from "@fiale-plus/pi-rogue-orchestration";
4
4
 
5
- const ENABLED_VALUES = new Set(["1", "true", "yes", "on"]);
5
+ const DISABLED_VALUES = new Set(["0", "false", "no", "off"]);
6
6
 
7
- function contextBrokerBetaEnabled(): boolean {
8
- return ENABLED_VALUES.has(String(process.env.PI_CONTEXT_BROKER_ENABLED ?? "").trim().toLowerCase());
7
+ function contextBrokerEnabled(): boolean {
8
+ return !DISABLED_VALUES.has(String(process.env.PI_CONTEXT_BROKER_ENABLED ?? "").trim().toLowerCase());
9
9
  }
10
10
 
11
11
  export async function registerBundle(pi: ExtensionAPI): Promise<void> {
@@ -13,9 +13,9 @@ export async function registerBundle(pi: ExtensionAPI): Promise<void> {
13
13
  if (p.__piRogueBundleRegistered) return;
14
14
  p.__piRogueBundleRegistered = true;
15
15
 
16
- if (contextBrokerBetaEnabled()) {
16
+ if (contextBrokerEnabled()) {
17
17
  const { registerContextBrokerBeta } = await import("@fiale-plus/pi-rogue-context-broker/extension");
18
- registerContextBrokerBeta(pi);
18
+ await registerContextBrokerBeta(pi);
19
19
  }
20
20
 
21
21
  registerAdvisor(pi);