ziprin-context-optimizer 8.0.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 +48 -0
- package/bin/ziprin-context-mcp.js +34 -0
- package/bin/ziprin-context-setup.js +10 -0
- package/install-source.json +5 -0
- package/media/context-activity.svg +7 -0
- package/package.json +154 -0
- package/releases/ziprin-context-optimizer-8.0.0.vsix +0 -0
- package/scripts/install.mjs +204 -0
- package/scripts/package-vsix.mjs +38 -0
- package/scripts/paths.mjs +40 -0
- package/scripts/publish.mjs +37 -0
- package/scripts/update.mjs +101 -0
- package/src/adaptive-budget.js +46 -0
- package/src/analyzer.js +456 -0
- package/src/audit-history.js +137 -0
- package/src/bm25.js +132 -0
- package/src/collector.js +343 -0
- package/src/compression.js +67 -0
- package/src/config.js +28 -0
- package/src/context-memory.js +151 -0
- package/src/context-profiles.js +124 -0
- package/src/dependency-graph.js +90 -0
- package/src/estimate.js +109 -0
- package/src/eval-harness.js +76 -0
- package/src/extension.js +322 -0
- package/src/firewall.js +35 -0
- package/src/fts-index.js +319 -0
- package/src/gateway-api.js +404 -0
- package/src/git-recency.js +43 -0
- package/src/glob.js +42 -0
- package/src/identifier.js +37 -0
- package/src/inspector-panel.js +470 -0
- package/src/language.js +157 -0
- package/src/mcp-event-stream.js +179 -0
- package/src/mcp-health-cli.js +34 -0
- package/src/mcp-lifecycle-manager.js +427 -0
- package/src/mcp-server.js +372 -0
- package/src/mmr.js +57 -0
- package/src/pagerank.js +176 -0
- package/src/profiles.js +74 -0
- package/src/pruner.js +130 -0
- package/src/quality-guard.js +122 -0
- package/src/query-rewrite.js +32 -0
- package/src/relevance-scorer.js +197 -0
- package/src/repo-map.js +134 -0
- package/src/retrieve.js +350 -0
- package/src/serena.js +126 -0
- package/src/session-ledger.js +83 -0
- package/src/session-store.js +78 -0
- package/src/skeleton.js +129 -0
- package/src/slice-pack.js +70 -0
- package/src/supervisor.js +113 -0
- package/src/task-analyzer.js +189 -0
- package/src/tool-router.js +92 -0
- package/src/version.js +5 -0
- package/templates/ziprin-context-mcp.mjs +68 -0
package/src/retrieve.js
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Hybrid retrieval v8:
|
|
5
|
+
* FTS5 BM25F + path BM25 + git dirty/recency + Personalized PageRank
|
|
6
|
+
* → weighted RRF → cascade (skip grep when top is clear) → MMR.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const { spawnSync } = require('child_process');
|
|
12
|
+
const { isTokenBomb } = require('./pruner');
|
|
13
|
+
const { bm25Path } = require('./bm25');
|
|
14
|
+
const { splitMany, tokenizePath } = require('./identifier');
|
|
15
|
+
const { ensureIndex, searchFts } = require('./fts-index');
|
|
16
|
+
const { rankGraph } = require('./pagerank');
|
|
17
|
+
const { mmrSelect } = require('./mmr');
|
|
18
|
+
const { gitRecency } = require('./git-recency');
|
|
19
|
+
|
|
20
|
+
const SOURCE_EXT = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.css', '.md', '.mdc']);
|
|
21
|
+
const SKIP_DIR = new Set(['node_modules', '.git', 'dist', 'build', '.next', 'coverage', '.turbo', '.ziprin-context']);
|
|
22
|
+
|
|
23
|
+
function rrf(lists, k = 60) {
|
|
24
|
+
const scores = new Map();
|
|
25
|
+
for (const list of lists) {
|
|
26
|
+
list.forEach((item, i) => {
|
|
27
|
+
const p = typeof item === 'string' ? item : item.path;
|
|
28
|
+
if (!p) return;
|
|
29
|
+
scores.set(p, (scores.get(p) || 0) + 1 / (k + i + 1));
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
return [...scores.entries()]
|
|
33
|
+
.sort((a, b) => b[1] - a[1])
|
|
34
|
+
.map(([p, score]) => ({ path: p, rrf: score }));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function weightedRrf(weightedLists, k = 60) {
|
|
38
|
+
const scores = new Map();
|
|
39
|
+
for (const { list, weight } of weightedLists || []) {
|
|
40
|
+
const w = weight == null ? 1 : weight;
|
|
41
|
+
(list || []).forEach((item, i) => {
|
|
42
|
+
const p = typeof item === 'string' ? item : item.path;
|
|
43
|
+
if (!p) return;
|
|
44
|
+
scores.set(p, (scores.get(p) || 0) + w / (k + i + 1));
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
return [...scores.entries()]
|
|
48
|
+
.sort((a, b) => b[1] - a[1])
|
|
49
|
+
.map(([p, score]) => ({ path: p, rrf: score }));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function gitDirty(root) {
|
|
53
|
+
try {
|
|
54
|
+
const r = spawnSync('git', ['-C', root, 'status', '--porcelain', '-uall'], {
|
|
55
|
+
encoding: 'utf8',
|
|
56
|
+
timeout: 4000,
|
|
57
|
+
});
|
|
58
|
+
if (r.status !== 0) return [];
|
|
59
|
+
return String(r.stdout || '')
|
|
60
|
+
.split('\n')
|
|
61
|
+
.map((line) => line.slice(3).trim().replace(/\\/g, '/'))
|
|
62
|
+
.filter((p) => p && SOURCE_EXT.has(path.extname(p)) && !isTokenBomb(p));
|
|
63
|
+
} catch {
|
|
64
|
+
return [];
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function gitDiffNames(root) {
|
|
69
|
+
try {
|
|
70
|
+
const r = spawnSync('git', ['-C', root, 'diff', '--name-only', 'HEAD'], {
|
|
71
|
+
encoding: 'utf8',
|
|
72
|
+
timeout: 4000,
|
|
73
|
+
});
|
|
74
|
+
if (r.status !== 0) return [];
|
|
75
|
+
return String(r.stdout || '')
|
|
76
|
+
.split('\n')
|
|
77
|
+
.map((p) => p.trim().replace(/\\/g, '/'))
|
|
78
|
+
.filter((p) => p && SOURCE_EXT.has(path.extname(p)) && !isTokenBomb(p));
|
|
79
|
+
} catch {
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function walkLimited(abs, root, acc, max, hints) {
|
|
85
|
+
if (acc.length >= max) return;
|
|
86
|
+
let entries;
|
|
87
|
+
try {
|
|
88
|
+
entries = fs.readdirSync(abs, { withFileTypes: true });
|
|
89
|
+
} catch {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const ranked = entries.slice().sort((a, b) => {
|
|
93
|
+
const ha = hints.some((h) => a.name.includes(h)) ? 0 : 1;
|
|
94
|
+
const hb = hints.some((h) => b.name.includes(h)) ? 0 : 1;
|
|
95
|
+
if (ha !== hb) return ha - hb;
|
|
96
|
+
return a.name.localeCompare(b.name);
|
|
97
|
+
});
|
|
98
|
+
for (const ent of ranked) {
|
|
99
|
+
if (acc.length >= max) return;
|
|
100
|
+
if (ent.isDirectory()) {
|
|
101
|
+
if (SKIP_DIR.has(ent.name)) continue;
|
|
102
|
+
walkLimited(path.join(abs, ent.name), root, acc, max, hints);
|
|
103
|
+
} else {
|
|
104
|
+
const rel = path.relative(root, path.join(abs, ent.name)).replace(/\\/g, '/');
|
|
105
|
+
if (!SOURCE_EXT.has(path.extname(rel))) continue;
|
|
106
|
+
if (isTokenBomb(rel)) continue;
|
|
107
|
+
acc.push(rel);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function collectPathUniverse(root, pathHints, domain, max = 80) {
|
|
113
|
+
const seeds = [];
|
|
114
|
+
if (domain?.priorityPaths) seeds.push(...domain.priorityPaths);
|
|
115
|
+
for (const h of pathHints || []) {
|
|
116
|
+
if (h.includes('/')) seeds.push(h);
|
|
117
|
+
seeds.push(`apps/dashboard/bss/src/portals/vendor/${h}`);
|
|
118
|
+
seeds.push(`apps/marketplace/src/features/${h}`);
|
|
119
|
+
}
|
|
120
|
+
seeds.push('apps/dashboard/bss/src', 'apps/marketplace/src', 'packages/shared/src', 'packages/ui/src');
|
|
121
|
+
|
|
122
|
+
const files = [];
|
|
123
|
+
const seen = new Set();
|
|
124
|
+
for (const seed of seeds) {
|
|
125
|
+
const abs = path.join(root, seed);
|
|
126
|
+
if (!fs.existsSync(abs)) continue;
|
|
127
|
+
const bucket = [];
|
|
128
|
+
if (fs.statSync(abs).isFile()) bucket.push(seed.replace(/\\/g, '/'));
|
|
129
|
+
else walkLimited(abs, root, bucket, 50, pathHints || []);
|
|
130
|
+
for (const p of bucket) {
|
|
131
|
+
if (seen.has(p)) continue;
|
|
132
|
+
seen.add(p);
|
|
133
|
+
files.push(p);
|
|
134
|
+
}
|
|
135
|
+
if (files.length >= max) break;
|
|
136
|
+
}
|
|
137
|
+
return files;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function queryTerms(analysis) {
|
|
141
|
+
const bag = [
|
|
142
|
+
...(analysis.expanded || analysis.tokens || []),
|
|
143
|
+
...(analysis.symbols || []),
|
|
144
|
+
...(analysis.pathHints || []),
|
|
145
|
+
...splitMany(analysis.symbols || []),
|
|
146
|
+
...splitMany(analysis.pathHints || []),
|
|
147
|
+
];
|
|
148
|
+
return [...new Set(bag.map((t) => String(t).toLowerCase()).filter((t) => t.length > 1))];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function pathRank(root, tokens, pathHints, domain, max = 80) {
|
|
152
|
+
const files = collectPathUniverse(root, pathHints, domain, max);
|
|
153
|
+
const q = [
|
|
154
|
+
...(tokens || []).filter((t) => t.length > 2),
|
|
155
|
+
...(pathHints || []),
|
|
156
|
+
...splitMany(pathHints || []),
|
|
157
|
+
].map((t) => String(t).toLowerCase());
|
|
158
|
+
const ranked = bm25Path(files, q);
|
|
159
|
+
const boosted = ranked.map((r) => {
|
|
160
|
+
const low = r.path.toLowerCase();
|
|
161
|
+
let s = r.score;
|
|
162
|
+
for (const h of pathHints || []) if (low.includes(h)) s += 2.5;
|
|
163
|
+
if (domain?.priorityPaths?.some((d) => low.startsWith(d.toLowerCase()) || low.includes(d.toLowerCase()))) s += 1.2;
|
|
164
|
+
return { path: r.path, score: s };
|
|
165
|
+
});
|
|
166
|
+
return boosted.sort((a, b) => b.score - a.score).slice(0, max);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function rgAvailable() {
|
|
170
|
+
try {
|
|
171
|
+
const r = spawnSync('rg', ['--version'], { encoding: 'utf8', timeout: 1500 });
|
|
172
|
+
return r.status === 0;
|
|
173
|
+
} catch {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function contentGrepRg(root, needles, maxHits = 40) {
|
|
179
|
+
const n = (needles || []).filter((x) => x && x.length > 2).slice(0, 8);
|
|
180
|
+
if (!n.length) return [];
|
|
181
|
+
try {
|
|
182
|
+
const pattern = n.map((x) => x.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|');
|
|
183
|
+
const r = spawnSync(
|
|
184
|
+
'rg',
|
|
185
|
+
['--json', '-i', '-l', '-g', '!node_modules', '-g', '!.git', '-g', '!dist', pattern, root],
|
|
186
|
+
{ encoding: 'utf8', timeout: 4000, maxBuffer: 2_000_000 },
|
|
187
|
+
);
|
|
188
|
+
const hits = [];
|
|
189
|
+
for (const line of String(r.stdout || '').split('\n')) {
|
|
190
|
+
if (!line.trim()) continue;
|
|
191
|
+
try {
|
|
192
|
+
const j = JSON.parse(line);
|
|
193
|
+
if (j.type !== 'path' && !(j.data && j.data.path)) continue;
|
|
194
|
+
const p = (j.data.path.text || '').replace(/\\/g, '/');
|
|
195
|
+
const rel = path.relative(root, p).replace(/\\/g, '/');
|
|
196
|
+
if (rel && SOURCE_EXT.has(path.extname(rel)) && !isTokenBomb(rel)) {
|
|
197
|
+
hits.push({ path: rel, score: 1 });
|
|
198
|
+
}
|
|
199
|
+
} catch {
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (hits.length >= maxHits) break;
|
|
203
|
+
}
|
|
204
|
+
return hits;
|
|
205
|
+
} catch {
|
|
206
|
+
return [];
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function contentGrep(root, files, needles, maxHits = 40) {
|
|
211
|
+
if (rgAvailable()) {
|
|
212
|
+
const rgHits = contentGrepRg(root, needles, maxHits);
|
|
213
|
+
if (rgHits.length) return rgHits;
|
|
214
|
+
}
|
|
215
|
+
const hits = [];
|
|
216
|
+
const n = (needles || []).filter((x) => x && x.length > 2).slice(0, 12);
|
|
217
|
+
if (!n.length) return hits;
|
|
218
|
+
for (const rel of files.slice(0, 120)) {
|
|
219
|
+
const abs = path.join(root, rel);
|
|
220
|
+
let text = '';
|
|
221
|
+
try {
|
|
222
|
+
text = fs.readFileSync(abs, 'utf8').slice(0, 16000).toLowerCase();
|
|
223
|
+
} catch {
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
let score = 0;
|
|
227
|
+
for (const needle of n) {
|
|
228
|
+
if (text.includes(needle.toLowerCase())) score += 1;
|
|
229
|
+
}
|
|
230
|
+
if (score) hits.push({ path: rel, score });
|
|
231
|
+
if (hits.length >= maxHits) break;
|
|
232
|
+
}
|
|
233
|
+
return hits.sort((a, b) => b.score - a.score);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function shouldCascadeSkip(ftsHits, pathList) {
|
|
237
|
+
const a = ftsHits[0]?.score || 0;
|
|
238
|
+
const b = ftsHits[1]?.score || 0;
|
|
239
|
+
const p0 = pathList[0]?.score || 0;
|
|
240
|
+
const p1 = pathList[1]?.score || 0;
|
|
241
|
+
const ftsClear = a > 0 && a >= 2 * Math.max(b, 1e-9);
|
|
242
|
+
const pathClear = p0 > 0 && p0 >= 2 * Math.max(p1, 1e-9);
|
|
243
|
+
const sameTop =
|
|
244
|
+
ftsHits[0]?.path && pathList[0]?.path && ftsHits[0].path === pathList[0].path;
|
|
245
|
+
return (ftsClear || pathClear) && (sameTop || ftsClear);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function retrieveCandidates(root, analysis, opts = {}) {
|
|
249
|
+
const t0 = Date.now();
|
|
250
|
+
const stages = {};
|
|
251
|
+
const dirty = [...new Set([...gitDirty(root), ...gitDiffNames(root), ...(opts.openFiles || [])])];
|
|
252
|
+
stages.gitMs = Date.now() - t0;
|
|
253
|
+
|
|
254
|
+
const tIndex = Date.now();
|
|
255
|
+
let ftsMeta = { backend: 'none', updated: 0 };
|
|
256
|
+
try {
|
|
257
|
+
ftsMeta = ensureIndex(root, { maxFiles: opts.maxIndex || 4000 });
|
|
258
|
+
} catch {
|
|
259
|
+
ftsMeta = { backend: 'error', updated: 0 };
|
|
260
|
+
}
|
|
261
|
+
stages.indexMs = Date.now() - tIndex;
|
|
262
|
+
|
|
263
|
+
const q = queryTerms(analysis);
|
|
264
|
+
const tFts = Date.now();
|
|
265
|
+
const ftsHits = searchFts(root, q, { limit: opts.max || 60 });
|
|
266
|
+
stages.ftsMs = Date.now() - tFts;
|
|
267
|
+
|
|
268
|
+
const pathList = pathRank(
|
|
269
|
+
root,
|
|
270
|
+
analysis.expanded || analysis.tokens,
|
|
271
|
+
analysis.pathHints,
|
|
272
|
+
analysis.domain,
|
|
273
|
+
opts.max || 80,
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
const cascadeSkip = shouldCascadeSkip(ftsHits, pathList);
|
|
277
|
+
let content = [];
|
|
278
|
+
const tGrep = Date.now();
|
|
279
|
+
if (!cascadeSkip) {
|
|
280
|
+
const grepNeedles = [
|
|
281
|
+
...(analysis.symbols || []),
|
|
282
|
+
...(analysis.pathHints || []),
|
|
283
|
+
...(analysis.expanded || []).filter((t) => /[a-z]/i.test(t)).slice(0, 8),
|
|
284
|
+
];
|
|
285
|
+
content = contentGrep(root, pathList.map((x) => x.path), grepNeedles);
|
|
286
|
+
}
|
|
287
|
+
stages.grepMs = Date.now() - tGrep;
|
|
288
|
+
stages.cascadeSkip = cascadeSkip;
|
|
289
|
+
|
|
290
|
+
const recency = gitRecency(root);
|
|
291
|
+
|
|
292
|
+
const tPr = Date.now();
|
|
293
|
+
const seeds = [
|
|
294
|
+
...dirty,
|
|
295
|
+
...ftsHits.slice(0, 12).map((x) => x.path),
|
|
296
|
+
...pathList.slice(0, 12).map((x) => x.path),
|
|
297
|
+
...(opts.openFiles || []),
|
|
298
|
+
];
|
|
299
|
+
let prRanked = [];
|
|
300
|
+
try {
|
|
301
|
+
const pr = rankGraph(root, seeds.slice(0, 40), seeds.slice(0, 16), { maxFiles: 60 });
|
|
302
|
+
prRanked = pr.ranked.filter((x) => x.score > 0).slice(0, 40);
|
|
303
|
+
stages.graphCached = Boolean(pr.graph?.cached);
|
|
304
|
+
stages.graphFingerprint = pr.graph?.fingerprint || null;
|
|
305
|
+
} catch {
|
|
306
|
+
prRanked = [];
|
|
307
|
+
}
|
|
308
|
+
stages.pagerankMs = Date.now() - tPr;
|
|
309
|
+
|
|
310
|
+
const fusedRaw = weightedRrf([
|
|
311
|
+
{ list: dirty.map((p) => ({ path: p })), weight: 1.4 },
|
|
312
|
+
{ list: ftsHits, weight: 1.25 },
|
|
313
|
+
{ list: pathList, weight: 1.0 },
|
|
314
|
+
{ list: prRanked, weight: 1.1 },
|
|
315
|
+
{ list: recency, weight: 0.8 },
|
|
316
|
+
{ list: content, weight: 0.55 },
|
|
317
|
+
]);
|
|
318
|
+
|
|
319
|
+
const diversified = mmrSelect(fusedRaw.map((x) => ({ path: x.path, score: x.rrf })), {
|
|
320
|
+
k: opts.max || 60,
|
|
321
|
+
lambda: 0.72,
|
|
322
|
+
}).map((x) => ({ path: x.path, rrf: x.score, mmr: x.mmr }));
|
|
323
|
+
|
|
324
|
+
stages.totalMs = Date.now() - t0;
|
|
325
|
+
|
|
326
|
+
return {
|
|
327
|
+
dirty,
|
|
328
|
+
pathRanked: pathList.map((x) => x.path),
|
|
329
|
+
contentHits: content.map((x) => x.path),
|
|
330
|
+
ftsHits: ftsHits.map((x) => x.path),
|
|
331
|
+
pagerank: prRanked.slice(0, 12).map((x) => x.path),
|
|
332
|
+
fused: diversified.slice(0, opts.max || 60),
|
|
333
|
+
cascadeSkip,
|
|
334
|
+
ftsMeta,
|
|
335
|
+
stages,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
module.exports = {
|
|
340
|
+
rrf,
|
|
341
|
+
weightedRrf,
|
|
342
|
+
gitDirty,
|
|
343
|
+
gitDiffNames,
|
|
344
|
+
pathRank,
|
|
345
|
+
contentGrep,
|
|
346
|
+
retrieveCandidates,
|
|
347
|
+
shouldCascadeSkip,
|
|
348
|
+
queryTerms,
|
|
349
|
+
tokenizePath,
|
|
350
|
+
};
|
package/src/serena.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Serena-first: emit exact find_symbol / overview calls + local regex fallback
|
|
5
|
+
* so the Agent does not have to read_file the whole module.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const { splitIdentifier } = require('./identifier');
|
|
11
|
+
|
|
12
|
+
const SYMBOL_RE = /(?:export\s+)?(?:async\s+)?(?:function|class|const|type|interface|enum)\s+([A-Za-z_][A-Za-z0-9_]*)/g;
|
|
13
|
+
|
|
14
|
+
function emitFindSymbol(name, opts = {}) {
|
|
15
|
+
return {
|
|
16
|
+
tool: 'find_symbol',
|
|
17
|
+
name_path_pattern: name,
|
|
18
|
+
relative_path: opts.relative_path || '',
|
|
19
|
+
include_body: opts.include_body === true,
|
|
20
|
+
depth: opts.depth || 0,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function emitOverview(relativePath) {
|
|
25
|
+
return {
|
|
26
|
+
tool: 'get_symbols_overview',
|
|
27
|
+
relative_path: relativePath,
|
|
28
|
+
max_answer_chars: 2000,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function emitReferencing(name) {
|
|
33
|
+
return {
|
|
34
|
+
tool: 'find_referencing_symbols',
|
|
35
|
+
name_path_pattern: name,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function emitForTask(symbols, selectedPaths) {
|
|
40
|
+
const calls = [];
|
|
41
|
+
for (const s of (symbols || []).slice(0, 8)) {
|
|
42
|
+
calls.push(emitFindSymbol(s, { include_body: false }));
|
|
43
|
+
}
|
|
44
|
+
for (const p of (selectedPaths || []).slice(0, 3)) {
|
|
45
|
+
calls.push(emitOverview(p));
|
|
46
|
+
}
|
|
47
|
+
return calls;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function scanFileForSymbol(abs, name) {
|
|
51
|
+
let text = '';
|
|
52
|
+
try {
|
|
53
|
+
text = fs.readFileSync(abs, 'utf8');
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
const lines = text.split(/\r?\n/);
|
|
58
|
+
const want = String(name || '');
|
|
59
|
+
const wantLow = want.toLowerCase();
|
|
60
|
+
const parts = splitIdentifier(want);
|
|
61
|
+
const hits = [];
|
|
62
|
+
for (let i = 0; i < lines.length; i++) {
|
|
63
|
+
const line = lines[i];
|
|
64
|
+
SYMBOL_RE.lastIndex = 0;
|
|
65
|
+
let m;
|
|
66
|
+
const re = /(?:export\s+)?(?:async\s+)?(?:function|class|const|type|interface|enum)\s+([A-Za-z_][A-Za-z0-9_]*)/g;
|
|
67
|
+
while ((m = re.exec(line))) {
|
|
68
|
+
const n = m[1];
|
|
69
|
+
if (n === want || n.toLowerCase() === wantLow || parts.every((p) => n.toLowerCase().includes(p))) {
|
|
70
|
+
hits.push({ name: n, line: i + 1, snippet: line.trim().slice(0, 160) });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return hits.length ? { hits, bytes: text.length } : null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function localFindSymbol(root, name, searchPaths = []) {
|
|
78
|
+
const hits = [];
|
|
79
|
+
const seen = new Set();
|
|
80
|
+
const queue = [...(searchPaths || [])];
|
|
81
|
+
while (queue.length && hits.length < 12) {
|
|
82
|
+
const rel = queue.shift();
|
|
83
|
+
if (!rel || seen.has(rel)) continue;
|
|
84
|
+
seen.add(rel);
|
|
85
|
+
const abs = path.join(root, rel);
|
|
86
|
+
let st;
|
|
87
|
+
try {
|
|
88
|
+
st = fs.statSync(abs);
|
|
89
|
+
} catch {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (st.isDirectory()) {
|
|
93
|
+
let ents = [];
|
|
94
|
+
try {
|
|
95
|
+
ents = fs.readdirSync(abs);
|
|
96
|
+
} catch {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
for (const e of ents) {
|
|
100
|
+
if (/\.(tsx?|jsx?)$/.test(e)) queue.push(path.posix.join(rel.replace(/\\/g, '/'), e));
|
|
101
|
+
}
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const found = scanFileForSymbol(abs, name);
|
|
105
|
+
if (found) {
|
|
106
|
+
for (const h of found.hits) {
|
|
107
|
+
hits.push({ path: rel.replace(/\\/g, '/'), ...h });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
name,
|
|
113
|
+
source: 'local',
|
|
114
|
+
hits,
|
|
115
|
+
serena: emitFindSymbol(name),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
module.exports = {
|
|
120
|
+
emitFindSymbol,
|
|
121
|
+
emitOverview,
|
|
122
|
+
emitReferencing,
|
|
123
|
+
emitForTask,
|
|
124
|
+
localFindSymbol,
|
|
125
|
+
scanFileForSymbol,
|
|
126
|
+
};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Per-session alreadyInContext ledger so token cost falls across turns.
|
|
5
|
+
* readNow = fresh files only; already delivered stay in alreadyInContext.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
|
|
11
|
+
const LEDGER_REL = '.ziprin-context/ledger.json';
|
|
12
|
+
|
|
13
|
+
function ledgerPath(root) {
|
|
14
|
+
return path.join(root, LEDGER_REL);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function loadLedger(root) {
|
|
18
|
+
const file = ledgerPath(root);
|
|
19
|
+
if (!fs.existsSync(file)) return { version: 1, sessions: {} };
|
|
20
|
+
try {
|
|
21
|
+
const j = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
22
|
+
return { version: 1, sessions: j.sessions || {} };
|
|
23
|
+
} catch {
|
|
24
|
+
return { version: 1, sessions: {} };
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function saveLedger(root, ledger) {
|
|
29
|
+
const file = ledgerPath(root);
|
|
30
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
31
|
+
fs.writeFileSync(file, JSON.stringify(ledger), 'utf8');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function splitFresh(selected, ledger, sessionKey = 'default') {
|
|
35
|
+
const files = selected || [];
|
|
36
|
+
const prev = new Set(ledger?.sessions?.[sessionKey]?.files || []);
|
|
37
|
+
const fresh = [];
|
|
38
|
+
const alreadyInContext = [];
|
|
39
|
+
for (const p of files) {
|
|
40
|
+
if (prev.has(p)) alreadyInContext.push(p);
|
|
41
|
+
else fresh.push(p);
|
|
42
|
+
}
|
|
43
|
+
return { fresh, alreadyInContext };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function remember(root, sessionKey, files) {
|
|
47
|
+
const key = sessionKey || 'default';
|
|
48
|
+
const ledger = loadLedger(root);
|
|
49
|
+
const prev = new Set(ledger.sessions[key]?.files || []);
|
|
50
|
+
for (const f of files || []) prev.add(f);
|
|
51
|
+
ledger.sessions[key] = {
|
|
52
|
+
files: [...prev],
|
|
53
|
+
ts: new Date().toISOString(),
|
|
54
|
+
};
|
|
55
|
+
// cap sessions
|
|
56
|
+
const keys = Object.keys(ledger.sessions);
|
|
57
|
+
if (keys.length > 40) {
|
|
58
|
+
const oldest = keys
|
|
59
|
+
.map((k) => ({ k, ts: ledger.sessions[k].ts || '' }))
|
|
60
|
+
.sort((a, b) => a.ts.localeCompare(b.ts))
|
|
61
|
+
.slice(0, keys.length - 40);
|
|
62
|
+
for (const o of oldest) delete ledger.sessions[o.k];
|
|
63
|
+
}
|
|
64
|
+
saveLedger(root, ledger);
|
|
65
|
+
return ledger;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function clearLedger(root, sessionKey) {
|
|
69
|
+
const ledger = loadLedger(root);
|
|
70
|
+
if (sessionKey) delete ledger.sessions[sessionKey];
|
|
71
|
+
else ledger.sessions = {};
|
|
72
|
+
saveLedger(root, ledger);
|
|
73
|
+
return ledger;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
module.exports = {
|
|
77
|
+
LEDGER_REL,
|
|
78
|
+
loadLedger,
|
|
79
|
+
saveLedger,
|
|
80
|
+
splitFresh,
|
|
81
|
+
remember,
|
|
82
|
+
clearLedger,
|
|
83
|
+
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
const SESSIONS_REL = '.cursor/context-inspector-sessions.jsonl';
|
|
7
|
+
|
|
8
|
+
function sessionsPath(root) {
|
|
9
|
+
return path.join(root, SESSIONS_REL);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function readSessions(root) {
|
|
13
|
+
const file = sessionsPath(root);
|
|
14
|
+
if (!fs.existsSync(file)) return [];
|
|
15
|
+
const out = [];
|
|
16
|
+
for (const line of fs.readFileSync(file, 'utf8').split(/\r?\n/)) {
|
|
17
|
+
if (!line.trim()) continue;
|
|
18
|
+
try {
|
|
19
|
+
out.push(JSON.parse(line));
|
|
20
|
+
} catch {
|
|
21
|
+
/* skip */
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function writeSessions(root, sessions) {
|
|
28
|
+
const file = sessionsPath(root);
|
|
29
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
30
|
+
const body = sessions.map((s) => JSON.stringify(s)).join('\n');
|
|
31
|
+
fs.writeFileSync(file, body ? body + '\n' : '', 'utf8');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function appendSession(root, session, maxEntries = 80) {
|
|
35
|
+
if (!root || !session) return session;
|
|
36
|
+
const all = readSessions(root);
|
|
37
|
+
all.push(session);
|
|
38
|
+
writeSessions(root, all.slice(-Math.max(1, maxEntries)));
|
|
39
|
+
return session;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function clearSessions(root) {
|
|
43
|
+
const file = sessionsPath(root);
|
|
44
|
+
if (fs.existsSync(file)) fs.unlinkSync(file);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function listSessions(root, limit = 40) {
|
|
48
|
+
return readSessions(root).slice(-limit).reverse();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function getSession(root, id) {
|
|
52
|
+
return readSessions(root).find((s) => s.id === id) || null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function historySummary(session) {
|
|
56
|
+
return {
|
|
57
|
+
id: session.id,
|
|
58
|
+
timestamp: session.timestamp,
|
|
59
|
+
task: session.task,
|
|
60
|
+
promptPreview: String(session.prompt || '').slice(0, 80),
|
|
61
|
+
filesIncludedCount: (session.filesIncluded || []).length,
|
|
62
|
+
filesExcludedCount: (session.filesExcluded || []).length,
|
|
63
|
+
tokensBeforePrune: session.tokensBeforePrune,
|
|
64
|
+
tokensAfterPrune: session.tokensAfterPrune,
|
|
65
|
+
profileId: session.profileId,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = {
|
|
70
|
+
SESSIONS_REL,
|
|
71
|
+
sessionsPath,
|
|
72
|
+
readSessions,
|
|
73
|
+
appendSession,
|
|
74
|
+
clearSessions,
|
|
75
|
+
listSessions,
|
|
76
|
+
getSession,
|
|
77
|
+
historySummary,
|
|
78
|
+
};
|