ctxman 3.1.1 → 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.
Files changed (53) hide show
  1. package/.ctxman/llm-profiles.json +6 -6
  2. package/README.md +16 -10
  3. package/bin/cli.js +107 -8
  4. package/bin/cm-ask.js +64 -6
  5. package/bin/cm-audit.js +76 -0
  6. package/bin/cm-diagnose.js +199 -0
  7. package/bin/cm-focus.js +322 -0
  8. package/bin/cm-map.js +71 -18
  9. package/bin/cm-pack.js +36 -5
  10. package/bin/cm-policy.js +107 -2
  11. package/bin/cm-pr.js +18 -3
  12. package/bin/cm-search.js +84 -19
  13. package/bin/cm-task.js +192 -0
  14. package/bin/cm-who.js +119 -0
  15. package/bin/cm-why.js +41 -5
  16. package/index.d.ts +931 -1
  17. package/index.js +105 -1
  18. package/lib/analyzers/token-calculator.js +76 -4
  19. package/lib/api/mcp/server.js +618 -16
  20. package/lib/cache/CacheManager.js +161 -47
  21. package/lib/core/Analyzer.js +61 -5
  22. package/lib/diagnose/index.js +712 -0
  23. package/lib/focus/index.js +790 -0
  24. package/lib/map/call-graph.js +570 -0
  25. package/lib/map/graph.js +30 -5
  26. package/lib/map/index.js +12 -4
  27. package/lib/map/rank.js +45 -7
  28. package/lib/map/render.js +89 -28
  29. package/lib/pack/index.js +20 -1
  30. package/lib/pack/select.js +8 -1
  31. package/lib/policy/audit.js +3 -1
  32. package/lib/policy/index.js +5 -0
  33. package/lib/policy/workspace.js +254 -0
  34. package/lib/pr/pr-context.js +292 -31
  35. package/lib/pr/pr-renderer.js +25 -10
  36. package/lib/rag/EmbeddingProvider.js +139 -2
  37. package/lib/rag/VectorStoreFactory.js +10 -4
  38. package/lib/rag/ask.js +186 -17
  39. package/lib/rag/stores/SqliteVectorStore.js +595 -0
  40. package/lib/search/hybrid.js +324 -0
  41. package/lib/search/symbols.js +75 -11
  42. package/lib/task/index.js +630 -0
  43. package/lib/utils/file-utils.js +1 -0
  44. package/lib/utils/multi-repo-manager.js +4 -1
  45. package/lib/watch/FileWatcher.js +121 -31
  46. package/lib/watch/IncrementalAnalyzer.js +964 -16
  47. package/lib/who/index.js +893 -0
  48. package/lib/why/analyzer-bridge.js +223 -0
  49. package/lib/why/discussions.js +314 -0
  50. package/lib/why/index.js +356 -30
  51. package/lib/why/render.js +154 -9
  52. package/lib/workspace/index.js +1274 -0
  53. 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. Needs the optional @xenova/transformers and @lancedb/lancedb
5
- * packages; the embedding model is downloaded on first use.
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 { TransformersEmbeddingProvider } from './EmbeddingProvider.js';
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
- return (await fs.readdir(storePath)).length > 0;
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: LanceDB with the local embedding model
48
- * @returns {Promise<{query: string, indexed: boolean, results: Array<{file: string,
49
- * score: number|null, text: string, truncated: boolean}>, removedByPolicy: number,
50
- * redactions: number}>}
51
- * @throws {Error} When the question is empty, the policy is invalid, the optional packages
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('lancedb', new TransformersEmbeddingProvider(), {
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 fullPath = row.metadata?.path;
101
- if (typeof fullPath !== 'string' || left(fullPath)) continue;
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++;