@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 +5 -5
- package/node_modules/@fiale-plus/pi-core/src/context-broker.ts +2 -0
- package/node_modules/@fiale-plus/pi-rogue-context-broker/README.md +20 -11
- package/node_modules/@fiale-plus/pi-rogue-context-broker/package.json +1 -1
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/extension.test.ts +155 -10
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/extension.ts +245 -47
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/index.test.ts +12 -0
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/index.ts +32 -0
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/sqlite.test.ts +24 -0
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/sqlite.ts +37 -0
- package/package.json +2 -2
- package/src/extension.test.ts +6 -6
- package/src/extension.ts +5 -5
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` (
|
|
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
|
|
32
|
-
-
|
|
33
|
-
- Set `PI_CONTEXT_BROKER_ENABLED=
|
|
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
|
-
-
|
|
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
|
|
|
@@ -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,
|
|
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
|
|
13
|
+
It is registered by default in the bundle, with an explicit env kill switch.
|
|
14
14
|
|
|
15
|
-
##
|
|
15
|
+
## Mainline extension
|
|
16
16
|
|
|
17
|
-
|
|
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=
|
|
20
|
+
PI_CONTEXT_BROKER_ENABLED=false pi
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
-
When
|
|
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
|
|
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
|
|
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
|
-
-
|
|
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": "
|
|
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
|
|
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.
|
|
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("
|
|
496
|
+
it("rewrites small tool results and leaves excluded bash outputs unchanged in context", async () => {
|
|
375
497
|
const { pi, handlers } = createPiMock();
|
|
376
|
-
registerContextBrokerBeta(pi
|
|
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).
|
|
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.
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
|
210
|
-
|
|
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: [
|
|
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)
|
|
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)
|
|
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
|
|
537
|
+
ctx.ui.setStatus?.("context-broker", "ctx:on");
|
|
394
538
|
ctx.ui.notify(
|
|
395
|
-
`Context broker
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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)
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
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.
|
|
4
|
-
"description": "Public Pi-Rogue bundle for advisor, orchestration, and
|
|
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": {
|
package/src/extension.test.ts
CHANGED
|
@@ -33,27 +33,27 @@ describe("bundle extension defaults", () => {
|
|
|
33
33
|
else process.env.PI_CONTEXT_BROKER_ENABLED = oldEnv;
|
|
34
34
|
});
|
|
35
35
|
|
|
36
|
-
it("
|
|
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(
|
|
42
|
+
expect(commands.has("context")).toBe(true);
|
|
43
43
|
});
|
|
44
44
|
|
|
45
|
-
it("
|
|
46
|
-
process.env.PI_CONTEXT_BROKER_ENABLED = "
|
|
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(
|
|
51
|
+
expect(commands.has("context")).toBe(false);
|
|
52
52
|
});
|
|
53
53
|
});
|
|
54
54
|
|
|
55
55
|
describe("bundle context-broker export", () => {
|
|
56
|
-
it("exposes the
|
|
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
|
|
5
|
+
const DISABLED_VALUES = new Set(["0", "false", "no", "off"]);
|
|
6
6
|
|
|
7
|
-
function
|
|
8
|
-
return
|
|
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 (
|
|
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);
|