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,259 @@
1
+ /**
2
+ * Phase 1: Deterministic extraction — zero LLM calls.
3
+ */
4
+
5
+ import path from "node:path";
6
+ import type { LlmMessage, ProfileConfig, StructuredExtraction, ToolCallBlock } from "../types.ts";
7
+ import { NO_OP_RE, SHIFT_RE, CHOICE_RE } from "../constants.ts";
8
+ import { estimateTokens } from "./tokens.ts";
9
+
10
+ export function extractText(content: unknown): string {
11
+ if (typeof content === "string") return content;
12
+ if (Array.isArray(content)) return content.map((b: unknown) => {
13
+ if (typeof b === "string") return b;
14
+ if ((b as Record<string, unknown>)?.type === "text") return (b as { text?: string }).text ?? "";
15
+ return "";
16
+ }).join("");
17
+ return "";
18
+ }
19
+
20
+ export function buildToolCallIndex(msgs: LlmMessage[]): Map<string, { name: string; arguments: Record<string, unknown>; msgIndex: number }> {
21
+ const idx = new Map<string, { name: string; arguments: Record<string, unknown>; msgIndex: number }>();
22
+ for (let i = 0; i < msgs.length; i++) {
23
+ const m = msgs[i];
24
+ if (m.role !== "assistant") continue;
25
+ const blocks = (m.content ?? []) as unknown[];
26
+ for (const b of blocks) {
27
+ const block = b as ToolCallBlock;
28
+ if (block?.type === "toolCall" && block.id) {
29
+ idx.set(block.id, { name: block.name, arguments: block.arguments, msgIndex: i });
30
+ }
31
+ }
32
+ }
33
+ return idx;
34
+ }
35
+
36
+ export function trackFileOps(msgs: LlmMessage[]): { modified: StructuredExtraction["modifiedFiles"]; read: string[]; deleted: string[] } {
37
+ const tcIdx = buildToolCallIndex(msgs);
38
+ const modMap = new Map<string, { toolCalls: number; lastIdx: number }>();
39
+ const readSet = new Set<string>();
40
+ const delSet = new Set<string>();
41
+
42
+ for (let i = 0; i < msgs.length; i++) {
43
+ const m = msgs[i];
44
+ if (m.role !== "toolResult" || m.isError) continue;
45
+ const tc = tcIdx.get(m.toolCallId ?? "");
46
+ if (!tc) continue;
47
+ const args = tc.arguments;
48
+ const filePath = (args?.path ?? args?.file_path ?? args?.filePath) as string | undefined;
49
+ if (!filePath) continue;
50
+ const tool = tc.name.toLowerCase();
51
+
52
+ if (tool.includes("write") || tool.includes("edit")) {
53
+ const resultText = extractText(m.content);
54
+ if (!NO_OP_RE.test(resultText)) {
55
+ const existing = modMap.get(filePath);
56
+ modMap.set(filePath, { toolCalls: (existing?.toolCalls ?? 0) + 1, lastIdx: i });
57
+ }
58
+ } else if (tool.includes("delete") || tool.includes("remove")) {
59
+ delSet.add(filePath);
60
+ } else if (tool.includes("read")) {
61
+ readSet.add(filePath);
62
+ }
63
+ }
64
+
65
+ return {
66
+ modified: [...modMap.entries()].map(([p, d]) => ({ path: p, toolCalls: d.toolCalls, lastModifiedIndex: d.lastIdx })),
67
+ read: [...readSet], deleted: [...delSet],
68
+ };
69
+ }
70
+
71
+ export function catalogErrors(msgs: LlmMessage[]): StructuredExtraction["errors"] {
72
+ const tcIdx = buildToolCallIndex(msgs);
73
+ const errors: StructuredExtraction["errors"] = [];
74
+
75
+ for (let i = 0; i < msgs.length; i++) {
76
+ const m = msgs[i];
77
+ if (m.role !== "toolResult") continue;
78
+ const tc = tcIdx.get(m.toolCallId ?? "");
79
+
80
+ if (m.isError) {
81
+ errors.push({ index: i, tool: tc?.name ?? "unknown", message: extractText(m.content).slice(0, 500), retryAttempted: false, resolved: false });
82
+ continue;
83
+ }
84
+
85
+ if (tc?.name === "bash") {
86
+ const txt = extractText(m.content);
87
+ const isLikelyError = /(?:command not found|no such file|permission denied|syntax error|cannot find|module not found|compilation error|build failed|test failed)/i.test(txt);
88
+ if (isLikelyError && txt.length < 2000) {
89
+ errors.push({ index: i, tool: "bash", message: txt.slice(0, 300), retryAttempted: false, resolved: false });
90
+ }
91
+ }
92
+ }
93
+
94
+ for (const err of errors) {
95
+ for (let j = err.index + 1; j < Math.min(msgs.length, err.index + 6); j++) {
96
+ if (msgs[j]?.role === "assistant") {
97
+ const blocks = (msgs[j]?.content ?? []) as unknown[];
98
+ for (const b of blocks) {
99
+ const block = b as ToolCallBlock;
100
+ if (block?.type === "toolCall" && block.name === err.tool) {
101
+ err.retryAttempted = true;
102
+ for (let k = j + 1; k < Math.min(msgs.length, j + 10); k++) {
103
+ if (msgs[k]?.role === "toolResult" && msgs[k]?.toolCallId === block.id && !msgs[k]?.isError) {
104
+ err.resolved = true; break;
105
+ }
106
+ }
107
+ break;
108
+ }
109
+ }
110
+ if (err.retryAttempted) break;
111
+ }
112
+ }
113
+ }
114
+ return errors;
115
+ }
116
+
117
+ export function extractDecisions(msgs: LlmMessage[]): StructuredExtraction["decisions"] {
118
+ const tcIdx = buildToolCallIndex(msgs);
119
+ const decisions: StructuredExtraction["decisions"] = [];
120
+
121
+ for (const [id, tc] of tcIdx) {
122
+ if (tc.name !== "ask_user") continue;
123
+ const args = tc.arguments;
124
+ const question = typeof args === "string" ? args : (args?.question ?? args?.prompt ?? "") as string;
125
+ if (!question) continue;
126
+ for (let i = tc.msgIndex + 1; i < Math.min(msgs.length, tc.msgIndex + 4); i++) {
127
+ if (msgs[i]?.role === "toolResult" && msgs[i]?.toolCallId === id) {
128
+ decisions.push({ index: tc.msgIndex, type: "explicit", summary: question.slice(0, 200), userResponse: extractText(msgs[i].content).slice(0, 300) });
129
+ break;
130
+ }
131
+ }
132
+ }
133
+
134
+ for (let i = 0; i < msgs.length; i++) {
135
+ if (msgs[i]?.role !== "user") continue;
136
+ const txt = extractText(msgs[i].content);
137
+ if (CHOICE_RE.test(txt)) {
138
+ decisions.push({ index: i, type: "implicit", summary: txt.slice(0, 200) });
139
+ }
140
+ }
141
+ return decisions;
142
+ }
143
+
144
+ const CONSTRAINT_PATTERNS: Array<{ re: RegExp; cat: StructuredExtraction["constraints"][0]["category"]; conf: number }> = [
145
+ { re: /\b(?:must|need|require|has to|important)\b.*\b(?:be|use|have|include|support)\b/i, cat: "requirement", conf: 0.85 },
146
+ { re: /\b(?:don't|never|avoid|shouldn't|must not|do not|no\s+(?:need|want))\b/i, cat: "prohibition", conf: 0.8 },
147
+ { re: /\b(?:prefer|like|want|would rather|should)\b.*\b(?:use|be|have|with)\b/i, cat: "preference", conf: 0.6 },
148
+ // Turkish patterns — both with and without diacriticals
149
+ { re: /\b(?:kritik|kritikal|\u00f6nemli|onemli|\u015fart|sart|zorunlu|\u015fart ko\u015ful|\u00f6nemli \u015fart|kesinlikle|kesinlikle \u015fart|asla|sak\u0131n|sak\u0131nha|bunu yapma|b\u00f6yle olsun|b\u00f6yle yap\u0131n|\u015f\u00f6yle olsun|\u015f\u00f6yle yap\u0131n)\b/iu, cat: "requirement", conf: 0.8 },
150
+ { re: /\b(?:yapma|kullanma|sak\u0131n|asla\s+(?:kullanma|yapma|getirme))\b/iu, cat: "prohibition", conf: 0.8 },
151
+ { re: /\b(?:tercih|isterim|olsun|kullanal\u0131m|yapal\u0131m|istiyorum)\b/iu, cat: "preference", conf: 0.6 },
152
+ ];
153
+
154
+ export function mineConstraints(msgs: LlmMessage[]): StructuredExtraction["constraints"] {
155
+ const constraints: StructuredExtraction["constraints"] = [];
156
+ for (let i = 0; i < msgs.length; i++) {
157
+ if (msgs[i]?.role !== "user") continue;
158
+ const txt = extractText(msgs[i].content);
159
+ if (txt.length < 10 || txt.startsWith("/")) continue;
160
+ for (const { re, cat, conf } of CONSTRAINT_PATTERNS) {
161
+ if (re.test(txt)) {
162
+ constraints.push({ index: i, text: txt.slice(0, 300), category: cat, confidence: conf });
163
+ break;
164
+ }
165
+ }
166
+ }
167
+ return constraints;
168
+ }
169
+
170
+ export function segmentTopicsHeuristic(msgs: LlmMessage[], pc: ProfileConfig, maxSegs = 20): StructuredExtraction["topics"] {
171
+ const topics: StructuredExtraction["topics"] = [];
172
+ let startIdx = 0, tokenAcc = 0, lastFile: string | null = null, errAcc = 0;
173
+ const tcIdx = buildToolCallIndex(msgs);
174
+
175
+ for (let i = 0; i < msgs.length; i++) {
176
+ const m = msgs[i];
177
+ const txt = extractText(m.content);
178
+ tokenAcc += estimateTokens(txt);
179
+ let brk = false;
180
+ let type: StructuredExtraction["topics"][0]["type"] = "exploration";
181
+ let primaryFile: string | null = null;
182
+
183
+ if (m.role === "assistant") {
184
+ const blocks = (m.content ?? []) as unknown[];
185
+ for (const b of blocks) {
186
+ const block = b as ToolCallBlock;
187
+ if (block?.type === "toolCall") {
188
+ const fp = (block.arguments?.path ?? block.arguments?.file_path) as string | undefined;
189
+ if (fp) {
190
+ const fn = path.basename(fp);
191
+ if (lastFile && fn !== lastFile && tokenAcc > pc.minChunkTokens) brk = true;
192
+ lastFile = fn;
193
+ primaryFile = fp;
194
+ if (block.name?.includes("write") || block.name?.includes("edit")) type = "implementation";
195
+ else if (block.name?.includes("read")) type = "review";
196
+ }
197
+ }
198
+ }
199
+ }
200
+ if (m.role === "toolResult" && m.isError) { errAcc++; type = "debugging"; }
201
+ if (m.role === "toolResult" && !m.isError) {
202
+ const tc = tcIdx.get(m.toolCallId ?? "");
203
+ if (tc?.name === "bash" && /error|fail/i.test(txt)) { errAcc++; type = "debugging"; }
204
+ }
205
+ if (m.role === "user" && SHIFT_RE.test(txt) && tokenAcc > pc.minChunkTokens) brk = true;
206
+ if (tokenAcc >= pc.maxChunkTokens) brk = true;
207
+
208
+ if (brk && i > startIdx && topics.length < maxSegs - 1) {
209
+ topics.push({ startIndex: startIdx, endIndex: i, primaryFile, type, errorDensity: errAcc });
210
+ startIdx = i + 1; tokenAcc = 0; lastFile = null; errAcc = 0;
211
+ }
212
+ }
213
+ if (startIdx < msgs.length) {
214
+ topics.push({ startIndex: startIdx, endIndex: msgs.length - 1, primaryFile: null, type: "exploration", errorDensity: errAcc });
215
+ }
216
+ return topics;
217
+ }
218
+
219
+ export function buildTimeline(msgs: LlmMessage[], errors: StructuredExtraction["errors"]): StructuredExtraction["timeline"] {
220
+ const timeline: StructuredExtraction["timeline"] = [];
221
+ const errorIndices = new Set(errors.map(e => e.index));
222
+ for (let i = 0; i < msgs.length; i++) {
223
+ const m = msgs[i];
224
+ if (m.role === "user") {
225
+ const txt = extractText(m.content);
226
+ if (!txt.startsWith("/")) timeline.push({ index: i, event: "user_request", summary: txt.slice(0, 150) });
227
+ }
228
+ if (errorIndices.has(i)) timeline.push({ index: i, event: "error", summary: errors.find(e => e.index === i)?.message.slice(0, 100) ?? "error" });
229
+ }
230
+ return timeline.length > 30
231
+ ? [...timeline.filter(t => t.event === "user_request").slice(0, 10), ...timeline.filter(t => t.event === "error")]
232
+ : timeline;
233
+ }
234
+
235
+ export function extractMainGoal(msgs: LlmMessage[]): string | null {
236
+ for (const m of msgs) {
237
+ if (m?.role !== "user") continue;
238
+ const txt = extractText(m.content).trim();
239
+ if (txt && !txt.startsWith("/")) return txt.slice(0, 300);
240
+ }
241
+ return null;
242
+ }
243
+
244
+ export function extractStructured(msgs: LlmMessage[], pc: ProfileConfig): StructuredExtraction {
245
+ const { modified, read, deleted } = trackFileOps(msgs);
246
+ const errors = catalogErrors(msgs);
247
+ const decisions = extractDecisions(msgs);
248
+ const constraints = mineConstraints(msgs);
249
+ const topics = segmentTopicsHeuristic(msgs, pc);
250
+ const timeline = buildTimeline(msgs, errors);
251
+ const mainGoal = extractMainGoal(msgs);
252
+ const lastUserMessages = msgs.filter(m => m.role === "user").slice(-5).map(m => extractText(m.content));
253
+ const lastErrors = errors.slice(-3).map(e => e.message);
254
+ return {
255
+ modifiedFiles: modified, readFiles: read, deletedFiles: deleted,
256
+ errors, decisions, constraints, topics, timeline,
257
+ mainGoal, lastUserMessages, lastErrors, messageCount: msgs.length,
258
+ };
259
+ }
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Lightweight project fingerprint for cross-session context.
3
+ * Stores basic project metadata to improve compaction accuracy.
4
+ */
5
+
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+ import type { StructuredExtraction } from "../types.ts";
9
+
10
+ export interface ProjectFingerprint {
11
+ id: string;
12
+ language: string;
13
+ framework: string | null;
14
+ keyDirectories: string[];
15
+ knownFiles: string[];
16
+ sessionCount: number;
17
+ updatedAt: number;
18
+ }
19
+
20
+ const FINGERPRINT_DIR = path.join(process.env.HOME ?? "/tmp", ".pi", "agent", ".cache", "smart-compact", "projects");
21
+
22
+ // Language detection heuristics from file extensions
23
+ const LANG_MAP: Record<string, string> = {
24
+ ".ts": "typescript", ".tsx": "typescript",
25
+ ".js": "javascript", ".jsx": "javascript",
26
+ ".rs": "rust",
27
+ ".py": "python",
28
+ ".go": "go",
29
+ ".java": "java",
30
+ ".rb": "ruby",
31
+ ".cs": "csharp",
32
+ ".cpp": "cpp", ".c": "c", ".h": "c",
33
+ ".swift": "swift",
34
+ ".kt": "kotlin",
35
+ ".php": "php",
36
+ };
37
+
38
+ // Framework detection from file paths and names
39
+ const FRAMEWORK_SIGNALS: Array<{ pattern: RegExp; framework: string }> = [
40
+ { pattern: /next\.config/i, framework: "nextjs" },
41
+ { pattern: /nuxt\.config/i, framework: "nuxt" },
42
+ { pattern: /vite\.config/i, framework: "vite" },
43
+ { pattern: /astro\.config/i, framework: "astro" },
44
+ { pattern: /tailwind\.config/i, framework: "tailwind" },
45
+ { pattern: /django/i, framework: "django" },
46
+ { pattern: /flask/i, framework: "flask" },
47
+ { pattern: /cargo\.toml/i, framework: "cargo" },
48
+ { pattern: /go\.mod/i, framework: "go-modules" },
49
+ { pattern: /Gemfile/i, framework: "bundler" },
50
+ { pattern: /package\.json/i, framework: "node" },
51
+ ];
52
+
53
+ function getFingerprintPath(projectId: string): string {
54
+ return path.join(FINGERPRINT_DIR, projectId + ".json");
55
+ }
56
+
57
+ /**
58
+ * Generate a project ID from file paths in the extraction.
59
+ * Uses the most common root directory as a heuristic.
60
+ */
61
+ export function deriveProjectId(extraction: StructuredExtraction): string {
62
+ const allPaths = [
63
+ ...extraction.modifiedFiles.map(f => f.path),
64
+ ...extraction.readFiles,
65
+ ];
66
+ if (!allPaths.length) return "unknown";
67
+
68
+ // Find most common root directory
69
+ const roots = new Map<string, number>();
70
+ for (const p of allPaths) {
71
+ const parts = p.split("/");
72
+ const root = parts.length > 1 ? parts.slice(0, Math.min(2, parts.length - 1)).join("/") : "root";
73
+ roots.set(root, (roots.get(root) ?? 0) + 1);
74
+ }
75
+ const topRoot = [...roots.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? "unknown";
76
+ // Simple hash of the root
77
+ let hash = 0;
78
+ for (let i = 0; i < topRoot.length; i++) {
79
+ hash = ((hash << 5) - hash + topRoot.charCodeAt(i)) | 0;
80
+ }
81
+ return "proj-" + Math.abs(hash).toString(36);
82
+ }
83
+
84
+ /**
85
+ * Detect language from file extensions in extraction data.
86
+ */
87
+ function detectLanguage(extraction: StructuredExtraction): string {
88
+ const extCounts = new Map<string, number>();
89
+ for (const f of extraction.modifiedFiles) {
90
+ const ext = path.extname(f.path).toLowerCase();
91
+ if (ext && LANG_MAP[ext]) {
92
+ extCounts.set(LANG_MAP[ext], (extCounts.get(LANG_MAP[ext]) ?? 0) + 1);
93
+ }
94
+ }
95
+ for (const f of extraction.readFiles) {
96
+ const ext = path.extname(f).toLowerCase();
97
+ if (ext && LANG_MAP[ext]) {
98
+ extCounts.set(LANG_MAP[ext], (extCounts.get(LANG_MAP[ext]) ?? 0) + 1);
99
+ }
100
+ }
101
+ if (!extCounts.size) return "unknown";
102
+ return [...extCounts.entries()].sort((a, b) => b[1] - a[1])[0][0];
103
+ }
104
+
105
+ /**
106
+ * Detect framework from file paths.
107
+ */
108
+ function detectFramework(extraction: StructuredExtraction): string | null {
109
+ const allPaths = extraction.readFiles.join(" ") + " " + extraction.modifiedFiles.map(f => f.path).join(" ");
110
+ for (const { pattern, framework } of FRAMEWORK_SIGNALS) {
111
+ if (pattern.test(allPaths)) return framework;
112
+ }
113
+ return null;
114
+ }
115
+
116
+ /**
117
+ * Extract key directory patterns from file paths.
118
+ */
119
+ function extractKeyDirs(extraction: StructuredExtraction, maxDirs = 8): string[] {
120
+ const dirCounts = new Map<string, number>();
121
+ for (const f of extraction.modifiedFiles) {
122
+ const parts = f.path.split("/");
123
+ if (parts.length > 1) {
124
+ const dir = parts.slice(0, -1).join("/");
125
+ dirCounts.set(dir, (dirCounts.get(dir) ?? 0) + 1);
126
+ }
127
+ }
128
+ return [...dirCounts.entries()]
129
+ .sort((a, b) => b[1] - a[1])
130
+ .slice(0, maxDirs)
131
+ .map(([d]) => d);
132
+ }
133
+
134
+ /**
135
+ * Load project fingerprint from cache.
136
+ */
137
+ export function loadProjectFingerprint(projectId: string): ProjectFingerprint | null {
138
+ try {
139
+ const fp = getFingerprintPath(projectId);
140
+ if (!fs.existsSync(fp)) return null;
141
+ const data = JSON.parse(fs.readFileSync(fp, "utf8")) as ProjectFingerprint;
142
+ // Expire after 30 days
143
+ if (Date.now() - data.updatedAt > 30 * 24 * 60 * 60 * 1000) return null;
144
+ return data;
145
+ } catch { return null; }
146
+ }
147
+
148
+ /**
149
+ * Save/update project fingerprint after compaction.
150
+ */
151
+ export function saveProjectFingerprint(
152
+ projectId: string,
153
+ extraction: StructuredExtraction,
154
+ ): void {
155
+ try {
156
+ if (!fs.existsSync(FINGERPRINT_DIR)) fs.mkdirSync(FINGERPRINT_DIR, { recursive: true });
157
+
158
+ const existing = loadProjectFingerprint(projectId);
159
+ const newKnownFiles = [...new Set([
160
+ ...(existing?.knownFiles ?? []),
161
+ ...extraction.modifiedFiles.map(f => f.path),
162
+ ...extraction.readFiles,
163
+ ])].slice(-50); // Keep last 50 unique files
164
+
165
+ const fingerprint: ProjectFingerprint = {
166
+ id: projectId,
167
+ language: existing?.language ?? detectLanguage(extraction),
168
+ framework: existing?.framework ?? detectFramework(extraction),
169
+ keyDirectories: extractKeyDirs(extraction),
170
+ knownFiles: newKnownFiles,
171
+ sessionCount: (existing?.sessionCount ?? 0) + 1,
172
+ updatedAt: Date.now(),
173
+ };
174
+
175
+ fs.writeFileSync(getFingerprintPath(projectId), JSON.stringify(fingerprint, null, 2));
176
+ } catch { /* best effort */ }
177
+ }
178
+
179
+ /**
180
+ * Build a project context string for injection into prompts.
181
+ */
182
+ export function buildProjectContext(fingerprint: ProjectFingerprint | null): string {
183
+ if (!fingerprint) return "";
184
+ return [
185
+ "## Project Context (learned from " + fingerprint.sessionCount + " session(s))",
186
+ "Language: " + fingerprint.language,
187
+ fingerprint.framework ? "Framework: " + fingerprint.framework : "",
188
+ fingerprint.keyDirectories.length ? "Key dirs: " + fingerprint.keyDirectories.join(", ") : "",
189
+ ].filter(Boolean).join("\n");
190
+ }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * General helpers: config, backup, batching, preprocessing.
3
+ */
4
+
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import crypto from "node:crypto";
8
+ import type { CompactConfig, CompressionProfile, ChunkSummary, LlmChunk, ProfileConfig, LlmMessage, StructuredExtraction, ExplorationReport } from "../types.ts";
9
+ import { DEFAULT_CONFIG, PROFILES } from "../constants.ts";
10
+
11
+ let _cfg: CompactConfig | null = null;
12
+ let _cfgMtime = 0;
13
+
14
+ export function loadConfig(): CompactConfig {
15
+ try {
16
+ const p = path.join(process.env.HOME ?? "/tmp", ".pi/agent/settings.json");
17
+ const stat = fs.statSync(p);
18
+ if (_cfg && stat.mtimeMs === _cfgMtime) return _cfg;
19
+ const raw = JSON.parse(fs.readFileSync(p, "utf-8"));
20
+ const sc = raw.smartCompact ?? raw.semanticCompact ?? {};
21
+ const merged = { ...DEFAULT_CONFIG, ...sc };
22
+ if (sc.profiles) merged.profiles = { ...PROFILES, ...sc.profiles };
23
+ if (!merged.backupDir) merged.backupDir = path.join(process.env.HOME ?? "/tmp", ".pi/agent/compact-backups");
24
+ _cfg = merged; _cfgMtime = stat.mtimeMs; return _cfg;
25
+ } catch {
26
+ return { ...DEFAULT_CONFIG, backupDir: path.join(process.env.HOME ?? "/tmp", ".pi/agent/compact-backups") };
27
+ }
28
+ }
29
+
30
+ export function backupConversation(convText: string, sessionId: string): string | null {
31
+ try {
32
+ const cfg = loadConfig(); if (!cfg.backupEnabled) return null;
33
+ const dir = cfg.backupDir; fs.mkdirSync(dir, { recursive: true });
34
+ const ts = new Date().toISOString().replace(/[:.]/g, "-");
35
+ const hash = crypto.createHash("sha256").update(convText).digest("hex").slice(0, 8);
36
+ const fp = path.join(dir, sessionId + "-" + ts + "-" + hash + ".md");
37
+ fs.writeFileSync(fp, "# Smart Compact Backup\n# Date: " + new Date().toISOString() + "\n# Session: " + sessionId + "\n\n" + convText);
38
+ return fp;
39
+ } catch { return null; }
40
+ }
41
+
42
+ export function getPreviousCompactionContext(branch: unknown[]): string {
43
+ interface BranchEntry { type: string; details?: { topics?: string[]; method?: string } }
44
+ const compactions = branch.filter((e: BranchEntry) => e.type === "compaction");
45
+ if (!compactions.length) return "";
46
+ const last = compactions[compactions.length - 1] as BranchEntry;
47
+ const topics = last.details?.topics ?? [];
48
+ if (!topics.length) return "";
49
+ return "\n[IMPORTANT: Previous compaction exists (" + (last.details?.method ?? "unknown") + "). Already summarized topics: " + topics.join(", ") + ". Build upon this, don't re-summarize the same content.]";
50
+ }
51
+
52
+ interface SessionMessageEntry { type: "message"; id: string; message: unknown }
53
+
54
+ export function smartKeepBoundary(msgs: SessionMessageEntry[], keepFromIndex: number): number {
55
+ if (keepFromIndex <= 0 || keepFromIndex >= msgs.length) return keepFromIndex;
56
+ const last = msgs[keepFromIndex - 1];
57
+ const first = msgs[keepFromIndex];
58
+ if (last && first) {
59
+ const lastText = JSON.stringify(last.message).toLowerCase();
60
+ const keptText = JSON.stringify(first.message).toLowerCase();
61
+ const fileRe = /(?:path|file)=["']([^"']+)["']/g;
62
+ const lastFiles = new Set([...lastText.matchAll(fileRe)].map(m => m[1].split("/").pop()));
63
+ fileRe.lastIndex = 0;
64
+ const keptFiles = new Set([...keptText.matchAll(fileRe)].map(m => m[1].split("/").pop()));
65
+ if ([...lastFiles].filter(f => keptFiles.has(f)).length > 0) return keepFromIndex - 1;
66
+ }
67
+ return keepFromIndex;
68
+ }
69
+
70
+ export function extractUserNote(args: string): string | undefined {
71
+ const SKIP = new Set(["verbose", "debug", "dry-run", "light", "balanced", "aggressive"]);
72
+ const tokens = args.trim().split(/\s+/).filter(Boolean);
73
+ const nonFlags = tokens.filter(t => !t.includes("/") && !SKIP.has(t.toLowerCase()));
74
+ return nonFlags.length > 0 ? nonFlags.join(" ") : undefined;
75
+ }
76
+
77
+ export function createBatches(chunks: LlmChunk[], maxTokens: number): LlmChunk[][] {
78
+ const batches: LlmChunk[][] = [];
79
+ let batch: LlmChunk[] = [], bt = 0;
80
+ for (const ch of chunks) {
81
+ if (batch.length && bt + ch.tokenEstimate > maxTokens) { batches.push(batch); batch = []; bt = 0; }
82
+ batch.push(ch); bt += ch.tokenEstimate;
83
+ }
84
+ if (batch.length) batches.push(batch);
85
+ return batches;
86
+ }
87
+
88
+ /**
89
+ * Allocate token budget per topic based on priority, error density, and recency.
90
+ * Topics with higher weights get more detail preserved.
91
+ */
92
+ function allocateTopicBudgets(summaries: ChunkSummary[], totalBudget: number): Map<string, number> {
93
+ const n = summaries.length;
94
+ if (n === 0) return new Map();
95
+
96
+ const weights = summaries.map((s, i) => {
97
+ let w = 1.0;
98
+ // Priority weighting
99
+ if (s.priority === "critical") w *= 2.0;
100
+ else if (s.priority === "high") w *= 1.5;
101
+ else if (s.priority === "low") w *= 0.6;
102
+ // Error density — topics with errors need more context
103
+ const errorKeywords = (s.summary.match(/error|fail|bug|fix|crash|exception/gi) ?? []).length;
104
+ w *= (1 + errorKeywords * 0.2);
105
+ // Recency — later topics are more relevant
106
+ const recency = (i + 1) / n;
107
+ w *= (0.6 + recency * 0.4);
108
+ // Topics with decisions are important
109
+ if (s.keyDecisions.length > 0) w *= 1.3;
110
+ return w;
111
+ });
112
+
113
+ const totalWeight = weights.reduce((a, b) => a + b, 0);
114
+ const baseTokensPerTopic = Math.floor(totalBudget / n);
115
+ const budgetMap = new Map<string, number>();
116
+ for (let i = 0; i < summaries.length; i++) {
117
+ const allocated = Math.round(baseTokensPerTopic * (weights[i] / (totalWeight / n)));
118
+ budgetMap.set(summaries[i].topic, Math.max(200, allocated)); // minimum 200 tokens per topic
119
+ }
120
+ return budgetMap;
121
+ }
122
+
123
+ export function preProcessSummaries(summaries: ChunkSummary[], budgetTokens?: number) {
124
+ const topicBudgets = budgetTokens ? allocateTopicBudgets(summaries, budgetTokens) : null;
125
+ return {
126
+ decisions: [...new Set(summaries.flatMap(s => s.keyDecisions))],
127
+ modified: [...new Set(summaries.flatMap(s => s.filesModified))].sort(),
128
+ read: [...new Set(summaries.flatMap(s => s.filesRead))].sort(),
129
+ text: summaries.map((cs, i) => {
130
+ const budgetHint = topicBudgets?.get(cs.topic);
131
+ const budgetLine = budgetHint ? "\nBudget: ~" + budgetHint + " tokens" : "";
132
+ return "### Segment " + (i + 1) + ": " + cs.topic + "\nPriority: " + cs.priority + " | msgs " + cs.startIndex + "-" + cs.endIndex + budgetLine + "\n\n" + cs.summary + "\n\nDecisions: " + (cs.keyDecisions.join("; ") || "None") + "\nModified: " + (cs.filesModified.join(", ") || "None") + "\nRead: " + (cs.filesRead.join(", ") || "None");
133
+ }).join("\n---\n"),
134
+ };
135
+ }
136
+
137
+ export function buildExtractionContext(extraction: StructuredExtraction, forRange?: { start: number; end: number }): string {
138
+ const files = forRange ? extraction.modifiedFiles.filter(f => f.lastModifiedIndex >= forRange.start && f.lastModifiedIndex <= forRange.end) : extraction.modifiedFiles;
139
+ const errors = forRange ? extraction.errors.filter(e => e.index >= forRange.start && e.index <= forRange.end) : extraction.errors;
140
+ return [
141
+ "## Deterministic Extraction (verified facts)",
142
+ "Files modified: " + (files.map(f => f.path).join(", ") || "none"),
143
+ "Errors: " + (errors.map(e => "[" + e.tool + "] " + e.message.slice(0, 80) + (e.resolved ? " ✓" : "")).join("; ") || "none"),
144
+ "Decisions: " + (extraction.decisions.map(d => d.type + ": " + d.summary.slice(0, 60)).join("; ") || "none"),
145
+ "Constraints: " + (extraction.constraints.map(c => "[" + c.category + "] " + c.text.slice(0, 60)).join("; ") || "none"),
146
+ ].join("\n");
147
+ }
148
+
149
+ export function buildExplorationContext(report: ExplorationReport): string {
150
+ if (!report.mainGoal && !report.crossReferences.length && !report.enrichedConstraints.length) return "";
151
+ return [
152
+ "## Exploration Report",
153
+ "Main goal: " + report.mainGoal,
154
+ "Session type: " + report.sessionType,
155
+ report.crossReferences.length ? "Cross-references: " + report.crossReferences.join("; ") : "",
156
+ report.enrichedConstraints.length ? "Enriched constraints: " + report.enrichedConstraints.join("; ") : "",
157
+ report.statusAssessment.done.length ? "Assessed done: " + report.statusAssessment.done.join("; ") : "",
158
+ report.statusAssessment.inProgress.length ? "Assessed in-progress: " + report.statusAssessment.inProgress.join("; ") : "",
159
+ report.criticalContext.length ? "Critical context: " + report.criticalContext.join("; ") : "",
160
+ ].filter(Boolean).join("\n");
161
+ }
@@ -0,0 +1,21 @@
1
+ import type { LlmContentBlock, LlmMessage, LlmTextBlock, LlmToolCallBlock } from "../types";
2
+
3
+ export function getBlocks(message: Pick<LlmMessage, "content">): LlmContentBlock[] {
4
+ return Array.isArray(message.content) ? message.content : [];
5
+ }
6
+
7
+ export function isTextBlock(block: LlmContentBlock): block is LlmTextBlock {
8
+ return typeof block !== "string" && block.type === "text";
9
+ }
10
+
11
+ export function isToolCallBlock(block: LlmContentBlock): block is LlmToolCallBlock {
12
+ return typeof block !== "string" && block.type === "toolCall";
13
+ }
14
+
15
+ export function getToolArgumentString(args: Record<string, unknown>, ...keys: string[]): string {
16
+ for (const key of keys) {
17
+ const value = args[key];
18
+ if (typeof value === "string" && value) return value;
19
+ }
20
+ return "";
21
+ }