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
package/lib/pr/pr-renderer.js
CHANGED
|
@@ -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
|
-
|
|
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)}
|
|
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
|
-
|
|
488
|
-
? { sha, short, author, date, subject }
|
|
489
|
-
: { sha, short, author, date, subject
|
|
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 = {
|
|
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
|
-
|
|
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;
|