pi-vcc 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +120 -0
- package/demo.gif +0 -0
- package/flow/plans/20260515-1300/plan.md +206 -0
- package/index.ts +14 -0
- package/package.json +36 -0
- package/pi-vcc-config.schema.json +131 -0
- package/scripts/audit-sessions.ts +88 -0
- package/scripts/benchmark-real-sessions.ts +25 -0
- package/scripts/compare-before-after.ts +36 -0
- package/scripts/dump-branch-output.ts +20 -0
- package/src/commands/pi-vcc.ts +33 -0
- package/src/commands/vcc-recall.ts +65 -0
- package/src/core/brief.ts +381 -0
- package/src/core/build-sections.ts +87 -0
- package/src/core/content.ts +60 -0
- package/src/core/filter-noise.ts +42 -0
- package/src/core/format-recall.ts +27 -0
- package/src/core/format.ts +56 -0
- package/src/core/lineage.ts +26 -0
- package/src/core/load-messages.ts +63 -0
- package/src/core/normalize.ts +66 -0
- package/src/core/recall-scope.ts +14 -0
- package/src/core/render-entries.ts +68 -0
- package/src/core/report.ts +237 -0
- package/src/core/sanitize.ts +5 -0
- package/src/core/search-entries.ts +230 -0
- package/src/core/settings.ts +215 -0
- package/src/core/skill-collapse.ts +35 -0
- package/src/core/summarize.ts +159 -0
- package/src/core/tool-args.ts +14 -0
- package/src/details.ts +7 -0
- package/src/extract/commits.ts +69 -0
- package/src/extract/files.ts +80 -0
- package/src/extract/goals.ts +79 -0
- package/src/extract/preferences.ts +55 -0
- package/src/extract/references.ts +214 -0
- package/src/extract/signals.ts +145 -0
- package/src/hooks/before-compact.ts +405 -0
- package/src/sections.ts +14 -0
- package/src/tools/recall.ts +109 -0
- package/src/types.ts +14 -0
- package/tests/before-compact-hook.test.ts +181 -0
- package/tests/before-compact.test.ts +140 -0
- package/tests/brief.test.ts +206 -0
- package/tests/build-sections.test.ts +90 -0
- package/tests/compile.test.ts +110 -0
- package/tests/config-integration.test.ts +107 -0
- package/tests/content.test.ts +31 -0
- package/tests/edge-cases.test.ts +368 -0
- package/tests/extract-goals.test.ts +86 -0
- package/tests/extract-preferences.test.ts +30 -0
- package/tests/extract-references.test.ts +475 -0
- package/tests/extract-signals.test.ts +561 -0
- package/tests/filter-noise.test.ts +61 -0
- package/tests/fixtures.ts +61 -0
- package/tests/format-recall.test.ts +30 -0
- package/tests/format.test.ts +91 -0
- package/tests/lineage.test.ts +33 -0
- package/tests/load-messages.test.ts +51 -0
- package/tests/normalize.test.ts +97 -0
- package/tests/real-sessions.test.ts +38 -0
- package/tests/recall-expand.test.ts +15 -0
- package/tests/recall-scope.test.ts +32 -0
- package/tests/recall-tool-scope.test.ts +67 -0
- package/tests/render-entries.test.ts +62 -0
- package/tests/report.test.ts +44 -0
- package/tests/sanitize.test.ts +24 -0
- package/tests/search-entries.test.ts +144 -0
- package/tests/settings-scaffold.test.ts +120 -0
- package/tests/settings.test.ts +32 -0
- package/tests/support/load-session.ts +23 -0
- package/tests/support/real-sessions.ts +51 -0
- package/tsconfig.json +14 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import type { Message } from "@mariozechner/pi-ai";
|
|
2
|
+
import type { RenderedEntry } from "./render-entries";
|
|
3
|
+
import { textOf } from "./content";
|
|
4
|
+
|
|
5
|
+
interface BashExecutionMessage {
|
|
6
|
+
role: "bashExecution";
|
|
7
|
+
command?: string;
|
|
8
|
+
output?: string;
|
|
9
|
+
}
|
|
10
|
+
function isBashExec(m: Message | BashExecutionMessage): m is BashExecutionMessage {
|
|
11
|
+
return typeof m === "object" && m !== null && (m as unknown as Record<string, unknown>).role === "bashExecution";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface SearchHit extends RenderedEntry {
|
|
15
|
+
/** Context snippet around the first matched term (only when query provided) */
|
|
16
|
+
snippet?: string;
|
|
17
|
+
/** Number of query terms matched (for ranking) */
|
|
18
|
+
matchCount?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const escapeRegex = (s: string): string =>
|
|
22
|
+
s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
23
|
+
|
|
24
|
+
/** Try to compile as regex; fall back to escaped literal. */
|
|
25
|
+
const safeRegex = (pattern: string): RegExp => {
|
|
26
|
+
try {
|
|
27
|
+
return new RegExp(pattern, "i");
|
|
28
|
+
} catch {
|
|
29
|
+
return new RegExp(escapeRegex(pattern), "i");
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** Detect if the query looks like a single regex pattern (contains regex metacharacters). */
|
|
34
|
+
const looksLikeRegex = (query: string): boolean =>
|
|
35
|
+
/[|*+?{}()[\]\\^$.]/.test(query);
|
|
36
|
+
|
|
37
|
+
/** Build a regex for snippet highlighting — matches first available term. */
|
|
38
|
+
const snippetRegex = (terms: string[]): RegExp => {
|
|
39
|
+
const alts = terms.map((t) => {
|
|
40
|
+
try {
|
|
41
|
+
// Validate that it's a valid regex
|
|
42
|
+
new RegExp(t, "i");
|
|
43
|
+
return t;
|
|
44
|
+
} catch {
|
|
45
|
+
return escapeRegex(t);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
return new RegExp(alts.join("|"), "i");
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// ── Stopwords for natural language queries ──
|
|
52
|
+
const STOPWORDS = new Set([
|
|
53
|
+
// English
|
|
54
|
+
"the", "a", "an", "is", "are", "was", "were", "be", "been", "being",
|
|
55
|
+
"have", "has", "had", "do", "does", "did", "will", "would", "could",
|
|
56
|
+
"should", "may", "might", "can", "shall", "of", "in", "to", "for",
|
|
57
|
+
"with", "on", "at", "from", "by", "as", "into", "through", "during",
|
|
58
|
+
"before", "after", "above", "below", "between", "out", "off", "over",
|
|
59
|
+
"under", "again", "further", "then", "once", "here", "there", "when",
|
|
60
|
+
"where", "why", "how", "all", "both", "each", "few", "more", "most",
|
|
61
|
+
"other", "some", "such", "no", "nor", "not", "only", "own", "same",
|
|
62
|
+
"so", "than", "too", "very", "just", "about", "it", "its", "that",
|
|
63
|
+
"this", "what", "which", "who", "whom", "these", "those",
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
/** Remove stopwords, keep meaningful terms. */
|
|
67
|
+
const filterStopwords = (terms: string[]): string[] => {
|
|
68
|
+
const meaningful = terms.filter((t) => !STOPWORDS.has(t.toLowerCase()) && t.length > 1);
|
|
69
|
+
// If all terms were stopwords, return original (don't lose everything)
|
|
70
|
+
return meaningful.length > 0 ? meaningful : terms;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** Count how many distinct terms match the haystack. */
|
|
74
|
+
const countMatches = (hay: string, terms: string[]): number => {
|
|
75
|
+
let count = 0;
|
|
76
|
+
for (const t of terms) {
|
|
77
|
+
if (safeRegex(t).test(hay)) count++;
|
|
78
|
+
}
|
|
79
|
+
return count;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// ── BM25-lite scoring ──
|
|
83
|
+
const BM25_K = 1.2;
|
|
84
|
+
const BM25_B = 0.75;
|
|
85
|
+
|
|
86
|
+
/** Count occurrences of a regex pattern in text. */
|
|
87
|
+
const termFreq = (text: string, pattern: RegExp): number => {
|
|
88
|
+
const matches = text.match(new RegExp(pattern.source, "gi"));
|
|
89
|
+
return matches ? matches.length : 0;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
interface BM25Context {
|
|
93
|
+
n: number; // total docs
|
|
94
|
+
avgDl: number; // average doc length (words)
|
|
95
|
+
df: Map<string, number>; // term -> number of docs containing it
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Precompute IDF and avgDl across all docs. */
|
|
99
|
+
const buildBM25Context = (docs: string[], terms: string[]): BM25Context => {
|
|
100
|
+
const n = docs.length;
|
|
101
|
+
const df = new Map<string, number>();
|
|
102
|
+
let totalLen = 0;
|
|
103
|
+
|
|
104
|
+
for (const doc of docs) {
|
|
105
|
+
totalLen += doc.split(/\s+/).length;
|
|
106
|
+
for (const t of terms) {
|
|
107
|
+
if (safeRegex(t).test(doc)) {
|
|
108
|
+
df.set(t, (df.get(t) ?? 0) + 1);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return { n, avgDl: totalLen / Math.max(n, 1), df };
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/** BM25 score for a single doc against query terms. */
|
|
117
|
+
const bm25Score = (doc: string, terms: string[], ctx: BM25Context): number => {
|
|
118
|
+
const dl = doc.split(/\s+/).length;
|
|
119
|
+
let score = 0;
|
|
120
|
+
|
|
121
|
+
for (const t of terms) {
|
|
122
|
+
const tf = termFreq(doc, safeRegex(t));
|
|
123
|
+
if (tf === 0) continue;
|
|
124
|
+
|
|
125
|
+
const docFreq = ctx.df.get(t) ?? 0;
|
|
126
|
+
// IDF: log((N - df + 0.5) / (df + 0.5) + 1)
|
|
127
|
+
const idf = Math.log((ctx.n - docFreq + 0.5) / (docFreq + 0.5) + 1);
|
|
128
|
+
// TF saturation with length normalization
|
|
129
|
+
const tfNorm = (tf * (BM25_K + 1)) / (tf + BM25_K * (1 - BM25_B + BM25_B * dl / ctx.avgDl));
|
|
130
|
+
score += idf * tfNorm;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return score;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
/** Line-based snippet: ±contextLines around first regex match. */
|
|
137
|
+
const lineSnippet = (text: string, regex: RegExp, contextLines = 2): string | undefined => {
|
|
138
|
+
const lines = text.split("\n");
|
|
139
|
+
let matchIdx = -1;
|
|
140
|
+
for (let i = 0; i < lines.length; i++) {
|
|
141
|
+
if (regex.test(lines[i])) {
|
|
142
|
+
matchIdx = i;
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (matchIdx === -1) return undefined;
|
|
147
|
+
|
|
148
|
+
const start = Math.max(0, matchIdx - contextLines);
|
|
149
|
+
const end = Math.min(lines.length, matchIdx + contextLines + 1);
|
|
150
|
+
const slice = lines.slice(start, end);
|
|
151
|
+
|
|
152
|
+
const parts: string[] = [];
|
|
153
|
+
if (start > 0) parts.push(`...(${start} lines above)`);
|
|
154
|
+
parts.push(...slice);
|
|
155
|
+
if (end < lines.length) parts.push(`...(${lines.length - end} lines below)`);
|
|
156
|
+
return parts.join("\n");
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
/** Build full searchable text for a message. */
|
|
160
|
+
const fullText = (msg: Message | BashExecutionMessage): string => {
|
|
161
|
+
if (isBashExec(msg)) {
|
|
162
|
+
return `${msg.command ?? ""} ${msg.output ?? ""}`;
|
|
163
|
+
}
|
|
164
|
+
return textOf(msg.content);
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
export const searchEntries = (
|
|
168
|
+
entries: RenderedEntry[],
|
|
169
|
+
messages: Message[],
|
|
170
|
+
query?: string,
|
|
171
|
+
): SearchHit[] => {
|
|
172
|
+
if (!query?.trim()) return entries;
|
|
173
|
+
|
|
174
|
+
const rawQuery = query.trim();
|
|
175
|
+
|
|
176
|
+
// If query looks like a single regex pattern (contains metacharacters),
|
|
177
|
+
// treat the whole thing as one pattern — don't split into terms
|
|
178
|
+
if (looksLikeRegex(rawQuery)) {
|
|
179
|
+
const regex = safeRegex(rawQuery);
|
|
180
|
+
const hits: SearchHit[] = [];
|
|
181
|
+
for (let i = 0; i < entries.length; i++) {
|
|
182
|
+
const e = entries[i];
|
|
183
|
+
const msg = messages[i];
|
|
184
|
+
const text = msg ? fullText(msg) : e.summary;
|
|
185
|
+
const filePart = e.files?.join(" ") ?? "";
|
|
186
|
+
const hay = `${e.role} ${text} ${filePart}`;
|
|
187
|
+
if (regex.test(hay)) {
|
|
188
|
+
const snip = lineSnippet(text, regex);
|
|
189
|
+
hits.push({ ...e, snippet: snip, matchCount: 1 });
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return hits;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Natural language / multi-word query: BM25 scoring
|
|
196
|
+
const rawTerms = rawQuery.split(/\s+/);
|
|
197
|
+
const terms = filterStopwords(rawTerms);
|
|
198
|
+
const snipRe = snippetRegex(terms);
|
|
199
|
+
|
|
200
|
+
// Build all docs for BM25 context
|
|
201
|
+
const docs: string[] = [];
|
|
202
|
+
for (let i = 0; i < entries.length; i++) {
|
|
203
|
+
const e = entries[i];
|
|
204
|
+
const msg = messages[i];
|
|
205
|
+
const text = msg ? fullText(msg) : e.summary;
|
|
206
|
+
const filePart = e.files?.join(" ") ?? "";
|
|
207
|
+
docs.push(`${e.role} ${text} ${filePart}`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const ctx = buildBM25Context(docs, terms);
|
|
211
|
+
|
|
212
|
+
const scored: Array<{ hit: SearchHit; score: number }> = [];
|
|
213
|
+
for (let i = 0; i < entries.length; i++) {
|
|
214
|
+
const e = entries[i];
|
|
215
|
+
const hay = docs[i];
|
|
216
|
+
const mc = countMatches(hay, terms);
|
|
217
|
+
if (mc === 0) continue;
|
|
218
|
+
const score = bm25Score(hay, terms, ctx);
|
|
219
|
+
const text = messages[i] ? fullText(messages[i]) : e.summary;
|
|
220
|
+
const snip = lineSnippet(text, snipRe);
|
|
221
|
+
scored.push({
|
|
222
|
+
hit: { ...e, snippet: snip, matchCount: mc },
|
|
223
|
+
score,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Sort by BM25 score desc
|
|
228
|
+
scored.sort((a, b) => b.score - a.score);
|
|
229
|
+
return scored.map((s) => s.hit);
|
|
230
|
+
};
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
2
|
+
import { readFile, writeFile, mkdir } from "fs/promises";
|
|
3
|
+
import { homedir } from "os";
|
|
4
|
+
import { dirname, join } from "path";
|
|
5
|
+
|
|
6
|
+
export const SETTINGS_PATH_DEFAULT = join(homedir(), ".pi", "agent", "pi-vcc-config.json");
|
|
7
|
+
const settingsPath = (): string => process.env.PI_VCC_CONFIG_PATH ?? SETTINGS_PATH_DEFAULT;
|
|
8
|
+
/** Backwards-compat export. Resolves at access time, not import time. */
|
|
9
|
+
export const SETTINGS_PATH = settingsPath();
|
|
10
|
+
|
|
11
|
+
export interface ExtractionCategoryConfig {
|
|
12
|
+
/** When false, skip this extraction entirely. Default: true */
|
|
13
|
+
enabled: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ReferencesConfig extends ExtractionCategoryConfig {
|
|
17
|
+
/** Additional URL regex patterns (added to built-in `https?://\\S+`). */
|
|
18
|
+
extraUrlPatterns: string[];
|
|
19
|
+
/** Additional GitHub ref patterns (added to built-in `#\\d+`, `PR\\s*#\\d+`, `owner/repo`). */
|
|
20
|
+
extraGithubRefPatterns: string[];
|
|
21
|
+
/** Additional version patterns (added to built-in `v?\\d+\\.\\d+\\.\\d+`). */
|
|
22
|
+
extraVersionPatterns: string[];
|
|
23
|
+
/** Additional branch patterns (added to built-in `feat|fix|.../xxx`). */
|
|
24
|
+
extraBranchPatterns: string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface KeySignalsConfig extends ExtractionCategoryConfig {
|
|
28
|
+
/** Additional constraint patterns (added to built-in `don't|must not|...`). */
|
|
29
|
+
extraConstraintPatterns: string[];
|
|
30
|
+
/** Additional decision patterns (added to built-in `decided|let's use|...`). */
|
|
31
|
+
extraDecisionPatterns: string[];
|
|
32
|
+
/** Additional status patterns (added to built-in `DONE|TODO|WIP|...`). */
|
|
33
|
+
extraStatusPatterns: string[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface GoalsConfig extends ExtractionCategoryConfig {
|
|
37
|
+
/** Additional task verbs (added to built-in `fix|implement|add|...`). */
|
|
38
|
+
extraTaskVerbs: string[];
|
|
39
|
+
/** Additional scope change keywords (added to built-in `instead|actually|...`). */
|
|
40
|
+
extraScopeChangeWords: string[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ExtractionConfig {
|
|
44
|
+
/** References extractor: URLs, GitHub refs, versions, branches. */
|
|
45
|
+
references: ReferencesConfig;
|
|
46
|
+
/** Key Signals extractor: constraints, decisions, status markers. */
|
|
47
|
+
keySignals: KeySignalsConfig;
|
|
48
|
+
/** Goals extractor tweaks. */
|
|
49
|
+
goals: GoalsConfig;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface PiVccSettings {
|
|
53
|
+
/**
|
|
54
|
+
* When true, pi-vcc handles ALL compactions:
|
|
55
|
+
* - /compact (no args)
|
|
56
|
+
* - /compact <text>
|
|
57
|
+
* - auto threshold / overflow
|
|
58
|
+
* - /pi-vcc (always handled regardless)
|
|
59
|
+
*
|
|
60
|
+
* When false (default), pi-vcc only handles /pi-vcc; everything else
|
|
61
|
+
* falls back to pi core's default LLM-based compaction.
|
|
62
|
+
*/
|
|
63
|
+
overrideDefaultCompaction: boolean;
|
|
64
|
+
/** Write debug snapshot to /tmp/pi-vcc-debug.json on each compaction. */
|
|
65
|
+
debug: boolean;
|
|
66
|
+
/** Fine-grained extraction configuration. All patterns are ADDITIVE — built-ins are never removed. */
|
|
67
|
+
extraction: ExtractionConfig;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const DEFAULT_EXTRACTION: ExtractionConfig = {
|
|
71
|
+
references: {
|
|
72
|
+
enabled: true,
|
|
73
|
+
extraUrlPatterns: [],
|
|
74
|
+
extraGithubRefPatterns: [],
|
|
75
|
+
extraVersionPatterns: [],
|
|
76
|
+
extraBranchPatterns: [],
|
|
77
|
+
},
|
|
78
|
+
keySignals: {
|
|
79
|
+
enabled: true,
|
|
80
|
+
extraConstraintPatterns: [],
|
|
81
|
+
extraDecisionPatterns: [],
|
|
82
|
+
extraStatusPatterns: [],
|
|
83
|
+
},
|
|
84
|
+
goals: {
|
|
85
|
+
enabled: true,
|
|
86
|
+
extraTaskVerbs: [],
|
|
87
|
+
extraScopeChangeWords: [],
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export const DEFAULT_SETTINGS: PiVccSettings = {
|
|
92
|
+
overrideDefaultCompaction: false,
|
|
93
|
+
debug: false,
|
|
94
|
+
extraction: DEFAULT_EXTRACTION,
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const readJson = async (path: string): Promise<Record<string, unknown> | null> => {
|
|
98
|
+
try {
|
|
99
|
+
return JSON.parse(await readFile(path, "utf-8"));
|
|
100
|
+
} catch (e) {
|
|
101
|
+
// Intentional fallback: if the file doesn't exist or is invalid JSON, treat as empty.
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
function deepMergeExtraction(parsed: Record<string, unknown>): ExtractionConfig {
|
|
107
|
+
const ext = (parsed.extraction ?? {}) as Record<string, unknown>;
|
|
108
|
+
const merge = <T extends ExtractionCategoryConfig>(
|
|
109
|
+
defaults: T,
|
|
110
|
+
user: Record<string, unknown>,
|
|
111
|
+
): T => {
|
|
112
|
+
const result = { ...defaults };
|
|
113
|
+
if (typeof user.enabled === "boolean") result.enabled = user.enabled;
|
|
114
|
+
for (const key of Object.keys(defaults)) {
|
|
115
|
+
if (key === "enabled") continue;
|
|
116
|
+
const defVal = defaults[key as keyof T];
|
|
117
|
+
const userVal = user[key];
|
|
118
|
+
// Arrays: user value replaces (not merges) to allow full control
|
|
119
|
+
if (Array.isArray(defVal) && Array.isArray(userVal)) {
|
|
120
|
+
(result as Record<string, unknown>)[key] = userVal;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return result;
|
|
124
|
+
};
|
|
125
|
+
return {
|
|
126
|
+
references: merge(DEFAULT_EXTRACTION.references, (ext.references ?? {}) as Record<string, unknown>),
|
|
127
|
+
keySignals: merge(DEFAULT_EXTRACTION.keySignals, (ext.keySignals ?? {}) as Record<string, unknown>),
|
|
128
|
+
goals: merge(DEFAULT_EXTRACTION.goals, (ext.goals ?? {}) as Record<string, unknown>),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function loadSettings(): Promise<PiVccSettings> {
|
|
133
|
+
const parsed = await readJson(settingsPath());
|
|
134
|
+
if (!parsed || typeof parsed !== "object") return { ...DEFAULT_SETTINGS };
|
|
135
|
+
const { extraction: _, ...topLevel } = { ...DEFAULT_SETTINGS, ...(parsed as Partial<PiVccSettings>) };
|
|
136
|
+
const extraction = deepMergeExtraction(parsed);
|
|
137
|
+
return { ...topLevel, extraction } as PiVccSettings;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Ensure ~/.pi/agent/pi-vcc-config.json exists with default keys.
|
|
142
|
+
* - File missing → create with full default block.
|
|
143
|
+
* - File exists but invalid JSON → no-op (don't clobber user file).
|
|
144
|
+
* - File exists and valid → fill in missing default keys, preserve existing values.
|
|
145
|
+
*/
|
|
146
|
+
/**
|
|
147
|
+
* @deprecated Use scaffoldSettingsAsync() instead — sync fs blocks the event loop in extension hooks.
|
|
148
|
+
*/
|
|
149
|
+
export function scaffoldSettings(): void {
|
|
150
|
+
try {
|
|
151
|
+
const path = settingsPath();
|
|
152
|
+
const dir = dirname(path);
|
|
153
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
154
|
+
|
|
155
|
+
if (!existsSync(path)) {
|
|
156
|
+
writeFileSync(path, `${JSON.stringify(DEFAULT_SETTINGS, null, 2)}\n`);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const parsed = JSON.parse(readFileSync(path, "utf-8"));
|
|
161
|
+
if (!parsed || typeof parsed !== "object") return; // don't clobber
|
|
162
|
+
|
|
163
|
+
let changed = false;
|
|
164
|
+
const next: Record<string, unknown> = { ...parsed };
|
|
165
|
+
for (const [key, value] of Object.entries(DEFAULT_SETTINGS)) {
|
|
166
|
+
if (!(key in next)) {
|
|
167
|
+
next[key] = value;
|
|
168
|
+
changed = true;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (changed) writeFileSync(path, `${JSON.stringify(next, null, 2)}\n`);
|
|
172
|
+
} catch (e) {
|
|
173
|
+
// Intentional fallback: settings scaffolding is best-effort and should not crash the extension load.
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Async version of scaffoldSettings(). Uses fs/promises to avoid blocking the event loop.
|
|
179
|
+
* - File missing → create with full default block.
|
|
180
|
+
* - File exists but invalid JSON → no-op (don't clobber user file).
|
|
181
|
+
* - File exists and valid → fill in missing default keys, preserve existing values.
|
|
182
|
+
*/
|
|
183
|
+
export async function scaffoldSettingsAsync(): Promise<void> {
|
|
184
|
+
try {
|
|
185
|
+
const path = settingsPath();
|
|
186
|
+
const dir = dirname(path);
|
|
187
|
+
|
|
188
|
+
let data: string;
|
|
189
|
+
try {
|
|
190
|
+
data = await readFile(path, "utf-8");
|
|
191
|
+
} catch (e: unknown) {
|
|
192
|
+
if (e instanceof Error && (e as NodeJS.ErrnoException).code === "ENOENT") {
|
|
193
|
+
// File doesn't exist → create parent dir + default config
|
|
194
|
+
await mkdir(dir, { recursive: true });
|
|
195
|
+
await writeFile(path, `${JSON.stringify(DEFAULT_SETTINGS, null, 2)}\n`);
|
|
196
|
+
}
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const parsed = JSON.parse(data);
|
|
201
|
+
if (!parsed || typeof parsed !== "object") return; // don't clobber
|
|
202
|
+
|
|
203
|
+
let changed = false;
|
|
204
|
+
const next: Record<string, unknown> = { ...parsed };
|
|
205
|
+
for (const [key, value] of Object.entries(DEFAULT_SETTINGS)) {
|
|
206
|
+
if (!(key in next)) {
|
|
207
|
+
next[key] = value;
|
|
208
|
+
changed = true;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (changed) await writeFile(path, `${JSON.stringify(next, null, 2)}\n`);
|
|
212
|
+
} catch (e) {
|
|
213
|
+
// Intentional fallback: settings scaffolding is best-effort and should not crash the extension load.
|
|
214
|
+
}
|
|
215
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** Shared skill-tag collapse utilities */
|
|
2
|
+
|
|
3
|
+
const SKILL_TAG_RE = /^-?\s*<skill\s+name="([^"]+)"/;
|
|
4
|
+
const SKILL_CLOSE_RE = /^-?\s*<\/skill>/;
|
|
5
|
+
|
|
6
|
+
/** Collapse skill tags in an array of lines — dedup by name, drop all content inside block */
|
|
7
|
+
export const collapseSkillLines = (lines: string[]): string[] => {
|
|
8
|
+
const result: string[] = [];
|
|
9
|
+
const seenSkills = new Set<string>();
|
|
10
|
+
let insideSkill = false;
|
|
11
|
+
|
|
12
|
+
for (const line of lines) {
|
|
13
|
+
const skillMatch = line.match(SKILL_TAG_RE);
|
|
14
|
+
if (skillMatch) {
|
|
15
|
+
insideSkill = true;
|
|
16
|
+
const name = skillMatch[1];
|
|
17
|
+
if (!seenSkills.has(name)) {
|
|
18
|
+
seenSkills.add(name);
|
|
19
|
+
result.push(`[skill: ${name}]`);
|
|
20
|
+
}
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (insideSkill) {
|
|
24
|
+
if (SKILL_CLOSE_RE.test(line)) insideSkill = false;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
result.push(line);
|
|
28
|
+
}
|
|
29
|
+
return result;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Collapse <skill name="X" ...>...</skill> blocks in raw text */
|
|
33
|
+
const SKILL_BLOCK_RE = /<skill\s+name="([^"]+)"[^>]*>[\s\S]*?(?:<\/skill>|$)/g;
|
|
34
|
+
export const collapseSkillText = (text: string): string =>
|
|
35
|
+
text.replace(SKILL_BLOCK_RE, (_, name) => `[skill: ${name}]`);
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import type { Message } from "@mariozechner/pi-ai";
|
|
2
|
+
import type { FileOps } from "../types";
|
|
3
|
+
import { normalize } from "./normalize";
|
|
4
|
+
import { filterNoise } from "./filter-noise";
|
|
5
|
+
import { buildSections } from "./build-sections";
|
|
6
|
+
import { formatSummary, capBrief, RECALL_NOTE } from "./format";
|
|
7
|
+
import { loadSettings, type ExtractionConfig } from "./settings";
|
|
8
|
+
|
|
9
|
+
export interface CompileInput {
|
|
10
|
+
messages: Message[];
|
|
11
|
+
previousSummary?: string;
|
|
12
|
+
fileOps?: FileOps;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const HEADER_NAMES = ["Session Goal", "Files And Changes", "Commits", "References", "Key Signals", "Outstanding Context", "User Preferences"];
|
|
16
|
+
|
|
17
|
+
const SEPARATOR = "\n\n---\n\n";
|
|
18
|
+
|
|
19
|
+
/** Extract a named section from summary text */
|
|
20
|
+
const sectionOf = (text: string, header: string): string => {
|
|
21
|
+
const tag = `[${header}]`;
|
|
22
|
+
const start = text.indexOf(tag);
|
|
23
|
+
if (start < 0) return "";
|
|
24
|
+
const after = text.slice(start);
|
|
25
|
+
// Find next section header or separator
|
|
26
|
+
const nextSection = HEADER_NAMES
|
|
27
|
+
.filter((h) => h !== header)
|
|
28
|
+
.map((h) => after.indexOf(`[${h}]`))
|
|
29
|
+
.filter((n) => n > 0);
|
|
30
|
+
const nextSep = after.indexOf("\n\n---\n\n");
|
|
31
|
+
const candidates = [...nextSection, ...(nextSep > 0 ? [nextSep] : [])].sort((a, b) => a - b);
|
|
32
|
+
const end = candidates[0];
|
|
33
|
+
return (end ? after.slice(0, end) : after).trim();
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** Extract the brief transcript part (everything after ---) */
|
|
37
|
+
const briefOf = (text: string): string => {
|
|
38
|
+
const idx = text.indexOf(SEPARATOR);
|
|
39
|
+
if (idx < 0) return "";
|
|
40
|
+
return text.slice(idx + SEPARATOR.length).trim();
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** Merge a header section */
|
|
44
|
+
const mergeHeaderSection = (header: string, prev: string, fresh: string): string => {
|
|
45
|
+
// Outstanding Context is volatile -- always use fresh only
|
|
46
|
+
if (header === "Outstanding Context") return fresh;
|
|
47
|
+
if (!prev) return fresh;
|
|
48
|
+
if (!fresh) return prev;
|
|
49
|
+
|
|
50
|
+
// Files And Changes: merge by category (Modified/Created/Read), dedup paths
|
|
51
|
+
if (header === "Files And Changes") {
|
|
52
|
+
return mergeFileLines(prev, fresh);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Session Goal, User Preferences: line-level dedup, cap
|
|
56
|
+
const isClean = (l: string) => l.startsWith("- ") && !l.includes("<skill") && !l.includes("</skill");
|
|
57
|
+
const prevLines = prev.split("\n").filter(isClean);
|
|
58
|
+
const freshLines = fresh.split("\n").filter(isClean);
|
|
59
|
+
const combined = [...new Set([...prevLines, ...freshLines])];
|
|
60
|
+
const CAP = header === "Session Goal" ? 8 : header === "Commits" ? 8 : 15;
|
|
61
|
+
const capped = combined.length > CAP ? combined.slice(-CAP) : combined;
|
|
62
|
+
if (capped.length === 0) return "";
|
|
63
|
+
return `[${header}]\n${capped.join("\n")}`;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** Merge Files And Changes by category, dedup paths across compactions */
|
|
67
|
+
const mergeFileLines = (prev: string, fresh: string): string => {
|
|
68
|
+
const categories = ["Modified", "Created", "Read"] as const;
|
|
69
|
+
const merged: Record<string, Set<string>> = {};
|
|
70
|
+
for (const cat of categories) merged[cat] = new Set();
|
|
71
|
+
|
|
72
|
+
// Parse "- Modified: a, b, c (+N more)" lines from both prev and fresh
|
|
73
|
+
for (const text of [prev, fresh]) {
|
|
74
|
+
for (const line of text.split("\n")) {
|
|
75
|
+
for (const cat of categories) {
|
|
76
|
+
const prefix = `- ${cat}: `;
|
|
77
|
+
if (!line.startsWith(prefix)) continue;
|
|
78
|
+
let rest = line.slice(prefix.length);
|
|
79
|
+
// Strip "(+N more)" suffix
|
|
80
|
+
rest = rest.replace(/\s*\(\+\d+ more\)\s*$/, "");
|
|
81
|
+
for (const p of rest.split(",")) {
|
|
82
|
+
const trimmed = p.trim();
|
|
83
|
+
if (trimmed) merged[cat].add(trimmed);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Dedup: if already in Modified, drop from Created (file existed before)
|
|
90
|
+
for (const p of merged.Modified) merged.Created.delete(p);
|
|
91
|
+
|
|
92
|
+
const cap = (set: Set<string>, limit: number) => {
|
|
93
|
+
const arr = [...set];
|
|
94
|
+
if (arr.length <= limit) return arr.join(", ");
|
|
95
|
+
return arr.slice(0, limit).join(", ") + ` (+${arr.length - limit} more)`;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const lines: string[] = [];
|
|
99
|
+
if (merged.Modified.size > 0) lines.push(`- Modified: ${cap(merged.Modified, 10)}`);
|
|
100
|
+
if (merged.Created.size > 0) lines.push(`- Created: ${cap(merged.Created, 10)}`);
|
|
101
|
+
if (merged.Read.size > 0) lines.push(`- Read: ${cap(merged.Read, 10)}`);
|
|
102
|
+
if (lines.length === 0) return "";
|
|
103
|
+
return `[Files And Changes]\n${lines.join("\n")}`;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const mergeBriefTranscript = (prev: string, fresh: string): string => {
|
|
107
|
+
if (!prev) return fresh;
|
|
108
|
+
if (!fresh) return prev;
|
|
109
|
+
return prev + "\n\n" + fresh;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const mergePrevious = (prev: string, fresh: string): string => {
|
|
113
|
+
// Merge header sections
|
|
114
|
+
const headers = HEADER_NAMES
|
|
115
|
+
.map((header) => {
|
|
116
|
+
const freshSec = sectionOf(fresh, header);
|
|
117
|
+
const prevSec = sectionOf(prev, header);
|
|
118
|
+
return mergeHeaderSection(header, prevSec, freshSec);
|
|
119
|
+
})
|
|
120
|
+
.filter(Boolean);
|
|
121
|
+
|
|
122
|
+
// Merge brief transcript
|
|
123
|
+
const prevBrief = briefOf(prev);
|
|
124
|
+
const freshBrief = briefOf(fresh);
|
|
125
|
+
const mergedBrief = mergeBriefTranscript(prevBrief, freshBrief);
|
|
126
|
+
|
|
127
|
+
const parts: string[] = [];
|
|
128
|
+
if (headers.length > 0) {
|
|
129
|
+
parts.push(headers.join("\n\n"));
|
|
130
|
+
}
|
|
131
|
+
if (mergedBrief) {
|
|
132
|
+
parts.push(capBrief(mergedBrief));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return parts.join(SEPARATOR);
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
export const compile = async (input: CompileInput): Promise<string> => {
|
|
139
|
+
const blocks = filterNoise(normalize(input.messages));
|
|
140
|
+
const settings = await loadSettings();
|
|
141
|
+
const data = buildSections({ blocks, extraction: settings.extraction });
|
|
142
|
+
const fresh = formatSummary(data);
|
|
143
|
+
// Strip any legacy RECALL_NOTE baked into prev summary (pre-fix format)
|
|
144
|
+
// so merge doesn't re-stack it inside the brief.
|
|
145
|
+
const prev = input.previousSummary
|
|
146
|
+
? stripRecallNote(input.previousSummary)
|
|
147
|
+
: undefined;
|
|
148
|
+
const merged = prev ? mergePrevious(prev, fresh) : fresh;
|
|
149
|
+
if (!merged) return "";
|
|
150
|
+
return merged + SEPARATOR + RECALL_NOTE;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const stripRecallNote = (text: string): string => {
|
|
154
|
+
// Remove trailing RECALL_NOTE (and any separators surrounding it) if present.
|
|
155
|
+
// Handles both current format (---\n\nNOTE) and bare trailing NOTE.
|
|
156
|
+
const idx = text.lastIndexOf(RECALL_NOTE);
|
|
157
|
+
if (idx < 0) return text;
|
|
158
|
+
return text.slice(0, idx).replace(/\s*(?:\n\n---\n\n)?\s*$/, "").trimEnd();
|
|
159
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export const extractPath = (args: Record<string, unknown>): string | null => {
|
|
2
|
+
for (const key of ["path", "file_path", "filePath", "file"]) {
|
|
3
|
+
if (typeof args[key] === "string") return args[key] as string;
|
|
4
|
+
}
|
|
5
|
+
return null;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export const summarizeToolArgs = (args: Record<string, unknown>): string => {
|
|
9
|
+
const path = extractPath(args);
|
|
10
|
+
if (path) return `path=${path}`;
|
|
11
|
+
if (typeof args.command === "string") return `command=${args.command}`;
|
|
12
|
+
if (typeof args.query === "string") return `query=${args.query}`;
|
|
13
|
+
return Object.keys(args).join(", ");
|
|
14
|
+
};
|