ctxman 3.0.0 → 3.1.1

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 (224) hide show
  1. package/.ctxman/llm-profiles.json +1067 -39
  2. package/.ctxman/profiles/backend-team.json +13 -0
  3. package/.ctxman/profiles/default.json +12 -0
  4. package/.ctxman/profiles/devops-team.json +21 -0
  5. package/.ctxman/profiles/frontend-team.json +20 -0
  6. package/.ctxman/templates/bug-fix.json +17 -0
  7. package/.ctxman/templates/code-review.json +9 -0
  8. package/.ctxman/templates/documentation.json +9 -0
  9. package/.ctxman/templates/feature.json +9 -0
  10. package/.ctxman/templates/full-context.json +9 -0
  11. package/.ctxman/templates/refactoring.json +18 -0
  12. package/.ctxman/templates/testing.json +9 -0
  13. package/.ctxman/wizard-profiles/security-audit/profile.json +7 -1
  14. package/LICENSE +21 -0
  15. package/README.md +381 -136
  16. package/bin/cli.js +1880 -529
  17. package/bin/cm-ask.js +18 -67
  18. package/bin/cm-audit.js +121 -0
  19. package/bin/cm-count.js +151 -0
  20. package/bin/cm-gitingest.js +128 -115
  21. package/bin/cm-map.js +242 -0
  22. package/bin/cm-mcp.js +293 -0
  23. package/bin/cm-pack.js +405 -0
  24. package/bin/cm-policy.js +170 -0
  25. package/bin/cm-pr.js +248 -0
  26. package/bin/cm-search.js +178 -0
  27. package/bin/cm-update.js +128 -128
  28. package/bin/cm-why.js +197 -0
  29. package/bin/mcp-server.js +8 -24
  30. package/ctxman.js +237 -183
  31. package/examples/README.md +19 -4
  32. package/examples/github-actions/ctxman-check.yml +49 -0
  33. package/examples/wizard-profiles/security-audit/profile.json +7 -1
  34. package/index.d.ts +1422 -0
  35. package/index.js +55 -31
  36. package/lib/agents/pr-failure-agent.js +48 -45
  37. package/lib/analyzers/ai-suggester.js +451 -0
  38. package/lib/analyzers/dependency-scanner.js +505 -0
  39. package/lib/analyzers/duplicate-detector.js +264 -0
  40. package/lib/analyzers/go-method-analyzer.js +151 -110
  41. package/lib/analyzers/import-tracker.js +431 -0
  42. package/lib/analyzers/method-analyzer.js +1055 -434
  43. package/lib/analyzers/performance-dashboard.js +687 -0
  44. package/lib/analyzers/token-calculator.js +895 -504
  45. package/lib/analyzers/unused-exports-detector.js +336 -0
  46. package/lib/api/mcp/install.js +424 -0
  47. package/lib/api/mcp/prompts.js +281 -281
  48. package/lib/api/mcp/resources.js +300 -241
  49. package/lib/api/mcp/sandbox.js +89 -0
  50. package/lib/api/mcp/server.js +772 -312
  51. package/lib/api/mcp/start.js +125 -0
  52. package/lib/api/mcp/transports/HttpSseTransport.js +60 -52
  53. package/lib/api/rest/server.js +105 -28
  54. package/lib/cache/CacheManager.js +9 -17
  55. package/lib/core/Analyzer.js +16 -10
  56. package/lib/core/ContextBuilder.js +19 -28
  57. package/lib/core/Reporter.js +15 -14
  58. package/lib/core/Scanner.js +81 -12
  59. package/lib/count/index.js +100 -0
  60. package/lib/formatters/format-registry.js +394 -372
  61. package/lib/formatters/gitingest-formatter.js +656 -621
  62. package/lib/formatters/toon-formatter-v1.3.js +637 -632
  63. package/lib/formatters/toon-formatter.js +239 -240
  64. package/lib/integrations/git/BlameTracker.js +4 -4
  65. package/lib/integrations/git/DiffAnalyzer.js +38 -31
  66. package/lib/integrations/git/GitClient.js +71 -53
  67. package/lib/lsp/server.js +311 -0
  68. package/lib/map/graph.js +299 -0
  69. package/lib/map/imports.js +233 -0
  70. package/lib/map/index.js +33 -0
  71. package/lib/map/rank.js +253 -0
  72. package/lib/map/render.js +346 -0
  73. package/lib/map/resolver.js +574 -0
  74. package/lib/pack/budget.js +111 -0
  75. package/lib/pack/code-lexer.js +360 -0
  76. package/lib/pack/compress.js +414 -0
  77. package/lib/pack/index.js +354 -0
  78. package/lib/pack/renderers/common.js +134 -0
  79. package/lib/pack/renderers/index.js +24 -0
  80. package/lib/pack/renderers/json.js +47 -0
  81. package/lib/pack/renderers/markdown.js +54 -0
  82. package/lib/pack/renderers/plain.js +38 -0
  83. package/lib/pack/renderers/xml.js +85 -0
  84. package/lib/pack/select.js +177 -0
  85. package/lib/pack/split.js +182 -0
  86. package/lib/pack/tree.js +35 -0
  87. package/lib/parsers/gitignore-parser.js +116 -107
  88. package/lib/parsers/method-filter-parser.js +50 -33
  89. package/lib/plugins/ExporterPlugin.js +3 -3
  90. package/lib/plugins/LanguagePlugin.js +3 -3
  91. package/lib/plugins/PluginManager.js +7 -13
  92. package/lib/policy/audit.js +312 -0
  93. package/lib/policy/enforce.js +125 -0
  94. package/lib/policy/ignore-files.js +169 -0
  95. package/lib/policy/index.js +53 -0
  96. package/lib/policy/policy.js +489 -0
  97. package/lib/pr/diff-parser.js +267 -0
  98. package/lib/pr/method-mapper.js +148 -0
  99. package/lib/pr/pr-context.js +829 -0
  100. package/lib/pr/pr-git.js +149 -0
  101. package/lib/pr/pr-renderer.js +644 -0
  102. package/lib/pr/related-tests.js +296 -0
  103. package/lib/rag/DataSourcePlugin.js +5 -5
  104. package/lib/rag/EmbeddingProvider.js +19 -8
  105. package/lib/rag/Indexer.js +14 -5
  106. package/lib/rag/VectorStoreFactory.js +3 -3
  107. package/lib/rag/ask.js +122 -0
  108. package/lib/rag/sources/FileDataSource.js +12 -9
  109. package/lib/rag/stores/BaseVectorStore.js +5 -5
  110. package/lib/rag/stores/ChromaDBStore.js +10 -10
  111. package/lib/rag/stores/LanceDBStore.js +29 -19
  112. package/lib/rag/stores/PineconeStore.js +11 -9
  113. package/lib/search/bm25.js +433 -0
  114. package/lib/search/code-units.js +201 -0
  115. package/lib/search/symbols.js +189 -0
  116. package/lib/ui/dashboard.js +120 -89
  117. package/lib/ui/index.js +1 -6
  118. package/lib/ui/init-wizard-ui.js +550 -0
  119. package/lib/ui/progress-bar.js +94 -88
  120. package/lib/ui/select-input.js +40 -32
  121. package/lib/ui/template-selector.js +99 -0
  122. package/lib/ui/wizard.js +374 -329
  123. package/lib/utils/claude-token-counter.js +151 -0
  124. package/lib/utils/cli-output.js +17 -0
  125. package/lib/utils/clipboard-utils.js +49 -50
  126. package/lib/utils/config-utils.js +81 -66
  127. package/lib/utils/context-versioning.js +532 -0
  128. package/lib/utils/error-handler.js +112 -112
  129. package/lib/utils/file-utils.js +153 -69
  130. package/lib/utils/format-converter.js +356 -283
  131. package/lib/utils/git-utils.js +366 -294
  132. package/lib/utils/llm-cost-estimator.js +281 -0
  133. package/lib/utils/llm-detector.js +242 -78
  134. package/lib/utils/logger.js +246 -237
  135. package/lib/utils/messages.js +355 -0
  136. package/lib/utils/multi-repo-manager.js +364 -0
  137. package/lib/utils/profile-manager.js +380 -0
  138. package/lib/utils/safe-read.js +61 -0
  139. package/lib/utils/secret-redactor.js +61 -0
  140. package/lib/utils/snapshot-manager.js +679 -0
  141. package/lib/utils/template-manager.js +378 -0
  142. package/lib/utils/token-utils.js +201 -170
  143. package/lib/utils/tokenizer-adapter.js +421 -377
  144. package/lib/utils/updater.js +539 -506
  145. package/lib/watch/FileWatcher.js +12 -8
  146. package/lib/watch/IncrementalAnalyzer.js +7 -7
  147. package/lib/why/coupling.js +81 -0
  148. package/lib/why/errors.js +23 -0
  149. package/lib/why/git.js +169 -0
  150. package/lib/why/history.js +108 -0
  151. package/lib/why/index.js +720 -0
  152. package/lib/why/ownership.js +79 -0
  153. package/lib/why/paths.js +207 -0
  154. package/lib/why/references.js +259 -0
  155. package/lib/why/render.js +722 -0
  156. package/lib/why/search.js +358 -0
  157. package/lib/why/target.js +389 -0
  158. package/lib/wizards/config-generator.js +257 -0
  159. package/lib/wizards/project-detector.js +572 -0
  160. package/package.json +44 -10
  161. package/.ctxman/logs/ctxman-2026-07-02.log +0 -156
  162. package/.ctxman/self-knowledge/documents.lance/_transactions/0-b3dd1558-f95b-44b9-a154-18e8c330ae7a.txn +0 -0
  163. package/.ctxman/self-knowledge/documents.lance/_transactions/1-44bff6d4-e552-4010-a769-e09ad822698d.txn +0 -0
  164. package/.ctxman/self-knowledge/documents.lance/_transactions/10-37260794-5947-4ecb-b4c8-d732f58f40f1.txn +0 -0
  165. package/.ctxman/self-knowledge/documents.lance/_transactions/11-0d52a883-5518-463c-9b6d-986c3af89dfd.txn +0 -0
  166. package/.ctxman/self-knowledge/documents.lance/_transactions/12-ae354646-52f2-49c9-90fb-2e6ed8932663.txn +0 -0
  167. package/.ctxman/self-knowledge/documents.lance/_transactions/13-5d7d5e57-27da-498b-b734-091b9d38da13.txn +0 -0
  168. package/.ctxman/self-knowledge/documents.lance/_transactions/14-558ce928-810b-4252-a364-fdd1ee6f8a25.txn +0 -0
  169. package/.ctxman/self-knowledge/documents.lance/_transactions/15-27e3a58f-8b64-43b8-a9f4-e3369446968b.txn +0 -0
  170. package/.ctxman/self-knowledge/documents.lance/_transactions/16-a110c390-e5c8-4464-b976-bc7f5195fa15.txn +0 -0
  171. package/.ctxman/self-knowledge/documents.lance/_transactions/17-97513225-53de-4216-b61a-6337bd0cc4ad.txn +0 -0
  172. package/.ctxman/self-knowledge/documents.lance/_transactions/18-5ba450f0-c527-459c-bf4f-6d79b5e4285f.txn +0 -0
  173. package/.ctxman/self-knowledge/documents.lance/_transactions/19-600d3a63-5225-49e9-b8b7-30e037b564ab.txn +0 -0
  174. package/.ctxman/self-knowledge/documents.lance/_transactions/2-48a61b4e-92cf-4342-8376-914c1b99a9f9.txn +0 -0
  175. package/.ctxman/self-knowledge/documents.lance/_transactions/20-121dd912-5410-410e-84ff-82c7a23604a0.txn +0 -0
  176. package/.ctxman/self-knowledge/documents.lance/_transactions/3-bc6844ab-7ea3-4bb3-9ee1-538ce5664a9c.txn +0 -0
  177. package/.ctxman/self-knowledge/documents.lance/_transactions/4-d5e1754f-dbb5-454e-a9be-2a7da8d7bcf9.txn +0 -0
  178. package/.ctxman/self-knowledge/documents.lance/_transactions/5-4fae93df-0568-4b6f-8acf-8bef4bba6047.txn +0 -0
  179. package/.ctxman/self-knowledge/documents.lance/_transactions/6-4b1b8b87-068e-49a3-ba2b-d8dae628b252.txn +0 -0
  180. package/.ctxman/self-knowledge/documents.lance/_transactions/7-63d040fb-d2fa-4c0f-bed0-53411918a7b0.txn +0 -0
  181. package/.ctxman/self-knowledge/documents.lance/_transactions/8-c5628cf6-264c-420f-aab8-bc96a9083591.txn +0 -0
  182. package/.ctxman/self-knowledge/documents.lance/_transactions/9-c94570aa-5e53-4fcb-971f-cecc1d1d5492.txn +0 -0
  183. package/.ctxman/self-knowledge/documents.lance/_versions/1.manifest +0 -0
  184. package/.ctxman/self-knowledge/documents.lance/_versions/10.manifest +0 -0
  185. package/.ctxman/self-knowledge/documents.lance/_versions/11.manifest +0 -0
  186. package/.ctxman/self-knowledge/documents.lance/_versions/12.manifest +0 -0
  187. package/.ctxman/self-knowledge/documents.lance/_versions/13.manifest +0 -0
  188. package/.ctxman/self-knowledge/documents.lance/_versions/14.manifest +0 -0
  189. package/.ctxman/self-knowledge/documents.lance/_versions/15.manifest +0 -0
  190. package/.ctxman/self-knowledge/documents.lance/_versions/16.manifest +0 -0
  191. package/.ctxman/self-knowledge/documents.lance/_versions/17.manifest +0 -0
  192. package/.ctxman/self-knowledge/documents.lance/_versions/18.manifest +0 -0
  193. package/.ctxman/self-knowledge/documents.lance/_versions/19.manifest +0 -0
  194. package/.ctxman/self-knowledge/documents.lance/_versions/2.manifest +0 -0
  195. package/.ctxman/self-knowledge/documents.lance/_versions/20.manifest +0 -0
  196. package/.ctxman/self-knowledge/documents.lance/_versions/21.manifest +0 -0
  197. package/.ctxman/self-knowledge/documents.lance/_versions/3.manifest +0 -0
  198. package/.ctxman/self-knowledge/documents.lance/_versions/4.manifest +0 -0
  199. package/.ctxman/self-knowledge/documents.lance/_versions/5.manifest +0 -0
  200. package/.ctxman/self-knowledge/documents.lance/_versions/6.manifest +0 -0
  201. package/.ctxman/self-knowledge/documents.lance/_versions/7.manifest +0 -0
  202. package/.ctxman/self-knowledge/documents.lance/_versions/8.manifest +0 -0
  203. package/.ctxman/self-knowledge/documents.lance/_versions/9.manifest +0 -0
  204. package/.ctxman/self-knowledge/documents.lance/data/000010010011011101000010de8621405d9d5cb06540dbf02c.lance +0 -0
  205. package/.ctxman/self-knowledge/documents.lance/data/0000101001011100011000003fb62a4aa8978974075c144f1c.lance +0 -0
  206. package/.ctxman/self-knowledge/documents.lance/data/000011010000100111001011c6cc824d628f3bb1f4e04cd5d0.lance +0 -0
  207. package/.ctxman/self-knowledge/documents.lance/data/001000001000011001011100d8f74349ada090b332cd1df669.lance +0 -0
  208. package/.ctxman/self-knowledge/documents.lance/data/01000001111010001000010045ab44467bb1e7e4d8ae06bd58.lance +0 -0
  209. package/.ctxman/self-knowledge/documents.lance/data/01000101101110111110011131ef904241a89e1ffb6ddaf8e3.lance +0 -0
  210. package/.ctxman/self-knowledge/documents.lance/data/010010110000010000011010fbfcc145c6a8a827b28057a07e.lance +0 -0
  211. package/.ctxman/self-knowledge/documents.lance/data/1000011000101010001110003fb4f045c3835982efa1405324.lance +0 -0
  212. package/.ctxman/self-knowledge/documents.lance/data/100111011101110111101011f4396b410fa06af3b028d204e6.lance +0 -0
  213. package/.ctxman/self-knowledge/documents.lance/data/100111100111101000001100596b174e00995d6cc2969cc8dc.lance +0 -0
  214. package/.ctxman/self-knowledge/documents.lance/data/1010001100011000110010102ea16b484d887c7c9725feffd6.lance +0 -0
  215. package/.ctxman/self-knowledge/documents.lance/data/101101100010101111110111c5230944d9b3af7301b6392dda.lance +0 -0
  216. package/.ctxman/self-knowledge/documents.lance/data/1011011100100111111110105892bf41b5a530fedd7cf516f1.lance +0 -0
  217. package/.ctxman/self-knowledge/documents.lance/data/10111110000010001011110164e8774742bade87b483c10f36.lance +0 -0
  218. package/.ctxman/self-knowledge/documents.lance/data/1100000100010110001110103ffaca40a2a42c85067ebaf9ae.lance +0 -0
  219. package/.ctxman/self-knowledge/documents.lance/data/110000010001110110101110664bde4c76a78dfc14b05e83b8.lance +0 -0
  220. package/.ctxman/self-knowledge/documents.lance/data/11001100111000010100110050645c4281b1f35b2022234f7f.lance +0 -0
  221. package/.ctxman/self-knowledge/documents.lance/data/1101111011001100110111017214924a368f5719eab2246619.lance +0 -0
  222. package/.ctxman/self-knowledge/documents.lance/data/1110000111001010001000002150c24dcab653d39d07032e63.lance +0 -0
  223. package/.ctxman/self-knowledge/documents.lance/data/1110100000000100000011014d20534967a18b5c87721659ff.lance +0 -0
  224. package/.ctxman/self-knowledge/documents.lance/data/11110101111010000101011135a0114ef2b35dc6f1297a3381.lance +0 -0
@@ -21,7 +21,7 @@ export class ChromaDBStore extends BaseVectorStore {
21
21
  try {
22
22
  const { ChromaClient } = await import('chromadb');
23
23
  this.client = new ChromaClient({ path: this.url });
24
-
24
+
25
25
  this.collection = await this.client.getOrCreateCollection({
26
26
  name: this.collectionName,
27
27
  });
@@ -37,22 +37,22 @@ export class ChromaDBStore extends BaseVectorStore {
37
37
  if (!this.collection) await this.init();
38
38
  if (docs.length === 0) return;
39
39
 
40
- const ids = docs.map(d => d.id || crypto.randomUUID());
41
- const metadatas = docs.map(d => d.metadata || {});
42
- const documents = docs.map(d => d.text);
43
-
40
+ const ids = docs.map((d) => d.id || crypto.randomUUID());
41
+ const metadatas = docs.map((d) => d.metadata || {});
42
+ const documents = docs.map((d) => d.text);
43
+
44
44
  // Chroma can handle embeddings, but we have a provider.
45
45
  // If we want to use our provider:
46
46
  const embeddings = [];
47
47
  for (const doc of docs) {
48
- embeddings.push(await this.embeddingProvider.embed(doc.text));
48
+ embeddings.push(await this.embeddingProvider.embed(doc.text));
49
49
  }
50
50
 
51
51
  await this.collection.add({
52
52
  ids,
53
53
  embeddings,
54
54
  metadatas,
55
- documents
55
+ documents,
56
56
  });
57
57
  }
58
58
 
@@ -63,7 +63,7 @@ export class ChromaDBStore extends BaseVectorStore {
63
63
 
64
64
  const results = await this.collection.query({
65
65
  queryEmbeddings: [queryEmbedding],
66
- nResults: limit
66
+ nResults: limit,
67
67
  });
68
68
 
69
69
  // Chroma returns arrays of arrays
@@ -71,12 +71,12 @@ export class ChromaDBStore extends BaseVectorStore {
71
71
 
72
72
  const hits = [];
73
73
  const count = results.ids[0].length;
74
-
74
+
75
75
  for (let i = 0; i < count; i++) {
76
76
  hits.push({
77
77
  text: results.documents[0][i],
78
78
  score: results.distances ? results.distances[0][i] : 0,
79
- metadata: results.metadatas[0][i]
79
+ metadata: results.metadatas[0][i],
80
80
  });
81
81
  }
82
82
 
@@ -1,4 +1,3 @@
1
- import * as lancedb from '@lancedb/lancedb';
2
1
  import fs from 'fs/promises';
3
2
  import { BaseVectorStore } from './BaseVectorStore.js';
4
3
 
@@ -19,11 +18,24 @@ export class LanceDBStore extends BaseVectorStore {
19
18
  async init() {
20
19
  if (this.initialized) return;
21
20
 
21
+ // Optional dependency: only `ctxman ask` needs it, so load it on first use
22
+ let lancedb;
23
+ try {
24
+ lancedb = await import('@lancedb/lancedb');
25
+ } catch (error) {
26
+ if (error.code === 'ERR_MODULE_NOT_FOUND') {
27
+ throw new Error(
28
+ 'Please install @lancedb/lancedb to use LanceDBStore (ctxman ask): npm install @lancedb/lancedb'
29
+ );
30
+ }
31
+ throw error;
32
+ }
33
+
22
34
  // Ensure directory exists
23
35
  await fs.mkdir(this.storagePath, { recursive: true });
24
36
 
25
37
  this.db = await lancedb.connect(this.storagePath);
26
-
38
+
27
39
  const tableNames = await this.db.tableNames();
28
40
  if (tableNames.includes(this.tableName)) {
29
41
  this.table = await this.db.openTable(this.tableName);
@@ -34,8 +46,8 @@ export class LanceDBStore extends BaseVectorStore {
34
46
 
35
47
  /**
36
48
  * Adds a single document to the store.
37
- * @param {string} text
38
- * @param {Object} metadata
49
+ * @param {string} text
50
+ * @param {Object} metadata
39
51
  */
40
52
  async addDocument(text, metadata = {}) {
41
53
  return this.addDocuments([{ text, metadata }]);
@@ -43,7 +55,7 @@ export class LanceDBStore extends BaseVectorStore {
43
55
 
44
56
  /**
45
57
  * Adds multiple documents to the store.
46
- * @param {Array<{text: string, metadata: Object}>} docs
58
+ * @param {Array<{text: string, metadata: Object}>} docs
47
59
  */
48
60
  async addDocuments(docs) {
49
61
  if (docs.length === 0) return;
@@ -57,7 +69,7 @@ export class LanceDBStore extends BaseVectorStore {
57
69
  text: doc.text,
58
70
  id: crypto.randomUUID(),
59
71
  timestamp: Date.now(),
60
- ...doc.metadata
72
+ ...doc.metadata,
61
73
  });
62
74
  }
63
75
 
@@ -71,8 +83,8 @@ export class LanceDBStore extends BaseVectorStore {
71
83
 
72
84
  /**
73
85
  * Semantic search for documents.
74
- * @param {string} queryText
75
- * @param {number} limit
86
+ * @param {string} queryText
87
+ * @param {number} limit
76
88
  * @returns {Promise<Array<{text: string, score: number, metadata: Object}>>}
77
89
  */
78
90
  async search(queryText, limit = 5) {
@@ -80,19 +92,17 @@ export class LanceDBStore extends BaseVectorStore {
80
92
  if (!this.table) return [];
81
93
 
82
94
  const queryVector = await this.embeddingProvider.embed(queryText);
83
-
95
+
84
96
  // LanceDB returns _distance by default (L2 distance usually).
85
97
  // Lower is better.
86
- const results = await this.table.search(queryVector)
87
- .limit(limit)
88
- .toArray();
98
+ const results = await this.table.search(queryVector).limit(limit).toArray();
89
99
 
90
- return results.map(row => {
91
- const { vector, text, _distance, ...metadata } = row;
100
+ return results.map((row) => {
101
+ const { vector: _vector, text, _distance, ...metadata } = row;
92
102
  return {
93
103
  text,
94
104
  score: _distance,
95
- metadata
105
+ metadata,
96
106
  };
97
107
  });
98
108
  }
@@ -100,10 +110,10 @@ export class LanceDBStore extends BaseVectorStore {
100
110
  async deleteTable() {
101
111
  if (!this.initialized) await this.init();
102
112
  try {
103
- await this.db.dropTable(this.tableName);
104
- this.table = null;
105
- } catch (e) {
106
- // Ignore if not exists
113
+ await this.db.dropTable(this.tableName);
114
+ this.table = null;
115
+ } catch (_e) {
116
+ // Ignore if not exists
107
117
  }
108
118
  }
109
119
  }
@@ -24,8 +24,10 @@ export class PineconeStore extends BaseVectorStore {
24
24
  this.client = new Pinecone({ apiKey: this.apiKey });
25
25
  this.index = this.client.index(this.indexName);
26
26
  } catch (error) {
27
- if (error.code === 'ERR_MODULE_NOT_FOUND') {
28
- throw new Error('Please install @pinecone-database/pinecone to use PineconeStore: npm install @pinecone-database/pinecone');
27
+ if (error.code === 'ERR_MODULE_NOT_FOUND') {
28
+ throw new Error(
29
+ 'Please install @pinecone-database/pinecone to use PineconeStore: npm install @pinecone-database/pinecone'
30
+ );
29
31
  }
30
32
  throw error;
31
33
  }
@@ -42,16 +44,16 @@ export class PineconeStore extends BaseVectorStore {
42
44
  id: doc.id || crypto.randomUUID(),
43
45
  values,
44
46
  metadata: {
45
- ...doc.metadata,
46
- text: doc.text // Pinecone metadata is limited, but storing text is useful
47
- }
47
+ ...doc.metadata,
48
+ text: doc.text, // Pinecone metadata is limited, but storing text is useful
49
+ },
48
50
  });
49
51
  }
50
52
 
51
53
  // Pinecone likes batches
52
54
  const batchSize = 100;
53
55
  for (let i = 0; i < vectors.length; i += batchSize) {
54
- await this.index.upsert(vectors.slice(i, i + batchSize));
56
+ await this.index.upsert(vectors.slice(i, i + batchSize));
55
57
  }
56
58
  }
57
59
 
@@ -62,13 +64,13 @@ export class PineconeStore extends BaseVectorStore {
62
64
  const results = await this.index.query({
63
65
  vector,
64
66
  topK: limit,
65
- includeMetadata: true
67
+ includeMetadata: true,
66
68
  });
67
69
 
68
- return results.matches.map(match => ({
70
+ return results.matches.map((match) => ({
69
71
  text: match.metadata.text || '', // Assuming text is stored in metadata
70
72
  score: match.score,
71
- metadata: match.metadata
73
+ metadata: match.metadata,
72
74
  }));
73
75
  }
74
76
  }
@@ -0,0 +1,433 @@
1
+ /**
2
+ * Ranked code search: BM25 over code chunks.
3
+ *
4
+ * A chunk is a method/function found by the method analyzers or, for code outside them and
5
+ * for files without methods, a window of up to 40 lines. Identifiers are split into their
6
+ * words (camelCase, snake_case, kebab-case) and lowercased, so `getUserName`, `get_user_name`
7
+ * and "user name" find each other. Pure JavaScript, no dependencies.
8
+ *
9
+ * The per-project index lives in memory and is rebuilt only for files whose modification
10
+ * time or size changed since the last search.
11
+ */
12
+
13
+ import fs from 'fs';
14
+ import path from 'path';
15
+ import { Scanner } from '../core/Scanner.js';
16
+ import TokenUtils from '../utils/token-utils.js';
17
+ import { redactSecrets } from '../utils/secret-redactor.js';
18
+ import { readFileChecked } from '../utils/safe-read.js';
19
+ import { extractCodeUnits } from './code-units.js';
20
+
21
+ export const DEFAULT_LIMIT = 10;
22
+ export const DEFAULT_MAX_TOKENS = 4000;
23
+ export const WINDOW_LINES = 40;
24
+ /** Files above this size (minified bundles, generated data) are not indexed */
25
+ export const MAX_FILE_BYTES = 1024 * 1024;
26
+ const MAX_CACHED_PROJECTS = 8;
27
+
28
+ // A word: letters, digits, `_` and `$`, optionally joined by `-` (kebab-case)
29
+ const WORD = /[\p{L}\p{N}_$]+(?:-[\p{L}\p{N}_$]+)*/gu;
30
+ // Parts of a camelCase word: `HTTPServer2Go` -> HTTP, Server, 2, Go
31
+ const PART = /\p{Lu}+(?!\p{Ll})|\p{Lu}?[\p{Ll}\p{Lo}\p{Lm}]+|\p{N}+/gu;
32
+
33
+ /**
34
+ * Fold an English plural to its singular, conservatively: `users` -> `user`,
35
+ * `classes` -> `class`; `status`, `class` and `analysis` stay as they are
36
+ * @param {string} term - Lower-case term
37
+ * @returns {string}
38
+ */
39
+ export function normalizeTerm(term) {
40
+ if (term.length <= 3 || !term.endsWith('s')) return term;
41
+ if (/(?:ss|us|is)$/.test(term)) return term;
42
+ if (/(?:sses|shes|ches|xes|zes)$/.test(term)) return term.slice(0, -2);
43
+ if (term.endsWith('ies') && term.length > 4) return `${term.slice(0, -3)}y`;
44
+ return term.slice(0, -1);
45
+ }
46
+
47
+ /**
48
+ * Split text into search terms.
49
+ *
50
+ * Each identifier yields its lower-cased parts (`parseHTTPResponse` -> parse, http,
51
+ * response) plus, when it has several parts, the joined form (`parsehttpresponse`), so an
52
+ * exact identifier query ranks the identifier above code that merely uses the same words.
53
+ * Single characters are dropped.
54
+ *
55
+ * @param {string} text
56
+ * @returns {string[]}
57
+ */
58
+ export function tokenize(text) {
59
+ const terms = [];
60
+ for (const [word] of String(text).matchAll(WORD)) {
61
+ const parts = [];
62
+ for (const piece of word.split(/[-_$]+/)) {
63
+ for (const [part] of piece.matchAll(PART)) parts.push(part.toLowerCase());
64
+ }
65
+ for (const part of parts) {
66
+ if (part.length > 1) terms.push(normalizeTerm(part));
67
+ }
68
+ if (parts.length > 1) {
69
+ terms.push(normalizeTerm(parts.join('')));
70
+ }
71
+ }
72
+ return terms;
73
+ }
74
+
75
+ /**
76
+ * Okapi BM25 index over documents given as term lists.
77
+ *
78
+ * IDF is the non-negative variant ln(1 + (N - n + 0.5) / (n + 0.5)). Results with equal
79
+ * scores keep the order in which their documents were added.
80
+ */
81
+ export class BM25Index {
82
+ /**
83
+ * @param {Object} [options]
84
+ * @param {number} [options.k1=1.2] - Term frequency saturation
85
+ * @param {number} [options.b=0.75] - Length normalisation
86
+ */
87
+ constructor({ k1 = 1.2, b = 0.75 } = {}) {
88
+ this.k1 = k1;
89
+ this.b = b;
90
+ this.docs = [];
91
+ this.lengths = [];
92
+ this.postings = new Map();
93
+ this.totalLength = 0;
94
+ }
95
+
96
+ /** Number of documents */
97
+ get size() {
98
+ return this.docs.length;
99
+ }
100
+
101
+ /**
102
+ * Add a document
103
+ * @param {*} doc - Returned with the search results
104
+ * @param {string[]} terms - Terms of the document (see tokenize())
105
+ * @returns {number} Document id
106
+ */
107
+ add(doc, terms) {
108
+ const id = this.docs.length;
109
+ const frequencies = new Map();
110
+ for (const term of terms) frequencies.set(term, (frequencies.get(term) || 0) + 1);
111
+ for (const [term, tf] of frequencies) {
112
+ let list = this.postings.get(term);
113
+ if (!list) {
114
+ list = [];
115
+ this.postings.set(term, list);
116
+ }
117
+ list.push(id, tf);
118
+ }
119
+ this.docs.push(doc);
120
+ this.lengths.push(terms.length);
121
+ this.totalLength += terms.length;
122
+ return id;
123
+ }
124
+
125
+ /**
126
+ * Inverse document frequency of a term
127
+ * @param {string} term
128
+ * @returns {number}
129
+ */
130
+ idf(term) {
131
+ const n = (this.postings.get(term)?.length ?? 0) / 2;
132
+ return Math.log(1 + (this.docs.length - n + 0.5) / (n + 0.5));
133
+ }
134
+
135
+ /**
136
+ * Rank documents for a query
137
+ * @param {string|string[]} query - Query text, or terms
138
+ * @param {Object} [options]
139
+ * @param {number} [options.limit=10] - Maximum number of results
140
+ * @returns {Array<{doc: *, id: number, score: number}>} Best first
141
+ */
142
+ search(query, { limit = DEFAULT_LIMIT } = {}) {
143
+ const terms = [...new Set(Array.isArray(query) ? query : tokenize(query))];
144
+ if (this.docs.length === 0 || terms.length === 0) return [];
145
+
146
+ const avgLength = this.totalLength / this.docs.length || 1;
147
+ const scores = new Map();
148
+ for (const term of terms) {
149
+ const list = this.postings.get(term);
150
+ if (!list) continue;
151
+ const idf = this.idf(term);
152
+ for (let i = 0; i < list.length; i += 2) {
153
+ const id = list[i];
154
+ const tf = list[i + 1];
155
+ const norm = this.k1 * (1 - this.b + (this.b * this.lengths[id]) / avgLength);
156
+ scores.set(id, (scores.get(id) || 0) + (idf * tf * (this.k1 + 1)) / (tf + norm));
157
+ }
158
+ }
159
+
160
+ return [...scores]
161
+ .sort((a, b) => b[1] - a[1] || a[0] - b[0])
162
+ .slice(0, Math.max(0, limit))
163
+ .map(([id, score]) => ({ doc: this.docs[id], id, score }));
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Split a file into search chunks: its methods/functions, plus windows of at most
169
+ * `windowLines` lines over the code outside them (the whole file when it has none).
170
+ * Windows without a single term are dropped.
171
+ *
172
+ * @param {string} content - File content
173
+ * @param {string} filePath - Path used for the language (extension)
174
+ * @param {Object} [options]
175
+ * @param {number} [options.windowLines=40]
176
+ * @param {Array} [options.units] - Code units, when already extracted
177
+ * @returns {Array<{name: string|null, kind: string, parent?: string, startLine: number,
178
+ * endLine: number}>} Sorted by start line
179
+ */
180
+ export function chunkFile(content, filePath, { windowLines = WINDOW_LINES, units } = {}) {
181
+ const lines = content.split('\n');
182
+ // A trailing newline does not start another line
183
+ const lineCount = lines.length > 1 && lines.at(-1) === '' ? lines.length - 1 : lines.length;
184
+ const methods = (units ?? extractCodeUnits(content, filePath)).filter((u) => !u.container);
185
+
186
+ const chunks = methods.map((unit) => ({
187
+ name: unit.name,
188
+ kind: unit.kind,
189
+ ...(unit.parent ? { parent: unit.parent } : {}),
190
+ startLine: unit.startLine,
191
+ endLine: Math.min(unit.endLine, lineCount),
192
+ }));
193
+
194
+ const covered = new Uint8Array(lineCount + 2);
195
+ for (const chunk of chunks) covered.fill(1, chunk.startLine, chunk.endLine + 1);
196
+
197
+ let line = 1;
198
+ while (line <= lineCount) {
199
+ // Windows start and end on a non-blank line
200
+ if (covered[line] || !lines[line - 1].trim()) {
201
+ line++;
202
+ continue;
203
+ }
204
+ const start = line;
205
+ while (line <= lineCount && !covered[line] && line - start < windowLines) line++;
206
+ let end = line - 1;
207
+ while (end > start && !lines[end - 1].trim()) end--;
208
+ // Same test as tokenize() yielding a term: a word of two or more characters
209
+ if (/[\p{L}\p{N}]{2}/u.test(lines.slice(start - 1, end).join('\n'))) {
210
+ chunks.push({ name: null, kind: 'window', startLine: start, endLine: end });
211
+ }
212
+ }
213
+
214
+ return chunks.sort((a, b) => a.startLine - b.startLine || a.endLine - b.endLine);
215
+ }
216
+
217
+ /**
218
+ * Source text of a line range (1-based, inclusive)
219
+ * @param {string} content
220
+ * @param {number} startLine
221
+ * @param {number} endLine
222
+ * @returns {string}
223
+ */
224
+ export function sliceLines(content, startLine, endLine) {
225
+ return content
226
+ .split('\n')
227
+ .slice(startLine - 1, endLine)
228
+ .join('\n');
229
+ }
230
+
231
+ /**
232
+ * Code-point order, independent of the locale
233
+ * @param {string} a
234
+ * @param {string} b
235
+ * @returns {number}
236
+ */
237
+ export function compareStrings(a, b) {
238
+ if (a < b) return -1;
239
+ return a > b ? 1 : 0;
240
+ }
241
+
242
+ // root + options -> { signature, files: Map<relativePath, entry>, index }
243
+ const projectCache = new Map();
244
+
245
+ /** Forget every cached project index (tests, long-running servers after big changes) */
246
+ export function clearSearchCache() {
247
+ projectCache.clear();
248
+ }
249
+
250
+ /**
251
+ * Load a file entry: content, code units and chunks, reused while mtime and size match
252
+ * @private
253
+ */
254
+ function loadFile(file, previous, windowLines) {
255
+ let stat;
256
+ try {
257
+ stat = fs.statSync(file.path);
258
+ } catch (_error) {
259
+ return null;
260
+ }
261
+ const signature = `${stat.mtimeMs}:${stat.size}`;
262
+ if (previous && previous.signature === signature) return previous;
263
+
264
+ const entry = {
265
+ path: file.path,
266
+ relativePath: file.relativePath,
267
+ signature,
268
+ content: '',
269
+ units: [],
270
+ chunks: [],
271
+ };
272
+ const read = readFileChecked(file.path, { maxBytes: MAX_FILE_BYTES });
273
+ if (read.status === 'too-large') return { ...entry, skipped: 'too large' };
274
+ if (read.status !== 'ok') return null;
275
+ entry.content = read.buffer.toString('utf-8');
276
+ entry.units = extractCodeUnits(entry.content, file.path);
277
+ entry.chunks = chunkFile(entry.content, file.path, { windowLines, units: entry.units });
278
+ return entry;
279
+ }
280
+
281
+ /**
282
+ * The in-memory index of a project, built on first use and refreshed when files are added,
283
+ * removed or changed (by modification time and size). Files are found with the Scanner, so
284
+ * .gitignore, .contextignore and .contextinclude apply.
285
+ *
286
+ * @param {string} root - Project directory
287
+ * @param {Object} [options]
288
+ * @param {number} [options.windowLines=40]
289
+ * @returns {{root: string, files: Map<string, Object>, index: BM25Index, signature: string,
290
+ * reused: boolean}}
291
+ * @throws {Error} When root is not a directory
292
+ */
293
+ export function getProjectIndex(root, { windowLines = WINDOW_LINES } = {}) {
294
+ const resolvedRoot = path.resolve(root);
295
+ if (!fs.statSync(resolvedRoot, { throwIfNoEntry: false })?.isDirectory()) {
296
+ throw new Error(`Not a directory: ${root}`);
297
+ }
298
+ const key = `${resolvedRoot}\0${windowLines}`;
299
+ const cached = projectCache.get(key);
300
+
301
+ const scanned = new Scanner(resolvedRoot)
302
+ .scan()
303
+ .sort((a, b) => compareStrings(a.relativePath, b.relativePath));
304
+
305
+ const files = new Map();
306
+ const signatureParts = [];
307
+ for (const file of scanned) {
308
+ const entry = loadFile(file, cached?.files.get(file.relativePath), windowLines);
309
+ if (!entry) continue;
310
+ files.set(file.relativePath, entry);
311
+ signatureParts.push(`${file.relativePath}\0${entry.signature}`);
312
+ }
313
+ const signature = signatureParts.join('\n');
314
+
315
+ // Most recently used last, so the first key is the one to evict
316
+ projectCache.delete(key);
317
+ if (cached && cached.signature === signature) {
318
+ projectCache.set(key, cached);
319
+ return { ...cached, reused: true };
320
+ }
321
+
322
+ const index = new BM25Index();
323
+ for (const entry of files.values()) {
324
+ const pathTerms = tokenize(entry.relativePath);
325
+ const lines = entry.content.split('\n');
326
+ for (const chunk of entry.chunks) {
327
+ // Index the redacted text, so the ranking cannot be used to probe secret values
328
+ const text = redactSecrets(lines.slice(chunk.startLine - 1, chunk.endLine).join('\n')).text;
329
+ const nameTerms = chunk.name ? tokenize(chunk.name) : [];
330
+ // The name counts three times and the file path once on top of the text
331
+ index.add({ entry, chunk }, [
332
+ ...tokenize(text),
333
+ ...nameTerms,
334
+ ...nameTerms,
335
+ ...nameTerms,
336
+ ...pathTerms,
337
+ ]);
338
+ }
339
+ }
340
+
341
+ const project = { root: resolvedRoot, files, index, signature };
342
+ projectCache.set(key, project);
343
+ while (projectCache.size > MAX_CACHED_PROJECTS) {
344
+ projectCache.delete(projectCache.keys().next().value);
345
+ }
346
+ return { ...project, reused: false };
347
+ }
348
+
349
+ /**
350
+ * Cut text to its first lines that fit in a token budget
351
+ * @param {string} text
352
+ * @param {number} budget - Tokens
353
+ * @param {string} filePath - For the token estimate when tiktoken is missing
354
+ * @returns {string} The fitting prefix ('' when not even one line fits)
355
+ */
356
+ export function fitToTokenBudget(text, budget, filePath) {
357
+ const lines = text.split('\n');
358
+ let low = 0;
359
+ let high = lines.length;
360
+ // Largest line count whose text fits
361
+ while (low < high) {
362
+ const mid = (low + high + 1) >> 1;
363
+ if (TokenUtils.calculate(lines.slice(0, mid).join('\n'), filePath) <= budget) low = mid;
364
+ else high = mid - 1;
365
+ }
366
+ return lines.slice(0, low).join('\n');
367
+ }
368
+
369
+ /**
370
+ * Ranked search over a project.
371
+ *
372
+ * @param {string} root - Project directory
373
+ * @param {string} query - Words or identifiers to look for
374
+ * @param {Object} [options]
375
+ * @param {number} [options.limit=10] - Maximum number of chunks
376
+ * @param {number} [options.maxTokens=4000] - Token budget for the chunk texts; chunks past
377
+ * it are cut (`truncated: true`) or listed without text
378
+ * @param {boolean} [options.redact=true] - Redact secrets in the returned text
379
+ * @param {number} [options.windowLines=40] - Window size for code outside methods
380
+ * @returns {{query: string, root: string, files: number, chunks: number, tokens: number,
381
+ * results: Array<{file: string, name: string|null, kind: string, parent?: string,
382
+ * startLine: number, endLine: number, score: number, tokens: number, text: string|null,
383
+ * truncated?: boolean}>}}
384
+ */
385
+ export function searchProject(
386
+ root,
387
+ query,
388
+ {
389
+ limit = DEFAULT_LIMIT,
390
+ maxTokens = DEFAULT_MAX_TOKENS,
391
+ redact = true,
392
+ windowLines = WINDOW_LINES,
393
+ } = {}
394
+ ) {
395
+ if (typeof query !== 'string' || !query.trim()) {
396
+ throw new Error('The search query is empty');
397
+ }
398
+ const project = getProjectIndex(root, { windowLines });
399
+ const hits = project.index.search(query, { limit });
400
+
401
+ let used = 0;
402
+ const results = hits.map(({ doc: { entry, chunk }, score }) => {
403
+ const raw = sliceLines(entry.content, chunk.startLine, chunk.endLine);
404
+ let text = redact ? redactSecrets(raw).text : raw;
405
+ let tokens = TokenUtils.calculate(text, entry.path);
406
+ const result = {
407
+ file: entry.relativePath,
408
+ name: chunk.name,
409
+ kind: chunk.kind,
410
+ ...(chunk.parent ? { parent: chunk.parent } : {}),
411
+ startLine: chunk.startLine,
412
+ endLine: chunk.endLine,
413
+ score: Math.round(score * 10000) / 10000,
414
+ };
415
+
416
+ if (used + tokens > maxTokens) {
417
+ text = fitToTokenBudget(text, maxTokens - used, entry.path);
418
+ tokens = text ? TokenUtils.calculate(text, entry.path) : 0;
419
+ result.truncated = true;
420
+ }
421
+ used += tokens;
422
+ return { ...result, tokens, text: text || null };
423
+ });
424
+
425
+ return {
426
+ query,
427
+ root: project.root,
428
+ files: project.files.size,
429
+ chunks: project.index.size,
430
+ tokens: used,
431
+ results,
432
+ };
433
+ }