@fiale-plus/pi-rogue-bundle 0.1.17 → 0.1.18

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
  }
@@ -120,6 +120,42 @@ describe("context broker beta enablement", () => {
120
120
  expect(notifications.at(-1)?.message).toContain("README.md");
121
121
  });
122
122
 
123
+ it("purges unpinned broker artifacts after session compaction", async () => {
124
+ const { pi, handlers, commands } = createPiMock();
125
+ registerContextBrokerBeta(pi, { briefBytes: 1200 });
126
+ const { ctx, notifications } = createCtx();
127
+
128
+ await runHandlers(handlers, "session_start", { type: "session_start" }, ctx);
129
+ await runHandlers(handlers, "tool_result", {
130
+ type: "tool_result",
131
+ toolCallId: "scratch-call",
132
+ toolName: "bash",
133
+ input: { command: "echo scratch" },
134
+ content: [{ type: "text", text: "scratch payload" }],
135
+ isError: false,
136
+ }, ctx);
137
+ await runHandlers(handlers, "tool_result", {
138
+ type: "tool_result",
139
+ toolCallId: "keep-call",
140
+ toolName: "bash",
141
+ input: { command: "echo keep" },
142
+ content: [{ type: "text", text: "keep payload" }],
143
+ isError: false,
144
+ }, ctx);
145
+ const keepCompletion = commands.get("context").getArgumentCompletions("pin ")?.find((item: any) => String(item.description).includes("echo keep"));
146
+ const keepHandle = keepCompletion?.value.replace(/^pin /, "");
147
+ expect(keepHandle).toBeTruthy();
148
+ await commands.get("context").handler(`pin ${keepHandle}`, ctx);
149
+
150
+ await runHandlers(handlers, "session_compact", { type: "session_compact", compactionEntry: { summary: "compact" }, fromExtension: false }, ctx);
151
+ await commands.get("context").handler("brief", ctx);
152
+
153
+ const brief = notifications.at(-1)?.message ?? "";
154
+ expect(brief).toContain("echo keep");
155
+ expect(brief).not.toContain("echo scratch");
156
+ expect(notifications.some((item) => item.message.includes("compact cleanup purged 1 unpinned artifact"))).toBe(true);
157
+ });
158
+
123
159
  it("is safe on malformed session branches", async () => {
124
160
  const { pi, handlers } = createPiMock();
125
161
  registerContextBrokerBeta(pi);
@@ -225,6 +261,70 @@ describe("context broker beta enablement", () => {
225
261
  expect(result.content[0].text).toContain("exact evidence payload");
226
262
  });
227
263
 
264
+ it("does not broker context_lookup results recursively", async () => {
265
+ const { pi, handlers, commands, tools } = createPiMock();
266
+ registerContextBrokerBeta(pi, { lookupBytes: 500, rewriteThresholdBytes: 1 });
267
+ const { ctx, notifications } = createCtx();
268
+
269
+ await runHandlers(handlers, "session_start", { type: "session_start" }, ctx);
270
+ await runHandlers(handlers, "tool_result", {
271
+ type: "tool_result",
272
+ toolCallId: "call-source",
273
+ toolName: "bash",
274
+ input: { command: "echo source" },
275
+ content: [{ type: "text", text: "source evidence payload" }],
276
+ isError: false,
277
+ }, ctx);
278
+ const handle = commands.get("context").getArgumentCompletions("lookup ")?.[0].value.replace(/^lookup /, "");
279
+ const lookupResult = await tools.get("context_lookup").execute("lookup-call", { handle }, undefined, undefined, ctx);
280
+
281
+ await runHandlers(handlers, "tool_result", {
282
+ type: "tool_result",
283
+ toolCallId: "lookup-call",
284
+ toolName: "context_lookup",
285
+ input: { handle },
286
+ content: lookupResult.content,
287
+ isError: false,
288
+ }, ctx);
289
+ const contextResult = await handlers.get("context")?.[0]({
290
+ type: "context",
291
+ messages: [{ role: "toolResult", toolCallId: "lookup-call", toolName: "context_lookup", content: lookupResult.content, isError: false }],
292
+ }, ctx);
293
+ await commands.get("context").handler("brief", ctx);
294
+
295
+ const rewrittenLookup = contextResult.messages[0].content[0].text;
296
+ const brief = notifications.at(-1)?.message ?? "";
297
+ expect(rewrittenLookup).toContain("Context lookup result omitted from prompt");
298
+ expect(rewrittenLookup).not.toContain("source evidence payload");
299
+ expect(rewrittenLookup).not.toContain("Context broker artifact: ctx://");
300
+ expect(brief).toContain("echo source");
301
+ expect(brief).not.toContain("completed context_lookup");
302
+ });
303
+
304
+ it("still brokers normal tool output that exactly matches broker marker text", async () => {
305
+ const { pi, handlers, commands } = createPiMock();
306
+ registerContextBrokerBeta(pi);
307
+ const { ctx, notifications } = createCtx();
308
+
309
+ await runHandlers(handlers, "session_start", { type: "session_start" }, ctx);
310
+ await runHandlers(handlers, "tool_result", {
311
+ type: "tool_result",
312
+ toolCallId: "grep-call",
313
+ toolName: "bash",
314
+ input: { command: "grep ctx session.log" },
315
+ content: [{ type: "text", text: [
316
+ "Context broker artifact: ctx://session/example",
317
+ "Summary: copied placeholder",
318
+ "Payload bytes: 10",
319
+ "Raw payload omitted from prompt. Use /context lookup <handle> if exact evidence is needed.",
320
+ ].join("\n") }],
321
+ isError: false,
322
+ }, ctx);
323
+ await commands.get("context").handler("brief", ctx);
324
+
325
+ expect(notifications.at(-1)?.message).toContain("grep ctx session.log");
326
+ });
327
+
228
328
  it("context_lookup refuses empty unfocused payload-dumping calls", async () => {
229
329
  const { pi, handlers, tools } = createPiMock();
230
330
  registerContextBrokerBeta(pi, { lookupBytes: 500 });
@@ -155,6 +155,14 @@ function brokerPlaceholder(artifact: ContextArtifact): string {
155
155
  ].join("\n");
156
156
  }
157
157
 
158
+ function contextLookupHistoryPlaceholder(): string {
159
+ return [
160
+ "Context lookup result omitted from prompt.",
161
+ "Prior context_lookup evidence is terminal and is not re-brokered.",
162
+ "Run context_lookup again with a focused handle/filter only if exact evidence is still needed.",
163
+ ].join("\n");
164
+ }
165
+
158
166
  function summarizeTool(event: { toolName: string; input?: any; isError?: boolean }, bytes: number): string {
159
167
  const command = event.toolName === "bash" ? event.input?.command : undefined;
160
168
  const path = event.input?.path;
@@ -162,6 +170,12 @@ function summarizeTool(event: { toolName: string; input?: any; isError?: boolean
162
170
  return `${event.isError ? "failed" : "completed"} ${event.toolName}${target}; payload=${bytes} bytes`;
163
171
  }
164
172
 
173
+ const NON_BROKERED_TOOL_NAMES = new Set(["context_lookup"]);
174
+
175
+ function shouldBrokerToolName(toolName: string): boolean {
176
+ return !NON_BROKERED_TOOL_NAMES.has(toolName);
177
+ }
178
+
165
179
  function ttlFromNowFor(createdAt: number | undefined): number | undefined {
166
180
  if (typeof createdAt !== "number" || !Number.isFinite(createdAt)) return undefined;
167
181
  return Math.max(DEFAULT_TTL_MS, Date.now() - createdAt + DEFAULT_TTL_MS);
@@ -212,6 +226,8 @@ export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrok
212
226
  createdAt?: number;
213
227
  ttlMs?: number;
214
228
  }): ContextArtifact | null {
229
+ if (!shouldBrokerToolName(event.toolName)) return null;
230
+
215
231
  if (event.sourceId) {
216
232
  const existingHandle = sourceHandles.get(event.sourceId);
217
233
  if (existingHandle) {
@@ -295,6 +311,7 @@ export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrok
295
311
  isError: Boolean(entry.message.isError),
296
312
  sourceId,
297
313
  createdAt,
314
+ ttlMs: ttlFromNowFor(createdAt),
298
315
  }) && !alreadySeen) added += 1;
299
316
  }
300
317
 
@@ -316,6 +333,7 @@ export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrok
316
333
  isError: typeof entry.message.exitCode === "number" ? entry.message.exitCode !== 0 : Boolean(entry.message.cancelled),
317
334
  sourceId,
318
335
  createdAt,
336
+ ttlMs: ttlFromNowFor(createdAt),
319
337
  }) && !alreadySeen) added += 1;
320
338
  }
321
339
  } catch {
@@ -379,6 +397,16 @@ export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrok
379
397
  );
380
398
  });
381
399
 
400
+ pi.on("session_compact", async (_event, ctx) => {
401
+ activeSessionId = sessionIdFor(ctx);
402
+ const before = broker.status();
403
+ const after = broker.purge({ sessionId: activeSessionId, keepPinned: true });
404
+ seenSourceIds.clear();
405
+ sourceHandles.clear();
406
+ const removed = before.records - after.records;
407
+ if (removed > 0) ctx.ui.notify(`Context broker compact cleanup purged ${removed} unpinned artifact${removed === 1 ? "" : "s"}; pinned artifacts retained.`, "info");
408
+ });
409
+
382
410
  pi.on("tool_result", async (event: ToolResultEvent, ctx) => {
383
411
  activeSessionId = sessionIdFor(ctx);
384
412
  publishToolArtifact({ ...event, sourceId: event.toolCallId });
@@ -387,13 +415,17 @@ export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrok
387
415
  pi.on("context", async (event, ctx) => {
388
416
  activeSessionId = sessionIdFor(ctx);
389
417
  const toolInputs = collectToolInputs(event.messages);
390
- const drafts = event.messages.map((message: any): { original: any; artifact?: ContextArtifact; rewrite?: (artifact: ContextArtifact) => any } => {
418
+ const drafts = event.messages.map((message: any): { original: any; replacement?: any; artifact?: ContextArtifact; rewrite?: (artifact: ContextArtifact) => any } => {
391
419
  if (message?.role === "toolResult") {
392
420
  const raw = contentText(message.content);
393
421
  if (Buffer.byteLength(raw, "utf8") <= rewriteThresholdBytes) return { original: message };
394
422
  const toolInput = typeof message.toolCallId === "string" ? toolInputs.get(message.toolCallId) : undefined;
423
+ const toolName = String(message.toolName ?? toolInput?.toolName ?? "tool");
424
+ if (!shouldBrokerToolName(toolName)) {
425
+ return { original: message, replacement: { ...message, content: [{ type: "text", text: contextLookupHistoryPlaceholder() }] } };
426
+ }
395
427
  const artifact = publishToolArtifact({
396
- toolName: String(message.toolName ?? toolInput?.toolName ?? "tool"),
428
+ toolName,
397
429
  input: message.input ?? toolInput?.input,
398
430
  content: message.content,
399
431
  details: message.details,
@@ -436,6 +468,10 @@ export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrok
436
468
 
437
469
  let changed = false;
438
470
  const messages = drafts.map((draft) => {
471
+ if (draft.replacement) {
472
+ changed = true;
473
+ return draft.replacement;
474
+ }
439
475
  if (!draft.artifact || !draft.rewrite) return draft.original;
440
476
  const live = broker.lookup({ handle: draft.artifact.handle })[0];
441
477
  if (!live) {
@@ -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.18",
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",