pi-smart-compact 7.5.0
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/CHANGELOG.md +72 -0
- package/LICENSE +21 -0
- package/README.md +200 -0
- package/package.json +42 -0
- package/src/constants.ts +140 -0
- package/src/core.ts +360 -0
- package/src/index.ts +175 -0
- package/src/phases/explore.ts +371 -0
- package/src/phases/synthesize.ts +184 -0
- package/src/phases/verify.ts +191 -0
- package/src/types.ts +176 -0
- package/src/ui/overlays.ts +329 -0
- package/src/utils/cache.ts +145 -0
- package/src/utils/damage.ts +153 -0
- package/src/utils/extraction.ts +259 -0
- package/src/utils/fingerprint.ts +190 -0
- package/src/utils/helpers.ts +161 -0
- package/src/utils/message-blocks.ts +21 -0
- package/src/utils/pruning.ts +147 -0
- package/src/utils/tokens.ts +63 -0
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 2: Targeted LLM Exploration.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { Model, Api } from "@earendil-works/pi-ai";
|
|
6
|
+
import type { LlmMessage, StructuredExtraction, ExplorationReport, TopicBoundary, CacheAwareOptions } from "../types.ts";
|
|
7
|
+
import { COMPACT_SYSTEM_PREFIX, EXPLORER_SYSTEM_PROMPT } from "../constants.ts";
|
|
8
|
+
import { extractText, extractMainGoal, extractStructured } from "../utils/extraction.ts";
|
|
9
|
+
import { trackedComplete, cacheOpts } from "../utils/cache.ts";
|
|
10
|
+
|
|
11
|
+
// ── Tool Support Cache with TTL ──
|
|
12
|
+
const _toolSupportCache = new Map<string, { result: boolean; timestamp: number }>();
|
|
13
|
+
const TOOL_CACHE_TTL = 30 * 60 * 1000; // 30 minutes
|
|
14
|
+
|
|
15
|
+
export function clearToolSupportCache(): void {
|
|
16
|
+
const now = Date.now();
|
|
17
|
+
for (const [k, v] of _toolSupportCache) {
|
|
18
|
+
if (now - v.timestamp > TOOL_CACHE_TTL) _toolSupportCache.delete(k);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Determine whether exploration is worthwhile based on session complexity.
|
|
24
|
+
* Simple sessions (few topics, few errors, few decisions) skip exploration
|
|
25
|
+
* and rely on heuristic boundaries instead — saving 3-8 LLM calls.
|
|
26
|
+
*/
|
|
27
|
+
export function shouldExplore(extraction: StructuredExtraction): boolean {
|
|
28
|
+
const unresolvedErrors = extraction.errors.filter(e => !e.resolved).length;
|
|
29
|
+
const topicCount = extraction.topics.length;
|
|
30
|
+
const decisionCount = extraction.decisions.length;
|
|
31
|
+
const crossFileWork = new Set(extraction.modifiedFiles.map(f => {
|
|
32
|
+
const parts = f.path.split("/");
|
|
33
|
+
return parts.length > 1 ? parts.slice(0, -1).join("/") : "root";
|
|
34
|
+
})).size;
|
|
35
|
+
|
|
36
|
+
// Skip exploration if session is simple
|
|
37
|
+
if (topicCount <= 3 && unresolvedErrors <= 1 && decisionCount <= 2 && crossFileWork <= 2) {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const EXPLORATION_TOOLS = [
|
|
44
|
+
{
|
|
45
|
+
name: "get_message_range", description: "Get compact summaries of messages from start to end index (0-based).",
|
|
46
|
+
parameters: { type: "object", properties: { start: { type: "number" }, end: { type: "number" } }, required: ["start", "end"] },
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
name: "search_conversation", description: "Search for text in conversation messages.",
|
|
50
|
+
parameters: { type: "object", properties: { query: { type: "string" } }, required: ["query"] },
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: "get_recent_user_messages", description: "Get the last N user messages.",
|
|
54
|
+
parameters: { type: "object", properties: { count: { type: "number" } } },
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
name: "get_context_around", description: "Get context around a specific message index.",
|
|
58
|
+
parameters: { type: "object", properties: { index: { type: "number" }, radius: { type: "number" } }, required: ["index"] },
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
name: "get_file_changes", description: "Get tool calls that modified a specific file.",
|
|
62
|
+
parameters: { type: "object", properties: { path: { type: "string" } }, required: ["path"] },
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
name: "get_error_chain", description: "Get all messages related to a specific error.",
|
|
66
|
+
parameters: { type: "object", properties: { index: { type: "number" }, context_radius: { type: "number" } }, required: ["index"] },
|
|
67
|
+
},
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
export function executeExplorationTool(call: { name: string; arguments: Record<string, unknown> }, llmMessages: LlmMessage[]): string {
|
|
71
|
+
const args = call.arguments ?? {};
|
|
72
|
+
switch (call.name) {
|
|
73
|
+
case "get_message_range": {
|
|
74
|
+
const s = (args.start as number) ?? 0, e = Math.min((args.end as number) ?? llmMessages.length, llmMessages.length);
|
|
75
|
+
return JSON.stringify(llmMessages.slice(s, e).map((m, i) => ({
|
|
76
|
+
idx: s + i, role: m?.role,
|
|
77
|
+
preview: extractText(m?.content).slice(0, 150),
|
|
78
|
+
toolCalls: ((m?.content ?? []) as unknown[]).filter((b: any) => b?.type === "toolCall").map((b: any) => b.name),
|
|
79
|
+
isError: m?.isError,
|
|
80
|
+
})));
|
|
81
|
+
}
|
|
82
|
+
case "search_conversation": {
|
|
83
|
+
const q = ((args.query as string) ?? "").toLowerCase();
|
|
84
|
+
return JSON.stringify(llmMessages.filter((m) => JSON.stringify(m).toLowerCase().includes(q)).slice(0, 10).map((m) => ({
|
|
85
|
+
idx: llmMessages.indexOf(m), role: m?.role, preview: extractText(m?.content).slice(0, 150),
|
|
86
|
+
})));
|
|
87
|
+
}
|
|
88
|
+
case "get_recent_user_messages": {
|
|
89
|
+
const count = (args.count as number) ?? 10;
|
|
90
|
+
return JSON.stringify(llmMessages.filter((m) => m?.role === "user").slice(-count).map((m) => extractText(m.content)));
|
|
91
|
+
}
|
|
92
|
+
case "get_context_around": {
|
|
93
|
+
const idx = (args.index as number) ?? 0, radius = (args.radius as number) ?? 5;
|
|
94
|
+
const s = Math.max(0, idx - radius), e = Math.min(llmMessages.length, idx + radius + 1);
|
|
95
|
+
return JSON.stringify(llmMessages.slice(s, e).map((m, i) => ({
|
|
96
|
+
idx: s + i, role: m?.role,
|
|
97
|
+
text: extractText(m?.content).slice(0, 300),
|
|
98
|
+
toolCalls: ((m?.content ?? []) as unknown[]).filter((b: any) => b?.type === "toolCall").map((b: any) => b.name),
|
|
99
|
+
isError: m?.isError,
|
|
100
|
+
})));
|
|
101
|
+
}
|
|
102
|
+
case "get_file_changes": {
|
|
103
|
+
const target = ((args.path as string) ?? "").toLowerCase();
|
|
104
|
+
const results: unknown[] = [];
|
|
105
|
+
for (let i = 0; i < llmMessages.length; i++) {
|
|
106
|
+
const blocks = (llmMessages[i]?.content ?? []) as unknown[];
|
|
107
|
+
for (const b of blocks) {
|
|
108
|
+
const block = b as { type?: string; name?: string; arguments?: Record<string, unknown> };
|
|
109
|
+
if (block?.type === "toolCall" && block.name === "edit" && JSON.stringify(block).toLowerCase().includes(target)) {
|
|
110
|
+
results.push({ idx: i, role: "assistant", toolCall: "edit", args: block.arguments, preview: extractText(llmMessages[i]?.content).slice(0, 400) });
|
|
111
|
+
}
|
|
112
|
+
if (block?.type === "toolCall" && block.name === "write" && JSON.stringify(block).toLowerCase().includes(target)) {
|
|
113
|
+
results.push({ idx: i, role: "assistant", toolCall: "write", preview: extractText(llmMessages[i]?.content).slice(0, 400) });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return JSON.stringify(results.slice(0, 15) || [{ info: "No edits found for: " + args.path }]);
|
|
118
|
+
}
|
|
119
|
+
case "get_error_chain": {
|
|
120
|
+
const errIdx = (args.index as number) ?? 0;
|
|
121
|
+
const ctxRadius = (args.context_radius as number) ?? 8;
|
|
122
|
+
const s = Math.max(0, errIdx - ctxRadius), e = Math.min(llmMessages.length, errIdx + ctxRadius + 1);
|
|
123
|
+
return JSON.stringify(llmMessages.slice(s, e).map((m, i) => ({
|
|
124
|
+
idx: s + i, role: m?.role,
|
|
125
|
+
text: extractText(m?.content).slice(0, 500),
|
|
126
|
+
isError: m?.isError,
|
|
127
|
+
toolCalls: ((m?.content ?? []) as unknown[]).filter((b: any) => b?.type === "toolCall").map((b: any) => b.name),
|
|
128
|
+
})));
|
|
129
|
+
}
|
|
130
|
+
default: return "Unknown tool: " + call.name;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function parseExplorationReport(text: string, llmMessages: LlmMessage[]): ExplorationReport {
|
|
135
|
+
let json = text.trim();
|
|
136
|
+
const md = text.match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
137
|
+
if (md) json = md[1].trim();
|
|
138
|
+
|
|
139
|
+
let s = json.indexOf("{"), e = json.lastIndexOf("}");
|
|
140
|
+
if (s === -1 || e === -1) return fallbackExplorationReport(llmMessages);
|
|
141
|
+
let rawJson = json.slice(s, e + 1);
|
|
142
|
+
|
|
143
|
+
try { return buildExplorationReportFromParsed(JSON.parse(rawJson), llmMessages); } catch {}
|
|
144
|
+
|
|
145
|
+
const cleaned = rawJson.replace(/,\s*([}\]])/g, "$1").replace(/'/g, "\"").replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "");
|
|
146
|
+
try { return buildExplorationReportFromParsed(JSON.parse(cleaned), llmMessages); } catch {}
|
|
147
|
+
|
|
148
|
+
const boundaryMatch = rawJson.match(/"boundaries"\s*:\s*\[([\s\S]*?)\]/);
|
|
149
|
+
if (boundaryMatch) {
|
|
150
|
+
try {
|
|
151
|
+
const boundaries = JSON.parse("[" + boundaryMatch[1] + "]");
|
|
152
|
+
return { ...fallbackExplorationReport(llmMessages), boundaries: boundaries.filter((b: any) => typeof b?.afterIndex === "number").map((b: any) => ({
|
|
153
|
+
afterIndex: Math.min(b.afterIndex, llmMessages.length - 2),
|
|
154
|
+
topic: String(b.topic ?? "").slice(0, 100),
|
|
155
|
+
priority: ["critical", "high", "normal", "low"].includes(b.priority) ? b.priority : "normal",
|
|
156
|
+
confidence: Math.min(1, Math.max(0, b.confidence ?? 0.5)),
|
|
157
|
+
})) };
|
|
158
|
+
} catch {}
|
|
159
|
+
}
|
|
160
|
+
return fallbackExplorationReport(llmMessages);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function buildExplorationReportFromParsed(parsed: any, llmMessages: LlmMessage[]): ExplorationReport {
|
|
164
|
+
return {
|
|
165
|
+
boundaries: (parsed.boundaries ?? []).filter((b: any) => typeof b?.afterIndex === "number").map((b: any) => ({
|
|
166
|
+
afterIndex: Math.min(b.afterIndex, llmMessages.length - 2),
|
|
167
|
+
topic: String(b.topic ?? "").slice(0, 100),
|
|
168
|
+
priority: ["critical", "high", "normal", "low"].includes(b.priority) ? b.priority : "normal",
|
|
169
|
+
confidence: Math.min(1, Math.max(0, b.confidence ?? 0.5)),
|
|
170
|
+
})),
|
|
171
|
+
mainGoal: parsed.mainGoal ?? "",
|
|
172
|
+
sessionType: ["implementation", "review", "debugging", "discussion"].includes(parsed.sessionType) ? parsed.sessionType : "implementation",
|
|
173
|
+
enrichedConstraints: Array.isArray(parsed.enrichedConstraints) ? parsed.enrichedConstraints.map(String) : [],
|
|
174
|
+
crossReferences: Array.isArray(parsed.crossReferences) ? parsed.crossReferences.map(String) : [],
|
|
175
|
+
statusAssessment: {
|
|
176
|
+
done: Array.isArray(parsed.statusAssessment?.done) ? parsed.statusAssessment.done.map(String) : [],
|
|
177
|
+
inProgress: Array.isArray(parsed.statusAssessment?.inProgress) ? parsed.statusAssessment.inProgress.map(String) : [],
|
|
178
|
+
blocked: Array.isArray(parsed.statusAssessment?.blocked) ? parsed.statusAssessment.blocked.map(String) : [],
|
|
179
|
+
},
|
|
180
|
+
criticalContext: Array.isArray(parsed.criticalContext) ? parsed.criticalContext.map(String) : [],
|
|
181
|
+
keyDecisions: Array.isArray(parsed.keyDecisions) ? parsed.keyDecisions.map(String) : [],
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function fallbackExplorationReport(llmMessages: LlmMessage[]): ExplorationReport {
|
|
186
|
+
return {
|
|
187
|
+
boundaries: [], mainGoal: extractMainGoal(llmMessages) ?? "", sessionType: "implementation",
|
|
188
|
+
enrichedConstraints: [], crossReferences: [],
|
|
189
|
+
statusAssessment: { done: [], inProgress: [], blocked: [] },
|
|
190
|
+
criticalContext: [], keyDecisions: [],
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export async function exploreConversation(
|
|
195
|
+
llmMessages: LlmMessage[], extraction: StructuredExtraction,
|
|
196
|
+
model: Model<Api>, auth: { apiKey: string; headers?: Record<string, string> },
|
|
197
|
+
prevSummary: string | undefined, userNote: string | undefined,
|
|
198
|
+
signal?: AbortSignal, maxRounds = 8,
|
|
199
|
+
notify?: (msg: string, type?: "info" | "success" | "warning" | "error") => void,
|
|
200
|
+
): Promise<{ report: ExplorationReport; rounds: number; toolSupported: boolean }> {
|
|
201
|
+
|
|
202
|
+
const extractionContext = [
|
|
203
|
+
"## Deterministic Extraction (verified facts)",
|
|
204
|
+
"Message count: " + extraction.messageCount,
|
|
205
|
+
"Main goal: " + (extraction.mainGoal ?? "unknown"),
|
|
206
|
+
"Files modified (" + extraction.modifiedFiles.length + "): " + (extraction.modifiedFiles.map(f => f.path).join(", ") || "none"),
|
|
207
|
+
"Files read (" + extraction.readFiles.length + "): " + (extraction.readFiles.join(", ") || "none"),
|
|
208
|
+
"Errors (" + extraction.errors.length + "): " + (extraction.errors.map(e => "[" + e.tool + "] " + e.message.slice(0, 80) + (e.resolved ? " (resolved)" : e.retryAttempted ? " (retry attempted)" : "")).join("; ") || "none"),
|
|
209
|
+
"Decisions (" + extraction.decisions.length + "): " + (extraction.decisions.map(d => d.type + ": " + d.summary.slice(0, 80)).join("; ") || "none"),
|
|
210
|
+
"Constraints (" + extraction.constraints.length + "): " + (extraction.constraints.map(cc => "[" + cc.category + "] " + cc.text.slice(0, 80)).join("; ") || "none"),
|
|
211
|
+
"Heuristic topics (" + extraction.topics.length + "): " + (extraction.topics.map(t => "[" + t.startIndex + "-" + t.endIndex + "] " + t.type).join("; ") || "none"),
|
|
212
|
+
extraction.lastUserMessages.length ? "Last user messages: " + extraction.lastUserMessages.map(m => m.slice(0, 100)).join(" | ") : "",
|
|
213
|
+
extraction.lastErrors.length ? "Last errors: " + extraction.lastErrors.map(e => e.slice(0, 100)).join(" | ") : "",
|
|
214
|
+
].filter(Boolean).join("\n");
|
|
215
|
+
|
|
216
|
+
const userContent = "Explore this conversation and produce the structured report.\n\n" +
|
|
217
|
+
extractionContext +
|
|
218
|
+
(prevSummary ? "\n\n## Previous Summary\n" + prevSummary : "") +
|
|
219
|
+
(userNote ? "\n\n## User Steering\n\"" + userNote + "\"" : "");
|
|
220
|
+
|
|
221
|
+
// Check tool support cache before probe
|
|
222
|
+
const cacheKey = model.provider + "/" + model.id;
|
|
223
|
+
const cachedSupport = _toolSupportCache.get(cacheKey);
|
|
224
|
+
const cacheValid = cachedSupport && Date.now() - cachedSupport.timestamp < TOOL_CACHE_TTL;
|
|
225
|
+
|
|
226
|
+
let supportsTools = false;
|
|
227
|
+
try {
|
|
228
|
+
if (cacheValid && !cachedSupport!.result) {
|
|
229
|
+
// Provider known to not support tools — skip probe
|
|
230
|
+
if (notify) notify("Tool support cached: unsupported (" + cacheKey + ")", "info");
|
|
231
|
+
const report = await directExploration(llmMessages, extraction, model, auth, prevSummary, userNote, signal);
|
|
232
|
+
if (!report.boundaries.length) {
|
|
233
|
+
const retried = await explorationRetry(model, auth, llmMessages, extraction, prevSummary, userNote, signal);
|
|
234
|
+
if (retried.boundaries.length) return { report: retried, rounds: 1, toolSupported: false };
|
|
235
|
+
}
|
|
236
|
+
return { report, rounds: 0, toolSupported: false };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const probeResp = await trackedComplete("explore", model, {
|
|
240
|
+
systemPrompt: COMPACT_SYSTEM_PREFIX,
|
|
241
|
+
messages: [{ role: "user", content: [{ type: "text", text: userContent }] }],
|
|
242
|
+
tools: EXPLORATION_TOOLS as any,
|
|
243
|
+
}, cacheOpts({ apiKey: auth.apiKey, headers: auth.headers, signal }));
|
|
244
|
+
|
|
245
|
+
const toolCalls = ((probeResp?.content ?? []) as unknown[]).filter((c: any) => c?.type === "toolCall");
|
|
246
|
+
|
|
247
|
+
if (toolCalls.length > 0) {
|
|
248
|
+
supportsTools = true;
|
|
249
|
+
_toolSupportCache.set(cacheKey, { result: true, timestamp: Date.now() });
|
|
250
|
+
const messages: any[] = [
|
|
251
|
+
{ role: "user", content: [{ type: "text", text: userContent }], timestamp: Date.now() },
|
|
252
|
+
{ role: "assistant", content: probeResp.content, timestamp: Date.now() },
|
|
253
|
+
];
|
|
254
|
+
for (const tc of toolCalls) {
|
|
255
|
+
const result = executeExplorationTool({ name: tc.name, arguments: tc.arguments }, llmMessages);
|
|
256
|
+
messages.push({ role: "toolResult", toolCallId: tc.id, toolName: tc.name, content: [{ type: "text", text: result }], isError: false, timestamp: Date.now() });
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
let rounds = 1;
|
|
260
|
+
while (rounds < maxRounds) {
|
|
261
|
+
rounds++;
|
|
262
|
+
let response: any;
|
|
263
|
+
try {
|
|
264
|
+
response = await trackedComplete("explore-loop", model, {
|
|
265
|
+
systemPrompt: COMPACT_SYSTEM_PREFIX + "\n\n" + EXPLORER_SYSTEM_PROMPT,
|
|
266
|
+
messages,
|
|
267
|
+
tools: EXPLORATION_TOOLS as any,
|
|
268
|
+
}, cacheOpts({ apiKey: auth.apiKey, headers: auth.headers, signal }));
|
|
269
|
+
} catch {
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const nextToolCalls = ((response?.content ?? []) as unknown[]).filter((c: any) => c?.type === "toolCall");
|
|
274
|
+
if (nextToolCalls.length === 0) {
|
|
275
|
+
const text = ((response?.content ?? []) as unknown[]).filter((c: any) => c?.type === "text").map((c: any) => c.text).join("\n").trim();
|
|
276
|
+
let report = parseExplorationReport(text, llmMessages);
|
|
277
|
+
if (!report.boundaries.length) {
|
|
278
|
+
report = await directExploration(llmMessages, extraction, model, auth, prevSummary, userNote, signal);
|
|
279
|
+
if (report.boundaries.length) rounds++;
|
|
280
|
+
}
|
|
281
|
+
return { report, rounds, toolSupported: true };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
messages.push({ role: "assistant", content: response.content, timestamp: Date.now() });
|
|
285
|
+
for (const tc of nextToolCalls) {
|
|
286
|
+
const result = executeExplorationTool({ name: tc.name, arguments: tc.arguments }, llmMessages);
|
|
287
|
+
messages.push({ role: "toolResult", toolCallId: tc.id, toolName: tc.name, content: [{ type: "text", text: result }], isError: false, timestamp: Date.now() });
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const lastAssistant = messages.filter((m: any) => m.role === "assistant").pop();
|
|
292
|
+
if (lastAssistant) {
|
|
293
|
+
const text = (lastAssistant.content ?? []).filter((c: any) => c?.type === "text").map((c: any) => c.text).join("\n").trim();
|
|
294
|
+
const report = parseExplorationReport(text, llmMessages);
|
|
295
|
+
if (report.boundaries.length) return { report, rounds, toolSupported: true };
|
|
296
|
+
}
|
|
297
|
+
} else {
|
|
298
|
+
const text = ((probeResp?.content ?? []) as unknown[]).filter((c: any) => c?.type === "text").map((c: any) => c.text).join("\n").trim();
|
|
299
|
+
let report = parseExplorationReport(text, llmMessages);
|
|
300
|
+
if (!report.boundaries.length) {
|
|
301
|
+
report = await directExploration(llmMessages, extraction, model, auth, prevSummary, userNote, signal);
|
|
302
|
+
}
|
|
303
|
+
return { report, rounds: 1, toolSupported: true };
|
|
304
|
+
}
|
|
305
|
+
// Provider responded without tool calls — still counts as tool-capable for this session
|
|
306
|
+
} catch {
|
|
307
|
+
// Probe failed — cache as unsupported
|
|
308
|
+
_toolSupportCache.set(cacheKey, { result: false, timestamp: Date.now() });
|
|
309
|
+
if (notify) notify("Tool calling not supported, using direct exploration", "warning");
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const report = await directExploration(llmMessages, extraction, model, auth, prevSummary, userNote, signal);
|
|
313
|
+
if (!report.boundaries.length) {
|
|
314
|
+
const retried = await explorationRetry(model, auth, llmMessages, extraction, prevSummary, userNote, signal);
|
|
315
|
+
if (retried.boundaries.length) return { report: retried, rounds: 1, toolSupported: false };
|
|
316
|
+
}
|
|
317
|
+
return { report, rounds: 0, toolSupported: supportsTools };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export async function explorationRetry(
|
|
321
|
+
model: Model<Api>, auth: { apiKey: string; headers?: Record<string, string> },
|
|
322
|
+
llmMessages: LlmMessage[], extraction: StructuredExtraction,
|
|
323
|
+
prevSummary: string | undefined, userNote: string | undefined,
|
|
324
|
+
signal?: AbortSignal,
|
|
325
|
+
): Promise<ExplorationReport> {
|
|
326
|
+
const last5 = llmMessages.slice(-5).map((m) => "[" + m?.role + "] " + extractText(m?.content).slice(0, 150)).join("\n");
|
|
327
|
+
const retryPrompt = "IMPORTANT: Output ONLY valid raw JSON. No markdown. No explanation. No code fences. Just the JSON object.\n\n" +
|
|
328
|
+
"Produce this exact structure:\n{\"mainGoal\":\"...\",\"sessionType\":\"implementation|review|debugging|discussion\",\"boundaries\":[{\"afterIndex\":N,\"topic\":\"...\",\"priority\":\"normal\",\"confidence\":0.5}],\"enrichedConstraints\":[],\"crossReferences\":[],\"statusAssessment\":{\"done\":[],\"inProgress\":[],\"blocked\":[]},\"criticalContext\":[],\"keyDecisions\":[]}\n\n" +
|
|
329
|
+
"Context:\nFiles: " + extraction.modifiedFiles.map(f => f.path).join(", ") + "\n" +
|
|
330
|
+
"Topics heuristic: " + extraction.topics.map(t => "[" + t.startIndex + "-" + t.endIndex + "]").join(", ") + "\n" +
|
|
331
|
+
"Last messages:\n" + last5 +
|
|
332
|
+
(userNote ? "\nUser steering: " + userNote : "");
|
|
333
|
+
|
|
334
|
+
try {
|
|
335
|
+
const resp = await trackedComplete("explore-retry", model, {
|
|
336
|
+
systemPrompt: COMPACT_SYSTEM_PREFIX,
|
|
337
|
+
messages: [{ role: "user", content: [{ type: "text", text: retryPrompt }] }],
|
|
338
|
+
}, cacheOpts({ apiKey: auth.apiKey, headers: auth.headers, maxTokens: 4096, signal }));
|
|
339
|
+
const text = (resp.content as any[]).filter((c: any) => c?.type === "text").map((c: any) => c.text).join("").trim();
|
|
340
|
+
return parseExplorationReport(text, llmMessages);
|
|
341
|
+
} catch { return fallbackExplorationReport(llmMessages); }
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export async function directExploration(
|
|
345
|
+
llmMessages: LlmMessage[], extraction: StructuredExtraction,
|
|
346
|
+
model: Model<Api>, auth: { apiKey: string; headers?: Record<string, string> },
|
|
347
|
+
prevSummary: string | undefined, userNote: string | undefined,
|
|
348
|
+
signal?: AbortSignal,
|
|
349
|
+
): Promise<ExplorationReport> {
|
|
350
|
+
const first3 = llmMessages.filter((m) => m?.role === "user").slice(0, 3).map((m) => extractText(m?.content).slice(0, 200)).join("\n---\n");
|
|
351
|
+
const last30 = llmMessages.slice(-30).map((m) => "[" + m?.role + "] " + extractText(m?.content).slice(0, 300)).join("\n");
|
|
352
|
+
const prompt = "Analyze this conversation and produce a JSON report.\n\nFirst user messages:\n" + first3 +
|
|
353
|
+
"\n\nDeterministic data:\n" +
|
|
354
|
+
"- Files modified: " + (extraction.modifiedFiles.map(f => f.path).join(", ") || "none") +
|
|
355
|
+
"\n- Errors: " + (extraction.errors.map(e => e.message.slice(0, 80)).join("; ") || "none") +
|
|
356
|
+
"\n- Decisions: " + (extraction.decisions.map(d => d.summary.slice(0, 80)).join("; ") || "none") +
|
|
357
|
+
"\n- Constraints: " + (extraction.constraints.map(c => c.text.slice(0, 80)).join("; ") || "none") +
|
|
358
|
+
"\n\nLast 30 messages:\n" + last30 +
|
|
359
|
+
(prevSummary ? "\n\nPrevious summary:\n" + prevSummary : "") +
|
|
360
|
+
(userNote ? "\n\nUser note: \"" + userNote + "\"" : "") +
|
|
361
|
+
"\n\nOutput ONLY JSON: {\"mainGoal\":\"...\",\"sessionType\":\"implementation|review|debugging|discussion\",\"boundaries\":[{\"afterIndex\":N,\"topic\":\"...\",\"priority\":\"normal\",\"confidence\":0.5}],\"enrichedConstraints\":[...],\"crossReferences\":[...],\"statusAssessment\":{\"done\":[...],\"inProgress\":[...],\"blocked\":[...]},\"criticalContext\":[...],\"keyDecisions\":[...]}";
|
|
362
|
+
|
|
363
|
+
try {
|
|
364
|
+
const resp = await trackedComplete("explore-direct", model, {
|
|
365
|
+
systemPrompt: COMPACT_SYSTEM_PREFIX,
|
|
366
|
+
messages: [{ role: "user", content: [{ type: "text", text: prompt }] }],
|
|
367
|
+
}, cacheOpts({ apiKey: auth.apiKey, headers: auth.headers, maxTokens: 4096, signal }));
|
|
368
|
+
const text = (resp.content as any[]).filter((c: any) => c?.type === "text").map((c: any) => c.text).join("\n").trim();
|
|
369
|
+
return parseExplorationReport(text, llmMessages);
|
|
370
|
+
} catch { return fallbackExplorationReport(llmMessages); }
|
|
371
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 3: Hierarchical Synthesis.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { Model, Api } from "@earendil-works/pi-ai";
|
|
6
|
+
import type {
|
|
7
|
+
LlmMessage, LlmChunk, ChunkSummary, StructuredExtraction,
|
|
8
|
+
ExplorationReport, ProfileConfig, CacheAwareOptions,
|
|
9
|
+
} from "../types.ts";
|
|
10
|
+
import { COMPACT_SYSTEM_PREFIX, SINGLE_PASS_PREFIX, SINGLE_PASS_SUFFIX, BATCH_PROMPT_PREFIX, BATCH_PROMPT_SUFFIX, ASSEMBLY_PROMPT_PREFIX, ASSEMBLY_PROMPT_SUFFIX, SESSION_TYPE_INSTRUCTIONS } from "../constants.ts";
|
|
11
|
+
import { estimateTokens } from "../utils/tokens.ts";
|
|
12
|
+
import { trackedComplete, cacheOpts } from "../utils/cache.ts";
|
|
13
|
+
import { extractText } from "../utils/extraction.ts";
|
|
14
|
+
import { buildExtractionContext, buildExplorationContext, createBatches, preProcessSummaries } from "../utils/helpers.ts";
|
|
15
|
+
|
|
16
|
+
export function chunkLlmMessages(msgs: LlmMessage[], boundaries: import("../types.ts").TopicBoundary[], pc: ProfileConfig): LlmChunk[] {
|
|
17
|
+
if (!msgs.length) return [];
|
|
18
|
+
if (!boundaries.length) {
|
|
19
|
+
return [{
|
|
20
|
+
startIndex: 0, endIndex: msgs.length - 1,
|
|
21
|
+
tokenEstimate: estimateTokens(JSON.stringify(msgs)),
|
|
22
|
+
topic: "Full conversation", priority: "normal", messages: msgs,
|
|
23
|
+
}];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const sorted = [...boundaries].sort((a, b) => a.afterIndex - b.afterIndex);
|
|
27
|
+
const chunks: LlmChunk[] = [];
|
|
28
|
+
let start = 0;
|
|
29
|
+
|
|
30
|
+
for (const bp of sorted) {
|
|
31
|
+
const end = bp.afterIndex + 1;
|
|
32
|
+
if (end > start && end <= msgs.length) {
|
|
33
|
+
const slice = msgs.slice(start, end);
|
|
34
|
+
chunks.push({
|
|
35
|
+
startIndex: start, endIndex: end - 1,
|
|
36
|
+
tokenEstimate: estimateTokens(JSON.stringify(slice)),
|
|
37
|
+
topic: bp.topic || "Segment " + (chunks.length + 1),
|
|
38
|
+
priority: bp.priority,
|
|
39
|
+
messages: slice,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
start = end;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (start < msgs.length) {
|
|
46
|
+
const slice = msgs.slice(start);
|
|
47
|
+
const lastTopic = sorted.length ? "After: " + sorted[sorted.length - 1].topic : "Full conversation";
|
|
48
|
+
chunks.push({
|
|
49
|
+
startIndex: start, endIndex: msgs.length - 1,
|
|
50
|
+
tokenEstimate: estimateTokens(JSON.stringify(slice)),
|
|
51
|
+
topic: lastTopic, priority: "normal", messages: slice,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const merged: LlmChunk[] = [];
|
|
56
|
+
for (const ch of chunks) {
|
|
57
|
+
if (merged.length && ch.tokenEstimate < pc.minChunkTokens) {
|
|
58
|
+
const prev = merged[merged.length - 1];
|
|
59
|
+
prev.endIndex = ch.endIndex;
|
|
60
|
+
prev.tokenEstimate += ch.tokenEstimate;
|
|
61
|
+
prev.messages = msgs.slice(prev.startIndex, prev.endIndex + 1);
|
|
62
|
+
prev.topic = prev.topic + " + " + ch.topic;
|
|
63
|
+
} else {
|
|
64
|
+
merged.push(ch);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return merged;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function singlePassCompact(
|
|
71
|
+
convText: string, extraction: StructuredExtraction, report: ExplorationReport | null,
|
|
72
|
+
prevContext: string,
|
|
73
|
+
model: Model<Api>, auth: { apiKey: string; headers?: Record<string, string> }, signal?: AbortSignal,
|
|
74
|
+
): Promise<{ summary: string; llmCalls: 1 }> {
|
|
75
|
+
const extractionCtx = buildExtractionContext(extraction);
|
|
76
|
+
const explorationCtx = report ? buildExplorationContext(report) : "";
|
|
77
|
+
// Session-aware prompt adaptation
|
|
78
|
+
const sessionType = report?.sessionType ?? "implementation";
|
|
79
|
+
const sessionInstruction = SESSION_TYPE_INSTRUCTIONS[sessionType] ?? SESSION_TYPE_INSTRUCTIONS.implementation;
|
|
80
|
+
const adaptedPrefix = SINGLE_PASS_PREFIX + "\nSession-specific instructions:\n" + sessionInstruction;
|
|
81
|
+
const dynamicSuffix = SINGLE_PASS_SUFFIX
|
|
82
|
+
.replace("{PREV_CONTEXT}", prevContext)
|
|
83
|
+
.replace("{EXTRACTION_CONTEXT}", extractionCtx)
|
|
84
|
+
.replace("{EXPLORATION_CONTEXT}", explorationCtx)
|
|
85
|
+
.replace("{CONVERSATION}", convText);
|
|
86
|
+
|
|
87
|
+
const resp = await trackedComplete("single-pass", model, {
|
|
88
|
+
systemPrompt: COMPACT_SYSTEM_PREFIX,
|
|
89
|
+
messages: [
|
|
90
|
+
{ role: "user" as const, content: [{ type: "text" as const, text: adaptedPrefix }] },
|
|
91
|
+
{ role: "user" as const, content: [{ type: "text" as const, text: dynamicSuffix }] },
|
|
92
|
+
],
|
|
93
|
+
}, cacheOpts({ apiKey: auth.apiKey, headers: auth.headers, maxTokens: 8192, signal }));
|
|
94
|
+
const summary = (resp.content as any[]).filter((c: any): c is { type: "text"; text: string } => c?.type === "text").map(c => c.text).join("\n").trim();
|
|
95
|
+
if (!summary.startsWith("##")) throw new Error("Single-pass malformed output");
|
|
96
|
+
return { summary, llmCalls: 1 };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function summarizeBatch(
|
|
100
|
+
batch: LlmChunk[], extraction: StructuredExtraction,
|
|
101
|
+
model: Model<Api>, auth: { apiKey: string; headers?: Record<string, string> }, signal?: AbortSignal,
|
|
102
|
+
): Promise<ChunkSummary[]> {
|
|
103
|
+
const range = { start: batch[0].startIndex, end: batch[batch.length - 1].endIndex };
|
|
104
|
+
const extractionCtx = buildExtractionContext(extraction, range);
|
|
105
|
+
// Decision propagation: inject decisions from before this batch's range
|
|
106
|
+
const activeDecisions = extraction.decisions
|
|
107
|
+
.filter(d => d.index < range.start)
|
|
108
|
+
.map(d => "- " + d.summary.slice(0, 120) + (d.userResponse ? " → " + d.userResponse.slice(0, 60) : ""));
|
|
109
|
+
const decisionCtx = activeDecisions.length
|
|
110
|
+
? "\n## Active Decisions from previous segments (honour these):\n" + activeDecisions.join("\n")
|
|
111
|
+
: "";
|
|
112
|
+
const text = batch.map(ch => "--- Topic: " + ch.topic + " (" + ch.priority + ") ---\n" + ch.messages.map((m) => {
|
|
113
|
+
const role = m?.role ?? "unknown";
|
|
114
|
+
const content = extractText(m?.content).slice(0, 500);
|
|
115
|
+
return "[" + role + "] " + content;
|
|
116
|
+
}).join("\n")).join("\n\n");
|
|
117
|
+
const dynamicSuffix = BATCH_PROMPT_SUFFIX.replace("{EXTRACTION_CONTEXT}", extractionCtx + decisionCtx).replace("{TEXT}", text);
|
|
118
|
+
|
|
119
|
+
const resp = await trackedComplete("batch", model, {
|
|
120
|
+
systemPrompt: COMPACT_SYSTEM_PREFIX,
|
|
121
|
+
messages: [
|
|
122
|
+
{ role: "user" as const, content: [{ type: "text" as const, text: BATCH_PROMPT_PREFIX }] },
|
|
123
|
+
{ role: "user" as const, content: [{ type: "text" as const, text: dynamicSuffix }] },
|
|
124
|
+
],
|
|
125
|
+
}, cacheOpts({ apiKey: auth.apiKey, headers: auth.headers, maxTokens: 4096, signal }));
|
|
126
|
+
const output = (resp.content as any[]).filter((c: any): c is { type: "text"; text: string } => c?.type === "text").map(c => c.text).join("\n");
|
|
127
|
+
const sections = output.split(/^### /m).filter(s => s.trim());
|
|
128
|
+
return batch.map((ch, i) => {
|
|
129
|
+
const sec = sections[i] ?? "";
|
|
130
|
+
const f = (n: string) => { const m = sec.match(new RegExp("\\*\\*" + n + "\\*\\*:\\s*(.+?)(?:\\n|$)", "i")); return m ? m[1].trim() : ""; };
|
|
131
|
+
const l = (n: string) => { const v = f(n); return !v || v === "None" ? [] : v.split(",").map(s => s.trim()).filter(Boolean); };
|
|
132
|
+
const prio = f("Priority").toLowerCase();
|
|
133
|
+
return {
|
|
134
|
+
topic: ch.topic,
|
|
135
|
+
startIndex: ch.startIndex, endIndex: ch.endIndex,
|
|
136
|
+
summary: f("Summary") || sec.split("\n").slice(1).join("\n").trim().slice(0, 500),
|
|
137
|
+
keyDecisions: l("Decisions"), filesModified: l("Modified"), filesRead: l("Read"),
|
|
138
|
+
priority: ["critical", "high", "normal", "low"].includes(prio) ? prio as ChunkSummary["priority"] : ch.priority,
|
|
139
|
+
};
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export async function assembleLLM(
|
|
144
|
+
summaries: ChunkSummary[], extraction: StructuredExtraction, report: ExplorationReport | null,
|
|
145
|
+
model: Model<Api>, auth: { apiKey: string; headers?: Record<string, string> }, budget: number,
|
|
146
|
+
prevContext: string, signal?: AbortSignal,
|
|
147
|
+
): Promise<string> {
|
|
148
|
+
const pp = preProcessSummaries(summaries, budget);
|
|
149
|
+
const detModified = extraction.modifiedFiles.map(f => f.path);
|
|
150
|
+
const detRead = extraction.readFiles;
|
|
151
|
+
const explorationCtx = report ? buildExplorationContext(report) : "";
|
|
152
|
+
const dynamicSuffix = ASSEMBLY_PROMPT_SUFFIX
|
|
153
|
+
.replace("{DECISIONS}", pp.decisions.join("; ") || "None")
|
|
154
|
+
.replace("{MODIFIED}", detModified.join(", ") || "None")
|
|
155
|
+
.replace("{READ}", detRead.join(", ") || "None")
|
|
156
|
+
.replace("{EXPLORATION_CONTEXT}", explorationCtx)
|
|
157
|
+
.replace("{PREV_CONTEXT}", prevContext)
|
|
158
|
+
.replace("{SUMMARIES}", pp.text);
|
|
159
|
+
|
|
160
|
+
const resp = await trackedComplete("assemble", model, {
|
|
161
|
+
systemPrompt: COMPACT_SYSTEM_PREFIX,
|
|
162
|
+
messages: [
|
|
163
|
+
{ role: "user" as const, content: [{ type: "text" as const, text: ASSEMBLY_PROMPT_PREFIX }] },
|
|
164
|
+
{ role: "user" as const, content: [{ type: "text" as const, text: dynamicSuffix }] },
|
|
165
|
+
],
|
|
166
|
+
}, cacheOpts({ apiKey: auth.apiKey, headers: auth.headers, maxTokens: Math.min(budget, 8192), signal }));
|
|
167
|
+
return (resp.content as any[]).filter((c: any): c is { type: "text"; text: string } => c?.type === "text").map(c => c.text).join("\n").trim();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function assembleFallback(summaries: ChunkSummary[], extraction: StructuredExtraction): string {
|
|
171
|
+
const detModified = extraction.modifiedFiles.map(f => f.path);
|
|
172
|
+
const detRead = extraction.readFiles;
|
|
173
|
+
return [
|
|
174
|
+
"## Goal", extraction.mainGoal ?? "See topics below.", "",
|
|
175
|
+
"## Constraints & Preferences", ...extraction.constraints.map(c => "- [" + c.category + "] " + c.text.slice(0, 200)), "",
|
|
176
|
+
"## Progress", "### Done", "- See topics below", "### In Progress", ...summaries.filter(s => s.priority === "high").map(s => "- [ ] " + s.summary.slice(0, 150)), "### Blocked", "- None", "",
|
|
177
|
+
"## Key Decisions", ...extraction.decisions.map(d => "- **" + d.summary.slice(0, 100) + "**" + (d.userResponse ? " → " + d.userResponse : "")), "",
|
|
178
|
+
"## Files Modified", ...detModified.map(f => "- " + f), "",
|
|
179
|
+
"## Files Read", ...detRead.map(f => "- " + f), "",
|
|
180
|
+
"## Next Steps", "1. See topics below", "",
|
|
181
|
+
"## Critical Context", ...extraction.errors.filter(e => !e.resolved).map(e => "- Unresolved error: " + e.message.slice(0, 100)), "",
|
|
182
|
+
"## Topics Covered", ...summaries.map(s => "- **" + s.topic + "** [" + s.priority + "]: " + s.summary.slice(0, 200)),
|
|
183
|
+
].join("\n");
|
|
184
|
+
}
|