pi-smart-compact 7.5.2 → 7.9.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.
Files changed (57) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +513 -198
  3. package/dist/constants.d.ts +45 -0
  4. package/dist/constants.d.ts.map +1 -0
  5. package/dist/core.d.ts +27 -0
  6. package/dist/core.d.ts.map +1 -0
  7. package/dist/index.d.ts +8 -0
  8. package/dist/index.d.ts.map +1 -0
  9. package/dist/index.js +3131 -0
  10. package/dist/phases/explore.d.ts +35 -0
  11. package/dist/phases/explore.d.ts.map +1 -0
  12. package/dist/phases/synthesize.d.ts +23 -0
  13. package/dist/phases/synthesize.d.ts.map +1 -0
  14. package/dist/phases/verify.d.ts +16 -0
  15. package/dist/phases/verify.d.ts.map +1 -0
  16. package/dist/types.d.ts +265 -0
  17. package/dist/types.d.ts.map +1 -0
  18. package/dist/ui/overlays.d.ts +29 -0
  19. package/dist/ui/overlays.d.ts.map +1 -0
  20. package/dist/utils/cache.d.ts +27 -0
  21. package/dist/utils/cache.d.ts.map +1 -0
  22. package/dist/utils/damage.d.ts +28 -0
  23. package/dist/utils/damage.d.ts.map +1 -0
  24. package/dist/utils/extraction.d.ts +27 -0
  25. package/dist/utils/extraction.d.ts.map +1 -0
  26. package/dist/utils/fingerprint.d.ts +32 -0
  27. package/dist/utils/fingerprint.d.ts.map +1 -0
  28. package/dist/utils/helpers.d.ts +22 -0
  29. package/dist/utils/helpers.d.ts.map +1 -0
  30. package/dist/utils/logger.d.ts +8 -0
  31. package/dist/utils/logger.d.ts.map +1 -0
  32. package/dist/utils/pruning.d.ts +19 -0
  33. package/dist/utils/pruning.d.ts.map +1 -0
  34. package/dist/utils/state.d.ts +62 -0
  35. package/dist/utils/state.d.ts.map +1 -0
  36. package/dist/utils/tokens.d.ts +8 -0
  37. package/dist/utils/tokens.d.ts.map +1 -0
  38. package/dist/utils/type-guards.d.ts +26 -0
  39. package/dist/utils/type-guards.d.ts.map +1 -0
  40. package/docs/assets/pi-smart-compact.png +0 -0
  41. package/package.json +13 -3
  42. package/src/constants.ts +0 -140
  43. package/src/core.ts +0 -360
  44. package/src/index.ts +0 -175
  45. package/src/phases/explore.ts +0 -371
  46. package/src/phases/synthesize.ts +0 -184
  47. package/src/phases/verify.ts +0 -191
  48. package/src/types.ts +0 -176
  49. package/src/ui/overlays.ts +0 -329
  50. package/src/utils/cache.ts +0 -145
  51. package/src/utils/damage.ts +0 -153
  52. package/src/utils/extraction.ts +0 -259
  53. package/src/utils/fingerprint.ts +0 -190
  54. package/src/utils/helpers.ts +0 -161
  55. package/src/utils/message-blocks.ts +0 -21
  56. package/src/utils/pruning.ts +0 -147
  57. package/src/utils/tokens.ts +0 -63
package/src/index.ts DELETED
@@ -1,175 +0,0 @@
1
- /**
2
- * Smart Compact Extension for Pi Coding Agent v7.3.2 (EESV Architecture)
3
- *
4
- * Architecture: Extract -> Explore -> Synthesize -> Verify
5
- */
6
-
7
- import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
8
- import type { Model, Api } from "@earendil-works/pi-ai";
9
- import type { CompressionProfile, PendingCompaction } from "./types.ts";
10
- import { VERSION } from "./constants.ts";
11
- import { loadConfig } from "./utils/helpers.ts";
12
- import { runSmartCompact } from "./core.ts";
13
- import { showCompactUI } from "./ui/overlays.ts";
14
-
15
- function resolveModelArg(ctx: ExtensionCommandContext, modelArg: string): Model<Api> | undefined {
16
- const [p, ...r] = modelArg.split("/");
17
- return ctx.modelRegistry.find(p, r.join("/"));
18
- }
19
-
20
- function resolveModels(
21
- ctx: ExtensionCommandContext,
22
- primary: Model<Api> | undefined,
23
- config: ReturnType<typeof loadConfig>,
24
- ): { segModel: Model<Api> | undefined; sumModel: Model<Api> | undefined } {
25
- const fallback = primary ?? ctx.model;
26
- const available = ctx.modelRegistry.getAvailable();
27
- let sumModel = fallback;
28
-
29
- const configuredSumModels = [config.summaryModel].filter(Boolean) as string[];
30
- for (const modelId of configuredSumModels) {
31
- const [p, ...r] = modelId.split("/");
32
- const found = ctx.modelRegistry.find(p, r.join("/"));
33
- if (found) { sumModel = found; break; }
34
- }
35
- if (sumModel === fallback && !fallback) sumModel = available[0];
36
-
37
- let segModel = sumModel;
38
- if (config.segmentationModel) {
39
- const [p, ...r] = config.segmentationModel.split("/");
40
- segModel = ctx.modelRegistry.find(p, r.join("/")) ?? sumModel;
41
- }
42
-
43
- return { segModel, sumModel };
44
- }
45
-
46
- export default function smartCompactExtension(pi: ExtensionAPI) {
47
- const pendingRef: { value: PendingCompaction | null; createdAt: number } = { value: null, createdAt: 0 };
48
- const isRunning: { value: boolean } = { value: false };
49
- const PENDING_TTL_MS = 5 * 60 * 1000;
50
-
51
- pi.registerCommand("smart-compact", {
52
- description: "EESV smart compaction v" + VERSION + ". Usage: /smart-compact [model] [light|balanced|aggressive] [verbose|debug|dry-run] [note]",
53
- getArgumentCompletions: (prefix: string) => {
54
- const m = ["verbose", "debug", "dry-run", "light", "balanced", "aggressive"].filter(o => o.startsWith(prefix)).map(o => ({ value: o, label: o }));
55
- return m.length ? m : null;
56
- },
57
- handler: async (args, ctx) => {
58
- try {
59
- const tokens = args.trim().split(/\s+/).filter(Boolean);
60
- const flags = tokens.map(t => t.toLowerCase());
61
- const verbose = flags.includes("verbose") || flags.includes("debug");
62
- const dryRun = flags.includes("dry-run");
63
- const modelArg = tokens.find(t => t.includes("/"));
64
- const profileArg = tokens.find(t => ["light", "balanced", "aggressive"].includes(t)) as CompressionProfile | undefined;
65
- const profile = profileArg ?? loadConfig().profile;
66
-
67
- if (!tokens.length) {
68
- const usage = ctx.getContextUsage();
69
- const totalTokens = usage?.tokens ?? 0;
70
- const pct = ctx.model && totalTokens ? Math.round((totalTokens / ctx.model.contextWindow) * 100) : 0;
71
- if (!totalTokens || totalTokens < 5000) { ctx.ui.notify("Context OK or unknown", "info"); return; }
72
- const cur = ctx.model;
73
- const avail = ctx.modelRegistry.getAvailable();
74
- const opts = avail.map(m => ({ value: m.provider + "/" + m.id, label: m.provider + "/" + m.id + (m.contextWindow >= 200000 ? " (" + Math.round(m.contextWindow / 1000) + "K)" : ""), model: m }));
75
- const defIdx = cur ? opts.findIndex(o => o.value === cur.provider + "/" + cur.id) : 0;
76
- const selected = await showCompactUI(ctx, { contextTokens: totalTokens, contextPercent: pct, currentModel: cur ? cur.provider + "/" + cur.id : "?", defaultModelIndex: defIdx >= 0 ? defIdx : 0 });
77
- if (!selected) { ctx.ui.notify("Cancelled", "info"); return; }
78
- const { segModel, sumModel } = resolveModels(ctx, selected.model.model, loadConfig());
79
- if (!sumModel) { ctx.ui.notify("Could not resolve model", "error"); return; }
80
- await runSmartCompact(ctx, sumModel, segModel ?? sumModel, selected.profile, false, false, pendingRef, isRunning, false);
81
- return;
82
- }
83
-
84
- const { segModel, sumModel } = resolveModels(ctx, modelArg ? resolveModelArg(ctx, modelArg) : ctx.model, loadConfig());
85
- if (!sumModel) { ctx.ui.notify("Could not resolve model", "error"); return; }
86
- const note = extractUserNote(args);
87
- await runSmartCompact(ctx, sumModel, segModel ?? sumModel, profile, verbose, dryRun, pendingRef, isRunning, false, note);
88
- } catch (error) {
89
- const msg = error instanceof Error ? error.message + "\n" + error.stack : String(error);
90
- ctx.ui.notify("smart-compact error: " + msg, "error");
91
- }
92
- },
93
- });
94
-
95
- pi.on("session_before_compact", async (_event, ctx) => {
96
- if (pendingRef.value) {
97
- const age = Date.now() - pendingRef.createdAt;
98
- if (age > PENDING_TTL_MS) {
99
- pendingRef.value = null;
100
- pendingRef.createdAt = 0;
101
- } else {
102
- const c = pendingRef.value;
103
- pendingRef.value = null;
104
- pendingRef.createdAt = 0;
105
- return { compaction: { summary: c.summary, firstKeptEntryId: c.firstKeptEntryId, tokensBefore: c.tokensBefore, details: c.details } };
106
- }
107
- }
108
- const config = loadConfig();
109
- if (!config.autoTrigger) return;
110
- try {
111
- const usage = ctx.getContextUsage();
112
- const totalTokens = usage?.tokens ?? 0;
113
- if (!totalTokens || totalTokens < 5000) return;
114
- const cur = ctx.model;
115
- if (!cur) return;
116
- const { segModel, sumModel } = resolveModels(ctx, cur, config);
117
- if (!sumModel) return;
118
- if (!isRunning.value) {
119
- await runSmartCompact(ctx, sumModel, segModel ?? sumModel, config.profile, false, false, pendingRef, isRunning, true);
120
- if (pendingRef.value) {
121
- const c = pendingRef.value;
122
- pendingRef.value = null;
123
- pendingRef.createdAt = 0;
124
- return { compaction: { summary: c.summary, firstKeptEntryId: c.firstKeptEntryId, tokensBefore: c.tokensBefore, details: c.details } };
125
- }
126
- }
127
- } catch { /* silent */ }
128
- });
129
-
130
- pi.registerTool({
131
- name: "smart_compact", label: "Smart Compact",
132
- description: "EESV smart compaction v" + VERSION + " with deterministic extraction, exploration, and verification.",
133
- promptSnippet: "Smart compaction",
134
- promptGuidelines: ["Use for long conversations.", "Prefer over default compact."],
135
- parameters: {
136
- type: "object",
137
- properties: {
138
- profile: { type: "string", description: "light, balanced, or aggressive" },
139
- verbose: { type: "boolean" },
140
- dry_run: { type: "boolean" },
141
- },
142
- },
143
- async execute(_id, params, _sig, _onUp, ctx) {
144
- const profile = (params.profile === "light" || params.profile === "balanced" || params.profile === "aggressive") ? params.profile : undefined;
145
- const verbose = !!params.verbose;
146
- const dryRun = !!params.dry_run;
147
- const config = loadConfig();
148
- const resolvedProfile = profile ?? config.profile;
149
- const cur = ('model' in ctx) ? (ctx as any).model : undefined;
150
- const { segModel, sumModel } = resolveModels(ctx as ExtensionCommandContext, cur, config);
151
- if (!sumModel) {
152
- return { content: [{ type: "text", text: "Error: Could not resolve model." }] };
153
- }
154
- try {
155
- const toolStart = Date.now();
156
- await runSmartCompact(ctx as ExtensionCommandContext, sumModel, segModel ?? sumModel, resolvedProfile, verbose, dryRun, pendingRef, isRunning, true, undefined, true);
157
- const toolSecs = ((Date.now() - toolStart) / 1000).toFixed(1);
158
- if (pendingRef.value) {
159
- return { content: [{ type: "text", text: "Smart summary generated (" + resolvedProfile + "). Tokens: " + (pendingRef.value.tokensBefore ?? "?") + " -> " + (pendingRef.value.summary?.length ?? 0) + " chars (" + toolSecs + "s).\n\nNow run tree compact to apply — the session_before_compact hook will use this summary.\nTTL: " + Math.round(PENDING_TTL_MS / 60000) + " minutes." }] };
160
- }
161
- return { content: [{ type: "text", text: "Compaction finished (" + resolvedProfile + ") but no summary was generated." }] };
162
- } catch (error) {
163
- const msg = error instanceof Error ? error.message : String(error);
164
- return { content: [{ type: "text", text: "Compaction error: " + msg }] };
165
- }
166
- },
167
- });
168
- }
169
-
170
- function extractUserNote(args: string): string | undefined {
171
- const SKIP = new Set(["verbose", "debug", "dry-run", "light", "balanced", "aggressive"]);
172
- const tokens = args.trim().split(/\s+/).filter(Boolean);
173
- const nonFlags = tokens.filter(t => !t.includes("/") && !SKIP.has(t.toLowerCase()));
174
- return nonFlags.length > 0 ? nonFlags.join(" ") : undefined;
175
- }
@@ -1,371 +0,0 @@
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
- }