@fiale-plus/pi-rogue-bundle 0.1.18 → 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",
@@ -1,4 +1,4 @@
1
- import { mkdtempSync, rmSync } from "node:fs";
1
+ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { afterEach, describe, expect, it } from "vitest";
@@ -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(() => {
@@ -80,6 +80,7 @@ describe("context broker beta enablement", () => {
80
80
  "brief",
81
81
  "lookup",
82
82
  "pin",
83
+ "export",
83
84
  "prune",
84
85
  ]);
85
86
  });
@@ -116,7 +117,7 @@ describe("context broker beta enablement", () => {
116
117
 
117
118
  expect(notifications[0].message).toContain("Backfilled 2/2");
118
119
  expect(notifications[1].message).toContain("Backfilled 0/2");
119
- expect(notifications.at(-2)?.message).toContain("records=2");
120
+ expect(notifications.find((entry) => entry.message.includes("Context broker: enabled"))?.message).toContain("records=2");
120
121
  expect(notifications.at(-1)?.message).toContain("README.md");
121
122
  });
122
123
 
@@ -218,6 +219,65 @@ describe("context broker beta enablement", () => {
218
219
  expect(Buffer.byteLength(payload, "utf8")).toBeLessThanOrEqual(80);
219
220
  });
220
221
 
222
+ it("full payload export path writes the full artifact payload", async () => {
223
+ const { pi, handlers, commands } = createPiMock();
224
+ registerContextBrokerBeta(pi, { lookupBytes: 80, searchBytes: 50 });
225
+ const { ctx, notifications } = createCtx();
226
+ const payload = "payload_" + "x".repeat(120) + "::END";
227
+
228
+ await runHandlers(handlers, "session_start", { type: "session_start" }, ctx);
229
+ await runHandlers(handlers, "tool_result", {
230
+ type: "tool_result",
231
+ toolCallId: "call-export",
232
+ toolName: "bash",
233
+ input: { command: "printf payload" },
234
+ content: [{ type: "text", text: payload }],
235
+ isError: false,
236
+ }, ctx);
237
+
238
+ const exportCompletion = commands.get("context").getArgumentCompletions("export ")?.[0];
239
+ expect(exportCompletion.value.startsWith("export ctx://")).toBe(true);
240
+ const exportHandle = exportCompletion.value.replace(/^export /, "");
241
+
242
+ await commands.get("context").handler(`export ${exportHandle}`, ctx);
243
+
244
+ const message = notifications.at(-1)?.message ?? "";
245
+ const exportPath = message.split(" to ").at(-1) ?? "";
246
+ expect(exportPath).toContain("pi-context-broker-export-");
247
+ expect(exportPath).toMatch(/\.txt$/);
248
+ const exportedPayload = readFileSync(exportPath, "utf8");
249
+ expect(exportedPayload).toContain("tool=bash");
250
+ expect(exportedPayload).toContain(payload);
251
+ rmSync(exportPath);
252
+ });
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
+
221
281
  it("text search lookup returns a smaller byte-clipped excerpt", async () => {
222
282
  const { pi, handlers, commands } = createPiMock();
223
283
  registerContextBrokerBeta(pi, { lookupBytes: 80, searchBytes: 50 });
@@ -240,6 +300,30 @@ describe("context broker beta enablement", () => {
240
300
  expect(Buffer.byteLength(payload, "utf8")).toBeLessThanOrEqual(50);
241
301
  });
242
302
 
303
+ it("sanitizes control characters in context command lookup output", async () => {
304
+ const { pi, handlers, commands } = createPiMock();
305
+ registerContextBrokerBeta(pi);
306
+ const { ctx, notifications } = createCtx();
307
+ const rawPayload = `${"SAFE"}\u0000${"x".repeat(220)}`;
308
+
309
+ await runHandlers(handlers, "session_start", { type: "session_start" }, ctx);
310
+ await runHandlers(handlers, "tool_result", {
311
+ type: "tool_result",
312
+ toolCallId: "call-control",
313
+ toolName: "bash",
314
+ input: { command: "echo control" },
315
+ content: [{ type: "text", text: rawPayload }],
316
+ isError: false,
317
+ }, ctx);
318
+
319
+ const completion = commands.get("context").getArgumentCompletions("lookup ")?.[0];
320
+ await commands.get("context").handler(completion?.value ?? "", ctx);
321
+
322
+ const message = notifications.at(-1)?.message ?? "";
323
+ expect(message).toContain("\\u0000");
324
+ expect(message).not.toContain(String.fromCharCode(0));
325
+ });
326
+
243
327
  it("context_lookup tool dereferences handles for exact evidence", async () => {
244
328
  const { pi, handlers, commands, tools } = createPiMock();
245
329
  registerContextBrokerBeta(pi, { lookupBytes: 500 });
@@ -261,6 +345,44 @@ describe("context broker beta enablement", () => {
261
345
  expect(result.content[0].text).toContain("exact evidence payload");
262
346
  });
263
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
+
264
386
  it("does not broker context_lookup results recursively", async () => {
265
387
  const { pi, handlers, commands, tools } = createPiMock();
266
388
  registerContextBrokerBeta(pi, { lookupBytes: 500, rewriteThresholdBytes: 1 });
@@ -371,9 +493,9 @@ describe("context broker beta enablement", () => {
371
493
  expect(notifications.at(-1)?.message).toContain("RAW_TOOL_OUTPUT_");
372
494
  });
373
495
 
374
- 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 () => {
375
497
  const { pi, handlers } = createPiMock();
376
- registerContextBrokerBeta(pi, { rewriteThresholdBytes: 40 });
498
+ registerContextBrokerBeta(pi);
377
499
  const { ctx } = createCtx();
378
500
  const secret = "SECRET_TOKEN=" + "z".repeat(80);
379
501
 
@@ -386,7 +508,8 @@ describe("context broker beta enablement", () => {
386
508
  ],
387
509
  }, ctx);
388
510
 
389
- 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 });
390
513
  });
391
514
 
392
515
  it("does not collapse repeated bash rewrites for the same command and timestamp", async () => {
@@ -439,7 +562,8 @@ describe("context broker beta enablement", () => {
439
562
  .map((message: any) => String(message.content?.[0]?.text ?? "").match(/ctx:\/\/\S+/)?.[0])
440
563
  .filter(Boolean);
441
564
  expect(handles.length).toBeLessThanOrEqual(2);
442
- 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_");
443
567
 
444
568
  for (const handle of handles) {
445
569
  await commands.get("context").handler(`lookup ${handle}`, ctx);
@@ -448,6 +572,27 @@ describe("context broker beta enablement", () => {
448
572
  }
449
573
  });
450
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
+
451
596
  it("redacts secrets before storing and displaying payloads", async () => {
452
597
  const { pi, handlers, commands } = createPiMock();
453
598
  registerContextBrokerBeta(pi);
@@ -514,7 +659,7 @@ describe("context broker beta enablement", () => {
514
659
  const dir = mkdtempSync(join(tmpdir(), "ctx-broker-test-"));
515
660
  try {
516
661
  const first = createPiMock();
517
- registerContextBrokerBeta(first.pi, { durable: true, storeDir: dir });
662
+ await registerContextBrokerBeta(first.pi, { durable: true, storeDir: dir });
518
663
  const { ctx } = createCtx();
519
664
  await runHandlers(first.handlers, "session_start", { type: "session_start" }, ctx);
520
665
  await runHandlers(first.handlers, "tool_result", {
@@ -531,7 +676,7 @@ describe("context broker beta enablement", () => {
531
676
 
532
677
  const second = createPiMock();
533
678
  const secondRun = createCtx();
534
- registerContextBrokerBeta(second.pi, { durable: true, storeDir: dir });
679
+ await registerContextBrokerBeta(second.pi, { durable: true, storeDir: dir });
535
680
  await runHandlers(second.handlers, "session_start", { type: "session_start" }, secondRun.ctx);
536
681
  const secondHandle = second.commands.get("context").getArgumentCompletions("lookup ")?.[0].value.replace(/^lookup /, "");
537
682
  await second.commands.get("context").handler(`lookup ${handle}`, secondRun.ctx);
@@ -539,7 +684,7 @@ describe("context broker beta enablement", () => {
539
684
 
540
685
  const third = createPiMock();
541
686
  const thirdRun = createCtx();
542
- registerContextBrokerBeta(third.pi, { durable: true, storeDir: dir });
687
+ await registerContextBrokerBeta(third.pi, { durable: true, storeDir: dir });
543
688
  await runHandlers(third.handlers, "session_start", { type: "session_start" }, thirdRun.ctx);
544
689
  await third.commands.get("context").handler(`lookup ${secondHandle}`, thirdRun.ctx);
545
690
  await third.commands.get("context").handler("brief", thirdRun.ctx);
@@ -1,4 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { mkdtempSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
2
5
  import type { AgentToolResult } from "@earendil-works/pi-coding-agent";
3
6
  import type { AutocompleteItem } from "@earendil-works/pi-tui";
4
7
  import { Type } from "typebox";
@@ -6,12 +9,13 @@ import type { ExtensionAPI, ExtensionContext, ToolResultEvent } from "@earendil-
6
9
  import type { ContextArtifact } from "@fiale-plus/pi-core";
7
10
  import { createFileContextBroker } from "./file.js";
8
11
  import { createInMemoryContextBroker } from "./index.js";
9
- import { createSqliteContextBroker } from "./sqlite.js";
10
12
 
11
13
  export interface ContextBrokerBetaOptions {
12
14
  enabled?: boolean;
13
15
  maxRecords?: number;
14
16
  maxBytes?: number;
17
+ globalMaxRecords?: number;
18
+ globalMaxBytes?: number;
15
19
  briefBytes?: number;
16
20
  lookupBytes?: number;
17
21
  searchBytes?: number;
@@ -26,7 +30,7 @@ type SessionContextLike = Pick<ExtensionContext, "cwd" | "sessionManager"> & { u
26
30
  const DEFAULT_BRIEF_BYTES = 1_800;
27
31
  const DEFAULT_LOOKUP_BYTES = 12_000;
28
32
  const DEFAULT_SEARCH_BYTES = 2_000;
29
- const DEFAULT_REWRITE_THRESHOLD_BYTES = 2_000;
33
+ const DEFAULT_REWRITE_THRESHOLD_BYTES = 0;
30
34
  const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
31
35
  const ENABLED_VALUES = new Set(["1", "true", "yes", "on"]);
32
36
 
@@ -34,6 +38,14 @@ function envFlag(name: string): boolean {
34
38
  return ENABLED_VALUES.has(String(process.env[name] ?? "").trim().toLowerCase());
35
39
  }
36
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
+
37
49
  function isEnvEnabled(): boolean {
38
50
  return envFlag("PI_CONTEXT_BROKER_ENABLED");
39
51
  }
@@ -69,6 +81,64 @@ function toText(value: unknown): string {
69
81
  }
70
82
  }
71
83
 
84
+ function sanitizeForPrompt(text: string): string {
85
+ return String(text).replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g, (char) => `\\x${char.charCodeAt(0).toString(16).padStart(2, "0")}`);
86
+ }
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
+
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
+
134
+ return [
135
+ sanitizeForPrompt(item.handle),
136
+ `tier=${item.tier} kind=${item.kind} bytes=${item.bytes}`,
137
+ `summary=${sanitizeForPrompt(item.summary)}`,
138
+ ...payloadLines,
139
+ ].join("\n");
140
+ }
141
+
72
142
  function truncateUtf8(text: string, maxBytes: number): string {
73
143
  const limit = Math.max(0, Math.floor(maxBytes));
74
144
  const totalBytes = Buffer.byteLength(text, "utf8");
@@ -163,11 +233,20 @@ function contextLookupHistoryPlaceholder(): string {
163
233
  ].join("\n");
164
234
  }
165
235
 
166
- 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 {
167
245
  const command = event.toolName === "bash" ? event.input?.command : undefined;
168
246
  const path = event.input?.path;
169
247
  const target = command ? ` command=${compact(String(command), 120)}` : path ? ` path=${path}` : "";
170
- 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}`;
171
250
  }
172
251
 
173
252
  const NON_BROKERED_TOOL_NAMES = new Set(["context_lookup"]);
@@ -181,7 +260,7 @@ function ttlFromNowFor(createdAt: number | undefined): number | undefined {
181
260
  return Math.max(DEFAULT_TTL_MS, Date.now() - createdAt + DEFAULT_TTL_MS);
182
261
  }
183
262
 
184
- export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrokerBetaOptions = {}): void {
263
+ export async function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrokerBetaOptions = {}): Promise<void> {
185
264
  const p = pi as any;
186
265
  if (p.__piRogueContextBrokerBetaRegistered) return;
187
266
  p.__piRogueContextBrokerBetaRegistered = true;
@@ -189,10 +268,15 @@ export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrok
189
268
  const briefBytes = options.briefBytes ?? DEFAULT_BRIEF_BYTES;
190
269
  const lookupBytes = options.lookupBytes ?? DEFAULT_LOOKUP_BYTES;
191
270
  const searchBytes = options.searchBytes ?? DEFAULT_SEARCH_BYTES;
192
- 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;
193
275
  const brokerOptions = {
194
276
  maxRecords: options.maxRecords ?? 64,
195
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"),
196
280
  briefBytes,
197
281
  };
198
282
  const durable = options.durable ?? (envFlag("PI_CONTEXT_BROKER_DURABLE") || Boolean(options.storeDir ?? process.env.PI_CONTEXT_BROKER_STORE_DIR));
@@ -200,22 +284,55 @@ export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrok
200
284
  const broker = durable
201
285
  ? durableBackend === "jsonl"
202
286
  ? createFileContextBroker({ ...brokerOptions, dir: options.storeDir ?? process.env.PI_CONTEXT_BROKER_STORE_DIR })
203
- : 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 })
204
288
  : createInMemoryContextBroker(brokerOptions);
205
289
  const seenSourceIds = new Set<string>();
206
290
  const sourceHandles = new Map<string, string>();
207
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
+ };
208
320
 
209
- function currentBrief(): string {
210
- 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(", ")}`;
211
334
  }
212
335
 
213
- p.__piRogueContextBroker = {
214
- renderBrief: currentBrief,
215
- lookup: broker.lookup,
216
- status: broker.status,
217
- };
218
-
219
336
  function publishToolArtifact(event: {
220
337
  toolName: string;
221
338
  input?: any;
@@ -248,18 +365,25 @@ export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrok
248
365
  };
249
366
  const payload = toolPayload(sanitizedEvent);
250
367
  const bytes = Buffer.byteLength(payload, "utf8");
368
+ const hostilePayload = isHostilePayload(payload) || hasHostileValue(sanitizedEvent);
251
369
  const artifact = broker.publish({
252
370
  sessionId: activeSessionId,
253
371
  kind: "tool_output",
254
372
  payload,
255
- summary: summarizeTool(sanitizedEvent, bytes),
256
- 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
+ ],
257
380
  command: event.toolName === "bash" && typeof sanitizedEvent.input?.command === "string" ? sanitizedEvent.input.command : undefined,
258
381
  paths: typeof sanitizedEvent.input?.path === "string" ? [sanitizedEvent.input.path] : [],
259
382
  ttlMs: event.ttlMs ?? DEFAULT_TTL_MS,
260
383
  parentIds: event.sourceId ? [event.sourceId] : [],
261
384
  createdAt: event.createdAt,
262
385
  });
386
+ if (artifact) routingTelemetry.toolResultArtifacts += 1;
263
387
  if (event.sourceId) sourceHandles.set(event.sourceId, artifact.handle);
264
388
  return artifact;
265
389
  }
@@ -300,6 +424,7 @@ export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrok
300
424
 
301
425
  if (entry?.type === "message" && entry.message?.role === "toolResult") {
302
426
  scanned += 1;
427
+ routingTelemetry.backfillScans += 1;
303
428
  const sourceId = typeof entry.message.toolCallId === "string" ? entry.message.toolCallId : entryId;
304
429
  const toolInput = sourceId ? toolInputs.get(sourceId) : undefined;
305
430
  const alreadySeen = sourceId ? seenSourceIds.has(sourceId) || sourceHandles.has(sourceId) : false;
@@ -312,12 +437,16 @@ export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrok
312
437
  sourceId,
313
438
  createdAt,
314
439
  ttlMs: ttlFromNowFor(createdAt),
315
- }) && !alreadySeen) added += 1;
440
+ }) && !alreadySeen) {
441
+ added += 1;
442
+ routingTelemetry.backfillAdded += 1;
443
+ }
316
444
  }
317
445
 
318
446
  if (entry?.type === "message" && entry.message?.role === "bashExecution") {
319
447
  if (entry.message.excludeFromContext === true) continue;
320
448
  scanned += 1;
449
+ routingTelemetry.backfillScans += 1;
321
450
  const sourceId = entryId;
322
451
  const alreadySeen = sourceId ? seenSourceIds.has(sourceId) || sourceHandles.has(sourceId) : false;
323
452
  if (publishToolArtifact({
@@ -334,25 +463,40 @@ export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrok
334
463
  sourceId,
335
464
  createdAt,
336
465
  ttlMs: ttlFromNowFor(createdAt),
337
- }) && !alreadySeen) added += 1;
466
+ }) && !alreadySeen) {
467
+ added += 1;
468
+ routingTelemetry.backfillAdded += 1;
469
+ }
338
470
  }
339
471
  } catch {
340
472
  errors += 1;
473
+ routingTelemetry.backfillErrors += 1;
341
474
  }
342
475
  }
343
476
 
344
477
  return { added, scanned, errors };
345
478
  }
346
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
+
347
490
  const contextActions: AutocompleteItem[] = [
348
491
  { value: "status", label: "status", description: "Show broker record, byte, and pinned counts" },
349
492
  { value: "brief", label: "brief", description: "Show the bounded broker brief" },
350
493
  { value: "lookup ", label: "lookup", description: "Lookup by ctx:// handle or current-session text" },
351
494
  { value: "pin ", label: "pin", description: "Pin an artifact by ctx:// handle or id" },
495
+ { value: "export ", label: "export", description: "Export full payload for a ctx:// handle or id" },
352
496
  { value: "prune", label: "prune", description: "Run TTL/cap pruning now" },
353
497
  ];
354
498
 
355
- function artifactCompletions(action: "lookup" | "pin", query: string): AutocompleteItem[] {
499
+ function artifactCompletions(action: "lookup" | "pin" | "export", query: string): AutocompleteItem[] {
356
500
  const needle = query.trim().toLowerCase();
357
501
  return broker.lookup({ sessionId: activeSessionId, limit: 10 })
358
502
  .filter((artifact) => {
@@ -380,7 +524,7 @@ export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrok
380
524
  return items.length ? items : contextActions;
381
525
  }
382
526
 
383
- if (action === "lookup" || action === "pin") {
527
+ if (action === "lookup" || action === "pin" || action === "export") {
384
528
  const items = artifactCompletions(action, restParts.join(" "));
385
529
  return items.length ? items : null;
386
530
  }
@@ -390,9 +534,9 @@ export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrok
390
534
 
391
535
  pi.on("session_start", async (_event, ctx) => {
392
536
  const { added, scanned, errors } = backfillSessionArtifacts(ctx);
393
- ctx.ui.setStatus?.("context-broker", "ctx:on beta");
537
+ ctx.ui.setStatus?.("context-broker", "ctx:on");
394
538
  ctx.ui.notify(
395
- `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.`,
396
540
  errors ? "warning" : "info",
397
541
  );
398
542
  });
@@ -409,18 +553,24 @@ export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrok
409
553
 
410
554
  pi.on("tool_result", async (event: ToolResultEvent, ctx) => {
411
555
  activeSessionId = sessionIdFor(ctx);
556
+ routingTelemetry.toolResultEvents += 1;
412
557
  publishToolArtifact({ ...event, sourceId: event.toolCallId });
413
558
  });
414
559
 
415
560
  pi.on("context", async (event, ctx) => {
416
561
  activeSessionId = sessionIdFor(ctx);
562
+ routingTelemetry.contextHookCalls += 1;
417
563
  const toolInputs = collectToolInputs(event.messages);
418
- 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 } => {
419
565
  if (message?.role === "toolResult") {
566
+ routingTelemetry.contextHookToolResults += 1;
420
567
  const raw = contentText(message.content);
421
- if (Buffer.byteLength(raw, "utf8") <= rewriteThresholdBytes) return { original: message };
422
568
  const toolInput = typeof message.toolCallId === "string" ? toolInputs.get(message.toolCallId) : undefined;
423
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 };
424
574
  if (!shouldBrokerToolName(toolName)) {
425
575
  return { original: message, replacement: { ...message, content: [{ type: "text", text: contextLookupHistoryPlaceholder() }] } };
426
576
  }
@@ -435,12 +585,22 @@ export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrok
435
585
  ttlMs: ttlFromNowFor(typeof message.timestamp === "number" ? message.timestamp : undefined),
436
586
  });
437
587
  if (!artifact) return { original: message };
438
- 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
+ };
439
595
  }
440
596
 
441
597
  if (message?.role === "bashExecution" && message.excludeFromContext !== true) {
598
+ routingTelemetry.contextHookBash += 1;
442
599
  const raw = String(message.output ?? "");
443
- 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 };
444
604
  const sourceId = typeof message.timestamp === "number"
445
605
  ? `bash:${message.timestamp}:${stableHash([message.command ?? "", raw, message.exitCode ?? "", message.cancelled ?? ""].join("\n"))}`
446
606
  : `bash:${stableHash([message.command ?? "", raw, message.exitCode ?? "", message.cancelled ?? ""].join("\n"))}`;
@@ -460,7 +620,13 @@ export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrok
460
620
  ttlMs: ttlFromNowFor(typeof message.timestamp === "number" ? message.timestamp : undefined),
461
621
  });
462
622
  if (!artifact) return { original: message };
463
- 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
+ };
464
630
  }
465
631
 
466
632
  return { original: message };
@@ -476,6 +642,10 @@ export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrok
476
642
  const live = broker.lookup({ handle: draft.artifact.handle })[0];
477
643
  if (!live) {
478
644
  for (const parentId of draft.artifact.parentIds) sourceHandles.delete(parentId);
645
+ if (draft.safeFallback) {
646
+ changed = true;
647
+ return draft.safeFallback;
648
+ }
479
649
  return draft.original;
480
650
  }
481
651
  changed = true;
@@ -492,7 +662,7 @@ export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrok
492
662
  systemPrompt: [
493
663
  event.systemPrompt,
494
664
  brief,
495
- "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.",
496
666
  ].join("\n\n"),
497
667
  };
498
668
  });
@@ -517,8 +687,11 @@ export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrok
517
687
  }),
518
688
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
519
689
  activeSessionId = sessionIdFor(ctx);
690
+ routingTelemetry.toolLookupCalls += 1;
520
691
  const p = params as { handle?: string; text?: string; path?: string; tag?: string; kind?: any; tier?: any; limit?: number };
521
692
  const exact = typeof p.handle === "string" && p.handle.startsWith("ctx://");
693
+ routingTelemetry.toolLookupExactCalls += exact ? 1 : 0;
694
+ routingTelemetry.toolLookupTextCalls += exact ? 0 : 1;
522
695
  const focused = exact || Boolean(p.text?.trim() || p.path?.trim() || p.tag?.trim() || p.kind || p.tier);
523
696
  if (!focused) {
524
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.");
@@ -533,19 +706,17 @@ export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrok
533
706
  tier: p.tier,
534
707
  limit: Math.min(10, Math.max(1, Math.floor(p.limit ?? (exact ? 1 : 5)))),
535
708
  });
536
- if (!results.length) return textResult("No context artifacts matched. Missing or expired handles should be reported explicitly.");
537
- return textResult(results.map((item) => [
538
- item.handle,
539
- `tier=${item.tier} kind=${item.kind} bytes=${item.bytes}`,
540
- `summary=${item.summary}`,
541
- "payload:",
542
- truncateUtf8(item.payload, exact ? lookupBytes : searchBytes),
543
- ].join("\n")).join("\n\n---\n\n"));
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;
714
+ return textResult(results.map((item) => renderLookupOutput(item, exact ? lookupBytes : searchBytes)).join("\n\n---\n\n"));
544
715
  },
545
716
  });
546
717
 
547
718
  pi.registerCommand("context", {
548
- description: "Inspect the beta context broker: status | brief | lookup <handle-or-text> | pin <handle> | prune",
719
+ description: "Inspect the context broker: status | brief | lookup <handle-or-text> | pin <handle-or-id> | export <handle-or-id> | prune",
549
720
  getArgumentCompletions: contextArgumentCompletions,
550
721
  handler: async (args, ctx) => {
551
722
  activeSessionId = sessionIdFor(ctx);
@@ -553,11 +724,13 @@ export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrok
553
724
  const query = rest.join(" ");
554
725
 
555
726
  if (action === "status") {
727
+ routingTelemetry.statusCalls += 1;
556
728
  const status = broker.status();
557
729
  ctx.ui.notify(
558
- `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`,
559
731
  "info",
560
732
  );
733
+ ctx.ui.notify(formatRoutingTelemetry(), "info");
561
734
  return;
562
735
  }
563
736
 
@@ -571,15 +744,17 @@ export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrok
571
744
  ctx.ui.notify("Usage: /context lookup <ctx://handle-or-text>", "warning");
572
745
  return;
573
746
  }
747
+ routingTelemetry.commandLookupCalls += 1;
574
748
  const exact = query.startsWith("ctx://");
749
+ routingTelemetry.commandLookupExactCalls += exact ? 1 : 0;
750
+ routingTelemetry.commandLookupTextCalls += exact ? 0 : 1;
575
751
  const results = broker.lookup(exact ? { handle: query } : { sessionId: activeSessionId, text: query, limit: 5 });
576
- ctx.ui.notify(results.length ? results.map((item) => [
577
- item.handle,
578
- `kind=${item.kind} bytes=${item.bytes}`,
579
- `summary=${item.summary}`,
580
- "payload:",
581
- truncateUtf8(item.payload, exact ? lookupBytes : searchBytes),
582
- ].join("\n")).join("\n\n---\n\n") : "No context artifacts matched.", "info");
752
+ if (results.length) {
753
+ routingTelemetry.commandLookupHits += 1;
754
+ } else {
755
+ routingTelemetry.commandLookupMisses += 1;
756
+ }
757
+ ctx.ui.notify(results.length ? results.map((item) => renderLookupOutput(item, exact ? lookupBytes : searchBytes)).join("\n\n---\n\n") : "No context artifacts matched.", "info");
583
758
  return;
584
759
  }
585
760
 
@@ -589,17 +764,40 @@ export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrok
589
764
  return;
590
765
  }
591
766
  const pinned = broker.pin(query, true);
767
+ routingTelemetry.pinCalls += 1;
592
768
  ctx.ui.notify(pinned ? `Pinned ${pinned.handle}` : "No artifact matched that handle/id.", pinned ? "info" : "warning");
593
769
  return;
594
770
  }
595
771
 
772
+ if (action === "export") {
773
+ if (!query) {
774
+ ctx.ui.notify("Usage: /context export <ctx://handle-or-id>", "warning");
775
+ return;
776
+ }
777
+
778
+ const exact = query.startsWith("ctx://");
779
+ const artifact = exact ? broker.lookup({ handle: query })[0] : broker.lookup({ id: query })[0];
780
+ if (!artifact) {
781
+ ctx.ui.notify("No artifact matched that handle-or-id.", "warning");
782
+ return;
783
+ }
784
+
785
+ const exportDir = mkdtempSync(join(tmpdir(), "pi-context-broker-export-"));
786
+ const exportPath = join(exportDir, `${artifact.id}.txt`);
787
+ writeFileSync(exportPath, artifact.payload, "utf8");
788
+ routingTelemetry.exportCalls += 1;
789
+ ctx.ui.notify(`Exported full payload for ${sanitizeForPrompt(artifact.handle)} (${artifact.bytes} bytes) to ${exportPath}`, "info");
790
+ return;
791
+ }
792
+
596
793
  if (action === "prune") {
794
+ routingTelemetry.pruneCalls += 1;
597
795
  const status = broker.prune();
598
796
  ctx.ui.notify(`Pruned. ${status.records} records, ${status.bytes} bytes remain.`, "info");
599
797
  return;
600
798
  }
601
799
 
602
- ctx.ui.notify("Usage: /context status | brief | lookup <handle-or-text> | pin <handle> | prune", "warning");
800
+ ctx.ui.notify("Usage: /context status | brief | lookup <handle-or-text> | pin <handle-or-id> | export <handle-or-id> | prune", "warning");
603
801
  },
604
802
  });
605
803
  }
@@ -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.18",
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);