sweet-search 0.0.1 → 2.3.0

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 (161) hide show
  1. package/LICENSE +190 -0
  2. package/NOTICE +23 -0
  3. package/core/cli.js +51 -0
  4. package/core/config.js +27 -0
  5. package/core/embedding/embedding-cache.js +467 -0
  6. package/core/embedding/embedding-local-model.js +845 -0
  7. package/core/embedding/embedding-remote.js +492 -0
  8. package/core/embedding/embedding-service.js +712 -0
  9. package/core/embedding/embedding-telemetry.js +219 -0
  10. package/core/embedding/index.js +40 -0
  11. package/core/graph/community-detector.js +294 -0
  12. package/core/graph/graph-expansion.js +839 -0
  13. package/core/graph/graph-extractor.js +2304 -0
  14. package/core/graph/graph-search.js +2148 -0
  15. package/core/graph/hcgs-generator.js +666 -0
  16. package/core/graph/index.js +16 -0
  17. package/core/graph/leiden-algorithm.js +547 -0
  18. package/core/graph/relationship-resolver.js +366 -0
  19. package/core/graph/repo-map.js +408 -0
  20. package/core/graph/summary-manager.js +549 -0
  21. package/core/indexing/artifact-builder.js +1054 -0
  22. package/core/indexing/ast-chunker.js +709 -0
  23. package/core/indexing/chunking/chunk-builder.js +170 -0
  24. package/core/indexing/chunking/markdown-chunker.js +503 -0
  25. package/core/indexing/chunking/plaintext-chunker.js +104 -0
  26. package/core/indexing/dedup/dedup-phase.js +159 -0
  27. package/core/indexing/dedup/exemplar-selector.js +65 -0
  28. package/core/indexing/document-chunker.js +56 -0
  29. package/core/indexing/incremental-parser.js +390 -0
  30. package/core/indexing/incremental-tracker.js +761 -0
  31. package/core/indexing/index-codebase-v21.js +472 -0
  32. package/core/indexing/index-maintainer.mjs +1674 -0
  33. package/core/indexing/index.js +90 -0
  34. package/core/indexing/indexer-ann.js +1077 -0
  35. package/core/indexing/indexer-build.js +742 -0
  36. package/core/indexing/indexer-phases.js +800 -0
  37. package/core/indexing/indexer-pool.js +764 -0
  38. package/core/indexing/indexer-sparse-gram.js +98 -0
  39. package/core/indexing/indexer-utils.js +536 -0
  40. package/core/indexing/indexer-worker.js +148 -0
  41. package/core/indexing/li-skip-policy.js +225 -0
  42. package/core/indexing/merkle-tracker.js +244 -0
  43. package/core/indexing/model-pool.js +166 -0
  44. package/core/infrastructure/code-graph-repository.js +120 -0
  45. package/core/infrastructure/codebase-repository.js +131 -0
  46. package/core/infrastructure/config/dedup.js +54 -0
  47. package/core/infrastructure/config/embedding.js +298 -0
  48. package/core/infrastructure/config/graph.js +80 -0
  49. package/core/infrastructure/config/index.js +82 -0
  50. package/core/infrastructure/config/indexing.js +8 -0
  51. package/core/infrastructure/config/platform.js +254 -0
  52. package/core/infrastructure/config/ranking.js +221 -0
  53. package/core/infrastructure/config/search.js +396 -0
  54. package/core/infrastructure/config/translation.js +89 -0
  55. package/core/infrastructure/config/vector-store.js +114 -0
  56. package/core/infrastructure/constants.js +86 -0
  57. package/core/infrastructure/coreml-cascade.js +909 -0
  58. package/core/infrastructure/coreml-cascade.json +46 -0
  59. package/core/infrastructure/coreml-provider.js +81 -0
  60. package/core/infrastructure/db-utils.js +69 -0
  61. package/core/infrastructure/dedup-hashing.js +83 -0
  62. package/core/infrastructure/hardware-capability.js +332 -0
  63. package/core/infrastructure/index.js +104 -0
  64. package/core/infrastructure/language-patterns/maps.js +121 -0
  65. package/core/infrastructure/language-patterns/registry-core.js +323 -0
  66. package/core/infrastructure/language-patterns/registry-data-query.js +155 -0
  67. package/core/infrastructure/language-patterns/registry-object-oriented.js +285 -0
  68. package/core/infrastructure/language-patterns/registry-tooling.js +240 -0
  69. package/core/infrastructure/language-patterns/registry-web-style.js +143 -0
  70. package/core/infrastructure/language-patterns/registry.js +19 -0
  71. package/core/infrastructure/language-patterns.js +141 -0
  72. package/core/infrastructure/llm-provider.js +733 -0
  73. package/core/infrastructure/manifest.json +46 -0
  74. package/core/infrastructure/maxsim.wasm +0 -0
  75. package/core/infrastructure/model-fetcher.js +423 -0
  76. package/core/infrastructure/model-registry.js +214 -0
  77. package/core/infrastructure/native-inference.js +587 -0
  78. package/core/infrastructure/native-resolver.js +187 -0
  79. package/core/infrastructure/native-sparse-gram.js +257 -0
  80. package/core/infrastructure/native-tokenizer.js +160 -0
  81. package/core/infrastructure/onnx-mutex.js +45 -0
  82. package/core/infrastructure/onnx-session-utils.js +261 -0
  83. package/core/infrastructure/ort-pipeline.js +111 -0
  84. package/core/infrastructure/project-detector.js +102 -0
  85. package/core/infrastructure/quantization.js +410 -0
  86. package/core/infrastructure/simd-distance.js +502 -0
  87. package/core/infrastructure/simd-distance.wasm +0 -0
  88. package/core/infrastructure/tree-sitter-provider.js +665 -0
  89. package/core/infrastructure/webgpu-maxsim.js +222 -0
  90. package/core/query/index.js +35 -0
  91. package/core/query/intent-detector.js +201 -0
  92. package/core/query/intent-router.js +156 -0
  93. package/core/query/query-router-catboost.js +222 -0
  94. package/core/query/query-router-ml.js +266 -0
  95. package/core/query/query-router.js +213 -0
  96. package/core/ranking/cascaded-scorer.js +379 -0
  97. package/core/ranking/flashrank.js +810 -0
  98. package/core/ranking/index.js +49 -0
  99. package/core/ranking/late-interaction-index.js +2383 -0
  100. package/core/ranking/late-interaction-model.js +812 -0
  101. package/core/ranking/local-reranker.js +374 -0
  102. package/core/ranking/mmr.js +379 -0
  103. package/core/ranking/quality-scorer.js +363 -0
  104. package/core/search/context-expander.js +1167 -0
  105. package/core/search/dedup/sibling-expander.js +327 -0
  106. package/core/search/index.js +16 -0
  107. package/core/search/search-boost.js +259 -0
  108. package/core/search/search-cli.js +544 -0
  109. package/core/search/search-format.js +282 -0
  110. package/core/search/search-fusion.js +327 -0
  111. package/core/search/search-hybrid.js +204 -0
  112. package/core/search/search-pattern-chunks.js +337 -0
  113. package/core/search/search-pattern-planner.js +439 -0
  114. package/core/search/search-pattern-prefilter.js +412 -0
  115. package/core/search/search-pattern-ripgrep.js +663 -0
  116. package/core/search/search-pattern.js +463 -0
  117. package/core/search/search-postprocess.js +452 -0
  118. package/core/search/search-semantic.js +706 -0
  119. package/core/search/search-server.js +554 -0
  120. package/core/search/session-daemon-prewarm.mjs +164 -0
  121. package/core/search/session-warmup.js +595 -0
  122. package/core/search/sweet-search.js +632 -0
  123. package/core/search/warmup-metrics.js +532 -0
  124. package/core/start-server.js +6 -0
  125. package/core/training/query-router/features/extractor.js +762 -0
  126. package/core/training/query-router/features/multilingual-patterns.js +431 -0
  127. package/core/training/query-router/features/text-segmenter.js +303 -0
  128. package/core/training/query-router/features/unicode-utils.js +383 -0
  129. package/core/training/query-router/output/v45_router_d4.js +11521 -0
  130. package/core/training/query-router/output/v46_router_d4.js +11498 -0
  131. package/core/vector-store/binary-heap.js +227 -0
  132. package/core/vector-store/binary-hnsw-index.js +1004 -0
  133. package/core/vector-store/float-vector-store.js +234 -0
  134. package/core/vector-store/hnsw-index.js +580 -0
  135. package/core/vector-store/index.js +39 -0
  136. package/core/vector-store/seismic-index.js +498 -0
  137. package/core/vocabulary/index.js +84 -0
  138. package/core/vocabulary/vocab-constants.js +20 -0
  139. package/core/vocabulary/vocab-miner-extractors.js +375 -0
  140. package/core/vocabulary/vocab-miner-nl.js +404 -0
  141. package/core/vocabulary/vocab-miner-utils.js +146 -0
  142. package/core/vocabulary/vocab-miner.js +574 -0
  143. package/core/vocabulary/vocab-prewarm-cli.js +110 -0
  144. package/core/vocabulary/vocab-ranker.js +492 -0
  145. package/core/vocabulary/vocab-warmer.js +523 -0
  146. package/core/vocabulary/vocab-warmup-orchestrator.js +425 -0
  147. package/core/vocabulary/vocabulary-utils.js +704 -0
  148. package/crates/wasm-router/pkg/package.json +13 -0
  149. package/crates/wasm-router/pkg/query_router_wasm.d.ts +36 -0
  150. package/crates/wasm-router/pkg/query_router_wasm.js +271 -0
  151. package/crates/wasm-router/pkg/query_router_wasm_bg.wasm +0 -0
  152. package/crates/wasm-router/pkg/query_router_wasm_bg.wasm.d.ts +19 -0
  153. package/mcp/config-gen.js +121 -0
  154. package/mcp/server.js +335 -0
  155. package/mcp/tool-handlers.js +476 -0
  156. package/package.json +131 -9
  157. package/scripts/benchmark-harness.js +794 -0
  158. package/scripts/init.js +1058 -0
  159. package/scripts/smoke-test.js +435 -0
  160. package/scripts/uninstall.js +478 -0
  161. package/scripts/verify-runtime.js +176 -0
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Embedding Telemetry - Per-mode query telemetry for vocabulary prewarm pipeline.
3
+ * Extracted from embedding-cache.js for SOLID compliance (single responsibility).
4
+ */
5
+
6
+ import fs from 'fs/promises';
7
+ import { existsSync, mkdirSync, appendFileSync } from 'fs';
8
+ import path from 'path';
9
+ import { DB_PATHS } from '../infrastructure/config/index.js';
10
+
11
+ // =============================================================================
12
+ // PER-MODE QUERY TELEMETRY (Step 0 of Vocabulary Prewarm)
13
+ // =============================================================================
14
+
15
+ const TELEMETRY_PATH = path.join(path.dirname(DB_PATHS.vocabulary), 'query-telemetry.jsonl');
16
+ const TELEMETRY_MAX_LINES = 10_000;
17
+ const FLUSH_THRESHOLD = 50;
18
+
19
+ export const telemetryStats = {
20
+ lexical: { hits: 0, misses: 0, totalLatencyMs: 0, count: 0 },
21
+ semantic: { hits: 0, misses: 0, totalLatencyMs: 0, count: 0 },
22
+ hybrid: { hits: 0, misses: 0, totalLatencyMs: 0, count: 0 },
23
+ };
24
+
25
+ // P1.2: Buffer entries in memory, flush periodically instead of per-query I/O
26
+ const _telemetryBuffer = [];
27
+ let _lineCountEstimate = -1; // -1 = unknown, needs initial read
28
+ let _flushPromise = null;
29
+
30
+ // Periodic flush interval (30s) and process exit hook to prevent tail loss.
31
+ // The interval uses unref() so it doesn't keep the process alive.
32
+ let _flushInterval = null;
33
+
34
+ function _ensureShutdownHooks() {
35
+ if (_flushInterval) return; // already registered
36
+ _flushInterval = setInterval(() => {
37
+ if (_telemetryBuffer.length > 0) flushTelemetry().catch(() => {});
38
+ }, 30_000);
39
+ _flushInterval.unref(); // don't keep process alive for telemetry
40
+
41
+ // Synchronous best-effort flush on exit — beforeExit fires for empty event
42
+ // loop (normal exit), but NOT for SIGTERM/uncaughtException. We use both
43
+ // beforeExit (async-safe) and exit (sync-only, last resort).
44
+ process.on('beforeExit', _beforeExitFlush);
45
+ process.on('exit', _syncFlush);
46
+ }
47
+
48
+ async function _beforeExitFlush() {
49
+ try { await flushTelemetry(); } catch {}
50
+ }
51
+
52
+ function _syncFlush() {
53
+ // exit handler is synchronous — write what we can via writeFileSync
54
+ if (_telemetryBuffer.length === 0) return;
55
+ try {
56
+ mkdirSync(path.dirname(TELEMETRY_PATH), { recursive: true });
57
+ appendFileSync(TELEMETRY_PATH, _telemetryBuffer.splice(0).join('\n') + '\n');
58
+ } catch {}
59
+ }
60
+
61
+ /**
62
+ * Record a single query telemetry event.
63
+ * Updates in-memory stats and buffers the entry. Flushes to disk
64
+ * every FLUSH_THRESHOLD entries (default 50) to avoid per-query file I/O.
65
+ *
66
+ * @param {'lexical'|'semantic'|'hybrid'} mode - Search mode used
67
+ * @param {boolean} hit - Whether the cache was hit
68
+ * @param {number} latencyMs - Query latency in milliseconds
69
+ * @param {string} [query] - The query text (for promotion/demotion analysis)
70
+ * @param {string} [source] - Embedding source ('vocabulary', 'semantic-cache', 'api', 'local')
71
+ * @param {boolean} [lexicalHit] - For hybrid mode: whether lexical sub-path was a hit
72
+ * @param {boolean} [semanticHit] - For hybrid mode: whether semantic sub-path was a hit
73
+ */
74
+ export async function recordQueryTelemetry(mode, hit, latencyMs, query, source, lexicalHit, semanticHit) {
75
+ const bucket = telemetryStats[mode];
76
+ if (!bucket) return;
77
+
78
+ bucket.count++;
79
+ bucket.totalLatencyMs += latencyMs;
80
+ if (hit) bucket.hits++; else bucket.misses++;
81
+
82
+ _telemetryBuffer.push(JSON.stringify({
83
+ mode,
84
+ hit,
85
+ latencyMs: Math.round(latencyMs * 100) / 100,
86
+ timestamp: new Date().toISOString(),
87
+ ...(query ? { query } : {}),
88
+ ...(source ? { source } : {}),
89
+ ...(lexicalHit != null ? { lexicalHit } : {}),
90
+ ...(semanticHit != null ? { semanticHit } : {}),
91
+ }));
92
+
93
+ _ensureShutdownHooks();
94
+
95
+ if (_telemetryBuffer.length >= FLUSH_THRESHOLD) {
96
+ await flushTelemetry();
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Flush buffered telemetry entries to disk.
102
+ * Safe to call multiple times; coalesces concurrent flushes.
103
+ */
104
+ export async function flushTelemetry() {
105
+ if (_telemetryBuffer.length === 0) return;
106
+ // Coalesce concurrent flush calls
107
+ if (_flushPromise) return _flushPromise;
108
+ _flushPromise = _flushTelemetryInner();
109
+ try { await _flushPromise; } finally { _flushPromise = null; }
110
+ }
111
+
112
+ async function _flushTelemetryInner() {
113
+ const batch = _telemetryBuffer.splice(0, _telemetryBuffer.length);
114
+ if (batch.length === 0) return;
115
+
116
+ try {
117
+ await fs.mkdir(path.dirname(TELEMETRY_PATH), { recursive: true });
118
+
119
+ // Lazy-init line count estimate (only once, not per query)
120
+ if (_lineCountEstimate < 0) {
121
+ if (existsSync(TELEMETRY_PATH)) {
122
+ const content = await fs.readFile(TELEMETRY_PATH, 'utf-8');
123
+ _lineCountEstimate = content.split('\n').filter(Boolean).length;
124
+ } else {
125
+ _lineCountEstimate = 0;
126
+ }
127
+ }
128
+
129
+ // Rotate if over limit
130
+ if (_lineCountEstimate + batch.length >= TELEMETRY_MAX_LINES) {
131
+ if (existsSync(TELEMETRY_PATH)) {
132
+ await fs.rename(TELEMETRY_PATH, TELEMETRY_PATH + '.bak');
133
+ }
134
+ _lineCountEstimate = 0;
135
+ }
136
+
137
+ await fs.appendFile(TELEMETRY_PATH, batch.join('\n') + '\n');
138
+ _lineCountEstimate += batch.length;
139
+ } catch (err) {
140
+ if (process.env.DEBUG_CATCHES) process.stderr.write(`[non-fatal] ${err?.message || err}\n`);
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Read the last N telemetry entries and compute per-mode statistics.
146
+ *
147
+ * @param {number} [lastN=100] - Number of recent entries to analyze
148
+ * @returns {Promise<{modes: Record<string, {hits: number, misses: number, hitRate: string, avgLatencyMs: number, count: number}>, total: number}>}
149
+ */
150
+ export async function getTelemetryReport(lastN = 100) {
151
+ const modes = {
152
+ lexical: { hits: 0, misses: 0, totalLatencyMs: 0, count: 0 },
153
+ semantic: { hits: 0, misses: 0, totalLatencyMs: 0, count: 0 },
154
+ hybrid: { hits: 0, misses: 0, totalLatencyMs: 0, count: 0 },
155
+ };
156
+
157
+ try {
158
+ if (!existsSync(TELEMETRY_PATH)) return { modes: _formatModes(modes), total: 0 };
159
+
160
+ const content = await fs.readFile(TELEMETRY_PATH, 'utf-8');
161
+ const lines = content.split('\n').filter(Boolean);
162
+ const recent = lines.slice(-lastN);
163
+
164
+ for (const line of recent) {
165
+ try {
166
+ const entry = JSON.parse(line);
167
+ const bucket = modes[entry.mode];
168
+ if (!bucket) continue;
169
+ bucket.count++;
170
+ bucket.totalLatencyMs += entry.latencyMs || 0;
171
+ if (entry.hit) bucket.hits++; else bucket.misses++;
172
+ } catch (err) {
173
+ if (process.env.DEBUG_CATCHES) process.stderr.write(`[non-fatal] ${err?.message || err}\n`);
174
+ }
175
+ }
176
+
177
+ return { modes: _formatModes(modes), total: recent.length };
178
+ } catch (err) {
179
+ if (process.env.DEBUG_CATCHES) process.stderr.write(`[non-fatal] ${err?.message || err}\n`);
180
+ return { modes: _formatModes(modes), total: 0 };
181
+ }
182
+ }
183
+
184
+ function _formatModes(modes) {
185
+ const result = {};
186
+ for (const [mode, stats] of Object.entries(modes)) {
187
+ const total = stats.hits + stats.misses;
188
+ result[mode] = {
189
+ hits: stats.hits,
190
+ misses: stats.misses,
191
+ hitRate: total > 0 ? `${(stats.hits / total * 100).toFixed(1)}%` : '0.0%',
192
+ avgLatencyMs: stats.count > 0 ? Math.round(stats.totalLatencyMs / stats.count * 100) / 100 : 0,
193
+ count: stats.count,
194
+ };
195
+ }
196
+ return result;
197
+ }
198
+
199
+ /**
200
+ * Reset in-memory telemetry stats and buffer (for testing).
201
+ * Also tears down shutdown hooks to prevent listener leaks in test suites.
202
+ */
203
+ export function resetTelemetryStats() {
204
+ for (const bucket of Object.values(telemetryStats)) {
205
+ bucket.hits = 0;
206
+ bucket.misses = 0;
207
+ bucket.totalLatencyMs = 0;
208
+ bucket.count = 0;
209
+ }
210
+ _telemetryBuffer.length = 0;
211
+ _lineCountEstimate = -1;
212
+ // Tear down hooks to prevent listener leaks during tests
213
+ if (_flushInterval) {
214
+ clearInterval(_flushInterval);
215
+ _flushInterval = null;
216
+ }
217
+ process.removeListener('beforeExit', _beforeExitFlush);
218
+ process.removeListener('exit', _syncFlush);
219
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Embedding Domain - Barrel export.
3
+ * Re-exports public API from all sub-modules.
4
+ *
5
+ * NOTE: expandVocabulary exists in both embedding-service.js and embedding-cache.js
6
+ * with different signatures. The service version (auto-supplies embedFn) is canonical.
7
+ * Telemetry symbols are re-exported by embedding-cache.js from embedding-telemetry.js,
8
+ * so we do NOT also export * from embedding-telemetry.js (ESM ambiguity).
9
+ */
10
+
11
+ // Facade (has default export)
12
+ export { default } from './embedding-service.js';
13
+ export * from './embedding-service.js';
14
+
15
+ // Remote (circuit breaker, rate limiters, API clients)
16
+ export * from './embedding-remote.js';
17
+
18
+ // Local model (ONNX inference)
19
+ export * from './embedding-local-model.js';
20
+
21
+ // Cache (LRU, vocabulary, semantic cache, deduplication)
22
+ // NOTE: embedding-cache.js re-exports telemetry symbols from embedding-telemetry.js
23
+ export * from './embedding-cache.js';
24
+
25
+ // Resolve ESM ambiguity: expandVocabulary exists in both service and cache.
26
+ // The service version (auto-supplies embedFn) is the public API.
27
+ export { expandVocabulary } from './embedding-service.js';
28
+
29
+ // CoreML cascade diagnostics — re-exported from the infrastructure
30
+ // layer so consumers inside `core/embedding/` and out-of-domain callers
31
+ // that import the embedding barrel can ask "what inference backend is
32
+ // armed right now?" without reaching into infrastructure directly.
33
+ // These are read-only views; the cascade itself is owned by
34
+ // `core/infrastructure/coreml-cascade.js`.
35
+ export {
36
+ isCoremlCascadeApplicable,
37
+ getCoremlCascadeState,
38
+ getCoremlCascadeReport,
39
+ getCoremlCascadeResolvedDirs,
40
+ } from '../infrastructure/coreml-cascade.js';
@@ -0,0 +1,294 @@
1
+ /**
2
+ * Community Detector Module
3
+ *
4
+ * Discovers logical communities of code entities using the Leiden algorithm
5
+ * on the entity/relationship graph stored in SQLite (code-graph.db).
6
+ *
7
+ * Algorithm core (Traag et al. 2019) lives in leiden-algorithm.js.
8
+ * This module handles DB I/O, graph construction, and the public API.
9
+ *
10
+ * Fallback: directory-based grouping when code-graph.db is absent or too large.
11
+ */
12
+
13
+ import Database from 'better-sqlite3';
14
+ import { createHash } from 'crypto';
15
+ import { DB_PATHS } from '../infrastructure/config/index.js';
16
+ import { applyReadPragmas } from '../infrastructure/db-utils.js';
17
+ import { leidenCommunities, findConnectedComponents } from './leiden-algorithm.js';
18
+
19
+ // Re-export for existing consumers
20
+ export { leidenCommunities } from './leiden-algorithm.js';
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Constants
24
+ // ---------------------------------------------------------------------------
25
+
26
+ const ENTITY_CUTOFF = 50_000;
27
+ const RELATIONSHIP_CUTOFF = 200_000;
28
+ const DEFAULT_TIMEOUT_MS = 2000;
29
+
30
+ // Relationship type weights for adjacency construction
31
+ const REL_WEIGHTS = {
32
+ imports: 3,
33
+ extends: 3,
34
+ implements: 3,
35
+ calls: 1,
36
+ uses: 1,
37
+ };
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Graph Hash
41
+ // ---------------------------------------------------------------------------
42
+
43
+ /**
44
+ * Compute sha256 hash of the code graph structure.
45
+ *
46
+ * @param {string} [dbPath] - Path to code-graph.db
47
+ * @returns {string} hex digest
48
+ */
49
+ export function computeGraphHash(dbPath) {
50
+ const resolvedPath = dbPath || DB_PATHS.codeGraph;
51
+ const db = new Database(resolvedPath, { readonly: true, timeout: 5000 });
52
+ applyReadPragmas(db);
53
+
54
+ try {
55
+ const rows = db.prepare(`
56
+ SELECT source_id, target_id, type
57
+ FROM relationships
58
+ ORDER BY source_id, target_id, type
59
+ `).all();
60
+
61
+ const hash = createHash('sha256');
62
+ for (const row of rows) {
63
+ hash.update(`${row.source_id}\t${row.target_id}\t${row.type}\n`);
64
+ }
65
+
66
+ return hash.digest('hex');
67
+ } finally {
68
+ db.close();
69
+ }
70
+ }
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Directory Fallback
74
+ // ---------------------------------------------------------------------------
75
+
76
+ /**
77
+ * Fallback: group entities by top-2 directory path segments.
78
+ *
79
+ * @param {Array<{id: number|string, name: string, file_path: string}>} entities
80
+ * @returns {Array<{id: number, entityIds: Array, fileIds: Array, entityCount: number}>}
81
+ */
82
+ export function fallbackDirectoryGrouping(entities) {
83
+ const groups = new Map();
84
+
85
+ for (const ent of entities) {
86
+ const fp = ent.file_path || '';
87
+ const parts = fp.split('/').filter(Boolean);
88
+ const dirKey = parts.length >= 2 ? parts.slice(0, 2).join('/') : parts[0] || '(root)';
89
+
90
+ if (!groups.has(dirKey)) {
91
+ groups.set(dirKey, { entityIds: new Set(), fileIds: new Set() });
92
+ }
93
+ const g = groups.get(dirKey);
94
+ g.entityIds.add(ent.id);
95
+ if (fp) g.fileIds.add(fp);
96
+ }
97
+
98
+ let communityId = 0;
99
+ const communities = [];
100
+ for (const [, group] of groups) {
101
+ communities.push({
102
+ id: communityId++,
103
+ entityIds: [...group.entityIds],
104
+ fileIds: [...group.fileIds],
105
+ entityCount: group.entityIds.size,
106
+ });
107
+ }
108
+
109
+ return communities;
110
+ }
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // Main API
114
+ // ---------------------------------------------------------------------------
115
+
116
+ /**
117
+ * Detect communities from code-graph.db relationships.
118
+ *
119
+ * @param {string} [dbPath] - Path to code-graph.db
120
+ * @param {object} [options]
121
+ * @param {number} [options.resolution=1.0]
122
+ * @param {number} [options.maxIterations=10]
123
+ * @param {number} [options.timeoutMs=2000]
124
+ * @returns {{ communities: Array, graphHash: string, stale: boolean }}
125
+ */
126
+ export function detectCommunities(dbPath, options = {}) {
127
+ const resolvedPath = dbPath || DB_PATHS.codeGraph;
128
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
129
+
130
+ let db;
131
+ try {
132
+ db = new Database(resolvedPath, { readonly: true, timeout: 5000 });
133
+ applyReadPragmas(db);
134
+ } catch {
135
+ return { communities: [], graphHash: '', stale: false };
136
+ }
137
+
138
+ try {
139
+ const entities = db.prepare(`
140
+ SELECT id, name, type, file_path
141
+ FROM entities
142
+ WHERE stale_since IS NULL
143
+ `).all();
144
+
145
+ const relationships = db.prepare(`
146
+ SELECT source_id, target_id, type
147
+ FROM relationships
148
+ `).all();
149
+
150
+ // Hard cutoffs: fall back to directory grouping
151
+ if (entities.length > ENTITY_CUTOFF || relationships.length > RELATIONSHIP_CUTOFF) {
152
+ const graphHash = computeGraphHashFromRows(relationships);
153
+ return {
154
+ communities: fallbackDirectoryGrouping(entities),
155
+ graphHash,
156
+ stale: false,
157
+ };
158
+ }
159
+
160
+ // Build weighted adjacency (undirected)
161
+ const adjacency = buildWeightedAdjacency(entities, relationships);
162
+
163
+ // Run Leiden
164
+ const startTime = Date.now();
165
+ const { assignment, converged } = leidenCommunities(adjacency, {
166
+ resolution: options.resolution,
167
+ maxIterations: options.maxIterations,
168
+ timeoutMs,
169
+ });
170
+ const elapsed = Date.now() - startTime;
171
+ const stale = !converged && elapsed >= timeoutMs;
172
+
173
+ // Build entity lookup for file_path mapping
174
+ const entityMap = new Map();
175
+ for (const ent of entities) entityMap.set(ent.id, ent);
176
+
177
+ // Group by community
178
+ const commGroups = new Map();
179
+ for (const [nodeId, commId] of assignment) {
180
+ if (!commGroups.has(commId)) {
181
+ commGroups.set(commId, { entityIds: [], fileIds: new Set() });
182
+ }
183
+ const g = commGroups.get(commId);
184
+ g.entityIds.push(nodeId);
185
+ const ent = entityMap.get(nodeId);
186
+ if (ent?.file_path) g.fileIds.add(ent.file_path);
187
+ }
188
+
189
+ // Connectivity sanity check per community (BFS on induced subgraph)
190
+ const finalCommunities = [];
191
+ let nextId = 0;
192
+
193
+ for (const [, group] of commGroups) {
194
+ const memberSet = new Set(group.entityIds);
195
+ const subAdj = new Map();
196
+ for (const nodeId of group.entityIds) {
197
+ const neighbors = adjacency.get(nodeId);
198
+ if (!neighbors) continue;
199
+ const subNeighbors = new Map();
200
+ for (const [neighbor, w] of neighbors) {
201
+ if (memberSet.has(neighbor)) subNeighbors.set(neighbor, w);
202
+ }
203
+ if (subNeighbors.size > 0) subAdj.set(nodeId, subNeighbors);
204
+ }
205
+
206
+ const components = findConnectedComponents(group.entityIds, subAdj);
207
+ for (const comp of components) {
208
+ const fileIds = new Set();
209
+ const entityNames = [];
210
+ for (const nodeId of comp) {
211
+ const ent = entityMap.get(nodeId);
212
+ if (ent?.file_path) fileIds.add(ent.file_path);
213
+ if (ent?.name) entityNames.push(ent.name);
214
+ }
215
+ finalCommunities.push({
216
+ id: nextId++,
217
+ entityIds: comp,
218
+ entityNames,
219
+ fileIds: [...fileIds],
220
+ entityCount: comp.length,
221
+ });
222
+ }
223
+ }
224
+
225
+ const graphHash = computeGraphHashFromRows(relationships);
226
+ return { communities: finalCommunities, graphHash, stale };
227
+ } finally {
228
+ db.close();
229
+ }
230
+ }
231
+
232
+ // ---------------------------------------------------------------------------
233
+ // Internal Helpers
234
+ // ---------------------------------------------------------------------------
235
+
236
+ /**
237
+ * Build weighted undirected adjacency from entities and relationships.
238
+ * @param {Array} entities
239
+ * @param {Array} relationships
240
+ * @returns {Map<number, Map<number, number>>}
241
+ */
242
+ function buildWeightedAdjacency(entities, relationships) {
243
+ const adjacency = new Map();
244
+ const entityIds = new Set();
245
+ for (const ent of entities) {
246
+ entityIds.add(ent.id);
247
+ adjacency.set(ent.id, new Map());
248
+ }
249
+
250
+ for (const rel of relationships) {
251
+ const src = rel.source_id;
252
+ const tgt = rel.target_id;
253
+ // Self-edges (src === tgt) are intentionally dropped: they have no
254
+ // effect on community structure (same-node edges don't influence
255
+ // modularity delta-Q), and Leiden's self-loop handling in local
256
+ // moving already skips them (leiden-algorithm.js line 172).
257
+ if (!src || !tgt || src === tgt) continue;
258
+ if (!entityIds.has(src) || !entityIds.has(tgt)) continue;
259
+
260
+ const w = REL_WEIGHTS[rel.type] || 1;
261
+
262
+ const srcNeighbors = adjacency.get(src);
263
+ const tgtNeighbors = adjacency.get(tgt);
264
+ srcNeighbors.set(tgt, (srcNeighbors.get(tgt) || 0) + w);
265
+ tgtNeighbors.set(src, (tgtNeighbors.get(src) || 0) + w);
266
+ }
267
+
268
+ return adjacency;
269
+ }
270
+
271
+ /**
272
+ * Compute graph hash from relationship rows (avoids re-querying DB).
273
+ * Sorts a copy to avoid mutating caller-owned arrays.
274
+ */
275
+ function computeGraphHashFromRows(relationships) {
276
+ const sorted = [...relationships].sort((a, b) => {
277
+ if (a.source_id !== b.source_id) return a.source_id - b.source_id;
278
+ if (a.target_id !== b.target_id) return a.target_id - b.target_id;
279
+ return (a.type || '') < (b.type || '') ? -1 : (a.type || '') > (b.type || '') ? 1 : 0;
280
+ });
281
+
282
+ const hash = createHash('sha256');
283
+ for (const row of sorted) {
284
+ hash.update(`${row.source_id}\t${row.target_id}\t${row.type}\n`);
285
+ }
286
+ return hash.digest('hex');
287
+ }
288
+
289
+ export default {
290
+ detectCommunities,
291
+ computeGraphHash,
292
+ leidenCommunities,
293
+ fallbackDirectoryGrouping,
294
+ };