@fiale-plus/pi-rogue-bundle 0.1.15 → 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.
@@ -0,0 +1,573 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { AgentToolResult } from "@earendil-works/pi-coding-agent";
3
+ import type { AutocompleteItem } from "@earendil-works/pi-tui";
4
+ import { Type } from "typebox";
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";
8
+ import { createInMemoryContextBroker } from "./index.js";
9
+ import { createSqliteContextBroker } from "./sqlite.js";
10
+
11
+ export interface ContextBrokerBetaOptions {
12
+ enabled?: boolean;
13
+ maxRecords?: number;
14
+ maxBytes?: number;
15
+ briefBytes?: number;
16
+ lookupBytes?: number;
17
+ searchBytes?: number;
18
+ rewriteThresholdBytes?: number;
19
+ durable?: boolean;
20
+ storeDir?: string;
21
+ }
22
+
23
+ type UiLike = { notify(message: string, type?: "info" | "warning" | "error"): void; setStatus?(key: string, text: string | undefined): void };
24
+ type SessionContextLike = Pick<ExtensionContext, "cwd" | "sessionManager"> & { ui: UiLike };
25
+
26
+ const DEFAULT_BRIEF_BYTES = 1_800;
27
+ const DEFAULT_LOOKUP_BYTES = 12_000;
28
+ const DEFAULT_SEARCH_BYTES = 2_000;
29
+ const DEFAULT_REWRITE_THRESHOLD_BYTES = 2_000;
30
+ const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
31
+ const ENABLED_VALUES = new Set(["1", "true", "yes", "on"]);
32
+
33
+ function envFlag(name: string): boolean {
34
+ return ENABLED_VALUES.has(String(process.env[name] ?? "").trim().toLowerCase());
35
+ }
36
+
37
+ function isEnvEnabled(): boolean {
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
+ ]));
61
+ }
62
+
63
+ function toText(value: unknown): string {
64
+ if (typeof value === "string") return redactSecrets(value);
65
+ try {
66
+ return redactSecrets(JSON.stringify(value, null, 2));
67
+ } catch {
68
+ return redactSecrets(String(value ?? ""));
69
+ }
70
+ }
71
+
72
+ function truncateUtf8(text: string, maxBytes: number): string {
73
+ const limit = Math.max(0, Math.floor(maxBytes));
74
+ const totalBytes = Buffer.byteLength(text, "utf8");
75
+ if (totalBytes <= limit) return text;
76
+ if (limit === 0) return "";
77
+
78
+ let omittedBytes = totalBytes;
79
+ let result = "";
80
+ let marker = "…";
81
+
82
+ for (let pass = 0; pass < 4; pass += 1) {
83
+ const verboseMarker = `\n[truncated: omitted ${omittedBytes} bytes]`;
84
+ marker = Buffer.byteLength(verboseMarker, "utf8") < limit ? verboseMarker : "…";
85
+ const contentLimit = Math.max(0, limit - Buffer.byteLength(marker, "utf8"));
86
+ let used = 0;
87
+ let prefix = "";
88
+
89
+ for (const char of text) {
90
+ const bytes = Buffer.byteLength(char, "utf8");
91
+ if (used + bytes > contentLimit) break;
92
+ prefix += char;
93
+ used += bytes;
94
+ }
95
+
96
+ result = prefix;
97
+ const nextOmittedBytes = totalBytes - used;
98
+ if (nextOmittedBytes === omittedBytes) break;
99
+ omittedBytes = nextOmittedBytes;
100
+ }
101
+
102
+ return `${result}${marker}`;
103
+ }
104
+
105
+ function compact(value: string, max = 120): string {
106
+ return truncateUtf8(value.replace(/\s+/g, " ").trim(), max);
107
+ }
108
+
109
+ function stableHash(value: string): string {
110
+ return createHash("sha256").update(value).digest("hex").slice(0, 16);
111
+ }
112
+
113
+ function sessionIdFor(ctx: Partial<SessionContextLike>): string {
114
+ const file = ctx.sessionManager?.getSessionFile?.();
115
+ return file || ctx.cwd || process.cwd();
116
+ }
117
+
118
+ function messageTimestamp(entry: any): number | undefined {
119
+ const value = entry?.message?.timestamp ?? entry?.timestamp;
120
+ if (typeof value === "number" && Number.isFinite(value)) return value;
121
+ const parsed = Date.parse(String(value ?? ""));
122
+ return Number.isFinite(parsed) ? parsed : undefined;
123
+ }
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
+
132
+ function toolPayload(event: { toolName: string; input?: unknown; content?: unknown; details?: unknown; isError?: boolean }): string {
133
+ return [
134
+ `tool=${event.toolName}`,
135
+ `isError=${Boolean(event.isError)}`,
136
+ "input:",
137
+ toText(event.input),
138
+ "content:",
139
+ toText(event.content),
140
+ "details:",
141
+ toText(event.details),
142
+ ].join("\n");
143
+ }
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 summarizeTool(event: { toolName: string; input?: any; isError?: boolean }, bytes: number): string {
159
+ const command = event.toolName === "bash" ? event.input?.command : undefined;
160
+ const path = event.input?.path;
161
+ const target = command ? ` command=${compact(String(command), 120)}` : path ? ` path=${path}` : "";
162
+ return `${event.isError ? "failed" : "completed"} ${event.toolName}${target}; payload=${bytes} bytes`;
163
+ }
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
+
170
+ export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrokerBetaOptions = {}): void {
171
+ const p = pi as any;
172
+ if (p.__piRogueContextBrokerBetaRegistered) return;
173
+ p.__piRogueContextBrokerBetaRegistered = true;
174
+
175
+ const briefBytes = options.briefBytes ?? DEFAULT_BRIEF_BYTES;
176
+ const lookupBytes = options.lookupBytes ?? DEFAULT_LOOKUP_BYTES;
177
+ const searchBytes = options.searchBytes ?? DEFAULT_SEARCH_BYTES;
178
+ const rewriteThresholdBytes = options.rewriteThresholdBytes ?? DEFAULT_REWRITE_THRESHOLD_BYTES;
179
+ const brokerOptions = {
180
+ maxRecords: options.maxRecords ?? 64,
181
+ maxBytes: options.maxBytes ?? 8 * 1024 * 1024,
182
+ briefBytes,
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);
191
+ const seenSourceIds = new Set<string>();
192
+ const sourceHandles = new Map<string, string>();
193
+ let activeSessionId = process.cwd();
194
+
195
+ function currentBrief(): string {
196
+ return broker.renderBrief({ sessionId: activeSessionId, budgetBytes: briefBytes });
197
+ }
198
+
199
+ p.__piRogueContextBroker = {
200
+ renderBrief: currentBrief,
201
+ lookup: broker.lookup,
202
+ status: broker.status,
203
+ };
204
+
205
+ function publishToolArtifact(event: {
206
+ toolName: string;
207
+ input?: any;
208
+ content?: unknown;
209
+ details?: unknown;
210
+ isError?: boolean;
211
+ sourceId?: string;
212
+ createdAt?: number;
213
+ ttlMs?: number;
214
+ }): ContextArtifact | null {
215
+ if (event.sourceId) {
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);
224
+ seenSourceIds.add(event.sourceId);
225
+ }
226
+
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);
234
+ const bytes = Buffer.byteLength(payload, "utf8");
235
+ const artifact = broker.publish({
236
+ sessionId: activeSessionId,
237
+ kind: "tool_output",
238
+ payload,
239
+ summary: summarizeTool(sanitizedEvent, bytes),
240
+ tags: [event.toolName, event.isError ? "error" : "ok", event.sourceId ? "session-backfill" : "live"],
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,
244
+ parentIds: event.sourceId ? [event.sourceId] : [],
245
+ createdAt: event.createdAt,
246
+ });
247
+ if (event.sourceId) sourceHandles.set(event.sourceId, artifact.handle);
248
+ return artifact;
249
+ }
250
+
251
+ function collectToolInputs(entries: any[]): Map<string, { toolName?: string; input?: unknown }> {
252
+ const toolInputs = new Map<string, { toolName?: string; input?: unknown }>();
253
+ for (const entry of entries) {
254
+ const message = entry?.type === "message" ? entry.message : entry;
255
+ if (message?.role !== "assistant" || !Array.isArray(message.content)) continue;
256
+ for (const block of message.content) {
257
+ if (block?.type === "toolCall" && typeof block.id === "string") {
258
+ toolInputs.set(block.id, { toolName: typeof block.name === "string" ? block.name : undefined, input: block.arguments });
259
+ }
260
+ }
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);
275
+
276
+ let added = 0;
277
+ let scanned = 0;
278
+ let errors = 0;
279
+
280
+ for (const entry of entries) {
281
+ try {
282
+ const entryId = typeof entry?.id === "string" ? entry.id : undefined;
283
+ const createdAt = messageTimestamp(entry);
284
+
285
+ if (entry?.type === "message" && entry.message?.role === "toolResult") {
286
+ scanned += 1;
287
+ const sourceId = typeof entry.message.toolCallId === "string" ? entry.message.toolCallId : entryId;
288
+ const toolInput = sourceId ? toolInputs.get(sourceId) : undefined;
289
+ const alreadySeen = sourceId ? seenSourceIds.has(sourceId) || sourceHandles.has(sourceId) : false;
290
+ if (publishToolArtifact({
291
+ toolName: String(entry.message.toolName ?? toolInput?.toolName ?? "tool"),
292
+ input: entry.message.input ?? toolInput?.input,
293
+ content: entry.message.content,
294
+ details: entry.message.details,
295
+ isError: Boolean(entry.message.isError),
296
+ sourceId,
297
+ createdAt,
298
+ }) && !alreadySeen) added += 1;
299
+ }
300
+
301
+ if (entry?.type === "message" && entry.message?.role === "bashExecution") {
302
+ if (entry.message.excludeFromContext === true) continue;
303
+ scanned += 1;
304
+ const sourceId = entryId;
305
+ const alreadySeen = sourceId ? seenSourceIds.has(sourceId) || sourceHandles.has(sourceId) : false;
306
+ if (publishToolArtifact({
307
+ toolName: "bash",
308
+ input: { command: entry.message.command },
309
+ content: entry.message.output,
310
+ details: {
311
+ exitCode: entry.message.exitCode,
312
+ cancelled: entry.message.cancelled,
313
+ truncated: entry.message.truncated,
314
+ fullOutputPath: entry.message.fullOutputPath,
315
+ },
316
+ isError: typeof entry.message.exitCode === "number" ? entry.message.exitCode !== 0 : Boolean(entry.message.cancelled),
317
+ sourceId,
318
+ createdAt,
319
+ }) && !alreadySeen) added += 1;
320
+ }
321
+ } catch {
322
+ errors += 1;
323
+ }
324
+ }
325
+
326
+ return { added, scanned, errors };
327
+ }
328
+
329
+ const contextActions: AutocompleteItem[] = [
330
+ { value: "status", label: "status", description: "Show broker record, byte, and pinned counts" },
331
+ { value: "brief", label: "brief", description: "Show the bounded broker brief" },
332
+ { value: "lookup ", label: "lookup", description: "Lookup by ctx:// handle or current-session text" },
333
+ { value: "pin ", label: "pin", description: "Pin an artifact by ctx:// handle or id" },
334
+ { value: "prune", label: "prune", description: "Run TTL/cap pruning now" },
335
+ ];
336
+
337
+ function artifactCompletions(action: "lookup" | "pin", query: string): AutocompleteItem[] {
338
+ const needle = query.trim().toLowerCase();
339
+ return broker.lookup({ sessionId: activeSessionId, limit: 10 })
340
+ .filter((artifact) => {
341
+ if (!needle) return true;
342
+ return artifact.handle.toLowerCase().includes(needle)
343
+ || artifact.summary.toLowerCase().includes(needle)
344
+ || artifact.kind.toLowerCase().includes(needle)
345
+ || artifact.tags.join(" ").toLowerCase().includes(needle)
346
+ || artifact.paths.join(" ").toLowerCase().includes(needle);
347
+ })
348
+ .map((artifact) => ({
349
+ value: `${action} ${artifact.handle}`,
350
+ label: `${action} ${artifact.kind}`,
351
+ description: `${artifact.pinned ? "pinned; " : ""}${artifact.summary}`,
352
+ }));
353
+ }
354
+
355
+ function contextArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null {
356
+ const prefix = argumentPrefix.trimStart();
357
+ const [action = "", ...restParts] = prefix.split(/\s+/);
358
+ const hasActionSeparator = /\s/.test(prefix);
359
+
360
+ if (!action || !hasActionSeparator) {
361
+ const items = contextActions.filter((item) => item.value.trim().startsWith(action));
362
+ return items.length ? items : contextActions;
363
+ }
364
+
365
+ if (action === "lookup" || action === "pin") {
366
+ const items = artifactCompletions(action, restParts.join(" "));
367
+ return items.length ? items : null;
368
+ }
369
+
370
+ return null;
371
+ }
372
+
373
+ pi.on("session_start", async (_event, ctx) => {
374
+ const { added, scanned, errors } = backfillSessionArtifacts(ctx);
375
+ ctx.ui.setStatus?.("context-broker", "ctx:on beta");
376
+ ctx.ui.notify(
377
+ `Context broker beta enabled. Backfilled ${added}/${scanned} current-branch tool artifacts${errors ? ` (${errors} malformed skipped)` : ""}. Use /context status or /context brief.`,
378
+ errors ? "warning" : "info",
379
+ );
380
+ });
381
+
382
+ pi.on("tool_result", async (event: ToolResultEvent, ctx) => {
383
+ activeSessionId = sessionIdFor(ctx);
384
+ publishToolArtifact({ ...event, sourceId: event.toolCallId });
385
+ });
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
+
452
+ pi.on("before_agent_start", async (event) => {
453
+ const brief = currentBrief();
454
+ if (!brief.includes("ctx://")) return;
455
+ return {
456
+ systemPrompt: [
457
+ event.systemPrompt,
458
+ brief,
459
+ "Context broker beta rule: use /context lookup <handle> for exact evidence when a broker handle is relevant. Broker briefs are bounded summaries and never raw payload dumps.",
460
+ ].join("\n\n"),
461
+ };
462
+ });
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
+
511
+ pi.registerCommand("context", {
512
+ description: "Inspect the beta context broker: status | brief | lookup <handle-or-text> | pin <handle> | prune",
513
+ getArgumentCompletions: contextArgumentCompletions,
514
+ handler: async (args, ctx) => {
515
+ activeSessionId = sessionIdFor(ctx);
516
+ const [action = "status", ...rest] = String(args || "").trim().split(/\s+/).filter(Boolean);
517
+ const query = rest.join(" ");
518
+
519
+ if (action === "status") {
520
+ const status = broker.status();
521
+ ctx.ui.notify(
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`,
523
+ "info",
524
+ );
525
+ return;
526
+ }
527
+
528
+ if (action === "brief") {
529
+ ctx.ui.notify(currentBrief(), "info");
530
+ return;
531
+ }
532
+
533
+ if (action === "lookup") {
534
+ if (!query) {
535
+ ctx.ui.notify("Usage: /context lookup <ctx://handle-or-text>", "warning");
536
+ return;
537
+ }
538
+ const exact = query.startsWith("ctx://");
539
+ 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");
547
+ return;
548
+ }
549
+
550
+ if (action === "pin") {
551
+ if (!query) {
552
+ ctx.ui.notify("Usage: /context pin <ctx://handle-or-id>", "warning");
553
+ return;
554
+ }
555
+ const pinned = broker.pin(query, true);
556
+ ctx.ui.notify(pinned ? `Pinned ${pinned.handle}` : "No artifact matched that handle/id.", pinned ? "info" : "warning");
557
+ return;
558
+ }
559
+
560
+ if (action === "prune") {
561
+ const status = broker.prune();
562
+ ctx.ui.notify(`Pruned. ${status.records} records, ${status.bytes} bytes remain.`, "info");
563
+ return;
564
+ }
565
+
566
+ ctx.ui.notify("Usage: /context status | brief | lookup <handle-or-text> | pin <handle> | prune", "warning");
567
+ },
568
+ });
569
+ }
570
+
571
+ export function shouldEnableContextBrokerBeta(options: ContextBrokerBetaOptions = {}): boolean {
572
+ return Boolean(options.enabled ?? isEnvEnabled());
573
+ }