@fiale-plus/pi-rogue-bundle 0.1.14 → 0.1.16
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 +7 -1
- package/node_modules/@fiale-plus/pi-core/README.md +13 -0
- package/node_modules/@fiale-plus/pi-core/package.json +25 -0
- package/node_modules/@fiale-plus/pi-core/src/context-broker.ts +81 -0
- package/node_modules/@fiale-plus/pi-core/src/index.ts +5 -0
- package/node_modules/@fiale-plus/pi-core/src/paths.ts +36 -0
- package/node_modules/@fiale-plus/pi-core/src/risk.test.ts +129 -0
- package/node_modules/@fiale-plus/pi-core/src/risk.ts +97 -0
- package/node_modules/@fiale-plus/pi-core/src/storage.ts +39 -0
- package/node_modules/@fiale-plus/pi-core/src/text.test.ts +36 -0
- package/node_modules/@fiale-plus/pi-core/src/text.ts +14 -0
- package/node_modules/@fiale-plus/pi-rogue-context-broker/README.md +38 -0
- package/node_modules/@fiale-plus/pi-rogue-context-broker/package.json +28 -0
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/extension.test.ts +221 -0
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/extension.ts +359 -0
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/index.test.ts +216 -0
- package/node_modules/@fiale-plus/pi-rogue-context-broker/src/index.ts +245 -0
- package/package.json +11 -3
- package/src/context-broker.ts +1 -0
- package/src/extension.test.ts +63 -0
- package/src/extension.ts +14 -3
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { registerContextBrokerBeta, shouldEnableContextBrokerBeta } from "./extension.js";
|
|
3
|
+
|
|
4
|
+
function createPiMock() {
|
|
5
|
+
const handlers = new Map<string, any[]>();
|
|
6
|
+
const commands = new Map<string, any>();
|
|
7
|
+
const pi: any = {
|
|
8
|
+
on(name: string, handler: any) {
|
|
9
|
+
handlers.set(name, [...(handlers.get(name) ?? []), handler]);
|
|
10
|
+
},
|
|
11
|
+
registerCommand(name: string, options: any) {
|
|
12
|
+
commands.set(name, options);
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
return { pi, handlers, commands };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function createCtx(entries: any[] = []) {
|
|
19
|
+
const notifications: Array<{ message: string; type?: string }> = [];
|
|
20
|
+
return {
|
|
21
|
+
ctx: {
|
|
22
|
+
cwd: "/repo",
|
|
23
|
+
ui: {
|
|
24
|
+
notify(message: string, type?: string) {
|
|
25
|
+
notifications.push({ message, type });
|
|
26
|
+
},
|
|
27
|
+
setStatus() {},
|
|
28
|
+
},
|
|
29
|
+
sessionManager: {
|
|
30
|
+
getSessionFile() {
|
|
31
|
+
return "/sessions/current.jsonl";
|
|
32
|
+
},
|
|
33
|
+
getBranch() {
|
|
34
|
+
return entries;
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
} as any,
|
|
38
|
+
notifications,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function runHandlers(handlers: Map<string, any[]>, name: string, event: any, ctx: any) {
|
|
43
|
+
for (const handler of handlers.get(name) ?? []) {
|
|
44
|
+
await handler(event, ctx);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe("context broker beta enablement", () => {
|
|
49
|
+
const oldEnv = process.env.PI_CONTEXT_BROKER_ENABLED;
|
|
50
|
+
|
|
51
|
+
afterEach(() => {
|
|
52
|
+
if (oldEnv === undefined) delete process.env.PI_CONTEXT_BROKER_ENABLED;
|
|
53
|
+
else process.env.PI_CONTEXT_BROKER_ENABLED = oldEnv;
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("is disabled by default unless explicitly opted in", () => {
|
|
57
|
+
delete process.env.PI_CONTEXT_BROKER_ENABLED;
|
|
58
|
+
expect(shouldEnableContextBrokerBeta()).toBe(false);
|
|
59
|
+
|
|
60
|
+
process.env.PI_CONTEXT_BROKER_ENABLED = "true";
|
|
61
|
+
expect(shouldEnableContextBrokerBeta()).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("registers /context with command completions", () => {
|
|
65
|
+
const { pi, commands } = createPiMock();
|
|
66
|
+
registerContextBrokerBeta(pi);
|
|
67
|
+
|
|
68
|
+
const command = commands.get("context");
|
|
69
|
+
expect(command).toBeTruthy();
|
|
70
|
+
expect(command.getArgumentCompletions("")?.map((item: any) => item.value.trim())).toEqual([
|
|
71
|
+
"status",
|
|
72
|
+
"brief",
|
|
73
|
+
"lookup",
|
|
74
|
+
"pin",
|
|
75
|
+
"prune",
|
|
76
|
+
]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("backfills current branch toolResult and bashExecution entries idempotently", async () => {
|
|
80
|
+
const { pi, handlers, commands } = createPiMock();
|
|
81
|
+
registerContextBrokerBeta(pi);
|
|
82
|
+
const entries = [
|
|
83
|
+
{
|
|
84
|
+
type: "message",
|
|
85
|
+
id: "assistant-1",
|
|
86
|
+
timestamp: "2026-06-05T00:00:00.000Z",
|
|
87
|
+
message: { role: "assistant", content: [{ type: "toolCall", id: "tc-read", name: "read", arguments: { path: "README.md" } }] },
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
type: "message",
|
|
91
|
+
id: "tool-1",
|
|
92
|
+
timestamp: "2026-06-05T00:00:00.000Z",
|
|
93
|
+
message: { role: "toolResult", toolCallId: "tc-read", toolName: "read", content: [{ type: "text", text: "readme" }], isError: false },
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
type: "message",
|
|
97
|
+
id: "bash-1",
|
|
98
|
+
timestamp: "2026-06-05T00:00:01.000Z",
|
|
99
|
+
message: { role: "bashExecution", command: "npm test", output: "passed", exitCode: 0, cancelled: false, truncated: false },
|
|
100
|
+
},
|
|
101
|
+
];
|
|
102
|
+
const { ctx, notifications } = createCtx(entries);
|
|
103
|
+
|
|
104
|
+
await runHandlers(handlers, "session_start", { type: "session_start" }, ctx);
|
|
105
|
+
await runHandlers(handlers, "session_start", { type: "session_start" }, ctx);
|
|
106
|
+
await commands.get("context").handler("status", ctx);
|
|
107
|
+
await commands.get("context").handler("lookup README.md", ctx);
|
|
108
|
+
|
|
109
|
+
expect(notifications[0].message).toContain("Backfilled 2/2");
|
|
110
|
+
expect(notifications[1].message).toContain("Backfilled 0/2");
|
|
111
|
+
expect(notifications.at(-2)?.message).toContain("records=2");
|
|
112
|
+
expect(notifications.at(-1)?.message).toContain("README.md");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("is safe on malformed session branches", async () => {
|
|
116
|
+
const { pi, handlers } = createPiMock();
|
|
117
|
+
registerContextBrokerBeta(pi);
|
|
118
|
+
const { ctx, notifications } = createCtx([null, { type: "message", id: "broken", message: null }]);
|
|
119
|
+
|
|
120
|
+
await runHandlers(handlers, "session_start", { type: "session_start" }, ctx);
|
|
121
|
+
|
|
122
|
+
expect(notifications[0].message).toContain("Backfilled 0/0");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("does not backfill bash entries explicitly excluded from context", async () => {
|
|
126
|
+
const { pi, handlers, commands } = createPiMock();
|
|
127
|
+
registerContextBrokerBeta(pi);
|
|
128
|
+
const { ctx, notifications } = createCtx([
|
|
129
|
+
{
|
|
130
|
+
type: "message",
|
|
131
|
+
id: "secret-bash",
|
|
132
|
+
timestamp: "2026-06-05T00:00:00.000Z",
|
|
133
|
+
message: {
|
|
134
|
+
role: "bashExecution",
|
|
135
|
+
command: "echo SECRET_TOKEN=abc123",
|
|
136
|
+
output: "SECRET_TOKEN=abc123",
|
|
137
|
+
exitCode: 0,
|
|
138
|
+
cancelled: false,
|
|
139
|
+
truncated: false,
|
|
140
|
+
excludeFromContext: true,
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
]);
|
|
144
|
+
|
|
145
|
+
await runHandlers(handlers, "session_start", { type: "session_start" }, ctx);
|
|
146
|
+
await commands.get("context").handler("brief", ctx);
|
|
147
|
+
|
|
148
|
+
expect(notifications[0].message).toContain("Backfilled 0/0");
|
|
149
|
+
expect(notifications.at(-1)?.message).not.toContain("SECRET_TOKEN");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("exact lookup returns byte-clipped payloads and marks truncation explicitly", async () => {
|
|
153
|
+
const { pi, handlers, commands } = createPiMock();
|
|
154
|
+
registerContextBrokerBeta(pi, { lookupBytes: 80, searchBytes: 50 });
|
|
155
|
+
const { ctx, notifications } = createCtx();
|
|
156
|
+
|
|
157
|
+
await runHandlers(handlers, "session_start", { type: "session_start" }, ctx);
|
|
158
|
+
await runHandlers(handlers, "tool_result", {
|
|
159
|
+
type: "tool_result",
|
|
160
|
+
toolCallId: "call-1",
|
|
161
|
+
toolName: "bash",
|
|
162
|
+
input: { command: "printf long" },
|
|
163
|
+
content: [{ type: "text", text: "測試".repeat(100) }],
|
|
164
|
+
isError: false,
|
|
165
|
+
}, ctx);
|
|
166
|
+
|
|
167
|
+
const lookupCompletion = commands.get("context").getArgumentCompletions("lookup ")?.[0];
|
|
168
|
+
expect(lookupCompletion.value).toMatch(/^lookup ctx:\/\//);
|
|
169
|
+
|
|
170
|
+
await commands.get("context").handler(lookupCompletion.value, ctx);
|
|
171
|
+
const payload = notifications.at(-1)?.message.split("payload:\n").at(-1) ?? "";
|
|
172
|
+
expect(notifications.at(-1)?.message).toContain("payload:");
|
|
173
|
+
expect(payload).toContain("[truncated: omitted");
|
|
174
|
+
expect(Buffer.byteLength(payload, "utf8")).toBeLessThanOrEqual(80);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("text search lookup returns a smaller byte-clipped excerpt", async () => {
|
|
178
|
+
const { pi, handlers, commands } = createPiMock();
|
|
179
|
+
registerContextBrokerBeta(pi, { lookupBytes: 80, searchBytes: 50 });
|
|
180
|
+
const { ctx, notifications } = createCtx();
|
|
181
|
+
|
|
182
|
+
await runHandlers(handlers, "session_start", { type: "session_start" }, ctx);
|
|
183
|
+
await runHandlers(handlers, "tool_result", {
|
|
184
|
+
type: "tool_result",
|
|
185
|
+
toolCallId: "call-2",
|
|
186
|
+
toolName: "bash",
|
|
187
|
+
input: { command: "echo needle" },
|
|
188
|
+
content: [{ type: "text", text: "needle " + "✅".repeat(100) }],
|
|
189
|
+
isError: false,
|
|
190
|
+
}, ctx);
|
|
191
|
+
|
|
192
|
+
await commands.get("context").handler("lookup needle", ctx);
|
|
193
|
+
const payload = notifications.at(-1)?.message.split("payload:\n").at(-1) ?? "";
|
|
194
|
+
expect(notifications.at(-1)?.message).toContain("payload:");
|
|
195
|
+
expect(payload).toContain("[truncated: omitted");
|
|
196
|
+
expect(Buffer.byteLength(payload, "utf8")).toBeLessThanOrEqual(50);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("injects a bounded broker brief without raw payload text", async () => {
|
|
200
|
+
const { pi, handlers } = createPiMock();
|
|
201
|
+
registerContextBrokerBeta(pi, { briefBytes: 220 });
|
|
202
|
+
const { ctx } = createCtx();
|
|
203
|
+
|
|
204
|
+
await runHandlers(handlers, "session_start", { type: "session_start" }, ctx);
|
|
205
|
+
await runHandlers(handlers, "tool_result", {
|
|
206
|
+
type: "tool_result",
|
|
207
|
+
toolCallId: "call-3",
|
|
208
|
+
toolName: "bash",
|
|
209
|
+
input: { command: "echo secret" },
|
|
210
|
+
content: [{ type: "text", text: "SECRET_TOKEN=" + "z".repeat(200) }],
|
|
211
|
+
isError: false,
|
|
212
|
+
}, ctx);
|
|
213
|
+
|
|
214
|
+
const result = await handlers.get("before_agent_start")?.[0]({ systemPrompt: "base" }, ctx);
|
|
215
|
+
|
|
216
|
+
expect(Buffer.byteLength(result.systemPrompt, "utf8")).toBeLessThanOrEqual(Buffer.byteLength("base\n\n", "utf8") + 220 + 180);
|
|
217
|
+
expect(result.systemPrompt).toContain("Context Broker");
|
|
218
|
+
expect(result.systemPrompt).toContain("ctx://");
|
|
219
|
+
expect(result.systemPrompt).not.toContain("SECRET_TOKEN");
|
|
220
|
+
});
|
|
221
|
+
});
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import type { AutocompleteItem } from "@earendil-works/pi-tui";
|
|
2
|
+
import type { ExtensionAPI, ExtensionContext, ToolResultEvent } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { createInMemoryContextBroker } from "./index.js";
|
|
4
|
+
|
|
5
|
+
export interface ContextBrokerBetaOptions {
|
|
6
|
+
enabled?: boolean;
|
|
7
|
+
maxRecords?: number;
|
|
8
|
+
maxBytes?: number;
|
|
9
|
+
briefBytes?: number;
|
|
10
|
+
lookupBytes?: number;
|
|
11
|
+
searchBytes?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type UiLike = { notify(message: string, type?: "info" | "warning" | "error"): void; setStatus?(key: string, text: string | undefined): void };
|
|
15
|
+
type SessionContextLike = Pick<ExtensionContext, "cwd" | "sessionManager"> & { ui: UiLike };
|
|
16
|
+
|
|
17
|
+
const DEFAULT_BRIEF_BYTES = 1_800;
|
|
18
|
+
const DEFAULT_LOOKUP_BYTES = 12_000;
|
|
19
|
+
const DEFAULT_SEARCH_BYTES = 2_000;
|
|
20
|
+
const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
|
|
21
|
+
const ENABLED_VALUES = new Set(["1", "true", "yes", "on"]);
|
|
22
|
+
|
|
23
|
+
function isEnvEnabled(): boolean {
|
|
24
|
+
return ENABLED_VALUES.has(String(process.env.PI_CONTEXT_BROKER_ENABLED ?? "").trim().toLowerCase());
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function toText(value: unknown): string {
|
|
28
|
+
if (typeof value === "string") return value;
|
|
29
|
+
try {
|
|
30
|
+
return JSON.stringify(value, null, 2);
|
|
31
|
+
} catch {
|
|
32
|
+
return String(value ?? "");
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function truncateUtf8(text: string, maxBytes: number): string {
|
|
37
|
+
const limit = Math.max(0, Math.floor(maxBytes));
|
|
38
|
+
const totalBytes = Buffer.byteLength(text, "utf8");
|
|
39
|
+
if (totalBytes <= limit) return text;
|
|
40
|
+
if (limit === 0) return "";
|
|
41
|
+
|
|
42
|
+
let omittedBytes = totalBytes;
|
|
43
|
+
let result = "";
|
|
44
|
+
let marker = "…";
|
|
45
|
+
|
|
46
|
+
for (let pass = 0; pass < 4; pass += 1) {
|
|
47
|
+
const verboseMarker = `\n[truncated: omitted ${omittedBytes} bytes]`;
|
|
48
|
+
marker = Buffer.byteLength(verboseMarker, "utf8") < limit ? verboseMarker : "…";
|
|
49
|
+
const contentLimit = Math.max(0, limit - Buffer.byteLength(marker, "utf8"));
|
|
50
|
+
let used = 0;
|
|
51
|
+
let prefix = "";
|
|
52
|
+
|
|
53
|
+
for (const char of text) {
|
|
54
|
+
const bytes = Buffer.byteLength(char, "utf8");
|
|
55
|
+
if (used + bytes > contentLimit) break;
|
|
56
|
+
prefix += char;
|
|
57
|
+
used += bytes;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
result = prefix;
|
|
61
|
+
const nextOmittedBytes = totalBytes - used;
|
|
62
|
+
if (nextOmittedBytes === omittedBytes) break;
|
|
63
|
+
omittedBytes = nextOmittedBytes;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return `${result}${marker}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function compact(value: string, max = 120): string {
|
|
70
|
+
return truncateUtf8(value.replace(/\s+/g, " ").trim(), max);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function sessionIdFor(ctx: Partial<SessionContextLike>): string {
|
|
74
|
+
const file = ctx.sessionManager?.getSessionFile?.();
|
|
75
|
+
return file || ctx.cwd || process.cwd();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function messageTimestamp(entry: any): number | undefined {
|
|
79
|
+
const value = entry?.message?.timestamp ?? entry?.timestamp;
|
|
80
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
81
|
+
const parsed = Date.parse(String(value ?? ""));
|
|
82
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function toolPayload(event: { toolName: string; input?: unknown; content?: unknown; details?: unknown; isError?: boolean }): string {
|
|
86
|
+
return [
|
|
87
|
+
`tool=${event.toolName}`,
|
|
88
|
+
`isError=${Boolean(event.isError)}`,
|
|
89
|
+
"input:",
|
|
90
|
+
toText(event.input),
|
|
91
|
+
"content:",
|
|
92
|
+
toText(event.content),
|
|
93
|
+
"details:",
|
|
94
|
+
toText(event.details),
|
|
95
|
+
].join("\n");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function summarizeTool(event: { toolName: string; input?: any; isError?: boolean }, bytes: number): string {
|
|
99
|
+
const command = event.toolName === "bash" ? event.input?.command : undefined;
|
|
100
|
+
const path = event.input?.path;
|
|
101
|
+
const target = command ? ` command=${compact(String(command), 120)}` : path ? ` path=${path}` : "";
|
|
102
|
+
return `${event.isError ? "failed" : "completed"} ${event.toolName}${target}; payload=${bytes} bytes`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function registerContextBrokerBeta(pi: ExtensionAPI, options: ContextBrokerBetaOptions = {}): void {
|
|
106
|
+
const p = pi as any;
|
|
107
|
+
if (p.__piRogueContextBrokerBetaRegistered) return;
|
|
108
|
+
p.__piRogueContextBrokerBetaRegistered = true;
|
|
109
|
+
|
|
110
|
+
const briefBytes = options.briefBytes ?? DEFAULT_BRIEF_BYTES;
|
|
111
|
+
const lookupBytes = options.lookupBytes ?? DEFAULT_LOOKUP_BYTES;
|
|
112
|
+
const searchBytes = options.searchBytes ?? DEFAULT_SEARCH_BYTES;
|
|
113
|
+
const broker = createInMemoryContextBroker({
|
|
114
|
+
maxRecords: options.maxRecords ?? 64,
|
|
115
|
+
maxBytes: options.maxBytes ?? 8 * 1024 * 1024,
|
|
116
|
+
briefBytes,
|
|
117
|
+
});
|
|
118
|
+
const seenSourceIds = new Set<string>();
|
|
119
|
+
let activeSessionId = process.cwd();
|
|
120
|
+
|
|
121
|
+
function currentBrief(): string {
|
|
122
|
+
return broker.renderBrief({ sessionId: activeSessionId, budgetBytes: briefBytes });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function publishToolArtifact(event: {
|
|
126
|
+
toolName: string;
|
|
127
|
+
input?: any;
|
|
128
|
+
content?: unknown;
|
|
129
|
+
details?: unknown;
|
|
130
|
+
isError?: boolean;
|
|
131
|
+
sourceId?: string;
|
|
132
|
+
createdAt?: number;
|
|
133
|
+
}): boolean {
|
|
134
|
+
if (event.sourceId) {
|
|
135
|
+
if (seenSourceIds.has(event.sourceId)) return false;
|
|
136
|
+
seenSourceIds.add(event.sourceId);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const payload = toolPayload(event);
|
|
140
|
+
const bytes = Buffer.byteLength(payload, "utf8");
|
|
141
|
+
broker.publish({
|
|
142
|
+
sessionId: activeSessionId,
|
|
143
|
+
kind: "tool_output",
|
|
144
|
+
payload,
|
|
145
|
+
summary: summarizeTool(event, bytes),
|
|
146
|
+
tags: [event.toolName, event.isError ? "error" : "ok", event.sourceId ? "session-backfill" : "live"],
|
|
147
|
+
command: event.toolName === "bash" && typeof event.input?.command === "string" ? event.input.command : undefined,
|
|
148
|
+
paths: typeof event.input?.path === "string" ? [event.input.path] : [],
|
|
149
|
+
ttlMs: DEFAULT_TTL_MS,
|
|
150
|
+
parentIds: event.sourceId ? [event.sourceId] : [],
|
|
151
|
+
createdAt: event.createdAt,
|
|
152
|
+
});
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function backfillSessionArtifacts(ctx: Partial<SessionContextLike>): { added: number; scanned: number; errors: number } {
|
|
157
|
+
activeSessionId = sessionIdFor(ctx);
|
|
158
|
+
let entries: any[] = [];
|
|
159
|
+
try {
|
|
160
|
+
entries = ctx.sessionManager?.getBranch?.() ?? [];
|
|
161
|
+
} catch {
|
|
162
|
+
return { added: 0, scanned: 0, errors: 1 };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const toolInputs = new Map<string, { toolName?: string; input?: unknown }>();
|
|
166
|
+
for (const entry of entries) {
|
|
167
|
+
const message = entry?.type === "message" ? entry.message : undefined;
|
|
168
|
+
if (message?.role !== "assistant" || !Array.isArray(message.content)) continue;
|
|
169
|
+
for (const block of message.content) {
|
|
170
|
+
if (block?.type === "toolCall" && typeof block.id === "string") {
|
|
171
|
+
toolInputs.set(block.id, { toolName: typeof block.name === "string" ? block.name : undefined, input: block.arguments });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
let added = 0;
|
|
177
|
+
let scanned = 0;
|
|
178
|
+
let errors = 0;
|
|
179
|
+
|
|
180
|
+
for (const entry of entries) {
|
|
181
|
+
try {
|
|
182
|
+
const entryId = typeof entry?.id === "string" ? entry.id : undefined;
|
|
183
|
+
const createdAt = messageTimestamp(entry);
|
|
184
|
+
|
|
185
|
+
if (entry?.type === "message" && entry.message?.role === "toolResult") {
|
|
186
|
+
scanned += 1;
|
|
187
|
+
const sourceId = typeof entry.message.toolCallId === "string" ? entry.message.toolCallId : entryId;
|
|
188
|
+
const toolInput = sourceId ? toolInputs.get(sourceId) : undefined;
|
|
189
|
+
if (publishToolArtifact({
|
|
190
|
+
toolName: String(entry.message.toolName ?? toolInput?.toolName ?? "tool"),
|
|
191
|
+
input: entry.message.input ?? toolInput?.input,
|
|
192
|
+
content: entry.message.content,
|
|
193
|
+
details: entry.message.details,
|
|
194
|
+
isError: Boolean(entry.message.isError),
|
|
195
|
+
sourceId,
|
|
196
|
+
createdAt,
|
|
197
|
+
})) added += 1;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (entry?.type === "message" && entry.message?.role === "bashExecution") {
|
|
201
|
+
if (entry.message.excludeFromContext === true) continue;
|
|
202
|
+
scanned += 1;
|
|
203
|
+
const sourceId = entryId;
|
|
204
|
+
if (publishToolArtifact({
|
|
205
|
+
toolName: "bash",
|
|
206
|
+
input: { command: entry.message.command },
|
|
207
|
+
content: entry.message.output,
|
|
208
|
+
details: {
|
|
209
|
+
exitCode: entry.message.exitCode,
|
|
210
|
+
cancelled: entry.message.cancelled,
|
|
211
|
+
truncated: entry.message.truncated,
|
|
212
|
+
fullOutputPath: entry.message.fullOutputPath,
|
|
213
|
+
},
|
|
214
|
+
isError: typeof entry.message.exitCode === "number" ? entry.message.exitCode !== 0 : Boolean(entry.message.cancelled),
|
|
215
|
+
sourceId,
|
|
216
|
+
createdAt,
|
|
217
|
+
})) added += 1;
|
|
218
|
+
}
|
|
219
|
+
} catch {
|
|
220
|
+
errors += 1;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return { added, scanned, errors };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const contextActions: AutocompleteItem[] = [
|
|
228
|
+
{ value: "status", label: "status", description: "Show broker record, byte, and pinned counts" },
|
|
229
|
+
{ value: "brief", label: "brief", description: "Show the bounded broker brief" },
|
|
230
|
+
{ value: "lookup ", label: "lookup", description: "Lookup by ctx:// handle or current-session text" },
|
|
231
|
+
{ value: "pin ", label: "pin", description: "Pin an artifact by ctx:// handle or id" },
|
|
232
|
+
{ value: "prune", label: "prune", description: "Run TTL/cap pruning now" },
|
|
233
|
+
];
|
|
234
|
+
|
|
235
|
+
function artifactCompletions(action: "lookup" | "pin", query: string): AutocompleteItem[] {
|
|
236
|
+
const needle = query.trim().toLowerCase();
|
|
237
|
+
return broker.lookup({ sessionId: activeSessionId, limit: 10 })
|
|
238
|
+
.filter((artifact) => {
|
|
239
|
+
if (!needle) return true;
|
|
240
|
+
return artifact.handle.toLowerCase().includes(needle)
|
|
241
|
+
|| artifact.summary.toLowerCase().includes(needle)
|
|
242
|
+
|| artifact.kind.toLowerCase().includes(needle)
|
|
243
|
+
|| artifact.tags.join(" ").toLowerCase().includes(needle)
|
|
244
|
+
|| artifact.paths.join(" ").toLowerCase().includes(needle);
|
|
245
|
+
})
|
|
246
|
+
.map((artifact) => ({
|
|
247
|
+
value: `${action} ${artifact.handle}`,
|
|
248
|
+
label: `${action} ${artifact.kind}`,
|
|
249
|
+
description: `${artifact.pinned ? "pinned; " : ""}${artifact.summary}`,
|
|
250
|
+
}));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function contextArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null {
|
|
254
|
+
const prefix = argumentPrefix.trimStart();
|
|
255
|
+
const [action = "", ...restParts] = prefix.split(/\s+/);
|
|
256
|
+
const hasActionSeparator = /\s/.test(prefix);
|
|
257
|
+
|
|
258
|
+
if (!action || !hasActionSeparator) {
|
|
259
|
+
const items = contextActions.filter((item) => item.value.trim().startsWith(action));
|
|
260
|
+
return items.length ? items : contextActions;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (action === "lookup" || action === "pin") {
|
|
264
|
+
const items = artifactCompletions(action, restParts.join(" "));
|
|
265
|
+
return items.length ? items : null;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
272
|
+
const { added, scanned, errors } = backfillSessionArtifacts(ctx);
|
|
273
|
+
ctx.ui.setStatus?.("context-broker", "ctx:on beta");
|
|
274
|
+
ctx.ui.notify(
|
|
275
|
+
`Context broker beta enabled. Backfilled ${added}/${scanned} current-branch tool artifacts${errors ? ` (${errors} malformed skipped)` : ""}. Use /context status or /context brief.`,
|
|
276
|
+
errors ? "warning" : "info",
|
|
277
|
+
);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
pi.on("tool_result", async (event: ToolResultEvent, ctx) => {
|
|
281
|
+
activeSessionId = sessionIdFor(ctx);
|
|
282
|
+
publishToolArtifact({ ...event, sourceId: event.toolCallId });
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
pi.on("before_agent_start", async (event) => {
|
|
286
|
+
const brief = currentBrief();
|
|
287
|
+
if (!brief.includes("ctx://")) return;
|
|
288
|
+
return {
|
|
289
|
+
systemPrompt: [
|
|
290
|
+
event.systemPrompt,
|
|
291
|
+
brief,
|
|
292
|
+
"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.",
|
|
293
|
+
].join("\n\n"),
|
|
294
|
+
};
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
pi.registerCommand("context", {
|
|
298
|
+
description: "Inspect the beta context broker: status | brief | lookup <handle-or-text> | pin <handle> | prune",
|
|
299
|
+
getArgumentCompletions: contextArgumentCompletions,
|
|
300
|
+
handler: async (args, ctx) => {
|
|
301
|
+
activeSessionId = sessionIdFor(ctx);
|
|
302
|
+
const [action = "status", ...rest] = String(args || "").trim().split(/\s+/).filter(Boolean);
|
|
303
|
+
const query = rest.join(" ");
|
|
304
|
+
|
|
305
|
+
if (action === "status") {
|
|
306
|
+
const status = broker.status();
|
|
307
|
+
ctx.ui.notify(
|
|
308
|
+
`Context broker beta: enabled, session=${activeSessionId}, records=${status.records}, bytes=${status.bytes}/${status.maxBytes}, pinned=${status.pinnedRecords}/${status.pinnedBytes} bytes`,
|
|
309
|
+
"info",
|
|
310
|
+
);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (action === "brief") {
|
|
315
|
+
ctx.ui.notify(currentBrief(), "info");
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (action === "lookup") {
|
|
320
|
+
if (!query) {
|
|
321
|
+
ctx.ui.notify("Usage: /context lookup <ctx://handle-or-text>", "warning");
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
const exact = query.startsWith("ctx://");
|
|
325
|
+
const results = broker.lookup(exact ? { handle: query } : { sessionId: activeSessionId, text: query, limit: 5 });
|
|
326
|
+
ctx.ui.notify(results.length ? results.map((item) => [
|
|
327
|
+
item.handle,
|
|
328
|
+
`kind=${item.kind} bytes=${item.bytes}`,
|
|
329
|
+
`summary=${item.summary}`,
|
|
330
|
+
"payload:",
|
|
331
|
+
truncateUtf8(item.payload, exact ? lookupBytes : searchBytes),
|
|
332
|
+
].join("\n")).join("\n\n---\n\n") : "No context artifacts matched.", "info");
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (action === "pin") {
|
|
337
|
+
if (!query) {
|
|
338
|
+
ctx.ui.notify("Usage: /context pin <ctx://handle-or-id>", "warning");
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
const pinned = broker.pin(query, true);
|
|
342
|
+
ctx.ui.notify(pinned ? `Pinned ${pinned.handle}` : "No artifact matched that handle/id.", pinned ? "info" : "warning");
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (action === "prune") {
|
|
347
|
+
const status = broker.prune();
|
|
348
|
+
ctx.ui.notify(`Pruned. ${status.records} records, ${status.bytes} bytes remain.`, "info");
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
ctx.ui.notify("Usage: /context status | brief | lookup <handle-or-text> | pin <handle> | prune", "warning");
|
|
353
|
+
},
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export function shouldEnableContextBrokerBeta(options: ContextBrokerBetaOptions = {}): boolean {
|
|
358
|
+
return Boolean(options.enabled ?? isEnvEnabled());
|
|
359
|
+
}
|