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.
@@ -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
+ }