@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.
- package/README.md +2 -1
- package/node_modules/@fiale-plus/pi-core/src/context-broker.ts +26 -0
- package/node_modules/@fiale-plus/pi-rogue-advisor/src/extension.ts +26 -7
- package/node_modules/@fiale-plus/pi-rogue-advisor/src/loop-convergence.test.ts +17 -0
- package/node_modules/@fiale-plus/pi-rogue-context-broker/README.md +12 -6
- package/node_modules/@fiale-plus/pi-rogue-context-broker/package.json +5 -2
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/extension.test.ts +362 -3
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/extension.ts +278 -28
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/file.ts +191 -0
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/index.test.ts +74 -0
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/index.ts +112 -20
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/sqlite.test.ts +98 -0
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/sqlite.ts +524 -0
- package/package.json +4 -2
- package/src/context-broker-file.ts +1 -0
- package/src/context-broker-sqlite.ts +1 -0
- package/src/extension.test.ts +5 -0
- package/src/extension.ts +3 -3
|
@@ -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
|
|
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
|
|
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
|
-
|
|
227
|
+
ttlMs?: number;
|
|
228
|
+
}): ContextArtifact | null {
|
|
229
|
+
if (!shouldBrokerToolName(event.toolName)) return null;
|
|
230
|
+
|
|
134
231
|
if (event.sourceId) {
|
|
135
|
-
|
|
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
|
|
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(
|
|
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
|
|
148
|
-
paths: typeof
|
|
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
|
-
|
|
263
|
+
if (event.sourceId) sourceHandles.set(event.sourceId, artifact.handle);
|
|
264
|
+
return artifact;
|
|
154
265
|
}
|
|
155
266
|
|
|
156
|
-
function
|
|
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 :
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|