sigmap 8.28.1 → 8.30.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/AGENTS.md +539 -824
- package/CHANGELOG.md +38 -0
- package/README.md +2 -2
- package/gen-context.js +755 -144
- package/llms-full.txt +2 -2
- package/llms.txt +2 -2
- package/package.json +5 -3
- package/packages/cli/package.json +1 -1
- package/packages/core/package.json +1 -1
- package/src/eval/runner.js +18 -49
- package/src/extractors/java.js +20 -4
- package/src/graph/builder.js +4 -3
- package/src/graph/call-graph.js +7 -3
- package/src/graph/path-key.js +26 -0
- package/src/mcp/install.js +29 -2
- package/src/mcp/server.js +1 -1
- package/src/retrieval/bm25.js +56 -6
- package/src/retrieval/module-doc.js +120 -0
- package/src/retrieval/ranker.js +220 -65
- package/src/retrieval/sig-index-store.js +95 -0
- package/src/skills/skills.js +25 -1
package/src/retrieval/ranker.js
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
const { loadWeights } = require('../learning/weights');
|
|
21
21
|
const { tokenize, STOP_WORDS } = require('./tokenizer');
|
|
22
|
-
const { bm25rank } = require('./bm25');
|
|
22
|
+
const { bm25rank, MODULE_DOC_RE } = require('./bm25');
|
|
23
23
|
|
|
24
24
|
// ---------------------------------------------------------------------------
|
|
25
25
|
// Default weights
|
|
@@ -43,17 +43,28 @@ const GRAPH_BOOST_AMOUNTS = {
|
|
|
43
43
|
// Max additive prior for import-graph centrality (opt-in retrieval.centralityBlend)
|
|
44
44
|
const CENTRALITY_BLEND_WEIGHT = 0.3;
|
|
45
45
|
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
46
|
+
// Per-intent weight profiles were removed in favour of a single weight set.
|
|
47
|
+
// They were provably inert: scoreFile's score was discarded by rank(), so the
|
|
48
|
+
// profiles only ever reached the explain table. Once the signal WAS wired into
|
|
49
|
+
// the score (see SIGNAL_BLEND below), a sweep over the leak-free hard corpus
|
|
50
|
+
// showed intent-specific profiles produced byte-identical metrics to the flat
|
|
51
|
+
// DEFAULT_WEIGHTS at every blend value — so they earn nothing and are gone.
|
|
52
|
+
// `detectIntent` is retained: it is still reported to the user and is the right
|
|
53
|
+
// hook for shaping OUTPUT depth later.
|
|
54
|
+
|
|
55
|
+
// How much the weighted keyword/symbol/path signal modulates the BM25 base.
|
|
56
|
+
// Multiplicative and bounded, so it can only reorder files that already match —
|
|
57
|
+
// it can never lift a zero-BM25 file into the results. Tuned on the leak-free
|
|
58
|
+
// corpus: 0.5 gave hit@5 50.0% -> 56.7% and MRR 0.419 -> 0.447; higher values
|
|
59
|
+
// held hit@5 but degraded MRR.
|
|
60
|
+
const SIGNAL_BLEND = 0.5;
|
|
61
|
+
|
|
62
|
+
// TRIED AND REJECTED: a same-line co-occurrence bonus, on the theory that a file
|
|
63
|
+
// declaring `parseAuthToken` should outrank one mentioning `parseAuth` and
|
|
64
|
+
// `token` on separate lines. Swept 0.15-1.0: hit@5 did not move on either the
|
|
65
|
+
// 90-task authored corpus or the 32-task mined one, and MRR degraded
|
|
66
|
+
// monotonically as the weight rose. Signatures are short and dense enough that
|
|
67
|
+
// BM25's bag already captures this. Not reinstated without new evidence.
|
|
57
68
|
|
|
58
69
|
// Penalty multipliers for negative signals
|
|
59
70
|
const PENALTY_SIGNALS = {
|
|
@@ -61,14 +72,69 @@ const PENALTY_SIGNALS = {
|
|
|
61
72
|
generatedCode: 0.3, // dist/build/.next in path
|
|
62
73
|
docsFile: 0.2, // docs/doc/README in path
|
|
63
74
|
nodeModules: 0.0, // node_modules (zero score)
|
|
75
|
+
dataHolder: 0.3, // generated POJO/entity: almost entirely accessors
|
|
64
76
|
};
|
|
65
77
|
|
|
66
|
-
|
|
78
|
+
// A file whose members are overwhelmingly trivial accessors is a data holder,
|
|
79
|
+
// not logic. Path-based detection cannot see these: generated JPA/MyBatis
|
|
80
|
+
// entities live in ordinary source trees. They match a query on any column
|
|
81
|
+
// name they happen to carry (`getNote`/`setNote` matches "note" as strongly as
|
|
82
|
+
// the service that actually implements order notes), so on an entity-heavy
|
|
83
|
+
// repo they crowd real code out of the top results.
|
|
84
|
+
const ACCESSOR_RE = /^\s*(get|set|is)[A-Z]\w*\s*\(/;
|
|
85
|
+
const DATA_HOLDER_RATIO = 0.8;
|
|
86
|
+
const DATA_HOLDER_MIN_MEMBERS = 6;
|
|
87
|
+
|
|
88
|
+
// Query terms that mean the penalised category IS the target. Read from the
|
|
89
|
+
// query tokens directly, NOT via detectIntent: that classifier is first-match-
|
|
90
|
+
// wins over its pattern object, and `debug` precedes `test`, so "fix the failing
|
|
91
|
+
// test" classifies as debug and never reaches the test branch.
|
|
92
|
+
const WANTS_TESTS = new Set(['test', 'tests', 'spec', 'specs', 'unit', 'integration', 'e2e', 'assertion', 'assert', 'mock', 'fixture', 'coverage', 'testing']);
|
|
93
|
+
const WANTS_DOCS = new Set(['doc', 'docs', 'documentation', 'readme', 'changelog', 'guide', 'tutorial']);
|
|
94
|
+
const WANTS_MODELS = new Set(['entity', 'entities', 'model', 'models', 'pojo', 'dto', 'bean', 'getter', 'getters', 'setter', 'setters', 'accessor', 'accessors', 'field', 'fields', 'column', 'columns', 'schema']);
|
|
95
|
+
|
|
96
|
+
/** Which penalised categories the query is explicitly asking for. */
|
|
97
|
+
function _queryWants(queryTokens) {
|
|
98
|
+
const wants = { tests: false, docs: false, models: false };
|
|
99
|
+
for (const t of queryTokens || []) {
|
|
100
|
+
if (WANTS_TESTS.has(t)) wants.tests = true;
|
|
101
|
+
if (WANTS_DOCS.has(t)) wants.docs = true;
|
|
102
|
+
if (WANTS_MODELS.has(t)) wants.models = true;
|
|
103
|
+
}
|
|
104
|
+
return wants;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* True when a file's members are overwhelmingly trivial accessors — a generated
|
|
109
|
+
* entity or POJO rather than logic. Type declarations are excluded from the
|
|
110
|
+
* ratio so a small class is not misjudged by its own `class X` line.
|
|
111
|
+
*/
|
|
112
|
+
function _isDataHolder(sigs) {
|
|
113
|
+
if (!Array.isArray(sigs)) return false;
|
|
114
|
+
const members = sigs.filter((line) => /^\s/.test(line) || !/^(class|interface|enum|struct|function|module\.exports)\b/.test(line));
|
|
115
|
+
if (members.length < DATA_HOLDER_MIN_MEMBERS) return false;
|
|
116
|
+
const accessors = members.filter((line) => ACCESSOR_RE.test(line)).length;
|
|
117
|
+
return accessors / members.length >= DATA_HOLDER_RATIO;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function _computePenalty(filePath, wants, sigs) {
|
|
67
121
|
const pathLower = filePath.toLowerCase();
|
|
68
122
|
if (pathLower.includes('node_modules')) return PENALTY_SIGNALS.nodeModules;
|
|
69
|
-
|
|
123
|
+
// A penalty must never fire on the very thing the user asked for. Before
|
|
124
|
+
// this, "write tests for the ranker" multiplied every test file by 0.4 —
|
|
125
|
+
// the query and the penalty were pulling in opposite directions.
|
|
126
|
+
if (/(^|\/)(test|tests|spec|__tests__|e2e)($|\/)/.test(pathLower) || /\.(test|spec)\./.test(pathLower)) {
|
|
127
|
+
return (wants && wants.tests) ? 1.0 : PENALTY_SIGNALS.testFile;
|
|
128
|
+
}
|
|
70
129
|
if (/(^|\/)(dist|build|\.next|\.nuxt|out|\.venv|venv)($|\/)/.test(pathLower)) return PENALTY_SIGNALS.generatedCode;
|
|
71
|
-
if (/(^|\/)(docs|doc|readme|changelog)($|\/)/.test(pathLower))
|
|
130
|
+
if (/(^|\/)(docs|doc|readme|changelog)($|\/)/.test(pathLower)) {
|
|
131
|
+
return (wants && wants.docs) ? 1.0 : PENALTY_SIGNALS.docsFile;
|
|
132
|
+
}
|
|
133
|
+
// Content-based, and last: a data holder is still a real source file, so it
|
|
134
|
+
// is only demoted once the path-based categories have had their say.
|
|
135
|
+
if (_isDataHolder(sigs)) {
|
|
136
|
+
return (wants && wants.models) ? 1.0 : PENALTY_SIGNALS.dataHolder;
|
|
137
|
+
}
|
|
72
138
|
return 1.0;
|
|
73
139
|
}
|
|
74
140
|
|
|
@@ -87,6 +153,26 @@ function _computeHubs(graph) {
|
|
|
87
153
|
}
|
|
88
154
|
|
|
89
155
|
// Common utility paths that should be treated as hubs regardless of fanout
|
|
156
|
+
// The graph builders disagree on key case: src/graph/builder.js lowercases every
|
|
157
|
+
// node (normalizePath), while src/graph/call-graph.js keys by a case-preserving
|
|
158
|
+
// path.resolve. Assuming either one breaks the other, so every graph lookup in
|
|
159
|
+
// this file probes both forms — the same thing the centrality blend already does.
|
|
160
|
+
function _graphKeys(p) {
|
|
161
|
+
const norm = require('path').normalize(p);
|
|
162
|
+
const lower = norm.toLowerCase();
|
|
163
|
+
return lower === norm ? [norm] : [norm, lower];
|
|
164
|
+
}
|
|
165
|
+
function _graphGet(map, absPath) {
|
|
166
|
+
for (const k of _graphKeys(absPath)) {
|
|
167
|
+
const hit = map.get(k);
|
|
168
|
+
if (hit !== undefined) return hit;
|
|
169
|
+
}
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
function _registerKeys(map, absPath, value) {
|
|
173
|
+
for (const k of _graphKeys(absPath)) if (!map.has(k)) map.set(k, value);
|
|
174
|
+
}
|
|
175
|
+
|
|
90
176
|
function _isHub(filePath) {
|
|
91
177
|
return /\/(utils|helpers|shared|common|constants|types|interfaces|index|zzz|globals)\.(ts|tsx|js|jsx|r|R)$/.test(filePath)
|
|
92
178
|
|| filePath.endsWith('/index.ts') || filePath.endsWith('/index.js')
|
|
@@ -102,14 +188,18 @@ function _isHub(filePath) {
|
|
|
102
188
|
* @param {object} weights
|
|
103
189
|
* @returns {{ score: number, signals: { exactToken: number, symbolMatch: number, prefixMatch: number, pathMatch: number, penalty: number } }}
|
|
104
190
|
*/
|
|
105
|
-
function scoreFile(filePath, sigs, queryTokens, weights) {
|
|
191
|
+
function scoreFile(filePath, sigs, queryTokens, weights, wants) {
|
|
106
192
|
if (!sigs || sigs.length === 0) return { score: 0, signals: { exactToken: 0, symbolMatch: 0, prefixMatch: 0, pathMatch: 0, penalty: 1.0 } };
|
|
107
193
|
|
|
108
194
|
const w = weights || DEFAULT_WEIGHTS;
|
|
109
|
-
const signals = { exactToken: 0, symbolMatch: 0, prefixMatch: 0, pathMatch: 0, penalty: _computePenalty(filePath) };
|
|
110
|
-
|
|
111
|
-
//
|
|
112
|
-
|
|
195
|
+
const signals = { exactToken: 0, symbolMatch: 0, prefixMatch: 0, pathMatch: 0, penalty: _computePenalty(filePath, wants, sigs) };
|
|
196
|
+
|
|
197
|
+
// Module-doc prose is excluded here on purpose. This signal measures overlap
|
|
198
|
+
// with DECLARED IDENTIFIERS; prose relevance is BM25's job, where it is scored
|
|
199
|
+
// as its own weighted field. Letting descriptive text inflate the identifier
|
|
200
|
+
// signal double-counts it and measurably degraded MRR.
|
|
201
|
+
const codeSigs = sigs.filter((line) => !MODULE_DOC_RE.test(line));
|
|
202
|
+
const sigText = codeSigs.join(' ');
|
|
113
203
|
const sigTokenSet = new Set(tokenize(sigText));
|
|
114
204
|
|
|
115
205
|
// Build token set from the file path
|
|
@@ -127,7 +217,7 @@ function scoreFile(filePath, sigs, queryTokens, weights) {
|
|
|
127
217
|
signals.exactToken += bonus;
|
|
128
218
|
|
|
129
219
|
// Bonus: appears directly in a function/class/method name line
|
|
130
|
-
const nameLineMatch =
|
|
220
|
+
const nameLineMatch = codeSigs.some((sig) => {
|
|
131
221
|
const nt = tokenize(sig.replace(/[^a-zA-Z0-9_\s]/g, ' '));
|
|
132
222
|
return nt.includes(qt);
|
|
133
223
|
});
|
|
@@ -189,13 +279,18 @@ function rank(query, sigIndex, opts) {
|
|
|
189
279
|
const graph = (opts && opts.graph && opts.graph.forward instanceof Map) ? opts.graph : null;
|
|
190
280
|
const cwd = (opts && opts.cwd) || null;
|
|
191
281
|
|
|
192
|
-
//
|
|
282
|
+
// Intent is reported to the user and shapes output depth; it no longer
|
|
283
|
+
// selects scoring weights (see SIGNAL_BLEND).
|
|
193
284
|
const intent = detectIntent(query);
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
285
|
+
const weights = (opts && opts.weights) ? Object.assign({}, DEFAULT_WEIGHTS, opts.weights) : DEFAULT_WEIGHTS;
|
|
286
|
+
// Learned per-file multipliers are a LOCAL, evolving signal (.context/weights.json).
|
|
287
|
+
// Benchmarks and CI gates must opt out via { learned: false }, or a developer's
|
|
288
|
+
// local learned state silently changes the score and CI stops being reproducible.
|
|
289
|
+
const useLearned = !(opts && opts.learned === false);
|
|
290
|
+
const learnedWeights = opts && opts.cwd && useLearned ? loadWeights(opts.cwd) : null;
|
|
197
291
|
|
|
198
292
|
const queryTokens = tokenize(query);
|
|
293
|
+
const queryWants = _queryWants(queryTokens);
|
|
199
294
|
if (queryTokens.length === 0) {
|
|
200
295
|
// Empty query: return top-K by file count (most signatures = most useful)
|
|
201
296
|
const all = [];
|
|
@@ -212,18 +307,32 @@ function rank(query, sigIndex, opts) {
|
|
|
212
307
|
// are matched. The existing negative-signal penalty and recency/graph/learned
|
|
213
308
|
// boosts are layered on top; the per-token signals stay for the explain table.
|
|
214
309
|
const bm25Scores = new Map();
|
|
215
|
-
for (const c of bm25rank(query, [...sigIndex.entries()].map(([file, sigs]) => ({ file, sigs })))) {
|
|
310
|
+
for (const c of bm25rank(query, [...sigIndex.entries()].map(([file, sigs]) => ({ file, sigs })), opts)) {
|
|
216
311
|
bm25Scores.set(c.file, c.score);
|
|
217
312
|
}
|
|
218
313
|
|
|
219
|
-
|
|
314
|
+
// Two passes: scoreFile's weighted signal needs the max across the corpus to
|
|
315
|
+
// normalise against, so collect first, then combine.
|
|
316
|
+
const prescored = [];
|
|
317
|
+
let maxSignal = 0;
|
|
220
318
|
for (const [file, sigs] of sigIndex.entries()) {
|
|
221
|
-
const result = scoreFile(file, sigs, queryTokens, weights);
|
|
319
|
+
const result = scoreFile(file, sigs, queryTokens, weights, queryWants);
|
|
320
|
+
if (result.score > maxSignal) maxSignal = result.score;
|
|
321
|
+
prescored.push({ file, sigs, result });
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const scored = [];
|
|
325
|
+
for (const { file, sigs, result } of prescored) {
|
|
222
326
|
const penalty = result.signals.penalty;
|
|
223
327
|
const base = bm25Scores.get(file) || 0;
|
|
224
|
-
|
|
328
|
+
// Blend the weighted keyword/symbol/path signal into the BM25 base. This
|
|
329
|
+
// was previously computed and thrown away — `result.score` was never read,
|
|
330
|
+
// which silently made DEFAULT_WEIGHTS and every intent profile dead config.
|
|
331
|
+
const signalNorm = maxSignal > 0 ? result.score / maxSignal : 0;
|
|
332
|
+
let score = base * penalty * (1 + SIGNAL_BLEND * signalNorm);
|
|
225
333
|
const signals = result.signals;
|
|
226
334
|
signals.bm25 = base;
|
|
335
|
+
signals.signalBlend = signalNorm;
|
|
227
336
|
|
|
228
337
|
// Recency boost
|
|
229
338
|
if (recencySet && recencySet.has(file) && score > 0) {
|
|
@@ -253,44 +362,46 @@ function rank(query, sigIndex, opts) {
|
|
|
253
362
|
// Hub suppression: files with high fanout (>20%) are not boosted
|
|
254
363
|
if (graph && cwd) {
|
|
255
364
|
const path = require('path');
|
|
256
|
-
//
|
|
257
|
-
|
|
258
|
-
|
|
365
|
+
// Every graph node is keyed by `path.normalize(p).toLowerCase()` (see
|
|
366
|
+
// normalizePath in src/graph/builder.js and src/graph/call-graph.js).
|
|
367
|
+
// Lookups MUST use the same key space: a bare path.resolve() preserves
|
|
368
|
+
// case, so on any repo whose absolute path contains an uppercase letter
|
|
369
|
+
// every .get() missed and this entire block was silently inert.
|
|
370
|
+
const keyToIdx = new Map();
|
|
259
371
|
for (let i = 0; i < scored.length; i++) {
|
|
260
|
-
|
|
261
|
-
const abs = path.resolve(cwd, scored[i].file);
|
|
262
|
-
absToRel.set(abs, scored[i].file);
|
|
372
|
+
_registerKeys(keyToIdx, path.resolve(cwd, scored[i].file), i);
|
|
263
373
|
}
|
|
264
374
|
|
|
265
375
|
const hubs = _computeHubs(graph);
|
|
266
|
-
const hop1Files = new Set(); //
|
|
376
|
+
const hop1Files = new Set(); // normalised keys that received a hop1 boost
|
|
377
|
+
const hop1Seeds = []; // original (un-normalised) paths, for hop-2 lookup
|
|
267
378
|
|
|
268
379
|
// Hop 1: direct neighbors of scored files
|
|
269
380
|
for (const entry of scored) {
|
|
270
381
|
if (entry.score <= 0) continue;
|
|
271
|
-
const
|
|
272
|
-
const neighbors = graph.forward.get(abs) || [];
|
|
382
|
+
const neighbors = _graphGet(graph.forward, path.resolve(cwd, entry.file)) || [];
|
|
273
383
|
for (const neighborAbs of neighbors) {
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
const idx =
|
|
384
|
+
const nk = path.normalize(neighborAbs);
|
|
385
|
+
if (_isHub(nk) || hubs.has(nk) || hubs.has(nk.toLowerCase())) continue;
|
|
386
|
+
const idx = _graphGet(keyToIdx, nk);
|
|
277
387
|
if (idx !== undefined) {
|
|
278
388
|
scored[idx].score += GRAPH_BOOST_AMOUNTS.hop1;
|
|
279
389
|
scored[idx].signals.graphBoost = (scored[idx].signals.graphBoost || 0) + GRAPH_BOOST_AMOUNTS.hop1;
|
|
280
|
-
hop1Files.add(
|
|
390
|
+
hop1Files.add(nk);
|
|
391
|
+
hop1Seeds.push(neighborAbs);
|
|
281
392
|
}
|
|
282
393
|
}
|
|
283
394
|
}
|
|
284
395
|
|
|
285
396
|
// Hop 2: neighbors of hop1 files (only if they didn't get a direct score)
|
|
286
|
-
for (const
|
|
287
|
-
if (
|
|
288
|
-
const neighbors = graph.forward
|
|
397
|
+
for (const hop1Key of hop1Seeds) {
|
|
398
|
+
if (_graphGet(keyToIdx, hop1Key) === undefined) continue; // skip files not in index
|
|
399
|
+
const neighbors = _graphGet(graph.forward, hop1Key) || [];
|
|
289
400
|
for (const neighborAbs of neighbors) {
|
|
290
|
-
|
|
291
|
-
if (
|
|
292
|
-
|
|
293
|
-
const idx =
|
|
401
|
+
const nk = path.normalize(neighborAbs);
|
|
402
|
+
if (_isHub(nk) || hubs.has(nk) || hubs.has(nk.toLowerCase())) continue;
|
|
403
|
+
if (hop1Files.has(nk)) continue; // skip already hop1-boosted
|
|
404
|
+
const idx = _graphGet(keyToIdx, nk);
|
|
294
405
|
if (idx !== undefined && scored[idx].score > 0) {
|
|
295
406
|
// Only boost files that have some baseline score (not noise)
|
|
296
407
|
scored[idx].score += GRAPH_BOOST_AMOUNTS.hop2;
|
|
@@ -307,16 +418,17 @@ function rank(query, sigIndex, opts) {
|
|
|
307
418
|
const callGraph = (opts && opts.callGraph && opts.callGraph.forward instanceof Map) ? opts.callGraph : null;
|
|
308
419
|
if (callGraph && cwd) {
|
|
309
420
|
const path = require('path');
|
|
310
|
-
|
|
311
|
-
|
|
421
|
+
// buildCallFileGraph keys by a CASE-PRESERVING path.resolve, unlike the
|
|
422
|
+
// import graph builder which lowercases — hence the dual-form probe.
|
|
423
|
+
const keyToIdx = new Map();
|
|
424
|
+
for (let i = 0; i < scored.length; i++) _registerKeys(keyToIdx, path.resolve(cwd, scored[i].file), i);
|
|
312
425
|
const hubs = _computeHubs(callGraph);
|
|
313
426
|
const seeds = scored.filter((e) => e.score > 0).map((e) => e.file);
|
|
314
427
|
for (const file of seeds) {
|
|
315
|
-
const
|
|
316
|
-
|
|
317
|
-
if (_isHub(
|
|
318
|
-
const
|
|
319
|
-
const idx = relToIdx.get(neighborRel);
|
|
428
|
+
for (const neighborAbs of (_graphGet(callGraph.forward, path.resolve(cwd, file)) || [])) {
|
|
429
|
+
const nk = path.normalize(neighborAbs);
|
|
430
|
+
if (_isHub(nk) || hubs.has(nk) || hubs.has(nk.toLowerCase())) continue;
|
|
431
|
+
const idx = _graphGet(keyToIdx, nk);
|
|
320
432
|
if (idx !== undefined && scored[idx].file !== file) {
|
|
321
433
|
scored[idx].score += GRAPH_BOOST_AMOUNTS.callHop;
|
|
322
434
|
scored[idx].signals.callGraphBoost = (scored[idx].signals.callGraphBoost || 0) + GRAPH_BOOST_AMOUNTS.callHop;
|
|
@@ -490,6 +602,18 @@ function _enrichSigIndexFromStrategy(cwd, index) {
|
|
|
490
602
|
}
|
|
491
603
|
} catch (_) {}
|
|
492
604
|
_mergeSigIndex(index, _buildSigIndexFromCache(cwd));
|
|
605
|
+
|
|
606
|
+
// The complete retrieval index (written by generate before applyTokenBudget)
|
|
607
|
+
// takes precedence: it is the only source containing files the budget dropped,
|
|
608
|
+
// and full signatures for files the budget collapsed to line anchors. It is
|
|
609
|
+
// merged as the BASE rather than on top because _mergeSigIndex only replaces
|
|
610
|
+
// when the source has MORE signatures — a collapsed entry has the same count
|
|
611
|
+
// as its full form, so merging the other way would keep the anchors.
|
|
612
|
+
try {
|
|
613
|
+
const full = require('./sig-index-store').readFullIndex(cwd);
|
|
614
|
+
if (full.size > 0) return _mergeSigIndex(full, index);
|
|
615
|
+
} catch (_) { /* absent → budgeted view is still served */ }
|
|
616
|
+
|
|
493
617
|
return index;
|
|
494
618
|
}
|
|
495
619
|
|
|
@@ -615,22 +739,53 @@ function formatRankJSON(results, query) {
|
|
|
615
739
|
// ---------------------------------------------------------------------------
|
|
616
740
|
// Intent detection — 7 intents
|
|
617
741
|
// ---------------------------------------------------------------------------
|
|
742
|
+
// Nouns carry an optional plural: `\btest\b` does not match "tests", so
|
|
743
|
+
// "write unit tests for the ranker" matched NO intent at all and fell through
|
|
744
|
+
// to the 'search' default.
|
|
618
745
|
const INTENT_PATTERNS = {
|
|
619
|
-
debug: /\b(
|
|
746
|
+
debug: /\b(bugs?|fix(es|ed)?|errors?|crash(es)?|exceptions?|broken|failing|failures?|issues?|problems?|regressions?)\b/i,
|
|
620
747
|
explain: /\b(explain|how does|what is|understand|overview|architecture|describe|walk me|teach)\b/i,
|
|
621
|
-
refactor: /\b(refactor|restructure|redesign|clean up|extract|move|rename|simplify|
|
|
748
|
+
refactor: /\b(refactor|restructure|redesign|clean up|extract|move|rename|simplify|optimi[sz]e)\b/i,
|
|
622
749
|
review: /\b(review|check|audit|security|pr|pull request|assess|validate)\b/i,
|
|
623
|
-
test: /\b(
|
|
624
|
-
integrate:/\b(
|
|
750
|
+
test: /\b(tests?|unit tests?|integration tests?|testing|specs?|assert(ion)?s?|mocks?|fixtures?)\b/i,
|
|
751
|
+
integrate:/\b(imports?|integrate|connect|wire|bind|requires?|exports?|depends?|dependenc(y|ies)|graph)\b/i,
|
|
625
752
|
navigate: /\b(find|locate|where|search|look for|show me|navigate|browse|list)\b/i,
|
|
626
753
|
};
|
|
627
754
|
|
|
628
|
-
|
|
629
|
-
|
|
755
|
+
/**
|
|
756
|
+
* Every intent whose pattern matches, strongest first.
|
|
757
|
+
*
|
|
758
|
+
* A real request is routinely multi-intent — "fix the failing test" is both a
|
|
759
|
+
* debug task and a test task — and reporting one label discards that. Worse,
|
|
760
|
+
* the single-label version returned the FIRST key in INTENT_PATTERNS order, so
|
|
761
|
+
* `debug` permanently shadowed `test`: no query containing "fix" or "failing"
|
|
762
|
+
* could ever be labelled a test, no matter how test-shaped it was.
|
|
763
|
+
*
|
|
764
|
+
* Ranked by how many distinct terms each pattern matched, so the dominant
|
|
765
|
+
* intent leads; ties fall back to declaration order for determinism.
|
|
766
|
+
*
|
|
767
|
+
* @param {string} query
|
|
768
|
+
* @returns {string[]} matched intents, never empty (defaults to ['search'])
|
|
769
|
+
*/
|
|
770
|
+
function detectIntents(query) {
|
|
771
|
+
if (!query || typeof query !== 'string') return ['search'];
|
|
772
|
+
const scored = [];
|
|
773
|
+
let order = 0;
|
|
630
774
|
for (const [intent, re] of Object.entries(INTENT_PATTERNS)) {
|
|
631
|
-
|
|
775
|
+
const hits = query.match(new RegExp(re.source, re.flags.includes('g') ? re.flags : re.flags + 'g'));
|
|
776
|
+
if (hits && hits.length) {
|
|
777
|
+
scored.push({ intent, hits: new Set(hits.map((h) => h.toLowerCase())).size, order: order });
|
|
778
|
+
}
|
|
779
|
+
order++;
|
|
632
780
|
}
|
|
633
|
-
return 'search';
|
|
781
|
+
if (scored.length === 0) return ['search'];
|
|
782
|
+
scored.sort((a, b) => (b.hits - a.hits) || (a.order - b.order));
|
|
783
|
+
return scored.map((s) => s.intent);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/** Primary intent. Kept for callers that want a single label. */
|
|
787
|
+
function detectIntent(query) {
|
|
788
|
+
return detectIntents(query)[0];
|
|
634
789
|
}
|
|
635
790
|
|
|
636
|
-
module.exports = { rank, buildSigIndex, scoreFile, formatRankTable, formatRankJSON, DEFAULT_WEIGHTS, GRAPH_BOOST_AMOUNTS, CENTRALITY_BLEND_WEIGHT, detectIntent };
|
|
791
|
+
module.exports = { rank, buildSigIndex, scoreFile, _queryWants, _isDataHolder, detectIntents, formatRankTable, formatRankJSON, DEFAULT_WEIGHTS, GRAPH_BOOST_AMOUNTS, CENTRALITY_BLEND_WEIGHT, detectIntent };
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Complete, unbudgeted signature index for retrieval.
|
|
5
|
+
*
|
|
6
|
+
* WHY THIS EXISTS
|
|
7
|
+
* ---------------
|
|
8
|
+
* The generated context file (CLAUDE.md / AGENTS.md / copilot-instructions.md)
|
|
9
|
+
* is a BUDGETED VIEW: `applyTokenBudget` drops and collapses files so the
|
|
10
|
+
* artifact stays under `maxTokens`, because it is injected into every prompt.
|
|
11
|
+
*
|
|
12
|
+
* `buildSigIndex` used to parse that same artifact to build the ranker's index,
|
|
13
|
+
* so retrieval inherited the prompt budget. Every file the budget dropped became
|
|
14
|
+
* permanently unreachable by `sigmap ask` — no ranking change can surface a file
|
|
15
|
+
* that is not in the index. On this repo that was 53 of 155 source files (34%),
|
|
16
|
+
* and restoring them moved hit@5 from 50% to 90% on the retrieval corpus.
|
|
17
|
+
*
|
|
18
|
+
* The two artifacts have opposite requirements — the prompt file wants to be
|
|
19
|
+
* small, the index wants to be complete — so they are now separate. This store
|
|
20
|
+
* is written by `generate` BEFORE the budget is applied, and lives under
|
|
21
|
+
* `.context/` (gitignored, never injected into a prompt).
|
|
22
|
+
*
|
|
23
|
+
* Zero-dependency, bundle-safe (fs + path only).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const fs = require('fs');
|
|
27
|
+
const path = require('path');
|
|
28
|
+
|
|
29
|
+
const INDEX_DIR = '.context';
|
|
30
|
+
const INDEX_FILE = 'sig-index.json';
|
|
31
|
+
const SCHEMA = 1;
|
|
32
|
+
|
|
33
|
+
/** Absolute path to the retrieval index artifact. */
|
|
34
|
+
function indexPath(cwd) {
|
|
35
|
+
return path.join(cwd, INDEX_DIR, INDEX_FILE);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Persist the complete signature index.
|
|
40
|
+
*
|
|
41
|
+
* @param {string} cwd
|
|
42
|
+
* @param {Array<{filePath: string, sigs: string[]}>} fileEntries - every
|
|
43
|
+
* extracted entry, BEFORE applyTokenBudget has dropped or collapsed any.
|
|
44
|
+
* @param {{ version?: string }} [opts]
|
|
45
|
+
* @returns {{ path: string, files: number }}
|
|
46
|
+
*/
|
|
47
|
+
function writeFullIndex(cwd, fileEntries, opts = {}) {
|
|
48
|
+
const files = {};
|
|
49
|
+
let count = 0;
|
|
50
|
+
for (const e of fileEntries || []) {
|
|
51
|
+
if (!e || !e.filePath || !Array.isArray(e.sigs) || e.sigs.length === 0) continue;
|
|
52
|
+
const rel = path.relative(cwd, e.filePath).replace(/\\/g, '/');
|
|
53
|
+
if (!rel || rel.startsWith('..')) continue;
|
|
54
|
+
files[rel] = e.sigs;
|
|
55
|
+
count++;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const out = indexPath(cwd);
|
|
59
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
60
|
+
// Write-then-rename so a concurrent `ask` never observes a half-written index.
|
|
61
|
+
const tmp = `${out}.tmp`;
|
|
62
|
+
fs.writeFileSync(tmp, JSON.stringify({
|
|
63
|
+
schema: SCHEMA,
|
|
64
|
+
sigmapVersion: opts.version || null,
|
|
65
|
+
generated: new Date().toISOString(),
|
|
66
|
+
files,
|
|
67
|
+
}), 'utf8');
|
|
68
|
+
fs.renameSync(tmp, out);
|
|
69
|
+
return { path: out, files: count };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Load the complete signature index, or an empty Map when absent/unreadable.
|
|
74
|
+
*
|
|
75
|
+
* Deliberately NOT version-busted (unlike .sigmap-cache.json): a stale but
|
|
76
|
+
* complete index still retrieves the right files, whereas discarding it drops
|
|
77
|
+
* retrieval back to the budgeted view — the exact failure this store exists to
|
|
78
|
+
* prevent. Staleness is handled by re-running generate or by cache/freshen.
|
|
79
|
+
*
|
|
80
|
+
* @param {string} cwd
|
|
81
|
+
* @returns {Map<string, string[]>}
|
|
82
|
+
*/
|
|
83
|
+
function readFullIndex(cwd) {
|
|
84
|
+
const index = new Map();
|
|
85
|
+
try {
|
|
86
|
+
const data = JSON.parse(fs.readFileSync(indexPath(cwd), 'utf8'));
|
|
87
|
+
if (!data || data.schema !== SCHEMA || !data.files) return index;
|
|
88
|
+
for (const [rel, sigs] of Object.entries(data.files)) {
|
|
89
|
+
if (Array.isArray(sigs) && sigs.length > 0) index.set(rel, sigs);
|
|
90
|
+
}
|
|
91
|
+
} catch (_) { /* absent or corrupt → caller falls back to the context file */ }
|
|
92
|
+
return index;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
module.exports = { writeFullIndex, readFullIndex, indexPath, SCHEMA, INDEX_FILE };
|
package/src/skills/skills.js
CHANGED
|
@@ -34,6 +34,24 @@ const SKILLS = {
|
|
|
34
34
|
'6. **Watch the budget.** Check the `get_budget` MCP tool or `sigmap budget` (estimates from SigMap\'s local ledger — no LLM calls). Near the budget: summarize-then-drop older context instead of accumulating, and prefer terse output.',
|
|
35
35
|
].join('\n'),
|
|
36
36
|
},
|
|
37
|
+
'sigmap-task': {
|
|
38
|
+
title: 'SigMap task loop',
|
|
39
|
+
kind: 'prompt',
|
|
40
|
+
description: 'Do a coding task grounded in SigMap: look up before reading, edit by line anchor, verify before reporting.',
|
|
41
|
+
argumentHint: 'the change you want, in plain words',
|
|
42
|
+
body: [
|
|
43
|
+
'Work through these steps **in order**. Do not open any file before step 2.',
|
|
44
|
+
'Every command runs from the integrated terminal — do not ask the user to run them for you.',
|
|
45
|
+
'',
|
|
46
|
+
'1. **Look up, do not search.** `npx sigmap ask "<the task>"` — this writes `.context/query-context.md`.',
|
|
47
|
+
'2. **Read the map.** `cat .context/query-context.md`. It ranks the relevant files and lists their signatures with `:start-end` line anchors — a few hundred tokens where the same files read whole are tens of thousands. Say which files it surfaced before continuing. If nothing relevant appears, re-run step 1 with different wording; fall back to search only after two attempts, and say so.',
|
|
48
|
+
'3. **Open only the anchored ranges.** A signature ending `:425-425` means read line 425, not the whole file. Never read a file in full when you hold an anchor for it.',
|
|
49
|
+
'4. **Make the change.** Follow the conventions visible in the signatures — same layering, same response wrapper, same annotation style. Add no dependencies.',
|
|
50
|
+
'5. **Verify before reporting.** Write what you changed to `.sigmap-notes.md`, naming every file by its **full repository-relative path** (a bare filename is reported as fake), then run `npx sigmap verify-ai-output .sigmap-notes.md`. It checks every name against the real index, offline, with no model call. Fix anything it flags and re-run before you reply.',
|
|
51
|
+
'6. **Refresh the map.** `npx sigmap` — your edits made it stale.',
|
|
52
|
+
'7. **Report.** The files you changed, the ranges you actually read, the step-1 token count, and the step-5 verify result. Say so if you fell back to searching or if verify flagged something.',
|
|
53
|
+
].join('\n'),
|
|
54
|
+
},
|
|
37
55
|
'sigmap-config-optimizer': {
|
|
38
56
|
title: 'SigMap config optimizer',
|
|
39
57
|
description: 'Playbook for getting a correct SigMap config on any repo: detect with sigmap tune, review the per-change reasons, apply, validate.',
|
|
@@ -58,7 +76,9 @@ const SKILL_CLIENTS = {
|
|
|
58
76
|
windsurf: { label: 'Windsurf', parent: ['.windsurf'],
|
|
59
77
|
target: (cwd, skill) => path.join(cwd, '.windsurf', 'rules', `${skill}.md`) },
|
|
60
78
|
copilot: { label: 'GitHub Copilot', parent: ['.github'],
|
|
61
|
-
target: (cwd, skill) =>
|
|
79
|
+
target: (cwd, skill) => (SKILLS[skill] && SKILLS[skill].kind === 'prompt'
|
|
80
|
+
? path.join(cwd, '.github', 'prompts', `${skill}.prompt.md`)
|
|
81
|
+
: path.join(cwd, '.github', 'instructions', `${skill}.instructions.md`)) },
|
|
62
82
|
codex: { label: 'Codex CLI (AGENTS.md)', parent: ['AGENTS.md'],
|
|
63
83
|
target: (cwd) => path.join(cwd, 'AGENTS.md'), inject: true },
|
|
64
84
|
};
|
|
@@ -79,6 +99,10 @@ function renderSkill(client, skillName, version) {
|
|
|
79
99
|
return `---\ndescription: ${skill.description}\nalwaysApply: false\n---\n\n${body}`;
|
|
80
100
|
}
|
|
81
101
|
if (client === 'copilot') {
|
|
102
|
+
if (skill.kind === 'prompt') {
|
|
103
|
+
return `---\nname: ${skillName}\nagent: 'agent'\ndescription: ${skill.description}\n`
|
|
104
|
+
+ `argument-hint: ${skill.argumentHint}\n---\n\n${body}`;
|
|
105
|
+
}
|
|
82
106
|
return `---\napplyTo: "**"\n---\n\n${body}`;
|
|
83
107
|
}
|
|
84
108
|
return body; // windsurf: plain markdown
|