@fiale-plus/pi-rogue-bundle 0.1.16 → 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.
@@ -1,6 +1,12 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { AgentToolResult } from "@earendil-works/pi-coding-agent";
1
3
  import type { AutocompleteItem } from "@earendil-works/pi-tui";
4
+ import { Type } from "typebox";
2
5
  import type { ExtensionAPI, ExtensionContext, ToolResultEvent } from "@earendil-works/pi-coding-agent";
6
+ import type { ContextArtifact } from "@fiale-plus/pi-core";
7
+ import { createFileContextBroker } from "./file.js";
3
8
  import { createInMemoryContextBroker } from "./index.js";
9
+ import { createSqliteContextBroker } from "./sqlite.js";
4
10
 
5
11
  export interface ContextBrokerBetaOptions {
6
12
  enabled?: boolean;
@@ -9,6 +15,9 @@ export interface ContextBrokerBetaOptions {
9
15
  briefBytes?: number;
10
16
  lookupBytes?: number;
11
17
  searchBytes?: number;
18
+ rewriteThresholdBytes?: number;
19
+ durable?: boolean;
20
+ storeDir?: string;
12
21
  }
13
22
 
14
23
  type UiLike = { notify(message: string, type?: "info" | "warning" | "error"): void; setStatus?(key: string, text: string | undefined): void };
@@ -17,19 +26,46 @@ type SessionContextLike = Pick<ExtensionContext, "cwd" | "sessionManager"> & { u
17
26
  const DEFAULT_BRIEF_BYTES = 1_800;
18
27
  const DEFAULT_LOOKUP_BYTES = 12_000;
19
28
  const DEFAULT_SEARCH_BYTES = 2_000;
29
+ const DEFAULT_REWRITE_THRESHOLD_BYTES = 2_000;
20
30
  const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
21
31
  const ENABLED_VALUES = new Set(["1", "true", "yes", "on"]);
22
32
 
33
+ function envFlag(name: string): boolean {
34
+ return ENABLED_VALUES.has(String(process.env[name] ?? "").trim().toLowerCase());
35
+ }
36
+
23
37
  function isEnvEnabled(): boolean {
24
- return ENABLED_VALUES.has(String(process.env.PI_CONTEXT_BROKER_ENABLED ?? "").trim().toLowerCase());
38
+ return envFlag("PI_CONTEXT_BROKER_ENABLED");
39
+ }
40
+
41
+ function isSensitiveKey(key: string): boolean {
42
+ return /(?:api[_-]?key|token|secret|password|credential)/i.test(key);
43
+ }
44
+
45
+ function redactSecrets(text: string): string {
46
+ return text
47
+ .replace(/\b(sk-[A-Za-z0-9_-]{12,})\b/g, "[REDACTED_API_KEY]")
48
+ .replace(/\b(gh[pousr]_[A-Za-z0-9_]{12,})\b/g, "[REDACTED_GITHUB_TOKEN]")
49
+ .replace(/([\"']?(?:api[_-]?key|token|secret|password|credential)[\w.-]*[\"']?\s*[:=]\s*[\"']?)([^\s'\",;}]+)/gi, "$1[REDACTED]");
50
+ }
51
+
52
+ function sanitizeValue(value: unknown, depth = 0): unknown {
53
+ if (typeof value === "string") return redactSecrets(value);
54
+ if (value == null || typeof value !== "object") return value;
55
+ if (depth > 6) return "[MAX_DEPTH]";
56
+ if (Array.isArray(value)) return value.map((item) => sanitizeValue(item, depth + 1));
57
+ return Object.fromEntries(Object.entries(value as Record<string, unknown>).map(([key, item]) => [
58
+ key,
59
+ isSensitiveKey(key) ? "[REDACTED]" : sanitizeValue(item, depth + 1),
60
+ ]));
25
61
  }
26
62
 
27
63
  function toText(value: unknown): string {
28
- if (typeof value === "string") return value;
64
+ if (typeof value === "string") return redactSecrets(value);
29
65
  try {
30
- return JSON.stringify(value, null, 2);
66
+ return redactSecrets(JSON.stringify(value, null, 2));
31
67
  } catch {
32
- return String(value ?? "");
68
+ return redactSecrets(String(value ?? ""));
33
69
  }
34
70
  }
35
71
 
@@ -70,6 +106,10 @@ function compact(value: string, max = 120): string {
70
106
  return truncateUtf8(value.replace(/\s+/g, " ").trim(), max);
71
107
  }
72
108
 
109
+ function stableHash(value: string): string {
110
+ return createHash("sha256").update(value).digest("hex").slice(0, 16);
111
+ }
112
+
73
113
  function sessionIdFor(ctx: Partial<SessionContextLike>): string {
74
114
  const file = ctx.sessionManager?.getSessionFile?.();
75
115
  return file || ctx.cwd || process.cwd();
@@ -82,6 +122,13 @@ function messageTimestamp(entry: any): number | undefined {
82
122
  return Number.isFinite(parsed) ? parsed : undefined;
83
123
  }
84
124
 
125
+ function contentText(content: unknown): string {
126
+ if (Array.isArray(content)) {
127
+ return content.map((block) => block?.type === "text" ? block.text : toText(block)).join("\n");
128
+ }
129
+ return toText(content);
130
+ }
131
+
85
132
  function toolPayload(event: { toolName: string; input?: unknown; content?: unknown; details?: unknown; isError?: boolean }): string {
86
133
  return [
87
134
  `tool=${event.toolName}`,
@@ -95,6 +142,27 @@ function toolPayload(event: { toolName: string; input?: unknown; content?: unkno
95
142
  ].join("\n");
96
143
  }
97
144
 
145
+ function textResult(text: string): AgentToolResult<unknown> {
146
+ return { content: [{ type: "text", text }], details: {} };
147
+ }
148
+
149
+ function brokerPlaceholder(artifact: ContextArtifact): string {
150
+ return [
151
+ `Context broker artifact: ${artifact.handle}`,
152
+ `Summary: ${artifact.summary}`,
153
+ `Payload bytes: ${artifact.bytes}`,
154
+ "Raw payload omitted from prompt. Use /context lookup <handle> if exact evidence is needed.",
155
+ ].join("\n");
156
+ }
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
+
98
166
  function summarizeTool(event: { toolName: string; input?: any; isError?: boolean }, bytes: number): string {
99
167
  const command = event.toolName === "bash" ? event.input?.command : undefined;
100
168
  const path = event.input?.path;
@@ -102,6 +170,17 @@ function summarizeTool(event: { toolName: string; input?: any; isError?: boolean
102
170
  return `${event.isError ? "failed" : "completed"} ${event.toolName}${target}; payload=${bytes} bytes`;
103
171
  }
104
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
+
179
+ function ttlFromNowFor(createdAt: number | undefined): number | undefined {
180
+ if (typeof createdAt !== "number" || !Number.isFinite(createdAt)) return undefined;
181
+ return Math.max(DEFAULT_TTL_MS, Date.now() - createdAt + DEFAULT_TTL_MS);
182
+ }
183
+
105
184
  export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrokerBetaOptions = {}): void {
106
185
  const p = pi as any;
107
186
  if (p.__piRogueContextBrokerBetaRegistered) return;
@@ -110,18 +189,33 @@ export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrok
110
189
  const briefBytes = options.briefBytes ?? DEFAULT_BRIEF_BYTES;
111
190
  const lookupBytes = options.lookupBytes ?? DEFAULT_LOOKUP_BYTES;
112
191
  const searchBytes = options.searchBytes ?? DEFAULT_SEARCH_BYTES;
113
- const broker = createInMemoryContextBroker({
192
+ const rewriteThresholdBytes = options.rewriteThresholdBytes ?? DEFAULT_REWRITE_THRESHOLD_BYTES;
193
+ const brokerOptions = {
114
194
  maxRecords: options.maxRecords ?? 64,
115
195
  maxBytes: options.maxBytes ?? 8 * 1024 * 1024,
116
196
  briefBytes,
117
- });
197
+ };
198
+ const durable = options.durable ?? (envFlag("PI_CONTEXT_BROKER_DURABLE") || Boolean(options.storeDir ?? process.env.PI_CONTEXT_BROKER_STORE_DIR));
199
+ const durableBackend = String(process.env.PI_CONTEXT_BROKER_BACKEND ?? "sqlite").trim().toLowerCase();
200
+ const broker = durable
201
+ ? durableBackend === "jsonl"
202
+ ? createFileContextBroker({ ...brokerOptions, dir: options.storeDir ?? process.env.PI_CONTEXT_BROKER_STORE_DIR })
203
+ : createSqliteContextBroker({ ...brokerOptions, dir: options.storeDir ?? process.env.PI_CONTEXT_BROKER_STORE_DIR })
204
+ : createInMemoryContextBroker(brokerOptions);
118
205
  const seenSourceIds = new Set<string>();
206
+ const sourceHandles = new Map<string, string>();
119
207
  let activeSessionId = process.cwd();
120
208
 
121
209
  function currentBrief(): string {
122
210
  return broker.renderBrief({ sessionId: activeSessionId, budgetBytes: briefBytes });
123
211
  }
124
212
 
213
+ p.__piRogueContextBroker = {
214
+ renderBrief: currentBrief,
215
+ lookup: broker.lookup,
216
+ status: broker.status,
217
+ };
218
+
125
219
  function publishToolArtifact(event: {
126
220
  toolName: string;
127
221
  input?: any;
@@ -130,41 +224,50 @@ export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrok
130
224
  isError?: boolean;
131
225
  sourceId?: string;
132
226
  createdAt?: number;
133
- }): boolean {
227
+ ttlMs?: number;
228
+ }): ContextArtifact | null {
229
+ if (!shouldBrokerToolName(event.toolName)) return null;
230
+
134
231
  if (event.sourceId) {
135
- if (seenSourceIds.has(event.sourceId)) return false;
232
+ const existingHandle = sourceHandles.get(event.sourceId);
233
+ if (existingHandle) {
234
+ const existing = broker.lookup({ handle: existingHandle })[0];
235
+ if (existing) return existing;
236
+ sourceHandles.delete(event.sourceId);
237
+ seenSourceIds.delete(event.sourceId);
238
+ }
239
+ if (seenSourceIds.has(event.sourceId)) seenSourceIds.delete(event.sourceId);
136
240
  seenSourceIds.add(event.sourceId);
137
241
  }
138
242
 
139
- const payload = toolPayload(event);
243
+ const sanitizedEvent = {
244
+ ...event,
245
+ input: sanitizeValue(event.input) as any,
246
+ content: sanitizeValue(event.content),
247
+ details: sanitizeValue(event.details),
248
+ };
249
+ const payload = toolPayload(sanitizedEvent);
140
250
  const bytes = Buffer.byteLength(payload, "utf8");
141
- broker.publish({
251
+ const artifact = broker.publish({
142
252
  sessionId: activeSessionId,
143
253
  kind: "tool_output",
144
254
  payload,
145
- summary: summarizeTool(event, bytes),
255
+ summary: summarizeTool(sanitizedEvent, bytes),
146
256
  tags: [event.toolName, event.isError ? "error" : "ok", event.sourceId ? "session-backfill" : "live"],
147
- command: event.toolName === "bash" && typeof event.input?.command === "string" ? event.input.command : undefined,
148
- paths: typeof event.input?.path === "string" ? [event.input.path] : [],
149
- ttlMs: DEFAULT_TTL_MS,
257
+ command: event.toolName === "bash" && typeof sanitizedEvent.input?.command === "string" ? sanitizedEvent.input.command : undefined,
258
+ paths: typeof sanitizedEvent.input?.path === "string" ? [sanitizedEvent.input.path] : [],
259
+ ttlMs: event.ttlMs ?? DEFAULT_TTL_MS,
150
260
  parentIds: event.sourceId ? [event.sourceId] : [],
151
261
  createdAt: event.createdAt,
152
262
  });
153
- return true;
263
+ if (event.sourceId) sourceHandles.set(event.sourceId, artifact.handle);
264
+ return artifact;
154
265
  }
155
266
 
156
- function backfillSessionArtifacts(ctx: Partial<SessionContextLike>): { added: number; scanned: number; errors: number } {
157
- activeSessionId = sessionIdFor(ctx);
158
- let entries: any[] = [];
159
- try {
160
- entries = ctx.sessionManager?.getBranch?.() ?? [];
161
- } catch {
162
- return { added: 0, scanned: 0, errors: 1 };
163
- }
164
-
267
+ function collectToolInputs(entries: any[]): Map<string, { toolName?: string; input?: unknown }> {
165
268
  const toolInputs = new Map<string, { toolName?: string; input?: unknown }>();
166
269
  for (const entry of entries) {
167
- const message = entry?.type === "message" ? entry.message : undefined;
270
+ const message = entry?.type === "message" ? entry.message : entry;
168
271
  if (message?.role !== "assistant" || !Array.isArray(message.content)) continue;
169
272
  for (const block of message.content) {
170
273
  if (block?.type === "toolCall" && typeof block.id === "string") {
@@ -172,6 +275,19 @@ export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrok
172
275
  }
173
276
  }
174
277
  }
278
+ return toolInputs;
279
+ }
280
+
281
+ function backfillSessionArtifacts(ctx: Partial<SessionContextLike>): { added: number; scanned: number; errors: number } {
282
+ activeSessionId = sessionIdFor(ctx);
283
+ let entries: any[] = [];
284
+ try {
285
+ entries = ctx.sessionManager?.getBranch?.() ?? [];
286
+ } catch {
287
+ return { added: 0, scanned: 0, errors: 1 };
288
+ }
289
+
290
+ const toolInputs = collectToolInputs(entries);
175
291
 
176
292
  let added = 0;
177
293
  let scanned = 0;
@@ -186,6 +302,7 @@ export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrok
186
302
  scanned += 1;
187
303
  const sourceId = typeof entry.message.toolCallId === "string" ? entry.message.toolCallId : entryId;
188
304
  const toolInput = sourceId ? toolInputs.get(sourceId) : undefined;
305
+ const alreadySeen = sourceId ? seenSourceIds.has(sourceId) || sourceHandles.has(sourceId) : false;
189
306
  if (publishToolArtifact({
190
307
  toolName: String(entry.message.toolName ?? toolInput?.toolName ?? "tool"),
191
308
  input: entry.message.input ?? toolInput?.input,
@@ -194,13 +311,15 @@ export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrok
194
311
  isError: Boolean(entry.message.isError),
195
312
  sourceId,
196
313
  createdAt,
197
- })) added += 1;
314
+ ttlMs: ttlFromNowFor(createdAt),
315
+ }) && !alreadySeen) added += 1;
198
316
  }
199
317
 
200
318
  if (entry?.type === "message" && entry.message?.role === "bashExecution") {
201
319
  if (entry.message.excludeFromContext === true) continue;
202
320
  scanned += 1;
203
321
  const sourceId = entryId;
322
+ const alreadySeen = sourceId ? seenSourceIds.has(sourceId) || sourceHandles.has(sourceId) : false;
204
323
  if (publishToolArtifact({
205
324
  toolName: "bash",
206
325
  input: { command: entry.message.command },
@@ -214,7 +333,8 @@ export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrok
214
333
  isError: typeof entry.message.exitCode === "number" ? entry.message.exitCode !== 0 : Boolean(entry.message.cancelled),
215
334
  sourceId,
216
335
  createdAt,
217
- })) added += 1;
336
+ ttlMs: ttlFromNowFor(createdAt),
337
+ }) && !alreadySeen) added += 1;
218
338
  }
219
339
  } catch {
220
340
  errors += 1;
@@ -277,11 +397,94 @@ export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrok
277
397
  );
278
398
  });
279
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
+
280
410
  pi.on("tool_result", async (event: ToolResultEvent, ctx) => {
281
411
  activeSessionId = sessionIdFor(ctx);
282
412
  publishToolArtifact({ ...event, sourceId: event.toolCallId });
283
413
  });
284
414
 
415
+ pi.on("context", async (event, ctx) => {
416
+ activeSessionId = sessionIdFor(ctx);
417
+ const toolInputs = collectToolInputs(event.messages);
418
+ const drafts = event.messages.map((message: any): { original: any; replacement?: any; artifact?: ContextArtifact; rewrite?: (artifact: ContextArtifact) => any } => {
419
+ if (message?.role === "toolResult") {
420
+ const raw = contentText(message.content);
421
+ if (Buffer.byteLength(raw, "utf8") <= rewriteThresholdBytes) return { original: message };
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
+ }
427
+ const artifact = publishToolArtifact({
428
+ toolName,
429
+ input: message.input ?? toolInput?.input,
430
+ content: message.content,
431
+ details: message.details,
432
+ isError: Boolean(message.isError),
433
+ sourceId: typeof message.toolCallId === "string" ? message.toolCallId : undefined,
434
+ createdAt: typeof message.timestamp === "number" ? message.timestamp : undefined,
435
+ ttlMs: ttlFromNowFor(typeof message.timestamp === "number" ? message.timestamp : undefined),
436
+ });
437
+ if (!artifact) return { original: message };
438
+ return { original: message, artifact, rewrite: (live) => ({ ...message, content: [{ type: "text", text: brokerPlaceholder(live) }] }) };
439
+ }
440
+
441
+ if (message?.role === "bashExecution" && message.excludeFromContext !== true) {
442
+ const raw = String(message.output ?? "");
443
+ if (Buffer.byteLength(raw, "utf8") <= rewriteThresholdBytes) return { original: message };
444
+ const sourceId = typeof message.timestamp === "number"
445
+ ? `bash:${message.timestamp}:${stableHash([message.command ?? "", raw, message.exitCode ?? "", message.cancelled ?? ""].join("\n"))}`
446
+ : `bash:${stableHash([message.command ?? "", raw, message.exitCode ?? "", message.cancelled ?? ""].join("\n"))}`;
447
+ const artifact = publishToolArtifact({
448
+ toolName: "bash",
449
+ input: { command: message.command },
450
+ content: message.output,
451
+ details: {
452
+ exitCode: message.exitCode,
453
+ cancelled: message.cancelled,
454
+ truncated: message.truncated,
455
+ fullOutputPath: message.fullOutputPath,
456
+ },
457
+ isError: typeof message.exitCode === "number" ? message.exitCode !== 0 : Boolean(message.cancelled),
458
+ sourceId,
459
+ createdAt: typeof message.timestamp === "number" ? message.timestamp : undefined,
460
+ ttlMs: ttlFromNowFor(typeof message.timestamp === "number" ? message.timestamp : undefined),
461
+ });
462
+ if (!artifact) return { original: message };
463
+ return { original: message, artifact, rewrite: (live) => ({ ...message, output: brokerPlaceholder(live), truncated: true }) };
464
+ }
465
+
466
+ return { original: message };
467
+ });
468
+
469
+ let changed = false;
470
+ const messages = drafts.map((draft) => {
471
+ if (draft.replacement) {
472
+ changed = true;
473
+ return draft.replacement;
474
+ }
475
+ if (!draft.artifact || !draft.rewrite) return draft.original;
476
+ const live = broker.lookup({ handle: draft.artifact.handle })[0];
477
+ if (!live) {
478
+ for (const parentId of draft.artifact.parentIds) sourceHandles.delete(parentId);
479
+ return draft.original;
480
+ }
481
+ changed = true;
482
+ return draft.rewrite(live);
483
+ });
484
+
485
+ return changed ? { messages } : undefined;
486
+ });
487
+
285
488
  pi.on("before_agent_start", async (event) => {
286
489
  const brief = currentBrief();
287
490
  if (!brief.includes("ctx://")) return;
@@ -294,6 +497,53 @@ export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrok
294
497
  };
295
498
  });
296
499
 
500
+ pi.registerTool({
501
+ name: "context_lookup",
502
+ label: "Context Lookup",
503
+ description: "Lookup exact or searchable context broker artifacts by handle, current-session text, path, tag, kind, or tier.",
504
+ promptSnippet: "context_lookup: retrieve context broker artifacts by ctx:// handle or focused filters before asking the user to repeat prior tool output.",
505
+ promptGuidelines: [
506
+ "Use context_lookup when a ctx:// handle is relevant and exact evidence is needed.",
507
+ "Do not paste large raw broker payloads unless the user explicitly asks; summarize and cite handles instead.",
508
+ ],
509
+ parameters: Type.Object({
510
+ handle: Type.Optional(Type.String({ description: "Exact ctx:// handle to retrieve" })),
511
+ text: Type.Optional(Type.String({ description: "Current-session text search over broker summaries and indexed payload text" })),
512
+ path: Type.Optional(Type.String({ description: "File or directory path filter" })),
513
+ tag: Type.Optional(Type.String({ description: "Artifact tag filter" })),
514
+ kind: Type.Optional(Type.String({ enum: ["tool_output", "diff", "file_snapshot", "subagent_result", "advisor_brief", "memory_note"] })),
515
+ tier: Type.Optional(Type.String({ enum: ["hot", "warm", "cold"] })),
516
+ limit: Type.Optional(Type.Number({ minimum: 1, maximum: 10, description: "Maximum artifacts to return" })),
517
+ }),
518
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
519
+ activeSessionId = sessionIdFor(ctx);
520
+ const p = params as { handle?: string; text?: string; path?: string; tag?: string; kind?: any; tier?: any; limit?: number };
521
+ const exact = typeof p.handle === "string" && p.handle.startsWith("ctx://");
522
+ const focused = exact || Boolean(p.text?.trim() || p.path?.trim() || p.tag?.trim() || p.kind || p.tier);
523
+ if (!focused) {
524
+ return textResult("context_lookup requires a focused filter: handle, text, path, tag, kind, or tier. Empty lookups are refused to avoid dumping brokered payloads into the prompt.");
525
+ }
526
+ const results = broker.lookup({
527
+ handle: exact ? p.handle : undefined,
528
+ sessionId: exact ? undefined : activeSessionId,
529
+ text: exact ? undefined : p.text,
530
+ path: p.path,
531
+ tag: p.tag,
532
+ kind: p.kind,
533
+ tier: p.tier,
534
+ limit: Math.min(10, Math.max(1, Math.floor(p.limit ?? (exact ? 1 : 5)))),
535
+ });
536
+ if (!results.length) return textResult("No context artifacts matched. Missing or expired handles should be reported explicitly.");
537
+ return textResult(results.map((item) => [
538
+ item.handle,
539
+ `tier=${item.tier} kind=${item.kind} bytes=${item.bytes}`,
540
+ `summary=${item.summary}`,
541
+ "payload:",
542
+ truncateUtf8(item.payload, exact ? lookupBytes : searchBytes),
543
+ ].join("\n")).join("\n\n---\n\n"));
544
+ },
545
+ });
546
+
297
547
  pi.registerCommand("context", {
298
548
  description: "Inspect the beta context broker: status | brief | lookup <handle-or-text> | pin <handle> | prune",
299
549
  getArgumentCompletions: contextArgumentCompletions,
@@ -305,7 +555,7 @@ export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrok
305
555
  if (action === "status") {
306
556
  const status = broker.status();
307
557
  ctx.ui.notify(
308
- `Context broker beta: enabled, session=${activeSessionId}, records=${status.records}, bytes=${status.bytes}/${status.maxBytes}, pinned=${status.pinnedRecords}/${status.pinnedBytes} bytes`,
558
+ `Context broker beta: enabled, session=${activeSessionId}, records=${status.records}, bytes=${status.bytes}/${status.maxBytes}, tiers=hot:${status.hotRecords}/${status.hotBytes} warm:${status.warmRecords}/${status.warmBytes} cold:${status.coldRecords}/${status.coldBytes}, pinned=${status.pinnedRecords}/${status.pinnedBytes} bytes`,
309
559
  "info",
310
560
  );
311
561
  return;
@@ -0,0 +1,191 @@
1
+ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import { safeName } from "@fiale-plus/pi-core";
5
+ import type { BoundedContextBroker, ContextArtifact, ContextArtifactInput, ContextArtifactTier, ContextBrokerOptions, ContextBrokerStatus, ContextLookupQuery, ContextPurgeOptions } from "@fiale-plus/pi-core";
6
+ import { createInMemoryContextBroker } from "./index.js";
7
+
8
+ export interface FileContextBrokerOptions extends ContextBrokerOptions {
9
+ dir?: string;
10
+ }
11
+
12
+ const STORE_VERSION = 1;
13
+
14
+ interface StoredRecord {
15
+ version: number;
16
+ handle: string;
17
+ baseTier?: ContextArtifactTier;
18
+ input: Omit<ContextArtifactInput, "payload"> & { payloadSha256: string };
19
+ }
20
+
21
+ function defaultStoreDir(): string {
22
+ return join(homedir(), ".pi", "agent", "fiale-plus", "context-broker");
23
+ }
24
+
25
+ function ensureDir(path: string): void {
26
+ mkdirSync(path, { recursive: true });
27
+ }
28
+
29
+ function metadataFile(dir: string): string {
30
+ return join(dir, "metadata.jsonl");
31
+ }
32
+
33
+ function blobFile(dir: string, sha256: string): string {
34
+ return join(dir, "blobs", `${sha256}.txt`);
35
+ }
36
+
37
+ function stableSource(input: ContextArtifactInput): string | undefined {
38
+ return input.parentIds?.find(Boolean);
39
+ }
40
+
41
+ function readStoredRecords(dir: string): StoredRecord[] {
42
+ const file = metadataFile(dir);
43
+ if (!existsSync(file)) return [];
44
+ const recordsByHandle = new Map<string, StoredRecord>();
45
+ for (const line of readFileSync(file, "utf8").split("\n")) {
46
+ const trimmed = line.trim();
47
+ if (!trimmed) continue;
48
+ try {
49
+ const parsed = JSON.parse(trimmed) as StoredRecord;
50
+ if (parsed?.version === STORE_VERSION && parsed.handle && parsed.input?.payloadSha256) {
51
+ recordsByHandle.set(parsed.handle, parsed);
52
+ }
53
+ } catch {
54
+ // Ignore corrupt JSONL rows; durable storage is append-only recovery, not a startup blocker.
55
+ }
56
+ }
57
+ return [...recordsByHandle.values()];
58
+ }
59
+
60
+ function loadPayload(dir: string, sha256: string): string | undefined {
61
+ const file = blobFile(dir, sha256);
62
+ if (!existsSync(file)) return undefined;
63
+ return readFileSync(file, "utf8");
64
+ }
65
+
66
+ function artifactBaseTier(artifact: ContextArtifact, fallback?: ContextArtifactTier): ContextArtifactTier {
67
+ return (artifact as ContextArtifact & { baseTier?: ContextArtifactTier }).baseTier ?? fallback ?? artifact.tier;
68
+ }
69
+
70
+ function persistRecord(dir: string, artifact: ContextArtifact, input: ContextArtifactInput): void {
71
+ ensureDir(join(dir, "blobs"));
72
+ const blob = blobFile(dir, artifact.sha256);
73
+ if (!existsSync(blob)) writeFileSync(blob, artifact.payload, "utf8");
74
+ const record: StoredRecord = {
75
+ version: STORE_VERSION,
76
+ handle: artifact.handle,
77
+ baseTier: artifactBaseTier(artifact, input.tier),
78
+ input: {
79
+ sessionId: input.sessionId,
80
+ kind: input.kind,
81
+ summary: input.summary,
82
+ tags: input.tags,
83
+ paths: input.paths,
84
+ command: input.command,
85
+ branch: input.branch,
86
+ tier: artifactBaseTier(artifact, input.tier),
87
+ ttlMs: input.ttlMs,
88
+ pinned: artifact.pinned,
89
+ parentIds: input.parentIds,
90
+ createdAt: input.createdAt ?? artifact.createdAt,
91
+ payloadSha256: artifact.sha256,
92
+ },
93
+ };
94
+ ensureDir(dirname(metadataFile(dir)));
95
+ appendFileSync(metadataFile(dir), `${JSON.stringify(record)}\n`, "utf8");
96
+ }
97
+
98
+ function persistArtifactSnapshot(dir: string, artifact: ContextArtifact): void {
99
+ persistRecord(dir, artifact, {
100
+ sessionId: artifact.sessionId,
101
+ kind: artifact.kind,
102
+ payload: artifact.payload,
103
+ summary: artifact.summary,
104
+ tags: artifact.tags,
105
+ paths: artifact.paths,
106
+ command: artifact.command,
107
+ branch: artifact.branch,
108
+ tier: artifactBaseTier(artifact),
109
+ ttlMs: artifact.expiresAt ? Math.max(0, artifact.expiresAt - artifact.createdAt) : 0,
110
+ pinned: artifact.pinned,
111
+ parentIds: artifact.parentIds,
112
+ createdAt: artifact.createdAt,
113
+ });
114
+ }
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
+
126
+ export function createFileContextBroker(options: FileContextBrokerOptions = {}): BoundedContextBroker {
127
+ const dir = options.dir ?? process.env.PI_CONTEXT_BROKER_STORE_DIR ?? defaultStoreDir();
128
+ ensureDir(join(dir, "blobs"));
129
+ const broker = createInMemoryContextBroker(options);
130
+ const persistedSources = new Map<string, string>();
131
+ const handleAliases = new Map<string, string>();
132
+
133
+ for (const record of readStoredRecords(dir)) {
134
+ const payload = loadPayload(dir, record.input.payloadSha256);
135
+ if (payload === undefined) continue;
136
+ const artifact = broker.publish({ ...record.input, tier: record.baseTier ?? record.input.tier, payload });
137
+ handleAliases.set(record.handle, artifact.handle);
138
+ const source = stableSource(record.input as unknown as ContextArtifactInput);
139
+ if (source) persistedSources.set(source, artifact.handle);
140
+ }
141
+
142
+ function publish(input: ContextArtifactInput): ContextArtifact {
143
+ const source = stableSource(input);
144
+ const existingHandle = source ? persistedSources.get(source) : undefined;
145
+ if (existingHandle) {
146
+ const existing = broker.lookup({ handle: existingHandle })[0];
147
+ if (existing) return existing;
148
+ }
149
+
150
+ const artifact = broker.publish(input);
151
+ if (source) persistedSources.set(source, artifact.handle);
152
+ persistRecord(dir, artifact, input);
153
+ return artifact;
154
+ }
155
+
156
+ return {
157
+ publish,
158
+ lookup(query?: ContextLookupQuery): ContextArtifact[] {
159
+ const mappedHandle = query?.handle ? handleAliases.get(query.handle) : undefined;
160
+ return broker.lookup(mappedHandle ? { ...query, handle: mappedHandle } : query);
161
+ },
162
+ pin(idOrHandle: string, pinned?: boolean): ContextArtifact | null {
163
+ const artifact = broker.pin(handleAliases.get(idOrHandle) ?? idOrHandle, pinned);
164
+ if (artifact) persistArtifactSnapshot(dir, artifact);
165
+ return artifact;
166
+ },
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
+ },
184
+ status(): ContextBrokerStatus { return broker.status(); },
185
+ renderBrief(query?: ContextLookupQuery & { budgetBytes?: number }): string { return broker.renderBrief(query); },
186
+ };
187
+ }
188
+
189
+ export function contextBrokerStoreDirForSession(baseDir: string, sessionId: string): string {
190
+ return join(baseDir, safeName(sessionId));
191
+ }