@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.
@@ -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: String(message.toolName ?? toolInput?.toolName ?? "tool"),
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.17",
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",