@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.
- package/CHANGELOG.md +120 -0
- package/LICENSE +21 -0
- package/README.md +341 -0
- package/media/demo.mp4 +0 -0
- package/media/screenshot.png +0 -0
- package/package.json +87 -0
- package/src/compaction/brief.ts +841 -0
- package/src/compaction/build-sections.ts +340 -0
- package/src/compaction/causal-keys.ts +138 -0
- package/src/compaction/content.ts +68 -0
- package/src/compaction/extract/commits.ts +78 -0
- package/src/compaction/extract/goals.ts +79 -0
- package/src/compaction/extract/preferences.ts +52 -0
- package/src/compaction/extract/shared-symbols.ts +376 -0
- package/src/compaction/filter-noise.ts +47 -0
- package/src/compaction/format.ts +89 -0
- package/src/compaction/index.ts +38 -0
- package/src/compaction/normalize.ts +73 -0
- package/src/compaction/sanitize.ts +5 -0
- package/src/compaction/sections.ts +19 -0
- package/src/compaction/skill-collapse.ts +35 -0
- package/src/compaction/tool-args.ts +14 -0
- package/src/compaction/types.ts +26 -0
- package/src/core/analyzer.ts +58 -0
- package/src/core/index.ts +8 -0
- package/src/core/inference.ts +77 -0
- package/src/core/prompt-builder.ts +137 -0
- package/src/core/prompt-loader.ts +125 -0
- package/src/core/reframe.ts +27 -0
- package/src/fabric-provider.ts +115 -0
- package/src/global-config.ts +65 -0
- package/src/index.ts +514 -0
- package/src/session/client.ts +46 -0
- package/src/session/response-parser.ts +37 -0
- package/src/session/supervisor-session.ts +102 -0
- package/src/state/manager.ts +133 -0
- package/src/state/mid-run-signals.ts +103 -0
- package/src/state/patterns.ts +82 -0
- package/src/state/reframe.ts +27 -0
- package/src/subagent-detector.ts +94 -0
- package/src/types.ts +42 -0
- package/src/ui/animations.ts +95 -0
- package/src/ui/model-picker.ts +72 -0
- package/src/ui/model-settings-selector.ts +440 -0
- package/src/ui/model-sort.ts +101 -0
- package/src/ui/renderer.ts +314 -0
- package/src/ui/types.ts +48 -0
- package/tests/compaction.test.ts +507 -0
- package/tests/engine.test.ts +622 -0
- package/tests/ephemeral-supervision.test.ts +347 -0
- package/tests/fabric-provider.test.ts +55 -0
- package/tests/full-fidelity-snapshot.test.ts +250 -0
- package/tests/global-config.test.ts +74 -0
- package/tests/model-sort.test.ts +157 -0
- package/tests/parsing.test.ts +303 -0
- package/tests/state.test.ts +474 -0
- package/tests/status-widget.test.ts +539 -0
- package/tests/subagent-detector.test.ts +191 -0
- package/tests/supervise-command.test.ts +363 -0
- package/tests/supervise-model-command.test.ts +184 -0
- package/tsconfig.json +14 -0
- 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
|
+
};
|