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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,19 @@ 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
+
98
158
  function summarizeTool(event: { toolName: string; input?: any; isError?: boolean }, bytes: number): string {
99
159
  const command = event.toolName === "bash" ? event.input?.command : undefined;
100
160
  const path = event.input?.path;
@@ -102,6 +162,11 @@ function summarizeTool(event: { toolName: string; input?: any; isError?: boolean
102
162
  return `${event.isError ? "failed" : "completed"} ${event.toolName}${target}; payload=${bytes} bytes`;
103
163
  }
104
164
 
165
+ function ttlFromNowFor(createdAt: number | undefined): number | undefined {
166
+ if (typeof createdAt !== "number" || !Number.isFinite(createdAt)) return undefined;
167
+ return Math.max(DEFAULT_TTL_MS, Date.now() - createdAt + DEFAULT_TTL_MS);
168
+ }
169
+
105
170
  export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrokerBetaOptions = {}): void {
106
171
  const p = pi as any;
107
172
  if (p.__piRogueContextBrokerBetaRegistered) return;
@@ -110,18 +175,33 @@ export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrok
110
175
  const briefBytes = options.briefBytes ?? DEFAULT_BRIEF_BYTES;
111
176
  const lookupBytes = options.lookupBytes ?? DEFAULT_LOOKUP_BYTES;
112
177
  const searchBytes = options.searchBytes ?? DEFAULT_SEARCH_BYTES;
113
- const broker = createInMemoryContextBroker({
178
+ const rewriteThresholdBytes = options.rewriteThresholdBytes ?? DEFAULT_REWRITE_THRESHOLD_BYTES;
179
+ const brokerOptions = {
114
180
  maxRecords: options.maxRecords ?? 64,
115
181
  maxBytes: options.maxBytes ?? 8 * 1024 * 1024,
116
182
  briefBytes,
117
- });
183
+ };
184
+ const durable = options.durable ?? (envFlag("PI_CONTEXT_BROKER_DURABLE") || Boolean(options.storeDir ?? process.env.PI_CONTEXT_BROKER_STORE_DIR));
185
+ const durableBackend = String(process.env.PI_CONTEXT_BROKER_BACKEND ?? "sqlite").trim().toLowerCase();
186
+ const broker = durable
187
+ ? durableBackend === "jsonl"
188
+ ? createFileContextBroker({ ...brokerOptions, dir: options.storeDir ?? process.env.PI_CONTEXT_BROKER_STORE_DIR })
189
+ : createSqliteContextBroker({ ...brokerOptions, dir: options.storeDir ?? process.env.PI_CONTEXT_BROKER_STORE_DIR })
190
+ : createInMemoryContextBroker(brokerOptions);
118
191
  const seenSourceIds = new Set<string>();
192
+ const sourceHandles = new Map<string, string>();
119
193
  let activeSessionId = process.cwd();
120
194
 
121
195
  function currentBrief(): string {
122
196
  return broker.renderBrief({ sessionId: activeSessionId, budgetBytes: briefBytes });
123
197
  }
124
198
 
199
+ p.__piRogueContextBroker = {
200
+ renderBrief: currentBrief,
201
+ lookup: broker.lookup,
202
+ status: broker.status,
203
+ };
204
+
125
205
  function publishToolArtifact(event: {
126
206
  toolName: string;
127
207
  input?: any;
@@ -130,41 +210,48 @@ export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrok
130
210
  isError?: boolean;
131
211
  sourceId?: string;
132
212
  createdAt?: number;
133
- }): boolean {
213
+ ttlMs?: number;
214
+ }): ContextArtifact | null {
134
215
  if (event.sourceId) {
135
- if (seenSourceIds.has(event.sourceId)) return false;
216
+ const existingHandle = sourceHandles.get(event.sourceId);
217
+ if (existingHandle) {
218
+ const existing = broker.lookup({ handle: existingHandle })[0];
219
+ if (existing) return existing;
220
+ sourceHandles.delete(event.sourceId);
221
+ seenSourceIds.delete(event.sourceId);
222
+ }
223
+ if (seenSourceIds.has(event.sourceId)) seenSourceIds.delete(event.sourceId);
136
224
  seenSourceIds.add(event.sourceId);
137
225
  }
138
226
 
139
- const payload = toolPayload(event);
227
+ const sanitizedEvent = {
228
+ ...event,
229
+ input: sanitizeValue(event.input) as any,
230
+ content: sanitizeValue(event.content),
231
+ details: sanitizeValue(event.details),
232
+ };
233
+ const payload = toolPayload(sanitizedEvent);
140
234
  const bytes = Buffer.byteLength(payload, "utf8");
141
- broker.publish({
235
+ const artifact = broker.publish({
142
236
  sessionId: activeSessionId,
143
237
  kind: "tool_output",
144
238
  payload,
145
- summary: summarizeTool(event, bytes),
239
+ summary: summarizeTool(sanitizedEvent, bytes),
146
240
  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,
241
+ command: event.toolName === "bash" && typeof sanitizedEvent.input?.command === "string" ? sanitizedEvent.input.command : undefined,
242
+ paths: typeof sanitizedEvent.input?.path === "string" ? [sanitizedEvent.input.path] : [],
243
+ ttlMs: event.ttlMs ?? DEFAULT_TTL_MS,
150
244
  parentIds: event.sourceId ? [event.sourceId] : [],
151
245
  createdAt: event.createdAt,
152
246
  });
153
- return true;
247
+ if (event.sourceId) sourceHandles.set(event.sourceId, artifact.handle);
248
+ return artifact;
154
249
  }
155
250
 
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
-
251
+ function collectToolInputs(entries: any[]): Map<string, { toolName?: string; input?: unknown }> {
165
252
  const toolInputs = new Map<string, { toolName?: string; input?: unknown }>();
166
253
  for (const entry of entries) {
167
- const message = entry?.type === "message" ? entry.message : undefined;
254
+ const message = entry?.type === "message" ? entry.message : entry;
168
255
  if (message?.role !== "assistant" || !Array.isArray(message.content)) continue;
169
256
  for (const block of message.content) {
170
257
  if (block?.type === "toolCall" && typeof block.id === "string") {
@@ -172,6 +259,19 @@ export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrok
172
259
  }
173
260
  }
174
261
  }
262
+ return toolInputs;
263
+ }
264
+
265
+ function backfillSessionArtifacts(ctx: Partial<SessionContextLike>): { added: number; scanned: number; errors: number } {
266
+ activeSessionId = sessionIdFor(ctx);
267
+ let entries: any[] = [];
268
+ try {
269
+ entries = ctx.sessionManager?.getBranch?.() ?? [];
270
+ } catch {
271
+ return { added: 0, scanned: 0, errors: 1 };
272
+ }
273
+
274
+ const toolInputs = collectToolInputs(entries);
175
275
 
176
276
  let added = 0;
177
277
  let scanned = 0;
@@ -186,6 +286,7 @@ export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrok
186
286
  scanned += 1;
187
287
  const sourceId = typeof entry.message.toolCallId === "string" ? entry.message.toolCallId : entryId;
188
288
  const toolInput = sourceId ? toolInputs.get(sourceId) : undefined;
289
+ const alreadySeen = sourceId ? seenSourceIds.has(sourceId) || sourceHandles.has(sourceId) : false;
189
290
  if (publishToolArtifact({
190
291
  toolName: String(entry.message.toolName ?? toolInput?.toolName ?? "tool"),
191
292
  input: entry.message.input ?? toolInput?.input,
@@ -194,13 +295,14 @@ export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrok
194
295
  isError: Boolean(entry.message.isError),
195
296
  sourceId,
196
297
  createdAt,
197
- })) added += 1;
298
+ }) && !alreadySeen) added += 1;
198
299
  }
199
300
 
200
301
  if (entry?.type === "message" && entry.message?.role === "bashExecution") {
201
302
  if (entry.message.excludeFromContext === true) continue;
202
303
  scanned += 1;
203
304
  const sourceId = entryId;
305
+ const alreadySeen = sourceId ? seenSourceIds.has(sourceId) || sourceHandles.has(sourceId) : false;
204
306
  if (publishToolArtifact({
205
307
  toolName: "bash",
206
308
  input: { command: entry.message.command },
@@ -214,7 +316,7 @@ export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrok
214
316
  isError: typeof entry.message.exitCode === "number" ? entry.message.exitCode !== 0 : Boolean(entry.message.cancelled),
215
317
  sourceId,
216
318
  createdAt,
217
- })) added += 1;
319
+ }) && !alreadySeen) added += 1;
218
320
  }
219
321
  } catch {
220
322
  errors += 1;
@@ -282,6 +384,71 @@ export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrok
282
384
  publishToolArtifact({ ...event, sourceId: event.toolCallId });
283
385
  });
284
386
 
387
+ pi.on("context", async (event, ctx) => {
388
+ activeSessionId = sessionIdFor(ctx);
389
+ const toolInputs = collectToolInputs(event.messages);
390
+ const drafts = event.messages.map((message: any): { original: any; artifact?: ContextArtifact; rewrite?: (artifact: ContextArtifact) => any } => {
391
+ if (message?.role === "toolResult") {
392
+ const raw = contentText(message.content);
393
+ if (Buffer.byteLength(raw, "utf8") <= rewriteThresholdBytes) return { original: message };
394
+ const toolInput = typeof message.toolCallId === "string" ? toolInputs.get(message.toolCallId) : undefined;
395
+ const artifact = publishToolArtifact({
396
+ toolName: String(message.toolName ?? toolInput?.toolName ?? "tool"),
397
+ input: message.input ?? toolInput?.input,
398
+ content: message.content,
399
+ details: message.details,
400
+ isError: Boolean(message.isError),
401
+ sourceId: typeof message.toolCallId === "string" ? message.toolCallId : undefined,
402
+ createdAt: typeof message.timestamp === "number" ? message.timestamp : undefined,
403
+ ttlMs: ttlFromNowFor(typeof message.timestamp === "number" ? message.timestamp : undefined),
404
+ });
405
+ if (!artifact) return { original: message };
406
+ return { original: message, artifact, rewrite: (live) => ({ ...message, content: [{ type: "text", text: brokerPlaceholder(live) }] }) };
407
+ }
408
+
409
+ if (message?.role === "bashExecution" && message.excludeFromContext !== true) {
410
+ const raw = String(message.output ?? "");
411
+ if (Buffer.byteLength(raw, "utf8") <= rewriteThresholdBytes) return { original: message };
412
+ const sourceId = typeof message.timestamp === "number"
413
+ ? `bash:${message.timestamp}:${stableHash([message.command ?? "", raw, message.exitCode ?? "", message.cancelled ?? ""].join("\n"))}`
414
+ : `bash:${stableHash([message.command ?? "", raw, message.exitCode ?? "", message.cancelled ?? ""].join("\n"))}`;
415
+ const artifact = publishToolArtifact({
416
+ toolName: "bash",
417
+ input: { command: message.command },
418
+ content: message.output,
419
+ details: {
420
+ exitCode: message.exitCode,
421
+ cancelled: message.cancelled,
422
+ truncated: message.truncated,
423
+ fullOutputPath: message.fullOutputPath,
424
+ },
425
+ isError: typeof message.exitCode === "number" ? message.exitCode !== 0 : Boolean(message.cancelled),
426
+ sourceId,
427
+ createdAt: typeof message.timestamp === "number" ? message.timestamp : undefined,
428
+ ttlMs: ttlFromNowFor(typeof message.timestamp === "number" ? message.timestamp : undefined),
429
+ });
430
+ if (!artifact) return { original: message };
431
+ return { original: message, artifact, rewrite: (live) => ({ ...message, output: brokerPlaceholder(live), truncated: true }) };
432
+ }
433
+
434
+ return { original: message };
435
+ });
436
+
437
+ let changed = false;
438
+ const messages = drafts.map((draft) => {
439
+ if (!draft.artifact || !draft.rewrite) return draft.original;
440
+ const live = broker.lookup({ handle: draft.artifact.handle })[0];
441
+ if (!live) {
442
+ for (const parentId of draft.artifact.parentIds) sourceHandles.delete(parentId);
443
+ return draft.original;
444
+ }
445
+ changed = true;
446
+ return draft.rewrite(live);
447
+ });
448
+
449
+ return changed ? { messages } : undefined;
450
+ });
451
+
285
452
  pi.on("before_agent_start", async (event) => {
286
453
  const brief = currentBrief();
287
454
  if (!brief.includes("ctx://")) return;
@@ -294,6 +461,53 @@ export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrok
294
461
  };
295
462
  });
296
463
 
464
+ pi.registerTool({
465
+ name: "context_lookup",
466
+ label: "Context Lookup",
467
+ description: "Lookup exact or searchable context broker artifacts by handle, current-session text, path, tag, kind, or tier.",
468
+ promptSnippet: "context_lookup: retrieve context broker artifacts by ctx:// handle or focused filters before asking the user to repeat prior tool output.",
469
+ promptGuidelines: [
470
+ "Use context_lookup when a ctx:// handle is relevant and exact evidence is needed.",
471
+ "Do not paste large raw broker payloads unless the user explicitly asks; summarize and cite handles instead.",
472
+ ],
473
+ parameters: Type.Object({
474
+ handle: Type.Optional(Type.String({ description: "Exact ctx:// handle to retrieve" })),
475
+ text: Type.Optional(Type.String({ description: "Current-session text search over broker summaries and indexed payload text" })),
476
+ path: Type.Optional(Type.String({ description: "File or directory path filter" })),
477
+ tag: Type.Optional(Type.String({ description: "Artifact tag filter" })),
478
+ kind: Type.Optional(Type.String({ enum: ["tool_output", "diff", "file_snapshot", "subagent_result", "advisor_brief", "memory_note"] })),
479
+ tier: Type.Optional(Type.String({ enum: ["hot", "warm", "cold"] })),
480
+ limit: Type.Optional(Type.Number({ minimum: 1, maximum: 10, description: "Maximum artifacts to return" })),
481
+ }),
482
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
483
+ activeSessionId = sessionIdFor(ctx);
484
+ const p = params as { handle?: string; text?: string; path?: string; tag?: string; kind?: any; tier?: any; limit?: number };
485
+ const exact = typeof p.handle === "string" && p.handle.startsWith("ctx://");
486
+ const focused = exact || Boolean(p.text?.trim() || p.path?.trim() || p.tag?.trim() || p.kind || p.tier);
487
+ if (!focused) {
488
+ 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.");
489
+ }
490
+ const results = broker.lookup({
491
+ handle: exact ? p.handle : undefined,
492
+ sessionId: exact ? undefined : activeSessionId,
493
+ text: exact ? undefined : p.text,
494
+ path: p.path,
495
+ tag: p.tag,
496
+ kind: p.kind,
497
+ tier: p.tier,
498
+ limit: Math.min(10, Math.max(1, Math.floor(p.limit ?? (exact ? 1 : 5)))),
499
+ });
500
+ 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"));
508
+ },
509
+ });
510
+
297
511
  pi.registerCommand("context", {
298
512
  description: "Inspect the beta context broker: status | brief | lookup <handle-or-text> | pin <handle> | prune",
299
513
  getArgumentCompletions: contextArgumentCompletions,
@@ -305,7 +519,7 @@ export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrok
305
519
  if (action === "status") {
306
520
  const status = broker.status();
307
521
  ctx.ui.notify(
308
- `Context broker beta: enabled, session=${activeSessionId}, records=${status.records}, bytes=${status.bytes}/${status.maxBytes}, pinned=${status.pinnedRecords}/${status.pinnedBytes} bytes`,
522
+ `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
523
  "info",
310
524
  );
311
525
  return;
@@ -0,0 +1,165 @@
1
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, 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 } 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
+ export function createFileContextBroker(options: FileContextBrokerOptions = {}): BoundedContextBroker {
117
+ const dir = options.dir ?? process.env.PI_CONTEXT_BROKER_STORE_DIR ?? defaultStoreDir();
118
+ ensureDir(join(dir, "blobs"));
119
+ const broker = createInMemoryContextBroker(options);
120
+ const persistedSources = new Map<string, string>();
121
+ const handleAliases = new Map<string, string>();
122
+
123
+ for (const record of readStoredRecords(dir)) {
124
+ const payload = loadPayload(dir, record.input.payloadSha256);
125
+ if (payload === undefined) continue;
126
+ const artifact = broker.publish({ ...record.input, tier: record.baseTier ?? record.input.tier, payload });
127
+ handleAliases.set(record.handle, artifact.handle);
128
+ const source = stableSource(record.input as unknown as ContextArtifactInput);
129
+ if (source) persistedSources.set(source, artifact.handle);
130
+ }
131
+
132
+ function publish(input: ContextArtifactInput): ContextArtifact {
133
+ const source = stableSource(input);
134
+ const existingHandle = source ? persistedSources.get(source) : undefined;
135
+ if (existingHandle) {
136
+ const existing = broker.lookup({ handle: existingHandle })[0];
137
+ if (existing) return existing;
138
+ }
139
+
140
+ const artifact = broker.publish(input);
141
+ if (source) persistedSources.set(source, artifact.handle);
142
+ persistRecord(dir, artifact, input);
143
+ return artifact;
144
+ }
145
+
146
+ return {
147
+ publish,
148
+ lookup(query?: ContextLookupQuery): ContextArtifact[] {
149
+ const mappedHandle = query?.handle ? handleAliases.get(query.handle) : undefined;
150
+ return broker.lookup(mappedHandle ? { ...query, handle: mappedHandle } : query);
151
+ },
152
+ pin(idOrHandle: string, pinned?: boolean): ContextArtifact | null {
153
+ const artifact = broker.pin(handleAliases.get(idOrHandle) ?? idOrHandle, pinned);
154
+ if (artifact) persistArtifactSnapshot(dir, artifact);
155
+ return artifact;
156
+ },
157
+ prune(now?: number): ContextBrokerStatus { return broker.prune(now); },
158
+ status(): ContextBrokerStatus { return broker.status(); },
159
+ renderBrief(query?: ContextLookupQuery & { budgetBytes?: number }): string { return broker.renderBrief(query); },
160
+ };
161
+ }
162
+
163
+ export function contextBrokerStoreDirForSession(baseDir: string, sessionId: string): string {
164
+ return join(baseDir, safeName(sessionId));
165
+ }
@@ -179,6 +179,66 @@ describe("createInMemoryContextBroker", () => {
179
179
  expect(broker.lookup({ id: artifact.id })).toEqual([]);
180
180
  });
181
181
 
182
+ it("classifies artifacts into hot, warm, and cold tiers on publish", () => {
183
+ const broker = createInMemoryContextBroker({ defaultTtlMs: 0 });
184
+ const failure = broker.publish({ sessionId: "s", kind: "tool_output", payload: "failed", tags: ["error"], createdAt: 1 });
185
+ const command = broker.publish({ sessionId: "s", kind: "tool_output", payload: "passed", tags: ["ok"], createdAt: 2 });
186
+ const archive = broker.publish({ sessionId: "s", kind: "subagent_result", payload: "old", tags: ["completed"], createdAt: 3 });
187
+ const explicit = broker.publish({ sessionId: "s", kind: "diff", payload: "manual", tier: "cold", createdAt: 4 });
188
+
189
+ expect(failure.tier).toBe("hot");
190
+ expect(command.tier).toBe("warm");
191
+ expect(archive.tier).toBe("cold");
192
+ expect(explicit.tier).toBe("cold");
193
+ expect(broker.lookup({ sessionId: "s", tier: "cold" }).map((artifact) => artifact.id)).toEqual([explicit.id, archive.id]);
194
+ });
195
+
196
+ it("renders prompt briefs hot-first, warm-second, and excludes cold unless explicit", () => {
197
+ const broker = createInMemoryContextBroker({ briefBytes: 900, defaultTtlMs: 0 });
198
+ const cold = broker.publish({ sessionId: "s", kind: "tool_output", payload: "cold", summary: "cold archive", tier: "cold", createdAt: 1 });
199
+ const warm = broker.publish({ sessionId: "s", kind: "tool_output", payload: "warm", summary: "warm command", tier: "warm", createdAt: 2 });
200
+ const hot = broker.publish({ sessionId: "s", kind: "tool_output", payload: "hot", summary: "hot failure", tier: "hot", createdAt: 3 });
201
+
202
+ const brief = broker.renderBrief({ sessionId: "s" });
203
+ expect(brief).toContain("Hot:");
204
+ expect(brief).toContain(hot.handle);
205
+ expect(brief).toContain("Warm:");
206
+ expect(brief).toContain(warm.handle);
207
+ expect(brief).not.toContain(cold.handle);
208
+ expect(brief.indexOf(hot.handle)).toBeLessThan(brief.indexOf(warm.handle));
209
+
210
+ const coldBrief = broker.renderBrief({ sessionId: "s", tier: "cold", budgetBytes: 500 });
211
+ expect(coldBrief).toContain("Cold:");
212
+ expect(coldBrief).toContain(cold.handle);
213
+
214
+ expect(broker.pin(cold.handle, true)?.tier).toBe("hot");
215
+ expect(broker.renderBrief({ sessionId: "s" })).toContain(cold.handle);
216
+ expect(broker.pin(cold.handle, false)?.tier).toBe("cold");
217
+ expect(broker.renderBrief({ sessionId: "s" })).not.toContain(cold.handle);
218
+ });
219
+
220
+ it("applies tier-specific record, byte, and ttl retention", () => {
221
+ const broker = createInMemoryContextBroker({
222
+ defaultTtlMs: 0,
223
+ hotMaxRecords: 1,
224
+ warmMaxBytes: 6,
225
+ coldTtlMs: 10,
226
+ });
227
+ const oldHot = broker.publish({ sessionId: "s", kind: "tool_output", payload: "old-hot", tier: "hot", createdAt: 1 });
228
+ const newHot = broker.publish({ sessionId: "s", kind: "tool_output", payload: "new-hot", tier: "hot", createdAt: 2 });
229
+ const oldWarm = broker.publish({ sessionId: "s", kind: "tool_output", payload: "12345", tier: "warm", createdAt: 3 });
230
+ const newWarm = broker.publish({ sessionId: "s", kind: "tool_output", payload: "abcde", tier: "warm", createdAt: 4 });
231
+ const cold = broker.publish({ sessionId: "s", kind: "tool_output", payload: "cold", tier: "cold", createdAt: 5 });
232
+
233
+ expect(broker.lookup({ id: oldHot.id })).toEqual([]);
234
+ expect(broker.lookup({ id: newHot.id })).toEqual([newHot]);
235
+ expect(broker.lookup({ id: oldWarm.id })).toEqual([]);
236
+ expect(broker.lookup({ id: newWarm.id })).toEqual([newWarm]);
237
+
238
+ broker.prune(16);
239
+ expect(broker.lookup({ id: cold.id })).toEqual([]);
240
+ });
241
+
182
242
  it("renders a bounded prompt brief with lookup instructions", () => {
183
243
  const broker = createInMemoryContextBroker({ briefBytes: 180 });
184
244
  broker.publish({