@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,79 @@
|
|
|
1
|
+
import type { NormalizedBlock } from '../types';
|
|
2
|
+
import { nonEmptyLines, clip } from '../content';
|
|
3
|
+
import { collapseSkillLines } from '../skill-collapse';
|
|
4
|
+
|
|
5
|
+
const SCOPE_CHANGE_RE =
|
|
6
|
+
/\b(instead|actually|change of plan|forget that|new task|switch to|now I want|pivot|let'?s do|stop .* and)\b/i;
|
|
7
|
+
|
|
8
|
+
const TASK_RE =
|
|
9
|
+
/\b(fix|implement|add|create|build|refactor|debug|investigate|update|remove|delete|migrate|deploy|test|write|set up)\b/i;
|
|
10
|
+
|
|
11
|
+
const NOISE_SHORT_RE = /^(ok|yes|no|sure|yeah|yep|go|hi|hey|thx|thanks|ok\b.*|y|n|k)\s*[.!?]*$/i;
|
|
12
|
+
|
|
13
|
+
// Reject lines that are clearly not user goals (pasted output, code, paths, tool dumps)
|
|
14
|
+
// or meta-prompt boilerplate (command templates like `/issues` that start with "For each issue:"
|
|
15
|
+
// followed by numbered "Read the issue in full..." steps).
|
|
16
|
+
const NON_GOAL_RE =
|
|
17
|
+
/^\s*[\[│├└─╭╰]|```|^\s*(=[A-Z]+\(|function |const |let |var |import |export |class )|^(https?:|file:|\/[A-Za-z])|\\n|^\s*For each\b|\bin full\b[^\n]*\b(comments|issue|issues|PRs?|linked)\b/;
|
|
18
|
+
|
|
19
|
+
// Signals that the rest of the user message is a command template (e.g. /issues),
|
|
20
|
+
// in which case we should stop collecting goals at the signal line.
|
|
21
|
+
const TEMPLATE_SIGNAL_RE =
|
|
22
|
+
/^\s*(For each\b|Do NOT implement\b|Analyze and propose\b|If Task\/context\b|Output:\s*$)/i;
|
|
23
|
+
|
|
24
|
+
const truncateAtTemplate = (lines: string[]): string[] => {
|
|
25
|
+
const idx = lines.findIndex((l) => TEMPLATE_SIGNAL_RE.test(l));
|
|
26
|
+
return idx >= 0 ? lines.slice(0, idx) : lines;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const stripLeadingBullet = (line: string): string =>
|
|
30
|
+
line.replace(/^\s*(?:[-*+]|\d+\.)\s+/, '').trim();
|
|
31
|
+
|
|
32
|
+
const MAX_GOAL_CHARS = 200;
|
|
33
|
+
|
|
34
|
+
const isSubstantiveGoal = (text: string): boolean => {
|
|
35
|
+
const t = text.trim();
|
|
36
|
+
if (t.length <= 5) return false;
|
|
37
|
+
if (t.length > MAX_GOAL_CHARS) return false;
|
|
38
|
+
if (NOISE_SHORT_RE.test(t)) return false;
|
|
39
|
+
if (NON_GOAL_RE.test(t)) return false;
|
|
40
|
+
return true;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// Test scope-change / task intent only on the leading portion of a user block
|
|
44
|
+
// so that pasted outputs below the actual instruction do not trigger matches.
|
|
45
|
+
const LEADING_CHARS = 200;
|
|
46
|
+
|
|
47
|
+
export const extractGoals = (blocks: NormalizedBlock[]): string[] => {
|
|
48
|
+
const goals: string[] = [];
|
|
49
|
+
let latestScopeChange: string[] | null = null;
|
|
50
|
+
|
|
51
|
+
for (const b of blocks) {
|
|
52
|
+
if (b.kind !== 'user') continue;
|
|
53
|
+
const rawLines = nonEmptyLines(b.text);
|
|
54
|
+
const truncated = truncateAtTemplate(rawLines);
|
|
55
|
+
const lines = collapseSkillLines(truncated.filter(isSubstantiveGoal))
|
|
56
|
+
.map(stripLeadingBullet)
|
|
57
|
+
.filter((l) => l.length > 5);
|
|
58
|
+
if (lines.length === 0) continue;
|
|
59
|
+
|
|
60
|
+
if (goals.length === 0) {
|
|
61
|
+
goals.push(...lines.slice(0, 6));
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const leading = b.text.slice(0, LEADING_CHARS);
|
|
66
|
+
if (SCOPE_CHANGE_RE.test(leading)) {
|
|
67
|
+
latestScopeChange = lines.slice(0, 3).map((l) => clip(l, MAX_GOAL_CHARS));
|
|
68
|
+
} else if (TASK_RE.test(leading) && lines[0].length > 15) {
|
|
69
|
+
latestScopeChange = lines.slice(0, 2).map((l) => clip(l, MAX_GOAL_CHARS));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Only emit the [Scope change] marker when we actually captured bullets.
|
|
74
|
+
if (latestScopeChange && latestScopeChange.length > 0) {
|
|
75
|
+
goals.push('[Scope change]', ...latestScopeChange);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return goals.slice(0, 8);
|
|
79
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { NormalizedBlock } from '../types';
|
|
2
|
+
import { clip, nonEmptyLines } from '../content';
|
|
3
|
+
|
|
4
|
+
// Tightened patterns: require a clear preference construction, not bare keywords.
|
|
5
|
+
const PREF_PATTERNS = [
|
|
6
|
+
/\bprefer(?:s|red|ring)?\s+\w/i,
|
|
7
|
+
/\bdon'?t want\b/i,
|
|
8
|
+
/\balways (?:use|do|run|prefer|keep|make|format|write|add|set|put|prefix|start|include|append)\b/i,
|
|
9
|
+
/\bnever (?:use|do|run|push|commit|write|ignore|add|set|put|remove|delete|include|deploy)\b/i,
|
|
10
|
+
/\bplease (?:use|avoid|keep|make|don'?t|do not|format|write)\b/i,
|
|
11
|
+
/\b(?:style|format|language|naming)\s*[:=]\s*\S/i,
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
export const extractPreferences = (blocks: NormalizedBlock[]): string[] => {
|
|
15
|
+
const prefs: string[] = [];
|
|
16
|
+
const seen = new Set<string>();
|
|
17
|
+
|
|
18
|
+
for (const b of blocks) {
|
|
19
|
+
if (b.kind !== 'user') continue;
|
|
20
|
+
|
|
21
|
+
let perBlock = 0;
|
|
22
|
+
for (const line of nonEmptyLines(b.text)) {
|
|
23
|
+
const trimmed = line.trim();
|
|
24
|
+
if (!trimmed || trimmed.length < 5) continue;
|
|
25
|
+
if (trimmed.length > 200) continue;
|
|
26
|
+
// Reject questions.
|
|
27
|
+
if (trimmed.endsWith('?') || trimmed.includes('?...')) continue;
|
|
28
|
+
if (!PREF_PATTERNS.some((p) => p.test(trimmed))) continue;
|
|
29
|
+
|
|
30
|
+
const clipped = clip(trimmed, 200);
|
|
31
|
+
const key = clipped.toLowerCase();
|
|
32
|
+
if (seen.has(key)) continue;
|
|
33
|
+
seen.add(key);
|
|
34
|
+
prefs.push(clipped);
|
|
35
|
+
|
|
36
|
+
// Cap per user block to avoid pasting long rule lists as many prefs.
|
|
37
|
+
if (++perBlock >= 1) break;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return prefs.slice(0, 10);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Remove preferences that duplicate goals (case-insensitive, trimmed).
|
|
46
|
+
* Called by `buildSections` so that the two sections do not overlap.
|
|
47
|
+
*/
|
|
48
|
+
export const dedupPreferencesAgainstGoals = (prefs: string[], goals: string[]): string[] => {
|
|
49
|
+
const norm = (s: string) => s.trim().toLowerCase();
|
|
50
|
+
const goalSet = new Set(goals.map(norm));
|
|
51
|
+
return prefs.filter((p) => !goalSet.has(norm(p)));
|
|
52
|
+
};
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
import type { NormalizedBlock, ToolResultIndex } from '../types';
|
|
2
|
+
import { extractPath } from '../tool-args';
|
|
3
|
+
|
|
4
|
+
const FILE_WRITE_TOOLS = new Set([
|
|
5
|
+
'Edit',
|
|
6
|
+
'Write',
|
|
7
|
+
'edit',
|
|
8
|
+
'write',
|
|
9
|
+
'edit_file',
|
|
10
|
+
'write_file',
|
|
11
|
+
'MultiEdit',
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
const FILE_READ_TOOLS = new Set(['Read', 'read', 'read_file', 'View']);
|
|
15
|
+
|
|
16
|
+
const FILE_CREATE_TOOLS = new Set(['Write', 'write', 'write_file']);
|
|
17
|
+
|
|
18
|
+
// Declaration regexes — consolidated from files.ts, symbol-changes.ts, type-catalog.ts
|
|
19
|
+
// Order: more specific patterns first, generic fallbacks last.
|
|
20
|
+
|
|
21
|
+
const TS_EXPORT_DECL_RE =
|
|
22
|
+
/^\s*export\s+(?:default\s+)?(?:async\s+)?(?:function|class|type|interface|const|let|enum)\s+(\w+)/;
|
|
23
|
+
const TS_TYPE_DECL_RE = /^\s*(?:export\s+)?(?:type|interface)\s+(\w+)/;
|
|
24
|
+
const TS_EXPORT_SIG_RE =
|
|
25
|
+
/^\s*export\s+(?:default\s+)?(?:async\s+)?(?:function|class|type|interface|const|let|enum)\s+\w+[^;{]*[;{]?/;
|
|
26
|
+
|
|
27
|
+
const RUST_DECL_RE =
|
|
28
|
+
/^\s*(?:pub(?:\s*\([^)]*\))?\s+)?(?:fn|struct|enum|trait|type|const|union|var)\s+(\w+)/;
|
|
29
|
+
const RUST_IMPL_RE =
|
|
30
|
+
/^\s*(?:pub(?:\s*\([^)]*\))?\s+)?impl\s+(?:<[^>]+>\s+)?(\w+)(?:\s+for\s+(\w+))?/;
|
|
31
|
+
const RUST_SIG_RE = /^\s*pub\s+(?:async\s+)?(?:fn|struct|enum|trait|type)\s+\w+/;
|
|
32
|
+
|
|
33
|
+
const ELIXIR_DEF_RE = /^\s*def(?:p|macro|macrop|guard|guardp)?\s+(\w+)/;
|
|
34
|
+
const ELIXIR_MODULE_RE = /^\s*defmodule\s+(\w+)/;
|
|
35
|
+
const ELIXIR_SPECIAL_RE = /^\s*def(?:struct|protocol|impl)\s+(\w+)/;
|
|
36
|
+
|
|
37
|
+
const JAVA_TYPE_RE =
|
|
38
|
+
/^\s*(?:(?:public|private|protected)\s+)?(?:abstract\s+|static\s+|final\s+|sealed\s+)?(?:class|interface|enum|@interface|record)\s+(\w+)/;
|
|
39
|
+
const JAVA_METHOD_RE =
|
|
40
|
+
/^\s*(?:public|protected)\s+(?:static\s+|abstract\s+|final\s+)?(?:\S+(?:\s*\[\])?\s+)(\w+)\s*\(/;
|
|
41
|
+
|
|
42
|
+
const C_TYPE_RE = /^\s*(?:typedef\s+)?(?:struct|class|enum|union)\s+(\w+)/;
|
|
43
|
+
const C_FUNC_RE =
|
|
44
|
+
/^\s*(?!func\b)(?:(?:static|extern|inline|virtual)\s+)?[\w][\w:*&\s]*?(\b\w+)\s*\(/;
|
|
45
|
+
|
|
46
|
+
const RUBY_DEF_RE = /^\s*def\s+(?:self\.)?(\w+)/;
|
|
47
|
+
const RUBY_TYPE_RE = /^\s*(?:class|module)\s+(\w+)/;
|
|
48
|
+
|
|
49
|
+
const PY_DECL_RE = /^\s*(?:async\s+)?def\s+(\w+)|^\s*class\s+(\w+)/;
|
|
50
|
+
const PY_SIG_RE = /^\s*(?:async\s+)?(?:def|class)\s+\w+\s*(?:\([^)]*\))?/;
|
|
51
|
+
|
|
52
|
+
const GO_DECL_RE = /^\s*func\s+(?:\(\w+\s+\*?\w+\)\s+)?(\w+)/;
|
|
53
|
+
const GO_SIG_RE = /^\s*func\s+(?:\(\w+\s+\*?\w+\)\s+)?\w+\s*(?:\([^)]*\))?\s*(?:\([^)]*\))?/;
|
|
54
|
+
|
|
55
|
+
interface SymbolInfo {
|
|
56
|
+
/** Simple declaration name (used by files.ts, symbol-changes.ts) */
|
|
57
|
+
name: string;
|
|
58
|
+
/** Declaration kind */
|
|
59
|
+
kind: 'function' | 'type' | 'class' | 'variable' | 'unknown';
|
|
60
|
+
/** Full signature line (used by type-catalog.ts) */
|
|
61
|
+
signature?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface ToolCallSymbols {
|
|
65
|
+
/** Symbols found in the tool_result text */
|
|
66
|
+
resultSymbols: SymbolInfo[];
|
|
67
|
+
/** Symbols found in Edit/Write args (newText/content) */
|
|
68
|
+
argSymbols: SymbolInfo[];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Fast screening regex: rejects lines that can't start any declaration.
|
|
72
|
+
// Avoids running the full 15-regex cascade on body code / comments / blank lines.
|
|
73
|
+
const DECL_SCREEN_RE =
|
|
74
|
+
/^\s*(?:export|pub|func|def|class|type|interface|async|abstract|static|public|private|protected|struct|enum|trait|impl|module|const|fn|sealed|record|typedef|union|virtual|extern|inline)/;
|
|
75
|
+
|
|
76
|
+
const parseDeclName = (line: string): { name: string; kind: SymbolInfo['kind'] } | null => {
|
|
77
|
+
// Quick reject: lines that can't start any declaration keyword
|
|
78
|
+
if (!DECL_SCREEN_RE.test(line)) return null;
|
|
79
|
+
|
|
80
|
+
let m = line.match(TS_EXPORT_DECL_RE);
|
|
81
|
+
if (m) {
|
|
82
|
+
const kind = line.includes('function')
|
|
83
|
+
? 'function'
|
|
84
|
+
: line.includes('class')
|
|
85
|
+
? 'class'
|
|
86
|
+
: line.includes('type')
|
|
87
|
+
? 'type'
|
|
88
|
+
: line.includes('interface')
|
|
89
|
+
? 'type'
|
|
90
|
+
: line.includes('enum')
|
|
91
|
+
? 'variable'
|
|
92
|
+
: 'variable';
|
|
93
|
+
return { name: m[1], kind };
|
|
94
|
+
}
|
|
95
|
+
m = line.match(TS_TYPE_DECL_RE);
|
|
96
|
+
if (m) return { name: m[1], kind: 'type' };
|
|
97
|
+
m = line.match(RUST_DECL_RE);
|
|
98
|
+
if (m) return { name: m[1], kind: 'function' };
|
|
99
|
+
m = line.match(RUST_IMPL_RE);
|
|
100
|
+
if (m) return { name: m[1], kind: 'class' };
|
|
101
|
+
m = line.match(ELIXIR_MODULE_RE);
|
|
102
|
+
if (m) return { name: m[1], kind: 'class' };
|
|
103
|
+
m = line.match(ELIXIR_SPECIAL_RE);
|
|
104
|
+
if (m) return { name: m[1], kind: 'class' };
|
|
105
|
+
m = line.match(ELIXIR_DEF_RE);
|
|
106
|
+
if (m) return { name: m[1], kind: 'function' };
|
|
107
|
+
m = line.match(JAVA_TYPE_RE);
|
|
108
|
+
if (m) return { name: m[1], kind: 'class' };
|
|
109
|
+
m = line.match(JAVA_METHOD_RE);
|
|
110
|
+
if (m) return { name: m[1], kind: 'function' };
|
|
111
|
+
m = line.match(C_TYPE_RE);
|
|
112
|
+
if (m) return { name: m[1], kind: 'class' };
|
|
113
|
+
m = line.match(C_FUNC_RE);
|
|
114
|
+
if (m) return { name: m[1], kind: 'function' };
|
|
115
|
+
m = line.match(RUBY_TYPE_RE);
|
|
116
|
+
if (m) return { name: m[1], kind: 'class' };
|
|
117
|
+
m = line.match(RUBY_DEF_RE);
|
|
118
|
+
if (m) return { name: m[1], kind: 'function' };
|
|
119
|
+
m = line.match(PY_DECL_RE);
|
|
120
|
+
if (m) return { name: m[1] || m[2], kind: m[2] ? 'class' : 'function' };
|
|
121
|
+
m = line.match(GO_DECL_RE);
|
|
122
|
+
if (m && m[1][0] === m[1][0].toUpperCase()) return { name: m[1], kind: 'function' };
|
|
123
|
+
return null;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const parseSignature = (line: string): string | null => {
|
|
127
|
+
if (TS_EXPORT_SIG_RE.test(line)) return line.trim();
|
|
128
|
+
if (
|
|
129
|
+
PY_SIG_RE.test(line) &&
|
|
130
|
+
!line.trim().startsWith('def _') &&
|
|
131
|
+
!line.trim().startsWith('class _')
|
|
132
|
+
)
|
|
133
|
+
return line.trim();
|
|
134
|
+
if (GO_SIG_RE.test(line)) {
|
|
135
|
+
const nameMatch = line.match(/func\s+(?:\(\w+\s+\*?\w+\)\s+)?(\w+)/);
|
|
136
|
+
if (nameMatch && nameMatch[1] && nameMatch[1][0] === nameMatch[1][0].toUpperCase())
|
|
137
|
+
return line.trim();
|
|
138
|
+
}
|
|
139
|
+
if (RUST_SIG_RE.test(line)) return line.trim();
|
|
140
|
+
return null;
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Line-by-line iteration over text using indexOf("\n") instead of split().
|
|
145
|
+
* Avoids allocating an intermediate string array — for a 300-line, 12KB
|
|
146
|
+
* tool result this saves ~30μs/scan vs split(). Over 600 tool results
|
|
147
|
+
* in a large session, that's ~18ms reclaimed.
|
|
148
|
+
*/
|
|
149
|
+
const eachLine = function* (text: string, maxLines: number): Generator<string> {
|
|
150
|
+
let pos = 0;
|
|
151
|
+
let count = 0;
|
|
152
|
+
const len = text.length;
|
|
153
|
+
while (pos < len && count < maxLines) {
|
|
154
|
+
const nl = text.indexOf('\n', pos);
|
|
155
|
+
if (nl === -1) {
|
|
156
|
+
yield text.slice(pos);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
yield text.slice(pos, nl);
|
|
160
|
+
pos = nl + 1;
|
|
161
|
+
count++;
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const extractSymbolsFromText = (
|
|
166
|
+
text: string,
|
|
167
|
+
maxLines: number,
|
|
168
|
+
includeSigs: boolean
|
|
169
|
+
): SymbolInfo[] => {
|
|
170
|
+
const names: SymbolInfo[] = [];
|
|
171
|
+
const seen = new Set<string>();
|
|
172
|
+
for (const line of eachLine(text, maxLines)) {
|
|
173
|
+
const decl = parseDeclName(line);
|
|
174
|
+
if (decl && !seen.has(decl.name)) {
|
|
175
|
+
seen.add(decl.name);
|
|
176
|
+
const sig = includeSigs ? parseSignature(line) : undefined;
|
|
177
|
+
names.push({ name: decl.name, kind: decl.kind, signature: sig ?? undefined });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return names;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
interface FileActivity {
|
|
184
|
+
read: Set<string>;
|
|
185
|
+
modified: Set<string>;
|
|
186
|
+
created: Set<string>;
|
|
187
|
+
symbols: Map<string, string[]>;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
interface ExportSig {
|
|
191
|
+
file: string;
|
|
192
|
+
signatures: string[];
|
|
193
|
+
modified: boolean;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export interface SymbolRef {
|
|
197
|
+
name: string;
|
|
198
|
+
file: string;
|
|
199
|
+
kind: 'function' | 'type' | 'class' | 'variable' | 'unknown';
|
|
200
|
+
access: 'modified' | 'read';
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export interface UnifiedExtractResult {
|
|
204
|
+
fileActivity: FileActivity;
|
|
205
|
+
typeCatalog: ExportSig[];
|
|
206
|
+
symbolChanges: SymbolRef[];
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Single-pass extraction of all file/symbol information from tool calls.
|
|
211
|
+
*
|
|
212
|
+
* Replaces the triple-redundant scan performed by:
|
|
213
|
+
* - extractFiles() — path collection + 200-line symbol scan
|
|
214
|
+
* - extractSymbolChanges() — 300-line symbol scan
|
|
215
|
+
* - extractTypeCatalog() — 150-line signature scan
|
|
216
|
+
*
|
|
217
|
+
* All three scanned the same tool_result content with overlapping regex
|
|
218
|
+
* patterns. This unified pass does it once and returns all three datasets.
|
|
219
|
+
*/
|
|
220
|
+
export const extractFileAndSymbolData = (
|
|
221
|
+
blocks: NormalizedBlock[],
|
|
222
|
+
tri?: ToolResultIndex
|
|
223
|
+
): UnifiedExtractResult => {
|
|
224
|
+
const read = new Set<string>();
|
|
225
|
+
const modified = new Set<string>();
|
|
226
|
+
const created = new Set<string>();
|
|
227
|
+
// Parallel dedup set for symbols Map — avoids O(n) Array.includes()
|
|
228
|
+
// on symbol arrays that can grow to 200+ entries per file.
|
|
229
|
+
const symbols = new Map<string, string[]>();
|
|
230
|
+
const symbolsSeen = new Map<string, Set<string>>();
|
|
231
|
+
const symbolRefs: SymbolRef[] = [];
|
|
232
|
+
const refSeen = new Set<string>();
|
|
233
|
+
|
|
234
|
+
// Type catalog state
|
|
235
|
+
const fileSigs = new Map<string, { sigs: string[]; modified: boolean }>();
|
|
236
|
+
const fileOrder: string[] = [];
|
|
237
|
+
|
|
238
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
239
|
+
const b = blocks[i];
|
|
240
|
+
if (b.kind !== 'tool_call') continue;
|
|
241
|
+
const p = extractPath(b.args);
|
|
242
|
+
if (!p) continue;
|
|
243
|
+
|
|
244
|
+
const isRead = FILE_READ_TOOLS.has(b.name);
|
|
245
|
+
const isWrite = FILE_WRITE_TOOLS.has(b.name);
|
|
246
|
+
const isCreate = FILE_CREATE_TOOLS.has(b.name);
|
|
247
|
+
|
|
248
|
+
if (isRead) read.add(p);
|
|
249
|
+
if (isWrite) modified.add(p);
|
|
250
|
+
if (isCreate) created.add(p);
|
|
251
|
+
|
|
252
|
+
// Extract symbols from Edit/Write args
|
|
253
|
+
if (isWrite) {
|
|
254
|
+
const newText = (b.args.newText ?? b.args.new_text ?? b.args.content ?? '') as string;
|
|
255
|
+
if (newText && typeof newText === 'string') {
|
|
256
|
+
const syms = extractSymbolsFromText(newText, 100, true);
|
|
257
|
+
|
|
258
|
+
// File activity symbols
|
|
259
|
+
let seen = symbolsSeen.get(p);
|
|
260
|
+
if (!seen) {
|
|
261
|
+
seen = new Set();
|
|
262
|
+
symbolsSeen.set(p, seen);
|
|
263
|
+
}
|
|
264
|
+
if (!symbols.has(p)) symbols.set(p, []);
|
|
265
|
+
const existing = symbols.get(p)!;
|
|
266
|
+
for (const s of syms) {
|
|
267
|
+
if (!seen.has(s.name)) {
|
|
268
|
+
seen.add(s.name);
|
|
269
|
+
existing.push(s.name);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Type catalog
|
|
274
|
+
if (!fileSigs.has(p)) {
|
|
275
|
+
fileSigs.set(p, { sigs: [], modified: true });
|
|
276
|
+
fileOrder.push(p);
|
|
277
|
+
} else {
|
|
278
|
+
fileSigs.get(p)!.modified = true;
|
|
279
|
+
}
|
|
280
|
+
const sigs = fileSigs.get(p)!.sigs;
|
|
281
|
+
for (const s of syms) {
|
|
282
|
+
if (s.signature && !sigs.includes(s.signature)) sigs.push(s.signature);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Symbol changes
|
|
286
|
+
const access = 'modified' as const;
|
|
287
|
+
for (const s of syms) {
|
|
288
|
+
const key = `${s.name}@${p}`;
|
|
289
|
+
if (!refSeen.has(key)) {
|
|
290
|
+
refSeen.add(key);
|
|
291
|
+
symbolRefs.push({ name: s.name, file: p, kind: s.kind, access });
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Extract from tool_result (shared look-ahead via index)
|
|
298
|
+
if (isRead || isWrite) {
|
|
299
|
+
const r = tri
|
|
300
|
+
? tri.get(i)
|
|
301
|
+
: (() => {
|
|
302
|
+
for (let j = i + 1; j < Math.min(blocks.length, i + 4); j++) {
|
|
303
|
+
const b2 = blocks[j];
|
|
304
|
+
if (b2.kind === 'tool_result')
|
|
305
|
+
return b2 as Extract<NormalizedBlock, { kind: 'tool_result' }>;
|
|
306
|
+
}
|
|
307
|
+
return null;
|
|
308
|
+
})();
|
|
309
|
+
|
|
310
|
+
if (r && r.text && !r.isError) {
|
|
311
|
+
const resultText = r.text;
|
|
312
|
+
|
|
313
|
+
// Parse symbols once — 200 lines covers all three extractors' needs
|
|
314
|
+
const syms = extractSymbolsFromText(resultText, 200, true);
|
|
315
|
+
|
|
316
|
+
// File activity symbols
|
|
317
|
+
let seen = symbolsSeen.get(p);
|
|
318
|
+
if (!seen) {
|
|
319
|
+
seen = new Set();
|
|
320
|
+
symbolsSeen.set(p, seen);
|
|
321
|
+
}
|
|
322
|
+
if (!symbols.has(p)) symbols.set(p, []);
|
|
323
|
+
const existing = symbols.get(p)!;
|
|
324
|
+
for (const s of syms) {
|
|
325
|
+
if (!seen.has(s.name)) {
|
|
326
|
+
seen.add(s.name);
|
|
327
|
+
existing.push(s.name);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Type catalog (Read results)
|
|
332
|
+
if (isRead) {
|
|
333
|
+
if (!fileSigs.has(p)) {
|
|
334
|
+
fileSigs.set(p, { sigs: [], modified: false });
|
|
335
|
+
fileOrder.push(p);
|
|
336
|
+
}
|
|
337
|
+
const sigs = fileSigs.get(p)!.sigs;
|
|
338
|
+
for (const s of syms) {
|
|
339
|
+
if (s.signature && !sigs.includes(s.signature)) sigs.push(s.signature);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Symbol changes
|
|
344
|
+
const access = isWrite ? ('modified' as const) : ('read' as const);
|
|
345
|
+
for (const s of syms) {
|
|
346
|
+
const key = `${s.name}@${p}`;
|
|
347
|
+
if (!refSeen.has(key)) {
|
|
348
|
+
refSeen.add(key);
|
|
349
|
+
symbolRefs.push({ name: s.name, file: p, kind: s.kind, access });
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Dedup: if already Modified, drop from Created
|
|
357
|
+
for (const p of modified) created.delete(p);
|
|
358
|
+
|
|
359
|
+
// Build type catalog — modified files first, then read files
|
|
360
|
+
const modifiedSigs: ExportSig[] = [];
|
|
361
|
+
const readSigs: ExportSig[] = [];
|
|
362
|
+
for (const file of fileOrder) {
|
|
363
|
+
const entry = fileSigs.get(file)!;
|
|
364
|
+
if (entry.sigs.length === 0) continue;
|
|
365
|
+
const esig: ExportSig = { file, signatures: entry.sigs.slice(0, 8), modified: entry.modified };
|
|
366
|
+
if (esig.modified) modifiedSigs.push(esig);
|
|
367
|
+
else readSigs.push(esig);
|
|
368
|
+
}
|
|
369
|
+
const typeCatalog = [...modifiedSigs, ...readSigs].slice(0, 12);
|
|
370
|
+
|
|
371
|
+
return {
|
|
372
|
+
fileActivity: { read, modified, created, symbols },
|
|
373
|
+
typeCatalog,
|
|
374
|
+
symbolChanges: symbolRefs,
|
|
375
|
+
};
|
|
376
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { NormalizedBlock } from './types';
|
|
2
|
+
|
|
3
|
+
const NOISE_TOOLS = new Set([
|
|
4
|
+
'TodoWrite',
|
|
5
|
+
'TodoRead',
|
|
6
|
+
'ToolSearch',
|
|
7
|
+
'WebSearch',
|
|
8
|
+
'AskUser',
|
|
9
|
+
'ExitSpecMode',
|
|
10
|
+
'GenerateDroid',
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
const NOISE_STRINGS = [
|
|
14
|
+
'Continue from where you left off.',
|
|
15
|
+
'No response requested.',
|
|
16
|
+
'IMPORTANT: TodoWrite was not called yet.',
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
const XML_WRAPPER_RE =
|
|
20
|
+
/<(system-reminder|ide_opened_file|command-message|context-window-usage)[^>]*>[\s\S]*?<\/\1>/g;
|
|
21
|
+
|
|
22
|
+
const isNoiseUserBlock = (text: string): boolean => {
|
|
23
|
+
const trimmed = text.trim();
|
|
24
|
+
if (NOISE_STRINGS.some((s) => trimmed.includes(s))) return true;
|
|
25
|
+
const stripped = trimmed.replace(XML_WRAPPER_RE, '').trim();
|
|
26
|
+
return stripped.length === 0;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const cleanUserText = (text: string): string => text.replace(XML_WRAPPER_RE, '').trim();
|
|
30
|
+
|
|
31
|
+
export const filterNoise = (blocks: NormalizedBlock[]): NormalizedBlock[] => {
|
|
32
|
+
const out: NormalizedBlock[] = [];
|
|
33
|
+
for (const b of blocks) {
|
|
34
|
+
if (b.kind === 'thinking') continue;
|
|
35
|
+
if (b.kind === 'tool_call' && NOISE_TOOLS.has(b.name)) continue;
|
|
36
|
+
if (b.kind === 'tool_result' && NOISE_TOOLS.has(b.name)) continue;
|
|
37
|
+
if (b.kind === 'user') {
|
|
38
|
+
if (isNoiseUserBlock(b.text)) continue;
|
|
39
|
+
const cleaned = cleanUserText(b.text);
|
|
40
|
+
if (!cleaned) continue;
|
|
41
|
+
out.push({ kind: 'user', text: cleaned });
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
out.push(b);
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { SectionData } from './sections';
|
|
2
|
+
|
|
3
|
+
const section = (title: string, items?: string[]): string => {
|
|
4
|
+
if (!items || items.length === 0) return '';
|
|
5
|
+
const body = items.map((i) => `- ${i}`).join('\n');
|
|
6
|
+
return `[${title}]\n${body}`;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const BRIEF_MAX_LINES = 120;
|
|
10
|
+
const TUI_SAFE_LINE_CHARS = 120;
|
|
11
|
+
|
|
12
|
+
const wrapLine = (line: string, maxChars: number): string[] => {
|
|
13
|
+
if (line.length <= maxChars) return [line];
|
|
14
|
+
|
|
15
|
+
const indent = line.match(/^\s*(?:[-*]\s+|\d+\.\s+)?/)?.[0] ?? '';
|
|
16
|
+
const continuationIndent = indent ? ' '.repeat(Math.min(indent.length, 8)) : '';
|
|
17
|
+
const wrapped: string[] = [];
|
|
18
|
+
let remaining = line;
|
|
19
|
+
let prefix = '';
|
|
20
|
+
|
|
21
|
+
while (prefix.length + remaining.length > maxChars) {
|
|
22
|
+
const available = Math.max(20, maxChars - prefix.length);
|
|
23
|
+
let splitAt = remaining.lastIndexOf(' ', available);
|
|
24
|
+
if (splitAt < Math.floor(available * 0.5)) splitAt = available;
|
|
25
|
+
|
|
26
|
+
wrapped.push(prefix + remaining.slice(0, splitAt).trimEnd());
|
|
27
|
+
remaining = remaining.slice(splitAt).trimStart();
|
|
28
|
+
prefix = continuationIndent;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (remaining) wrapped.push(prefix + remaining);
|
|
32
|
+
return wrapped;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const wrapLongLines = (text: string, maxChars = TUI_SAFE_LINE_CHARS): string =>
|
|
36
|
+
text
|
|
37
|
+
.split('\n')
|
|
38
|
+
.flatMap((line) => wrapLine(line, maxChars))
|
|
39
|
+
.join('\n');
|
|
40
|
+
|
|
41
|
+
export const capBrief = (text: string): string => {
|
|
42
|
+
const lines = text.split('\n');
|
|
43
|
+
if (lines.length <= BRIEF_MAX_LINES) return text;
|
|
44
|
+
const omitted = lines.length - BRIEF_MAX_LINES;
|
|
45
|
+
const kept = lines.slice(-BRIEF_MAX_LINES);
|
|
46
|
+
// Find first section header to avoid cutting mid-section
|
|
47
|
+
const firstHeader = kept.findIndex((l) => /^\[.+\]/.test(l));
|
|
48
|
+
const clean = firstHeader > 0 ? kept.slice(firstHeader) : kept;
|
|
49
|
+
const crumbLine = `...(${omitted} earlier lines omitted)`;
|
|
50
|
+
return `${crumbLine}\n\n${clean.join('\n')}`;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** Format the summary with cache-friendly section ordering.
|
|
54
|
+
*
|
|
55
|
+
* Stable (merged/accumulated) sections come first so the prompt prefix
|
|
56
|
+
* stays cacheable across compactions. Volatile (always-fresh) sections
|
|
57
|
+
* come last. */
|
|
58
|
+
export const formatSummary = (data: SectionData): string => {
|
|
59
|
+
// Cache-friendly ordering: stable first, volatile last
|
|
60
|
+
const stableSections = [
|
|
61
|
+
section('Session Goal', data.sessionGoal),
|
|
62
|
+
section('User Preferences', data.userPreferences),
|
|
63
|
+
section('Files And Changes', data.filesAndChanges),
|
|
64
|
+
section('Commits', data.commits),
|
|
65
|
+
].filter(Boolean);
|
|
66
|
+
|
|
67
|
+
const volatileSections = [
|
|
68
|
+
section('Type Catalog', data.typeCatalog),
|
|
69
|
+
section('Outstanding Context', data.outstandingContext),
|
|
70
|
+
section('Earlier Turns', data.turnSummaries),
|
|
71
|
+
].filter(Boolean);
|
|
72
|
+
|
|
73
|
+
// All header sections (stable + volatile) form the header block
|
|
74
|
+
const allHeaders = [...stableSections, ...volatileSections];
|
|
75
|
+
|
|
76
|
+
const parts: string[] = [];
|
|
77
|
+
if (allHeaders.length > 0) {
|
|
78
|
+
parts.push(allHeaders.join('\n\n'));
|
|
79
|
+
}
|
|
80
|
+
if (data.briefTranscript) {
|
|
81
|
+
parts.push(capBrief(data.briefTranscript));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (parts.length === 0) return '';
|
|
85
|
+
|
|
86
|
+
let result = wrapLongLines(parts.join('\n\n---\n\n'));
|
|
87
|
+
|
|
88
|
+
return result;
|
|
89
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Message } from '@earendil-works/pi-ai';
|
|
2
|
+
import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
3
|
+
import type { SectionData } from './sections';
|
|
4
|
+
import { normalize } from './normalize';
|
|
5
|
+
import { filterNoise } from './filter-noise';
|
|
6
|
+
import { buildSections } from './build-sections';
|
|
7
|
+
import { formatSummary } from './format';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Extract Message[] from the active branch entries.
|
|
11
|
+
*/
|
|
12
|
+
export function extractMessages(ctx: ExtensionContext): Message[] {
|
|
13
|
+
const entries = ctx.sessionManager.getBranch();
|
|
14
|
+
const messages: Message[] = [];
|
|
15
|
+
for (const entry of entries) {
|
|
16
|
+
if (entry.type === 'message' && (entry as any).message) {
|
|
17
|
+
messages.push((entry as any).message);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return messages;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Build a structured compaction summary from branch messages.
|
|
25
|
+
* One-shot, no merge, no state — fresh each time.
|
|
26
|
+
*/
|
|
27
|
+
export function buildCompactionSummary(messages: Message[]): SectionData {
|
|
28
|
+
const blocks = filterNoise(normalize(messages));
|
|
29
|
+
return buildSections({ blocks });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Format SectionData as text for the supervisor prompt.
|
|
34
|
+
* Uses cache-friendly section ordering (stable first, volatile last).
|
|
35
|
+
*/
|
|
36
|
+
export function formatForSupervisor(data: SectionData): string {
|
|
37
|
+
return formatSummary(data);
|
|
38
|
+
}
|