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
@@ -17,6 +17,7 @@ import FileUtils from '../utils/file-utils.js';
17
17
  import TokenUtils from '../utils/token-utils.js';
18
18
  import { redactSecrets } from '../utils/secret-redactor.js';
19
19
  import { readFileChecked } from '../utils/safe-read.js';
20
+ import { resolveWorkspaceRepos, enforceWorkspacePolicies } from '../workspace/index.js';
20
21
  import PrGit, { bufferToText } from './pr-git.js';
21
22
  import {
22
23
  parseNameStatus,
@@ -53,6 +54,7 @@ const DEFAULTS = {
53
54
  redact: true,
54
55
  maxFileBytes: 1024 * 1024,
55
56
  maxRelatedTests: 50,
57
+ repos: null,
56
58
  };
57
59
 
58
60
  // Test files read to look for imports and references: per file, and for all of them
@@ -65,6 +67,7 @@ const FILTER_NAMES = {
65
67
  context: '.contextignore',
66
68
  'context-include': '.contextinclude',
67
69
  };
70
+ const IMPACT_ORDER = { low: 0, medium: 1, high: 2 };
68
71
 
69
72
  /**
70
73
  * Check and complete the options
@@ -273,35 +276,58 @@ function describeDropped(dropped) {
273
276
  return labels;
274
277
  }
275
278
 
276
- /**
277
- * Build the review context for a branch, the staged changes or the working tree
278
- * @param {string} [root=process.cwd()] - Project directory (inside a git work tree); a
279
- * subdirectory limits the change to that directory
280
- * @param {object} [options]
281
- * @param {string} [options.base] - Base revision (default: origin/HEAD, else main, else master;
282
- * HEAD with staged/working)
283
- * @param {string} [options.head='HEAD'] - Head revision
284
- * @param {boolean} [options.staged] - Compare the index with the base instead
285
- * @param {boolean} [options.working] - Compare the working tree (with untracked files)
286
- * @param {number} [options.unified=3] - Context lines in the diff
287
- * @param {boolean} [options.diff=true] - Include the unified diff
288
- * @param {boolean} [options.withTests] - Include related tests' contents
289
- * @param {boolean} [options.withFiles] - Include changed files' full contents
290
- * @param {string} [options.ask] - Question appended at the end
291
- * @param {string} [options.instructions] - Instructions appended at the end
292
- * @param {number} [options.budget] - Maximum tokens of the output
293
- * @param {'markdown'|'xml'|'json'} [options.style='markdown']
294
- * @param {boolean} [options.redact=true] - Mask secrets in contents, diff and commit messages
295
- * @returns {Promise<object>} `output` (the rendered text), `tokens`, per-section `sections`
296
- * token counts, the collected `commits`, `files`, `methods`, `relatedTests`, `stats`,
297
- * `budget` (limit, dropped, over), `redactions` and `warnings`
298
- */
299
- export async function buildPrContext(root = process.cwd(), options = {}) {
300
- const opts = normalizePrOptions(options);
301
- const rootPath = path.resolve(root);
302
- if (!fs.existsSync(rootPath) || !fs.statSync(rootPath).isDirectory()) {
303
- throw new Error(`Not a directory: ${rootPath}`);
279
+ function buildWorkspaceRepoInputs(primaryRoot, extraRepos) {
280
+ const realPrimary = fs.existsSync(primaryRoot)
281
+ ? fs.realpathSync(primaryRoot)
282
+ : path.resolve(primaryRoot);
283
+ const isContainerOfExtra = extraRepos.some((item) => {
284
+ const raw = typeof item === 'string' ? item : item?.path;
285
+ if (typeof raw !== 'string' || !raw.trim()) return false;
286
+ const resolved = path.resolve(raw);
287
+ const real = fs.existsSync(resolved) ? fs.realpathSync(resolved) : resolved;
288
+ return real !== realPrimary && real.startsWith(realPrimary + path.sep);
289
+ });
290
+ if (isContainerOfExtra || !PrGit.findRepoRoot(realPrimary)) {
291
+ return [...extraRepos];
304
292
  }
293
+ return [primaryRoot, ...extraRepos];
294
+ }
295
+
296
+ function prefixDiffWithAlias(diffText, alias) {
297
+ if (!diffText) return diffText;
298
+ return diffText
299
+ .split('\n')
300
+ .map((line) => {
301
+ if (line.startsWith('diff --git a/')) {
302
+ return line.replace(
303
+ /^diff --git a\/(.+?) b\/(.+)$/,
304
+ `diff --git a/${alias}/$1 b/${alias}/$2`
305
+ );
306
+ }
307
+ if (line.startsWith('--- a/')) {
308
+ return `--- a/${alias}/${line.slice(6)}`;
309
+ }
310
+ if (line.startsWith('+++ b/')) {
311
+ return `+++ b/${alias}/${line.slice(6)}`;
312
+ }
313
+ if (line.startsWith('rename from ')) {
314
+ return `rename from ${alias}/${line.slice(12)}`;
315
+ }
316
+ if (line.startsWith('rename to ')) {
317
+ return `rename to ${alias}/${line.slice(10)}`;
318
+ }
319
+ if (line.startsWith('copy from ')) {
320
+ return `copy from ${alias}/${line.slice(10)}`;
321
+ }
322
+ if (line.startsWith('copy to ')) {
323
+ return `copy to ${alias}/${line.slice(8)}`;
324
+ }
325
+ return line;
326
+ })
327
+ .join('\n');
328
+ }
329
+
330
+ function collectSingleRepoPr(rootPath, opts) {
305
331
  const repoTop = PrGit.findRepoRoot(rootPath);
306
332
  if (!repoTop) throw new Error(`Not inside a git repository: ${rootPath}`);
307
333
  const repoRoot = fs.realpathSync(repoTop);
@@ -635,7 +661,10 @@ export async function buildPrContext(root = process.cwd(), options = {}) {
635
661
  );
636
662
  }
637
663
 
638
- // --- render within the budget ------------------------------------------------------------
664
+ return { model, scope, redactions, warnings };
665
+ }
666
+
667
+ function renderModelWithinBudget(model, opts, redactions, warnings, scope) {
639
668
  const count = (text) => TokenUtils.calculate(text, 'pr-context.md');
640
669
  const dropped = {
641
670
  contents: [],
@@ -691,6 +720,7 @@ export async function buildPrContext(root = process.cwd(), options = {}) {
691
720
  }
692
721
 
693
722
  return {
723
+ ...(model.repos ? { repos: model.repos } : {}),
694
724
  output: result.output,
695
725
  tokens: result.tokens,
696
726
  style: opts.style,
@@ -704,8 +734,9 @@ export async function buildPrContext(root = process.cwd(), options = {}) {
704
734
  budget: model.budget,
705
735
  commits: model.commits,
706
736
  files: model.files,
707
- methods: { changed: methods, deleted: deletedMethods },
708
- relatedTests: tests.map(({ path: testPath, reasons, changed, excluded }) => ({
737
+ methods: { changed: model.methods, deleted: model.deletedMethods },
738
+ relatedTests: model.tests.map(({ path: testPath, reasons, changed, excluded, repo }) => ({
739
+ ...(repo ? { repo } : {}),
709
740
  path: testPath,
710
741
  reasons,
711
742
  changed,
@@ -716,6 +747,236 @@ export async function buildPrContext(root = process.cwd(), options = {}) {
716
747
  };
717
748
  }
718
749
 
750
+ /**
751
+ * Build the review context for a branch, the staged changes or the working tree
752
+ * @param {string} [root=process.cwd()] - Project directory (inside a git work tree); a
753
+ * subdirectory limits the change to that directory
754
+ * @param {object} [options]
755
+ * @param {string} [options.base] - Base revision (default: origin/HEAD, else main, else master;
756
+ * HEAD with staged/working)
757
+ * @param {string} [options.head='HEAD'] - Head revision
758
+ * @param {boolean} [options.staged] - Compare the index with the base instead
759
+ * @param {boolean} [options.working] - Compare the working tree (with untracked files)
760
+ * @param {number} [options.unified=3] - Context lines in the diff
761
+ * @param {boolean} [options.diff=true] - Include the unified diff
762
+ * @param {boolean} [options.withTests] - Include related tests' contents
763
+ * @param {boolean} [options.withFiles] - Include changed files' full contents
764
+ * @param {string} [options.ask] - Question appended at the end
765
+ * @param {string} [options.instructions] - Instructions appended at the end
766
+ * @param {number} [options.budget] - Maximum tokens of the output
767
+ * @param {'markdown'|'xml'|'json'} [options.style='markdown']
768
+ * @param {boolean} [options.redact=true] - Mask secrets in contents, diff and commit messages
769
+ * @param {Array<string|{path: string, alias?: string}>} [options.repos] - Additional workspace repositories
770
+ * @returns {Promise<object>} `output` (the rendered text), `tokens`, per-section `sections`
771
+ * token counts, the collected `commits`, `files`, `methods`, `relatedTests`, `stats`,
772
+ * `budget` (limit, dropped, over), `redactions` and `warnings`
773
+ */
774
+ export async function buildPrContext(root = process.cwd(), options = {}) {
775
+ const opts = normalizePrOptions(options);
776
+ const rootPath = path.resolve(root);
777
+ if (!fs.existsSync(rootPath) || !fs.statSync(rootPath).isDirectory()) {
778
+ throw new Error(`Not a directory: ${rootPath}`);
779
+ }
780
+
781
+ const isWorkspace = Array.isArray(options.repos) && options.repos.length > 0;
782
+ if (!isWorkspace) {
783
+ const { model, scope, redactions, warnings } = collectSingleRepoPr(rootPath, opts);
784
+ return renderModelWithinBudget(model, opts, redactions, warnings, scope);
785
+ }
786
+
787
+ const resolvedRepos = resolveWorkspaceRepos(buildWorkspaceRepoInputs(rootPath, options.repos));
788
+ enforceWorkspacePolicies(resolvedRepos, {
789
+ filesByRepo: new Map(),
790
+ tokens: 0,
791
+ redact: opts.redact,
792
+ });
793
+
794
+ for (const repo of resolvedRepos) {
795
+ if (!PrGit.findRepoRoot(repo.realRoot)) {
796
+ throw new Error(`Not inside a git repository: ${repo.root}`);
797
+ }
798
+ }
799
+
800
+ const perRepoResults = [];
801
+ const skippedErrors = [];
802
+ let firstError = null;
803
+
804
+ for (const repo of resolvedRepos) {
805
+ try {
806
+ const single = collectSingleRepoPr(repo.root, { ...opts, budget: null });
807
+ perRepoResults.push({ repo, ...single });
808
+ } catch (error) {
809
+ if (error.name === 'PolicyConfigError' || error.name === 'PolicyViolationError') {
810
+ throw error;
811
+ }
812
+ firstError ??= error;
813
+ skippedErrors.push({ repo, error });
814
+ }
815
+ }
816
+
817
+ if (perRepoResults.length === 0 && firstError) {
818
+ throw firstError;
819
+ }
820
+
821
+ const warnings = [];
822
+ for (const { repo, error } of skippedErrors) {
823
+ warnings.push(`${repo.alias}: skipped (${error.message})`);
824
+ }
825
+
826
+ const redactions = {};
827
+ const commits = [];
828
+ const files = [];
829
+ const methods = [];
830
+ const deletedMethods = [];
831
+ const diffs = [];
832
+ const allTests = [];
833
+ const contents = [];
834
+ const byStatus = {};
835
+ const filesByRepo = new Map(resolvedRepos.map((r) => [r.alias, []]));
836
+
837
+ let highestImpact = { level: 'low', score: 0 };
838
+
839
+ for (const { repo, model: m, redactions: r, warnings: w } of perRepoResults) {
840
+ for (const [type, count] of Object.entries(r)) {
841
+ redactions[type] = (redactions[type] || 0) + count;
842
+ }
843
+ for (const warning of w) {
844
+ warnings.push(`${repo.alias}: ${warning}`);
845
+ }
846
+ if (
847
+ (IMPACT_ORDER[m.impact.level] ?? 0) > (IMPACT_ORDER[highestImpact.level] ?? 0) ||
848
+ m.impact.score > highestImpact.score
849
+ ) {
850
+ highestImpact = {
851
+ level:
852
+ (IMPACT_ORDER[m.impact.level] ?? 0) >= (IMPACT_ORDER[highestImpact.level] ?? 0)
853
+ ? m.impact.level
854
+ : highestImpact.level,
855
+ score: Math.max(highestImpact.score, m.impact.score),
856
+ };
857
+ }
858
+ for (const [status, cnt] of Object.entries(m.stats.byStatus)) {
859
+ byStatus[status] = (byStatus[status] || 0) + cnt;
860
+ }
861
+ for (const c of m.commits) {
862
+ commits.push({ ...c, repo: repo.alias });
863
+ }
864
+ for (const f of m.files) {
865
+ filesByRepo.get(repo.alias)?.push({ path: f.path, tokens: 0 });
866
+ files.push({
867
+ ...f,
868
+ repo: repo.alias,
869
+ path: `${repo.alias}/${f.path}`,
870
+ oldPath: f.oldPath ? `${repo.alias}/${f.oldPath}` : null,
871
+ });
872
+ }
873
+ for (const method of m.methods) {
874
+ methods.push({
875
+ ...method,
876
+ repo: repo.alias,
877
+ file: `${repo.alias}/${method.file}`,
878
+ });
879
+ }
880
+ for (const dm of m.deletedMethods) {
881
+ deletedMethods.push({
882
+ ...dm,
883
+ repo: repo.alias,
884
+ file: `${repo.alias}/${dm.file}`,
885
+ });
886
+ }
887
+ for (const d of m.diffs) {
888
+ diffs.push({
889
+ ...d,
890
+ repo: repo.alias,
891
+ path: `${repo.alias}/${d.path}`,
892
+ text: prefixDiffWithAlias(d.text, repo.alias),
893
+ });
894
+ }
895
+ for (const t of m.tests) {
896
+ allTests.push({
897
+ ...t,
898
+ repo: repo.alias,
899
+ path: `${repo.alias}/${t.path}`,
900
+ });
901
+ }
902
+ for (const c of m.contents) {
903
+ contents.push({
904
+ ...c,
905
+ repo: repo.alias,
906
+ path: `${repo.alias}/${c.path}`,
907
+ });
908
+ }
909
+ }
910
+
911
+ const tests = allTests.slice(0, opts.maxRelatedTests);
912
+ if (allTests.length > tests.length) {
913
+ warnings.push(
914
+ `${allTests.length} related tests found across workspace; the first ${tests.length} are listed`
915
+ );
916
+ }
917
+
918
+ const uniqueJoin = (values) => [...new Set(values.filter(Boolean))].join(', ');
919
+ const firstModel = perRepoResults[0].model;
920
+ const baseRef = uniqueJoin(perRepoResults.map((x) => x.model.base.ref));
921
+ const baseShort = uniqueJoin(perRepoResults.map((x) => x.model.base.short));
922
+ const headRef = uniqueJoin(perRepoResults.map((x) => x.model.head.ref));
923
+ const headShort = uniqueJoin(perRepoResults.map((x) => x.model.head.short));
924
+ const mergeShort = uniqueJoin(perRepoResults.map((x) => x.model.mergeBase.short));
925
+
926
+ const workspaceModel = {
927
+ repos: resolvedRepos.map(({ alias, root: r }) => ({ alias, root: r })),
928
+ mode: firstModel.mode,
929
+ base: { ref: baseRef, sha: firstModel.base.sha, short: baseShort || null },
930
+ head:
931
+ firstModel.mode === 'range'
932
+ ? { ref: headRef, sha: firstModel.head.sha, short: headShort || null }
933
+ : { ref: headRef, sha: null, short: null },
934
+ mergeBase: { sha: firstModel.mergeBase.sha, short: mergeShort },
935
+ scope: '',
936
+ impact: highestImpact,
937
+ stats: {
938
+ commits: commits.length,
939
+ files: files.length,
940
+ byStatus,
941
+ additions: files.reduce((sum, f) => sum + f.additions, 0),
942
+ deletions: files.reduce((sum, f) => sum + f.deletions, 0),
943
+ excluded: files.filter((f) => f.excluded).length,
944
+ changedMethods: methods.length,
945
+ addedMethods: methods.filter((m) => m.change === 'added').length,
946
+ deletedMethods: deletedMethods.length,
947
+ relatedTests: tests.length,
948
+ },
949
+ commits,
950
+ files,
951
+ methods,
952
+ deletedMethods,
953
+ diffEnabled: opts.diff,
954
+ diffs,
955
+ tests,
956
+ withTests: opts.withTests,
957
+ withFiles: opts.withFiles,
958
+ contents,
959
+ instructions: opts.instructions,
960
+ ask: opts.ask,
961
+ budget: opts.budget ? { limit: opts.budget, dropped: [], over: false } : null,
962
+ tokens: null,
963
+ logBodiesDropped: false,
964
+ logDropped: false,
965
+ filesDropped: false,
966
+ methodsDropped: false,
967
+ };
968
+
969
+ const rendered = renderModelWithinBudget(workspaceModel, opts, redactions, warnings, '');
970
+
971
+ enforceWorkspacePolicies(resolvedRepos, {
972
+ filesByRepo,
973
+ tokens: rendered.tokens,
974
+ redact: opts.redact,
975
+ });
976
+
977
+ return rendered;
978
+ }
979
+
719
980
  /**
720
981
  * What the budget drops, least important first: full file contents, test contents, related
721
982
  * test paths, per-file diffs, method sources, the methods list, commit bodies, the commit
@@ -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;