@ngockhoale/ukit 2.6.7 → 2.6.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +52 -0
- package/manifests/documentation.yaml +77 -6
- package/manifests/hostCapabilities.yaml +49 -0
- package/manifests/instructionRules.yaml +7 -0
- package/package.json +1 -1
- package/scripts/bench/goldTasks.json +38 -0
- package/scripts/bench/runGold.mjs +220 -0
- package/scripts/index/build-index.mjs +2 -1
- package/scripts/index/query-index.mjs +2 -0
- package/scripts/release/verify-release.mjs +6 -0
- package/src/cli/commands/code.js +182 -0
- package/src/cli/commands/doctor.js +35 -3
- package/src/cli/commands/indexTools.js +107 -1
- package/src/cli/commands/install.js +2 -1
- package/src/cli/commands/memory.js +137 -0
- package/src/cli/index.js +7 -0
- package/src/core/codeintel/analogy.js +197 -0
- package/src/core/codeintel/cochange.js +205 -0
- package/src/core/codeintel/compiler.js +389 -0
- package/src/core/codeintel/diagnostics.js +114 -0
- package/src/core/codeintel/freshness.js +295 -0
- package/src/core/codeintel/graph.js +291 -0
- package/src/core/codeintel/impact.js +274 -0
- package/src/core/codeintel/invalidation.js +150 -0
- package/src/core/codeintel/manifest.js +176 -0
- package/src/core/codeintel/packet.js +147 -0
- package/src/core/codeintel/providers.js +201 -0
- package/src/core/codeintel/retriever.js +418 -0
- package/src/core/codeintel/router.js +149 -0
- package/src/core/codeintel/semanticProvider.js +235 -0
- package/src/core/codeintel/summaries.js +194 -0
- package/src/core/codeintel/vectorProvider.js +213 -0
- package/src/core/docContracts.js +723 -0
- package/src/core/memory/migrate.js +324 -0
- package/src/core/memory/records.js +172 -0
- package/src/core/memory/retrieval.js +161 -11
- package/src/core/memory/store.js +398 -0
- package/src/core/memory/storeV2.js +171 -0
- package/src/core/memory/storeV2Loader.js +22 -0
- package/src/core/runtimeConfig.js +173 -0
- package/src/core/runtimePaths.js +3 -0
- package/src/index/buildIndex.js +29 -0
- package/src/index/paths.js +2 -0
- package/src/index/taskRouting.js +39 -0
- package/templates/.claude/ukit/index/route-task.mjs +40 -0
- package/templates/AGENTS.md +46 -99
- package/templates/CLAUDE.md +46 -99
- package/templates/docs/AI_HANDOFF/tasks/_TEMPLATE.md +5 -0
- package/templates/docs/BUGFIX.md +2 -19
- package/templates/docs/BUG_INDEX.md +43 -0
- package/templates/docs/BUG_METRICS.md +1 -5
- package/templates/docs/BUG_TEMPLATE.md +1 -11
- package/templates/docs/UKIT_INTERNALS.md +4 -0
- package/templates/instructions/core.md +46 -99
- package/templates/ukit/storage/config.json +35 -0
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { getArtifactPath, INDEX_ARTIFACTS, INDEX_SCHEMA_VERSION, normalizeRelative } from '../../index/paths.js';
|
|
5
|
+
import { loadRuntimeConfig } from '../runtimeConfig.js';
|
|
6
|
+
import { createEdge, getSemanticProvider, IndexFileSyntaxProvider } from './providers.js';
|
|
7
|
+
import { createSemanticProvider } from './semanticProvider.js';
|
|
8
|
+
import { createEmbeddingProvider } from './vectorProvider.js';
|
|
9
|
+
|
|
10
|
+
// Hybrid Retriever (SPEC §3): each lane produces a ranked list of file paths,
|
|
11
|
+
// then lanes merge via Reciprocal Rank Fusion (k=60, per-lane weights from
|
|
12
|
+
// codeIntel.retriever.weights). merge:'concat' preserves the exact C26
|
|
13
|
+
// sequential ordering as an escape hatch. A missing/failing lane is skipped
|
|
14
|
+
// and the reason lands in `omitted` — never fatal, never fabricated anchors.
|
|
15
|
+
|
|
16
|
+
const DEFAULT_LIMIT = 20;
|
|
17
|
+
const DEFAULT_BM25 = { k1: 1.2, b: 0.75 };
|
|
18
|
+
const DEFAULT_RRF_K = 60;
|
|
19
|
+
const DEFAULT_WEIGHTS = { exact: 1.0, symbol: 1.2, bm25: 0.8, vector: 0.6, semantic: 1.0 };
|
|
20
|
+
const LANE_ORDER = ['exact', 'symbol', 'bm25', 'vector', 'semantic'];
|
|
21
|
+
|
|
22
|
+
async function readArtifact(rootDir, name) {
|
|
23
|
+
try {
|
|
24
|
+
const raw = await fs.readFile(getArtifactPath(rootDir, name), 'utf8');
|
|
25
|
+
const parsed = JSON.parse(raw);
|
|
26
|
+
if (parsed?.schemaVersion !== undefined && parsed.schemaVersion !== INDEX_SCHEMA_VERSION) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
return parsed;
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function tokenize(text) {
|
|
36
|
+
return String(text ?? '')
|
|
37
|
+
.split(/[^A-Za-z0-9_$]+/)
|
|
38
|
+
.map((t) => t.toLowerCase())
|
|
39
|
+
.filter(Boolean);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function retrieverDefaults(config) {
|
|
43
|
+
const retriever = config?.codeIntel?.retriever;
|
|
44
|
+
const configWeights = retriever?.weights;
|
|
45
|
+
const weight = (lane) => (
|
|
46
|
+
typeof configWeights?.[lane] === 'number' ? configWeights[lane] : DEFAULT_WEIGHTS[lane]
|
|
47
|
+
);
|
|
48
|
+
return {
|
|
49
|
+
limit: typeof retriever?.limit === 'number' ? retriever.limit : DEFAULT_LIMIT,
|
|
50
|
+
merge: retriever?.merge === 'concat' ? 'concat' : 'rrf',
|
|
51
|
+
rrfK: typeof retriever?.rrfK === 'number' && retriever.rrfK > 0 ? retriever.rrfK : DEFAULT_RRF_K,
|
|
52
|
+
bm25: {
|
|
53
|
+
k1: typeof retriever?.bm25?.k1 === 'number' ? retriever.bm25.k1 : DEFAULT_BM25.k1,
|
|
54
|
+
b: typeof retriever?.bm25?.b === 'number' ? retriever.bm25.b : DEFAULT_BM25.b,
|
|
55
|
+
},
|
|
56
|
+
weights: {
|
|
57
|
+
exact: weight('exact'),
|
|
58
|
+
symbol: weight('symbol'),
|
|
59
|
+
bm25: weight('bm25'),
|
|
60
|
+
vector: weight('vector'),
|
|
61
|
+
semantic: weight('semantic'),
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function bm25Score(queryTokens, docTokens, avgDocLen, { k1, b }) {
|
|
67
|
+
if (docTokens.length === 0) return 0;
|
|
68
|
+
const freq = new Map();
|
|
69
|
+
for (const t of docTokens) freq.set(t, (freq.get(t) ?? 0) + 1);
|
|
70
|
+
let score = 0;
|
|
71
|
+
const lenNorm = 1 - b + (b * docTokens.length) / (avgDocLen || 1);
|
|
72
|
+
for (const qt of queryTokens) {
|
|
73
|
+
const f = freq.get(qt) ?? 0;
|
|
74
|
+
if (f === 0) continue;
|
|
75
|
+
score += ((f * (k1 + 1)) / (f + k1 * lenNorm));
|
|
76
|
+
}
|
|
77
|
+
return score;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function resolveRelativeSpecifier(fromFile, specifier, fileSet) {
|
|
81
|
+
const candidates = [];
|
|
82
|
+
if (specifier.startsWith('.')) {
|
|
83
|
+
const base = path.posix.normalize(path.posix.join(path.posix.dirname(fromFile), specifier));
|
|
84
|
+
candidates.push(base, `${base}.js`, `${base}.ts`, `${base}.mjs`, `${base}.jsx`, `${base}.tsx`, `${base}/index.js`, `${base}/index.ts`);
|
|
85
|
+
} else {
|
|
86
|
+
candidates.push(specifier);
|
|
87
|
+
}
|
|
88
|
+
for (const candidate of candidates) {
|
|
89
|
+
if (fileSet.has(candidate)) return candidate;
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function makeEvidence({ path: filePath, why, excerpt }) {
|
|
95
|
+
const item = { level: 'L1', source: 'index', why };
|
|
96
|
+
if (filePath) item.path = filePath;
|
|
97
|
+
if (excerpt) item.excerpt = excerpt;
|
|
98
|
+
return item;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Lane helpers produce ranked, deduped lists of { path, why, extra }.
|
|
102
|
+
const laneEntry = (filePath, why, extra = {}) => ({ path: filePath, why, extra });
|
|
103
|
+
|
|
104
|
+
function exactLane(fileItems, normalizedQuery, queryBase) {
|
|
105
|
+
const lane = [];
|
|
106
|
+
const seen = new Set();
|
|
107
|
+
for (const item of fileItems) {
|
|
108
|
+
if (!item?.filePath || seen.has(item.filePath)) continue;
|
|
109
|
+
const base = path.posix.basename(item.filePath).toLowerCase();
|
|
110
|
+
if (item.filePath === normalizedQuery || base === queryBase || base === normalizedQuery.toLowerCase()) {
|
|
111
|
+
seen.add(item.filePath);
|
|
112
|
+
lane.push(laneEntry(item.filePath, `exact-path match for "${normalizedQuery}"`));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return lane;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function symbolLane(symbolItems, normalizedQuery, fileSet) {
|
|
119
|
+
const lane = [];
|
|
120
|
+
const seen = new Set();
|
|
121
|
+
for (const sym of symbolItems) {
|
|
122
|
+
if (!sym?.name || !sym?.filePath || seen.has(sym.filePath)) continue;
|
|
123
|
+
if (sym.name === normalizedQuery || sym.name.toLowerCase() === normalizedQuery.toLowerCase()) {
|
|
124
|
+
if (fileSet.size === 0 || fileSet.has(sym.filePath)) {
|
|
125
|
+
seen.add(sym.filePath);
|
|
126
|
+
lane.push(laneEntry(
|
|
127
|
+
sym.filePath,
|
|
128
|
+
`symbol "${sym.name}" defined here`,
|
|
129
|
+
{ symbol: sym.name, line: sym.line },
|
|
130
|
+
));
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return lane;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function bm25Lane(fileItems, symbolItems, queryTokens, bm25) {
|
|
138
|
+
if (queryTokens.length === 0) return null;
|
|
139
|
+
const docs = new Map();
|
|
140
|
+
for (const item of fileItems) {
|
|
141
|
+
if (!item?.filePath) continue;
|
|
142
|
+
docs.set(item.filePath, tokenize(item.filePath));
|
|
143
|
+
}
|
|
144
|
+
for (const sym of symbolItems) {
|
|
145
|
+
if (!sym?.filePath) continue;
|
|
146
|
+
const doc = docs.get(sym.filePath) ?? [];
|
|
147
|
+
doc.push(...tokenize(sym.name));
|
|
148
|
+
docs.set(sym.filePath, doc);
|
|
149
|
+
}
|
|
150
|
+
const avgLen = [...docs.values()].reduce((sum, d) => sum + d.length, 0) / (docs.size || 1);
|
|
151
|
+
return [...docs.entries()]
|
|
152
|
+
.map(([filePath, docTokens]) => ({
|
|
153
|
+
filePath,
|
|
154
|
+
score: bm25Score(queryTokens, docTokens, avgLen, bm25),
|
|
155
|
+
}))
|
|
156
|
+
.filter((entry) => entry.score > 0)
|
|
157
|
+
.sort((a, b) => b.score - a.score || a.filePath.localeCompare(b.filePath))
|
|
158
|
+
.map((entry) => laneEntry(entry.filePath, `bm25 match (score ${entry.score.toFixed(2)})`));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Semantic lane (SPEC §2/§3): registered provider wins, else
|
|
162
|
+
// createSemanticProvider honors config.codeIntel.providers.semantic. Edges'
|
|
163
|
+
// `to` carries "file:line" — reduce to a ranked file list.
|
|
164
|
+
async function semanticLane(rootDir, normalizedQuery, config, fileSet, snapshotId) {
|
|
165
|
+
const provider = getSemanticProvider()?.name === 'null'
|
|
166
|
+
? createSemanticProvider({ projectRoot: rootDir, config })
|
|
167
|
+
: getSemanticProvider();
|
|
168
|
+
if (!provider || provider.name === 'null' || typeof provider.resolve !== 'function') {
|
|
169
|
+
return { lane: null, why: 'unavailable' };
|
|
170
|
+
}
|
|
171
|
+
let edges;
|
|
172
|
+
try {
|
|
173
|
+
edges = await provider.resolve(normalizedQuery, { mode: 'both', snapshot: snapshotId });
|
|
174
|
+
} catch {
|
|
175
|
+
return { lane: null, why: 'provider-error' };
|
|
176
|
+
}
|
|
177
|
+
if (edges === null || edges === undefined) {
|
|
178
|
+
return { lane: null, why: 'unavailable' };
|
|
179
|
+
}
|
|
180
|
+
if (!Array.isArray(edges) || edges.length === 0) {
|
|
181
|
+
return { lane: [], why: 'no-match' };
|
|
182
|
+
}
|
|
183
|
+
const lane = [];
|
|
184
|
+
const seen = new Set();
|
|
185
|
+
for (const edge of edges) {
|
|
186
|
+
const target = typeof edge?.to === 'string' ? edge.to.split(':')[0] : null;
|
|
187
|
+
if (!target) continue;
|
|
188
|
+
const rel = path.isAbsolute(target) ? normalizeRelative(rootDir, target) : target;
|
|
189
|
+
if (seen.has(rel)) continue;
|
|
190
|
+
if (fileSet.size > 0 && !fileSet.has(rel)) continue;
|
|
191
|
+
seen.add(rel);
|
|
192
|
+
lane.push(laneEntry(rel, `semantic ${edge.kind ?? 'match'} for "${normalizedQuery}"`));
|
|
193
|
+
}
|
|
194
|
+
return { lane, why: lane.length === 0 ? 'no-match' : null };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Vector lane (SPEC §2/§3): dep-free hashed embedding provider; mirrors the
|
|
198
|
+
// semantic lane. Edges' `to` carries "file:0" — reduce to a ranked file list.
|
|
199
|
+
async function vectorLane(rootDir, normalizedQuery, config, fileSet, snapshotId) {
|
|
200
|
+
const provider = createEmbeddingProvider({ projectRoot: rootDir, config });
|
|
201
|
+
if (!provider || provider.name === 'null' || typeof provider.resolve !== 'function') {
|
|
202
|
+
return { lane: null, why: 'unavailable' };
|
|
203
|
+
}
|
|
204
|
+
let edges;
|
|
205
|
+
try {
|
|
206
|
+
edges = await provider.resolve(normalizedQuery, { limit: DEFAULT_LIMIT, snapshot: snapshotId });
|
|
207
|
+
} catch {
|
|
208
|
+
return { lane: null, why: 'provider-error' };
|
|
209
|
+
}
|
|
210
|
+
if (edges === null || edges === undefined) {
|
|
211
|
+
return { lane: null, why: 'unavailable' };
|
|
212
|
+
}
|
|
213
|
+
if (!Array.isArray(edges) || edges.length === 0) {
|
|
214
|
+
return { lane: [], why: 'no-match' };
|
|
215
|
+
}
|
|
216
|
+
const lane = [];
|
|
217
|
+
const seen = new Set();
|
|
218
|
+
for (const edge of edges) {
|
|
219
|
+
const target = typeof edge?.to === 'string' ? edge.to.split(':')[0] : null;
|
|
220
|
+
if (!target) continue;
|
|
221
|
+
const rel = path.isAbsolute(target) ? normalizeRelative(rootDir, target) : target;
|
|
222
|
+
if (seen.has(rel)) continue;
|
|
223
|
+
if (fileSet.size > 0 && !fileSet.has(rel)) continue;
|
|
224
|
+
seen.add(rel);
|
|
225
|
+
lane.push(laneEntry(rel, `vector ${edge.kind ?? 'match'} for "${normalizedQuery}"`));
|
|
226
|
+
}
|
|
227
|
+
return { lane, why: lane.length === 0 ? 'no-match' : null };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function rrfMerge(lanes, weights, rrfK) {
|
|
231
|
+
const scores = new Map();
|
|
232
|
+
const meta = new Map();
|
|
233
|
+
for (const laneName of LANE_ORDER) {
|
|
234
|
+
const lane = lanes.get(laneName);
|
|
235
|
+
if (!lane) continue;
|
|
236
|
+
const w = weights[laneName] ?? 0;
|
|
237
|
+
if (w <= 0) continue;
|
|
238
|
+
lane.forEach((entry, index) => {
|
|
239
|
+
scores.set(entry.path, (scores.get(entry.path) ?? 0) + w / (rrfK + index + 1));
|
|
240
|
+
if (!meta.has(entry.path)) meta.set(entry.path, entry);
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
return [...scores.entries()]
|
|
244
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
245
|
+
.map(([filePath]) => meta.get(filePath));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function concatMerge(lanes) {
|
|
249
|
+
const merged = [];
|
|
250
|
+
const seen = new Set();
|
|
251
|
+
for (const laneName of LANE_ORDER) {
|
|
252
|
+
const lane = lanes.get(laneName);
|
|
253
|
+
if (!lane) continue;
|
|
254
|
+
for (const entry of lane) {
|
|
255
|
+
if (seen.has(entry.path)) continue;
|
|
256
|
+
seen.add(entry.path);
|
|
257
|
+
merged.push(entry);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return merged;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* retrieve(projectRoot, query, { mode, limit, snapshot, merge, weights }) →
|
|
265
|
+
* { anchors, evidence, relations, omitted }
|
|
266
|
+
*
|
|
267
|
+
* modes: 'search' (default) | 'impact' (adds 1-hop reverse-import edges).
|
|
268
|
+
* merge: 'rrf' (default) | 'concat' (exact C26 sequential ordering).
|
|
269
|
+
* weights: per-lane RRF weight overrides; defaults come from
|
|
270
|
+
* codeIntel.retriever.weights (in-code defaults when config is absent).
|
|
271
|
+
* Never throws on a missing/partial index — returns empty lanes with reasons
|
|
272
|
+
* in `omitted`.
|
|
273
|
+
*/
|
|
274
|
+
export async function retrieve(projectRoot, query, { mode = 'search', limit, snapshot, merge, weights } = {}) {
|
|
275
|
+
const rootDir = path.resolve(projectRoot ?? process.cwd());
|
|
276
|
+
const omitted = [];
|
|
277
|
+
|
|
278
|
+
let config = null;
|
|
279
|
+
try {
|
|
280
|
+
config = await loadRuntimeConfig(rootDir);
|
|
281
|
+
} catch {
|
|
282
|
+
config = null;
|
|
283
|
+
}
|
|
284
|
+
const defaults = retrieverDefaults(config);
|
|
285
|
+
const effectiveLimit = typeof limit === 'number' && limit > 0 ? limit : defaults.limit;
|
|
286
|
+
const effectiveMerge = merge === 'concat' || merge === 'rrf' ? merge : defaults.merge;
|
|
287
|
+
const effectiveWeights = { ...defaults.weights };
|
|
288
|
+
if (weights && typeof weights === 'object') {
|
|
289
|
+
for (const laneName of LANE_ORDER) {
|
|
290
|
+
if (typeof weights[laneName] === 'number') effectiveWeights[laneName] = weights[laneName];
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const [filesArtifact, symbolsArtifact, importsArtifact, metaArtifact] = await Promise.all([
|
|
295
|
+
readArtifact(rootDir, INDEX_ARTIFACTS.files),
|
|
296
|
+
readArtifact(rootDir, INDEX_ARTIFACTS.symbols),
|
|
297
|
+
readArtifact(rootDir, INDEX_ARTIFACTS.imports),
|
|
298
|
+
readArtifact(rootDir, INDEX_ARTIFACTS.meta),
|
|
299
|
+
]);
|
|
300
|
+
|
|
301
|
+
const snapshotId = snapshot ?? metaArtifact?.headSha ?? metaArtifact?.snapshot ?? null;
|
|
302
|
+
|
|
303
|
+
if (!filesArtifact && !symbolsArtifact) {
|
|
304
|
+
omitted.push({ what: 'retrieval', why: 'index-missing' });
|
|
305
|
+
return { anchors: [], evidence: [], relations: [], omitted };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const fileItems = filesArtifact?.items ?? [];
|
|
309
|
+
const symbolItems = symbolsArtifact?.items ?? [];
|
|
310
|
+
const fileSet = new Set(fileItems.map((f) => f.filePath));
|
|
311
|
+
const normalizedQuery = String(query ?? '').trim();
|
|
312
|
+
const queryTokens = tokenize(normalizedQuery);
|
|
313
|
+
const queryBase = path.posix.basename(normalizedQuery).toLowerCase();
|
|
314
|
+
|
|
315
|
+
const lanes = new Map();
|
|
316
|
+
|
|
317
|
+
// Lane 1 — exact path / basename match.
|
|
318
|
+
const exact = exactLane(fileItems, normalizedQuery, queryBase);
|
|
319
|
+
if (exact.length > 0) lanes.set('exact', exact);
|
|
320
|
+
|
|
321
|
+
// Lane 2 — symbol-name match via the syntax provider (index-file fallback).
|
|
322
|
+
const provider = new IndexFileSyntaxProvider({ rootDir });
|
|
323
|
+
if (!symbolsArtifact) {
|
|
324
|
+
omitted.push({ what: 'symbol-lane', why: 'index-missing' });
|
|
325
|
+
}
|
|
326
|
+
const symbol = symbolLane(symbolItems, normalizedQuery, fileSet);
|
|
327
|
+
if (symbol.length > 0) lanes.set('symbol', symbol);
|
|
328
|
+
// Enrich anchors with provider symbols when the lane matched (best-effort).
|
|
329
|
+
for (const entry of symbol) {
|
|
330
|
+
try {
|
|
331
|
+
if (provider.supports(entry.path)) {
|
|
332
|
+
await provider.symbols(entry.path);
|
|
333
|
+
}
|
|
334
|
+
} catch {
|
|
335
|
+
// Provider enrichment is best-effort; anchors already stand.
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Lane 3 — BM25-lite over file/symbol tokens.
|
|
340
|
+
const bm25 = bm25Lane(fileItems, symbolItems, queryTokens, defaults.bm25);
|
|
341
|
+
if (bm25 === null) {
|
|
342
|
+
if (exact.length === 0 && symbol.length === 0) {
|
|
343
|
+
omitted.push({ what: 'bm25-lane', why: 'empty-query-tokens' });
|
|
344
|
+
}
|
|
345
|
+
} else if (bm25.length > 0) {
|
|
346
|
+
// Lane pool is wider than the output limit so deep-ranked bm25 hits still
|
|
347
|
+
// get an RRF rank instead of silently dropping out of the merge.
|
|
348
|
+
lanes.set('bm25', bm25.slice(0, Math.max(effectiveLimit, 100)));
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Lane 4 — dep-free hashed vector provider (index-bound; absent → omitted).
|
|
352
|
+
const vector = await vectorLane(rootDir, normalizedQuery, config, fileSet, snapshotId);
|
|
353
|
+
if (vector.lane === null) {
|
|
354
|
+
omitted.push({ what: 'vector-lane', why: vector.why });
|
|
355
|
+
} else if (vector.lane.length > 0) {
|
|
356
|
+
lanes.set('vector', vector.lane);
|
|
357
|
+
} else {
|
|
358
|
+
omitted.push({ what: 'vector-lane', why: vector.why });
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Lane 5 — semantic provider (defensive; absent → skipped, never fatal).
|
|
362
|
+
const semantic = await semanticLane(rootDir, normalizedQuery, config, fileSet, snapshotId);
|
|
363
|
+
if (semantic.lane === null) {
|
|
364
|
+
omitted.push({ what: 'semantic-lane', why: semantic.why });
|
|
365
|
+
} else if (semantic.lane.length > 0) {
|
|
366
|
+
lanes.set('semantic', semantic.lane);
|
|
367
|
+
} else {
|
|
368
|
+
omitted.push({ what: 'semantic-lane', why: semantic.why });
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const merged = effectiveMerge === 'concat'
|
|
372
|
+
? concatMerge(lanes)
|
|
373
|
+
: rrfMerge(lanes, effectiveWeights, defaults.rrfK);
|
|
374
|
+
|
|
375
|
+
const anchors = [];
|
|
376
|
+
const evidence = [];
|
|
377
|
+
for (const entry of merged) {
|
|
378
|
+
if (anchors.length >= effectiveLimit) break;
|
|
379
|
+
anchors.push({ path: entry.path, ...entry.extra });
|
|
380
|
+
evidence.push(makeEvidence({ path: entry.path, why: entry.why }));
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (anchors.length === 0) {
|
|
384
|
+
omitted.push({ what: 'anchors', why: 'no-match' });
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const relations = [];
|
|
388
|
+
|
|
389
|
+
// Impact mode — 1-hop reverse imports: who imports each anchor file.
|
|
390
|
+
if (mode === 'impact') {
|
|
391
|
+
const importItems = importsArtifact?.items ?? [];
|
|
392
|
+
if (!importsArtifact) {
|
|
393
|
+
omitted.push({ what: 'impact-lane', why: 'index-missing' });
|
|
394
|
+
} else {
|
|
395
|
+
for (const anchor of anchors) {
|
|
396
|
+
for (const imp of importItems) {
|
|
397
|
+
if (!imp?.from || !imp?.to) continue;
|
|
398
|
+
const resolved = resolveRelativeSpecifier(imp.from, imp.to, fileSet);
|
|
399
|
+
if (resolved !== anchor.path) continue;
|
|
400
|
+
relations.push(createEdge({
|
|
401
|
+
from: imp.from,
|
|
402
|
+
to: anchor.path,
|
|
403
|
+
kind: imp.kind ?? 'import',
|
|
404
|
+
confidence: 1,
|
|
405
|
+
provider: 'index-file',
|
|
406
|
+
evidence: INDEX_ARTIFACTS.imports,
|
|
407
|
+
snapshot: snapshotId,
|
|
408
|
+
}));
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
if (relations.length === 0) {
|
|
412
|
+
omitted.push({ what: 'impact-lane', why: 'no-match' });
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
return { anchors, evidence, relations, omitted };
|
|
418
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// Task Router v1 (SPEC §6) — rule-based gate, no model calls.
|
|
2
|
+
// First-match wins, top→bottom. Output: { mode, reasons[], budget, depth }.
|
|
3
|
+
|
|
4
|
+
const DEFAULT_BUDGETS = Object.freeze({
|
|
5
|
+
none: 0,
|
|
6
|
+
peek: 500,
|
|
7
|
+
targeted: 2000,
|
|
8
|
+
impact: 3000,
|
|
9
|
+
explore: 4000,
|
|
10
|
+
deep_flow: 6000,
|
|
11
|
+
analogy: 4000,
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const DEFAULT_DEPTHS = Object.freeze({
|
|
15
|
+
none: 0,
|
|
16
|
+
peek: 1,
|
|
17
|
+
targeted: 1,
|
|
18
|
+
impact: 2,
|
|
19
|
+
explore: 2,
|
|
20
|
+
deep_flow: 3,
|
|
21
|
+
analogy: 2,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export const ROUTER_MODES = Object.freeze([
|
|
25
|
+
'none',
|
|
26
|
+
'peek',
|
|
27
|
+
'targeted',
|
|
28
|
+
'explore',
|
|
29
|
+
'impact',
|
|
30
|
+
'deep_flow',
|
|
31
|
+
'analogy',
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
const TRIVIAL_RE = /\b(typo|label|rename|spacing|toggle|whitespace|comment fix|small tweak)\b/i;
|
|
35
|
+
const ERROR_RE = /\b(error|exception|stack ?trace|bug|crash|failing|fails|failure|regression|TypeError|ReferenceError|SyntaxError|ENOENT|segfault)\b/i;
|
|
36
|
+
const EXPLORE_RE = /\b(where|how does|how do|find|explore|understand|what does|which file|locate|handled)\b/i;
|
|
37
|
+
const DEEP_FLOW_RE = /\b(refactor|migrate|migration|flow|end[- ]?to[- ]?end|trace|across|pipeline|lifecycle)\b/i;
|
|
38
|
+
const ANALOGY_RE = /\b(similar to|analogy|like\b|same as|pattern|equivalent)\b/i;
|
|
39
|
+
const PATH_RE = /(?:[\w.-]+\/)+[\w.-]+|[\w.-]+\.(?:js|ts|mjs|cjs|jsx|tsx|py|rb|go|rs|java|md|json|yml|yaml|css|html|sh)\b/i;
|
|
40
|
+
|
|
41
|
+
function resolveBudgets(options) {
|
|
42
|
+
const configured = options?.config?.codeIntel?.budgets ?? options?.budgets;
|
|
43
|
+
const budgets = { ...DEFAULT_BUDGETS };
|
|
44
|
+
if (configured && typeof configured === 'object') {
|
|
45
|
+
for (const mode of ROUTER_MODES) {
|
|
46
|
+
const value = configured[mode];
|
|
47
|
+
if (typeof value === 'number' && Number.isFinite(value) && value >= 0) {
|
|
48
|
+
budgets[mode] = value;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return budgets;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function decide(prompt, { hasError = false, filesHinted } = {}) {
|
|
56
|
+
const hintedFiles = Array.isArray(filesHinted) ? filesHinted : [];
|
|
57
|
+
const reasons = [];
|
|
58
|
+
|
|
59
|
+
// Rule 2 — trivial signals: typo/label/rename/toggle, <=1 hinted file, no error.
|
|
60
|
+
if (!hasError && hintedFiles.length <= 1 && TRIVIAL_RE.test(prompt)) {
|
|
61
|
+
reasons.push('trivial signal matched (typo/label/rename/toggle) with no error context');
|
|
62
|
+
return { mode: 'none', reasons };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Rule 3 — explicit path/symbol mention, clear scope, no error.
|
|
66
|
+
const hasExplicitTarget = hintedFiles.length > 0 || PATH_RE.test(prompt);
|
|
67
|
+
const errorSignalled = hasError || ERROR_RE.test(prompt);
|
|
68
|
+
if (!errorSignalled && hasExplicitTarget) {
|
|
69
|
+
reasons.push('explicit path or symbol mention with clear scope');
|
|
70
|
+
return { mode: 'targeted', reasons };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Rule 4 — error signature / stack / bug wording.
|
|
74
|
+
if (errorSignalled) {
|
|
75
|
+
reasons.push('error signature or failure wording detected');
|
|
76
|
+
return { mode: 'impact', reasons };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Rule 5 — open-ended exploration wording.
|
|
80
|
+
if (EXPLORE_RE.test(prompt)) {
|
|
81
|
+
reasons.push('open-ended where/how/find/explore wording');
|
|
82
|
+
return { mode: 'explore', reasons };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Rule 6 — cross-cutting refactor/migrate/flow wording.
|
|
86
|
+
if (DEEP_FLOW_RE.test(prompt)) {
|
|
87
|
+
reasons.push('cross-cutting refactor/migrate/flow wording');
|
|
88
|
+
return { mode: 'deep_flow', reasons };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Rule 7 — analogy / pattern search wording.
|
|
92
|
+
if (ANALOGY_RE.test(prompt)) {
|
|
93
|
+
reasons.push('similar-to / analogy pattern-search wording');
|
|
94
|
+
return { mode: 'analogy', reasons };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Rule 8 — fallback: single-file verb (single hinted file or lone path mention).
|
|
98
|
+
if (hintedFiles.length === 1 || PATH_RE.test(prompt)) {
|
|
99
|
+
reasons.push('fallback: single-file hint');
|
|
100
|
+
return { mode: 'peek', reasons };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Rule 9 — fallback otherwise.
|
|
104
|
+
reasons.push('fallback: no rule matched');
|
|
105
|
+
return { mode: 'explore', reasons };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* routeTask({ prompt, hasError?, filesHinted?, flags? }, options?)
|
|
110
|
+
* options: { config } (runtime config — uses config.codeIntel.budgets) or { budgets }.
|
|
111
|
+
* Returns { mode, reasons, budget, depth }.
|
|
112
|
+
*/
|
|
113
|
+
export function routeTask(input = {}, options = {}) {
|
|
114
|
+
const safeInput = input && typeof input === 'object' ? input : {};
|
|
115
|
+
const prompt = typeof safeInput.prompt === 'string' ? safeInput.prompt : '';
|
|
116
|
+
const flags = safeInput.flags && typeof safeInput.flags === 'object' ? safeInput.flags : {};
|
|
117
|
+
const budgets = resolveBudgets(options);
|
|
118
|
+
|
|
119
|
+
// Rule 1 — forced mode wins; unknown forced mode falls back to explore gracefully.
|
|
120
|
+
if (typeof flags.mode === 'string' && flags.mode.trim() !== '') {
|
|
121
|
+
const forced = flags.mode.trim();
|
|
122
|
+
if (ROUTER_MODES.includes(forced)) {
|
|
123
|
+
return {
|
|
124
|
+
mode: forced,
|
|
125
|
+
reasons: [`flags.mode forced to '${forced}'`],
|
|
126
|
+
budget: budgets[forced],
|
|
127
|
+
depth: DEFAULT_DEPTHS[forced],
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
mode: 'explore',
|
|
132
|
+
reasons: [`flags.mode '${forced}' is not a valid mode; graceful fallback to explore`],
|
|
133
|
+
budget: budgets.explore,
|
|
134
|
+
depth: DEFAULT_DEPTHS.explore,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const { mode, reasons } = decide(prompt, {
|
|
139
|
+
hasError: safeInput.hasError === true,
|
|
140
|
+
filesHinted: safeInput.filesHinted,
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
mode,
|
|
145
|
+
reasons,
|
|
146
|
+
budget: budgets[mode],
|
|
147
|
+
depth: DEFAULT_DEPTHS[mode],
|
|
148
|
+
};
|
|
149
|
+
}
|