@pi-unipi/compactor 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +86 -0
- package/package.json +54 -0
- package/skills/compactor/SKILL.md +74 -0
- package/skills/compactor-doctor/SKILL.md +74 -0
- package/skills/compactor-ops/SKILL.md +65 -0
- package/skills/compactor-stats/SKILL.md +49 -0
- package/skills/compactor-tools/SKILL.md +120 -0
- package/src/commands/index.ts +248 -0
- package/src/compaction/brief.ts +334 -0
- package/src/compaction/build-sections.ts +77 -0
- package/src/compaction/content.ts +47 -0
- package/src/compaction/cut.ts +80 -0
- package/src/compaction/extract/commits.ts +52 -0
- package/src/compaction/extract/files.ts +58 -0
- package/src/compaction/extract/goals.ts +36 -0
- package/src/compaction/extract/preferences.ts +40 -0
- package/src/compaction/filter-noise.ts +46 -0
- package/src/compaction/format.ts +48 -0
- package/src/compaction/hooks.ts +145 -0
- package/src/compaction/merge.ts +113 -0
- package/src/compaction/normalize.ts +68 -0
- package/src/compaction/recall-scope.ts +32 -0
- package/src/compaction/sanitize.ts +12 -0
- package/src/compaction/search-entries.ts +101 -0
- package/src/compaction/sections.ts +15 -0
- package/src/compaction/summarize.ts +29 -0
- package/src/config/manager.ts +89 -0
- package/src/config/presets.ts +83 -0
- package/src/config/schema.ts +55 -0
- package/src/display/bash-display.ts +28 -0
- package/src/display/diff-presentation.ts +20 -0
- package/src/display/diff-renderer.ts +255 -0
- package/src/display/line-width-safety.ts +16 -0
- package/src/display/pending-diff-preview.ts +51 -0
- package/src/display/render-utils.ts +52 -0
- package/src/display/thinking-label.ts +18 -0
- package/src/display/tool-overrides.ts +136 -0
- package/src/display/user-message-box.ts +16 -0
- package/src/executor/executor.ts +242 -0
- package/src/executor/runtime.ts +125 -0
- package/src/index.ts +211 -0
- package/src/info-screen.ts +60 -0
- package/src/security/evaluator.ts +142 -0
- package/src/security/policy.ts +74 -0
- package/src/security/scanner.ts +65 -0
- package/src/session/db.ts +237 -0
- package/src/session/extract.ts +107 -0
- package/src/session/resume-inject.ts +25 -0
- package/src/session/snapshot.ts +326 -0
- package/src/store/chunking.ts +126 -0
- package/src/store/db-base.ts +79 -0
- package/src/store/index.ts +364 -0
- package/src/tools/compact.ts +20 -0
- package/src/tools/ctx-batch-execute.ts +53 -0
- package/src/tools/ctx-doctor.ts +78 -0
- package/src/tools/ctx-execute-file.ts +26 -0
- package/src/tools/ctx-execute.ts +21 -0
- package/src/tools/ctx-fetch-and-index.ts +37 -0
- package/src/tools/ctx-index.ts +42 -0
- package/src/tools/ctx-search.ts +23 -0
- package/src/tools/ctx-stats.ts +37 -0
- package/src/tools/register.ts +360 -0
- package/src/tools/vcc-recall.ts +64 -0
- package/src/tui/settings-overlay.ts +290 -0
- package/src/types.ts +269 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* All /unipi:compact-* commands
|
|
3
|
+
*
|
|
4
|
+
* Commands perform real work by calling tool implementations directly.
|
|
5
|
+
* Dependencies (sessionDB, contentStore, sessionId) are injected at registration time.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
9
|
+
import { loadConfig, saveConfig } from "../config/manager.js";
|
|
10
|
+
import { applyPreset, parsePreset } from "../config/presets.js";
|
|
11
|
+
import { getLastCompactionStats } from "../compaction/hooks.js";
|
|
12
|
+
import { compactTool } from "../tools/compact.js";
|
|
13
|
+
import { vccRecall } from "../tools/vcc-recall.js";
|
|
14
|
+
import { ctxStats } from "../tools/ctx-stats.js";
|
|
15
|
+
import { ctxDoctor } from "../tools/ctx-doctor.js";
|
|
16
|
+
import { ctxIndex } from "../tools/ctx-index.js";
|
|
17
|
+
import { ctxSearch } from "../tools/ctx-search.js";
|
|
18
|
+
import { ContentStore } from "../store/index.js";
|
|
19
|
+
import type { SessionDB } from "../session/db.js";
|
|
20
|
+
import type { NormalizedBlock } from "../types.js";
|
|
21
|
+
|
|
22
|
+
export interface CommandDeps {
|
|
23
|
+
sessionDB: SessionDB | null;
|
|
24
|
+
contentStore: ContentStore | null;
|
|
25
|
+
getSessionId: () => string;
|
|
26
|
+
getBlocks: () => NormalizedBlock[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function registerCommands(pi: ExtensionAPI, deps?: CommandDeps): void {
|
|
30
|
+
pi.registerCommand("compact", {
|
|
31
|
+
description: "Trigger manual compaction with stats",
|
|
32
|
+
handler: async (_args: string, ctx: any) => {
|
|
33
|
+
const result = compactTool();
|
|
34
|
+
const stats = getLastCompactionStats();
|
|
35
|
+
const msg = stats
|
|
36
|
+
? `🗜️ Compaction: ${stats.summarized} summarized, ${stats.kept} kept (~${stats.keptTokensEst} tok)\n${result.message}`
|
|
37
|
+
: `🗜️ ${result.message}`;
|
|
38
|
+
ctx.ui.notify(msg, "info");
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
pi.registerCommand("compact-recall", {
|
|
43
|
+
description: "Search session history (BM25 or regex)",
|
|
44
|
+
handler: async (args: string, ctx: any) => {
|
|
45
|
+
const query = args.trim();
|
|
46
|
+
if (!query) {
|
|
47
|
+
ctx.ui.notify("Usage: /unipi:compact-recall <query>", "warning");
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const blocks = deps?.getBlocks() ?? [];
|
|
51
|
+
if (blocks.length === 0) {
|
|
52
|
+
ctx.ui.notify("No session history available for search.", "warning");
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const result = vccRecall(blocks, { query, limit: 10 });
|
|
56
|
+
if (result.hits.length === 0) {
|
|
57
|
+
ctx.ui.notify(`No results for "${query}".`, "info");
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const lines = result.hits.map(
|
|
61
|
+
(h, i) => `[${i + 1}] score=${h.score.toFixed(2)} kind=${h.kind}\n${h.text.slice(0, 200)}`,
|
|
62
|
+
);
|
|
63
|
+
ctx.ui.notify(`Found ${result.total} results:\n${lines.join("\n\n")}`, "info");
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
pi.registerCommand("compact-stats", {
|
|
68
|
+
description: "Show context savings dashboard",
|
|
69
|
+
handler: async (_args: string, ctx: any) => {
|
|
70
|
+
if (!deps?.sessionDB || !deps?.contentStore) {
|
|
71
|
+
ctx.ui.notify("Compactor services not initialized.", "error");
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
const stats = await ctxStats(deps.sessionDB, deps.contentStore, deps.getSessionId());
|
|
76
|
+
const lines = [
|
|
77
|
+
"📊 Compactor Stats",
|
|
78
|
+
`Session events: ${stats.sessionEvents}`,
|
|
79
|
+
`Compactions: ${stats.compactions}`,
|
|
80
|
+
`Tokens saved: ${stats.tokensSaved}`,
|
|
81
|
+
`Indexed docs: ${stats.indexedDocs} (${stats.indexedChunks} chunks)`,
|
|
82
|
+
`Sandbox runs: ${stats.sandboxRuns}`,
|
|
83
|
+
`Search queries: ${stats.searchQueries}`,
|
|
84
|
+
];
|
|
85
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
86
|
+
} catch (err) {
|
|
87
|
+
ctx.ui.notify(`Stats error: ${err}`, "error");
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
pi.registerCommand("compact-doctor", {
|
|
93
|
+
description: "Run diagnostics checklist",
|
|
94
|
+
handler: async (_args: string, ctx: any) => {
|
|
95
|
+
if (!deps?.sessionDB || !deps?.contentStore) {
|
|
96
|
+
ctx.ui.notify("Compactor services not initialized.", "error");
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
const result = await ctxDoctor(deps.sessionDB, deps.contentStore);
|
|
101
|
+
const icon = (s: string) => (s === "pass" ? "✅" : s === "warn" ? "⚠️" : "❌");
|
|
102
|
+
const lines = [
|
|
103
|
+
result.healthy ? "🩺 All checks passed" : "🩺 Issues found",
|
|
104
|
+
"",
|
|
105
|
+
...result.checks.map((c) => `${icon(c.status)} ${c.name}: ${c.message}`),
|
|
106
|
+
];
|
|
107
|
+
ctx.ui.notify(lines.join("\n"), result.healthy ? "info" : "warning");
|
|
108
|
+
} catch (err) {
|
|
109
|
+
ctx.ui.notify(`Doctor error: ${err}`, "error");
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
pi.registerCommand("compact-settings", {
|
|
115
|
+
description: "Open TUI settings overlay",
|
|
116
|
+
handler: async (_args: string, ctx: any) => {
|
|
117
|
+
try {
|
|
118
|
+
const { renderSettingsOverlay } = await import("../tui/settings-overlay.js");
|
|
119
|
+
const result = await ctx.ui.custom(renderSettingsOverlay());
|
|
120
|
+
if (result) {
|
|
121
|
+
ctx.ui.notify("Settings saved.", "info");
|
|
122
|
+
} else {
|
|
123
|
+
ctx.ui.notify("Settings cancelled.", "info");
|
|
124
|
+
}
|
|
125
|
+
} catch (err) {
|
|
126
|
+
ctx.ui.notify(`Settings overlay error: ${err}`, "error");
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
pi.registerCommand("compact-preset", {
|
|
132
|
+
description: "Apply quick preset (opencode/balanced/verbose/minimal)",
|
|
133
|
+
handler: async (args: string, ctx: any) => {
|
|
134
|
+
const presetName = parsePreset(args.trim());
|
|
135
|
+
if (!presetName) {
|
|
136
|
+
ctx.ui.notify("Unknown preset. Use: opencode, balanced, verbose, minimal", "error");
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const config = applyPreset(presetName);
|
|
140
|
+
const result = saveConfig(config);
|
|
141
|
+
if (result.success) {
|
|
142
|
+
ctx.ui.notify(`Applied '${presetName}' preset.`, "info");
|
|
143
|
+
} else {
|
|
144
|
+
ctx.ui.notify(`Failed to save preset: ${result.error}`, "error");
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
pi.registerCommand("compact-index", {
|
|
150
|
+
description: "Index current project files into FTS5",
|
|
151
|
+
handler: async (_args: string, ctx: any) => {
|
|
152
|
+
if (!deps?.contentStore) {
|
|
153
|
+
ctx.ui.notify("Content store not initialized. Enable fts5Index in config.", "warning");
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
const cwd = (ctx as any).cwd ?? process.cwd();
|
|
158
|
+
const { readdirSync, readFileSync, statSync } = await import("node:fs");
|
|
159
|
+
const { join, relative, extname } = await import("node:path");
|
|
160
|
+
|
|
161
|
+
const indexable = [".md", ".txt", ".ts", ".js", ".json", ".py", ".sh"];
|
|
162
|
+
const files: string[] = [];
|
|
163
|
+
|
|
164
|
+
const walk = (dir: string, depth = 0) => {
|
|
165
|
+
if (depth > 3) return;
|
|
166
|
+
try {
|
|
167
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
168
|
+
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
|
|
169
|
+
const full = join(dir, entry.name);
|
|
170
|
+
if (entry.isDirectory()) {
|
|
171
|
+
walk(full, depth + 1);
|
|
172
|
+
} else if (indexable.includes(extname(entry.name))) {
|
|
173
|
+
files.push(full);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
} catch {
|
|
177
|
+
// skip unreadable dirs
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
walk(cwd);
|
|
182
|
+
let totalChunks = 0;
|
|
183
|
+
for (const file of files.slice(0, 100)) {
|
|
184
|
+
try {
|
|
185
|
+
const content = readFileSync(file, "utf-8");
|
|
186
|
+
if (content.length < 50) continue;
|
|
187
|
+
const ext = extname(file);
|
|
188
|
+
const ct = ext === ".md" ? "markdown" : ext === ".json" ? "json" : "plain";
|
|
189
|
+
const result = await deps.contentStore.index(relative(cwd, file), content, {
|
|
190
|
+
contentType: ct,
|
|
191
|
+
source: file,
|
|
192
|
+
});
|
|
193
|
+
totalChunks += result.totalChunks;
|
|
194
|
+
} catch {
|
|
195
|
+
// skip unreadable files
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
ctx.ui.notify(`Indexed ${Math.min(files.length, 100)} files (${totalChunks} chunks).`, "info");
|
|
199
|
+
} catch (err) {
|
|
200
|
+
ctx.ui.notify(`Index error: ${err}`, "error");
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
pi.registerCommand("compact-search", {
|
|
206
|
+
description: "Search indexed content",
|
|
207
|
+
handler: async (args: string, ctx: any) => {
|
|
208
|
+
const query = args.trim();
|
|
209
|
+
if (!query) {
|
|
210
|
+
ctx.ui.notify("Usage: /unipi:compact-search <query>", "warning");
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
if (!deps?.contentStore) {
|
|
214
|
+
ctx.ui.notify("Content store not initialized.", "warning");
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
try {
|
|
218
|
+
const results = await ctxSearch({ query, limit: 10 });
|
|
219
|
+
if (results.length === 0) {
|
|
220
|
+
ctx.ui.notify(`No results for "${query}".`, "info");
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
const lines = results.map(
|
|
224
|
+
(r, i) => `[${i + 1}] ${r.title} (rank: ${r.rank.toFixed(3)})\n${r.content.slice(0, 200)}`,
|
|
225
|
+
);
|
|
226
|
+
ctx.ui.notify(`Found ${results.length} results:\n${lines.join("\n\n")}`, "info");
|
|
227
|
+
} catch (err) {
|
|
228
|
+
ctx.ui.notify(`Search error: ${err}`, "error");
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
pi.registerCommand("compact-purge", {
|
|
234
|
+
description: "Wipe all indexed content from FTS5",
|
|
235
|
+
handler: async (_args: string, ctx: any) => {
|
|
236
|
+
if (!deps?.contentStore) {
|
|
237
|
+
ctx.ui.notify("Content store not initialized.", "warning");
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
try {
|
|
241
|
+
await deps.contentStore.purge();
|
|
242
|
+
ctx.ui.notify("All indexed content purged.", "info");
|
|
243
|
+
} catch (err) {
|
|
244
|
+
ctx.ui.notify(`Purge error: ${err}`, "error");
|
|
245
|
+
}
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
}
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stage 4: Brief Transcript — truncate and format compacted output
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { NormalizedBlock } from "../types.js";
|
|
6
|
+
import { clip, firstLine } from "./content.js";
|
|
7
|
+
import { extractPath } from "./extract/files.js";
|
|
8
|
+
|
|
9
|
+
const TRUNCATE_USER = 256;
|
|
10
|
+
const TRUNCATE_ASSISTANT = 200;
|
|
11
|
+
|
|
12
|
+
const SELF_TALK_PREFIX_RE =
|
|
13
|
+
/^\s*(?:hmm|wait|actually|oh|okay|ok|well|so)[,.!\s-]+/i;
|
|
14
|
+
|
|
15
|
+
const segmenter = new Intl.Segmenter(undefined, { granularity: "word" });
|
|
16
|
+
|
|
17
|
+
const isWord = (seg: { segment: string; isWordLike?: boolean }): boolean =>
|
|
18
|
+
!!seg.isWordLike || /[\p{L}\p{N}]/u.test(seg.segment);
|
|
19
|
+
|
|
20
|
+
const STOP_WORDS = new Set([
|
|
21
|
+
"a", "an", "the", "is", "are", "was", "were", "be", "been", "being",
|
|
22
|
+
"have", "has", "had", "do", "does", "did", "will", "would", "could",
|
|
23
|
+
"should", "may", "might", "shall", "can", "need", "must",
|
|
24
|
+
"to", "of", "in", "for", "on", "with", "at", "by", "from", "as",
|
|
25
|
+
"into", "through", "during", "before", "after", "above", "below",
|
|
26
|
+
"between", "under", "over",
|
|
27
|
+
"and", "but", "or", "nor", "not", "so", "yet", "both", "either",
|
|
28
|
+
"neither", "each", "every", "all", "any", "few", "more", "most",
|
|
29
|
+
"other", "some", "such", "no",
|
|
30
|
+
"that", "this", "these", "those", "it", "its",
|
|
31
|
+
"i", "me", "my", "we", "our", "you", "your", "he", "him", "his",
|
|
32
|
+
"she", "her", "they", "them", "their", "who", "which", "what",
|
|
33
|
+
"if", "then", "than", "when", "where", "how", "just", "also",
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
const truncateTokens = (text: string, limit: number): string => {
|
|
37
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
38
|
+
let count = 0;
|
|
39
|
+
let lastEnd = 0;
|
|
40
|
+
for (const seg of segmenter.segment(flat)) {
|
|
41
|
+
if (isWord(seg)) {
|
|
42
|
+
if (!STOP_WORDS.has(seg.segment.toLowerCase())) {
|
|
43
|
+
count++;
|
|
44
|
+
if (count > limit) {
|
|
45
|
+
return flat.slice(0, lastEnd).trimEnd() + "...(truncated)";
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
lastEnd = seg.index + seg.segment.length;
|
|
50
|
+
}
|
|
51
|
+
return flat;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const BASH_CAP = 120;
|
|
55
|
+
const PIPE_TAIL_RE = /\s*\|\s*(?:head|tail|sort|wc|column|tr|cut|awk|uniq|python3|node|bun)(?:\s[^|]*)?$/;
|
|
56
|
+
|
|
57
|
+
const compressBash = (raw: string): string => {
|
|
58
|
+
let cmd = raw.split("\n").map((l) => l.trim()).filter(Boolean)[0] ?? raw;
|
|
59
|
+
cmd = cmd.replace(/^cd\s+\S+\s*&&\s*/, "");
|
|
60
|
+
for (let i = 0; i < 3; i++) {
|
|
61
|
+
const stripped = cmd.replace(PIPE_TAIL_RE, "");
|
|
62
|
+
if (stripped === cmd) break;
|
|
63
|
+
cmd = stripped;
|
|
64
|
+
}
|
|
65
|
+
if (cmd.length > BASH_CAP) {
|
|
66
|
+
return cmd.slice(0, BASH_CAP - 3) + "...";
|
|
67
|
+
}
|
|
68
|
+
return cmd;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const TOOL_SUMMARY_FIELDS: Record<string, string> = {
|
|
72
|
+
Read: "file_path", Edit: "file_path", Write: "file_path",
|
|
73
|
+
read: "file_path", edit: "file_path", write: "file_path",
|
|
74
|
+
Glob: "pattern", Grep: "pattern",
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const toolOneLiner = (name: string, args: Record<string, unknown>): string => {
|
|
78
|
+
const field = TOOL_SUMMARY_FIELDS[name];
|
|
79
|
+
if (field && typeof args[field] === "string") {
|
|
80
|
+
return `* ${name} "${args[field] as string}"`;
|
|
81
|
+
}
|
|
82
|
+
const path = extractPath(args);
|
|
83
|
+
if (path) return `* ${name} "${path}"`;
|
|
84
|
+
if (name === "bash" || name === "Bash") {
|
|
85
|
+
const raw = (args.command ?? args.description ?? "") as string;
|
|
86
|
+
const cmd = compressBash(raw);
|
|
87
|
+
return `* ${name} "${cmd}"`;
|
|
88
|
+
}
|
|
89
|
+
if (typeof args.query === "string") {
|
|
90
|
+
return `* ${name} "${clip(args.query as string, 60)}"`;
|
|
91
|
+
}
|
|
92
|
+
return `* ${name}`;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export interface BriefLine {
|
|
96
|
+
header: string;
|
|
97
|
+
lines: string[];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface TranscriptEntry {
|
|
101
|
+
role: "user" | "assistant" | "tool_error";
|
|
102
|
+
text?: string;
|
|
103
|
+
tool?: string;
|
|
104
|
+
cmd?: string;
|
|
105
|
+
ref?: string;
|
|
106
|
+
count?: number;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export const buildBriefSections = (blocks: NormalizedBlock[]): BriefLine[] => {
|
|
110
|
+
const sections: BriefLine[] = [];
|
|
111
|
+
let lastHeader = "";
|
|
112
|
+
|
|
113
|
+
const push = (header: string, line: string) => {
|
|
114
|
+
if (header === lastHeader && sections.length > 0) {
|
|
115
|
+
sections[sections.length - 1].lines.push(line);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
sections.push({ header, lines: [line] });
|
|
119
|
+
lastHeader = header;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
for (const b of blocks) {
|
|
123
|
+
switch (b.kind) {
|
|
124
|
+
case "user": {
|
|
125
|
+
if (!b.text.trim()) break;
|
|
126
|
+
const text = truncateTokens(b.text, TRUNCATE_USER);
|
|
127
|
+
if (text) {
|
|
128
|
+
const ref = b.sourceIndex != null ? ` (#${b.sourceIndex})` : "";
|
|
129
|
+
push("[user]", text + ref);
|
|
130
|
+
}
|
|
131
|
+
lastHeader = "[user]";
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
case "assistant": {
|
|
135
|
+
let raw = b.text;
|
|
136
|
+
for (let i = 0; i < 2; i++) {
|
|
137
|
+
const stripped = raw.replace(SELF_TALK_PREFIX_RE, "");
|
|
138
|
+
if (stripped === raw) break;
|
|
139
|
+
raw = stripped;
|
|
140
|
+
}
|
|
141
|
+
const text = truncateTokens(raw, TRUNCATE_ASSISTANT);
|
|
142
|
+
if (text) {
|
|
143
|
+
const ref = b.sourceIndex != null ? ` (#${b.sourceIndex})` : "";
|
|
144
|
+
push("[assistant]", text + ref);
|
|
145
|
+
}
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
case "tool_call": {
|
|
149
|
+
if (!b.name || b.name.trim() === "") break;
|
|
150
|
+
const ref = b.sourceIndex != null ? ` (#${b.sourceIndex})` : "";
|
|
151
|
+
const summary = toolOneLiner(b.name, b.args) + ref;
|
|
152
|
+
push("[assistant]", summary);
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
case "tool_result": {
|
|
156
|
+
if (b.isError) {
|
|
157
|
+
const body = firstLine(b.text, 150);
|
|
158
|
+
if (!body || body === "(no output)") break;
|
|
159
|
+
const ref = b.sourceIndex != null ? ` (#${b.sourceIndex})` : "";
|
|
160
|
+
const header = `[tool_error] ${b.name}${ref}`;
|
|
161
|
+
push(header, body);
|
|
162
|
+
lastHeader = header;
|
|
163
|
+
}
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
case "thinking":
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Collapse consecutive identical tool lines
|
|
172
|
+
for (const sec of sections) {
|
|
173
|
+
if (sec.header !== "[assistant]") continue;
|
|
174
|
+
const out: string[] = [];
|
|
175
|
+
for (const line of sec.lines) {
|
|
176
|
+
if (!line.startsWith("* ")) { out.push(line); continue; }
|
|
177
|
+
const ref = line.match(/\(#(\d+)\)$/)?.[1] ?? "";
|
|
178
|
+
const base = ref ? line.slice(0, -(ref.length + 3)).trimEnd() : line;
|
|
179
|
+
const last = out.length > 0 ? out[out.length - 1] : "";
|
|
180
|
+
const m = last.match(/^(.*) \((#[\d, #]+)\) x(\d+)$/);
|
|
181
|
+
if (m && m[1] === base) {
|
|
182
|
+
out[out.length - 1] = `${base} (${m[2]}, #${ref}) x${parseInt(m[3]) + 1}`;
|
|
183
|
+
} else if (last.match(/\(#\d+\)$/) && last.replace(/\s*\(#\d+\)$/, "") === base) {
|
|
184
|
+
const prevRef = last.match(/\(#(\d+)\)$/)?.[1];
|
|
185
|
+
out[out.length - 1] = `${base} (#${prevRef}, #${ref}) x2`;
|
|
186
|
+
} else {
|
|
187
|
+
out.push(line);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
sec.lines = out;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Cap tool calls per [assistant] turn
|
|
194
|
+
const TOOL_CALLS_PER_TURN = 8;
|
|
195
|
+
for (const sec of sections) {
|
|
196
|
+
if (sec.header !== "[assistant]") continue;
|
|
197
|
+
const toolIdxs = sec.lines
|
|
198
|
+
.map((l, i) => (l.startsWith("* ") ? i : -1))
|
|
199
|
+
.filter((i) => i >= 0);
|
|
200
|
+
if (toolIdxs.length <= TOOL_CALLS_PER_TURN) continue;
|
|
201
|
+
const dropCount = toolIdxs.length - TOOL_CALLS_PER_TURN;
|
|
202
|
+
const dropSet = new Set(toolIdxs.slice(0, dropCount));
|
|
203
|
+
const firstKeptToolIdx = toolIdxs[dropCount];
|
|
204
|
+
const next: string[] = [];
|
|
205
|
+
let inserted = false;
|
|
206
|
+
for (let i = 0; i < sec.lines.length; i++) {
|
|
207
|
+
if (dropSet.has(i)) continue;
|
|
208
|
+
if (!inserted && i === firstKeptToolIdx) {
|
|
209
|
+
next.push(`* (${dropCount} earlier tool-call entries omitted)`);
|
|
210
|
+
inserted = true;
|
|
211
|
+
}
|
|
212
|
+
next.push(sec.lines[i]);
|
|
213
|
+
}
|
|
214
|
+
sec.lines = next;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Collapse consecutive identical [tool_error] sections
|
|
218
|
+
const collapsedErrors: BriefLine[] = [];
|
|
219
|
+
for (const sec of sections) {
|
|
220
|
+
const m = sec.header.match(/^\[tool_error\]\s+(\S+?)(?:\s*\(#(\d+)\))?$/);
|
|
221
|
+
if (!m || sec.lines.length !== 1) {
|
|
222
|
+
collapsedErrors.push(sec);
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
const tool = m[1];
|
|
226
|
+
const ref = m[2];
|
|
227
|
+
const body = sec.lines[0];
|
|
228
|
+
const prev = collapsedErrors[collapsedErrors.length - 1];
|
|
229
|
+
const prevMatch = prev?.header.match(
|
|
230
|
+
/^\[tool_error\]\s+(\S+?)\s*\(((?:#\d+(?:,\s*)?)+)\)(?:\s*x(\d+))?$/,
|
|
231
|
+
);
|
|
232
|
+
if (prev && prevMatch && prevMatch[1] === tool && prev.lines.length === 1 && prev.lines[0] === body) {
|
|
233
|
+
const refs = prevMatch[2] + (ref ? `, #${ref}` : "");
|
|
234
|
+
const count = prevMatch[3] ? parseInt(prevMatch[3]) + 1 : 2;
|
|
235
|
+
prev.header = `[tool_error] ${tool} (${refs}) x${count}`;
|
|
236
|
+
} else {
|
|
237
|
+
collapsedErrors.push(sec);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
sections.length = 0;
|
|
241
|
+
sections.push(...collapsedErrors);
|
|
242
|
+
|
|
243
|
+
return sections;
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
export const stringifyBrief = (sections: BriefLine[]): string => {
|
|
247
|
+
const out: string[] = [];
|
|
248
|
+
for (let i = 0; i < sections.length; i++) {
|
|
249
|
+
const sec = sections[i];
|
|
250
|
+
if (i > 0) {
|
|
251
|
+
const prev = sections[i - 1];
|
|
252
|
+
const prevIsTools = prev.header === "[assistant]" &&
|
|
253
|
+
prev.lines.every((l) => l.startsWith("* "));
|
|
254
|
+
const curIsTools = sec.header === "[assistant]" &&
|
|
255
|
+
sec.lines.every((l) => l.startsWith("* "));
|
|
256
|
+
if (!(prevIsTools && curIsTools)) {
|
|
257
|
+
out.push("");
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
out.push(sec.header);
|
|
261
|
+
for (const line of sec.lines) {
|
|
262
|
+
out.push(line);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return out.join("\n");
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
const parseToolLine = (line: string): { tool: string; cmd?: string; ref?: string; count?: number } | null => {
|
|
269
|
+
const m = line.match(/^\* (\S+)\s*(?:"([^"]*)")?\s*(?:\((#[\d, #]+)\))?\s*(?:x(\d+))?$/);
|
|
270
|
+
if (!m) return null;
|
|
271
|
+
return {
|
|
272
|
+
tool: m[1],
|
|
273
|
+
cmd: m[2] || undefined,
|
|
274
|
+
ref: m[3] || undefined,
|
|
275
|
+
count: m[4] ? parseInt(m[4]) : undefined,
|
|
276
|
+
};
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
const extractRef = (text: string): { clean: string; ref?: string } => {
|
|
280
|
+
const m = text.match(/\s*\(#(\d+)\)$/);
|
|
281
|
+
if (!m) return { clean: text };
|
|
282
|
+
return { clean: text.slice(0, m.index).trimEnd(), ref: `#${m[1]}` };
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
export const sectionsToTranscript = (sections: BriefLine[]): TranscriptEntry[] => {
|
|
286
|
+
const entries: TranscriptEntry[] = [];
|
|
287
|
+
|
|
288
|
+
for (const sec of sections) {
|
|
289
|
+
if (sec.header === "[user]") {
|
|
290
|
+
for (const line of sec.lines) {
|
|
291
|
+
const { clean, ref } = extractRef(line);
|
|
292
|
+
entries.push({ role: "user", text: clean, ...(ref && { ref }) });
|
|
293
|
+
}
|
|
294
|
+
} else if (sec.header === "[assistant]") {
|
|
295
|
+
for (const line of sec.lines) {
|
|
296
|
+
if (line.startsWith("* ")) {
|
|
297
|
+
const parsed = parseToolLine(line);
|
|
298
|
+
if (parsed) {
|
|
299
|
+
entries.push({
|
|
300
|
+
role: "assistant",
|
|
301
|
+
tool: parsed.tool,
|
|
302
|
+
...(parsed.cmd && { cmd: parsed.cmd }),
|
|
303
|
+
...(parsed.ref && { ref: parsed.ref }),
|
|
304
|
+
...(parsed.count && { count: parsed.count }),
|
|
305
|
+
});
|
|
306
|
+
} else {
|
|
307
|
+
const { clean, ref } = extractRef(line.slice(2));
|
|
308
|
+
entries.push({ role: "assistant", text: clean, ...(ref && { ref }) });
|
|
309
|
+
}
|
|
310
|
+
} else {
|
|
311
|
+
const { clean, ref } = extractRef(line);
|
|
312
|
+
entries.push({ role: "assistant", text: clean, ...(ref && { ref }) });
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
} else if (sec.header.startsWith("[tool_error]")) {
|
|
316
|
+
const headerMatch = sec.header.match(/^\[tool_error\]\s+(\S+)\s*(?:\(#(\d+)\))?/);
|
|
317
|
+
const tool = headerMatch?.[1] ?? "unknown";
|
|
318
|
+
const ref = headerMatch?.[2] ? `#${headerMatch[2]}` : undefined;
|
|
319
|
+
for (const line of sec.lines) {
|
|
320
|
+
entries.push({
|
|
321
|
+
role: "tool_error",
|
|
322
|
+
tool,
|
|
323
|
+
text: line,
|
|
324
|
+
...(ref && { ref }),
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return entries;
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
export const compileBrief = (blocks: NormalizedBlock[]): string =>
|
|
334
|
+
stringifyBrief(buildBriefSections(blocks));
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stage 3: Build Sections — Goals, Files, Commits, Blockers, Preferences
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { NormalizedBlock } from "../types.js";
|
|
6
|
+
import { clip, clipSentence, firstLine, nonEmptyLines } from "./content.js";
|
|
7
|
+
import { extractGoals } from "./extract/goals.js";
|
|
8
|
+
import { extractFiles } from "./extract/files.js";
|
|
9
|
+
import { extractPreferences, dedupPreferencesAgainstGoals } from "./extract/preferences.js";
|
|
10
|
+
import { extractCommits, formatCommits } from "./extract/commits.js";
|
|
11
|
+
import { buildBriefSections, sectionsToTranscript, stringifyBrief } from "./brief.js";
|
|
12
|
+
import type { SectionData } from "./sections.js";
|
|
13
|
+
export type { SectionData } from "./sections.js";
|
|
14
|
+
|
|
15
|
+
const BLOCKER_RE =
|
|
16
|
+
/\b(fail(ed|s|ure|ing)?|broken|cannot|can't|won't work|does not work|doesn't work|still (broken|failing|wrong)|blocked|blocker|not (fixed|resolved|working)|crash(es|ed|ing)?)\b/i;
|
|
17
|
+
|
|
18
|
+
const extractOutstandingContext = (blocks: NormalizedBlock[]): string[] => {
|
|
19
|
+
const items: string[] = [];
|
|
20
|
+
const tail = blocks.slice(-20);
|
|
21
|
+
|
|
22
|
+
for (const b of tail) {
|
|
23
|
+
if (b.kind === "tool_result" && b.isError) {
|
|
24
|
+
items.push(`[${b.name}] ${firstLine(b.text, 150)}`);
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (b.kind === "assistant" || b.kind === "user") {
|
|
29
|
+
for (const line of nonEmptyLines(b.text)) {
|
|
30
|
+
if (!BLOCKER_RE.test(line)) continue;
|
|
31
|
+
if (line.length < 15) continue;
|
|
32
|
+
if (/^\s*[-*+>]\s/.test(line)) continue;
|
|
33
|
+
if (/^\s*\(/.test(line)) continue;
|
|
34
|
+
if (!/^\s*["'`*_]?[A-Z`]/.test(line)) continue;
|
|
35
|
+
const clipped = b.kind === "user" ? `[user] ${clipSentence(line, 150)}` : clipSentence(line, 150);
|
|
36
|
+
if (!items.includes(clipped)) items.push(clipped);
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return items.slice(0, 5);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const formatFileActivity = (blocks: NormalizedBlock[]): string[] => {
|
|
46
|
+
const act = extractFiles(blocks);
|
|
47
|
+
for (const p of act.modified) act.created.delete(p);
|
|
48
|
+
const lines: string[] = [];
|
|
49
|
+
const cap = (set: Set<string>, limit: number) => {
|
|
50
|
+
const arr = [...set];
|
|
51
|
+
if (arr.length <= limit) return arr.join(", ");
|
|
52
|
+
return arr.slice(0, limit).join(", ") + ` (+${arr.length - limit} more)`;
|
|
53
|
+
};
|
|
54
|
+
if (act.modified.size > 0) lines.push(`Modified: ${cap(act.modified, 10)}`);
|
|
55
|
+
if (act.created.size > 0) lines.push(`Created: ${cap(act.created, 10)}`);
|
|
56
|
+
if (act.read.size > 0) lines.push(`Read: ${cap(act.read, 10)}`);
|
|
57
|
+
return lines;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export const buildSections = (input: { blocks: NormalizedBlock[] }): SectionData => {
|
|
61
|
+
const { blocks } = input;
|
|
62
|
+
const briefSections = buildBriefSections(blocks);
|
|
63
|
+
const sessionGoal = extractGoals(blocks);
|
|
64
|
+
const userPreferences = dedupPreferencesAgainstGoals(
|
|
65
|
+
extractPreferences(blocks),
|
|
66
|
+
sessionGoal,
|
|
67
|
+
);
|
|
68
|
+
return {
|
|
69
|
+
sessionGoal,
|
|
70
|
+
outstandingContext: extractOutstandingContext(blocks),
|
|
71
|
+
filesAndChanges: formatFileActivity(blocks),
|
|
72
|
+
commits: formatCommits(extractCommits(blocks)),
|
|
73
|
+
userPreferences,
|
|
74
|
+
briefTranscript: stringifyBrief(briefSections),
|
|
75
|
+
transcriptEntries: sectionsToTranscript(briefSections),
|
|
76
|
+
};
|
|
77
|
+
};
|