@codragraph/cli 2.1.4 → 2.1.6

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 (109) hide show
  1. package/README.md +36 -7
  2. package/dist/cli/ai-context.js +297 -0
  3. package/dist/cli/analyze.d.ts +9 -4
  4. package/dist/cli/analyze.js +37 -13
  5. package/dist/cli/index.js +40 -14
  6. package/dist/cli/status.d.ts +1 -1
  7. package/dist/cli/status.js +8 -0
  8. package/dist/cli/tool.d.ts +10 -2
  9. package/dist/cli/tool.js +100 -39
  10. package/dist/config/ignore-service.js +1 -0
  11. package/dist/core/adaptive-profile.d.ts +52 -0
  12. package/dist/core/adaptive-profile.js +180 -0
  13. package/dist/core/cgdb/cgdb-adapter.d.ts +34 -5
  14. package/dist/core/cgdb/cgdb-adapter.js +418 -5
  15. package/dist/core/cgdb/pool-adapter.js +130 -20
  16. package/dist/core/ingestion/parsing-processor.js +7 -1
  17. package/dist/core/ingestion/pipeline-phases/parse-impl.js +7 -1
  18. package/dist/core/ingestion/pipeline-phases/structure.js +19 -3
  19. package/dist/core/ingestion/pipeline.d.ts +10 -0
  20. package/dist/core/ingestion/workers/parse-worker.js +1 -1
  21. package/dist/core/ingestion/workers/worker-pool.d.ts +14 -1
  22. package/dist/core/ingestion/workers/worker-pool.js +33 -17
  23. package/dist/core/run-analyze.d.ts +27 -2
  24. package/dist/core/run-analyze.js +626 -32
  25. package/dist/core/search/bm25-index.d.ts +16 -8
  26. package/dist/core/search/bm25-index.js +72 -110
  27. package/dist/mcp/local/local-backend.d.ts +2 -0
  28. package/dist/mcp/local/local-backend.js +241 -21
  29. package/dist/storage/repo-manager.d.ts +29 -0
  30. package/dist/web/assets/__vite-browser-external-BIHI7g3E.js +1 -0
  31. package/dist/web/assets/agent-DcdaQnmu.js +1104 -0
  32. package/dist/web/assets/architectureDiagram-UL44E2DR-DFSpa3Hb.js +36 -0
  33. package/dist/web/assets/blockDiagram-7IZFK4PR-DlFaxH1b.js +132 -0
  34. package/dist/web/assets/{c4Diagram-DFAF54RM-C4Hl3J2U.js → c4Diagram-Y2BXMSZH-BjJ_Yrim.js} +1 -1
  35. package/dist/web/assets/{chunk-7RZVMHOQ-BitYcNVR.js → chunk-3SSMPTDK-KGZSzG3Y.js} +1 -1
  36. package/dist/web/assets/{chunk-TBF5ZNIQ-DL5stGM1.js → chunk-6764PJDD-p1sGJgVm.js} +1 -1
  37. package/dist/web/assets/{chunk-KSICW3F5-BYzvDLNI.js → chunk-AZZRMDJM-DIDkQA4V.js} +1 -1
  38. package/dist/web/assets/{chunk-AEOMTBSW-BgTIXPsY.js → chunk-JQRUD6KW-DAwg-yCU.js} +1 -1
  39. package/dist/web/assets/chunk-KRXBNO2N-ChVO_XdS.js +1 -0
  40. package/dist/web/assets/chunk-LCXTWHL2-DGYdb_Eh.js +231 -0
  41. package/dist/web/assets/{chunk-O5ABG6QK-dHwHzA6n.js → chunk-LII3EMHJ-Bzh9SNgD.js} +1 -1
  42. package/dist/web/assets/chunk-RG4AUYOV-Bcl7U_IV.js +206 -0
  43. package/dist/web/assets/{chunk-TU3PZOEN-RLyvLcv-.js → chunk-T5OCTHI4-CZYMg5sc.js} +1 -1
  44. package/dist/web/assets/chunk-W44A43WB-REOI67PN.js +13 -0
  45. package/dist/web/assets/{chunk-RWUO3TPN-BgRTY0_k.js → chunk-ZXARS5L4-BfFdV1tf.js} +1 -1
  46. package/dist/web/assets/classDiagram-KGZ6W3CR-B-qkKMYi.js +1 -0
  47. package/dist/web/assets/classDiagram-v2-72OJOZXJ-B-qkKMYi.js +1 -0
  48. package/dist/web/assets/{cose-bilkent-PNC4W37J-DVhePRYg.js → cose-bilkent-UX7MHV2Q-D6vANJGG.js} +1 -1
  49. package/dist/web/assets/dagre-ND4H6XIP-BiHe5Lal.js +4 -0
  50. package/dist/web/assets/diagram-3NCE3AQN-CEutBCOW.js +43 -0
  51. package/dist/web/assets/diagram-GF46GFSD-CZns6HPQ.js +24 -0
  52. package/dist/web/assets/diagram-HNR7UZ2L-Vz8fE5of.js +3 -0
  53. package/dist/web/assets/diagram-QXG6HAR7-D60HKZ_y.js +24 -0
  54. package/dist/web/assets/diagram-WEQXMOUZ-vGAf1p3E.js +10 -0
  55. package/dist/web/assets/{erDiagram-GCSMX5X6-C3dhDFA8.js → erDiagram-L5TCEMPS-DZaplJA6.js} +5 -5
  56. package/dist/web/assets/{flowDiagram-OTCZ4VVT-CWSFWmhr.js → flowDiagram-H6V6AXG4-BqUqeAsI.js} +9 -9
  57. package/dist/web/assets/ganttDiagram-JCBTUEKG-XEB6H-0G.js +292 -0
  58. package/dist/web/assets/gitGraphDiagram-S2ZK5IYY-7G50u1Cd.js +106 -0
  59. package/dist/web/assets/index-B5WxtMpv.js +1415 -0
  60. package/dist/web/assets/infoDiagram-3YFTVSEB-Cut_rzaf.js +2 -0
  61. package/dist/web/assets/{ishikawaDiagram-YMYX4NHK-DUoJvNP2.js → ishikawaDiagram-BNXS4ZKH-B4DGfGi3.js} +3 -3
  62. package/dist/web/assets/{journeyDiagram-SO5T7YLQ-RMFPNNqz.js → journeyDiagram-M6C3CM3L-BBFhsL3E.js} +1 -1
  63. package/dist/web/assets/{kanban-definition-LJHFXRCJ-BzpDs1K9.js → kanban-definition-75IXJCU3-DarGRyn3.js} +4 -4
  64. package/dist/web/assets/{katex-GD7MH7QM-DBQvrix-.js → katex-K3KEBU37-W5XTYMhr.js} +1 -1
  65. package/dist/web/assets/mindmap-definition-2TDM6QVE-BgeczIJM.js +96 -0
  66. package/dist/web/assets/pieDiagram-CU6KROY3-Kkoo-Noq.js +30 -0
  67. package/dist/web/assets/quadrantDiagram-VICAPDV7-CDQFeRWN.js +7 -0
  68. package/dist/web/assets/{requirementDiagram-M5DCFWZL-DLHOVTSv.js → requirementDiagram-JXO7QTGE-Cz9-XnkA.js} +2 -2
  69. package/dist/web/assets/sankeyDiagram-URQDO5SZ-CU26z0n7.js +40 -0
  70. package/dist/web/assets/sequenceDiagram-VS2MUI6T-OGK1FLOt.js +162 -0
  71. package/dist/web/assets/stateDiagram-7D4R322I-DJ9brq0U.js +1 -0
  72. package/dist/web/assets/stateDiagram-v2-36443NZ5-DhJ4Ky-7.js +1 -0
  73. package/dist/web/assets/{timeline-definition-5SPVSISX-TRSDRgPw.js → timeline-definition-O6YCAMPW-XZvnjqTT.js} +4 -4
  74. package/dist/web/assets/{vennDiagram-IE5QUKF5-DNy7HRBM.js → vennDiagram-MWXL3ELB-CJUssEjA.js} +6 -6
  75. package/dist/web/assets/wardley-L42UT6IY-5TKZOOLJ-DZr11zBG.js +173 -0
  76. package/dist/web/assets/wardleyDiagram-CUQ6CDDI-C276iqrN.js +78 -0
  77. package/dist/web/assets/{xychartDiagram-ZHJ5623Y-Dr9r7a35.js → xychartDiagram-N2JHSOCM-B9-uCZyP.js} +4 -4
  78. package/dist/web/index.html +1 -1
  79. package/hooks/claude/codragraph-hook.cjs +15 -122
  80. package/package.json +1 -1
  81. package/vendor/node_modules/node-addon-api/node_addon_api_except.stamp +0 -0
  82. package/dist/web/assets/agent-D5lb0zXz.js +0 -1089
  83. package/dist/web/assets/architectureDiagram-EMZXCZ2Q-CZtc99v_.js +0 -36
  84. package/dist/web/assets/blockDiagram-IGV67L2C-BtoUp-6Y.js +0 -132
  85. package/dist/web/assets/chunk-3GS5O3IE-DkUjU0WD.js +0 -231
  86. package/dist/web/assets/chunk-3YCYZ6SJ-CQkVgT_z.js +0 -1
  87. package/dist/web/assets/chunk-H3VCZNTA-Cx5XV_aC.js +0 -13
  88. package/dist/web/assets/chunk-HN6EAY2L-BBnyTNdB.js +0 -1
  89. package/dist/web/assets/chunk-PK6DOVAG-CvsEnugt.js +0 -206
  90. package/dist/web/assets/classDiagram-PPOCWD7C-DTr8QIOf.js +0 -1
  91. package/dist/web/assets/classDiagram-v2-23LJLIIU-DTr8QIOf.js +0 -1
  92. package/dist/web/assets/dagre-E77IOHMT-Dzx0A6ZU.js +0 -4
  93. package/dist/web/assets/diagram-H7BISOXX-CC9pRew1.js +0 -43
  94. package/dist/web/assets/diagram-JC5VWROH-Bau_i9tf.js +0 -24
  95. package/dist/web/assets/diagram-LXUTUG65-D9_FM2Gt.js +0 -10
  96. package/dist/web/assets/diagram-WEHSV5V5-BMlayouL.js +0 -24
  97. package/dist/web/assets/ganttDiagram-MUNLMDZQ-D3a67Yol.js +0 -292
  98. package/dist/web/assets/gitGraphDiagram-3HKGZ4G3-7jmry-vM.js +0 -106
  99. package/dist/web/assets/index-BgeqpYgd.js +0 -1415
  100. package/dist/web/assets/infoDiagram-MN7RKWGX-G7lhP0Ib.js +0 -2
  101. package/dist/web/assets/mindmap-definition-2EUWGEK5-Bk0O4roa.js +0 -96
  102. package/dist/web/assets/pieDiagram-3IATQBI2-DKU7kpgS.js +0 -30
  103. package/dist/web/assets/quadrantDiagram-E256RVCF-BY0TGWCS.js +0 -7
  104. package/dist/web/assets/sankeyDiagram-L3NBLAOT-DVMj5rX2.js +0 -10
  105. package/dist/web/assets/sequenceDiagram-ZOUHS735-CJC73bV-.js +0 -157
  106. package/dist/web/assets/stateDiagram-MLPALWAM-BCFyESls.js +0 -1
  107. package/dist/web/assets/stateDiagram-v2-B5LQ5ZB2-DahzzIca.js +0 -1
  108. package/dist/web/assets/wardley-RL74JXVD-BCRCBASE-B-eZEzf9.js +0 -161
  109. package/dist/web/assets/wardleyDiagram-XU3VSMPF-BP-r1xzR.js +0 -20
@@ -15,7 +15,7 @@ import * as fsSync from 'node:fs';
15
15
  import * as v8 from 'node:v8';
16
16
  import { getLanguageFromFilename } from '../_shared/index.js';
17
17
  import { runPipelineFromRepo } from './ingestion/pipeline.js';
18
- import { initCgdb, loadGraphToCgdb, getCgdbStats, executeQuery, executeWithReusedStatement, closeCgdb, loadCachedEmbeddings, } from './cgdb/cgdb-adapter.js';
18
+ import { initCgdb, loadGraphToCgdb, getCgdbStats, executeQuery, executeWithReusedStatement, closeCgdb, loadCachedEmbeddings, ensureFTSIndex, applyFileGraphPatchToCgdb, replaceFileScopedGraphInCgdb, replaceGlobalGraphLayersInCgdb, loadKnowledgeGraphFromCgdb, fetchExistingEmbeddingHashes, } from './cgdb/cgdb-adapter.js';
19
19
  import { getStoragePaths, saveMeta, loadMeta, addToGitignore, registerRepo, cleanupOldKuzuFiles, INDEX_SCHEMA_VERSION, } from '../storage/repo-manager.js';
20
20
  import { getCurrentCommit, getRemoteUrl, hasGitDir, getInferredRepoName } from '../storage/git.js';
21
21
  import { shouldIgnorePath } from '../config/ignore-service.js';
@@ -23,8 +23,13 @@ import { recordAnalysisSnapshot } from './graphstore/index.js';
23
23
  import { generateAIContextFiles } from '../cli/ai-context.js';
24
24
  import { EMBEDDING_TABLE_NAME } from './cgdb/schema.js';
25
25
  import { STALE_HASH_SENTINEL } from './cgdb/schema.js';
26
- /** Threshold: auto-skip embeddings for repos with more nodes than this */
27
- const EMBEDDING_NODE_LIMIT = 50_000;
26
+ import { FTS_TABLES, ftsPropertiesFor } from './search/bm25-index.js';
27
+ import { processCommunities, } from './ingestion/community-processor.js';
28
+ import { processProcesses } from './ingestion/process-processor.js';
29
+ import { processFeatureClusters, } from './ingestion/feature-cluster-processor.js';
30
+ import { createKnowledgeGraph } from './graph/graph.js';
31
+ import { generateId } from '../lib/utils.js';
32
+ import { decideEmbeddingRun, formatAdaptiveAnalyzePlan, resolveAdaptiveAnalyzePlan, } from './adaptive-profile.js';
28
33
  const GENERATED_AGENT_CONTEXT_PATHS = new Set(['agents.md', 'claude.md']);
29
34
  const GENERATED_AGENT_CONTEXT_PREFIXES = [
30
35
  '.claude/skills/generated/',
@@ -56,6 +61,14 @@ const GRAPH_CONFIG_BASENAMES = new Set([
56
61
  ]);
57
62
  const GRAPH_CONFIG_PATTERNS = [/^tsconfig\..+\.json$/i, /^jsconfig\..+\.json$/i];
58
63
  const MARKDOWN_EXTENSIONS = new Set(['.md', '.mdx']);
64
+ const GLOBAL_LAYER_NODE_LABELS = new Set(['Community', 'Process', 'FeatureCluster']);
65
+ const GLOBAL_LAYER_REL_TYPES = new Set([
66
+ 'MEMBER_OF',
67
+ 'STEP_IN_PROCESS',
68
+ 'ENTRY_POINT_OF',
69
+ 'FEATURE_MEMBER_OF',
70
+ 'FEATURE_DEPENDS_ON',
71
+ ]);
59
72
  export const PHASE_LABELS = {
60
73
  extracting: 'Scanning files',
61
74
  structure: 'Building structure',
@@ -144,8 +157,10 @@ export const changedPathAffectsGraph = (change) => {
144
157
  const paths = [change.path, change.previousPath].filter((p) => Boolean(p));
145
158
  if (paths.some(isGraphContentPath))
146
159
  return true;
147
- // Add/delete/rename/copy can change File/Folder structure even when content
148
- // is not parsed. Ignored or generated-agent paths are outside the index.
160
+ // Add/delete/rename/copy affect the graph's File/Folder topology even when
161
+ // the path is not source code. Ignore only generated agent context and
162
+ // configured ignored paths; staying conservative here prevents stale file
163
+ // and documentation surfaces after path-only commits.
149
164
  if (statusCode === 'A' || statusCode === 'D' || statusCode === 'R' || statusCode === 'C') {
150
165
  return paths.some((p) => !isGeneratedAgentContextPath(p) && !shouldIgnorePath(p));
151
166
  }
@@ -157,11 +172,119 @@ export const changedPathAffectsGraph = (change) => {
157
172
  return true;
158
173
  };
159
174
  export const getGraphRelevantChangedPaths = (changes) => changes.filter(changedPathAffectsGraph);
175
+ export const isPatchableIncrementalPath = (filePath) => {
176
+ const normalized = normalizeGitPath(filePath);
177
+ if (isGeneratedAgentContextPath(normalized) || shouldIgnorePath(normalized))
178
+ return false;
179
+ if (getLanguageFromFilename(normalized) !== null)
180
+ return true;
181
+ return MARKDOWN_EXTENSIONS.has(path.posix.extname(normalized.toLowerCase()));
182
+ };
183
+ const isTopologyPatchablePath = (filePath) => {
184
+ const normalized = normalizeGitPath(filePath);
185
+ return !isGeneratedAgentContextPath(normalized) && !shouldIgnorePath(normalized);
186
+ };
187
+ const isGlobalGraphInputPath = (filePath) => {
188
+ const normalized = normalizeGitPath(filePath);
189
+ const basename = path.posix.basename(normalized);
190
+ const lowerBasename = basename.toLowerCase();
191
+ return (IGNORE_CONTROL_FILES.has(lowerBasename) ||
192
+ GRAPH_CONFIG_BASENAMES.has(lowerBasename) ||
193
+ GRAPH_CONFIG_PATTERNS.some((pattern) => pattern.test(basename)));
194
+ };
195
+ export const buildIncrementalFilePatchPlan = (changes, _options = {}) => {
196
+ if (changes.length === 0) {
197
+ return {
198
+ eligible: false,
199
+ reason: 'no indexed graph input changes',
200
+ replacePaths: [],
201
+ currentPaths: [],
202
+ fileCountDelta: 0,
203
+ replaceAllFileScoped: false,
204
+ pathAliases: {},
205
+ };
206
+ }
207
+ const replacePaths = new Set();
208
+ const currentPaths = new Set();
209
+ const pathAliases = {};
210
+ let fileCountDelta = 0;
211
+ let replaceAllFileScoped = false;
212
+ const reasons = new Set();
213
+ for (const change of changes) {
214
+ const statusCode = change.status[0]?.toUpperCase();
215
+ const paths = statusCode === 'C'
216
+ ? [change.path]
217
+ : [change.path, change.previousPath].filter((p) => Boolean(p));
218
+ if (paths.some(isGlobalGraphInputPath)) {
219
+ replaceAllFileScoped = true;
220
+ reasons.add('global config/input changed');
221
+ continue;
222
+ }
223
+ const contentPatchable = paths.every(isPatchableIncrementalPath);
224
+ const topologyPatchable = paths.every(isTopologyPatchablePath);
225
+ if (!contentPatchable && !topologyPatchable) {
226
+ return {
227
+ eligible: false,
228
+ reason: `change ${change.status} ${formatChangeForLog(change)} touches a global or unsupported graph input`,
229
+ replacePaths: [],
230
+ currentPaths: [],
231
+ fileCountDelta: 0,
232
+ replaceAllFileScoped: false,
233
+ pathAliases: {},
234
+ };
235
+ }
236
+ if (statusCode === 'D') {
237
+ replacePaths.add(change.path);
238
+ fileCountDelta -= 1;
239
+ continue;
240
+ }
241
+ if (statusCode === 'R') {
242
+ if (change.previousPath) {
243
+ replacePaths.add(change.previousPath);
244
+ pathAliases[change.previousPath] = change.path;
245
+ }
246
+ replacePaths.add(change.path);
247
+ currentPaths.add(change.path);
248
+ reasons.add('rename remapped');
249
+ continue;
250
+ }
251
+ if (statusCode === 'M' || statusCode === 'A' || statusCode === 'T' || statusCode === 'C') {
252
+ replacePaths.add(change.path);
253
+ currentPaths.add(change.path);
254
+ if (statusCode === 'A' || statusCode === 'C') {
255
+ fileCountDelta += 1;
256
+ }
257
+ if (!contentPatchable) {
258
+ reasons.add('topology-only file change');
259
+ }
260
+ continue;
261
+ }
262
+ replaceAllFileScoped = true;
263
+ reasons.add(`unsupported git status ${change.status}`);
264
+ }
265
+ const strategy = replaceAllFileScoped
266
+ ? 'all file-scoped graph rows will be refreshed'
267
+ : `${replacePaths.size} file path(s) will be patched`;
268
+ if (changes.length > 20)
269
+ reasons.add(`${changes.length} graph input changes`);
270
+ return {
271
+ eligible: true,
272
+ reason: [...reasons, strategy].filter(Boolean).join('; '),
273
+ replacePaths: [...replacePaths].sort(),
274
+ currentPaths: [...currentPaths].sort(),
275
+ fileCountDelta,
276
+ replaceAllFileScoped,
277
+ pathAliases,
278
+ };
279
+ };
160
280
  export const getAnalyzeConfigRebuildReason = (existingMeta, options) => {
161
281
  const existingCompress = existingMeta.compress ?? 'none';
162
282
  if (options.compress && options.compress !== existingCompress) {
163
283
  return `requested compression changed from ${existingCompress} to ${options.compress}`;
164
284
  }
285
+ if (existingMeta.searchIndexes?.fts !== true) {
286
+ return 'search indexes are missing';
287
+ }
165
288
  if (options.embeddings && (existingMeta.stats?.embeddings ?? 0) === 0) {
166
289
  return 'embeddings were requested but the existing index has no vectors';
167
290
  }
@@ -176,6 +299,39 @@ const buildReusedMeta = (existingMeta, repoPath, currentCommit) => ({
176
299
  schemaVersion: INDEX_SCHEMA_VERSION,
177
300
  remoteUrl: hasGitDir(repoPath) ? getRemoteUrl(repoPath) : existingMeta.remoteUrl,
178
301
  });
302
+ const metaStatsForAIContext = (stats = {}) => ({
303
+ files: stats.files,
304
+ nodes: stats.nodes,
305
+ edges: stats.edges,
306
+ communities: stats.communities,
307
+ clusters: stats.featureClusters,
308
+ processes: stats.processes,
309
+ });
310
+ const buildAdaptiveProfileMeta = (adaptivePlan, embeddingDecision) => ({
311
+ requested: adaptivePlan.requestedProfile,
312
+ resolved: adaptivePlan.profile,
313
+ platform: adaptivePlan.machine.platform,
314
+ arch: adaptivePlan.machine.arch,
315
+ cpuCount: adaptivePlan.machine.availableParallelism,
316
+ totalMemoryBytes: adaptivePlan.machine.totalMemoryBytes,
317
+ heapLimitBytes: adaptivePlan.machine.heapLimitBytes,
318
+ compression: adaptivePlan.compress,
319
+ embeddingMode: adaptivePlan.embeddingMode,
320
+ embeddingNodeLimit: adaptivePlan.embeddingNodeLimit,
321
+ embeddingDecision: embeddingDecision.enabled ? 'enabled' : 'skipped',
322
+ embeddingReason: embeddingDecision.reason,
323
+ workerPoolSize: adaptivePlan.workerPoolSize,
324
+ workerSubBatchSize: adaptivePlan.workerSubBatchSize,
325
+ });
326
+ const countEmbeddings = async () => {
327
+ try {
328
+ const embResult = await executeQuery(`MATCH (e:${EMBEDDING_TABLE_NAME}) RETURN count(e) AS cnt`);
329
+ return Number(embResult?.[0]?.cnt ?? embResult?.[0]?.[0] ?? 0);
330
+ }
331
+ catch {
332
+ return 0;
333
+ }
334
+ };
179
335
  const pathExists = async (targetPath) => {
180
336
  try {
181
337
  await fs.stat(targetPath);
@@ -185,6 +341,387 @@ const pathExists = async (targetPath) => {
185
341
  return false;
186
342
  }
187
343
  };
344
+ const addCommunityLayerToGraph = (graph, communityResult) => {
345
+ communityResult.communities.forEach((comm) => {
346
+ graph.addNode({
347
+ id: comm.id,
348
+ label: 'Community',
349
+ properties: {
350
+ name: comm.label,
351
+ filePath: '',
352
+ heuristicLabel: comm.heuristicLabel,
353
+ cohesion: comm.cohesion,
354
+ symbolCount: comm.symbolCount,
355
+ },
356
+ });
357
+ });
358
+ communityResult.memberships.forEach((membership) => {
359
+ graph.addRelationship({
360
+ id: `${membership.nodeId}_member_of_${membership.communityId}`,
361
+ type: 'MEMBER_OF',
362
+ sourceId: membership.nodeId,
363
+ targetId: membership.communityId,
364
+ confidence: 1.0,
365
+ reason: 'leiden-algorithm',
366
+ });
367
+ });
368
+ };
369
+ const addProcessLayerToGraph = (graph, processResult) => {
370
+ processResult.processes.forEach((proc) => {
371
+ graph.addNode({
372
+ id: proc.id,
373
+ label: 'Process',
374
+ properties: {
375
+ name: proc.label,
376
+ filePath: '',
377
+ heuristicLabel: proc.heuristicLabel,
378
+ processType: proc.processType,
379
+ stepCount: proc.stepCount,
380
+ communities: proc.communities,
381
+ entryPointId: proc.entryPointId,
382
+ terminalId: proc.terminalId,
383
+ },
384
+ });
385
+ });
386
+ processResult.steps.forEach((step) => {
387
+ graph.addRelationship({
388
+ id: `${step.nodeId}_step_${step.step}_${step.processId}`,
389
+ type: 'STEP_IN_PROCESS',
390
+ sourceId: step.nodeId,
391
+ targetId: step.processId,
392
+ confidence: 1.0,
393
+ reason: 'trace-detection',
394
+ step: step.step,
395
+ });
396
+ });
397
+ };
398
+ const addRouteToolProcessLinks = (graph, processResult) => {
399
+ const routesByFile = new Map();
400
+ const toolsByFile = new Map();
401
+ for (const node of graph.iterNodes()) {
402
+ const filePath = typeof node.properties?.filePath === 'string' ? node.properties.filePath : undefined;
403
+ const name = typeof node.properties?.name === 'string' ? node.properties.name : undefined;
404
+ if (!filePath || !name)
405
+ continue;
406
+ if (node.label === 'Route') {
407
+ let routes = routesByFile.get(filePath);
408
+ if (!routes) {
409
+ routes = [];
410
+ routesByFile.set(filePath, routes);
411
+ }
412
+ routes.push(name);
413
+ }
414
+ else if (node.label === 'Tool') {
415
+ let tools = toolsByFile.get(filePath);
416
+ if (!tools) {
417
+ tools = [];
418
+ toolsByFile.set(filePath, tools);
419
+ }
420
+ tools.push(name);
421
+ }
422
+ }
423
+ if (routesByFile.size === 0 && toolsByFile.size === 0)
424
+ return;
425
+ for (const proc of processResult.processes) {
426
+ if (!proc.entryPointId)
427
+ continue;
428
+ const entryNode = graph.getNode(proc.entryPointId);
429
+ const entryFile = typeof entryNode?.properties?.filePath === 'string' ? entryNode.properties.filePath : '';
430
+ if (!entryFile)
431
+ continue;
432
+ for (const routeURL of routesByFile.get(entryFile) ?? []) {
433
+ const routeNodeId = generateId('Route', routeURL);
434
+ graph.addRelationship({
435
+ id: generateId('ENTRY_POINT_OF', `${routeNodeId}->${proc.id}`),
436
+ sourceId: routeNodeId,
437
+ targetId: proc.id,
438
+ type: 'ENTRY_POINT_OF',
439
+ confidence: 0.85,
440
+ reason: 'route-handler-entry-point',
441
+ });
442
+ }
443
+ for (const toolName of toolsByFile.get(entryFile) ?? []) {
444
+ const toolNodeId = generateId('Tool', toolName);
445
+ graph.addRelationship({
446
+ id: generateId('ENTRY_POINT_OF', `${toolNodeId}->${proc.id}`),
447
+ sourceId: toolNodeId,
448
+ targetId: proc.id,
449
+ type: 'ENTRY_POINT_OF',
450
+ confidence: 0.85,
451
+ reason: 'tool-handler-entry-point',
452
+ });
453
+ }
454
+ }
455
+ };
456
+ const addFeatureClusterLayerToGraph = (graph, featureClusterResult) => {
457
+ featureClusterResult.clusters.forEach((cluster) => {
458
+ graph.addNode({
459
+ id: cluster.id,
460
+ label: 'FeatureCluster',
461
+ properties: {
462
+ name: cluster.name,
463
+ filePath: '',
464
+ slug: cluster.slug,
465
+ featureKind: cluster.featureKind,
466
+ summary: cluster.summary,
467
+ description: cluster.description,
468
+ repo: cluster.repo,
469
+ service: cluster.service,
470
+ signals: cluster.signals,
471
+ memberCount: cluster.memberCount,
472
+ entryPointIds: cluster.entryPointIds,
473
+ routes: cluster.routes,
474
+ tools: cluster.tools,
475
+ testCoverageHints: cluster.testCoverageHints,
476
+ lastIndexedCommit: cluster.lastIndexedCommit,
477
+ confidence: cluster.confidence,
478
+ source: 'heuristic',
479
+ },
480
+ });
481
+ });
482
+ featureClusterResult.memberships.forEach((membership) => {
483
+ graph.addRelationship({
484
+ id: generateId('FEATURE_MEMBER_OF', `${membership.nodeId}->${membership.clusterId}`),
485
+ sourceId: membership.nodeId,
486
+ targetId: membership.clusterId,
487
+ type: 'FEATURE_MEMBER_OF',
488
+ confidence: membership.confidence,
489
+ reason: membership.signals.join('|'),
490
+ });
491
+ });
492
+ featureClusterResult.dependencies.forEach((dependency) => {
493
+ graph.addRelationship({
494
+ id: generateId('FEATURE_DEPENDS_ON', `${dependency.sourceClusterId}->${dependency.targetClusterId}`),
495
+ sourceId: dependency.sourceClusterId,
496
+ targetId: dependency.targetClusterId,
497
+ type: 'FEATURE_DEPENDS_ON',
498
+ confidence: dependency.confidence,
499
+ reason: `member-dependency|edges:${dependency.edgeCount}|types:${dependency.relationshipTypes.join(',')}`,
500
+ });
501
+ });
502
+ };
503
+ const extractGlobalLayerGraph = (graph) => {
504
+ const globalGraph = createKnowledgeGraph();
505
+ const globalNodeIds = new Set();
506
+ for (const node of graph.iterNodes()) {
507
+ if (GLOBAL_LAYER_NODE_LABELS.has(node.label)) {
508
+ globalNodeIds.add(node.id);
509
+ globalGraph.addNode(node);
510
+ }
511
+ }
512
+ for (const rel of graph.iterRelationships()) {
513
+ if (GLOBAL_LAYER_REL_TYPES.has(rel.type) ||
514
+ globalNodeIds.has(rel.sourceId) ||
515
+ globalNodeIds.has(rel.targetId)) {
516
+ globalGraph.addRelationship(rel);
517
+ }
518
+ }
519
+ return globalGraph;
520
+ };
521
+ const scaleAnalyzerProgress = (start, span, progress) => {
522
+ const normalized = Math.max(0, Math.min(100, progress)) / 100;
523
+ return Math.round(start + normalized * span);
524
+ };
525
+ const recomputeGlobalGraphLayers = async (input) => {
526
+ const { repoPath, storagePath, currentCommit, repoNameForFeatureClusters, compress, progress } = input;
527
+ progress('communities', 82, 'Loading patched graph for global recompute...');
528
+ const graph = await loadKnowledgeGraphFromCgdb({ includeGlobal: false });
529
+ const communityResult = await processCommunities(graph, (message, phaseProgress) => {
530
+ progress('communities', scaleAnalyzerProgress(83, 2, phaseProgress), message);
531
+ });
532
+ addCommunityLayerToGraph(graph, communityResult);
533
+ let symbolCount = 0;
534
+ graph.forEachNode((n) => {
535
+ if (n.label !== 'File')
536
+ symbolCount++;
537
+ });
538
+ const dynamicMaxProcesses = Math.max(20, Math.min(300, Math.round(symbolCount / 10)));
539
+ const processResult = await processProcesses(graph, communityResult.memberships, (message, phaseProgress) => {
540
+ progress('processes', scaleAnalyzerProgress(85, 2, phaseProgress), message);
541
+ }, { maxProcesses: dynamicMaxProcesses, minSteps: 3 });
542
+ addProcessLayerToGraph(graph, processResult);
543
+ addRouteToolProcessLinks(graph, processResult);
544
+ const featureClusterResult = await processFeatureClusters(graph, (message, phaseProgress) => {
545
+ progress('feature_clusters', scaleAnalyzerProgress(87, 1, phaseProgress), message);
546
+ }, {
547
+ repo: repoNameForFeatureClusters,
548
+ lastIndexedCommit: currentCommit || undefined,
549
+ });
550
+ addFeatureClusterLayerToGraph(graph, featureClusterResult);
551
+ progress('cgdb', 88, 'Replacing global graph layers...');
552
+ const globalGraph = extractGlobalLayerGraph(graph);
553
+ const replaceResult = await replaceGlobalGraphLayersInCgdb(globalGraph, repoPath, storagePath, undefined, { compress });
554
+ return {
555
+ communityResult,
556
+ processResult,
557
+ featureClusterResult,
558
+ deletedGlobalNodes: replaceResult.deletedGlobalNodes,
559
+ insertedGlobalRels: replaceResult.insertedRels,
560
+ };
561
+ };
562
+ const runIncrementalFilePatchAnalysis = async (input) => {
563
+ const { repoPath, storagePath, cgdbPath, currentCommit, existingMeta, adaptivePlan, patchPlan, options, progress, log, } = input;
564
+ const repoNameForFeatureClusters = options.registryName ?? getInferredRepoName(repoPath) ?? path.basename(repoPath);
565
+ progress('extracting', 5, patchPlan.replaceAllFileScoped
566
+ ? 'Incremental full graph scan for global input change'
567
+ : `Incremental scan: ${patchPlan.currentPaths.length} current file(s)`);
568
+ const pipelineResult = await runPipelineFromRepo(repoPath, (p) => {
569
+ const phaseLabel = PHASE_LABELS[p.phase] || p.phase;
570
+ const scaled = Math.min(59, 5 + Math.round((p.percent / 100) * 54));
571
+ progress(p.phase, scaled, phaseLabel);
572
+ }, {
573
+ skipGraphPhases: true,
574
+ featureClusterRepo: repoNameForFeatureClusters,
575
+ lastIndexedCommit: currentCommit || undefined,
576
+ workerPoolSize: adaptivePlan.workerPoolSize,
577
+ workerSubBatchSize: adaptivePlan.workerSubBatchSize,
578
+ focusPaths: patchPlan.replaceAllFileScoped ? undefined : patchPlan.currentPaths,
579
+ });
580
+ progress('cgdb', 60, patchPlan.replaceAllFileScoped
581
+ ? 'Replacing file-scoped graph rows...'
582
+ : `Patching ${patchPlan.replacePaths.length} file path(s)...`);
583
+ await initCgdb(cgdbPath);
584
+ try {
585
+ let cgdbMsgCount = 0;
586
+ const fileGraphProgress = (msg) => {
587
+ cgdbMsgCount++;
588
+ const pct = Math.min(82, 60 + Math.round((cgdbMsgCount / (cgdbMsgCount + 8)) * 22));
589
+ progress('cgdb', pct, msg);
590
+ };
591
+ if (patchPlan.replaceAllFileScoped) {
592
+ const replacementResult = await replaceFileScopedGraphInCgdb(pipelineResult.graph, repoPath, storagePath, fileGraphProgress, { compress: adaptivePlan.compress });
593
+ log(`Smart analyze: refreshed all file-scoped graph rows, deleted ${replacementResult.deletedNodes} node(s), ` +
594
+ `inserted ${replacementResult.insertedRels} edge(s).`);
595
+ }
596
+ else {
597
+ const patchResult = await applyFileGraphPatchToCgdb(pipelineResult.graph, repoPath, storagePath, patchPlan.replacePaths, fileGraphProgress, { compress: adaptivePlan.compress, pathAliases: patchPlan.pathAliases });
598
+ log(`Smart analyze: incrementally patched ${patchResult.replacedFiles} path(s), ` +
599
+ `deleted ${patchResult.deletedNodeIds} stale node(s), inserted ${patchResult.insertedRels} edge(s), ` +
600
+ `restored ${patchResult.restoredRels} preserved edge(s), pruned ${patchResult.prunedFolders} folder(s).`);
601
+ }
602
+ const globalResult = await recomputeGlobalGraphLayers({
603
+ repoPath,
604
+ storagePath,
605
+ currentCommit,
606
+ repoNameForFeatureClusters,
607
+ compress: adaptivePlan.compress,
608
+ progress,
609
+ });
610
+ log(`Smart analyze: recomputed global layers (${globalResult.communityResult.stats.totalCommunities} communities, ` +
611
+ `${globalResult.processResult.stats.totalProcesses} processes, ` +
612
+ `${globalResult.featureClusterResult.stats.totalClusters} feature clusters).`);
613
+ progress('fts', 89, 'Refreshing search indexes...');
614
+ const ftsProperties = [...ftsPropertiesFor(adaptivePlan.compress)];
615
+ for (const { table, indexName } of FTS_TABLES) {
616
+ await ensureFTSIndex(table, indexName, ftsProperties);
617
+ }
618
+ const stats = await getCgdbStats();
619
+ const embeddingDecision = decideEmbeddingRun(adaptivePlan, {
620
+ nodes: stats.nodes,
621
+ embeddings: existingMeta.stats?.embeddings ?? 0,
622
+ });
623
+ log(embeddingDecision.enabled
624
+ ? `Embeddings enabled: ${embeddingDecision.reason}.`
625
+ : `Embeddings skipped: ${embeddingDecision.reason}.`);
626
+ if (embeddingDecision.enabled) {
627
+ const { isHttpMode } = await import('./embeddings/http-client.js');
628
+ const httpMode = isHttpMode();
629
+ progress('embeddings', 90, httpMode ? 'Connecting to embedding endpoint...' : 'Loading embedding model...');
630
+ const { runEmbeddingPipeline } = await import('./embeddings/embedding-pipeline.js');
631
+ const existingEmbeddings = await fetchExistingEmbeddingHashes(executeQuery);
632
+ const { readServerMapping } = await import('./embeddings/server-mapping.js');
633
+ const projectName = path.basename(repoPath);
634
+ const serverName = await readServerMapping(projectName);
635
+ await runEmbeddingPipeline(executeQuery, executeWithReusedStatement, (p) => {
636
+ const scaled = 90 + Math.round((p.percent / 100) * 7);
637
+ const label = p.phase === 'loading-model'
638
+ ? httpMode
639
+ ? 'Connecting to embedding endpoint...'
640
+ : 'Loading embedding model...'
641
+ : `Embedding ${p.nodesProcessed || 0}/${p.totalNodes || '?'}`;
642
+ progress('embeddings', scaled, label);
643
+ }, {}, undefined, { repoName: projectName, serverName }, existingEmbeddings);
644
+ }
645
+ progress('done', 97, 'Recording graph snapshot...');
646
+ let graphstoreCurrentBranch;
647
+ let graphstoreHeadCommit;
648
+ try {
649
+ const snapshotResult = await recordAnalysisSnapshot({
650
+ storagePath,
651
+ indexedRepoCommit: currentCommit || undefined,
652
+ onSkipTable: (tableName, err) => {
653
+ log(`graphstore: skipped table "${tableName}": ${err instanceof Error ? err.message : String(err)}`);
654
+ },
655
+ });
656
+ if (snapshotResult) {
657
+ graphstoreCurrentBranch = snapshotResult.branch;
658
+ graphstoreHeadCommit = snapshotResult.commitId;
659
+ log(`graphstore: snapshot ${snapshotResult.snapshotId.slice(0, 19)}... ` +
660
+ `commit ${snapshotResult.commitId.slice(0, 19)}... ` +
661
+ `branch ${snapshotResult.branch}`);
662
+ }
663
+ }
664
+ catch (err) {
665
+ log(`graphstore: snapshot failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
666
+ }
667
+ progress('done', 98, 'Saving metadata...');
668
+ const embeddingCount = await countEmbeddings();
669
+ const previousFileCount = existingMeta.stats?.files ?? 0;
670
+ const fileCount = Math.max(0, previousFileCount + patchPlan.fileCountDelta);
671
+ const meta = {
672
+ repoPath,
673
+ lastCommit: currentCommit,
674
+ indexedAt: new Date().toISOString(),
675
+ schemaVersion: INDEX_SCHEMA_VERSION,
676
+ compress: adaptivePlan.compress,
677
+ searchIndexes: { fts: true },
678
+ adaptiveProfile: buildAdaptiveProfileMeta(adaptivePlan, embeddingDecision),
679
+ remoteUrl: hasGitDir(repoPath) ? getRemoteUrl(repoPath) : undefined,
680
+ currentBranch: graphstoreCurrentBranch,
681
+ headCommit: graphstoreHeadCommit,
682
+ stats: {
683
+ files: patchPlan.replaceAllFileScoped ? pipelineResult.totalFileCount : fileCount,
684
+ nodes: stats.nodes,
685
+ edges: stats.edges,
686
+ communities: globalResult.communityResult.stats.totalCommunities,
687
+ featureClusters: globalResult.featureClusterResult.stats.totalClusters,
688
+ processes: globalResult.processResult.stats.totalProcesses,
689
+ embeddings: embeddingCount,
690
+ },
691
+ };
692
+ await saveMeta(storagePath, meta);
693
+ const projectName = await registerRepo(repoPath, meta, {
694
+ name: options.registryName,
695
+ allowDuplicateName: options.allowDuplicateName,
696
+ });
697
+ if (hasGitDir(repoPath)) {
698
+ await addToGitignore(repoPath);
699
+ }
700
+ try {
701
+ await generateAIContextFiles(repoPath, storagePath, projectName, metaStatsForAIContext(meta.stats), undefined, { skipAgentsMd: options.skipAgentsMd, noStats: options.noStats });
702
+ }
703
+ catch {
704
+ // Best-effort only.
705
+ }
706
+ await closeCgdb();
707
+ progress('done', 100, 'Done');
708
+ return {
709
+ repoName: projectName,
710
+ repoPath,
711
+ stats: meta.stats ?? {},
712
+ pipelineResult,
713
+ };
714
+ }
715
+ catch (err) {
716
+ try {
717
+ await closeCgdb();
718
+ }
719
+ catch {
720
+ /* swallow */
721
+ }
722
+ throw err;
723
+ }
724
+ };
188
725
  // ---------------------------------------------------------------------------
189
726
  // Main orchestrator
190
727
  // ---------------------------------------------------------------------------
@@ -274,6 +811,15 @@ export async function runFullAnalysis(repoPath, options, callbacks) {
274
811
  const repoHasGit = hasGitDir(repoPath);
275
812
  const currentCommit = repoHasGit ? getCurrentCommit(repoPath) : '';
276
813
  const existingMeta = await loadMeta(storagePath);
814
+ const adaptivePlan = resolveAdaptiveAnalyzePlan({
815
+ profile: options.profile,
816
+ embeddingMode: options.embeddingMode,
817
+ embeddings: options.embeddings,
818
+ compress: options.compress,
819
+ existingMeta,
820
+ });
821
+ const existingEmbeddingDecision = decideEmbeddingRun(adaptivePlan, existingMeta?.stats);
822
+ log(formatAdaptiveAnalyzePlan(adaptivePlan));
277
823
  // ── Early-return: already up to date ──────────────────────────────
278
824
  // Schema-version mismatch forces a full re-analyze regardless of commit
279
825
  // equality: existing 1.7.x indexes have no `schemaVersion` field at all,
@@ -288,7 +834,10 @@ export async function runFullAnalysis(repoPath, options, callbacks) {
288
834
  : null;
289
835
  const configRebuildReason = storageRebuildReason ??
290
836
  (existingMeta && schemaUpToDate && !options.force
291
- ? getAnalyzeConfigRebuildReason(existingMeta, options)
837
+ ? getAnalyzeConfigRebuildReason(existingMeta, {
838
+ compress: adaptivePlan.compress,
839
+ embeddings: existingEmbeddingDecision.enabled,
840
+ })
292
841
  : null);
293
842
  if (existingMeta &&
294
843
  schemaUpToDate &&
@@ -297,8 +846,15 @@ export async function runFullAnalysis(repoPath, options, callbacks) {
297
846
  existingMeta.lastCommit === currentCommit) {
298
847
  // Non-git folders have currentCommit = '' — always rebuild since we can't detect changes
299
848
  if (currentCommit !== '') {
849
+ const repoName = options.registryName ?? getInferredRepoName(repoPath) ?? path.basename(repoPath);
850
+ try {
851
+ await generateAIContextFiles(repoPath, storagePath, repoName, metaStatsForAIContext(existingMeta.stats), undefined, { skipAgentsMd: options.skipAgentsMd, noStats: options.noStats });
852
+ }
853
+ catch {
854
+ // Best-effort only.
855
+ }
300
856
  return {
301
- repoName: options.registryName ?? getInferredRepoName(repoPath) ?? path.basename(repoPath),
857
+ repoName,
302
858
  repoPath,
303
859
  stats: existingMeta.stats ?? {},
304
860
  alreadyUpToDate: true,
@@ -327,8 +883,14 @@ export async function runFullAnalysis(repoPath, options, callbacks) {
327
883
  if (hasGitDir(repoPath)) {
328
884
  await addToGitignore(repoPath);
329
885
  }
886
+ try {
887
+ await generateAIContextFiles(repoPath, storagePath, projectName, metaStatsForAIContext(reusedMeta.stats), undefined, { skipAgentsMd: options.skipAgentsMd, noStats: options.noStats });
888
+ }
889
+ catch {
890
+ // Best-effort only.
891
+ }
330
892
  const reuseReason = `Smart analyze reused the existing graph; ${changedPaths.length} changed ` +
331
- `file(s) did not affect indexed code, docs, config, or file structure.`;
893
+ `file(s) did not affect indexed graph inputs.`;
332
894
  log(reuseReason);
333
895
  progress('done', 100, 'Existing graph reused');
334
896
  return {
@@ -342,9 +904,39 @@ export async function runFullAnalysis(repoPath, options, callbacks) {
342
904
  }
343
905
  const preview = graphRelevantChanges.slice(0, 5).map(formatChangeForLog).join(', ');
344
906
  const suffix = graphRelevantChanges.length > 5 ? ', ...' : '';
345
- log(`Smart analyze: ${graphRelevantChanges.length} indexed change(s) require rebuild` +
907
+ log(`Smart analyze: ${graphRelevantChanges.length} indexed graph input change(s) require rebuild` +
346
908
  (preview ? ` (${preview}${suffix})` : '') +
347
909
  '.');
910
+ const patchPlan = buildIncrementalFilePatchPlan(graphRelevantChanges);
911
+ if (patchPlan.eligible) {
912
+ log(`Smart analyze: ${patchPlan.reason}.`);
913
+ try {
914
+ return await runIncrementalFilePatchAnalysis({
915
+ repoPath,
916
+ storagePath,
917
+ cgdbPath,
918
+ currentCommit,
919
+ existingMeta,
920
+ adaptivePlan,
921
+ patchPlan,
922
+ options,
923
+ progress,
924
+ log,
925
+ });
926
+ }
927
+ catch (err) {
928
+ log(`Smart analyze: incremental patch failed (${err instanceof Error ? err.message : String(err)}); rebuilding.`);
929
+ try {
930
+ await closeCgdb();
931
+ }
932
+ catch {
933
+ /* swallow */
934
+ }
935
+ }
936
+ }
937
+ else {
938
+ log(`Smart analyze: incremental patch unavailable: ${patchPlan.reason}; rebuilding.`);
939
+ }
348
940
  }
349
941
  else {
350
942
  log('Smart analyze: could not inspect git diff; rebuilding.');
@@ -358,7 +950,7 @@ export async function runFullAnalysis(repoPath, options, callbacks) {
358
950
  // ── Cache embeddings from existing index before rebuild ────────────
359
951
  let cachedEmbeddingNodeIds = new Set();
360
952
  let cachedEmbeddings = [];
361
- if (options.embeddings && existingMeta && !options.force) {
953
+ if (existingEmbeddingDecision.enabled && existingMeta && !options.force) {
362
954
  try {
363
955
  progress('embeddings', 0, 'Caching embeddings...');
364
956
  await initCgdb(cgdbPath);
@@ -385,6 +977,8 @@ export async function runFullAnalysis(repoPath, options, callbacks) {
385
977
  }, {
386
978
  featureClusterRepo: repoNameForFeatureClusters,
387
979
  lastIndexedCommit: currentCommit || undefined,
980
+ workerPoolSize: adaptivePlan.workerPoolSize,
981
+ workerSubBatchSize: adaptivePlan.workerSubBatchSize,
388
982
  });
389
983
  // ── Phase 2: LadybugDB (60–85%) ──────────────────────────────────
390
984
  progress('cgdb', 60, 'Loading into LadybugDB...');
@@ -413,7 +1007,7 @@ export async function runFullAnalysis(repoPath, options, callbacks) {
413
1007
  // through encodeContent before hitting the CSV. Default 'none' is
414
1008
  // a true passthrough, so the on-disk layout is byte-identical to
415
1009
  // pre-Phase-2 indexes when no compression flag is passed.
416
- { compress: options.compress });
1010
+ { compress: adaptivePlan.compress });
417
1011
  // ── Phase 2.5: Versioned-graph snapshot (best-effort) ────────────
418
1012
  // Phase 4 hook: snapshot the freshly-loaded graph into the
419
1013
  // content-addressed `.codragraph/graphstore/`. Failures here do NOT
@@ -441,12 +1035,15 @@ export async function runFullAnalysis(repoPath, options, callbacks) {
441
1035
  log(`graphstore: snapshot failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
442
1036
  }
443
1037
  // ── Phase 3: FTS (85–90%) ─────────────────────────────────────────
444
- // FTS indexes are created lazily on first `query`/`context` call instead
445
- // of eagerly here. On small repos / CI runners the LadybugDB
446
- // CREATE_FTS_INDEX cost is ~440 ms × 5 (≈2 s) regardless of table size,
447
- // which dominated `analyze` runtime and pushed Windows CI past its
448
- // 30 s test budget. Lazy creation is implemented in
449
- // `core/search/bm25-index.ts` via `ensureFTSIndex`.
1038
+ // Build persisted keyword indexes while the analyzer still owns a writable
1039
+ // LadybugDB handle. MCP/local query paths intentionally open read-only so
1040
+ // they can coexist with editors and servers; if FTS is not warmed here,
1041
+ // the first agent `query` degrades to a bounded table scan.
1042
+ progress('fts', 85, 'Creating search indexes...');
1043
+ const ftsProperties = [...ftsPropertiesFor(adaptivePlan.compress)];
1044
+ for (const { table, indexName } of FTS_TABLES) {
1045
+ await ensureFTSIndex(table, indexName, ftsProperties);
1046
+ }
450
1047
  // ── Phase 3.5: Re-insert cached embeddings ────────────────────────
451
1048
  if (cachedEmbeddings.length > 0) {
452
1049
  const cachedDims = cachedEmbeddings[0].embedding.length;
@@ -474,12 +1071,14 @@ export async function runFullAnalysis(repoPath, options, callbacks) {
474
1071
  }
475
1072
  // ── Phase 4: Embeddings (90–98%) ──────────────────────────────────
476
1073
  const stats = await getCgdbStats();
477
- let embeddingSkipped = true;
478
- if (options.embeddings) {
479
- if (stats.nodes <= EMBEDDING_NODE_LIMIT) {
480
- embeddingSkipped = false;
481
- }
482
- }
1074
+ const embeddingDecision = decideEmbeddingRun(adaptivePlan, {
1075
+ nodes: stats.nodes,
1076
+ embeddings: existingMeta?.stats?.embeddings ?? cachedEmbeddings.length,
1077
+ });
1078
+ const embeddingSkipped = !embeddingDecision.enabled;
1079
+ log(embeddingDecision.enabled
1080
+ ? `Embeddings enabled: ${embeddingDecision.reason}.`
1081
+ : `Embeddings skipped: ${embeddingDecision.reason}.`);
483
1082
  if (!embeddingSkipped) {
484
1083
  const { isHttpMode } = await import('./embeddings/http-client.js');
485
1084
  const httpMode = isHttpMode();
@@ -509,20 +1108,15 @@ export async function runFullAnalysis(repoPath, options, callbacks) {
509
1108
  // ── Phase 5: Finalize (98–100%) ───────────────────────────────────
510
1109
  progress('done', 98, 'Saving metadata...');
511
1110
  // Count embeddings in the index (cached + newly generated)
512
- let embeddingCount = 0;
513
- try {
514
- const embResult = await executeQuery(`MATCH (e:${EMBEDDING_TABLE_NAME}) RETURN count(e) AS cnt`);
515
- embeddingCount = embResult?.[0]?.cnt ?? 0;
516
- }
517
- catch {
518
- /* table may not exist if embeddings never ran */
519
- }
1111
+ const embeddingCount = await countEmbeddings();
520
1112
  const meta = {
521
1113
  repoPath,
522
1114
  lastCommit: currentCommit,
523
1115
  indexedAt: new Date().toISOString(),
524
1116
  schemaVersion: INDEX_SCHEMA_VERSION,
525
- compress: options.compress ?? 'none',
1117
+ compress: adaptivePlan.compress,
1118
+ searchIndexes: { fts: true },
1119
+ adaptiveProfile: buildAdaptiveProfileMeta(adaptivePlan, embeddingDecision),
526
1120
  // Captured here (not at registration) so it travels with the
527
1121
  // on-disk meta.json — sibling-clone fingerprinting works for
528
1122
  // out-of-tree consumers (group-status, future tooling) without