ctxman 3.1.1 → 3.2.8
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 +16 -10
- package/bin/cli.js +109 -10
- package/bin/cm-ask.js +64 -6
- package/bin/cm-audit.js +76 -0
- package/bin/cm-count.js +45 -6
- 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/index.d.ts +940 -1
- package/index.js +105 -1
- package/lib/analyzers/token-calculator.js +76 -4
- package/lib/api/mcp/server.js +843 -29
- package/lib/cache/CacheManager.js +161 -47
- package/lib/core/Analyzer.js +61 -5
- package/lib/count/index.js +74 -14
- 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/policy/audit.js +3 -1
- package/lib/policy/index.js +5 -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 +186 -17
- 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/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/workspace/index.js +1274 -0
- package/package.json +1 -1
package/lib/rag/ask.js
CHANGED
|
@@ -1,21 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Semantic search over a project (`ctxman ask` and the MCP `ask_codebase` tool): an
|
|
3
3
|
* embedding index under .ctxman/self-knowledge, built on first use, then the snippets
|
|
4
|
-
* closest to the question.
|
|
5
|
-
*
|
|
4
|
+
* closest to the question. By default uses the built-in zero-native-binary SqliteVectorStore
|
|
5
|
+
* and AutoEmbeddingProvider (100% offline, zero optional dependencies). Optional LanceDB
|
|
6
|
+
* and @xenova/transformers can also be used if configured.
|
|
6
7
|
*
|
|
7
8
|
* Files left out by .gitignore, .contextignore, .contextinclude or the export policy are
|
|
8
9
|
* not indexed, and results from an older index are checked against the policy again.
|
|
9
10
|
*/
|
|
10
11
|
|
|
12
|
+
import fsSync from 'fs';
|
|
11
13
|
import fs from 'fs/promises';
|
|
12
14
|
import path from 'path';
|
|
13
15
|
import { Scanner } from '../core/Scanner.js';
|
|
14
16
|
import { redactSecrets } from '../utils/secret-redactor.js';
|
|
15
17
|
import { VectorStoreFactory } from './VectorStoreFactory.js';
|
|
16
|
-
import {
|
|
18
|
+
import { AutoEmbeddingProvider } from './EmbeddingProvider.js';
|
|
17
19
|
import { Indexer } from './Indexer.js';
|
|
18
20
|
import { FileDataSource } from './sources/FileDataSource.js';
|
|
21
|
+
import { resolveWorkspaceRepos, enforceWorkspacePolicies } from '../workspace/index.js';
|
|
19
22
|
|
|
20
23
|
export const ASK_STORE_DIR = path.join('.ctxman', 'self-knowledge');
|
|
21
24
|
export const DEFAULT_ASK_LIMIT = 5;
|
|
@@ -25,9 +28,16 @@ const EXCLUDE = ['node_modules', '.git', 'dist', 'coverage', '.ctxman'];
|
|
|
25
28
|
// Extra rows asked of the store, so results the policy removes still leave `limit`
|
|
26
29
|
const OVERFETCH = 10;
|
|
27
30
|
|
|
28
|
-
async function hasIndex(storePath) {
|
|
31
|
+
async function hasIndex(storePath, store) {
|
|
29
32
|
try {
|
|
30
|
-
|
|
33
|
+
if ((await fs.readdir(storePath)).length === 0) return false;
|
|
34
|
+
if (store && typeof store.count === 'function') {
|
|
35
|
+
if (typeof store.init === 'function' && !store.initialized) {
|
|
36
|
+
await store.init();
|
|
37
|
+
}
|
|
38
|
+
return store.count() > 0;
|
|
39
|
+
}
|
|
40
|
+
return true;
|
|
31
41
|
} catch {
|
|
32
42
|
return false;
|
|
33
43
|
}
|
|
@@ -35,6 +45,20 @@ async function hasIndex(storePath) {
|
|
|
35
45
|
|
|
36
46
|
const toPosix = (p) => p.split(path.sep).join('/');
|
|
37
47
|
|
|
48
|
+
function buildWorkspaceRepoInputs(primaryRoot, extraRepos) {
|
|
49
|
+
const realPrimary = fsSync.existsSync(primaryRoot)
|
|
50
|
+
? fsSync.realpathSync(primaryRoot)
|
|
51
|
+
: path.resolve(primaryRoot);
|
|
52
|
+
const isContainerOfExtra = extraRepos.some((item) => {
|
|
53
|
+
const raw = typeof item === 'string' ? item : item?.path;
|
|
54
|
+
if (typeof raw !== 'string' || !raw.trim()) return false;
|
|
55
|
+
const resolved = path.resolve(raw);
|
|
56
|
+
const real = fsSync.existsSync(resolved) ? fsSync.realpathSync(resolved) : resolved;
|
|
57
|
+
return real !== realPrimary && real.startsWith(realPrimary + path.sep);
|
|
58
|
+
});
|
|
59
|
+
return isContainerOfExtra ? [...extraRepos] : [primaryRoot, ...extraRepos];
|
|
60
|
+
}
|
|
61
|
+
|
|
38
62
|
/**
|
|
39
63
|
* @param {string} root - Project directory
|
|
40
64
|
* @param {string} query - The question
|
|
@@ -42,14 +66,14 @@ const toPosix = (p) => p.split(path.sep).join('/');
|
|
|
42
66
|
* @param {number} [options.limit=5] - Snippets to return
|
|
43
67
|
* @param {boolean} [options.redact=true] - Redact secrets in the snippets
|
|
44
68
|
* @param {number} [options.maxChars] - Cut each snippet to this many characters
|
|
69
|
+
* @param {Array<string|{path: string, alias?: string}>} [options.repos] - Additional repository roots for multi-repo semantic search
|
|
45
70
|
* @param {(event: 'indexing'|'indexed') => void} [options.onProgress]
|
|
46
|
-
* @param {(storePath: string) => object} [options.createStore] - Vector store factory
|
|
47
|
-
* (tests); default:
|
|
48
|
-
* @returns {Promise<{query: string, indexed: boolean,
|
|
49
|
-
* score: number|null, text: string, truncated: boolean}>,
|
|
50
|
-
* redactions: number}>}
|
|
51
|
-
* @throws {Error} When the question is empty, the policy is invalid,
|
|
52
|
-
* are missing or indexing fails
|
|
71
|
+
* @param {(storePath: string, repo?: object) => object} [options.createStore] - Vector store factory
|
|
72
|
+
* (tests); default: SqliteVectorStore with AutoEmbeddingProvider
|
|
73
|
+
* @returns {Promise<{query: string, indexed: boolean, repos?: Array<{alias: string, root: string}>,
|
|
74
|
+
* results: Array<{repo?: string, file: string, score: number|null, text: string, truncated: boolean}>,
|
|
75
|
+
* removedByPolicy: number, redactions: number}>}
|
|
76
|
+
* @throws {Error} When the question is empty, the policy is invalid, or indexing fails
|
|
53
77
|
*/
|
|
54
78
|
export async function askCodebase(root, query, options = {}) {
|
|
55
79
|
const question = typeof query === 'string' ? query.trim() : '';
|
|
@@ -57,9 +81,152 @@ export async function askCodebase(root, query, options = {}) {
|
|
|
57
81
|
const limit =
|
|
58
82
|
Number.isInteger(options.limit) && options.limit > 0 ? options.limit : DEFAULT_ASK_LIMIT;
|
|
59
83
|
const projectRoot = path.resolve(root);
|
|
60
|
-
const storePath = path.join(projectRoot, ASK_STORE_DIR);
|
|
61
84
|
const onProgress = options.onProgress || (() => {});
|
|
85
|
+
const isWorkspace = Array.isArray(options.repos) && options.repos.length > 0;
|
|
86
|
+
|
|
87
|
+
if (isWorkspace) {
|
|
88
|
+
const resolvedRepos = resolveWorkspaceRepos(
|
|
89
|
+
buildWorkspaceRepoInputs(projectRoot, options.repos)
|
|
90
|
+
);
|
|
91
|
+
enforceWorkspacePolicies(resolvedRepos, {
|
|
92
|
+
filesByRepo: new Map(),
|
|
93
|
+
tokens: 0,
|
|
94
|
+
redact: options.redact !== false,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const scannersByAlias = new Map();
|
|
98
|
+
const repoByAlias = new Map();
|
|
99
|
+
for (const repo of resolvedRepos) {
|
|
100
|
+
const scanner = new Scanner(repo.root);
|
|
101
|
+
scanner.getPolicy();
|
|
102
|
+
scannersByAlias.set(repo.alias, scanner);
|
|
103
|
+
repoByAlias.set(repo.alias, repo);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let indexedAny = false;
|
|
107
|
+
let startedIndexing = false;
|
|
108
|
+
let removedByPolicy = 0;
|
|
109
|
+
const candidatesByFile = new Map();
|
|
110
|
+
|
|
111
|
+
for (const repo of resolvedRepos) {
|
|
112
|
+
const repoStorePath = path.join(repo.root, ASK_STORE_DIR);
|
|
113
|
+
const repoScanner = scannersByAlias.get(repo.alias);
|
|
114
|
+
const relativeToRepo = (targetRepo, fullPath) =>
|
|
115
|
+
toPosix(path.relative(targetRepo.root, fullPath));
|
|
116
|
+
const leftOfRepo = (targetRepo, fullPath) => {
|
|
117
|
+
const rel = relativeToRepo(targetRepo, fullPath);
|
|
118
|
+
return rel.startsWith('../') || path.isAbsolute(rel);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const store = options.createStore
|
|
122
|
+
? options.createStore(repoStorePath, repo)
|
|
123
|
+
: VectorStoreFactory.create('sqlite', new AutoEmbeddingProvider(), {
|
|
124
|
+
path: repoStorePath,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
if (!(await hasIndex(repoStorePath, store))) {
|
|
128
|
+
if (!startedIndexing) {
|
|
129
|
+
onProgress('indexing');
|
|
130
|
+
startedIndexing = true;
|
|
131
|
+
}
|
|
132
|
+
const indexer = new Indexer(store);
|
|
133
|
+
indexer.registerSource(new FileDataSource());
|
|
134
|
+
await indexer.index('local-files', {
|
|
135
|
+
targetDir: repo.root,
|
|
136
|
+
include: INCLUDE,
|
|
137
|
+
exclude: EXCLUDE,
|
|
138
|
+
skip: (fullPath) =>
|
|
139
|
+
repoScanner.shouldIgnore(path.relative(repo.root, fullPath), fullPath) ||
|
|
140
|
+
repoScanner.isDenied(relativeToRepo(repo, fullPath)),
|
|
141
|
+
});
|
|
142
|
+
indexedAny = true;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const rows = await store.search(question, limit + OVERFETCH);
|
|
146
|
+
for (const row of rows) {
|
|
147
|
+
const rawPath = row.metadata?.path ?? row.metadata?.relativePath;
|
|
148
|
+
if (typeof rawPath !== 'string') continue;
|
|
149
|
+
|
|
150
|
+
let targetRepo = repo;
|
|
151
|
+
let resolvedFullPath;
|
|
152
|
+
const posixRaw = toPosix(rawPath);
|
|
153
|
+
const slashIdx = posixRaw.indexOf('/');
|
|
154
|
+
const maybeAlias =
|
|
155
|
+
row.metadata?.repo || (slashIdx > 0 ? posixRaw.slice(0, slashIdx) : null);
|
|
62
156
|
|
|
157
|
+
if (
|
|
158
|
+
maybeAlias &&
|
|
159
|
+
repoByAlias.has(maybeAlias) &&
|
|
160
|
+
!path.isAbsolute(rawPath) &&
|
|
161
|
+
posixRaw.startsWith(`${maybeAlias}/`)
|
|
162
|
+
) {
|
|
163
|
+
targetRepo = repoByAlias.get(maybeAlias);
|
|
164
|
+
resolvedFullPath = path.resolve(targetRepo.root, posixRaw.slice(maybeAlias.length + 1));
|
|
165
|
+
} else {
|
|
166
|
+
resolvedFullPath = path.isAbsolute(rawPath) ? rawPath : path.resolve(repo.root, rawPath);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (leftOfRepo(targetRepo, resolvedFullPath)) continue;
|
|
170
|
+
const relFile = relativeToRepo(targetRepo, resolvedFullPath);
|
|
171
|
+
const targetScanner = scannersByAlias.get(targetRepo.alias);
|
|
172
|
+
if (targetScanner.isDenied(relFile)) {
|
|
173
|
+
removedByPolicy++;
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const prefixedFile = `${targetRepo.alias}/${relFile}`;
|
|
178
|
+
const existing = candidatesByFile.get(prefixedFile);
|
|
179
|
+
const score = row.score ?? null;
|
|
180
|
+
if (!existing || (score ?? -Infinity) > (existing.score ?? -Infinity)) {
|
|
181
|
+
candidatesByFile.set(prefixedFile, {
|
|
182
|
+
repo: targetRepo.alias,
|
|
183
|
+
file: prefixedFile,
|
|
184
|
+
score,
|
|
185
|
+
rawText: String(row.text ?? ''),
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (startedIndexing) {
|
|
192
|
+
onProgress('indexed');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const sortedCandidates = [...candidatesByFile.values()].sort(
|
|
196
|
+
(a, b) => (b.score ?? -Infinity) - (a.score ?? -Infinity) || a.file.localeCompare(b.file)
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
const results = [];
|
|
200
|
+
let redactions = 0;
|
|
201
|
+
for (const cand of sortedCandidates.slice(0, limit)) {
|
|
202
|
+
let text = cand.rawText;
|
|
203
|
+
if (options.redact !== false) {
|
|
204
|
+
const redacted = redactSecrets(text);
|
|
205
|
+
text = redacted.text;
|
|
206
|
+
redactions += redacted.findings.length;
|
|
207
|
+
}
|
|
208
|
+
const truncated = Boolean(options.maxChars) && text.length > options.maxChars;
|
|
209
|
+
if (truncated) text = text.slice(0, options.maxChars);
|
|
210
|
+
results.push({
|
|
211
|
+
repo: cand.repo,
|
|
212
|
+
file: cand.file,
|
|
213
|
+
score: cand.score,
|
|
214
|
+
text,
|
|
215
|
+
truncated,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
query: question,
|
|
221
|
+
indexed: indexedAny,
|
|
222
|
+
repos: resolvedRepos.map(({ alias, root: r }) => ({ alias, root: r })),
|
|
223
|
+
results,
|
|
224
|
+
removedByPolicy,
|
|
225
|
+
redactions,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const storePath = path.join(projectRoot, ASK_STORE_DIR);
|
|
63
230
|
const scanner = new Scanner(projectRoot);
|
|
64
231
|
// Load now: an invalid policy fails the command instead of being skipped
|
|
65
232
|
scanner.getPolicy();
|
|
@@ -71,12 +238,12 @@ export async function askCodebase(root, query, options = {}) {
|
|
|
71
238
|
|
|
72
239
|
const store = options.createStore
|
|
73
240
|
? options.createStore(storePath)
|
|
74
|
-
: VectorStoreFactory.create('
|
|
241
|
+
: VectorStoreFactory.create('sqlite', new AutoEmbeddingProvider(), {
|
|
75
242
|
path: storePath,
|
|
76
243
|
});
|
|
77
244
|
|
|
78
245
|
let indexed = false;
|
|
79
|
-
if (!(await hasIndex(storePath))) {
|
|
246
|
+
if (!(await hasIndex(storePath, store))) {
|
|
80
247
|
onProgress('indexing');
|
|
81
248
|
const indexer = new Indexer(store);
|
|
82
249
|
indexer.registerSource(new FileDataSource());
|
|
@@ -97,8 +264,10 @@ export async function askCodebase(root, query, options = {}) {
|
|
|
97
264
|
let removedByPolicy = 0;
|
|
98
265
|
let redactions = 0;
|
|
99
266
|
for (const row of rows) {
|
|
100
|
-
const
|
|
101
|
-
if (typeof
|
|
267
|
+
const rawPath = row.metadata?.path ?? row.metadata?.relativePath;
|
|
268
|
+
if (typeof rawPath !== 'string') continue;
|
|
269
|
+
const fullPath = path.isAbsolute(rawPath) ? rawPath : path.resolve(projectRoot, rawPath);
|
|
270
|
+
if (left(fullPath)) continue;
|
|
102
271
|
const file = relative(fullPath);
|
|
103
272
|
if (scanner.isDenied(file)) {
|
|
104
273
|
removedByPolicy++;
|