ctxman 3.1.0 → 3.2.7
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/.ctxman/llm-profiles.json +6 -6
- package/README.md +17 -10
- package/bin/cli.js +107 -8
- package/bin/cm-ask.js +74 -70
- package/bin/cm-audit.js +80 -7
- package/bin/cm-diagnose.js +199 -0
- package/bin/cm-focus.js +322 -0
- package/bin/cm-map.js +71 -18
- package/bin/cm-pack.js +36 -5
- package/bin/cm-policy.js +107 -2
- package/bin/cm-pr.js +18 -3
- package/bin/cm-search.js +84 -19
- package/bin/cm-task.js +192 -0
- package/bin/cm-who.js +119 -0
- package/bin/cm-why.js +41 -5
- package/examples/github-actions/ctxman-check.yml +1 -1
- package/index.d.ts +931 -1
- package/index.js +105 -1
- package/lib/analyzers/performance-dashboard.js +7 -3
- package/lib/analyzers/token-calculator.js +76 -4
- package/lib/api/mcp/server.js +713 -14
- package/lib/cache/CacheManager.js +161 -47
- package/lib/core/Analyzer.js +61 -5
- package/lib/diagnose/index.js +712 -0
- package/lib/focus/index.js +790 -0
- package/lib/map/call-graph.js +570 -0
- package/lib/map/graph.js +30 -5
- package/lib/map/index.js +12 -4
- package/lib/map/rank.js +45 -7
- package/lib/map/render.js +89 -28
- package/lib/pack/index.js +20 -1
- package/lib/pack/select.js +8 -1
- package/lib/parsers/gitignore-parser.js +4 -1
- package/lib/policy/audit.js +22 -2
- package/lib/policy/index.js +6 -0
- package/lib/policy/workspace.js +254 -0
- package/lib/pr/pr-context.js +292 -31
- package/lib/pr/pr-renderer.js +25 -10
- package/lib/rag/EmbeddingProvider.js +139 -2
- package/lib/rag/VectorStoreFactory.js +10 -4
- package/lib/rag/ask.js +291 -0
- package/lib/rag/sources/FileDataSource.js +6 -3
- package/lib/rag/stores/SqliteVectorStore.js +595 -0
- package/lib/search/hybrid.js +324 -0
- package/lib/search/symbols.js +75 -11
- package/lib/task/index.js +630 -0
- package/lib/utils/file-utils.js +1 -0
- package/lib/utils/logger.js +5 -2
- package/lib/utils/multi-repo-manager.js +4 -1
- package/lib/watch/FileWatcher.js +121 -31
- package/lib/watch/IncrementalAnalyzer.js +964 -16
- package/lib/who/index.js +893 -0
- package/lib/why/analyzer-bridge.js +223 -0
- package/lib/why/discussions.js +314 -0
- package/lib/why/index.js +356 -30
- package/lib/why/render.js +154 -9
- package/lib/wizards/config-generator.js +20 -18
- package/lib/wizards/project-detector.js +2 -1
- package/lib/workspace/index.js +1274 -0
- package/package.json +2 -2
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { Scanner } from '../core/Scanner.js';
|
|
4
|
+
import { hybridSearch } from '../search/hybrid.js';
|
|
5
|
+
import { buildRepoGraph } from '../map/graph.js';
|
|
6
|
+
import { renderRepoMap } from '../map/render.js';
|
|
7
|
+
import { normalizeSymbolName } from '../map/call-graph.js';
|
|
8
|
+
import { compressCode } from '../pack/compress.js';
|
|
9
|
+
import { fitToTokenBudget } from '../search/bm25.js';
|
|
10
|
+
import { redactSecrets } from '../utils/secret-redactor.js';
|
|
11
|
+
import TokenUtils from '../utils/token-utils.js';
|
|
12
|
+
import {
|
|
13
|
+
resolveWorkspaceRepos,
|
|
14
|
+
enforceWorkspacePolicies,
|
|
15
|
+
buildWorkspaceGraph,
|
|
16
|
+
mapWorkspace,
|
|
17
|
+
searchWorkspace,
|
|
18
|
+
} from '../workspace/index.js';
|
|
19
|
+
|
|
20
|
+
export const DEFAULT_TASK_BUDGET = 32000;
|
|
21
|
+
export const SUPPORTED_TASK_FORMATS = ['markdown', 'json', 'xml'];
|
|
22
|
+
const MAX_RENDERED_OMITTED = 12;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Parse budget expression (e.g. 32000, "32k", "40k", "1m").
|
|
26
|
+
* @param {number|string} budget
|
|
27
|
+
* @param {number} defaultBudget
|
|
28
|
+
* @returns {number}
|
|
29
|
+
*/
|
|
30
|
+
export function parseBudget(budget, defaultBudget = DEFAULT_TASK_BUDGET) {
|
|
31
|
+
if (budget === undefined || budget === null || budget === '') return defaultBudget;
|
|
32
|
+
if (typeof budget === 'number') {
|
|
33
|
+
if (!Number.isInteger(budget) || budget <= 0) {
|
|
34
|
+
throw new Error(`budget must be a positive integer, got ${budget}`);
|
|
35
|
+
}
|
|
36
|
+
return budget;
|
|
37
|
+
}
|
|
38
|
+
const match = /^(\d+(?:\.\d+)?)([km])?$/i.exec(String(budget).trim());
|
|
39
|
+
const scale = { k: 1e3, m: 1e6 }[match?.[2]?.toLowerCase()] ?? 1;
|
|
40
|
+
const value = match ? Math.round(Number(match[1]) * scale) : NaN;
|
|
41
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
42
|
+
throw new Error(`budget must be a positive integer, got ${JSON.stringify(budget)}`);
|
|
43
|
+
}
|
|
44
|
+
return value;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const toPosix = (p) => p.split(path.sep).join('/');
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Computes intra-file helper closure starting from `symbolName` inside `node`.
|
|
51
|
+
*/
|
|
52
|
+
function intraFileClosure(node, symbolName) {
|
|
53
|
+
const short = normalizeSymbolName(symbolName);
|
|
54
|
+
const closure = new Set([symbolName, short].filter(Boolean));
|
|
55
|
+
if (!node || !Array.isArray(node.methods)) return closure;
|
|
56
|
+
|
|
57
|
+
const byName = new Map();
|
|
58
|
+
for (const m of node.methods) {
|
|
59
|
+
const mShort = normalizeSymbolName(m.name);
|
|
60
|
+
if (!byName.has(mShort)) byName.set(mShort, []);
|
|
61
|
+
byName.get(mShort).push(m);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const queue = [short];
|
|
65
|
+
while (queue.length > 0) {
|
|
66
|
+
const current = queue.shift();
|
|
67
|
+
for (const m of byName.get(current) || []) {
|
|
68
|
+
for (const called of m.calls || []) {
|
|
69
|
+
const cShort = normalizeSymbolName(called);
|
|
70
|
+
if (byName.has(cShort) && !closure.has(cShort)) {
|
|
71
|
+
closure.add(cShort);
|
|
72
|
+
queue.push(cShort);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return closure;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function buildTaskContext(root, taskQuery, options = {}) {
|
|
81
|
+
const query = typeof taskQuery === 'string' ? taskQuery.trim() : '';
|
|
82
|
+
if (!query) {
|
|
83
|
+
throw new Error('A task query is required');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const projectRoot = path.resolve(root);
|
|
87
|
+
const budget = parseBudget(options.budget, DEFAULT_TASK_BUDGET);
|
|
88
|
+
const format = options.format ? String(options.format).toLowerCase() : 'markdown';
|
|
89
|
+
if (!SUPPORTED_TASK_FORMATS.includes(format)) {
|
|
90
|
+
throw new Error(
|
|
91
|
+
`Invalid format "${format}". Supported formats: ${SUPPORTED_TASK_FORMATS.join(', ')}`
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
const redact = options.redact !== false;
|
|
95
|
+
const useCallGraph = options.callGraph !== false;
|
|
96
|
+
const searchMode = options.searchMode ?? 'hybrid';
|
|
97
|
+
const seedLimit = Number.isInteger(options.limit) && options.limit > 0 ? options.limit : 5;
|
|
98
|
+
|
|
99
|
+
const extraRepos = Array.isArray(options.repos) ? options.repos.filter(Boolean) : [];
|
|
100
|
+
const isMultiRepo = extraRepos.length > 0;
|
|
101
|
+
|
|
102
|
+
let resolvedRepos = null;
|
|
103
|
+
let repoByAlias = null;
|
|
104
|
+
let scannersByAlias = null;
|
|
105
|
+
let scanner = null;
|
|
106
|
+
|
|
107
|
+
if (isMultiRepo) {
|
|
108
|
+
resolvedRepos = resolveWorkspaceRepos([projectRoot, ...extraRepos]);
|
|
109
|
+
enforceWorkspacePolicies(resolvedRepos, {
|
|
110
|
+
filesByRepo: new Map(),
|
|
111
|
+
tokens: 0,
|
|
112
|
+
redact,
|
|
113
|
+
targetModel: options.model ?? null,
|
|
114
|
+
});
|
|
115
|
+
repoByAlias = new Map(resolvedRepos.map((r) => [r.alias, r]));
|
|
116
|
+
scannersByAlias = new Map(
|
|
117
|
+
resolvedRepos.map((r) => {
|
|
118
|
+
const s = new Scanner(r.root);
|
|
119
|
+
s.getPolicy();
|
|
120
|
+
return [r.alias, s];
|
|
121
|
+
})
|
|
122
|
+
);
|
|
123
|
+
} else {
|
|
124
|
+
scanner = new Scanner(projectRoot);
|
|
125
|
+
scanner.getPolicy();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const isPathDenied = (graphPath) => {
|
|
129
|
+
const posix = toPosix(graphPath);
|
|
130
|
+
if (!isMultiRepo) return scanner.isDenied(posix);
|
|
131
|
+
const slashIdx = posix.indexOf('/');
|
|
132
|
+
const alias = slashIdx > 0 ? posix.slice(0, slashIdx) : null;
|
|
133
|
+
const repoScanner = alias ? scannersByAlias.get(alias) : null;
|
|
134
|
+
if (!repoScanner) return false;
|
|
135
|
+
return repoScanner.isDenied(posix.slice(alias.length + 1));
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const resolveDiskPath = (graphPath) => {
|
|
139
|
+
const posix = toPosix(graphPath);
|
|
140
|
+
if (!isMultiRepo)
|
|
141
|
+
return { fullPath: path.join(projectRoot, posix), repo: null, relativePath: posix };
|
|
142
|
+
const slashIdx = posix.indexOf('/');
|
|
143
|
+
const alias = slashIdx > 0 ? posix.slice(0, slashIdx) : null;
|
|
144
|
+
const repo = alias ? repoByAlias.get(alias) : null;
|
|
145
|
+
if (!repo) return { fullPath: null, repo: null, relativePath: posix };
|
|
146
|
+
const relativePath = posix.slice(alias.length + 1);
|
|
147
|
+
return { fullPath: path.join(repo.root, relativePath), repo: alias, relativePath };
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
let totalRedactions = 0;
|
|
151
|
+
|
|
152
|
+
// 1. Search to find seed files and symbols
|
|
153
|
+
let searchResult;
|
|
154
|
+
try {
|
|
155
|
+
if (isMultiRepo) {
|
|
156
|
+
searchResult = searchWorkspace(resolvedRepos, query, {
|
|
157
|
+
limit: seedLimit * 2,
|
|
158
|
+
redact,
|
|
159
|
+
});
|
|
160
|
+
} else {
|
|
161
|
+
searchResult = await hybridSearch(projectRoot, query, {
|
|
162
|
+
limit: seedLimit * 2,
|
|
163
|
+
mode: searchMode,
|
|
164
|
+
redact,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
} catch (_error) {
|
|
168
|
+
searchResult = { results: [] };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const seeds = [];
|
|
172
|
+
const seedFileSet = new Set();
|
|
173
|
+
const seedSymbolsByFile = new Map();
|
|
174
|
+
for (const hit of searchResult.results || []) {
|
|
175
|
+
const rel = toPosix(hit.file);
|
|
176
|
+
if (isPathDenied(rel)) continue;
|
|
177
|
+
if (hit.name) {
|
|
178
|
+
if (!seedSymbolsByFile.has(rel)) seedSymbolsByFile.set(rel, new Set());
|
|
179
|
+
seedSymbolsByFile.get(rel).add(hit.name);
|
|
180
|
+
seedSymbolsByFile.get(rel).add(normalizeSymbolName(hit.name));
|
|
181
|
+
}
|
|
182
|
+
if (!seedFileSet.has(rel)) {
|
|
183
|
+
seedFileSet.add(rel);
|
|
184
|
+
seeds.push({
|
|
185
|
+
file: rel,
|
|
186
|
+
...(hit.repo ? { repo: hit.repo } : {}),
|
|
187
|
+
score: hit.score,
|
|
188
|
+
kind: hit.kind,
|
|
189
|
+
name: hit.name,
|
|
190
|
+
});
|
|
191
|
+
if (seeds.length >= seedLimit) break;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// 2. Build repository or workspace graph (import edges + AST symbol call edges) for neighborhood traversal
|
|
196
|
+
const graph = isMultiRepo
|
|
197
|
+
? buildWorkspaceGraph(resolvedRepos, { redact })
|
|
198
|
+
: buildRepoGraph(projectRoot, { redact });
|
|
199
|
+
const nodeByPath = new Map(graph.nodes.map((n) => [n.path, n]));
|
|
200
|
+
|
|
201
|
+
const importOutgoing = new Map();
|
|
202
|
+
const importIncoming = new Map();
|
|
203
|
+
for (const edge of graph.edges) {
|
|
204
|
+
if (!importOutgoing.has(edge.from)) importOutgoing.set(edge.from, new Set());
|
|
205
|
+
importOutgoing.get(edge.from).add(edge.to);
|
|
206
|
+
if (!importIncoming.has(edge.to)) importIncoming.set(edge.to, new Set());
|
|
207
|
+
importIncoming.get(edge.to).add(edge.from);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const callOutgoing = new Map();
|
|
211
|
+
const callIncoming = new Map();
|
|
212
|
+
const callEdges = useCallGraph && Array.isArray(graph.callEdges) ? graph.callEdges : [];
|
|
213
|
+
for (const ce of callEdges) {
|
|
214
|
+
if (!callOutgoing.has(ce.from)) callOutgoing.set(ce.from, new Set());
|
|
215
|
+
callOutgoing.get(ce.from).add(ce.to);
|
|
216
|
+
if (!callIncoming.has(ce.to)) callIncoming.set(ce.to, new Set());
|
|
217
|
+
callIncoming.get(ce.to).add(ce.from);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const hop0 = new Set(seeds.map((s) => s.file));
|
|
221
|
+
if (hop0.size === 0 && graph.nodes.length > 0) {
|
|
222
|
+
const topNode = graph.nodes[0].path;
|
|
223
|
+
if (!isPathDenied(topNode)) {
|
|
224
|
+
hop0.add(topNode);
|
|
225
|
+
seeds.push({
|
|
226
|
+
file: topNode,
|
|
227
|
+
...(graph.nodes[0].repo ? { repo: graph.nodes[0].repo } : {}),
|
|
228
|
+
score: 1.0,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const hop1 = new Set();
|
|
234
|
+
if (useCallGraph && callEdges.length > 0) {
|
|
235
|
+
// 2a. Prioritize cross-file call edges directly originating from or targeting seed symbols
|
|
236
|
+
const expandedSeedSymbols = new Map();
|
|
237
|
+
for (const [file, symSet] of seedSymbolsByFile.entries()) {
|
|
238
|
+
const node = nodeByPath.get(file);
|
|
239
|
+
const fullClosure = new Set();
|
|
240
|
+
for (const sym of symSet) {
|
|
241
|
+
for (const c of intraFileClosure(node, sym)) fullClosure.add(c);
|
|
242
|
+
}
|
|
243
|
+
expandedSeedSymbols.set(file, fullClosure);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
for (const ce of callEdges) {
|
|
247
|
+
const callerSyms = expandedSeedSymbols.get(ce.from);
|
|
248
|
+
if (
|
|
249
|
+
callerSyms &&
|
|
250
|
+
(callerSyms.has(ce.caller) || callerSyms.has(normalizeSymbolName(ce.caller))) &&
|
|
251
|
+
!hop0.has(ce.to) &&
|
|
252
|
+
!isPathDenied(ce.to)
|
|
253
|
+
) {
|
|
254
|
+
hop1.add(ce.to);
|
|
255
|
+
}
|
|
256
|
+
const calleeSyms = expandedSeedSymbols.get(ce.to);
|
|
257
|
+
if (
|
|
258
|
+
calleeSyms &&
|
|
259
|
+
(calleeSyms.has(ce.callee) || calleeSyms.has(normalizeSymbolName(ce.callee))) &&
|
|
260
|
+
!hop0.has(ce.from) &&
|
|
261
|
+
!isPathDenied(ce.from)
|
|
262
|
+
) {
|
|
263
|
+
hop1.add(ce.from);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// 2b. All 1-hop cross-file symbol call neighbors of seed files (including barrel-resolved calls)
|
|
268
|
+
for (const node of hop0) {
|
|
269
|
+
for (const out of callOutgoing.get(node) || []) {
|
|
270
|
+
if (!hop0.has(out) && !isPathDenied(out)) hop1.add(out);
|
|
271
|
+
}
|
|
272
|
+
for (const inc of callIncoming.get(node) || []) {
|
|
273
|
+
if (!hop0.has(inc) && !isPathDenied(inc)) hop1.add(inc);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// 2c. 1-hop module import neighbors of seed files
|
|
279
|
+
for (const node of hop0) {
|
|
280
|
+
for (const out of importOutgoing.get(node) || []) {
|
|
281
|
+
if (!hop0.has(out) && !isPathDenied(out)) hop1.add(out);
|
|
282
|
+
}
|
|
283
|
+
for (const inc of importIncoming.get(node) || []) {
|
|
284
|
+
if (!hop0.has(inc) && !isPathDenied(inc)) hop1.add(inc);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const hop2 = new Set();
|
|
289
|
+
if (useCallGraph && callEdges.length > 0) {
|
|
290
|
+
// 2d. Prioritize 2-hop cross-file symbol call neighbors
|
|
291
|
+
for (const node of hop1) {
|
|
292
|
+
for (const out of callOutgoing.get(node) || []) {
|
|
293
|
+
if (!hop0.has(out) && !hop1.has(out) && !isPathDenied(out)) hop2.add(out);
|
|
294
|
+
}
|
|
295
|
+
for (const inc of callIncoming.get(node) || []) {
|
|
296
|
+
if (!hop0.has(inc) && !hop1.has(inc) && !isPathDenied(inc)) hop2.add(inc);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// 2e. 2-hop module import neighbors
|
|
302
|
+
for (const node of hop1) {
|
|
303
|
+
for (const out of importOutgoing.get(node) || []) {
|
|
304
|
+
if (!hop0.has(out) && !hop1.has(out) && !isPathDenied(out)) hop2.add(out);
|
|
305
|
+
}
|
|
306
|
+
for (const inc of importIncoming.get(node) || []) {
|
|
307
|
+
if (!hop0.has(inc) && !hop1.has(inc) && !isPathDenied(inc)) hop2.add(inc);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// 3. Architectural Overview (token-bounded markdown summary)
|
|
312
|
+
const mapBudget = Math.max(100, Math.min(Math.floor(budget * 0.15), 2500));
|
|
313
|
+
const repoMapResult = isMultiRepo
|
|
314
|
+
? mapWorkspace(resolvedRepos, {
|
|
315
|
+
tokens: mapBudget,
|
|
316
|
+
focus: Array.from(hop0),
|
|
317
|
+
callGraph: useCallGraph,
|
|
318
|
+
redact,
|
|
319
|
+
style: 'markdown',
|
|
320
|
+
})
|
|
321
|
+
: renderRepoMap(projectRoot, {
|
|
322
|
+
tokens: mapBudget,
|
|
323
|
+
focus: Array.from(hop0),
|
|
324
|
+
callGraph: useCallGraph,
|
|
325
|
+
redact,
|
|
326
|
+
style: 'markdown',
|
|
327
|
+
});
|
|
328
|
+
const repoMapOutput = repoMapResult.output;
|
|
329
|
+
const repoMapTokens = TokenUtils.calculate(repoMapOutput, 'repomap.md');
|
|
330
|
+
|
|
331
|
+
// 4. Read & process files: Hop 0 and 1 full code, Hop 2 compressed signatures
|
|
332
|
+
const fileCandidates = [];
|
|
333
|
+
const addCandidate = (relPath, hop) => {
|
|
334
|
+
const { fullPath, repo, relativePath } = resolveDiskPath(relPath);
|
|
335
|
+
if (!fullPath || !fs.existsSync(fullPath)) return;
|
|
336
|
+
try {
|
|
337
|
+
let rawContent = fs.readFileSync(fullPath, 'utf-8');
|
|
338
|
+
if (redact) {
|
|
339
|
+
const red = redactSecrets(rawContent);
|
|
340
|
+
rawContent = red.text;
|
|
341
|
+
totalRedactions += red.findings.length;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
let finalContent = rawContent;
|
|
345
|
+
let compressed = false;
|
|
346
|
+
|
|
347
|
+
if (hop === 2) {
|
|
348
|
+
const comp = compressCode(rawContent, relPath);
|
|
349
|
+
finalContent = comp.text;
|
|
350
|
+
compressed = true;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const tokens = TokenUtils.calculate(finalContent, relPath);
|
|
354
|
+
const lines = finalContent.split('\n').length;
|
|
355
|
+
fileCandidates.push({
|
|
356
|
+
path: relPath,
|
|
357
|
+
...(repo ? { repo, relativePath } : {}),
|
|
358
|
+
hop,
|
|
359
|
+
compressed,
|
|
360
|
+
tokens,
|
|
361
|
+
lines,
|
|
362
|
+
content: finalContent,
|
|
363
|
+
rawContent,
|
|
364
|
+
});
|
|
365
|
+
} catch {
|
|
366
|
+
// Skip unreadable files
|
|
367
|
+
}
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
for (const file of hop0) addCandidate(file, 0);
|
|
371
|
+
for (const file of hop1) addCandidate(file, 1);
|
|
372
|
+
for (const file of hop2) addCandidate(file, 2);
|
|
373
|
+
|
|
374
|
+
// 5. Fit files within budget
|
|
375
|
+
const envelopeReserve = format === 'json' ? Math.min(600, Math.floor(budget * 0.25)) : 240;
|
|
376
|
+
let availableTokens = Math.max(0, budget - repoMapTokens - envelopeReserve);
|
|
377
|
+
const perFileOverhead = format === 'json' ? 60 : 30;
|
|
378
|
+
const packedFiles = [];
|
|
379
|
+
const omitted = [];
|
|
380
|
+
|
|
381
|
+
for (const candidate of fileCandidates) {
|
|
382
|
+
const cost = candidate.tokens + perFileOverhead;
|
|
383
|
+
if (availableTokens >= cost) {
|
|
384
|
+
packedFiles.push(candidate);
|
|
385
|
+
availableTokens -= cost;
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (!candidate.compressed && availableTokens > perFileOverhead + 60) {
|
|
390
|
+
const compText = compressCode(candidate.rawContent, candidate.path).text;
|
|
391
|
+
const compTokens = TokenUtils.calculate(compText, candidate.path);
|
|
392
|
+
if (availableTokens >= compTokens + perFileOverhead) {
|
|
393
|
+
packedFiles.push({
|
|
394
|
+
...candidate,
|
|
395
|
+
compressed: true,
|
|
396
|
+
tokens: compTokens,
|
|
397
|
+
lines: compText.split('\n').length,
|
|
398
|
+
content: compText,
|
|
399
|
+
});
|
|
400
|
+
availableTokens -= compTokens + perFileOverhead;
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (candidate.hop === 0 && packedFiles.length === 0) {
|
|
405
|
+
const fitted = fitToTokenBudget(
|
|
406
|
+
compTokens < candidate.tokens ? compText : candidate.content,
|
|
407
|
+
availableTokens - perFileOverhead,
|
|
408
|
+
candidate.path
|
|
409
|
+
);
|
|
410
|
+
if (fitted) {
|
|
411
|
+
const fittedTokens = TokenUtils.calculate(fitted, candidate.path);
|
|
412
|
+
packedFiles.push({
|
|
413
|
+
...candidate,
|
|
414
|
+
compressed: true,
|
|
415
|
+
tokens: fittedTokens,
|
|
416
|
+
lines: fitted.split('\n').length,
|
|
417
|
+
content: fitted,
|
|
418
|
+
});
|
|
419
|
+
availableTokens = Math.max(0, availableTokens - fittedTokens - perFileOverhead);
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
omitted.push({ path: candidate.path, reason: 'exceeded budget' });
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const filterRelevantCallEdges = (filesList) => {
|
|
429
|
+
if (!useCallGraph || callEdges.length === 0) return [];
|
|
430
|
+
const includedSet = new Set(filesList.map((f) => f.path));
|
|
431
|
+
return callEdges.filter((ce) => includedSet.has(ce.from) && includedSet.has(ce.to));
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
// 6. Format document output (cap serialized omitted list so large graphs don't bloat JSON/XML)
|
|
435
|
+
const targetExt = format === 'json' ? 'json' : format === 'xml' ? 'xml' : 'md';
|
|
436
|
+
const summarizeOmitted = (omittedList) =>
|
|
437
|
+
omittedList.length <= MAX_RENDERED_OMITTED
|
|
438
|
+
? omittedList
|
|
439
|
+
: [
|
|
440
|
+
...omittedList.slice(0, MAX_RENDERED_OMITTED),
|
|
441
|
+
{
|
|
442
|
+
path: `... (+${omittedList.length - MAX_RENDERED_OMITTED} more files)`,
|
|
443
|
+
reason: 'exceeded budget',
|
|
444
|
+
},
|
|
445
|
+
];
|
|
446
|
+
|
|
447
|
+
const workspaceReposMeta = isMultiRepo
|
|
448
|
+
? resolvedRepos.map(({ alias, root: rRoot }) => ({ alias, root: rRoot }))
|
|
449
|
+
: null;
|
|
450
|
+
|
|
451
|
+
const renderOutput = (filesList, omittedList) => {
|
|
452
|
+
const activeCallEdges = filterRelevantCallEdges(filesList);
|
|
453
|
+
|
|
454
|
+
if (format === 'markdown') {
|
|
455
|
+
const sections = [];
|
|
456
|
+
sections.push(`# Task Context: ${query}`);
|
|
457
|
+
sections.push(`\n## Architectural Overview\n\n${repoMapOutput.trim()}`);
|
|
458
|
+
|
|
459
|
+
if (seeds.length > 0) {
|
|
460
|
+
sections.push('\n## Seed Discoveries');
|
|
461
|
+
for (const s of seeds) {
|
|
462
|
+
const details = [
|
|
463
|
+
`score: ${s.score}`,
|
|
464
|
+
s.kind ? `kind: ${s.kind}` : null,
|
|
465
|
+
s.name ? `symbol: ${s.name}` : null,
|
|
466
|
+
]
|
|
467
|
+
.filter(Boolean)
|
|
468
|
+
.join(', ');
|
|
469
|
+
sections.push(`- \`${s.file}\` (${details})`);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
sections.push('\n## Relevant Code Context');
|
|
474
|
+
for (const f of filesList) {
|
|
475
|
+
const hopLabel =
|
|
476
|
+
f.hop === 0
|
|
477
|
+
? f.compressed
|
|
478
|
+
? 'seed · compressed'
|
|
479
|
+
: 'seed · full code'
|
|
480
|
+
: f.hop === 1
|
|
481
|
+
? f.compressed
|
|
482
|
+
? '1-hop neighbor · compressed'
|
|
483
|
+
: '1-hop neighbor · full code'
|
|
484
|
+
: '2-hop neighbor · signatures';
|
|
485
|
+
sections.push(`\n### \`${f.path}\` (${hopLabel}, ${f.tokens} tokens)`);
|
|
486
|
+
const ext = path.extname(f.path).slice(1) || '';
|
|
487
|
+
sections.push(`\`\`\`${ext}\n${f.content.trimEnd()}\n\`\`\``);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
if (omittedList.length > 0) {
|
|
491
|
+
sections.push(`\n*${omittedList.length} file(s) omitted to fit token budget (${budget}).*`);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
return sections.join('\n');
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const displayedOmitted = summarizeOmitted(omittedList);
|
|
498
|
+
if (format === 'json') {
|
|
499
|
+
const doc = {
|
|
500
|
+
query,
|
|
501
|
+
budget,
|
|
502
|
+
...(workspaceReposMeta ? { repos: workspaceReposMeta } : {}),
|
|
503
|
+
architecturalOverview: repoMapOutput.trim(),
|
|
504
|
+
seeds,
|
|
505
|
+
files: filesList.map((f) => ({
|
|
506
|
+
path: f.path,
|
|
507
|
+
...(f.repo ? { repo: f.repo } : {}),
|
|
508
|
+
hop: f.hop,
|
|
509
|
+
compressed: f.compressed,
|
|
510
|
+
tokens: f.tokens,
|
|
511
|
+
lines: f.lines,
|
|
512
|
+
content: f.content,
|
|
513
|
+
})),
|
|
514
|
+
callEdges: activeCallEdges,
|
|
515
|
+
omittedCount: omittedList.length,
|
|
516
|
+
omitted: displayedOmitted,
|
|
517
|
+
redactions: { total: totalRedactions },
|
|
518
|
+
};
|
|
519
|
+
return JSON.stringify(doc, null, 2);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const out = [];
|
|
523
|
+
out.push(`<task_context query="${escapeXml(query)}" budget="${budget}">`);
|
|
524
|
+
out.push(' <overview>');
|
|
525
|
+
out.push(`<![CDATA[\n${repoMapOutput}\n]]>`);
|
|
526
|
+
out.push(' </overview>');
|
|
527
|
+
out.push(' <seeds>');
|
|
528
|
+
for (const s of seeds) {
|
|
529
|
+
out.push(
|
|
530
|
+
` <seed file="${escapeXml(s.file)}" score="${s.score}"${s.name ? ` symbol="${escapeXml(s.name)}"` : ''} />`
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
out.push(' </seeds>');
|
|
534
|
+
out.push(' <files>');
|
|
535
|
+
for (const f of filesList) {
|
|
536
|
+
out.push(
|
|
537
|
+
` <file path="${escapeXml(f.path)}" hop="${f.hop}" compressed="${f.compressed}" tokens="${f.tokens}">`
|
|
538
|
+
);
|
|
539
|
+
out.push(`<![CDATA[\n${f.content}\n]]>`);
|
|
540
|
+
out.push(' </file>');
|
|
541
|
+
}
|
|
542
|
+
out.push(' </files>');
|
|
543
|
+
if (displayedOmitted.length > 0) {
|
|
544
|
+
out.push(` <omitted total="${omittedList.length}">`);
|
|
545
|
+
for (const o of displayedOmitted) {
|
|
546
|
+
out.push(` <file path="${escapeXml(o.path)}" reason="${escapeXml(o.reason)}" />`);
|
|
547
|
+
}
|
|
548
|
+
out.push(' </omitted>');
|
|
549
|
+
}
|
|
550
|
+
out.push('</task_context>');
|
|
551
|
+
return out.join('\n');
|
|
552
|
+
};
|
|
553
|
+
|
|
554
|
+
let renderedText = renderOutput(packedFiles, omitted);
|
|
555
|
+
let finalTokens = TokenUtils.calculate(renderedText, `task.${targetExt}`);
|
|
556
|
+
while (finalTokens > budget && packedFiles.length > 1) {
|
|
557
|
+
const dropped = packedFiles.pop();
|
|
558
|
+
omitted.push({ path: dropped.path, reason: 'exceeded budget' });
|
|
559
|
+
renderedText = renderOutput(packedFiles, omitted);
|
|
560
|
+
finalTokens = TokenUtils.calculate(renderedText, `task.${targetExt}`);
|
|
561
|
+
}
|
|
562
|
+
if (finalTokens > budget && packedFiles.length === 1) {
|
|
563
|
+
const sole = packedFiles[0];
|
|
564
|
+
const targetFileTokens = Math.max(40, sole.tokens - (finalTokens - budget) - 40);
|
|
565
|
+
const trimmed = fitToTokenBudget(sole.content, targetFileTokens, sole.path);
|
|
566
|
+
if (trimmed) {
|
|
567
|
+
sole.content = trimmed;
|
|
568
|
+
sole.compressed = true;
|
|
569
|
+
sole.tokens = TokenUtils.calculate(trimmed, sole.path);
|
|
570
|
+
sole.lines = trimmed.split('\n').length;
|
|
571
|
+
renderedText = renderOutput(packedFiles, omitted);
|
|
572
|
+
finalTokens = TokenUtils.calculate(renderedText, `task.${targetExt}`);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
if (isMultiRepo) {
|
|
577
|
+
const filesByRepo = new Map(resolvedRepos.map((r) => [r.alias, []]));
|
|
578
|
+
for (const f of packedFiles) {
|
|
579
|
+
if (f.repo && filesByRepo.has(f.repo)) {
|
|
580
|
+
filesByRepo.get(f.repo).push({ path: f.relativePath, tokens: f.tokens });
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
enforceWorkspacePolicies(resolvedRepos, {
|
|
584
|
+
filesByRepo,
|
|
585
|
+
tokens: finalTokens,
|
|
586
|
+
redact,
|
|
587
|
+
targetModel: options.model ?? null,
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
if (options.output) {
|
|
592
|
+
const outPath = path.resolve(options.output);
|
|
593
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
594
|
+
fs.writeFileSync(outPath, renderedText, 'utf-8');
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const finalCallEdges = filterRelevantCallEdges(packedFiles);
|
|
598
|
+
|
|
599
|
+
return {
|
|
600
|
+
query,
|
|
601
|
+
format,
|
|
602
|
+
budget,
|
|
603
|
+
totalTokens: finalTokens,
|
|
604
|
+
text: renderedText,
|
|
605
|
+
...(workspaceReposMeta ? { repos: workspaceReposMeta } : {}),
|
|
606
|
+
seeds,
|
|
607
|
+
files: packedFiles.map((f) => ({
|
|
608
|
+
path: f.path,
|
|
609
|
+
...(f.repo ? { repo: f.repo } : {}),
|
|
610
|
+
hop: f.hop,
|
|
611
|
+
compressed: f.compressed,
|
|
612
|
+
tokens: f.tokens,
|
|
613
|
+
lines: f.lines,
|
|
614
|
+
})),
|
|
615
|
+
callEdges: finalCallEdges,
|
|
616
|
+
omitted,
|
|
617
|
+
redactions: { total: totalRedactions },
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function escapeXml(str) {
|
|
622
|
+
return String(str ?? '')
|
|
623
|
+
.replace(/&/g, '&')
|
|
624
|
+
.replace(/</g, '<')
|
|
625
|
+
.replace(/>/g, '>')
|
|
626
|
+
.replace(/"/g, '"')
|
|
627
|
+
.replace(/'/g, ''');
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
export default buildTaskContext;
|
package/lib/utils/file-utils.js
CHANGED
package/lib/utils/logger.js
CHANGED
|
@@ -94,13 +94,16 @@ class Logger {
|
|
|
94
94
|
formatMessage(level, message, meta = {}) {
|
|
95
95
|
const timestamp = this.getTimestamp();
|
|
96
96
|
const icon = this.icons[level];
|
|
97
|
+
// One entry per line: line breaks in the message (a path, an error text) are written
|
|
98
|
+
// escaped, so the message cannot add entries of its own
|
|
99
|
+
const text = String(message).replace(/\r\n|\r|\n/g, '\\n');
|
|
97
100
|
|
|
98
101
|
// Console format (with colors)
|
|
99
|
-
const consoleMessage = `${this.colors[level]}${icon} [${level.toUpperCase()}]${this.colors.reset} ${
|
|
102
|
+
const consoleMessage = `${this.colors[level]}${icon} [${level.toUpperCase()}]${this.colors.reset} ${text}`;
|
|
100
103
|
|
|
101
104
|
// File format (plain text with metadata)
|
|
102
105
|
const metaString = Object.keys(meta).length > 0 ? ` | ${JSON.stringify(meta)}` : '';
|
|
103
|
-
const fileMessage = `[${timestamp}] [${level.toUpperCase()}] ${
|
|
106
|
+
const fileMessage = `[${timestamp}] [${level.toUpperCase()}] ${text}${metaString}`;
|
|
104
107
|
|
|
105
108
|
return { consoleMessage, fileMessage };
|
|
106
109
|
}
|
|
@@ -38,6 +38,7 @@ export class MultiRepoManager {
|
|
|
38
38
|
this.projectRoot = projectRoot;
|
|
39
39
|
this.configPath = options.configPath || path.join(projectRoot, '.ctxman', 'repos.json');
|
|
40
40
|
this.config = null;
|
|
41
|
+
this._lastIdTs = 0;
|
|
41
42
|
this.options = {
|
|
42
43
|
autoSave: true,
|
|
43
44
|
...options,
|
|
@@ -281,7 +282,9 @@ export class MultiRepoManager {
|
|
|
281
282
|
*/
|
|
282
283
|
generateRepoId(repoPath) {
|
|
283
284
|
const basename = path.basename(repoPath);
|
|
284
|
-
const
|
|
285
|
+
const now = Date.now();
|
|
286
|
+
this._lastIdTs = now > this._lastIdTs ? now : this._lastIdTs + 1;
|
|
287
|
+
const timestamp = this._lastIdTs.toString(36);
|
|
285
288
|
return `${basename}-${timestamp}`;
|
|
286
289
|
}
|
|
287
290
|
|