@fiale-plus/pi-rogue-bundle 0.1.17 → 0.1.19
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/node_modules/@fiale-plus/pi-core/src/context-broker.ts +6 -0
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/extension.test.ts +159 -1
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/extension.ts +82 -20
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/file.ts +28 -2
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/index.test.ts +14 -0
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/index.ts +13 -0
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/sqlite.test.ts +20 -0
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/sqlite.ts +25 -1
- package/package.json +1 -1
|
@@ -91,11 +91,17 @@ export interface ContextBrokerOptions {
|
|
|
91
91
|
briefBytes?: number;
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
export interface ContextPurgeOptions {
|
|
95
|
+
sessionId?: string;
|
|
96
|
+
keepPinned?: boolean;
|
|
97
|
+
}
|
|
98
|
+
|
|
94
99
|
export interface BoundedContextBroker {
|
|
95
100
|
publish(input: ContextArtifactInput): ContextArtifact;
|
|
96
101
|
lookup(query?: ContextLookupQuery): ContextArtifact[];
|
|
97
102
|
pin(idOrHandle: string, pinned?: boolean): ContextArtifact | null;
|
|
98
103
|
prune(now?: number): ContextBrokerStatus;
|
|
104
|
+
purge(options?: ContextPurgeOptions): ContextBrokerStatus;
|
|
99
105
|
status(): ContextBrokerStatus;
|
|
100
106
|
renderBrief(query?: ContextLookupQuery & { budgetBytes?: number }): string;
|
|
101
107
|
}
|
|
@@ -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";
|
|
@@ -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
|
});
|
|
@@ -120,6 +121,42 @@ describe("context broker beta enablement", () => {
|
|
|
120
121
|
expect(notifications.at(-1)?.message).toContain("README.md");
|
|
121
122
|
});
|
|
122
123
|
|
|
124
|
+
it("purges unpinned broker artifacts after session compaction", async () => {
|
|
125
|
+
const { pi, handlers, commands } = createPiMock();
|
|
126
|
+
registerContextBrokerBeta(pi, { briefBytes: 1200 });
|
|
127
|
+
const { ctx, notifications } = createCtx();
|
|
128
|
+
|
|
129
|
+
await runHandlers(handlers, "session_start", { type: "session_start" }, ctx);
|
|
130
|
+
await runHandlers(handlers, "tool_result", {
|
|
131
|
+
type: "tool_result",
|
|
132
|
+
toolCallId: "scratch-call",
|
|
133
|
+
toolName: "bash",
|
|
134
|
+
input: { command: "echo scratch" },
|
|
135
|
+
content: [{ type: "text", text: "scratch payload" }],
|
|
136
|
+
isError: false,
|
|
137
|
+
}, ctx);
|
|
138
|
+
await runHandlers(handlers, "tool_result", {
|
|
139
|
+
type: "tool_result",
|
|
140
|
+
toolCallId: "keep-call",
|
|
141
|
+
toolName: "bash",
|
|
142
|
+
input: { command: "echo keep" },
|
|
143
|
+
content: [{ type: "text", text: "keep payload" }],
|
|
144
|
+
isError: false,
|
|
145
|
+
}, ctx);
|
|
146
|
+
const keepCompletion = commands.get("context").getArgumentCompletions("pin ")?.find((item: any) => String(item.description).includes("echo keep"));
|
|
147
|
+
const keepHandle = keepCompletion?.value.replace(/^pin /, "");
|
|
148
|
+
expect(keepHandle).toBeTruthy();
|
|
149
|
+
await commands.get("context").handler(`pin ${keepHandle}`, ctx);
|
|
150
|
+
|
|
151
|
+
await runHandlers(handlers, "session_compact", { type: "session_compact", compactionEntry: { summary: "compact" }, fromExtension: false }, ctx);
|
|
152
|
+
await commands.get("context").handler("brief", ctx);
|
|
153
|
+
|
|
154
|
+
const brief = notifications.at(-1)?.message ?? "";
|
|
155
|
+
expect(brief).toContain("echo keep");
|
|
156
|
+
expect(brief).not.toContain("echo scratch");
|
|
157
|
+
expect(notifications.some((item) => item.message.includes("compact cleanup purged 1 unpinned artifact"))).toBe(true);
|
|
158
|
+
});
|
|
159
|
+
|
|
123
160
|
it("is safe on malformed session branches", async () => {
|
|
124
161
|
const { pi, handlers } = createPiMock();
|
|
125
162
|
registerContextBrokerBeta(pi);
|
|
@@ -182,6 +219,38 @@ describe("context broker beta enablement", () => {
|
|
|
182
219
|
expect(Buffer.byteLength(payload, "utf8")).toBeLessThanOrEqual(80);
|
|
183
220
|
});
|
|
184
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
|
+
|
|
185
254
|
it("text search lookup returns a smaller byte-clipped excerpt", async () => {
|
|
186
255
|
const { pi, handlers, commands } = createPiMock();
|
|
187
256
|
registerContextBrokerBeta(pi, { lookupBytes: 80, searchBytes: 50 });
|
|
@@ -204,6 +273,31 @@ describe("context broker beta enablement", () => {
|
|
|
204
273
|
expect(Buffer.byteLength(payload, "utf8")).toBeLessThanOrEqual(50);
|
|
205
274
|
});
|
|
206
275
|
|
|
276
|
+
it("sanitizes control characters in context command lookup output", async () => {
|
|
277
|
+
const { pi, handlers, commands } = createPiMock();
|
|
278
|
+
registerContextBrokerBeta(pi);
|
|
279
|
+
const { ctx, notifications } = createCtx();
|
|
280
|
+
const rawPayload = `${"SAFE"}\u0000${"\x1B"}[31mBLOCK\u0000`;
|
|
281
|
+
|
|
282
|
+
await runHandlers(handlers, "session_start", { type: "session_start" }, ctx);
|
|
283
|
+
await runHandlers(handlers, "tool_result", {
|
|
284
|
+
type: "tool_result",
|
|
285
|
+
toolCallId: "call-control",
|
|
286
|
+
toolName: "bash",
|
|
287
|
+
input: { command: "echo control" },
|
|
288
|
+
content: [{ type: "text", text: rawPayload }],
|
|
289
|
+
isError: false,
|
|
290
|
+
}, ctx);
|
|
291
|
+
|
|
292
|
+
const completion = commands.get("context").getArgumentCompletions("lookup ")?.[0];
|
|
293
|
+
await commands.get("context").handler(completion?.value ?? "", ctx);
|
|
294
|
+
|
|
295
|
+
const message = notifications.at(-1)?.message ?? "";
|
|
296
|
+
expect(message).toContain("\\u0000");
|
|
297
|
+
expect(message).toContain("\\u001b");
|
|
298
|
+
expect(message).not.toContain(String.fromCharCode(0));
|
|
299
|
+
});
|
|
300
|
+
|
|
207
301
|
it("context_lookup tool dereferences handles for exact evidence", async () => {
|
|
208
302
|
const { pi, handlers, commands, tools } = createPiMock();
|
|
209
303
|
registerContextBrokerBeta(pi, { lookupBytes: 500 });
|
|
@@ -225,6 +319,70 @@ describe("context broker beta enablement", () => {
|
|
|
225
319
|
expect(result.content[0].text).toContain("exact evidence payload");
|
|
226
320
|
});
|
|
227
321
|
|
|
322
|
+
it("does not broker context_lookup results recursively", async () => {
|
|
323
|
+
const { pi, handlers, commands, tools } = createPiMock();
|
|
324
|
+
registerContextBrokerBeta(pi, { lookupBytes: 500, rewriteThresholdBytes: 1 });
|
|
325
|
+
const { ctx, notifications } = createCtx();
|
|
326
|
+
|
|
327
|
+
await runHandlers(handlers, "session_start", { type: "session_start" }, ctx);
|
|
328
|
+
await runHandlers(handlers, "tool_result", {
|
|
329
|
+
type: "tool_result",
|
|
330
|
+
toolCallId: "call-source",
|
|
331
|
+
toolName: "bash",
|
|
332
|
+
input: { command: "echo source" },
|
|
333
|
+
content: [{ type: "text", text: "source evidence payload" }],
|
|
334
|
+
isError: false,
|
|
335
|
+
}, ctx);
|
|
336
|
+
const handle = commands.get("context").getArgumentCompletions("lookup ")?.[0].value.replace(/^lookup /, "");
|
|
337
|
+
const lookupResult = await tools.get("context_lookup").execute("lookup-call", { handle }, undefined, undefined, ctx);
|
|
338
|
+
|
|
339
|
+
await runHandlers(handlers, "tool_result", {
|
|
340
|
+
type: "tool_result",
|
|
341
|
+
toolCallId: "lookup-call",
|
|
342
|
+
toolName: "context_lookup",
|
|
343
|
+
input: { handle },
|
|
344
|
+
content: lookupResult.content,
|
|
345
|
+
isError: false,
|
|
346
|
+
}, ctx);
|
|
347
|
+
const contextResult = await handlers.get("context")?.[0]({
|
|
348
|
+
type: "context",
|
|
349
|
+
messages: [{ role: "toolResult", toolCallId: "lookup-call", toolName: "context_lookup", content: lookupResult.content, isError: false }],
|
|
350
|
+
}, ctx);
|
|
351
|
+
await commands.get("context").handler("brief", ctx);
|
|
352
|
+
|
|
353
|
+
const rewrittenLookup = contextResult.messages[0].content[0].text;
|
|
354
|
+
const brief = notifications.at(-1)?.message ?? "";
|
|
355
|
+
expect(rewrittenLookup).toContain("Context lookup result omitted from prompt");
|
|
356
|
+
expect(rewrittenLookup).not.toContain("source evidence payload");
|
|
357
|
+
expect(rewrittenLookup).not.toContain("Context broker artifact: ctx://");
|
|
358
|
+
expect(brief).toContain("echo source");
|
|
359
|
+
expect(brief).not.toContain("completed context_lookup");
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it("still brokers normal tool output that exactly matches broker marker text", async () => {
|
|
363
|
+
const { pi, handlers, commands } = createPiMock();
|
|
364
|
+
registerContextBrokerBeta(pi);
|
|
365
|
+
const { ctx, notifications } = createCtx();
|
|
366
|
+
|
|
367
|
+
await runHandlers(handlers, "session_start", { type: "session_start" }, ctx);
|
|
368
|
+
await runHandlers(handlers, "tool_result", {
|
|
369
|
+
type: "tool_result",
|
|
370
|
+
toolCallId: "grep-call",
|
|
371
|
+
toolName: "bash",
|
|
372
|
+
input: { command: "grep ctx session.log" },
|
|
373
|
+
content: [{ type: "text", text: [
|
|
374
|
+
"Context broker artifact: ctx://session/example",
|
|
375
|
+
"Summary: copied placeholder",
|
|
376
|
+
"Payload bytes: 10",
|
|
377
|
+
"Raw payload omitted from prompt. Use /context lookup <handle> if exact evidence is needed.",
|
|
378
|
+
].join("\n") }],
|
|
379
|
+
isError: false,
|
|
380
|
+
}, ctx);
|
|
381
|
+
await commands.get("context").handler("brief", ctx);
|
|
382
|
+
|
|
383
|
+
expect(notifications.at(-1)?.message).toContain("grep ctx session.log");
|
|
384
|
+
});
|
|
385
|
+
|
|
228
386
|
it("context_lookup refuses empty unfocused payload-dumping calls", async () => {
|
|
229
387
|
const { pi, handlers, tools } = createPiMock();
|
|
230
388
|
registerContextBrokerBeta(pi, { lookupBytes: 500 });
|
|
@@ -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";
|
|
@@ -69,6 +72,20 @@ function toText(value: unknown): string {
|
|
|
69
72
|
}
|
|
70
73
|
}
|
|
71
74
|
|
|
75
|
+
function sanitizeForPrompt(text: string): string {
|
|
76
|
+
return String(text).replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g, (char) => `\\x${char.charCodeAt(0).toString(16).padStart(2, "0")}`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function renderLookupOutput(item: ContextArtifact, payloadLimit: number): string {
|
|
80
|
+
return [
|
|
81
|
+
sanitizeForPrompt(item.handle),
|
|
82
|
+
`tier=${item.tier} kind=${item.kind} bytes=${item.bytes}`,
|
|
83
|
+
`summary=${sanitizeForPrompt(item.summary)}`,
|
|
84
|
+
"payload:",
|
|
85
|
+
truncateUtf8(sanitizeForPrompt(item.payload), payloadLimit),
|
|
86
|
+
].join("\n");
|
|
87
|
+
}
|
|
88
|
+
|
|
72
89
|
function truncateUtf8(text: string, maxBytes: number): string {
|
|
73
90
|
const limit = Math.max(0, Math.floor(maxBytes));
|
|
74
91
|
const totalBytes = Buffer.byteLength(text, "utf8");
|
|
@@ -155,6 +172,14 @@ function brokerPlaceholder(artifact: ContextArtifact): string {
|
|
|
155
172
|
].join("\n");
|
|
156
173
|
}
|
|
157
174
|
|
|
175
|
+
function contextLookupHistoryPlaceholder(): string {
|
|
176
|
+
return [
|
|
177
|
+
"Context lookup result omitted from prompt.",
|
|
178
|
+
"Prior context_lookup evidence is terminal and is not re-brokered.",
|
|
179
|
+
"Run context_lookup again with a focused handle/filter only if exact evidence is still needed.",
|
|
180
|
+
].join("\n");
|
|
181
|
+
}
|
|
182
|
+
|
|
158
183
|
function summarizeTool(event: { toolName: string; input?: any; isError?: boolean }, bytes: number): string {
|
|
159
184
|
const command = event.toolName === "bash" ? event.input?.command : undefined;
|
|
160
185
|
const path = event.input?.path;
|
|
@@ -162,6 +187,12 @@ function summarizeTool(event: { toolName: string; input?: any; isError?: boolean
|
|
|
162
187
|
return `${event.isError ? "failed" : "completed"} ${event.toolName}${target}; payload=${bytes} bytes`;
|
|
163
188
|
}
|
|
164
189
|
|
|
190
|
+
const NON_BROKERED_TOOL_NAMES = new Set(["context_lookup"]);
|
|
191
|
+
|
|
192
|
+
function shouldBrokerToolName(toolName: string): boolean {
|
|
193
|
+
return !NON_BROKERED_TOOL_NAMES.has(toolName);
|
|
194
|
+
}
|
|
195
|
+
|
|
165
196
|
function ttlFromNowFor(createdAt: number | undefined): number | undefined {
|
|
166
197
|
if (typeof createdAt !== "number" || !Number.isFinite(createdAt)) return undefined;
|
|
167
198
|
return Math.max(DEFAULT_TTL_MS, Date.now() - createdAt + DEFAULT_TTL_MS);
|
|
@@ -212,6 +243,8 @@ export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrok
|
|
|
212
243
|
createdAt?: number;
|
|
213
244
|
ttlMs?: number;
|
|
214
245
|
}): ContextArtifact | null {
|
|
246
|
+
if (!shouldBrokerToolName(event.toolName)) return null;
|
|
247
|
+
|
|
215
248
|
if (event.sourceId) {
|
|
216
249
|
const existingHandle = sourceHandles.get(event.sourceId);
|
|
217
250
|
if (existingHandle) {
|
|
@@ -295,6 +328,7 @@ export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrok
|
|
|
295
328
|
isError: Boolean(entry.message.isError),
|
|
296
329
|
sourceId,
|
|
297
330
|
createdAt,
|
|
331
|
+
ttlMs: ttlFromNowFor(createdAt),
|
|
298
332
|
}) && !alreadySeen) added += 1;
|
|
299
333
|
}
|
|
300
334
|
|
|
@@ -316,6 +350,7 @@ export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrok
|
|
|
316
350
|
isError: typeof entry.message.exitCode === "number" ? entry.message.exitCode !== 0 : Boolean(entry.message.cancelled),
|
|
317
351
|
sourceId,
|
|
318
352
|
createdAt,
|
|
353
|
+
ttlMs: ttlFromNowFor(createdAt),
|
|
319
354
|
}) && !alreadySeen) added += 1;
|
|
320
355
|
}
|
|
321
356
|
} catch {
|
|
@@ -331,10 +366,11 @@ export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrok
|
|
|
331
366
|
{ value: "brief", label: "brief", description: "Show the bounded broker brief" },
|
|
332
367
|
{ value: "lookup ", label: "lookup", description: "Lookup by ctx:// handle or current-session text" },
|
|
333
368
|
{ value: "pin ", label: "pin", description: "Pin an artifact by ctx:// handle or id" },
|
|
369
|
+
{ value: "export ", label: "export", description: "Export full payload for a ctx:// handle or id" },
|
|
334
370
|
{ value: "prune", label: "prune", description: "Run TTL/cap pruning now" },
|
|
335
371
|
];
|
|
336
372
|
|
|
337
|
-
function artifactCompletions(action: "lookup" | "pin", query: string): AutocompleteItem[] {
|
|
373
|
+
function artifactCompletions(action: "lookup" | "pin" | "export", query: string): AutocompleteItem[] {
|
|
338
374
|
const needle = query.trim().toLowerCase();
|
|
339
375
|
return broker.lookup({ sessionId: activeSessionId, limit: 10 })
|
|
340
376
|
.filter((artifact) => {
|
|
@@ -362,7 +398,7 @@ export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrok
|
|
|
362
398
|
return items.length ? items : contextActions;
|
|
363
399
|
}
|
|
364
400
|
|
|
365
|
-
if (action === "lookup" || action === "pin") {
|
|
401
|
+
if (action === "lookup" || action === "pin" || action === "export") {
|
|
366
402
|
const items = artifactCompletions(action, restParts.join(" "));
|
|
367
403
|
return items.length ? items : null;
|
|
368
404
|
}
|
|
@@ -379,6 +415,16 @@ export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrok
|
|
|
379
415
|
);
|
|
380
416
|
});
|
|
381
417
|
|
|
418
|
+
pi.on("session_compact", async (_event, ctx) => {
|
|
419
|
+
activeSessionId = sessionIdFor(ctx);
|
|
420
|
+
const before = broker.status();
|
|
421
|
+
const after = broker.purge({ sessionId: activeSessionId, keepPinned: true });
|
|
422
|
+
seenSourceIds.clear();
|
|
423
|
+
sourceHandles.clear();
|
|
424
|
+
const removed = before.records - after.records;
|
|
425
|
+
if (removed > 0) ctx.ui.notify(`Context broker compact cleanup purged ${removed} unpinned artifact${removed === 1 ? "" : "s"}; pinned artifacts retained.`, "info");
|
|
426
|
+
});
|
|
427
|
+
|
|
382
428
|
pi.on("tool_result", async (event: ToolResultEvent, ctx) => {
|
|
383
429
|
activeSessionId = sessionIdFor(ctx);
|
|
384
430
|
publishToolArtifact({ ...event, sourceId: event.toolCallId });
|
|
@@ -387,13 +433,17 @@ export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrok
|
|
|
387
433
|
pi.on("context", async (event, ctx) => {
|
|
388
434
|
activeSessionId = sessionIdFor(ctx);
|
|
389
435
|
const toolInputs = collectToolInputs(event.messages);
|
|
390
|
-
const drafts = event.messages.map((message: any): { original: any; artifact?: ContextArtifact; rewrite?: (artifact: ContextArtifact) => any } => {
|
|
436
|
+
const drafts = event.messages.map((message: any): { original: any; replacement?: any; artifact?: ContextArtifact; rewrite?: (artifact: ContextArtifact) => any } => {
|
|
391
437
|
if (message?.role === "toolResult") {
|
|
392
438
|
const raw = contentText(message.content);
|
|
393
439
|
if (Buffer.byteLength(raw, "utf8") <= rewriteThresholdBytes) return { original: message };
|
|
394
440
|
const toolInput = typeof message.toolCallId === "string" ? toolInputs.get(message.toolCallId) : undefined;
|
|
441
|
+
const toolName = String(message.toolName ?? toolInput?.toolName ?? "tool");
|
|
442
|
+
if (!shouldBrokerToolName(toolName)) {
|
|
443
|
+
return { original: message, replacement: { ...message, content: [{ type: "text", text: contextLookupHistoryPlaceholder() }] } };
|
|
444
|
+
}
|
|
395
445
|
const artifact = publishToolArtifact({
|
|
396
|
-
toolName
|
|
446
|
+
toolName,
|
|
397
447
|
input: message.input ?? toolInput?.input,
|
|
398
448
|
content: message.content,
|
|
399
449
|
details: message.details,
|
|
@@ -436,6 +486,10 @@ export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrok
|
|
|
436
486
|
|
|
437
487
|
let changed = false;
|
|
438
488
|
const messages = drafts.map((draft) => {
|
|
489
|
+
if (draft.replacement) {
|
|
490
|
+
changed = true;
|
|
491
|
+
return draft.replacement;
|
|
492
|
+
}
|
|
439
493
|
if (!draft.artifact || !draft.rewrite) return draft.original;
|
|
440
494
|
const live = broker.lookup({ handle: draft.artifact.handle })[0];
|
|
441
495
|
if (!live) {
|
|
@@ -498,18 +552,12 @@ export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrok
|
|
|
498
552
|
limit: Math.min(10, Math.max(1, Math.floor(p.limit ?? (exact ? 1 : 5)))),
|
|
499
553
|
});
|
|
500
554
|
if (!results.length) return textResult("No context artifacts matched. Missing or expired handles should be reported explicitly.");
|
|
501
|
-
return textResult(results.map((item) =>
|
|
502
|
-
item.handle,
|
|
503
|
-
`tier=${item.tier} kind=${item.kind} bytes=${item.bytes}`,
|
|
504
|
-
`summary=${item.summary}`,
|
|
505
|
-
"payload:",
|
|
506
|
-
truncateUtf8(item.payload, exact ? lookupBytes : searchBytes),
|
|
507
|
-
].join("\n")).join("\n\n---\n\n"));
|
|
555
|
+
return textResult(results.map((item) => renderLookupOutput(item, exact ? lookupBytes : searchBytes)).join("\n\n---\n\n"));
|
|
508
556
|
},
|
|
509
557
|
});
|
|
510
558
|
|
|
511
559
|
pi.registerCommand("context", {
|
|
512
|
-
description: "Inspect the beta context broker: status | brief | lookup <handle-or-text> | pin <handle> | prune",
|
|
560
|
+
description: "Inspect the beta context broker: status | brief | lookup <handle-or-text> | pin <handle-or-id> | export <handle-or-id> | prune",
|
|
513
561
|
getArgumentCompletions: contextArgumentCompletions,
|
|
514
562
|
handler: async (args, ctx) => {
|
|
515
563
|
activeSessionId = sessionIdFor(ctx);
|
|
@@ -537,13 +585,7 @@ export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrok
|
|
|
537
585
|
}
|
|
538
586
|
const exact = query.startsWith("ctx://");
|
|
539
587
|
const results = broker.lookup(exact ? { handle: query } : { sessionId: activeSessionId, text: query, limit: 5 });
|
|
540
|
-
ctx.ui.notify(results.length ? results.map((item) =>
|
|
541
|
-
item.handle,
|
|
542
|
-
`kind=${item.kind} bytes=${item.bytes}`,
|
|
543
|
-
`summary=${item.summary}`,
|
|
544
|
-
"payload:",
|
|
545
|
-
truncateUtf8(item.payload, exact ? lookupBytes : searchBytes),
|
|
546
|
-
].join("\n")).join("\n\n---\n\n") : "No context artifacts matched.", "info");
|
|
588
|
+
ctx.ui.notify(results.length ? results.map((item) => renderLookupOutput(item, exact ? lookupBytes : searchBytes)).join("\n\n---\n\n") : "No context artifacts matched.", "info");
|
|
547
589
|
return;
|
|
548
590
|
}
|
|
549
591
|
|
|
@@ -557,13 +599,33 @@ export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrok
|
|
|
557
599
|
return;
|
|
558
600
|
}
|
|
559
601
|
|
|
602
|
+
if (action === "export") {
|
|
603
|
+
if (!query) {
|
|
604
|
+
ctx.ui.notify("Usage: /context export <ctx://handle-or-id>", "warning");
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
const exact = query.startsWith("ctx://");
|
|
609
|
+
const artifact = exact ? broker.lookup({ handle: query })[0] : broker.lookup({ id: query })[0];
|
|
610
|
+
if (!artifact) {
|
|
611
|
+
ctx.ui.notify("No artifact matched that handle/id.", "warning");
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const exportDir = mkdtempSync(join(tmpdir(), "pi-context-broker-export-"));
|
|
616
|
+
const exportPath = join(exportDir, `${artifact.id}.txt`);
|
|
617
|
+
writeFileSync(exportPath, artifact.payload, "utf8");
|
|
618
|
+
ctx.ui.notify(`Exported full payload for ${sanitizeForPrompt(artifact.handle)} (${artifact.bytes} bytes) to ${exportPath}`, "info");
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
|
|
560
622
|
if (action === "prune") {
|
|
561
623
|
const status = broker.prune();
|
|
562
624
|
ctx.ui.notify(`Pruned. ${status.records} records, ${status.bytes} bytes remain.`, "info");
|
|
563
625
|
return;
|
|
564
626
|
}
|
|
565
627
|
|
|
566
|
-
ctx.ui.notify("Usage: /context status | brief | lookup <handle-or-text> | pin <handle> | prune", "warning");
|
|
628
|
+
ctx.ui.notify("Usage: /context status | brief | lookup <handle-or-text> | pin <handle-or-id> | export <handle-or-id> | prune", "warning");
|
|
567
629
|
},
|
|
568
630
|
});
|
|
569
631
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import { safeName } from "@fiale-plus/pi-core";
|
|
5
|
-
import type { BoundedContextBroker, ContextArtifact, ContextArtifactInput, ContextArtifactTier, ContextBrokerOptions, ContextBrokerStatus, ContextLookupQuery } from "@fiale-plus/pi-core";
|
|
5
|
+
import type { BoundedContextBroker, ContextArtifact, ContextArtifactInput, ContextArtifactTier, ContextBrokerOptions, ContextBrokerStatus, ContextLookupQuery, ContextPurgeOptions } from "@fiale-plus/pi-core";
|
|
6
6
|
import { createInMemoryContextBroker } from "./index.js";
|
|
7
7
|
|
|
8
8
|
export interface FileContextBrokerOptions extends ContextBrokerOptions {
|
|
@@ -113,6 +113,16 @@ function persistArtifactSnapshot(dir: string, artifact: ContextArtifact): void {
|
|
|
113
113
|
});
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
function removeUnreferencedBlobs(dir: string, keptSha256: Set<string>): void {
|
|
117
|
+
const blobsDir = join(dir, "blobs");
|
|
118
|
+
if (!existsSync(blobsDir)) return;
|
|
119
|
+
for (const entry of readdirSync(blobsDir)) {
|
|
120
|
+
if (!entry.endsWith(".txt")) continue;
|
|
121
|
+
const sha256 = entry.slice(0, -4);
|
|
122
|
+
if (!keptSha256.has(sha256)) unlinkSync(join(blobsDir, entry));
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
116
126
|
export function createFileContextBroker(options: FileContextBrokerOptions = {}): BoundedContextBroker {
|
|
117
127
|
const dir = options.dir ?? process.env.PI_CONTEXT_BROKER_STORE_DIR ?? defaultStoreDir();
|
|
118
128
|
ensureDir(join(dir, "blobs"));
|
|
@@ -155,6 +165,22 @@ export function createFileContextBroker(options: FileContextBrokerOptions = {}):
|
|
|
155
165
|
return artifact;
|
|
156
166
|
},
|
|
157
167
|
prune(now?: number): ContextBrokerStatus { return broker.prune(now); },
|
|
168
|
+
purge(options?: ContextPurgeOptions): ContextBrokerStatus {
|
|
169
|
+
const status = broker.purge(options);
|
|
170
|
+
const remaining = broker.lookup({ limit: Number.MAX_SAFE_INTEGER });
|
|
171
|
+
persistedSources.clear();
|
|
172
|
+
handleAliases.clear();
|
|
173
|
+
writeFileSync(metadataFile(dir), "", "utf8");
|
|
174
|
+
const keptSha256 = new Set<string>();
|
|
175
|
+
for (const artifact of remaining) {
|
|
176
|
+
keptSha256.add(artifact.sha256);
|
|
177
|
+
for (const parentId of artifact.parentIds) persistedSources.set(parentId, artifact.handle);
|
|
178
|
+
handleAliases.set(artifact.handle, artifact.handle);
|
|
179
|
+
persistArtifactSnapshot(dir, artifact);
|
|
180
|
+
}
|
|
181
|
+
removeUnreferencedBlobs(dir, keptSha256);
|
|
182
|
+
return status;
|
|
183
|
+
},
|
|
158
184
|
status(): ContextBrokerStatus { return broker.status(); },
|
|
159
185
|
renderBrief(query?: ContextLookupQuery & { budgetBytes?: number }): string { return broker.renderBrief(query); },
|
|
160
186
|
};
|
|
@@ -273,4 +273,18 @@ describe("createInMemoryContextBroker", () => {
|
|
|
273
273
|
expect(Buffer.byteLength(brief, "utf8")).toBeLessThanOrEqual(170);
|
|
274
274
|
expect(brief).toContain("Context Broker");
|
|
275
275
|
});
|
|
276
|
+
|
|
277
|
+
it("purges unpinned session artifacts while retaining pinned evidence", () => {
|
|
278
|
+
const broker = createInMemoryContextBroker();
|
|
279
|
+
const unpinned = broker.publish({ sessionId: "s", kind: "tool_output", payload: "scratch", summary: "scratch" });
|
|
280
|
+
const pinned = broker.publish({ sessionId: "s", kind: "tool_output", payload: "keep", summary: "keep", pinned: true });
|
|
281
|
+
const other = broker.publish({ sessionId: "other", kind: "tool_output", payload: "other", summary: "other" });
|
|
282
|
+
|
|
283
|
+
const status = broker.purge({ sessionId: "s", keepPinned: true });
|
|
284
|
+
|
|
285
|
+
expect(status.records).toBe(2);
|
|
286
|
+
expect(broker.lookup({ handle: unpinned.handle })).toEqual([]);
|
|
287
|
+
expect(broker.lookup({ handle: pinned.handle })[0]?.payload).toBe("keep");
|
|
288
|
+
expect(broker.lookup({ handle: other.handle })[0]?.payload).toBe("other");
|
|
289
|
+
});
|
|
276
290
|
});
|
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
ContextBrokerOptions,
|
|
10
10
|
ContextBrokerStatus,
|
|
11
11
|
ContextLookupQuery,
|
|
12
|
+
ContextPurgeOptions,
|
|
12
13
|
} from "@fiale-plus/pi-core";
|
|
13
14
|
|
|
14
15
|
export type {
|
|
@@ -20,6 +21,7 @@ export type {
|
|
|
20
21
|
ContextBrokerOptions,
|
|
21
22
|
ContextBrokerStatus,
|
|
22
23
|
ContextLookupQuery,
|
|
24
|
+
ContextPurgeOptions,
|
|
23
25
|
} from "@fiale-plus/pi-core";
|
|
24
26
|
|
|
25
27
|
const DEFAULT_MAX_RECORDS = 256;
|
|
@@ -222,6 +224,16 @@ export function createInMemoryContextBroker(options: ContextBrokerOptions = {}):
|
|
|
222
224
|
return currentStatus();
|
|
223
225
|
}
|
|
224
226
|
|
|
227
|
+
function purge(options: ContextPurgeOptions = {}): ContextBrokerStatus {
|
|
228
|
+
dropExpired();
|
|
229
|
+
const keepPinned = options.keepPinned ?? true;
|
|
230
|
+
artifacts = artifacts.filter((artifact) => {
|
|
231
|
+
if (options.sessionId && artifact.sessionId !== options.sessionId) return true;
|
|
232
|
+
return keepPinned && artifact.pinned;
|
|
233
|
+
});
|
|
234
|
+
return currentStatus();
|
|
235
|
+
}
|
|
236
|
+
|
|
225
237
|
function publish(input: ContextArtifactInput): ContextArtifact {
|
|
226
238
|
const now = input.createdAt ?? Date.now();
|
|
227
239
|
const payload = payloadText(input.payload);
|
|
@@ -318,6 +330,7 @@ export function createInMemoryContextBroker(options: ContextBrokerOptions = {}):
|
|
|
318
330
|
lookup,
|
|
319
331
|
pin,
|
|
320
332
|
prune,
|
|
333
|
+
purge,
|
|
321
334
|
status,
|
|
322
335
|
renderBrief,
|
|
323
336
|
};
|
|
@@ -75,4 +75,24 @@ describe("createSqliteContextBroker", () => {
|
|
|
75
75
|
rmSync(dir, { recursive: true, force: true });
|
|
76
76
|
}
|
|
77
77
|
});
|
|
78
|
+
|
|
79
|
+
it("purges unpinned durable artifacts for a session", () => {
|
|
80
|
+
const dir = mkdtempSync(join(tmpdir(), "ctx-sqlite-test-"));
|
|
81
|
+
try {
|
|
82
|
+
const path = join(dir, "artifacts.sqlite");
|
|
83
|
+
let broker = createSqliteContextBroker({ path, defaultTtlMs: 0 });
|
|
84
|
+
const scratch = broker.publish({ sessionId: "s", kind: "tool_output", payload: "scratch" });
|
|
85
|
+
const pinned = broker.publish({ sessionId: "s", kind: "tool_output", payload: "keep", pinned: true });
|
|
86
|
+
const other = broker.publish({ sessionId: "other", kind: "tool_output", payload: "other" });
|
|
87
|
+
|
|
88
|
+
broker.purge({ sessionId: "s", keepPinned: true });
|
|
89
|
+
broker = createSqliteContextBroker({ path, defaultTtlMs: 0 });
|
|
90
|
+
|
|
91
|
+
expect(broker.lookup({ handle: scratch.handle })).toEqual([]);
|
|
92
|
+
expect(broker.lookup({ handle: pinned.handle })[0]?.payload).toBe("keep");
|
|
93
|
+
expect(broker.lookup({ handle: other.handle })[0]?.payload).toBe("other");
|
|
94
|
+
} finally {
|
|
95
|
+
rmSync(dir, { recursive: true, force: true });
|
|
96
|
+
}
|
|
97
|
+
});
|
|
78
98
|
});
|
|
@@ -13,6 +13,7 @@ import type {
|
|
|
13
13
|
ContextBrokerOptions,
|
|
14
14
|
ContextBrokerStatus,
|
|
15
15
|
ContextLookupQuery,
|
|
16
|
+
ContextPurgeOptions,
|
|
16
17
|
} from "@fiale-plus/pi-core";
|
|
17
18
|
|
|
18
19
|
export interface SqliteContextBrokerOptions extends ContextBrokerOptions {
|
|
@@ -321,6 +322,29 @@ export function createSqliteContextBroker(options: SqliteContextBrokerOptions =
|
|
|
321
322
|
return currentStatus();
|
|
322
323
|
}
|
|
323
324
|
|
|
325
|
+
function purge(options: ContextPurgeOptions = {}): ContextBrokerStatus {
|
|
326
|
+
dropExpired();
|
|
327
|
+
const keepPinned = options.keepPinned ?? true;
|
|
328
|
+
const clauses: string[] = [];
|
|
329
|
+
const params: Array<string | number> = [];
|
|
330
|
+
if (options.sessionId) {
|
|
331
|
+
clauses.push("sessionId = ?");
|
|
332
|
+
params.push(options.sessionId);
|
|
333
|
+
}
|
|
334
|
+
if (keepPinned) clauses.push("pinned = 0");
|
|
335
|
+
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
336
|
+
const rows = db.prepare(`SELECT id FROM artifacts ${where}`).all(...params);
|
|
337
|
+
db.exec("BEGIN IMMEDIATE");
|
|
338
|
+
try {
|
|
339
|
+
for (const row of rows) deleteArtifact(String(row.id));
|
|
340
|
+
db.exec("COMMIT");
|
|
341
|
+
} catch (error) {
|
|
342
|
+
db.exec("ROLLBACK");
|
|
343
|
+
throw error;
|
|
344
|
+
}
|
|
345
|
+
return currentStatus();
|
|
346
|
+
}
|
|
347
|
+
|
|
324
348
|
function publish(input: ContextArtifactInput): ContextArtifact {
|
|
325
349
|
dropExpired();
|
|
326
350
|
const source = stableSource(input);
|
|
@@ -492,7 +516,7 @@ export function createSqliteContextBroker(options: SqliteContextBrokerOptions =
|
|
|
492
516
|
return truncateUtf8(lines.join("\n"), budget);
|
|
493
517
|
}
|
|
494
518
|
|
|
495
|
-
return { publish, lookup, pin, prune, status, renderBrief };
|
|
519
|
+
return { publish, lookup, pin, prune, purge, status, renderBrief };
|
|
496
520
|
}
|
|
497
521
|
|
|
498
522
|
export function contextBrokerSqlitePathForSession(baseDir: string, sessionId: string): string {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fiale-plus/pi-rogue-bundle",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.19",
|
|
4
4
|
"description": "Public Pi-Rogue bundle for advisor, orchestration, and beta context broker. Single consolidated artefact (leaf releases paused; private packages are bundled here).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|