@pi-unipi/compactor 2.6.1 → 2.9.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/README.md +5 -4
- package/package.json +7 -7
- package/skills/compactor/SKILL.md +1 -1
- package/skills/compactor-detail/SKILL.md +3 -5
- package/skills/compactor-doctor/SKILL.md +1 -1
- package/skills/compactor-stats/SKILL.md +1 -1
- package/src/commands/index.ts +47 -78
- package/src/compaction/brief.ts +161 -90
- package/src/compaction/build-sections.ts +3 -4
- package/src/compaction/compact-args.ts +86 -0
- package/src/compaction/cut.ts +270 -28
- package/src/compaction/drill-down.ts +261 -0
- package/src/compaction/format-recall.ts +96 -0
- package/src/compaction/format.ts +8 -3
- package/src/compaction/hooks.ts +248 -72
- package/src/compaction/merge.ts +34 -4
- package/src/compaction/rank.ts +270 -0
- package/src/compaction/recall-scope.ts +28 -0
- package/src/compaction/search-entries.ts +333 -96
- package/src/compaction/skill-collapse.ts +35 -0
- package/src/compaction/summarize.ts +37 -6
- package/src/compaction/token-estimate.ts +104 -0
- package/src/compaction/touched-files.ts +35 -0
- package/src/config/manager.ts +2 -27
- package/src/config/presets.ts +0 -2
- package/src/config/schema.ts +2 -16
- package/src/executor/executor.ts +6 -15
- package/src/executor/runtime.ts +2 -12
- package/src/index.ts +12 -122
- package/src/info-screen.ts +3 -10
- package/src/security/evaluator.ts +0 -53
- package/src/security/policy.ts +7 -8
- package/src/session/db.ts +0 -6
- package/src/tools/ctx-execute-file.ts +0 -5
- package/src/tools/register.ts +27 -50
- package/src/tools/vcc-recall.ts +86 -48
- package/src/tui/settings-overlay.ts +20 -40
- package/src/types.ts +43 -100
- package/src/display/diff-renderer.ts +0 -281
- package/src/display/line-width-safety.ts +0 -28
- package/src/display/render-utils.ts +0 -52
- package/src/display/thinking-label.ts +0 -18
- package/src/display/tool-overrides.ts +0 -136
- package/src/tools/compact.ts +0 -20
|
@@ -1,16 +1,36 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* Includes module-level index cache
|
|
2
|
+
* Hybrid search over normalized message blocks (pi-vcc parity):
|
|
3
|
+
* regex-pattern detection with keyword BM25 fallback, line snippets,
|
|
4
|
+
* and match-count ranking. Includes module-level index cache.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { NormalizedBlock } from "../types.js";
|
|
8
|
+
import { extractPath } from "./extract/files.js";
|
|
8
9
|
import { createHash } from "node:crypto";
|
|
9
10
|
|
|
11
|
+
export interface SearchHit {
|
|
12
|
+
docId: number;
|
|
13
|
+
score: number;
|
|
14
|
+
text: string;
|
|
15
|
+
kind: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Recall hit with optional snippet + file indicators (format-recall shape) */
|
|
19
|
+
export interface RecallHit {
|
|
20
|
+
index: number;
|
|
21
|
+
score: number;
|
|
22
|
+
text: string;
|
|
23
|
+
kind: string;
|
|
24
|
+
snippet?: string;
|
|
25
|
+
matchCount?: number;
|
|
26
|
+
files?: string[];
|
|
27
|
+
}
|
|
28
|
+
|
|
10
29
|
interface SearchDoc {
|
|
11
30
|
id: number;
|
|
12
31
|
text: string;
|
|
13
32
|
kind: string;
|
|
33
|
+
files: string[];
|
|
14
34
|
}
|
|
15
35
|
|
|
16
36
|
function tokenize(text: string): string[] {
|
|
@@ -21,6 +41,207 @@ function tokenize(text: string): string[] {
|
|
|
21
41
|
.filter((w) => w.length > 1);
|
|
22
42
|
}
|
|
23
43
|
|
|
44
|
+
// ── Regex safety (pi-vcc parity) ──────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
const escapeRegex = (s: string): string =>
|
|
47
|
+
s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
48
|
+
|
|
49
|
+
/** Quantifier starting at `i`, if any. Only unbounded forms (+, *, {n,}) can
|
|
50
|
+
* drive catastrophic backtracking. */
|
|
51
|
+
const quantifierAt = (p: string, i: number): { len: number; unbounded: boolean } => {
|
|
52
|
+
const c = p[i];
|
|
53
|
+
if (c === "+" || c === "*") return { len: 1, unbounded: true };
|
|
54
|
+
if (c === "{") {
|
|
55
|
+
const end = p.indexOf("}", i);
|
|
56
|
+
const body = end === -1 ? "" : p.slice(i + 1, end);
|
|
57
|
+
if (/^\d+(,\d*)?$/.test(body)) return { len: end - i + 1, unbounded: body.endsWith(",") };
|
|
58
|
+
}
|
|
59
|
+
return { len: 0, unbounded: false };
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Detect an unbounded quantifier applied to a group that already contains one,
|
|
64
|
+
* e.g. `(a+)+`. The search budget in `searchEntries` is the backstop.
|
|
65
|
+
*/
|
|
66
|
+
const hasNestedQuantifier = (pattern: string): boolean => {
|
|
67
|
+
const groups: boolean[] = [];
|
|
68
|
+
let inClass = false;
|
|
69
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
70
|
+
const c = pattern[i];
|
|
71
|
+
if (c === "\\") { i++; continue; }
|
|
72
|
+
if (inClass) { if (c === "]") inClass = false; continue; }
|
|
73
|
+
if (c === "[") { inClass = true; continue; }
|
|
74
|
+
if (c === "(") { groups.push(false); continue; }
|
|
75
|
+
if (c === ")") {
|
|
76
|
+
const inner = groups.pop() ?? false;
|
|
77
|
+
const q = quantifierAt(pattern, i + 1);
|
|
78
|
+
if (inner && q.unbounded) return true;
|
|
79
|
+
if (groups.length) groups[groups.length - 1] ||= inner || q.unbounded;
|
|
80
|
+
i += q.len;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const q = quantifierAt(pattern, i);
|
|
84
|
+
if (q.unbounded && groups.length) {
|
|
85
|
+
groups[groups.length - 1] = true;
|
|
86
|
+
i += q.len - 1;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return false;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/** Try to compile as regex; fall back to escaped literal. Patterns with nested
|
|
93
|
+
* unbounded quantifiers are treated as literals rather than compiled. */
|
|
94
|
+
const safeRegex = (pattern: string): RegExp => {
|
|
95
|
+
if (hasNestedQuantifier(pattern)) return new RegExp(escapeRegex(pattern), "i");
|
|
96
|
+
try {
|
|
97
|
+
return new RegExp(pattern, "i");
|
|
98
|
+
} catch {
|
|
99
|
+
return new RegExp(escapeRegex(pattern), "i");
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/** Wall-clock budget for one search. Per-entry checkpoint, not a hard ceiling. */
|
|
104
|
+
const SEARCH_BUDGET_MS = 3000;
|
|
105
|
+
|
|
106
|
+
const startBudget = (): (() => void) => {
|
|
107
|
+
const deadline = Date.now() + SEARCH_BUDGET_MS;
|
|
108
|
+
return () => {
|
|
109
|
+
if (Date.now() > deadline) {
|
|
110
|
+
throw new Error(
|
|
111
|
+
`Search aborted: query exceeded ${SEARCH_BUDGET_MS}ms. Simplify the pattern — ` +
|
|
112
|
+
"nested quantifiers such as (a+)+ can make matching blow up.",
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/** Detect if the query looks like a single regex pattern (contains metacharacters). */
|
|
119
|
+
const looksLikeRegex = (query: string): boolean =>
|
|
120
|
+
/[|*+?{}()[\]\\^$.]/.test(query);
|
|
121
|
+
|
|
122
|
+
/** Build a regex for snippet highlighting — matches first available term. */
|
|
123
|
+
const snippetRegex = (terms: string[]): RegExp => {
|
|
124
|
+
const alts = terms.map((t) => safeRegex(t).source);
|
|
125
|
+
return new RegExp(alts.join("|"), "i");
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// ── Stopwords for natural language queries ──
|
|
129
|
+
const STOPWORDS = new Set([
|
|
130
|
+
"the", "a", "an", "is", "are", "was", "were", "be", "been", "being",
|
|
131
|
+
"have", "has", "had", "do", "does", "did", "will", "would", "could",
|
|
132
|
+
"should", "may", "might", "can", "shall", "of", "in", "to", "for",
|
|
133
|
+
"with", "on", "at", "from", "by", "as", "into", "through", "during",
|
|
134
|
+
"before", "after", "above", "below", "between", "out", "off", "over",
|
|
135
|
+
"under", "again", "further", "then", "once", "here", "there", "when",
|
|
136
|
+
"where", "why", "how", "all", "both", "each", "few", "more", "most",
|
|
137
|
+
"other", "some", "such", "no", "nor", "not", "only", "own", "same",
|
|
138
|
+
"so", "than", "too", "very", "just", "about", "it", "its", "that",
|
|
139
|
+
"this", "what", "which", "who", "whom", "these", "those",
|
|
140
|
+
]);
|
|
141
|
+
|
|
142
|
+
const filterStopwords = (terms: string[]): string[] => {
|
|
143
|
+
const meaningful = terms.filter((t) => !STOPWORDS.has(t.toLowerCase()) && t.length > 1);
|
|
144
|
+
return meaningful.length > 0 ? meaningful : terms;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const countMatches = (hay: string, terms: string[]): number => {
|
|
148
|
+
let count = 0;
|
|
149
|
+
for (const t of terms) {
|
|
150
|
+
if (safeRegex(t).test(hay)) count++;
|
|
151
|
+
}
|
|
152
|
+
return count;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
// ── BM25 scoring ──────────────────────────────────────────────────────────
|
|
156
|
+
const BM25_K = 1.2;
|
|
157
|
+
const BM25_B = 0.75;
|
|
158
|
+
|
|
159
|
+
const termFreq = (text: string, pattern: RegExp): number => {
|
|
160
|
+
const matches = text.match(new RegExp(pattern.source, "gi"));
|
|
161
|
+
return matches ? matches.length : 0;
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
interface BM25Context {
|
|
165
|
+
n: number;
|
|
166
|
+
avgDl: number;
|
|
167
|
+
df: Map<string, number>;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const buildBM25Context = (docs: string[], terms: string[], checkBudget: () => void): BM25Context => {
|
|
171
|
+
const n = docs.length;
|
|
172
|
+
const df = new Map<string, number>();
|
|
173
|
+
let totalLen = 0;
|
|
174
|
+
|
|
175
|
+
for (const doc of docs) {
|
|
176
|
+
checkBudget();
|
|
177
|
+
totalLen += doc.split(/\s+/).length;
|
|
178
|
+
for (const t of terms) {
|
|
179
|
+
if (safeRegex(t).test(doc)) {
|
|
180
|
+
df.set(t, (df.get(t) ?? 0) + 1);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return { n, avgDl: totalLen / Math.max(n, 1), df };
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const bm25Score = (doc: string, terms: string[], ctx: BM25Context): number => {
|
|
189
|
+
const dl = doc.split(/\s+/).length;
|
|
190
|
+
let score = 0;
|
|
191
|
+
|
|
192
|
+
for (const t of terms) {
|
|
193
|
+
const tf = termFreq(doc, safeRegex(t));
|
|
194
|
+
if (tf === 0) continue;
|
|
195
|
+
|
|
196
|
+
const docFreq = ctx.df.get(t) ?? 0;
|
|
197
|
+
const idf = Math.log((ctx.n - docFreq + 0.5) / (docFreq + 0.5) + 1);
|
|
198
|
+
const tfNorm = (tf * (BM25_K + 1)) / (tf + BM25_K * (1 - BM25_B + BM25_B * dl / ctx.avgDl));
|
|
199
|
+
score += idf * tfNorm;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return score;
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
/** Line-based snippet: ±contextLines around first regex match. */
|
|
206
|
+
const lineSnippet = (text: string, regex: RegExp, contextLines = 2): string | undefined => {
|
|
207
|
+
const lines = text.split("\n");
|
|
208
|
+
let matchIdx = -1;
|
|
209
|
+
for (let i = 0; i < lines.length; i++) {
|
|
210
|
+
if (regex.test(lines[i])) {
|
|
211
|
+
matchIdx = i;
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (matchIdx === -1) return undefined;
|
|
216
|
+
|
|
217
|
+
const start = Math.max(0, matchIdx - contextLines);
|
|
218
|
+
const end = Math.min(lines.length, matchIdx + contextLines + 1);
|
|
219
|
+
const slice = lines.slice(start, end);
|
|
220
|
+
|
|
221
|
+
const parts: string[] = [];
|
|
222
|
+
if (start > 0) parts.push(`...(${start} lines above)`);
|
|
223
|
+
parts.push(...slice);
|
|
224
|
+
if (end < lines.length) parts.push(`...(${lines.length - end} lines below)`);
|
|
225
|
+
return parts.join("\n");
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
// ── Block → doc text ──────────────────────────────────────────────────────
|
|
229
|
+
|
|
230
|
+
const blockFiles = (b: NormalizedBlock): string[] => {
|
|
231
|
+
if (b.kind !== "tool_call") return [];
|
|
232
|
+
const p = extractPath(b.args);
|
|
233
|
+
return p ? [p] : [];
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
const blockText = (b: NormalizedBlock): string =>
|
|
237
|
+
b.kind === "tool_call" ? `${b.name} ${JSON.stringify(b.args)}` : b.kind === "tool_result" ? `${b.name} ${b.text}` : b.text;
|
|
238
|
+
|
|
239
|
+
// ── Module-level index cache ──────────────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
let cachedIndexHash = "";
|
|
242
|
+
let cachedIndex: Map<string, number[]> | null = null;
|
|
243
|
+
let cachedDocs: SearchDoc[] = [];
|
|
244
|
+
|
|
24
245
|
function buildIndex(docs: SearchDoc[]): Map<string, number[]> {
|
|
25
246
|
const index = new Map<string, number[]>();
|
|
26
247
|
for (const doc of docs) {
|
|
@@ -34,115 +255,131 @@ function buildIndex(docs: SearchDoc[]): Map<string, number[]> {
|
|
|
34
255
|
return index;
|
|
35
256
|
}
|
|
36
257
|
|
|
37
|
-
function
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
avgDocLen: number,
|
|
43
|
-
docLens: Map<number, number>,
|
|
44
|
-
): number {
|
|
45
|
-
const k1 = 1.5;
|
|
46
|
-
const b = 0.75;
|
|
47
|
-
let score = 0;
|
|
48
|
-
const docLen = docLens.get(docId) ?? 1;
|
|
49
|
-
|
|
50
|
-
for (const token of queryTokens) {
|
|
51
|
-
const postings = index.get(token) ?? [];
|
|
52
|
-
const df = new Set(postings).size;
|
|
53
|
-
if (df === 0) continue;
|
|
54
|
-
const tf = postings.filter((id) => id === docId).length;
|
|
55
|
-
const idf = Math.log((docCount - df + 0.5) / (df + 0.5) + 1);
|
|
56
|
-
score += idf * ((tf * (k1 + 1)) / (tf + k1 * (1 - b + b * (docLen / avgDocLen))));
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
return score;
|
|
60
|
-
}
|
|
258
|
+
function getCachedIndex(docs: SearchDoc[]): { index: Map<string, number[]>; docs: SearchDoc[] } {
|
|
259
|
+
const hashSource = docs.length > 0
|
|
260
|
+
? `${docs.length}:${docs[0].text.slice(0, 80)}:${docs[docs.length - 1].text.slice(-80)}`
|
|
261
|
+
: "empty";
|
|
262
|
+
const currentHash = createHash("sha256").update(hashSource).digest("hex");
|
|
61
263
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
264
|
+
if (currentHash === cachedIndexHash && cachedIndex && cachedDocs.length === docs.length) {
|
|
265
|
+
return { index: cachedIndex, docs: cachedDocs };
|
|
266
|
+
}
|
|
267
|
+
const index = buildIndex(docs);
|
|
268
|
+
cachedIndexHash = currentHash;
|
|
269
|
+
cachedIndex = index;
|
|
270
|
+
cachedDocs = docs;
|
|
271
|
+
return { index, docs };
|
|
67
272
|
}
|
|
68
273
|
|
|
69
|
-
//
|
|
70
|
-
let cachedIndexHash = "";
|
|
71
|
-
let cachedDocs: SearchDoc[] = [];
|
|
72
|
-
let cachedIndex: Map<string, number[]> | null = null;
|
|
73
|
-
let cachedDocCount = 0;
|
|
74
|
-
let cachedAvgDocLen = 0;
|
|
75
|
-
let cachedDocLens: Map<number, number> = new Map();
|
|
76
|
-
|
|
77
|
-
export function invalidateSearchCache(): void {
|
|
78
|
-
cachedIndexHash = "";
|
|
79
|
-
cachedDocs = [];
|
|
80
|
-
cachedIndex = null;
|
|
81
|
-
cachedDocCount = 0;
|
|
82
|
-
cachedAvgDocLen = 0;
|
|
83
|
-
cachedDocLens = new Map();
|
|
84
|
-
}
|
|
274
|
+
// ── Main search ───────────────────────────────────────────────────────────
|
|
85
275
|
|
|
86
|
-
|
|
276
|
+
/**
|
|
277
|
+
* Search blocks. When `query` is empty/omitted, returns all blocks as hits
|
|
278
|
+
* (caller decides recent-window slicing).
|
|
279
|
+
*/
|
|
280
|
+
export const searchEntries = (
|
|
87
281
|
blocks: NormalizedBlock[],
|
|
88
|
-
query
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const docs: SearchDoc[] = blocks.map((b, i) => ({
|
|
282
|
+
query?: string,
|
|
283
|
+
): RecallHit[] => {
|
|
284
|
+
const allDocs: SearchDoc[] = blocks.map((b, i) => ({
|
|
92
285
|
id: i,
|
|
93
|
-
text:
|
|
286
|
+
text: blockText(b),
|
|
94
287
|
kind: b.kind,
|
|
288
|
+
files: blockFiles(b),
|
|
95
289
|
}));
|
|
96
290
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
let docCount: number;
|
|
106
|
-
let avgDocLen: number;
|
|
107
|
-
let docLens: Map<number, number>;
|
|
108
|
-
|
|
109
|
-
if (currentHash === cachedIndexHash && cachedIndex) {
|
|
110
|
-
index = cachedIndex;
|
|
111
|
-
docCount = cachedDocCount;
|
|
112
|
-
avgDocLen = cachedAvgDocLen;
|
|
113
|
-
docLens = cachedDocLens;
|
|
114
|
-
} else {
|
|
115
|
-
index = buildIndex(docs);
|
|
116
|
-
docCount = docs.length;
|
|
117
|
-
docLens = new Map(docs.map((d) => [d.id, tokenize(d.text).length]));
|
|
118
|
-
avgDocLen = docCount > 0 ? [...docLens.values()].reduce((a, b) => a + b, 0) / docCount : 1;
|
|
119
|
-
|
|
120
|
-
// Update cache
|
|
121
|
-
cachedIndexHash = currentHash;
|
|
122
|
-
cachedDocs = docs;
|
|
123
|
-
cachedIndex = index;
|
|
124
|
-
cachedDocCount = docCount;
|
|
125
|
-
cachedAvgDocLen = avgDocLen;
|
|
126
|
-
cachedDocLens = docLens;
|
|
291
|
+
if (!query?.trim()) {
|
|
292
|
+
return allDocs.map((d) => ({
|
|
293
|
+
index: d.id,
|
|
294
|
+
score: 0,
|
|
295
|
+
text: d.text,
|
|
296
|
+
kind: d.kind,
|
|
297
|
+
files: d.files.length > 0 ? d.files : undefined,
|
|
298
|
+
}));
|
|
127
299
|
}
|
|
128
300
|
|
|
129
|
-
const
|
|
130
|
-
|
|
301
|
+
const rawQuery = query.trim();
|
|
302
|
+
const checkBudget = startBudget();
|
|
131
303
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
304
|
+
// If the query looks like a single regex pattern (contains metacharacters),
|
|
305
|
+
// treat the whole thing as one pattern — don't split into terms. Detection
|
|
306
|
+
// is deliberately loose (ordinary prose trips it), so an empty regex result
|
|
307
|
+
// falls through to term search below — mode detection must never silently
|
|
308
|
+
// lose results.
|
|
309
|
+
if (looksLikeRegex(rawQuery)) {
|
|
310
|
+
const regex = safeRegex(rawQuery);
|
|
311
|
+
const hits: RecallHit[] = [];
|
|
312
|
+
for (let i = 0; i < allDocs.length; i++) {
|
|
313
|
+
checkBudget();
|
|
314
|
+
const d = allDocs[i];
|
|
315
|
+
const hay = `${d.kind} ${d.text} ${d.files.join(" ")}`;
|
|
316
|
+
if (regex.test(hay)) {
|
|
317
|
+
const snip = lineSnippet(d.text, regex);
|
|
318
|
+
hits.push({ index: d.id, score: 1, text: d.text, kind: d.kind, snippet: snip, matchCount: 1, files: d.files.length > 0 ? d.files : undefined });
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
if (hits.length > 0) return hits;
|
|
136
322
|
}
|
|
137
323
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
324
|
+
// Natural language / multi-word query: BM25 scoring
|
|
325
|
+
const rawTerms = rawQuery.split(/\s+/);
|
|
326
|
+
const terms = filterStopwords(rawTerms);
|
|
327
|
+
const snipRe = snippetRegex(terms);
|
|
328
|
+
|
|
329
|
+
const docs = allDocs.map((d) => `${d.kind} ${d.text} ${d.files.join(" ")}`);
|
|
330
|
+
const ctx = buildBM25Context(docs, terms, checkBudget);
|
|
331
|
+
|
|
332
|
+
const scored: Array<{ hit: RecallHit; score: number }> = [];
|
|
333
|
+
for (let i = 0; i < allDocs.length; i++) {
|
|
334
|
+
checkBudget();
|
|
335
|
+
const d = allDocs[i];
|
|
336
|
+
const hay = docs[i];
|
|
337
|
+
const mc = countMatches(hay, terms);
|
|
338
|
+
if (mc === 0) continue;
|
|
339
|
+
const score = bm25Score(hay, terms, ctx);
|
|
340
|
+
const snip = lineSnippet(d.text, snipRe);
|
|
341
|
+
scored.push({
|
|
342
|
+
hit: {
|
|
343
|
+
index: d.id,
|
|
344
|
+
score,
|
|
345
|
+
text: d.text,
|
|
346
|
+
kind: d.kind,
|
|
347
|
+
snippet: snip,
|
|
348
|
+
matchCount: mc,
|
|
349
|
+
files: d.files.length > 0 ? d.files : undefined,
|
|
350
|
+
},
|
|
351
|
+
score,
|
|
143
352
|
});
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
scored.sort((a, b) => b.score - a.score);
|
|
356
|
+
return scored.map((s) => s.hit);
|
|
357
|
+
};
|
|
144
358
|
|
|
359
|
+
/** Backwards-compatible BM25-only search over blocks (legacy callers). */
|
|
360
|
+
export function searchEntriesLegacy(
|
|
361
|
+
blocks: NormalizedBlock[],
|
|
362
|
+
query: string,
|
|
363
|
+
opts?: { limit?: number; offset?: number },
|
|
364
|
+
): SearchHit[] {
|
|
365
|
+
const hits = searchEntries(blocks, query);
|
|
145
366
|
const offset = opts?.offset ?? 0;
|
|
146
367
|
const limit = opts?.limit ?? 10;
|
|
147
|
-
return
|
|
368
|
+
return hits.slice(offset, offset + limit).map((h) => ({
|
|
369
|
+
docId: h.index,
|
|
370
|
+
score: h.score,
|
|
371
|
+
text: h.text.slice(0, 300),
|
|
372
|
+
kind: h.kind,
|
|
373
|
+
}));
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Keep the token-index cache warm for repeated legacy queries.
|
|
377
|
+
export function warmIndexCache(blocks: NormalizedBlock[]): void {
|
|
378
|
+
const docs: SearchDoc[] = blocks.map((b, i) => ({
|
|
379
|
+
id: i,
|
|
380
|
+
text: blockText(b),
|
|
381
|
+
kind: b.kind,
|
|
382
|
+
files: blockFiles(b),
|
|
383
|
+
}));
|
|
384
|
+
getCachedIndex(docs);
|
|
148
385
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** Shared skill-tag collapse utilities (pi-vcc parity) */
|
|
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}]`);
|
|
@@ -3,27 +3,58 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import type { Message } from "@earendil-works/pi-ai";
|
|
6
|
-
import type { CompileInput, FileOps } from "../types.js";
|
|
6
|
+
import type { CompileInput, FileOps, NormalizedBlock } from "../types.js";
|
|
7
7
|
import { normalizeMessages } from "./normalize.js";
|
|
8
8
|
import { filterNoise } from "./filter-noise.js";
|
|
9
9
|
import { buildSections } from "./build-sections.js";
|
|
10
10
|
import { formatSummary, RECALL_NOTE } from "./format.js";
|
|
11
|
-
import { mergePrevious } from "./merge.js";
|
|
11
|
+
import { mergePrevious, mergeBriefTranscriptWithFreshBudget } from "./merge.js";
|
|
12
|
+
import { selectRankedBriefBlocks, type BriefRankingOptions } from "./rank.js";
|
|
12
13
|
|
|
13
|
-
export
|
|
14
|
+
export interface RankedCompileInput extends CompileInput {
|
|
15
|
+
fileOps?: FileOps;
|
|
16
|
+
ranking?: BriefRankingOptions;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface CompileWithBriefBlocksOptions {
|
|
20
|
+
briefBlocksFor?: (blocks: NormalizedBlock[]) => NormalizedBlock[];
|
|
21
|
+
capFreshBrief?: boolean;
|
|
22
|
+
preserveFreshBriefOnMerge?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const compileWithBriefBlocks = (input: CompileInput, options: CompileWithBriefBlocksOptions = {}): string => {
|
|
14
26
|
const blocks = filterNoise(normalizeMessages(input.messages));
|
|
15
|
-
const
|
|
16
|
-
const
|
|
27
|
+
const briefBlocks = options.briefBlocksFor?.(blocks);
|
|
28
|
+
const data = buildSections({ blocks, briefBlocks });
|
|
29
|
+
const fresh = formatSummary(data, { capBriefTranscript: options.capFreshBrief ?? true });
|
|
17
30
|
const prev = input.previousSummary
|
|
18
31
|
? stripRecallNote(input.previousSummary)
|
|
19
32
|
: undefined;
|
|
20
|
-
const merged = prev
|
|
33
|
+
const merged = prev
|
|
34
|
+
? mergePrevious(prev, fresh, { preserveFreshBrief: options.preserveFreshBriefOnMerge })
|
|
35
|
+
: fresh;
|
|
21
36
|
if (!merged) return "";
|
|
22
37
|
return merged + "\n\n---\n\n" + RECALL_NOTE;
|
|
23
38
|
};
|
|
24
39
|
|
|
40
|
+
export const compile = (input: CompileInput): string =>
|
|
41
|
+
compileWithBriefBlocks(input);
|
|
42
|
+
|
|
43
|
+
export const compileRanked = (input: RankedCompileInput): string =>
|
|
44
|
+
compileWithBriefBlocks(input, {
|
|
45
|
+
briefBlocksFor: (blocks) =>
|
|
46
|
+
selectRankedBriefBlocks(blocks, {
|
|
47
|
+
...input.ranking,
|
|
48
|
+
fileOps: input.ranking?.fileOps ?? input.fileOps,
|
|
49
|
+
}),
|
|
50
|
+
capFreshBrief: false,
|
|
51
|
+
preserveFreshBriefOnMerge: true,
|
|
52
|
+
});
|
|
53
|
+
|
|
25
54
|
const stripRecallNote = (text: string): string => {
|
|
26
55
|
const idx = text.lastIndexOf(RECALL_NOTE);
|
|
27
56
|
if (idx < 0) return text;
|
|
28
57
|
return text.slice(0, idx).replace(/\s*(?:\n\n---\n\n)?\s*$/, "").trimEnd();
|
|
29
58
|
};
|
|
59
|
+
|
|
60
|
+
export { mergeBriefTranscriptWithFreshBudget };
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token estimation with per-session calibration (ported from pi-vcc token-estimate.ts)
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_CHARS_PER_TOKEN = 4;
|
|
6
|
+
export const MIN_CHARS_PER_TOKEN = 2;
|
|
7
|
+
export const MAX_CHARS_PER_TOKEN = 6;
|
|
8
|
+
|
|
9
|
+
export type TokenEstimateMode = "heuristic" | "calibrated";
|
|
10
|
+
|
|
11
|
+
export interface TokenEstimateCalibration {
|
|
12
|
+
mode: TokenEstimateMode;
|
|
13
|
+
charsPerToken: number;
|
|
14
|
+
sourceChars?: number;
|
|
15
|
+
sourceTokens?: number;
|
|
16
|
+
rawCharsPerToken?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const clamp = (value: number, min: number, max: number): number =>
|
|
20
|
+
Math.min(max, Math.max(min, value));
|
|
21
|
+
|
|
22
|
+
export const calibrateCharsPerToken = (
|
|
23
|
+
sourceChars: number,
|
|
24
|
+
sourceTokens: number | undefined,
|
|
25
|
+
): TokenEstimateCalibration => {
|
|
26
|
+
if (!sourceTokens || sourceTokens <= 0 || sourceChars <= 0) {
|
|
27
|
+
return { mode: "heuristic", charsPerToken: DEFAULT_CHARS_PER_TOKEN };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const rawCharsPerToken = sourceChars / sourceTokens;
|
|
31
|
+
if (!Number.isFinite(rawCharsPerToken) || rawCharsPerToken <= 0) {
|
|
32
|
+
return { mode: "heuristic", charsPerToken: DEFAULT_CHARS_PER_TOKEN };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
mode: "calibrated",
|
|
37
|
+
charsPerToken: clamp(rawCharsPerToken, MIN_CHARS_PER_TOKEN, MAX_CHARS_PER_TOKEN),
|
|
38
|
+
sourceChars,
|
|
39
|
+
sourceTokens,
|
|
40
|
+
rawCharsPerToken,
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const estimateTokensFromChars = (
|
|
45
|
+
chars: number,
|
|
46
|
+
charsPerToken = DEFAULT_CHARS_PER_TOKEN,
|
|
47
|
+
): number => Math.ceil(chars / charsPerToken);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Chars attributed to one image part, mirroring pi-agent-core's own
|
|
51
|
+
* estimateTokens heuristic (4800 chars ≈ 1200 tokens at 4 chars/token).
|
|
52
|
+
*/
|
|
53
|
+
export const IMAGE_CONTENT_CHARS = 4800;
|
|
54
|
+
|
|
55
|
+
const safeJsonStringify = (value: unknown): string => {
|
|
56
|
+
try {
|
|
57
|
+
return JSON.stringify(value ?? "") ?? "";
|
|
58
|
+
} catch {
|
|
59
|
+
return "";
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Estimate the char length of a message's content (string or content-parts
|
|
65
|
+
* array). Counts every token-bearing part that pi-agent-core's harness
|
|
66
|
+
* estimateTokens counts, so the calibrated chars/token ratio is not deflated:
|
|
67
|
+
* - text → text.length
|
|
68
|
+
* - thinking → thinking.length (opus emits large reasoning blocks)
|
|
69
|
+
* - toolCall → name + arguments (Pi uses `arguments`; `input` kept for compat)
|
|
70
|
+
* - image → IMAGE_CONTENT_CHARS
|
|
71
|
+
* - toolResult → nested content (legacy part shape)
|
|
72
|
+
*/
|
|
73
|
+
export const estimateMessageContentChars = (content: unknown): number => {
|
|
74
|
+
if (typeof content === "string") return content.length;
|
|
75
|
+
if (!Array.isArray(content)) return 0;
|
|
76
|
+
return content.reduce((sum: number, part: any) => {
|
|
77
|
+
if (!part || typeof part !== "object") return sum;
|
|
78
|
+
switch (part.type) {
|
|
79
|
+
case "text":
|
|
80
|
+
return sum + (typeof part.text === "string" ? part.text.length : 0);
|
|
81
|
+
case "thinking":
|
|
82
|
+
return sum + (typeof part.thinking === "string" ? part.thinking.length : 0);
|
|
83
|
+
case "toolCall": {
|
|
84
|
+
const args = part.arguments ?? part.input;
|
|
85
|
+
const argLength = typeof args === "string" ? args.length : safeJsonStringify(args).length;
|
|
86
|
+
return sum + (part.name?.length ?? 0) + argLength;
|
|
87
|
+
}
|
|
88
|
+
case "toolResult": {
|
|
89
|
+
const c = part.content;
|
|
90
|
+
return sum + (typeof c === "string" ? c.length : safeJsonStringify(c).length);
|
|
91
|
+
}
|
|
92
|
+
case "image":
|
|
93
|
+
return sum + IMAGE_CONTENT_CHARS;
|
|
94
|
+
default:
|
|
95
|
+
// Unknown part: fall back to any text field so we never undercount.
|
|
96
|
+
return sum + (typeof part.text === "string" ? part.text.length : 0);
|
|
97
|
+
}
|
|
98
|
+
}, 0);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export const estimateMessageContentTokens = (
|
|
102
|
+
content: unknown,
|
|
103
|
+
charsPerToken = DEFAULT_CHARS_PER_TOKEN,
|
|
104
|
+
): number => estimateTokensFromChars(estimateMessageContentChars(content), charsPerToken);
|