@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.
- package/README.md +2 -1
- package/node_modules/@fiale-plus/pi-core/src/context-broker.ts +20 -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 +262 -3
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/extension.ts +242 -28
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/file.ts +165 -0
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/index.test.ts +60 -0
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/index.ts +99 -20
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/sqlite.test.ts +78 -0
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/sqlite.ts +500 -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,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
|
|
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
|
-
|
|
213
|
+
ttlMs?: number;
|
|
214
|
+
}): ContextArtifact | null {
|
|
134
215
|
if (event.sourceId) {
|
|
135
|
-
|
|
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
|
|
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(
|
|
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
|
|
148
|
-
paths: typeof
|
|
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
|
-
|
|
247
|
+
if (event.sourceId) sourceHandles.set(event.sourceId, artifact.handle);
|
|
248
|
+
return artifact;
|
|
154
249
|
}
|
|
155
250
|
|
|
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
|
-
|
|
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 :
|
|
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({
|