@monotykamary/pi-supervisor 0.5.9

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 (62) hide show
  1. package/CHANGELOG.md +120 -0
  2. package/LICENSE +21 -0
  3. package/README.md +341 -0
  4. package/media/demo.mp4 +0 -0
  5. package/media/screenshot.png +0 -0
  6. package/package.json +87 -0
  7. package/src/compaction/brief.ts +841 -0
  8. package/src/compaction/build-sections.ts +340 -0
  9. package/src/compaction/causal-keys.ts +138 -0
  10. package/src/compaction/content.ts +68 -0
  11. package/src/compaction/extract/commits.ts +78 -0
  12. package/src/compaction/extract/goals.ts +79 -0
  13. package/src/compaction/extract/preferences.ts +52 -0
  14. package/src/compaction/extract/shared-symbols.ts +376 -0
  15. package/src/compaction/filter-noise.ts +47 -0
  16. package/src/compaction/format.ts +89 -0
  17. package/src/compaction/index.ts +38 -0
  18. package/src/compaction/normalize.ts +73 -0
  19. package/src/compaction/sanitize.ts +5 -0
  20. package/src/compaction/sections.ts +19 -0
  21. package/src/compaction/skill-collapse.ts +35 -0
  22. package/src/compaction/tool-args.ts +14 -0
  23. package/src/compaction/types.ts +26 -0
  24. package/src/core/analyzer.ts +58 -0
  25. package/src/core/index.ts +8 -0
  26. package/src/core/inference.ts +77 -0
  27. package/src/core/prompt-builder.ts +137 -0
  28. package/src/core/prompt-loader.ts +125 -0
  29. package/src/core/reframe.ts +27 -0
  30. package/src/fabric-provider.ts +115 -0
  31. package/src/global-config.ts +65 -0
  32. package/src/index.ts +514 -0
  33. package/src/session/client.ts +46 -0
  34. package/src/session/response-parser.ts +37 -0
  35. package/src/session/supervisor-session.ts +102 -0
  36. package/src/state/manager.ts +133 -0
  37. package/src/state/mid-run-signals.ts +103 -0
  38. package/src/state/patterns.ts +82 -0
  39. package/src/state/reframe.ts +27 -0
  40. package/src/subagent-detector.ts +94 -0
  41. package/src/types.ts +42 -0
  42. package/src/ui/animations.ts +95 -0
  43. package/src/ui/model-picker.ts +72 -0
  44. package/src/ui/model-settings-selector.ts +440 -0
  45. package/src/ui/model-sort.ts +101 -0
  46. package/src/ui/renderer.ts +314 -0
  47. package/src/ui/types.ts +48 -0
  48. package/tests/compaction.test.ts +507 -0
  49. package/tests/engine.test.ts +622 -0
  50. package/tests/ephemeral-supervision.test.ts +347 -0
  51. package/tests/fabric-provider.test.ts +55 -0
  52. package/tests/full-fidelity-snapshot.test.ts +250 -0
  53. package/tests/global-config.test.ts +74 -0
  54. package/tests/model-sort.test.ts +157 -0
  55. package/tests/parsing.test.ts +303 -0
  56. package/tests/state.test.ts +474 -0
  57. package/tests/status-widget.test.ts +539 -0
  58. package/tests/subagent-detector.test.ts +191 -0
  59. package/tests/supervise-command.test.ts +363 -0
  60. package/tests/supervise-model-command.test.ts +184 -0
  61. package/tsconfig.json +14 -0
  62. package/vitest.config.ts +15 -0
@@ -0,0 +1,340 @@
1
+ import type { NormalizedBlock, ToolResultIndex } from './types';
2
+ import { clip, clipSentence, firstLine, nonEmptyLines } from './content';
3
+ import type { SectionData } from './sections';
4
+ import { extractGoals } from './extract/goals';
5
+ import { extractPath } from './tool-args';
6
+ import { extractFileAndSymbolData } from './extract/shared-symbols';
7
+ import { extractPreferences, dedupPreferencesAgainstGoals } from './extract/preferences';
8
+ import { extractCommits, formatCommits } from './extract/commits';
9
+ import { buildBriefSections, identifyTurns, sectionsToTranscript, stringifyBrief } from './brief';
10
+
11
+ /**
12
+ * Build a one-time look-ahead index: for each tool_call block, find the
13
+ * nearest tool_result block that follows it (within +3 positions).
14
+ *
15
+ * Without this, files.ts / symbol-changes.ts / type-catalog.ts each scan
16
+ * forward independently — tripling the look-ahead cost and the regex parsing
17
+ * of tool results. The index collapses that to a single O(n) pre-scan.
18
+ */
19
+ const buildToolResultIndex = (blocks: NormalizedBlock[]): ToolResultIndex => {
20
+ const map = new Map<number, Extract<NormalizedBlock, { kind: 'tool_result' }>>();
21
+ for (let i = 0; i < blocks.length; i++) {
22
+ if (blocks[i].kind !== 'tool_call') continue;
23
+ for (let j = i + 1; j < Math.min(blocks.length, i + 4); j++) {
24
+ if (blocks[j].kind === 'tool_result') {
25
+ map.set(i, blocks[j] as Extract<NormalizedBlock, { kind: 'tool_result' }>);
26
+ break;
27
+ }
28
+ }
29
+ }
30
+ return {
31
+ get: (callIndex: number) => map.get(callIndex) ?? null,
32
+ };
33
+ };
34
+
35
+ interface BuildSectionsInput {
36
+ blocks: NormalizedBlock[];
37
+ /** Pre-built tool-call → tool-result look-ahead index. Built once, shared across extractors. */
38
+ toolResultIndex?: ToolResultIndex;
39
+ }
40
+
41
+ // TypeScript compiler error pattern
42
+ const TSC_ERROR_RE = /error TS\d+:.+/;
43
+
44
+ // Test failure indicators
45
+ const TEST_FAIL_RE = /(?:FAIL|✗|✘|×)\s|(\d+)\s+(?:failed|failure|failing)/i;
46
+
47
+ // Empty grep/search result indicators
48
+ const EMPTY_RESULT_RE =
49
+ /^(?:No matches? found\.?|No files? matched\.?|0 results?|No results?\.?)$/i;
50
+
51
+ // Maximum characters of bash output to scan for error patterns.
52
+ // Compiler/test errors almost always appear near the start of output;
53
+ // scanning the full output (potentially megabytes) is unnecessary.
54
+ const BASH_OUTPUT_SCAN_LIMIT = 8_000;
55
+
56
+ const BLOCKER_RE =
57
+ /\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;
58
+
59
+ // Priority tags for outstanding context items
60
+ const PRIORITY_ERROR = '[ERROR]';
61
+ const PRIORITY_WARN = '[WARN]';
62
+ const PRIORITY_INFO = '[INFO]';
63
+
64
+ /** Prepend a priority tag based on the error type and exit code. */
65
+ const priorityTag = (item: string): string => {
66
+ if (/^\[tsc\]/.test(item)) return `${PRIORITY_ERROR} ${item}`;
67
+ if (/^\[bash:exit [1-9]\d*\]/.test(item)) return `${PRIORITY_ERROR} ${item}`;
68
+ if (/^\[tests\]/.test(item)) return `${PRIORITY_WARN} ${item}`;
69
+ if (/^\[no matches\]/.test(item)) return `${PRIORITY_INFO} ${item}`;
70
+ if (/^\[user\]/.test(item)) return `${PRIORITY_WARN} ${item}`;
71
+ // Generic tool errors
72
+ if (/^\[\w+\]/.test(item)) return `${PRIORITY_ERROR} ${item}`;
73
+ return `${PRIORITY_WARN} ${item}`;
74
+ };
75
+
76
+ // Write-tool names used for resolution detection
77
+ const FILE_EDIT_TOOLS = new Set(['Edit', 'Write', 'edit', 'write', 'MultiEdit']);
78
+
79
+ /** Extract file path from a [tsc] error line like "src/auth.ts(5,18): error TS2304: ..." */
80
+ const extractTscFile = (item: string): string | null => {
81
+ const m = item.match(/^\[tsc\]\s+(\S+)\(\d+,\d+\)/);
82
+ return m ? m[1] : null;
83
+ };
84
+
85
+ /** Check if a tsc error's file was edited at a position after the error. */
86
+ const isTscResolved = (
87
+ file: string,
88
+ tailIdx: number,
89
+ editPositions: Map<number, Set<string>>
90
+ ): boolean => {
91
+ for (const [pos, files] of editPositions) {
92
+ if (pos > tailIdx && files.has(file)) return true;
93
+ }
94
+ return false;
95
+ };
96
+
97
+ const extractOutstandingContext = (blocks: NormalizedBlock[]): string[] => {
98
+ const items: string[] = [];
99
+ const itemTailIndices: number[] = [];
100
+ const seen = new Set<string>();
101
+ const tail = blocks.slice(-25);
102
+
103
+ const push = (item: string, tailIndex?: number) => {
104
+ if (!seen.has(item)) {
105
+ seen.add(item);
106
+ items.push(item);
107
+ itemTailIndices.push(tailIndex ?? -1);
108
+ }
109
+ };
110
+
111
+ for (let bi = 0; bi < tail.length; bi++) {
112
+ const b = tail[bi];
113
+
114
+ // 1. Bash non-zero exit codes (the exitCode field is already captured but was unused)
115
+ if (b.kind === 'bash' && b.exitCode !== undefined && b.exitCode !== 0) {
116
+ const cmd =
117
+ b.command
118
+ .split('\n')
119
+ .map((l) => l.trim())
120
+ .filter(Boolean)[0] ?? b.command;
121
+ const cmdDisplay = cmd.length > 80 ? cmd.slice(0, 77) + '...' : cmd;
122
+ const outLine = firstLine(b.output, 120);
123
+ const errTag = `exit ${b.exitCode}`;
124
+ push(
125
+ `[bash:${errTag}] ${cmdDisplay}${outLine && outLine !== cmdDisplay ? ` → ${outLine}` : ''}`
126
+ );
127
+ continue;
128
+ }
129
+
130
+ // 2. TypeScript compiler errors in bash output
131
+ // Scan only the first BASH_OUTPUT_SCAN_LIMIT chars — errors appear at start of output
132
+ // Now includes file path (e.g., src/auth.ts(5,18): error TS2304:) for resolution detection
133
+ if (b.kind === 'bash' && b.output) {
134
+ const outputHead = b.output.slice(0, BASH_OUTPUT_SCAN_LIMIT);
135
+ if (TSC_ERROR_RE.test(outputHead)) {
136
+ const tsLines = outputHead
137
+ .split('\n')
138
+ .filter((l) => TSC_ERROR_RE.test(l.trim()))
139
+ .slice(0, 3);
140
+ for (const line of tsLines) {
141
+ push(`[tsc] ${clip(line.trim(), 150)}`, bi);
142
+ }
143
+ continue;
144
+ }
145
+ }
146
+
147
+ // 3. Test failures in bash output
148
+ if (
149
+ b.kind === 'bash' &&
150
+ b.output &&
151
+ TEST_FAIL_RE.test(b.output.slice(0, BASH_OUTPUT_SCAN_LIMIT))
152
+ ) {
153
+ push(`[tests] ${firstLine(b.output, 150)}`);
154
+ continue;
155
+ }
156
+
157
+ // 4. Empty grep/search results (searched for something that wasn't found = signal)
158
+ if (
159
+ b.kind === 'tool_result' &&
160
+ (b.name === 'grep' || b.name === 'Grep' || b.name === 'Glob' || b.name === 'glob')
161
+ ) {
162
+ const trimmed = b.text.trim();
163
+ if (EMPTY_RESULT_RE.test(trimmed) || trimmed === '') {
164
+ let prevIdx = -1;
165
+ for (let pi = bi - 1; pi >= 0; pi--) {
166
+ const pp = tail[pi];
167
+ if (
168
+ pp.kind === 'tool_call' &&
169
+ (pp.name === 'grep' || pp.name === 'Grep' || pp.name === 'Glob' || pp.name === 'glob')
170
+ ) {
171
+ prevIdx = pi;
172
+ break;
173
+ }
174
+ }
175
+ let pattern = '';
176
+ if (prevIdx >= 0) {
177
+ const pc = tail[prevIdx];
178
+ if (pc.kind === 'tool_call') {
179
+ pattern = (pc.args.pattern ?? pc.args.query ?? pc.args.glob ?? '') as string;
180
+ if (pattern) pattern = ` "${clip(pattern, 60)}"`;
181
+ }
182
+ }
183
+ push(`[no matches] ${b.name}${pattern}`);
184
+ continue;
185
+ }
186
+ }
187
+
188
+ // 5. Tool errors — classify tsc/test failures before generic catch
189
+ if (b.kind === 'tool_result' && b.isError) {
190
+ // Check for tsc errors in tool result text first
191
+ if (TSC_ERROR_RE.test(b.text)) {
192
+ const tsLines = b.text
193
+ .split('\n')
194
+ .filter((l) => TSC_ERROR_RE.test(l.trim()))
195
+ .slice(0, 3);
196
+ for (const line of tsLines) {
197
+ push(`[tsc] ${clip(line.trim(), 150)}`, bi);
198
+ }
199
+ continue;
200
+ }
201
+ // Check for test failures
202
+ if (TEST_FAIL_RE.test(b.text)) {
203
+ push(`[tests] ${firstLine(b.text, 150)}`);
204
+ continue;
205
+ }
206
+ // Generic error fallback
207
+ push(`[${b.name}] ${firstLine(b.text, 150)}`);
208
+ continue;
209
+ }
210
+
211
+ // 6. BLOCKER_RE text matching (user/assistant mentions of problems)
212
+ if (b.kind === 'assistant' || b.kind === 'user') {
213
+ for (const line of nonEmptyLines(b.text)) {
214
+ if (!BLOCKER_RE.test(line)) continue;
215
+ if (line.length < 15) continue;
216
+ if (/^\s*[-*+>]\s/.test(line)) continue;
217
+ if (/^\s*\(/.test(line)) continue;
218
+ if (!/^\s*["'`*_]?[A-Z`]/.test(line)) continue;
219
+ const clipped =
220
+ b.kind === 'user' ? `[user] ${clipSentence(line, 150)}` : clipSentence(line, 150);
221
+ push(clipped);
222
+ break;
223
+ }
224
+ }
225
+ }
226
+
227
+ // Resolution detection: pre-compute edit positions in the tail so we can
228
+ // check whether tsc errors were subsequently fixed by an edit to the same file.
229
+ const editPositions = new Map<number, Set<string>>();
230
+ for (let i = 0; i < tail.length; i++) {
231
+ const b = tail[i];
232
+ if (b.kind === 'tool_call' && FILE_EDIT_TOOLS.has(b.name)) {
233
+ const path = extractPath(b.args);
234
+ if (path) {
235
+ if (!editPositions.has(i)) editPositions.set(i, new Set());
236
+ editPositions.get(i)!.add(path);
237
+ }
238
+ }
239
+ }
240
+
241
+ // Apply priority tags, marking resolved tsc errors as [RESOLVED]
242
+ return items.slice(0, 8).map((item, idx) => {
243
+ const tailIdx = itemTailIndices[idx] ?? -1;
244
+ const file = extractTscFile(item);
245
+ const resolved = tailIdx >= 0 && file !== null && isTscResolved(file, tailIdx, editPositions);
246
+ if (!resolved) return priorityTag(item);
247
+ const tagged = priorityTag(item);
248
+ return tagged.replace(/^\[(ERROR|WARN)\]/, '[RESOLVED]');
249
+ });
250
+ };
251
+
252
+ const formatFileActivityFromUnified = (
253
+ data: import('./extract/shared-symbols').UnifiedExtractResult
254
+ ): string[] => {
255
+ const act = data.fileActivity;
256
+ const formatCategory = (label: string, set: Set<string>): string | null => {
257
+ if (set.size === 0) return null;
258
+ const arr = [...set];
259
+ const kept = arr.slice(0, 10);
260
+
261
+ if (arr.length > 10) {
262
+ const omitted = arr.slice(10);
263
+ return `${label}: ${kept.join(', ')}, +recall: ${omitted.join(', ')}`;
264
+ }
265
+ return `${label}: ${kept.join(', ')}`;
266
+ };
267
+
268
+ const lines: string[] = [];
269
+ const modLine = formatCategory('Modified', act.modified);
270
+ if (modLine) lines.push(modLine);
271
+ const createLine = formatCategory('Created', act.created);
272
+ if (createLine) lines.push(createLine);
273
+ const readLine = formatCategory('Read', act.read);
274
+ if (readLine) lines.push(readLine);
275
+ return lines;
276
+ };
277
+
278
+ const formatTypeCatalogFromUnified = (
279
+ data: import('./extract/shared-symbols').UnifiedExtractResult
280
+ ): string[] => {
281
+ const catalog = data.typeCatalog;
282
+ if (catalog.length === 0) return [];
283
+ const lines: string[] = [];
284
+ let totalSigs = 0;
285
+ const MAX_TOTAL_SIGS = 30;
286
+
287
+ const omittedFiles: string[] = [];
288
+ for (let i = 0; i < catalog.length; i++) {
289
+ const entry = catalog[i];
290
+ if (totalSigs >= MAX_TOTAL_SIGS) {
291
+ omittedFiles.push(entry.file);
292
+ continue;
293
+ }
294
+ lines.push(`${entry.file}:`);
295
+ for (const sig of entry.signatures) {
296
+ if (totalSigs >= MAX_TOTAL_SIGS) break;
297
+ lines.push(` ${sig}`);
298
+ totalSigs++;
299
+ }
300
+ }
301
+ if (omittedFiles.length > 0) {
302
+ lines.push(`(${omittedFiles.length} more files with signatures omitted)`);
303
+ }
304
+
305
+ return lines;
306
+ };
307
+
308
+ export const buildSections = (input: BuildSectionsInput): SectionData => {
309
+ const { blocks } = input;
310
+ // Build tool-call → tool-result look-ahead index once, share across extractors.
311
+ const tri = input.toolResultIndex ?? buildToolResultIndex(blocks);
312
+
313
+ // Single-pass file and symbol extraction — replaces the triple-redundant
314
+ // scan that extractFiles / extractSymbolChanges / extractTypeCatalog each
315
+ // performed independently, each re-scanning the same tool results with
316
+ // overlapping regex patterns.
317
+ const fileAndSymbols = extractFileAndSymbolData(blocks, tri);
318
+
319
+ const briefSections = buildBriefSections(blocks);
320
+ const sessionGoal = extractGoals(blocks);
321
+ const userPreferences = dedupPreferencesAgainstGoals(extractPreferences(blocks), sessionGoal);
322
+
323
+ const turnSummaries = identifyTurns(blocks).map((t) => t.summary);
324
+ const outstandingContext = extractOutstandingContext(blocks);
325
+
326
+ const result: SectionData = {
327
+ sessionGoal,
328
+ outstandingContext,
329
+ filesAndChanges: formatFileActivityFromUnified(fileAndSymbols),
330
+ commits: formatCommits(extractCommits(blocks)),
331
+ userPreferences,
332
+ typeCatalog: formatTypeCatalogFromUnified(fileAndSymbols),
333
+ symbolChanges: fileAndSymbols.symbolChanges,
334
+ turnSummaries,
335
+ briefTranscript: stringifyBrief(briefSections),
336
+ transcriptEntries: sectionsToTranscript(briefSections),
337
+ };
338
+
339
+ return result;
340
+ };
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Shared stop words and key refinement for causal breadcrumb extraction.
3
+ *
4
+ * Used by both brief.ts (turn summary key generation) and format.ts
5
+ * (breadcrumb extraction from capped lines) to ensure consistent key output.
6
+ */
7
+
8
+ // Stop words filtered out when building breadcrumb keys.
9
+ // These carry no causal signal and would bloat the key.
10
+ export const KEY_STOPS = new Set([
11
+ // Articles, pronouns, demonstratives
12
+ 'the',
13
+ 'a',
14
+ 'an',
15
+ 'this',
16
+ 'that',
17
+ 'these',
18
+ 'those',
19
+ 'it',
20
+ 'its',
21
+ // Be-verbs, auxiliaries
22
+ 'is',
23
+ 'was',
24
+ 'are',
25
+ 'were',
26
+ 'been',
27
+ 'being',
28
+ 'has',
29
+ 'have',
30
+ 'had',
31
+ 'does',
32
+ 'do',
33
+ 'did',
34
+ 'will',
35
+ 'would',
36
+ 'could',
37
+ 'should',
38
+ 'may',
39
+ 'might',
40
+ 'shall',
41
+ 'can',
42
+ // Prepositions
43
+ 'to',
44
+ 'of',
45
+ 'in',
46
+ 'for',
47
+ 'on',
48
+ 'with',
49
+ 'at',
50
+ 'by',
51
+ 'from',
52
+ 'as',
53
+ 'into',
54
+ 'through',
55
+ 'before',
56
+ 'after',
57
+ 'above',
58
+ 'below',
59
+ // Conjunctions, adverbs
60
+ 'and',
61
+ 'but',
62
+ 'or',
63
+ 'not',
64
+ 'so',
65
+ 'yet',
66
+ 'before',
67
+ 'after',
68
+ 'when',
69
+ 'where',
70
+ 'while',
71
+ 'during',
72
+ // Contextual filler that doesn't carry causal signal
73
+ 'against',
74
+ 'into',
75
+ 'around',
76
+ 'before',
77
+ 'all',
78
+ // Marker remnant words: verbs from resolution markers that survive
79
+ // the extraction because the fragment starts right after the marker.
80
+ // E.g. "added session check" → we want "session-check", not "added-session-check".
81
+ 'added',
82
+ 'adding',
83
+ 'created',
84
+ 'creating',
85
+ 'applied',
86
+ 'applying',
87
+ 'inserted',
88
+ 'inserting',
89
+ 'implemented',
90
+ 'implementing',
91
+ 'introduced',
92
+ 'introducing',
93
+ 'using',
94
+ 'swapped',
95
+ 'swapping',
96
+ 'split',
97
+ 'splitting',
98
+ 'migrated',
99
+ 'migrating',
100
+ 'isolated',
101
+ 'isolating',
102
+ 'removed',
103
+ 'removing',
104
+ 'extracted',
105
+ 'extracting',
106
+ 'replaced',
107
+ 'replacing',
108
+ 'refactored',
109
+ 'refactoring',
110
+ 'wrapped',
111
+ 'wrapping',
112
+ 'guarded',
113
+ 'guarding',
114
+ 'moved',
115
+ 'moving',
116
+ 'updated',
117
+ 'configured',
118
+ 'enabled',
119
+ 'switched',
120
+ ]);
121
+
122
+ /** Maximum content words in a breadcrumb key. */
123
+ const KEY_MAX_WORDS = 3;
124
+
125
+ /**
126
+ * Build a compact breadcrumb key from a raw fragment.
127
+ * Takes up to KEY_MAX_WORDS content words (skipping stop words), joined with "-".
128
+ */
129
+ export const refineBreadcrumbKey = (fragment: string, maxChars = 40): string => {
130
+ const words = fragment.split(/\s+/);
131
+ const content: string[] = [];
132
+ for (const w of words) {
133
+ if (KEY_STOPS.has(w.toLowerCase())) continue;
134
+ content.push(w);
135
+ if (content.length >= KEY_MAX_WORDS) break;
136
+ }
137
+ return content.join('-') || fragment.slice(0, maxChars);
138
+ };
@@ -0,0 +1,68 @@
1
+ import type { Message } from '@earendil-works/pi-ai';
2
+
3
+ export const clip = (text: string, max = 200): string => {
4
+ if (text.length <= max) return text;
5
+ // Try to cut at a word boundary
6
+ const cut = text.lastIndexOf(' ', max);
7
+ let end = cut > max * 0.6 ? cut : max;
8
+ // Avoid splitting a surrogate pair
9
+ if (end > 0 && end < text.length) {
10
+ const code = text.charCodeAt(end - 1);
11
+ if (code >= 0xd800 && code <= 0xdbff) end--;
12
+ }
13
+ return text.slice(0, end);
14
+ };
15
+
16
+ /**
17
+ * Clip text to last sentence boundary at or before `max` chars.
18
+ * Falls back to word boundary (clip()) if no sentence end is found in the
19
+ * acceptable range. Trailing whitespace stripped.
20
+ */
21
+ export const clipSentence = (text: string, max = 200): string => {
22
+ if (text.length <= max) return text;
23
+ // Look for sentence terminators followed by space/newline within [max*0.5, max]
24
+ const window = text.slice(0, max);
25
+ const matches = [...window.matchAll(/[.!?](?:\s|$)/g)];
26
+ if (matches.length > 0) {
27
+ const last = matches[matches.length - 1];
28
+ const end = (last.index ?? 0) + 1; // include the punctuation
29
+ if (end >= max * 0.5) return text.slice(0, end);
30
+ }
31
+ return clip(text, max);
32
+ };
33
+
34
+ export const nonEmptyLines = (text: string): string[] =>
35
+ text
36
+ .split('\n')
37
+ .map((line) => line.trim())
38
+ .filter(Boolean);
39
+
40
+ export const firstLine = (text: string, max = 200): string => clip(text.split('\n')[0] ?? '', max);
41
+
42
+ const textParts = (content: Message['content']): string[] => {
43
+ if (!content) return [];
44
+ if (typeof content === 'string') return [content];
45
+ return content.filter((part) => part.type === 'text').map((part) => part.text);
46
+ };
47
+
48
+ export const textOf = (content: Message['content']): string => textParts(content).join('\n');
49
+
50
+ const thinkingParts = (content: Message['content']): string[] => {
51
+ if (!content) return [];
52
+ if (typeof content === 'string') return [];
53
+ return content.filter((part) => part.type === 'thinking').map((part) => part.thinking ?? '');
54
+ };
55
+
56
+ export const thinkingOf = (content: Message['content']): string =>
57
+ thinkingParts(content).join('\n');
58
+
59
+ /** Extract a snippet of ~`radius` chars around the first match of `term` in `text`. */
60
+ const snippet = (text: string, term: string, radius = 60): string | null => {
61
+ const idx = text.toLowerCase().indexOf(term.toLowerCase());
62
+ if (idx === -1) return null;
63
+ const start = Math.max(0, idx - radius);
64
+ const end = Math.min(text.length, idx + term.length + radius);
65
+ const prefix = start > 0 ? '...' : '';
66
+ const suffix = end < text.length ? '...' : '';
67
+ return `${prefix}${text.slice(start, end)}${suffix}`;
68
+ };
@@ -0,0 +1,78 @@
1
+ import type { NormalizedBlock } from '../types';
2
+
3
+ interface CommitInfo {
4
+ hash?: string;
5
+ message: string;
6
+ }
7
+
8
+ const COMMIT_MSG_RE =
9
+ /git\s+commit[^\n]*?-m\s+(?:"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)'|\$?'((?:[^'\\]|\\.)*)')/;
10
+ // Match short hash from git output: "[branch hash]" or "main hash" or 7-12 hex
11
+ const HASH_RE = /\b([0-9a-f]{7,12})\b/;
12
+
13
+ const firstLineOf = (text: string): string => {
14
+ const line = text.split(/\\n|\n/)[0] ?? '';
15
+ return line.trim();
16
+ };
17
+
18
+ const cleanMessage = (msg: string): string => msg.replace(/\\"/g, '"').replace(/\\'/g, "'").trim();
19
+
20
+ /**
21
+ * Extract git commits from bash tool calls (`git commit -m "..."`) and pair
22
+ * with hash from the immediately following tool_result.
23
+ */
24
+ export const extractCommits = (blocks: NormalizedBlock[]): CommitInfo[] => {
25
+ const commits: CommitInfo[] = [];
26
+
27
+ for (let i = 0; i < blocks.length; i++) {
28
+ const b = blocks[i];
29
+ if (b.kind !== 'tool_call' || b.name !== 'bash') continue;
30
+ const cmd = typeof b.args.command === 'string' ? b.args.command : '';
31
+ if (!/\bgit\s+commit\b/.test(cmd)) continue;
32
+ const m = cmd.match(COMMIT_MSG_RE);
33
+ if (!m) continue;
34
+ const message = firstLineOf(cleanMessage(m[1] ?? m[2] ?? m[3] ?? ''));
35
+ if (!message) continue;
36
+
37
+ let hash: string | undefined;
38
+ // Look at next tool_result for hash
39
+ for (let j = i + 1; j < Math.min(blocks.length, i + 3); j++) {
40
+ const r = blocks[j];
41
+ if (r.kind !== 'tool_result') continue;
42
+ // Common git commit output: `[branch <hash>] message` or `<branch> <hash>..<hash>`
43
+ const bracket = r.text.match(/\[\S+\s+([0-9a-f]{7,12})\]/);
44
+ if (bracket) {
45
+ hash = bracket[1];
46
+ break;
47
+ }
48
+ const range = r.text.match(/\b([0-9a-f]{7,12})\.\.([0-9a-f]{7,12})\b/);
49
+ if (range) {
50
+ hash = range[2];
51
+ break;
52
+ }
53
+ const plain = r.text.match(HASH_RE);
54
+ if (plain) {
55
+ hash = plain[1];
56
+ break;
57
+ }
58
+ }
59
+
60
+ // Dedup by message+hash
61
+ const key = `${hash ?? ''}::${message}`;
62
+ if (!commits.some((c) => `${c.hash ?? ''}::${c.message}` === key)) {
63
+ commits.push({ hash, message });
64
+ }
65
+ }
66
+
67
+ return commits;
68
+ };
69
+
70
+ export const formatCommits = (commits: CommitInfo[], limit = 8): string[] => {
71
+ const lines: string[] = [];
72
+ const items = commits.slice(-limit); // keep most recent
73
+ for (const c of items) {
74
+ const prefix = c.hash ? `${c.hash}: ` : '';
75
+ lines.push(`${prefix}${c.message}`);
76
+ }
77
+ return lines;
78
+ };