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.
Files changed (60) hide show
  1. package/.ctxman/llm-profiles.json +6 -6
  2. package/README.md +17 -10
  3. package/bin/cli.js +107 -8
  4. package/bin/cm-ask.js +74 -70
  5. package/bin/cm-audit.js +80 -7
  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/examples/github-actions/ctxman-check.yml +1 -1
  17. package/index.d.ts +931 -1
  18. package/index.js +105 -1
  19. package/lib/analyzers/performance-dashboard.js +7 -3
  20. package/lib/analyzers/token-calculator.js +76 -4
  21. package/lib/api/mcp/server.js +713 -14
  22. package/lib/cache/CacheManager.js +161 -47
  23. package/lib/core/Analyzer.js +61 -5
  24. package/lib/diagnose/index.js +712 -0
  25. package/lib/focus/index.js +790 -0
  26. package/lib/map/call-graph.js +570 -0
  27. package/lib/map/graph.js +30 -5
  28. package/lib/map/index.js +12 -4
  29. package/lib/map/rank.js +45 -7
  30. package/lib/map/render.js +89 -28
  31. package/lib/pack/index.js +20 -1
  32. package/lib/pack/select.js +8 -1
  33. package/lib/parsers/gitignore-parser.js +4 -1
  34. package/lib/policy/audit.js +22 -2
  35. package/lib/policy/index.js +6 -0
  36. package/lib/policy/workspace.js +254 -0
  37. package/lib/pr/pr-context.js +292 -31
  38. package/lib/pr/pr-renderer.js +25 -10
  39. package/lib/rag/EmbeddingProvider.js +139 -2
  40. package/lib/rag/VectorStoreFactory.js +10 -4
  41. package/lib/rag/ask.js +291 -0
  42. package/lib/rag/sources/FileDataSource.js +6 -3
  43. package/lib/rag/stores/SqliteVectorStore.js +595 -0
  44. package/lib/search/hybrid.js +324 -0
  45. package/lib/search/symbols.js +75 -11
  46. package/lib/task/index.js +630 -0
  47. package/lib/utils/file-utils.js +1 -0
  48. package/lib/utils/logger.js +5 -2
  49. package/lib/utils/multi-repo-manager.js +4 -1
  50. package/lib/watch/FileWatcher.js +121 -31
  51. package/lib/watch/IncrementalAnalyzer.js +964 -16
  52. package/lib/who/index.js +893 -0
  53. package/lib/why/analyzer-bridge.js +223 -0
  54. package/lib/why/discussions.js +314 -0
  55. package/lib/why/index.js +356 -30
  56. package/lib/why/render.js +154 -9
  57. package/lib/wizards/config-generator.js +20 -18
  58. package/lib/wizards/project-detector.js +2 -1
  59. package/lib/workspace/index.js +1274 -0
  60. package/package.json +2 -2
@@ -140,6 +140,9 @@ const linesLabel = (item) =>
140
140
  export function summaryFacts(model) {
141
141
  const { stats } = model;
142
142
  const facts = [];
143
+ if (Array.isArray(model.repos) && model.repos.length > 0) {
144
+ facts.push(['Repositories', model.repos.map((r) => r.alias).join(', ')]);
145
+ }
143
146
  if (model.mode === 'range') {
144
147
  facts.push(['Base', `${model.base.ref} (${model.base.short})`]);
145
148
  facts.push(['Head', `${model.head.ref} (${model.head.short})`]);
@@ -206,7 +209,8 @@ const markdown = {
206
209
  },
207
210
 
208
211
  commit(commit, model) {
209
- let text = `### ${code(commit.short)} ${commit.subject}\n${commit.author}, ${commit.date}`;
212
+ const repoBadge = commit.repo ? `[${commit.repo}] ` : '';
213
+ let text = `### ${code(commit.short)} ${repoBadge}${commit.subject}\n${commit.author}, ${commit.date}`;
210
214
  if (commit.body && !model.logBodiesDropped) {
211
215
  text += `\n\n${commit.body
212
216
  .split('\n')
@@ -229,7 +233,7 @@ const markdown = {
229
233
  const rows = model.files.map((file) => {
230
234
  const name =
231
235
  file.oldPath && file.oldPath !== file.path && (file.status === 'R' || file.status === 'C')
232
- ? `${code(file.oldPath)} → ${code(file.path)}`
236
+ ? `${code(file.oldPath)} \u2192 ${code(file.path)}`
233
237
  : code(file.path);
234
238
  const note = file.excluded ? ` (content excluded by ${file.excluded})` : '';
235
239
  const added = file.binary ? 'binary' : file.additions;
@@ -344,8 +348,9 @@ function createXml() {
344
348
  },
345
349
 
346
350
  commit(commit, model) {
351
+ const repoAttr = commit.repo ? ` repo="${escapeAttr(commit.repo)}"` : '';
347
352
  const body = commit.body && !model.logBodiesDropped ? `\n\n${commit.body}` : '';
348
- return `<commit sha="${commit.short}" author="${escapeAttr(commit.author)}" date="${commit.date}">\n${commit.subject}${body}\n</commit>`;
353
+ return `<commit sha="${commit.short}"${repoAttr} author="${escapeAttr(commit.author)}" date="${commit.date}">\n${commit.subject}${body}\n</commit>`;
349
354
  },
350
355
 
351
356
  commits(model) {
@@ -468,6 +473,7 @@ function createXml() {
468
473
  const json = {
469
474
  summary(model) {
470
475
  const summary = {
476
+ ...(Array.isArray(model.repos) && model.repos.length > 0 ? { repos: model.repos } : {}),
471
477
  mode: model.mode,
472
478
  base: model.base,
473
479
  head: model.head,
@@ -483,10 +489,11 @@ const json = {
483
489
  },
484
490
 
485
491
  commit(commit, model) {
486
- const { sha, short, author, date, subject, body } = commit;
487
- return model.logBodiesDropped
488
- ? { sha, short, author, date, subject }
489
- : { sha, short, author, date, subject, body };
492
+ const { sha, short, author, date, subject, body, repo } = commit;
493
+ const base = repo
494
+ ? { repo, sha, short, author, date, subject }
495
+ : { sha, short, author, date, subject };
496
+ return model.logBodiesDropped ? base : { ...base, body };
490
497
  },
491
498
 
492
499
  commits(model) {
@@ -497,6 +504,7 @@ const json = {
497
504
  files(model) {
498
505
  if (model.filesDropped) return null;
499
506
  return model.files.map((file) => ({
507
+ ...(file.repo ? { repo: file.repo } : {}),
500
508
  status: file.status,
501
509
  path: file.path,
502
510
  oldPath: file.oldPath,
@@ -510,8 +518,9 @@ const json = {
510
518
  },
511
519
 
512
520
  method(method) {
513
- const { file, name, start, end, change, changedLines } = method;
521
+ const { file, name, start, end, change, changedLines, repo } = method;
514
522
  return {
523
+ ...(repo ? { repo } : {}),
515
524
  file,
516
525
  name,
517
526
  start,
@@ -527,7 +536,8 @@ const json = {
527
536
  if (model.methods.length === 0 && model.deletedMethods.length === 0) return null;
528
537
  return {
529
538
  changed: model.methods.map(json.method),
530
- deleted: model.deletedMethods.map(({ file, name, start, end }) => ({
539
+ deleted: model.deletedMethods.map(({ file, name, start, end, repo }) => ({
540
+ ...(repo ? { repo } : {}),
531
541
  file,
532
542
  name,
533
543
  start,
@@ -548,7 +558,12 @@ const json = {
548
558
  },
549
559
 
550
560
  testLine(test) {
551
- const entry = { path: test.path, reasons: test.reasons, changed: test.changed };
561
+ const entry = {
562
+ ...(test.repo ? { repo: test.repo } : {}),
563
+ path: test.path,
564
+ reasons: test.reasons,
565
+ changed: test.changed,
566
+ };
552
567
  if (test.excluded) entry.contentExcludedBy = test.excluded;
553
568
  return entry;
554
569
  },
@@ -5,13 +5,103 @@ export class EmbeddingProvider {
5
5
  /**
6
6
  * Generates an embedding for the given text.
7
7
  * @param {string} text
8
- * @returns {Promise<number[]>}
9
- */
8
+ * @returns {Promise<number[]>}\n */
10
9
  async embed(_text) {
11
10
  throw new Error('Not implemented');
12
11
  }
13
12
  }
14
13
 
14
+ /**
15
+ * Deterministic, zero-dependency local embedding provider (256 dimensions).
16
+ * Uses feature-hashing over identifier tokens and character trigrams with L2 normalization.
17
+ * Requires zero external packages and works 100% offline.
18
+ */
19
+ export class LocalHashEmbeddingProvider extends EmbeddingProvider {
20
+ constructor(dimension = 256) {
21
+ super();
22
+ this.dimension = dimension;
23
+ }
24
+
25
+ /**
26
+ * 32-bit FNV-1a hash function with optional seed.
27
+ * @private
28
+ */
29
+ _hash(str, seed = 0x811c9dc5) {
30
+ let h = seed >>> 0;
31
+ for (let i = 0; i < str.length; i++) {
32
+ h ^= str.charCodeAt(i);
33
+ h = Math.imul(h, 0x01000193) >>> 0;
34
+ }
35
+ return h >>> 0;
36
+ }
37
+
38
+ /**
39
+ * Generates a deterministic 256-dim L2-normalized embedding for the given text.
40
+ * @param {string} text
41
+ * @returns {Promise<number[]>}
42
+ */
43
+ async embed(text) {
44
+ const raw = String(text ?? '');
45
+ const vector = new Float64Array(this.dimension);
46
+
47
+ // 1. Extract identifier tokens
48
+ const tokens = raw.match(/[a-zA-Z0-9_$]+/g) || [];
49
+ for (const token of tokens) {
50
+ const lower = token.toLowerCase();
51
+ // Hash whole token
52
+ const h1 = this._hash(lower, 0x12345678);
53
+ const idx1 = h1 % this.dimension;
54
+ const sign1 = (this._hash(lower, 0x87654321) & 1) === 0 ? 1 : -1;
55
+ vector[idx1] += sign1 * 1.5;
56
+
57
+ // Split camelCase/snake_case sub-tokens if composite
58
+ const subTokens = token.replace(/([a-z])([A-Z])/g, '$1 $2').split(/[_\s]+/);
59
+ if (subTokens.length > 1) {
60
+ for (const sub of subTokens) {
61
+ if (sub.length > 1) {
62
+ const subLower = sub.toLowerCase();
63
+ const hSub = this._hash(subLower, 0x2468ace0);
64
+ const idxSub = hSub % this.dimension;
65
+ const signSub = (this._hash(subLower, 0x0eca8642) & 1) === 0 ? 1 : -1;
66
+ vector[idxSub] += signSub * 1.0;
67
+ }
68
+ }
69
+ }
70
+ }
71
+
72
+ // 2. Extract character trigrams
73
+ const normalized = raw.toLowerCase().replace(/\s+/g, ' ');
74
+ if (normalized.length >= 3) {
75
+ for (let i = 0; i <= normalized.length - 3; i++) {
76
+ const trigram = normalized.slice(i, i + 3);
77
+ const hTri = this._hash(trigram, 0x13579bdf);
78
+ const idxTri = hTri % this.dimension;
79
+ const signTri = (this._hash(trigram, 0xfdb97531) & 1) === 0 ? 1 : -1;
80
+ vector[idxTri] += signTri * 1.0;
81
+ }
82
+ } else if (normalized.length > 0) {
83
+ const hShort = this._hash(normalized, 0x99887766);
84
+ const idxShort = hShort % this.dimension;
85
+ const signShort = (this._hash(normalized, 0x66778899) & 1) === 0 ? 1 : -1;
86
+ vector[idxShort] += signShort * 1.0;
87
+ }
88
+
89
+ // 3. L2 normalize
90
+ let sumSq = 0;
91
+ for (let i = 0; i < this.dimension; i++) {
92
+ sumSq += vector[i] * vector[i];
93
+ }
94
+ const norm = Math.sqrt(sumSq);
95
+ if (norm > 0) {
96
+ for (let i = 0; i < this.dimension; i++) {
97
+ vector[i] /= norm;
98
+ }
99
+ }
100
+
101
+ return Array.from(vector);
102
+ }
103
+ }
104
+
15
105
  /**
16
106
  * Uses @xenova/transformers to generate embeddings locally using ONNX models.
17
107
  * Default model: Xenova/all-MiniLM-L6-v2 (384 dimensions)
@@ -53,6 +143,53 @@ export class TransformersEmbeddingProvider extends EmbeddingProvider {
53
143
  }
54
144
  }
55
145
 
146
+ /**
147
+ * Tries TransformersEmbeddingProvider if @xenova/transformers is installed
148
+ * and CTXMAN_EMBEDDER !== 'local'; otherwise seamlessly falls back to
149
+ * LocalHashEmbeddingProvider without network or model loading failure.
150
+ */
151
+ export class AutoEmbeddingProvider extends EmbeddingProvider {
152
+ /**
153
+ * @param {object} [options]
154
+ * @param {string} [options.modelName]
155
+ */
156
+ constructor(options = {}) {
157
+ super();
158
+ this.modelName = options.modelName;
159
+ this.activeProvider = null;
160
+ }
161
+
162
+ async init() {
163
+ if (this.activeProvider) return;
164
+
165
+ if (process.env.CTXMAN_EMBEDDER === 'local') {
166
+ this.activeProvider = new LocalHashEmbeddingProvider();
167
+ return;
168
+ }
169
+
170
+ try {
171
+ await import('@xenova/transformers');
172
+ } catch (err) {
173
+ if (err.code === 'ERR_MODULE_NOT_FOUND') {
174
+ this.activeProvider = new LocalHashEmbeddingProvider();
175
+ return;
176
+ }
177
+ throw err;
178
+ }
179
+
180
+ const provider = new TransformersEmbeddingProvider(this.modelName);
181
+ await provider.init();
182
+ this.activeProvider = provider;
183
+ }
184
+
185
+ async embed(text) {
186
+ if (!this.activeProvider) {
187
+ await this.init();
188
+ }
189
+ return this.activeProvider.embed(text);
190
+ }
191
+ }
192
+
56
193
  /**
57
194
  * Mock provider for testing without loading heavy models.
58
195
  */
@@ -1,15 +1,19 @@
1
+ import { SqliteVectorStore } from './stores/SqliteVectorStore.js';
1
2
  import { LanceDBStore } from './stores/LanceDBStore.js';
2
3
  import { ChromaDBStore } from './stores/ChromaDBStore.js';
3
4
  import { PineconeStore } from './stores/PineconeStore.js';
4
5
 
5
6
  export class VectorStoreFactory {
6
7
  /**
7
- * @param {string} type
8
+ * @param {string} [type='sqlite']
8
9
  * @param {import('./EmbeddingProvider').EmbeddingProvider} embeddingProvider
9
- * @param {Object} config
10
+ * @param {Object} [config={}]
10
11
  */
11
- static create(type, embeddingProvider, config = {}) {
12
- switch (type.toLowerCase()) {
12
+ static create(type = 'sqlite', embeddingProvider, config = {}) {
13
+ const storeType = (type || 'sqlite').toLowerCase();
14
+ switch (storeType) {
15
+ case 'sqlite':
16
+ return new SqliteVectorStore(embeddingProvider, config.path || config.storagePath);
13
17
  case 'lancedb':
14
18
  return new LanceDBStore(embeddingProvider, config.path);
15
19
  case 'chroma':
@@ -22,3 +26,5 @@ export class VectorStoreFactory {
22
26
  }
23
27
  }
24
28
  }
29
+
30
+ export default VectorStoreFactory;
package/lib/rag/ask.js ADDED
@@ -0,0 +1,291 @@
1
+ /**
2
+ * Semantic search over a project (`ctxman ask` and the MCP `ask_codebase` tool): an
3
+ * embedding index under .ctxman/self-knowledge, built on first use, then the snippets
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.
7
+ *
8
+ * Files left out by .gitignore, .contextignore, .contextinclude or the export policy are
9
+ * not indexed, and results from an older index are checked against the policy again.
10
+ */
11
+
12
+ import fsSync from 'fs';
13
+ import fs from 'fs/promises';
14
+ import path from 'path';
15
+ import { Scanner } from '../core/Scanner.js';
16
+ import { redactSecrets } from '../utils/secret-redactor.js';
17
+ import { VectorStoreFactory } from './VectorStoreFactory.js';
18
+ import { AutoEmbeddingProvider } from './EmbeddingProvider.js';
19
+ import { Indexer } from './Indexer.js';
20
+ import { FileDataSource } from './sources/FileDataSource.js';
21
+ import { resolveWorkspaceRepos, enforceWorkspacePolicies } from '../workspace/index.js';
22
+
23
+ export const ASK_STORE_DIR = path.join('.ctxman', 'self-knowledge');
24
+ export const DEFAULT_ASK_LIMIT = 5;
25
+
26
+ const INCLUDE = ['.js', '.md', '.ts', '.json'];
27
+ const EXCLUDE = ['node_modules', '.git', 'dist', 'coverage', '.ctxman'];
28
+ // Extra rows asked of the store, so results the policy removes still leave `limit`
29
+ const OVERFETCH = 10;
30
+
31
+ async function hasIndex(storePath, store) {
32
+ try {
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;
41
+ } catch {
42
+ return false;
43
+ }
44
+ }
45
+
46
+ const toPosix = (p) => p.split(path.sep).join('/');
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
+
62
+ /**
63
+ * @param {string} root - Project directory
64
+ * @param {string} query - The question
65
+ * @param {Object} [options]
66
+ * @param {number} [options.limit=5] - Snippets to return
67
+ * @param {boolean} [options.redact=true] - Redact secrets in the snippets
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
70
+ * @param {(event: 'indexing'|'indexed') => void} [options.onProgress]
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
77
+ */
78
+ export async function askCodebase(root, query, options = {}) {
79
+ const question = typeof query === 'string' ? query.trim() : '';
80
+ if (!question) throw new Error('A question is required');
81
+ const limit =
82
+ Number.isInteger(options.limit) && options.limit > 0 ? options.limit : DEFAULT_ASK_LIMIT;
83
+ const projectRoot = path.resolve(root);
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);
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);
230
+ const scanner = new Scanner(projectRoot);
231
+ // Load now: an invalid policy fails the command instead of being skipped
232
+ scanner.getPolicy();
233
+ const relative = (fullPath) => toPosix(path.relative(projectRoot, fullPath));
234
+ const left = (fullPath) => {
235
+ const rel = relative(fullPath);
236
+ return rel.startsWith('../') || path.isAbsolute(rel);
237
+ };
238
+
239
+ const store = options.createStore
240
+ ? options.createStore(storePath)
241
+ : VectorStoreFactory.create('sqlite', new AutoEmbeddingProvider(), {
242
+ path: storePath,
243
+ });
244
+
245
+ let indexed = false;
246
+ if (!(await hasIndex(storePath, store))) {
247
+ onProgress('indexing');
248
+ const indexer = new Indexer(store);
249
+ indexer.registerSource(new FileDataSource());
250
+ await indexer.index('local-files', {
251
+ targetDir: projectRoot,
252
+ include: INCLUDE,
253
+ exclude: EXCLUDE,
254
+ skip: (fullPath) =>
255
+ scanner.shouldIgnore(path.relative(projectRoot, fullPath), fullPath) ||
256
+ scanner.isDenied(relative(fullPath)),
257
+ });
258
+ indexed = true;
259
+ onProgress('indexed');
260
+ }
261
+
262
+ const rows = await store.search(question, limit + OVERFETCH);
263
+ const results = [];
264
+ let removedByPolicy = 0;
265
+ let redactions = 0;
266
+ for (const row of rows) {
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;
271
+ const file = relative(fullPath);
272
+ if (scanner.isDenied(file)) {
273
+ removedByPolicy++;
274
+ continue;
275
+ }
276
+ if (results.length === limit) continue;
277
+ let text = String(row.text ?? '');
278
+ if (options.redact !== false) {
279
+ const redacted = redactSecrets(text);
280
+ text = redacted.text;
281
+ redactions += redacted.findings.length;
282
+ }
283
+ const truncated = Boolean(options.maxChars) && text.length > options.maxChars;
284
+ if (truncated) text = text.slice(0, options.maxChars);
285
+ results.push({ file, score: row.score ?? null, text, truncated });
286
+ }
287
+
288
+ return { query: question, indexed, results, removedByPolicy, redactions };
289
+ }
290
+
291
+ export default askCodebase;
@@ -19,11 +19,13 @@ export class FileDataSource extends DataSourcePlugin {
19
19
  const maxDepth = options.maxDepth || 5;
20
20
  const include = options.include || ['.js', '.md', '.ts', '.json'];
21
21
  const exclude = options.exclude || ['node_modules', '.git', 'dist', 'coverage'];
22
+ // (fullPath, isDirectory) => true leaves a file or directory out
23
+ const skip = options.skip || (() => false);
22
24
 
23
- yield* this.walkDirectory(targetDir, maxDepth, include, exclude);
25
+ yield* this.walkDirectory(targetDir, maxDepth, include, exclude, skip);
24
26
  }
25
27
 
26
- async *walkDirectory(dir, depth, include, exclude) {
28
+ async *walkDirectory(dir, depth, include, exclude, skip = () => false) {
27
29
  if (depth < 0) return;
28
30
 
29
31
  let entries;
@@ -38,9 +40,10 @@ export class FileDataSource extends DataSourcePlugin {
38
40
  const fullPath = path.join(dir, entry.name);
39
41
 
40
42
  if (exclude.some((ex) => fullPath.includes(ex))) continue;
43
+ if (skip(fullPath, entry.isDirectory())) continue;
41
44
 
42
45
  if (entry.isDirectory()) {
43
- yield* this.walkDirectory(fullPath, depth - 1, include, exclude);
46
+ yield* this.walkDirectory(fullPath, depth - 1, include, exclude, skip);
44
47
  } else if (entry.isFile()) {
45
48
  const ext = path.extname(entry.name);
46
49
  if (include.length > 0 && !include.includes(ext)) continue;