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