@monoes/monograph 1.3.0 → 1.4.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 (301) hide show
  1. package/.monomind/registry.json +6 -0
  2. package/__tests__/staleness/git-staleness.test.ts +3 -3
  3. package/dist/src/analysis/benchmark.js.map +1 -1
  4. package/dist/src/analysis/changed-files.d.ts.map +1 -1
  5. package/dist/src/analysis/changed-files.js +11 -2
  6. package/dist/src/analysis/changed-files.js.map +1 -1
  7. package/dist/src/analysis/changed-workspaces.d.ts.map +1 -1
  8. package/dist/src/analysis/changed-workspaces.js +11 -3
  9. package/dist/src/analysis/changed-workspaces.js.map +1 -1
  10. package/dist/src/analysis/churn.d.ts +1 -1
  11. package/dist/src/analysis/churn.d.ts.map +1 -1
  12. package/dist/src/analysis/churn.js +56 -10
  13. package/dist/src/analysis/churn.js.map +1 -1
  14. package/dist/src/analysis/cluster-quality.d.ts +4 -4
  15. package/dist/src/analysis/cluster-quality.d.ts.map +1 -1
  16. package/dist/src/analysis/cluster-quality.js +24 -27
  17. package/dist/src/analysis/cluster-quality.js.map +1 -1
  18. package/dist/src/analysis/combined.d.ts.map +1 -1
  19. package/dist/src/analysis/combined.js +2 -0
  20. package/dist/src/analysis/combined.js.map +1 -1
  21. package/dist/src/analysis/feature-flags.d.ts.map +1 -1
  22. package/dist/src/analysis/feature-flags.js +11 -5
  23. package/dist/src/analysis/feature-flags.js.map +1 -1
  24. package/dist/src/analysis/progress.d.ts.map +1 -1
  25. package/dist/src/analysis/project-state.d.ts.map +1 -1
  26. package/dist/src/analysis/shape-extractor.js.map +1 -1
  27. package/dist/src/analysis/suppression-context.d.ts.map +1 -1
  28. package/dist/src/analysis/surprise-scorer.js.map +1 -1
  29. package/dist/src/analysis/targets.d.ts.map +1 -1
  30. package/dist/src/analysis/targets.js +4 -5
  31. package/dist/src/analysis/targets.js.map +1 -1
  32. package/dist/src/analysis/worker-pool.d.ts.map +1 -1
  33. package/dist/src/analysis/worker-pool.js.map +1 -1
  34. package/dist/src/analyze/unused-deps.js.map +1 -1
  35. package/dist/src/analyze/unused-exports.js.map +1 -1
  36. package/dist/src/analyze/unused-files.js.map +1 -1
  37. package/dist/src/cache/augment-cache.js.map +1 -1
  38. package/dist/src/cache/extraction-cache.d.ts +21 -0
  39. package/dist/src/cache/extraction-cache.d.ts.map +1 -1
  40. package/dist/src/cache/extraction-cache.js +62 -6
  41. package/dist/src/cache/extraction-cache.js.map +1 -1
  42. package/dist/src/check/index.js.map +1 -1
  43. package/dist/src/cli/check-update.js.map +1 -1
  44. package/dist/src/cli/doctor.d.ts.map +1 -1
  45. package/dist/src/cli/doctor.js +6 -10
  46. package/dist/src/cli/doctor.js.map +1 -1
  47. package/dist/src/config/boundary-config.js.map +1 -1
  48. package/dist/src/config/config-parsing.d.ts +1 -1
  49. package/dist/src/config/config-parsing.d.ts.map +1 -1
  50. package/dist/src/config/migration-types.d.ts +2 -2
  51. package/dist/src/config/migration-types.d.ts.map +1 -1
  52. package/dist/src/config/migration-types.js.map +1 -1
  53. package/dist/src/config/used-class-members.js.map +1 -1
  54. package/dist/src/config/workspace.js.map +1 -1
  55. package/dist/src/coverage/upload-source-maps.js.map +1 -1
  56. package/dist/src/duplicates/detect/filtering.d.ts.map +1 -1
  57. package/dist/src/duplicates/shingle-filter.js.map +1 -1
  58. package/dist/src/error/fallow-error.d.ts.map +1 -1
  59. package/dist/src/error/fallow-error.js.map +1 -1
  60. package/dist/src/error.d.ts.map +1 -1
  61. package/dist/src/error.js.map +1 -1
  62. package/dist/src/export/analysis-json.js +1 -1
  63. package/dist/src/export/analysis-json.js.map +1 -1
  64. package/dist/src/export/badge.js.map +1 -1
  65. package/dist/src/export/dot.js.map +1 -1
  66. package/dist/src/export/sarif.d.ts.map +1 -1
  67. package/dist/src/export/sarif.js +13 -2
  68. package/dist/src/export/sarif.js.map +1 -1
  69. package/dist/src/fix/orchestrator.js.map +1 -1
  70. package/dist/src/graph/baseline.d.ts.map +1 -1
  71. package/dist/src/graph/baseline.js +29 -20
  72. package/dist/src/graph/baseline.js.map +1 -1
  73. package/dist/src/graph/cycle-detection.js.map +1 -1
  74. package/dist/src/graph/dead-code.d.ts +7 -2
  75. package/dist/src/graph/dead-code.d.ts.map +1 -1
  76. package/dist/src/graph/dead-code.js +19 -16
  77. package/dist/src/graph/dead-code.js.map +1 -1
  78. package/dist/src/graph/dfs.js.map +1 -1
  79. package/dist/src/graph/edge-filter.js.map +1 -1
  80. package/dist/src/graph/health-score.d.ts.map +1 -1
  81. package/dist/src/graph/health-score.js +11 -14
  82. package/dist/src/graph/health-score.js.map +1 -1
  83. package/dist/src/graph/hotspots.d.ts.map +1 -1
  84. package/dist/src/graph/hotspots.js +8 -4
  85. package/dist/src/graph/hotspots.js.map +1 -1
  86. package/dist/src/graph/import-chain.js.map +1 -1
  87. package/dist/src/graph/merge.js.map +1 -1
  88. package/dist/src/graph/node-search.js.map +1 -1
  89. package/dist/src/graph/node-types.d.ts.map +1 -1
  90. package/dist/src/graph/pagerank.d.ts.map +1 -1
  91. package/dist/src/graph/pagerank.js +10 -1
  92. package/dist/src/graph/pagerank.js.map +1 -1
  93. package/dist/src/graph/regex-search.js.map +1 -1
  94. package/dist/src/graph/regression.d.ts.map +1 -1
  95. package/dist/src/graph/resolve/discover-types.js.map +1 -1
  96. package/dist/src/graph/resolve/types.d.ts +3 -3
  97. package/dist/src/graph/resolve/types.d.ts.map +1 -1
  98. package/dist/src/graph/statistics.d.ts +3 -2
  99. package/dist/src/graph/statistics.d.ts.map +1 -1
  100. package/dist/src/graph/statistics.js +18 -7
  101. package/dist/src/graph/statistics.js.map +1 -1
  102. package/dist/src/graph/stats.d.ts.map +1 -1
  103. package/dist/src/graph/stats.js +2 -1
  104. package/dist/src/graph/stats.js.map +1 -1
  105. package/dist/src/groups/group-search.d.ts.map +1 -1
  106. package/dist/src/groups/group-search.js +9 -18
  107. package/dist/src/groups/group-search.js.map +1 -1
  108. package/dist/src/health/complexity-findings.js.map +1 -1
  109. package/dist/src/health/grouping.js.map +1 -1
  110. package/dist/src/health/health-report-types.js.map +1 -1
  111. package/dist/src/health/health-report.js.map +1 -1
  112. package/dist/src/health/targets.js.map +1 -1
  113. package/dist/src/index.d.ts +3 -0
  114. package/dist/src/index.d.ts.map +1 -1
  115. package/dist/src/index.js +3 -0
  116. package/dist/src/index.js.map +1 -1
  117. package/dist/src/ingest/transcribe.js.map +1 -1
  118. package/dist/src/ingest/url-ingest.js.map +1 -1
  119. package/dist/src/license/manager.d.ts.map +1 -1
  120. package/dist/src/license/manager.js.map +1 -1
  121. package/dist/src/lsp/diagnostics-push.js.map +1 -1
  122. package/dist/src/mcp-tools/impact.d.ts.map +1 -1
  123. package/dist/src/mcp-tools/impact.js +7 -1
  124. package/dist/src/mcp-tools/impact.js.map +1 -1
  125. package/dist/src/mcp-tools/list-repos.d.ts +1 -1
  126. package/dist/src/mcp-tools/list-repos.d.ts.map +1 -1
  127. package/dist/src/mcp-tools/query.d.ts +1 -1
  128. package/dist/src/mcp-tools/query.d.ts.map +1 -1
  129. package/dist/src/parsers/c.js.map +1 -1
  130. package/dist/src/parsers/cpp.js.map +1 -1
  131. package/dist/src/parsers/csharp.js.map +1 -1
  132. package/dist/src/parsers/dart.js.map +1 -1
  133. package/dist/src/parsers/go.js.map +1 -1
  134. package/dist/src/parsers/java.js.map +1 -1
  135. package/dist/src/parsers/kotlin.js.map +1 -1
  136. package/dist/src/parsers/loader.js.map +1 -1
  137. package/dist/src/parsers/php.js.map +1 -1
  138. package/dist/src/parsers/python.js.map +1 -1
  139. package/dist/src/parsers/ruby.js.map +1 -1
  140. package/dist/src/parsers/rust.js.map +1 -1
  141. package/dist/src/parsers/swift.js.map +1 -1
  142. package/dist/src/parsers/typescript.js.map +1 -1
  143. package/dist/src/parsers/vue.js.map +1 -1
  144. package/dist/src/pipeline/effort.js.map +1 -1
  145. package/dist/src/pipeline/orchestrator.d.ts.map +1 -1
  146. package/dist/src/pipeline/orchestrator.js +85 -24
  147. package/dist/src/pipeline/orchestrator.js.map +1 -1
  148. package/dist/src/pipeline/phases/bridge-adapters/electron-ipc.d.ts +11 -0
  149. package/dist/src/pipeline/phases/bridge-adapters/electron-ipc.d.ts.map +1 -0
  150. package/dist/src/pipeline/phases/bridge-adapters/electron-ipc.js +96 -0
  151. package/dist/src/pipeline/phases/bridge-adapters/electron-ipc.js.map +1 -0
  152. package/dist/src/pipeline/phases/bridge-adapters/registry.d.ts +3 -0
  153. package/dist/src/pipeline/phases/bridge-adapters/registry.d.ts.map +1 -0
  154. package/dist/src/pipeline/phases/bridge-adapters/registry.js +13 -0
  155. package/dist/src/pipeline/phases/bridge-adapters/registry.js.map +1 -0
  156. package/dist/src/pipeline/phases/bridge-adapters/tauri.d.ts +10 -0
  157. package/dist/src/pipeline/phases/bridge-adapters/tauri.d.ts.map +1 -0
  158. package/dist/src/pipeline/phases/bridge-adapters/tauri.js +104 -0
  159. package/dist/src/pipeline/phases/bridge-adapters/tauri.js.map +1 -0
  160. package/dist/src/pipeline/phases/bridge-adapters/types.d.ts +30 -0
  161. package/dist/src/pipeline/phases/bridge-adapters/types.d.ts.map +1 -0
  162. package/dist/src/pipeline/phases/bridge-adapters/types.js +2 -0
  163. package/dist/src/pipeline/phases/bridge-adapters/types.js.map +1 -0
  164. package/dist/src/pipeline/phases/bridge-adapters/wails.d.ts +10 -0
  165. package/dist/src/pipeline/phases/bridge-adapters/wails.d.ts.map +1 -0
  166. package/dist/src/pipeline/phases/bridge-adapters/wails.js +27 -0
  167. package/dist/src/pipeline/phases/bridge-adapters/wails.js.map +1 -0
  168. package/dist/src/pipeline/phases/bridge-resolver.d.ts +23 -0
  169. package/dist/src/pipeline/phases/bridge-resolver.d.ts.map +1 -0
  170. package/dist/src/pipeline/phases/bridge-resolver.js +70 -0
  171. package/dist/src/pipeline/phases/bridge-resolver.js.map +1 -0
  172. package/dist/src/pipeline/phases/cross-file.d.ts.map +1 -1
  173. package/dist/src/pipeline/phases/cross-file.js +70 -27
  174. package/dist/src/pipeline/phases/cross-file.js.map +1 -1
  175. package/dist/src/pipeline/phases/leiden.js.map +1 -1
  176. package/dist/src/pipeline/phases/parse.d.ts.map +1 -1
  177. package/dist/src/pipeline/phases/parse.js +58 -17
  178. package/dist/src/pipeline/phases/parse.js.map +1 -1
  179. package/dist/src/pipeline/phases/scan.d.ts.map +1 -1
  180. package/dist/src/pipeline/phases/scan.js +12 -5
  181. package/dist/src/pipeline/phases/scan.js.map +1 -1
  182. package/dist/src/pipeline/phases/scope-resolution.d.ts +13 -0
  183. package/dist/src/pipeline/phases/scope-resolution.d.ts.map +1 -1
  184. package/dist/src/pipeline/phases/scope-resolution.js +18 -4
  185. package/dist/src/pipeline/phases/scope-resolution.js.map +1 -1
  186. package/dist/src/pipeline/progress.d.ts.map +1 -1
  187. package/dist/src/pipeline/runner.d.ts.map +1 -1
  188. package/dist/src/pipeline/runner.js.map +1 -1
  189. package/dist/src/pipeline/types.d.ts +2 -0
  190. package/dist/src/pipeline/types.d.ts.map +1 -1
  191. package/dist/src/pipeline/types.js +1 -0
  192. package/dist/src/pipeline/types.js.map +1 -1
  193. package/dist/src/regression/baseline.js.map +1 -1
  194. package/dist/src/regression/outcome.js.map +1 -1
  195. package/dist/src/report/badge-svg.js.map +1 -1
  196. package/dist/src/report/duplication-grouping.js.map +1 -1
  197. package/dist/src/report/human-check.js.map +1 -1
  198. package/dist/src/report/human-dupes.js.map +1 -1
  199. package/dist/src/report/human-health.js.map +1 -1
  200. package/dist/src/report/json-builders.js.map +1 -1
  201. package/dist/src/report/number-format.js.map +1 -1
  202. package/dist/src/reporting/graph-report.d.ts.map +1 -1
  203. package/dist/src/reporting/graph-report.js +8 -9
  204. package/dist/src/reporting/graph-report.js.map +1 -1
  205. package/dist/src/scope-resolution/evidence-weights.d.ts.map +1 -1
  206. package/dist/src/search/hybrid-query.js.map +1 -1
  207. package/dist/src/security/safe-fetch.js.map +1 -1
  208. package/dist/src/staleness/git-staleness.d.ts.map +1 -1
  209. package/dist/src/staleness/git-staleness.js +5 -2
  210. package/dist/src/staleness/git-staleness.js.map +1 -1
  211. package/dist/src/storage/db.d.ts +13 -1
  212. package/dist/src/storage/db.d.ts.map +1 -1
  213. package/dist/src/storage/db.js +23 -2
  214. package/dist/src/storage/db.js.map +1 -1
  215. package/dist/src/storage/edge-store.d.ts +6 -0
  216. package/dist/src/storage/edge-store.d.ts.map +1 -1
  217. package/dist/src/storage/edge-store.js +8 -1
  218. package/dist/src/storage/edge-store.js.map +1 -1
  219. package/dist/src/storage/fts-store.d.ts.map +1 -1
  220. package/dist/src/storage/fts-store.js +31 -9
  221. package/dist/src/storage/fts-store.js.map +1 -1
  222. package/dist/src/storage/node-store.js.map +1 -1
  223. package/dist/src/storage/schema.d.ts +5 -0
  224. package/dist/src/storage/schema.d.ts.map +1 -1
  225. package/dist/src/storage/schema.js +25 -0
  226. package/dist/src/storage/schema.js.map +1 -1
  227. package/dist/src/storage/unlinked-refs.js.map +1 -1
  228. package/dist/src/types.d.ts +2 -2
  229. package/dist/src/types.d.ts.map +1 -1
  230. package/dist/src/types.js.map +1 -1
  231. package/dist/src/watch/watcher.d.ts.map +1 -1
  232. package/dist/src/watch/watcher.js +12 -2
  233. package/dist/src/watch/watcher.js.map +1 -1
  234. package/dist/src/web/analyze-api.d.ts.map +1 -1
  235. package/dist/src/web/analyze-api.js +3 -1
  236. package/dist/src/web/analyze-api.js.map +1 -1
  237. package/dist/src/web/api.d.ts +3 -0
  238. package/dist/src/web/api.d.ts.map +1 -1
  239. package/dist/src/web/api.js +59 -20
  240. package/dist/src/web/api.js.map +1 -1
  241. package/dist/src/web/async-jobs.d.ts.map +1 -1
  242. package/dist/src/web/async-jobs.js +15 -0
  243. package/dist/src/web/async-jobs.js.map +1 -1
  244. package/dist/src/web/react-ui.js +2 -2
  245. package/dist/src/wiki/providers.js.map +1 -1
  246. package/dist/src/wiki/wiki-generator.d.ts.map +1 -1
  247. package/dist/src/wiki/wiki-generator.js +9 -6
  248. package/dist/src/wiki/wiki-generator.js.map +1 -1
  249. package/dist/tsconfig.tsbuildinfo +1 -1
  250. package/package.json +5 -2
  251. package/src/__tests__/graph/dead-code.test.ts +27 -7
  252. package/src/__tests__/pipeline/phases/bridge-adapters/electron-ipc.test.ts +87 -0
  253. package/src/__tests__/pipeline/phases/bridge-adapters/tauri.test.ts +116 -0
  254. package/src/__tests__/pipeline/phases/bridge-adapters/wails.test.ts +81 -0
  255. package/src/__tests__/pipeline/phases/bridge-resolver.test.ts +110 -0
  256. package/src/analysis/changed-files.ts +14 -2
  257. package/src/analysis/changed-workspaces.ts +13 -4
  258. package/src/analysis/churn.ts +57 -10
  259. package/src/analysis/cluster-quality.ts +25 -26
  260. package/src/analysis/combined.ts +2 -0
  261. package/src/analysis/feature-flags.ts +10 -5
  262. package/src/analysis/targets.ts +4 -5
  263. package/src/cache/extraction-cache.ts +59 -6
  264. package/src/cli/doctor.ts +6 -10
  265. package/src/export/analysis-json.ts +1 -1
  266. package/src/export/sarif.ts +11 -2
  267. package/src/graph/baseline.ts +29 -20
  268. package/src/graph/dead-code.ts +19 -16
  269. package/src/graph/health-score.ts +13 -16
  270. package/src/graph/hotspots.ts +8 -4
  271. package/src/graph/pagerank.ts +10 -1
  272. package/src/graph/statistics.ts +19 -7
  273. package/src/graph/stats.ts +2 -1
  274. package/src/groups/group-search.ts +9 -27
  275. package/src/index.ts +3 -0
  276. package/src/mcp-tools/impact.ts +7 -1
  277. package/src/pipeline/orchestrator.ts +87 -26
  278. package/src/pipeline/phases/bridge-adapters/electron-ipc.ts +91 -0
  279. package/src/pipeline/phases/bridge-adapters/registry.ts +14 -0
  280. package/src/pipeline/phases/bridge-adapters/tauri.ts +103 -0
  281. package/src/pipeline/phases/bridge-adapters/types.ts +31 -0
  282. package/src/pipeline/phases/bridge-adapters/wails.ts +33 -0
  283. package/src/pipeline/phases/bridge-resolver.ts +77 -0
  284. package/src/pipeline/phases/cross-file.ts +75 -27
  285. package/src/pipeline/phases/parse.ts +60 -17
  286. package/src/pipeline/phases/scan.ts +11 -5
  287. package/src/pipeline/phases/scope-resolution.ts +19 -4
  288. package/src/pipeline/types.ts +3 -0
  289. package/src/reporting/graph-report.ts +8 -9
  290. package/src/staleness/git-staleness.ts +5 -2
  291. package/src/storage/db.ts +35 -1
  292. package/src/storage/edge-store.ts +8 -1
  293. package/src/storage/fts-store.ts +31 -9
  294. package/src/storage/schema.ts +30 -0
  295. package/src/watch/watcher.ts +11 -2
  296. package/src/web/analyze-api.ts +3 -1
  297. package/src/web/api.ts +62 -22
  298. package/src/web/async-jobs.ts +15 -0
  299. package/src/web/react-ui.ts +2 -2
  300. package/src/wiki/wiki-generator.ts +9 -6
  301. package/tsconfig.json +2 -1
@@ -1,53 +1,101 @@
1
+ import { readFileSync } from 'fs';
2
+ import { join } from 'path';
1
3
  import type { PipelinePhase } from '../types.js';
2
4
  import type { MonographEdge } from '../../types.js';
3
5
  import { makeId, CONFIDENCE_SCORE } from '../../types.js';
4
6
  import { insertEdges } from '../../storage/edge-store.js';
5
7
  import type { ParseOutput } from './parse.js';
6
8
  import type { StructureOutput } from './structure.js';
9
+ import { resolveModuleSpecifier, buildWorkspacePackageMap } from './scope-resolution.js';
7
10
 
8
11
  export interface CrossFileOutput {
9
12
  resolvedEdges: MonographEdge[];
10
13
  }
11
14
 
15
+ // Mirrors parsers/extractor.ts's re-export detection regex — used here to recover
16
+ // the ORIGINAL specifier string behind a RE_EXPORTS edge's mangled placeholder id
17
+ // (makeId('import', specifier) is lossy: '/' and '.' are collapsed to '_', so the
18
+ // placeholder can't be reversed directly — we re-derive it from source instead).
19
+ const RE_EXPORT_RE = /export\s+(?:\*|\{[^}]*\})\s+from\s+['"]([^'"]+)['"]/g;
20
+
12
21
  // monolean: IMPORTS name-matching removed — it matched import names against a
13
22
  // global symbol index (e.g. `import fs` → random Variable named `fs` in wrong file).
14
23
  // scope-resolution.ts now handles cross-file CALLS via source-parsed import maps.
15
24
  export const crossFilePhase: PipelinePhase<CrossFileOutput> = {
16
25
  name: 'cross-file',
17
26
  deps: ['parse', 'structure'],
18
- async execute(_ctx, deps) {
27
+ async execute(ctx, deps) {
19
28
  const { allEdges } = deps.get('parse') as ParseOutput;
20
29
  const { fileNodes } = deps.get('structure') as StructureOutput;
21
30
 
22
- function lastSegment(s: string): string {
23
- const idx = s.lastIndexOf('/');
24
- return idx === -1 ? s : s.slice(idx + 1);
25
- }
31
+ const reExportEdges = allEdges.filter((e) => e.relation === 'RE_EXPORTS');
32
+ const resolvedEdges: MonographEdge[] = [];
26
33
 
27
- function targetBasename(rawId: string): string {
28
- const stripped = rawId.startsWith('import_') ? rawId.slice(7) : rawId;
29
- return lastSegment(stripped);
30
- }
34
+ if (reExportEdges.length > 0) {
35
+ // fileId <-> filePath maps for resolving edge endpoints to real file nodes.
36
+ const fileIdToPath = new Map<string, string>();
37
+ const knownFiles = new Set<string>();
38
+ for (const fileNode of fileNodes) {
39
+ if (fileNode.filePath) {
40
+ fileIdToPath.set(fileNode.id, fileNode.filePath);
41
+ knownFiles.add(fileNode.filePath);
42
+ }
43
+ }
44
+ const filePathToId = new Map<string, string>();
45
+ for (const [id, path] of fileIdToPath) filePathToId.set(path, id);
31
46
 
32
- const fileBasenameIndex = new Map<string, string>();
33
- for (const fileNode of fileNodes) {
34
- const basename = lastSegment(fileNode.filePath ?? '').toLowerCase();
35
- const dotIdx = basename.lastIndexOf('.');
36
- const noExt = dotIdx === -1 ? basename : basename.slice(0, dotIdx);
37
- fileBasenameIndex.set(basename, fileNode.id);
38
- if (noExt !== basename) fileBasenameIndex.set(noExt, fileNode.id);
39
- }
47
+ const workspaceMap = buildWorkspacePackageMap(ctx.repoPath);
40
48
 
41
- const resolvedEdges: MonographEdge[] = [];
49
+ // Group by importing file so each source file is read at most once.
50
+ const edgesBySource = new Map<string, MonographEdge[]>();
51
+ for (const edge of reExportEdges) {
52
+ const arr = edgesBySource.get(edge.sourceId);
53
+ if (arr) arr.push(edge);
54
+ else edgesBySource.set(edge.sourceId, [edge]);
55
+ }
56
+
57
+ for (const [sourceId, edges] of edgesBySource) {
58
+ const filePath = fileIdToPath.get(sourceId);
59
+ if (!filePath) continue;
60
+
61
+ let source: string;
62
+ try {
63
+ source = readFileSync(join(ctx.repoPath, filePath), 'utf-8');
64
+ } catch {
65
+ continue;
66
+ }
67
+
68
+ // Recover each `export * / { ... } from '<specifier>'` string, keyed by the
69
+ // same mangled placeholder id the extractor used (makeId('import', specifier))
70
+ // so it can be matched back to the corresponding edge.
71
+ const specifierByPlaceholder = new Map<string, string>();
72
+ RE_EXPORT_RE.lastIndex = 0;
73
+ let m: RegExpExecArray | null;
74
+ while ((m = RE_EXPORT_RE.exec(source)) !== null) {
75
+ const specifier = m[1]!;
76
+ specifierByPlaceholder.set(makeId('import', specifier), specifier);
77
+ }
78
+
79
+ for (const edge of edges) {
80
+ const specifier = specifierByPlaceholder.get(edge.targetId);
81
+ if (!specifier) continue;
82
+
83
+ // Resolve relative to THIS importing file's own directory (reusing the
84
+ // same resolver scope-resolution.ts uses for IMPORTS) instead of an
85
+ // ambiguous global basename lookup that collides on repeated basenames
86
+ // like index.ts / utils.ts across the repo.
87
+ const resolvedPath = resolveModuleSpecifier(
88
+ filePath,
89
+ specifier,
90
+ ctx.repoPath,
91
+ knownFiles,
92
+ workspaceMap,
93
+ );
94
+ if (!resolvedPath) continue;
42
95
 
43
- for (const edge of allEdges) {
44
- if (edge.relation === 'RE_EXPORTS') {
45
- const basename = targetBasename(edge.targetId).toLowerCase();
46
- const dotIdx = basename.lastIndexOf('.');
47
- const noExt = dotIdx === -1 ? basename : basename.slice(0, dotIdx);
48
- const resolvedId = fileBasenameIndex.get(basename) ?? fileBasenameIndex.get(noExt);
96
+ const resolvedId = filePathToId.get(resolvedPath);
97
+ if (!resolvedId || resolvedId === edge.targetId) continue;
49
98
 
50
- if (resolvedId && resolvedId !== edge.targetId) {
51
99
  resolvedEdges.push({
52
100
  ...edge,
53
101
  id: makeId(edge.sourceId, resolvedId, 'reexports_resolved'),
@@ -59,8 +107,8 @@ export const crossFilePhase: PipelinePhase<CrossFileOutput> = {
59
107
  }
60
108
  }
61
109
 
62
- if (_ctx.db && resolvedEdges.length > 0) {
63
- insertEdges(_ctx.db, resolvedEdges);
110
+ if (ctx.db && resolvedEdges.length > 0) {
111
+ insertEdges(ctx.db, resolvedEdges);
64
112
  }
65
113
 
66
114
  return { resolvedEdges };
@@ -3,8 +3,8 @@ import { extname, join } from 'path';
3
3
  import type { PipelinePhase, PipelineContext } from '../types.js';
4
4
  import type { MonographNode, MonographEdge } from '../../types.js';
5
5
  import { parseFile } from '../../parsers/loader.js';
6
- import { insertNodes } from '../../storage/node-store.js';
7
- import { insertEdges } from '../../storage/edge-store.js';
6
+ import { insertNodes, deleteNodesForFile } from '../../storage/node-store.js';
7
+ import { insertEdges, deleteEdgesForFile } from '../../storage/edge-store.js';
8
8
  import type { StructureOutput } from './structure.js';
9
9
  import { extractVariables, variableToNode } from './variables.js';
10
10
  import { ExtractionCache } from '../../cache/extraction-cache.js';
@@ -24,8 +24,18 @@ export const parsePhase: PipelinePhase<ParseOutput> = {
24
24
  const { fileNodes } = deps.get('structure') as StructureOutput;
25
25
  const symbolNodes: MonographNode[] = [];
26
26
  const allEdges: MonographEdge[] = [];
27
- const freshNodes: MonographNode[] = [];
28
- const freshEdges: MonographEdge[] = [];
27
+ // freshNodes/freshEdges (the cache-miss-only subset) are NOT accumulated in a
28
+ // parallel array during the loop — that duplicated every fresh node/edge object
29
+ // in memory for the entire pipeline run. Instead we record the [start, end)
30
+ // slice of symbolNodes/allEdges that each cache-miss file contributed, and
31
+ // materialize the fresh subset via a single slice pass at the end, only when
32
+ // it's actually needed (cacheHits > 0). On a cold build (cacheHits === 0) the
33
+ // fresh subset is never materialized at all, since nodesToInsert falls back to
34
+ // symbolNodes/allEdges directly in that case — eliminating the redundant
35
+ // allocation entirely for the memory-heaviest scenario.
36
+ const freshNodeRanges: Array<[number, number]> = [];
37
+ const freshEdgeRanges: Array<[number, number]> = [];
38
+ const staleFilePaths: string[] = [];
29
39
  const parseErrors: string[] = [];
30
40
  const fileContents = new Map<string, string>();
31
41
  let processed = 0;
@@ -39,8 +49,10 @@ export const parsePhase: PipelinePhase<ParseOutput> = {
39
49
  const ext = extname(absPath).toLowerCase();
40
50
  if (ext === '.md' || ext === '.markdown') { processed++; continue; }
41
51
 
42
- // Fast path: mtime+size check avoids reading file content entirely
43
- const cached = cache.getWithStat(absPath);
52
+ // Fast path: mtime+size check avoids reading file content entirely.
53
+ // Skipped entirely under --force so a force rebuild is a genuine from-scratch
54
+ // parse, not a replay of whatever extraction the cache happens to hold.
55
+ const cached = ctx.options.force ? null : cache.getWithStat(absPath);
44
56
  if (cached) {
45
57
  symbolNodes.push(...cached.nodes);
46
58
  allEdges.push(...cached.edges);
@@ -54,7 +66,12 @@ export const parsePhase: PipelinePhase<ParseOutput> = {
54
66
  continue;
55
67
  }
56
68
  source = readFileSync(absPath, 'utf-8');
57
- } catch { continue; }
69
+ } catch (err) {
70
+ const code = (err as NodeJS.ErrnoException)?.code;
71
+ const detail = code || (err instanceof Error ? err.message : String(err));
72
+ parseErrors.push(`${fileNode.filePath}: unreadable (${detail})`);
73
+ continue;
74
+ }
58
75
  fileContents.set(fileNode.filePath ?? absPath, source);
59
76
  const result = await parseFile(absPath, source, fileNode.filePath ?? '');
60
77
  const fileSymbols: MonographNode[] = [...result.nodes];
@@ -98,10 +115,16 @@ export const parsePhase: PipelinePhase<ParseOutput> = {
98
115
  const fileHash = cache.hashContent(source);
99
116
  cache.setDeferred(absPath, fileHash, fileSymbols, fileEdges);
100
117
  } catch { /* non-fatal */ }
118
+ const nodeStart = symbolNodes.length;
119
+ const edgeStart = allEdges.length;
101
120
  symbolNodes.push(...fileSymbols);
102
121
  allEdges.push(...fileEdges);
103
- freshNodes.push(...fileSymbols);
104
- freshEdges.push(...fileEdges);
122
+ freshNodeRanges.push([nodeStart, symbolNodes.length]);
123
+ freshEdgeRanges.push([edgeStart, allEdges.length]);
124
+ // This file was re-parsed (cache miss) — its OLD node/edge set (from the
125
+ // previous build) must be deleted before the fresh rows are inserted below,
126
+ // otherwise a renamed/removed symbol's old row survives forever (ghost rows).
127
+ if (fileNode.filePath) staleFilePaths.push(fileNode.filePath);
105
128
  cacheMisses++;
106
129
  }
107
130
  processed++;
@@ -114,16 +137,35 @@ export const parsePhase: PipelinePhase<ParseOutput> = {
114
137
  cache.flush();
115
138
 
116
139
  if (ctx.db) {
140
+ const db = ctx.db;
117
141
  // Only insert freshly-parsed nodes — cached nodes are already in the DB
118
142
  // from the previous build (node IDs are deterministic from file_path + symbol).
119
- // On first build (no cache hits), freshNodes === symbolNodes.
120
- const nodesToInsert = cacheHits > 0 ? freshNodes : symbolNodes;
121
- insertNodes(ctx.db, nodesToInsert);
143
+ // On first build (no cache hits), freshNodes === symbolNodes, so the fresh
144
+ // subset is never materialized symbolNodes/allEdges are used directly.
145
+ const freshNodes = cacheHits > 0
146
+ ? freshNodeRanges.flatMap(([s, e]) => symbolNodes.slice(s, e))
147
+ : symbolNodes;
148
+ const freshEdges = cacheHits > 0
149
+ ? freshEdgeRanges.flatMap(([s, e]) => allEdges.slice(s, e))
150
+ : allEdges;
151
+ const nodesToInsert = freshNodes;
122
152
  const knownIds = new Set(symbolNodes.map(n => n.id));
123
- const edgesToInsert = cacheHits > 0
124
- ? freshEdges.filter(e => knownIds.has(e.targetId))
125
- : allEdges.filter(e => knownIds.has(e.targetId));
126
- insertEdges(ctx.db, edgesToInsert);
153
+ const edgesToInsert = freshEdges.filter(e => knownIds.has(e.targetId));
154
+
155
+ // For every cache-miss file, purge its OLD node/edge set BEFORE inserting the
156
+ // fresh parse results, inside the same transaction — otherwise a renamed or
157
+ // removed symbol's old row would survive forever (INSERT OR REPLACE only
158
+ // overwrites rows whose id still matches; it never deletes rows whose id
159
+ // disappeared because the symbol was renamed).
160
+ const writeAll = db.transaction(() => {
161
+ for (const filePath of staleFilePaths) {
162
+ deleteEdgesForFile(db, filePath);
163
+ deleteNodesForFile(db, filePath);
164
+ }
165
+ insertNodes(db, nodesToInsert);
166
+ insertEdges(db, edgesToInsert);
167
+ });
168
+ writeAll();
127
169
  }
128
170
 
129
171
  if (cacheHits > 0 && cacheMisses === 0) {
@@ -131,8 +173,9 @@ export const parsePhase: PipelinePhase<ParseOutput> = {
131
173
  }
132
174
 
133
175
  if (cacheHits > 0) {
176
+ const freshNodeCount = freshNodeRanges.reduce((sum, [s, e]) => sum + (e - s), 0);
134
177
  ctx.onProgress?.({ phase: 'parse', filesProcessed: processed, totalFiles: fileNodes.length,
135
- message: `cache: ${cacheHits} hits, ${cacheMisses} misses (${freshNodes.length} nodes inserted)` } as any);
178
+ message: `cache: ${cacheHits} hits, ${cacheMisses} misses (${freshNodeCount} nodes inserted)` } as any);
136
179
  }
137
180
 
138
181
  return { symbolNodes, allEdges, parseErrors, fileContents, cacheStats: { hits: cacheHits, misses: cacheMisses } };
@@ -1,4 +1,4 @@
1
- import { readdirSync, statSync, existsSync, readFileSync } from 'fs';
1
+ import { readdirSync, statSync, lstatSync, existsSync, readFileSync } from 'fs';
2
2
  import { join, extname } from 'path';
3
3
  import micromatch from 'micromatch';
4
4
  import type { PipelinePhase, PipelineContext } from '../types.js';
@@ -50,16 +50,22 @@ export const scanPhase: PipelinePhase<ScanOutput> = {
50
50
  }
51
51
 
52
52
  function walk(dir: string) {
53
- let entries: string[];
54
- try { entries = readdirSync(dir); } catch { return; }
53
+ let dirents: import('fs').Dirent[];
54
+ try { dirents = readdirSync(dir, { withFileTypes: true }); } catch { return; }
55
55
 
56
- for (const entry of entries) {
56
+ for (const dirent of dirents) {
57
+ const entry = dirent.name;
57
58
  if (ignoreDirs.has(entry)) continue;
58
59
  // Skip macOS AppleDouble resource fork files (._*) — common on ExFAT/network volumes
59
60
  if (entry.startsWith('._')) continue;
61
+ // Never follow symlinks — a symlink to an ancestor directory causes infinite
62
+ // recursion (stack overflow), and a symlink to a large external tree would
63
+ // get fully indexed as if it were part of the repo. Matches how most
64
+ // code-indexing tools behave by default.
65
+ if (dirent.isSymbolicLink()) continue;
60
66
  const fullPath = join(dir, entry);
61
67
  let stat: ReturnType<typeof statSync>;
62
- try { stat = statSync(fullPath); } catch { continue; }
68
+ try { stat = lstatSync(fullPath); } catch { continue; }
63
69
 
64
70
  if (stat.isDirectory()) {
65
71
  // Always traverse directories — negation patterns may rescue files inside ignored dirs
@@ -286,11 +286,25 @@ const GO_RESOLVE_EXTS = ['.go'];
286
286
  const JAVA_RESOLVE_EXTS = ['.java'];
287
287
  const RUST_RESOLVE_EXTS = ['.rs'];
288
288
 
289
+ const workspacePackageMapCache = new Map<string, Map<string, string>>();
290
+
291
+ /** Invalidate the cached workspace package map for a repo — call at the start
292
+ * of each build so long-lived processes (watch mode) pick up package.json
293
+ * additions/removals instead of serving a stale map from a prior build. */
294
+ export function clearWorkspacePackageMapCache(repoPath: string): void {
295
+ workspacePackageMapCache.delete(repoPath);
296
+ }
297
+
289
298
  /**
290
299
  * Build package-name → directory map from workspace package.json files.
291
300
  * Scans packages/ for package.json and maps npm name to its relative src path.
301
+ * Cached per repoPath — multiple pipeline phases (cross-file, scope-resolution,
302
+ * the latter's own re-export loop) call this per-file/per-edge within the same
303
+ * build, and the workspace's package.json set doesn't change mid-build.
292
304
  */
293
- function buildWorkspacePackageMap(repoPath: string): Map<string, string> {
305
+ export function buildWorkspacePackageMap(repoPath: string): Map<string, string> {
306
+ const cached = workspacePackageMapCache.get(repoPath);
307
+ if (cached) return cached;
294
308
  const result = new Map<string, string>();
295
309
  const packagesDir = join(repoPath, 'packages');
296
310
  try {
@@ -315,10 +329,11 @@ function buildWorkspacePackageMap(repoPath: string): Map<string, string> {
315
329
  };
316
330
  scanDirs(packagesDir, 0);
317
331
  } catch { /* no packages dir */ }
332
+ workspacePackageMapCache.set(repoPath, result);
318
333
  return result;
319
334
  }
320
335
 
321
- function resolveModuleSpecifier(
336
+ export function resolveModuleSpecifier(
322
337
  importerPath: string,
323
338
  specifier: string,
324
339
  repoPath: string,
@@ -346,8 +361,8 @@ function resolveModuleSpecifier(
346
361
  return null;
347
362
  }
348
363
 
349
- // Workspace package specifier: @monomind/hooks → packages/@monomind/hooks/src/index.ts
350
- // Also handles subpath: @monomind/hooks/src/foo → packages/@monomind/hooks/src/foo.ts
364
+ // Workspace package specifier: @monoes/hooks → packages/@monomind/hooks/src/index.ts
365
+ // Also handles subpath: @monoes/hooks/src/foo → packages/@monomind/hooks/src/foo.ts
351
366
  for (const [pkgName, pkgDir] of workspaceMap) {
352
367
  if (specifier === pkgName) {
353
368
  // Bare import: resolve to package entry point (src/index.ts)
@@ -20,6 +20,8 @@ export interface PipelineOptions {
20
20
  ignore: string[];
21
21
  /** Max Section nodes to submit to LLM extraction (0 = disabled). Default 0. */
22
22
  llmMaxSections: number;
23
+ /** When true, bypass the on-disk extraction cache entirely and re-parse every file from scratch. Default false. */
24
+ force?: boolean;
23
25
  }
24
26
 
25
27
  export const DEFAULT_OPTIONS: PipelineOptions = {
@@ -29,6 +31,7 @@ export const DEFAULT_OPTIONS: PipelineOptions = {
29
31
  workerChunkBudgetBytes: 20971520,
30
32
  ignore: [],
31
33
  llmMaxSections: 0,
34
+ force: false,
32
35
  };
33
36
 
34
37
  export interface PipelinePhase<TOutput> {
@@ -54,16 +54,15 @@ function queryEdgesByRelation(db: MonographDb): EdgeRelationStat[] {
54
54
 
55
55
  function queryTopNodesByDegree(db: MonographDb, limit = 10): TopDegreeNode[] {
56
56
  const rows = db.prepare(`
57
- SELECT
58
- n.id,
59
- n.name,
60
- n.label,
61
- (
62
- SELECT COUNT(*) FROM edges WHERE source_id = n.id
63
- ) + (
64
- SELECT COUNT(*) FROM edges WHERE target_id = n.id
65
- ) AS degree
57
+ SELECT n.id, n.name, n.label, COALESCE(d.degree, 0) AS degree
66
58
  FROM nodes n
59
+ LEFT JOIN (
60
+ SELECT node_id, SUM(cnt) AS degree FROM (
61
+ SELECT source_id AS node_id, COUNT(*) AS cnt FROM edges GROUP BY source_id
62
+ UNION ALL
63
+ SELECT target_id AS node_id, COUNT(*) AS cnt FROM edges GROUP BY target_id
64
+ ) GROUP BY node_id
65
+ ) d ON d.node_id = n.id
67
66
  ORDER BY degree DESC
68
67
  LIMIT ?
69
68
  `).all(limit) as TopDegreeNode[];
@@ -28,10 +28,13 @@ export function checkStaleness(db: MonographDb, repoPath: string): StalenessRepo
28
28
  const indexedCommitShort = indexedCommitFull ? indexedCommitFull.slice(0, 7) : null;
29
29
  const currentCommitShort = currentCommit ? currentCommit.slice(0, 7) : null;
30
30
 
31
- // 3. If no stored commit or short SHAs match, not stale
32
- if (!indexedCommitFull || indexedCommitShort === currentCommitShort) {
31
+ // 3. If SHAs match → fresh. If no stored commit staleness unknown (not "fresh").
32
+ if (indexedCommitShort === currentCommitShort) {
33
33
  return { isStale: false, indexedAt: null, indexedCommit: indexedCommitShort, currentCommit: currentCommitShort, changedSince: [], staleSince: null };
34
34
  }
35
+ if (!indexedCommitFull) {
36
+ return { isStale: true, indexedAt: null, indexedCommit: null, currentCommit: currentCommitShort, changedSince: [], staleSince: null };
37
+ }
35
38
 
36
39
  // Guard: indexedCommitFull is read from SQLite — validate before shell interpolation
37
40
  if (!/^[0-9a-f]{7,40}$/i.test(indexedCommitFull)) {
package/src/storage/db.ts CHANGED
@@ -5,12 +5,31 @@ import {
5
5
  CREATE_NODES, CREATE_EDGES, CREATE_COMMUNITIES,
6
6
  CREATE_INDEX_META, CREATE_NODES_FTS, CREATE_INDEXES, FTS_SYNC_TRIGGERS,
7
7
  CREATE_EMBEDDINGS, CREATE_WIKI_PAGES,
8
+ CREATE_AGENT_INTERACTIONS, CREATE_AGENT_INTERACTIONS_IDX,
9
+ CREATE_AGENT_INTERACTIONS_ORG_IDX, CREATE_AGENT_INTERACTIONS_TYPE_IDX,
10
+ CREATE_AGENT_INTERACTIONS_TS_IDX,
8
11
  } from './schema.js';
9
12
  import { MonographError } from '../types.js';
10
13
 
11
14
  export type MonographDb = Database.Database;
12
15
 
13
- export function openDb(dbPath: string): MonographDb {
16
+ export interface OpenDbOptions {
17
+ /**
18
+ * When true, throw a clear error instead of silently creating a fresh,
19
+ * empty-but-migrated database if `dbPath` doesn't exist yet. Defaults to
20
+ * false to preserve legacy build-time behavior (the `monograph build`
21
+ * codepath legitimately creates new DBs). Read-only analysis tools should
22
+ * pass `true` so pointing them at a repo whose real index lives elsewhere
23
+ * (e.g. running from a subdirectory) fails loudly instead of reporting
24
+ * fake-successful-looking empty results.
25
+ */
26
+ fileMustExist?: boolean;
27
+ }
28
+
29
+ export function openDb(dbPath: string, options: OpenDbOptions = {}): MonographDb {
30
+ if (options.fileMustExist && !existsSync(dbPath)) {
31
+ throw new MonographError(`Monograph database does not exist at ${dbPath}. Run monograph build first.`);
32
+ }
14
33
  try {
15
34
  mkdirSync(dirname(dbPath), { recursive: true });
16
35
  const db = new Database(dbPath);
@@ -45,6 +64,11 @@ function applyMigrations(db: MonographDb): void {
45
64
  db.exec(FTS_SYNC_TRIGGERS);
46
65
  db.exec(CREATE_EMBEDDINGS);
47
66
  db.exec(CREATE_WIKI_PAGES);
67
+ db.exec(CREATE_AGENT_INTERACTIONS);
68
+ db.exec(CREATE_AGENT_INTERACTIONS_IDX);
69
+ db.exec(CREATE_AGENT_INTERACTIONS_ORG_IDX);
70
+ db.exec(CREATE_AGENT_INTERACTIONS_TYPE_IDX);
71
+ db.exec(CREATE_AGENT_INTERACTIONS_TS_IDX);
48
72
 
49
73
  // Schema version table — tracks incremental column additions.
50
74
  db.exec(`CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY)`);
@@ -66,6 +90,16 @@ export function atomicRebuild(dbPath: string, buildFn: (db: MonographDb) => void
66
90
  buildFn(db);
67
91
  db.close();
68
92
  renameSync(tmpPath, dbPath);
93
+ // The old db's WAL-mode sidecar files belong to the file we just replaced —
94
+ // if left behind, the next process to open dbPath pairs the NEW db file with
95
+ // a STALE -wal/-shm whose salt/checkpoint state doesn't match, which can
96
+ // surface as "file is not a database" or subtle corruption.
97
+ for (const suffix of ['-wal', '-shm']) {
98
+ const sidecar = dbPath + suffix;
99
+ if (existsSync(sidecar)) {
100
+ try { unlinkSync(sidecar); } catch { /* best-effort cleanup */ }
101
+ }
102
+ }
69
103
  } catch (err) {
70
104
  db.close();
71
105
  if (existsSync(tmpPath)) {
@@ -73,10 +73,17 @@ export function getEdgesForTarget(db: Database.Database, targetId: string): Mono
73
73
  return rows.map(rowToEdge);
74
74
  }
75
75
 
76
+ /**
77
+ * Delete every edge that touches a node belonging to `filePath`, on either end.
78
+ * Must be called BEFORE the corresponding nodes are deleted (the subselects need
79
+ * the file's nodes to still exist to identify which edges to remove) — e.g.
80
+ * `deleteEdgesForFile` then `deleteNodesForFile`, never the reverse.
81
+ */
76
82
  export function deleteEdgesForFile(db: Database.Database, filePath: string): void {
77
83
  db.prepare(`
78
84
  DELETE FROM edges WHERE source_id IN (SELECT id FROM nodes WHERE file_path = ?)
79
- `).run(filePath);
85
+ OR target_id IN (SELECT id FROM nodes WHERE file_path = ?)
86
+ `).run(filePath, filePath);
80
87
  }
81
88
 
82
89
  export function countEdges(db: Database.Database): number {
@@ -13,6 +13,20 @@ export interface FtsResult {
13
13
  endLine: number | null;
14
14
  }
15
15
 
16
+ /**
17
+ * Quote a single FTS5 search term as a string literal when it contains characters
18
+ * that would otherwise be parsed as FTS5 syntax (quotes, parens, boolean keywords
19
+ * like AND/OR/NOT, colons, hyphens, etc). Wrapping in double-quotes forces FTS5 to
20
+ * treat the term as a literal string; any literal `"` inside the term is escaped by
21
+ * doubling it, per FTS5's own string-literal quoting rules.
22
+ */
23
+ function quoteFtsTerm(term: string): string {
24
+ // Bare alphanumeric/underscore terms need no quoting and are the common case.
25
+ if (/^[A-Za-z0-9_]+$/.test(term)) return term;
26
+ const escaped = term.replace(/"/g, '""');
27
+ return `"${escaped}"`;
28
+ }
29
+
16
30
  export function ftsSearch(
17
31
  db: Database.Database,
18
32
  query: string,
@@ -24,8 +38,13 @@ export function ftsSearch(
24
38
  const safeQuery = query.replace(/[*]/g, ' ').trim();
25
39
  if (!safeQuery) return [];
26
40
 
27
- // Trigram handles substring matching natively — no need to append * to each term
28
- const ftsQuery = safeQuery.split(/\s+/).join(' ');
41
+ // Trigram handles substring matching natively — no need to append * to each term.
42
+ // Quote each term individually so characters like `"`, `(`, `)`, or bare boolean
43
+ // keywords (AND/OR/NOT) don't get parsed as FTS5 query syntax.
44
+ const ftsQuery = safeQuery
45
+ .split(/\s+/)
46
+ .map(quoteFtsTerm)
47
+ .join(' ');
29
48
 
30
49
  let matchSql = `
31
50
  SELECT n.id, n.name, n.norm_label, n.file_path, n.label,
@@ -52,16 +71,19 @@ export function ftsSearch(
52
71
  // FTS MATCH can throw on malformed queries; fall through to LIKE path
53
72
  }
54
73
 
55
- // For short queries (≤2 chars) trigram requires at least 3 characters to fire, so
56
- // supplement with a LIKE fallback to catch single/double-character matches.
57
- if (safeQuery.length <= 2) {
58
- const escapedQuery = safeQuery.replace(/%/g, '\\%').replace(/_/g, '\\_');
74
+ // Run the LIKE fallback whenever MATCH threw (malformed/boolean-keyword query) or
75
+ // returned zero rows not just for short (≤2 char) queries. Short queries need it
76
+ // because trigram requires ≥3 characters to fire; longer queries need it whenever
77
+ // MATCH couldn't handle the syntax, so a query-syntax failure doesn't silently read
78
+ // as "no results".
79
+ if (safeQuery.length <= 2 || matchRows.length === 0) {
80
+ const escapedQuery = safeQuery.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_');
59
81
  const likePattern = `%${escapedQuery}%`;
60
82
  let likeSql = `
61
83
  SELECT n.id, n.name, n.norm_label, n.file_path, n.label,
62
84
  n.start_line, n.end_line, 0 AS rank
63
85
  FROM nodes n
64
- WHERE (n.name LIKE ? OR n.norm_label LIKE ? OR n.file_path LIKE ?)
86
+ WHERE (n.name LIKE ? ESCAPE '\\' OR n.norm_label LIKE ? ESCAPE '\\' OR n.file_path LIKE ? ESCAPE '\\')
65
87
  `;
66
88
  const likeParams: unknown[] = [likePattern, likePattern, likePattern];
67
89
  if (label) {
@@ -186,13 +208,13 @@ export function hybridSearch(
186
208
  // ── Strategy 2: LIKE fallback ──────────────────────────────────────────────
187
209
  // Always run for short queries (≤3 chars) or when FTS returned nothing.
188
210
  if (safeQuery.length <= 3 || ftsRows.length === 0) {
189
- const escapedSafeQuery = safeQuery.replace(/%/g, '\\%').replace(/_/g, '\\_');
211
+ const escapedSafeQuery = safeQuery.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_');
190
212
  const likePattern = `%${escapedSafeQuery}%`;
191
213
  let likeSql = `
192
214
  SELECT n.id, n.name, n.norm_label, n.file_path, n.label,
193
215
  n.start_line, n.end_line
194
216
  FROM nodes n
195
- WHERE (n.name LIKE ? OR n.norm_label LIKE ?)
217
+ WHERE (n.name LIKE ? ESCAPE '\\' OR n.norm_label LIKE ? ESCAPE '\\')
196
218
  `;
197
219
  const likeParams: unknown[] = [likePattern, likePattern];
198
220
  if (label) {
@@ -124,6 +124,36 @@ export const CREATE_FILE_CACHE = `
124
124
  )
125
125
  `;
126
126
 
127
+ export const CREATE_AGENT_INTERACTIONS = `
128
+ CREATE TABLE IF NOT EXISTS agent_interactions (
129
+ id TEXT PRIMARY KEY,
130
+ session_id TEXT NOT NULL,
131
+ org_name TEXT,
132
+ agent_type TEXT NOT NULL,
133
+ parent_agent TEXT,
134
+ prompt_summary TEXT,
135
+ result_summary TEXT,
136
+ tokens_in INTEGER NOT NULL DEFAULT 0,
137
+ tokens_out INTEGER NOT NULL DEFAULT 0,
138
+ cost_usd REAL NOT NULL DEFAULT 0,
139
+ success INTEGER NOT NULL DEFAULT 1,
140
+ duration_ms INTEGER NOT NULL DEFAULT 0,
141
+ timestamp INTEGER NOT NULL,
142
+ metadata TEXT
143
+ )`;
144
+
145
+ export const CREATE_AGENT_INTERACTIONS_IDX = `
146
+ CREATE INDEX IF NOT EXISTS idx_agent_interactions_session ON agent_interactions(session_id)`;
147
+
148
+ export const CREATE_AGENT_INTERACTIONS_ORG_IDX = `
149
+ CREATE INDEX IF NOT EXISTS idx_agent_interactions_org ON agent_interactions(org_name)`;
150
+
151
+ export const CREATE_AGENT_INTERACTIONS_TYPE_IDX = `
152
+ CREATE INDEX IF NOT EXISTS idx_agent_interactions_type ON agent_interactions(agent_type)`;
153
+
154
+ export const CREATE_AGENT_INTERACTIONS_TS_IDX = `
155
+ CREATE INDEX IF NOT EXISTS idx_agent_interactions_ts ON agent_interactions(timestamp)`;
156
+
127
157
  export const FTS_SYNC_TRIGGERS = `
128
158
  CREATE TRIGGER IF NOT EXISTS nodes_fts_insert AFTER INSERT ON nodes BEGIN
129
159
  INSERT INTO nodes_fts(rowid, id, name, norm_label, file_path, label)
@@ -34,8 +34,17 @@ export async function watchAsync(
34
34
  do {
35
35
  rerun = false;
36
36
  opts.onProgress?.({ phase: 'watch', message: `Changed: ${files.slice(0, 3).join(', ')}` });
37
- await buildAsync(repoPath, { onProgress: opts.onProgress, force: opts.force, codeOnly: opts.codeOnly, llmMaxSections: opts.llmMaxSections ?? 0 });
38
- opts.onProgress?.({ phase: 'watch', message: 'Graph rebuilt.' });
37
+ try {
38
+ await buildAsync(repoPath, { onProgress: opts.onProgress, force: opts.force, codeOnly: opts.codeOnly, llmMaxSections: opts.llmMaxSections ?? 0 });
39
+ opts.onProgress?.({ phase: 'watch', message: 'Graph rebuilt.' });
40
+ } catch (err) {
41
+ // A rebuild failure (locked DB past busy_timeout, disk full, transient
42
+ // I/O error) must not crash the watch process — log it, notify listeners,
43
+ // and keep watching for the next change instead of letting an unhandled
44
+ // rejection escape this listener.
45
+ watcher.emit('monograph:error', err);
46
+ opts.onProgress?.({ phase: 'watch', message: `Rebuild failed: ${err instanceof Error ? err.message : String(err)}` });
47
+ }
39
48
  } while (rerun);
40
49
  } finally {
41
50
  building = false;
@@ -52,11 +52,13 @@ async function handleAnalyze(req: IncomingMessage, res: ServerResponse): Promise
52
52
  }
53
53
 
54
54
  // Set SSE headers
55
+ const origin = req.headers['origin'] ?? '';
56
+ const allowedOrigin = /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin) ? origin : '';
55
57
  res.writeHead(200, {
56
58
  'Content-Type': 'text/event-stream',
57
59
  'Cache-Control': 'no-cache',
58
60
  'Connection': 'keep-alive',
59
- 'Access-Control-Allow-Origin': 'http://localhost',
61
+ ...(allowedOrigin && { 'Access-Control-Allow-Origin': allowedOrigin }),
60
62
  });
61
63
 
62
64
  const dbPath = resolve(join(repoPath, '.monomind', 'monograph.db'));