@unerr-ai/unerr 0.2.1 → 0.2.3

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 (363) hide show
  1. package/README.md +36 -45
  2. package/dist/cli.js +37443 -36022
  3. package/package.json +2 -1
  4. package/dist/behaviors/agent-llm-bridge.js +0 -166
  5. package/dist/behaviors/architecture-guard.js +0 -256
  6. package/dist/behaviors/auto-doc.js +0 -247
  7. package/dist/behaviors/cascade-guard.js +0 -289
  8. package/dist/behaviors/change-narrative.js +0 -270
  9. package/dist/behaviors/convention-drift.js +0 -290
  10. package/dist/behaviors/framework.js +0 -235
  11. package/dist/behaviors/guard-formatter.js +0 -44
  12. package/dist/behaviors/incomplete-work.js +0 -270
  13. package/dist/behaviors/loop-breaker.js +0 -300
  14. package/dist/behaviors/session-continuity.js +0 -208
  15. package/dist/commands/branches.js +0 -97
  16. package/dist/commands/check-commit.js +0 -225
  17. package/dist/commands/compress-output.js +0 -64
  18. package/dist/commands/config-verify.js +0 -243
  19. package/dist/commands/daemon.js +0 -905
  20. package/dist/commands/dashboard.js +0 -52
  21. package/dist/commands/debug.js +0 -200
  22. package/dist/commands/enrich.js +0 -184
  23. package/dist/commands/exec.js +0 -233
  24. package/dist/commands/gain.js +0 -156
  25. package/dist/commands/hook.js +0 -88
  26. package/dist/commands/index.js +0 -88
  27. package/dist/commands/init.js +0 -74
  28. package/dist/commands/install.js +0 -505
  29. package/dist/commands/learn.js +0 -116
  30. package/dist/commands/manifest.js +0 -193
  31. package/dist/commands/rewind.js +0 -103
  32. package/dist/commands/serve.js +0 -19
  33. package/dist/commands/setup-wizard.js +0 -414
  34. package/dist/commands/skills.js +0 -64
  35. package/dist/commands/stats.js +0 -20
  36. package/dist/commands/status.js +0 -654
  37. package/dist/commands/timeline.js +0 -139
  38. package/dist/commands/uninstall.js +0 -230
  39. package/dist/components/App.js +0 -109
  40. package/dist/components/Banner.js +0 -12
  41. package/dist/components/ConfirmPrompt.js +0 -25
  42. package/dist/components/DriftSummary.js +0 -23
  43. package/dist/components/GradeBadge.js +0 -15
  44. package/dist/components/HealthCard.js +0 -18
  45. package/dist/components/InkSpinner.js +0 -22
  46. package/dist/components/InputBox.js +0 -17
  47. package/dist/components/KeyValue.js +0 -13
  48. package/dist/components/MessageList.js +0 -14
  49. package/dist/components/ProgressBar.js +0 -26
  50. package/dist/components/Section.js +0 -16
  51. package/dist/components/SessionSummaryCard.js +0 -73
  52. package/dist/components/StartupDisplay.js +0 -24
  53. package/dist/components/StatusDashboard.js +0 -57
  54. package/dist/components/StatusLine.js +0 -8
  55. package/dist/components/StepLine.js +0 -22
  56. package/dist/components/Theme.js +0 -20
  57. package/dist/components/ToolProgress.js +0 -8
  58. package/dist/components/ViolationList.js +0 -21
  59. package/dist/components/render.js +0 -13
  60. package/dist/config/agent-registry.js +0 -237
  61. package/dist/config/claude-settings-hooks.js +0 -304
  62. package/dist/config/hook-installer.js +0 -65
  63. package/dist/config/instruction-writer.js +0 -388
  64. package/dist/config/mcp-config-writer.js +0 -266
  65. package/dist/config/settings.js +0 -174
  66. package/dist/config/tool-detector.js +0 -42
  67. package/dist/config/value-surfacing.js +0 -119
  68. package/dist/core/context-assembly.js +0 -108
  69. package/dist/core/conversation.js +0 -33
  70. package/dist/core/local-chat-provider.js +0 -475
  71. package/dist/core/provider-factory.js +0 -55
  72. package/dist/core/providers.js +0 -90
  73. package/dist/core/query-engine.js +0 -174
  74. package/dist/daemon/api.js +0 -312
  75. package/dist/daemon/autostart.js +0 -119
  76. package/dist/daemon/bootstrap.js +0 -39
  77. package/dist/daemon/client.js +0 -164
  78. package/dist/daemon/detect-ci.js +0 -81
  79. package/dist/daemon/platform-linux.js +0 -146
  80. package/dist/daemon/platform-macos.js +0 -134
  81. package/dist/daemon/platform-windows.js +0 -116
  82. package/dist/daemon/process-manager.js +0 -299
  83. package/dist/daemon/protocol.js +0 -23
  84. package/dist/daemon/registry.js +0 -270
  85. package/dist/daemon/settings-schema.js +0 -72
  86. package/dist/daemon/system-health.js +0 -134
  87. package/dist/daemon/version-checker.js +0 -262
  88. package/dist/daemon/warm-start.js +0 -223
  89. package/dist/entrypoints/cli.js +0 -1043
  90. package/dist/entrypoints/daemon.js +0 -380
  91. package/dist/entrypoints/repl.js +0 -147
  92. package/dist/hooks/adapters/claude-code.js +0 -90
  93. package/dist/hooks/adapters/cline.js +0 -100
  94. package/dist/hooks/adapters/cursor.js +0 -98
  95. package/dist/hooks/hook-dedup.js +0 -79
  96. package/dist/hooks/hook-runner.js +0 -113
  97. package/dist/hooks/navigation-hooks.js +0 -175
  98. package/dist/hooks/prompt-hooks.js +0 -63
  99. package/dist/hooks/shell-hooks.js +0 -47
  100. package/dist/ignore.js +0 -111
  101. package/dist/intelligence/approach-suggester.js +0 -61
  102. package/dist/intelligence/ast-extractor.js +0 -2615
  103. package/dist/intelligence/ast-worker.js +0 -34
  104. package/dist/intelligence/background-indexer.js +0 -121
  105. package/dist/intelligence/blast-radius.js +0 -200
  106. package/dist/intelligence/community-detection.js +0 -691
  107. package/dist/intelligence/community-detector.js +0 -184
  108. package/dist/intelligence/computation-scheduler.js +0 -75
  109. package/dist/intelligence/confidence-propagation.js +0 -47
  110. package/dist/intelligence/convention-detector.js +0 -242
  111. package/dist/intelligence/convention-learner.js +0 -205
  112. package/dist/intelligence/convention-matcher.js +0 -205
  113. package/dist/intelligence/cozo-schema.js +0 -376
  114. package/dist/intelligence/decision-point-detector.js +0 -90
  115. package/dist/intelligence/deep-dive-tools.js +0 -586
  116. package/dist/intelligence/durability-scorer.js +0 -84
  117. package/dist/intelligence/exploration-cost.js +0 -204
  118. package/dist/intelligence/exploration-pattern-tracker.js +0 -61
  119. package/dist/intelligence/fact-generator.js +0 -322
  120. package/dist/intelligence/facts-schema.js +0 -90
  121. package/dist/intelligence/file-intelligence.js +0 -59
  122. package/dist/intelligence/graph-holder.js +0 -220
  123. package/dist/intelligence/graph-temporal-joiner.js +0 -238
  124. package/dist/intelligence/health-grade.js +0 -423
  125. package/dist/intelligence/health-grader.js +0 -200
  126. package/dist/intelligence/health-map-data.js +0 -259
  127. package/dist/intelligence/import-symbols.js +0 -136
  128. package/dist/intelligence/incremental-indexer.js +0 -658
  129. package/dist/intelligence/indexer/centrality.js +0 -62
  130. package/dist/intelligence/indexer/cfg-context.js +0 -95
  131. package/dist/intelligence/indexer/confidence.js +0 -34
  132. package/dist/intelligence/indexer/cross-file-resolver.js +0 -104
  133. package/dist/intelligence/indexer/edge-repair.js +0 -89
  134. package/dist/intelligence/indexer/entity-key.js +0 -17
  135. package/dist/intelligence/indexer/export-map.js +0 -132
  136. package/dist/intelligence/indexer/git-cochange.js +0 -128
  137. package/dist/intelligence/indexer/graph-patch.js +0 -147
  138. package/dist/intelligence/indexer/incremental.js +0 -78
  139. package/dist/intelligence/indexer/ingest.js +0 -160
  140. package/dist/intelligence/indexer/language-detect.js +0 -226
  141. package/dist/intelligence/indexer/metadata.js +0 -63
  142. package/dist/intelligence/indexer/mutation-tracker.js +0 -79
  143. package/dist/intelligence/indexer/orchestrator.js +0 -155
  144. package/dist/intelligence/indexer/plugin-interface.js +0 -31
  145. package/dist/intelligence/indexer/plugins/csharp.js +0 -440
  146. package/dist/intelligence/indexer/plugins/go.js +0 -335
  147. package/dist/intelligence/indexer/plugins/java.js +0 -370
  148. package/dist/intelligence/indexer/plugins/python.js +0 -358
  149. package/dist/intelligence/indexer/plugins/regex-fallback.js +0 -82
  150. package/dist/intelligence/indexer/plugins/ruby.js +0 -290
  151. package/dist/intelligence/indexer/plugins/rust.js +0 -484
  152. package/dist/intelligence/indexer/plugins/tier2-generic.js +0 -310
  153. package/dist/intelligence/indexer/plugins/typescript.js +0 -456
  154. package/dist/intelligence/indexer/resource-monitor.js +0 -93
  155. package/dist/intelligence/indexer/scip/decoder.js +0 -253
  156. package/dist/intelligence/indexer/scip/detector.js +0 -232
  157. package/dist/intelligence/indexer/scip/downloader.js +0 -427
  158. package/dist/intelligence/indexer/scip/fallback.js +0 -34
  159. package/dist/intelligence/indexer/scip/merger.js +0 -109
  160. package/dist/intelligence/indexer/scip/orchestrator.js +0 -433
  161. package/dist/intelligence/indexer/scip/runner.js +0 -98
  162. package/dist/intelligence/indexer/snapshot.js +0 -66
  163. package/dist/intelligence/indexer/test-detector.js +0 -196
  164. package/dist/intelligence/indexer/watch-integration.js +0 -61
  165. package/dist/intelligence/indexer/worker.js +0 -85
  166. package/dist/intelligence/local-convention-detector.js +0 -437
  167. package/dist/intelligence/local-embeddings.js +0 -190
  168. package/dist/intelligence/local-graph.js +0 -1946
  169. package/dist/intelligence/local-indexer.js +0 -1575
  170. package/dist/intelligence/local-llm.js +0 -163
  171. package/dist/intelligence/local-rule-generator.js +0 -154
  172. package/dist/intelligence/local-snapshot.js +0 -213
  173. package/dist/intelligence/negative-knowledge.js +0 -103
  174. package/dist/intelligence/persistent-db.js +0 -85
  175. package/dist/intelligence/query-router.js +0 -2556
  176. package/dist/intelligence/risk-classifier.js +0 -116
  177. package/dist/intelligence/rule-evaluator.js +0 -380
  178. package/dist/intelligence/rule-generator.js +0 -49
  179. package/dist/intelligence/search-index.js +0 -173
  180. package/dist/intelligence/semantic/docstring-extractor.js +0 -67
  181. package/dist/intelligence/semantic/embedding-store.js +0 -52
  182. package/dist/intelligence/semantic/enrichment-orchestrator.js +0 -48
  183. package/dist/intelligence/semantic/git-message-miner.js +0 -114
  184. package/dist/intelligence/semantic/identifier-tokenizer.js +0 -51
  185. package/dist/intelligence/semantic/node2vec-embeddings.js +0 -71
  186. package/dist/intelligence/semantic/node2vec-walks.js +0 -103
  187. package/dist/intelligence/semantic/path-domain-inference.js +0 -112
  188. package/dist/intelligence/semantic/similarity-engine.js +0 -60
  189. package/dist/intelligence/semantic/tfidf-vectors.js +0 -88
  190. package/dist/intelligence/session-brief-builder.js +0 -159
  191. package/dist/intelligence/session-context.js +0 -221
  192. package/dist/intelligence/session-health-monitor.js +0 -211
  193. package/dist/intelligence/session-narrative.js +0 -197
  194. package/dist/intelligence/session-pattern-analyzer.js +0 -218
  195. package/dist/intelligence/signal-scorer.js +0 -390
  196. package/dist/intelligence/signal-show-store.js +0 -182
  197. package/dist/intelligence/smart-truncate.js +0 -158
  198. package/dist/intelligence/subgraph-cache.js +0 -88
  199. package/dist/intelligence/temporal-facts.js +0 -494
  200. package/dist/intelligence/token-estimator.js +0 -100
  201. package/dist/intelligence/tool-injector.js +0 -87
  202. package/dist/intelligence/tree-sitter-loader.js +0 -71
  203. package/dist/intelligence/worker-pool.js +0 -116
  204. package/dist/proxy/arg-validator.js +0 -79
  205. package/dist/proxy/auto-bootstrap.js +0 -167
  206. package/dist/proxy/bridge.js +0 -147
  207. package/dist/proxy/budget-enforcer.js +0 -70
  208. package/dist/proxy/compression-quality-monitor.js +0 -160
  209. package/dist/proxy/compression-stats.js +0 -51
  210. package/dist/proxy/context-rot-detector.js +0 -137
  211. package/dist/proxy/drift-detector.js +0 -139
  212. package/dist/proxy/efficiency-tracker.js +0 -79
  213. package/dist/proxy/fact-ranking.js +0 -154
  214. package/dist/proxy/format-encoder.js +0 -266
  215. package/dist/proxy/http-transport.js +0 -90
  216. package/dist/proxy/lifecycle-actor.js +0 -55
  217. package/dist/proxy/lifecycle-machine.js +0 -187
  218. package/dist/proxy/log-tailer.js +0 -265
  219. package/dist/proxy/model-pricing.js +0 -98
  220. package/dist/proxy/network-firewall.js +0 -141
  221. package/dist/proxy/nudge-state.js +0 -93
  222. package/dist/proxy/output-compressor.js +0 -185
  223. package/dist/proxy/pid-lock.js +0 -291
  224. package/dist/proxy/proxy-context.js +0 -11
  225. package/dist/proxy/proxy.js +0 -2633
  226. package/dist/proxy/response-enrichment.js +0 -32
  227. package/dist/proxy/response-envelope.js +0 -313
  228. package/dist/proxy/session-dedup.js +0 -82
  229. package/dist/proxy/session-legend.js +0 -30
  230. package/dist/proxy/session-persistence.js +0 -210
  231. package/dist/proxy/session-resume.js +0 -94
  232. package/dist/proxy/session-stats.js +0 -513
  233. package/dist/proxy/shell-classifier.js +0 -1346
  234. package/dist/proxy/shell-compression-log.js +0 -93
  235. package/dist/proxy/shell-compressor.js +0 -390
  236. package/dist/proxy/shell-graph-boost.js +0 -202
  237. package/dist/proxy/shell-monitor-map.js +0 -18
  238. package/dist/proxy/shell-stats.js +0 -54
  239. package/dist/proxy/shell-strategies/cloud.js +0 -215
  240. package/dist/proxy/shell-strategies/diff.js +0 -159
  241. package/dist/proxy/shell-strategies/error-diagnostic.js +0 -796
  242. package/dist/proxy/shell-strategies/filter-dsl.js +0 -358
  243. package/dist/proxy/shell-strategies/git-status.js +0 -177
  244. package/dist/proxy/shell-strategies/key-value.js +0 -193
  245. package/dist/proxy/shell-strategies/log-text.js +0 -154
  246. package/dist/proxy/shell-strategies/omni.js +0 -188
  247. package/dist/proxy/shell-strategies/progress.js +0 -55
  248. package/dist/proxy/shell-strategies/redact.js +0 -76
  249. package/dist/proxy/shell-strategies/structured.js +0 -241
  250. package/dist/proxy/shell-strategies/tabular.js +0 -243
  251. package/dist/proxy/shell-strategies/test-results-types.js +0 -13
  252. package/dist/proxy/shell-strategies/test-results.js +0 -784
  253. package/dist/proxy/shell-strategies/tree-paths.js +0 -144
  254. package/dist/proxy/shell-strategies/yaml.js +0 -182
  255. package/dist/proxy/shell-tee.js +0 -111
  256. package/dist/proxy/signal-dedup.js +0 -171
  257. package/dist/proxy/startup-renderer.js +0 -158
  258. package/dist/proxy/task-token-display.js +0 -38
  259. package/dist/proxy/token-counter.js +0 -61
  260. package/dist/proxy/tool-clusters.js +0 -273
  261. package/dist/proxy/tool-definitions.js +0 -525
  262. package/dist/proxy/transport-mux.js +0 -229
  263. package/dist/proxy/wire-cap.js +0 -268
  264. package/dist/rules/developer.mozilla.org.json +0 -9
  265. package/dist/rules/github.com.json +0 -21
  266. package/dist/schemas/api/skills.js +0 -19
  267. package/dist/schemas/common/errors.js +0 -7
  268. package/dist/schemas/common/headers.js +0 -5
  269. package/dist/schemas/entities/edge.js +0 -25
  270. package/dist/schemas/entities/entity.js +0 -22
  271. package/dist/schemas/entities/rule.js +0 -18
  272. package/dist/schemas/index.js +0 -14
  273. package/dist/server/event-bus.js +0 -59
  274. package/dist/server/http.js +0 -156
  275. package/dist/server/middleware.js +0 -70
  276. package/dist/server/routes/drift.js +0 -97
  277. package/dist/server/routes/intelligence.js +0 -1217
  278. package/dist/server/routes/reasoning-quality.js +0 -444
  279. package/dist/server/routes/session.js +0 -86
  280. package/dist/server/routes/stream.js +0 -120
  281. package/dist/server/routes/system.js +0 -73
  282. package/dist/server/routes/temporal.js +0 -170
  283. package/dist/server/routes/timeline.js +0 -232
  284. package/dist/server/routes/token-flow.js +0 -403
  285. package/dist/skills/effectiveness-tracker.js +0 -93
  286. package/dist/skills/local-pack.js +0 -380
  287. package/dist/skills/resolver.js +0 -495
  288. package/dist/state-detector.js +0 -83
  289. package/dist/timeline/intent-detector.js +0 -263
  290. package/dist/timeline/loop-miner.js +0 -140
  291. package/dist/timeline/open-threads.js +0 -49
  292. package/dist/timeline/signal-reinforcer.js +0 -62
  293. package/dist/timeline/timeline-bootstrap.js +0 -151
  294. package/dist/timeline/timeline-store.js +0 -618
  295. package/dist/tools/coding/bash.js +0 -49
  296. package/dist/tools/coding/file-edit.js +0 -72
  297. package/dist/tools/coding/file-outline.js +0 -227
  298. package/dist/tools/coding/file-read-protocol.js +0 -425
  299. package/dist/tools/coding/file-read.js +0 -35
  300. package/dist/tools/coding/file-write.js +0 -43
  301. package/dist/tools/coding/glob-tool.js +0 -109
  302. package/dist/tools/coding/grep.js +0 -162
  303. package/dist/tools/coding/index.js +0 -27
  304. package/dist/tools/intelligence/index.js +0 -269
  305. package/dist/tools/intelligence/record-fact.js +0 -48
  306. package/dist/tools/intelligence/timeline-markers.js +0 -130
  307. package/dist/tools/registry.js +0 -47
  308. package/dist/tools/types.js +0 -8
  309. package/dist/tracking/auto-snapshot-triggers.js +0 -246
  310. package/dist/tracking/branch-context.js +0 -115
  311. package/dist/tracking/branch-snapshot.js +0 -217
  312. package/dist/tracking/causal-bridge.js +0 -317
  313. package/dist/tracking/circuit-breaker.js +0 -147
  314. package/dist/tracking/commit-watcher.js +0 -114
  315. package/dist/tracking/context-ledger.js +0 -119
  316. package/dist/tracking/correction-detector.js +0 -324
  317. package/dist/tracking/drift-tracker.js +0 -874
  318. package/dist/tracking/durability-tracker.js +0 -94
  319. package/dist/tracking/entity-rewind.js +0 -200
  320. package/dist/tracking/file-hash-state.js +0 -114
  321. package/dist/tracking/git-attribution.js +0 -132
  322. package/dist/tracking/git-trailers.js +0 -171
  323. package/dist/tracking/intelligence-counter.js +0 -46
  324. package/dist/tracking/intent-correlator.js +0 -202
  325. package/dist/tracking/intent-encoder.js +0 -52
  326. package/dist/tracking/intent-token-tracker.js +0 -159
  327. package/dist/tracking/ledger-archiver.js +0 -94
  328. package/dist/tracking/ledger-chains.js +0 -245
  329. package/dist/tracking/metrics-store.js +0 -361
  330. package/dist/tracking/native-watcher.js +0 -131
  331. package/dist/tracking/offline-rewind.js +0 -295
  332. package/dist/tracking/pending-violations.js +0 -74
  333. package/dist/tracking/persistence-effectiveness.js +0 -167
  334. package/dist/tracking/prompt-durability.js +0 -202
  335. package/dist/tracking/quality-signals.js +0 -213
  336. package/dist/tracking/redactor.js +0 -73
  337. package/dist/tracking/rewind-engine.js +0 -161
  338. package/dist/tracking/session-history.js +0 -128
  339. package/dist/tracking/session-receipt.js +0 -88
  340. package/dist/tracking/session-summary-writer.js +0 -157
  341. package/dist/tracking/shadow-ledger.js +0 -321
  342. package/dist/tracking/stash-manager.js +0 -258
  343. package/dist/tracking/timeline-fork.js +0 -213
  344. package/dist/tracking/timeline.js +0 -69
  345. package/dist/tracking/token-flow.js +0 -276
  346. package/dist/tracking/turn-segmenter.js +0 -122
  347. package/dist/tracking/weekly-accumulator.js +0 -179
  348. package/dist/tracking/working-snapshots.js +0 -188
  349. package/dist/tracking/workspace-manifest.js +0 -176
  350. package/dist/transport/http.js +0 -102
  351. package/dist/utils/counterfactual.js +0 -65
  352. package/dist/utils/deep-link.js +0 -34
  353. package/dist/utils/detect.js +0 -193
  354. package/dist/utils/exec.js +0 -73
  355. package/dist/utils/file-logger.js +0 -87
  356. package/dist/utils/format-error.js +0 -29
  357. package/dist/utils/git.js +0 -181
  358. package/dist/utils/log.js +0 -57
  359. package/dist/utils/logger.js +0 -35
  360. package/dist/utils/mcp-content-json.js +0 -8
  361. package/dist/utils/session-logger.js +0 -154
  362. package/dist/utils/startup-log.js +0 -512
  363. package/dist/utils/ui.js +0 -56
@@ -1,2556 +0,0 @@
1
- /**
2
- * Query Router — executes local tool calls against CozoDB graph.
3
- *
4
- * All tools are local: graph queries + rules + business context answered sub-5ms.
5
- *
6
- * Features:
7
- * - Drift overlay merge: overlay entities replace/augment base graph results
8
- * - Drift injection: branch context + entity drift status attach to the internal
9
- * `meta.drift` carrier and surface as a `ur|dft` prefix line on every response
10
- * (MCP clients filter `_meta` envelopes, so signals must ride inline in the body)
11
- * - get_business_context, get_conventions: from justifications/patterns
12
- */
13
- import { enforceBudget, isStructuredContent, } from "../proxy/budget-enforcer.js";
14
- import { formatToolOutput } from "../proxy/format-encoder.js";
15
- import { calculateDollarSavings } from "../proxy/model-pricing.js";
16
- import { compressOutput, } from "../proxy/output-compressor.js";
17
- import { createSessionLegendTracker } from "../proxy/session-legend.js";
18
- import { formatUnknownError } from "../utils/format-error.js";
19
- import { estimateExplorationCost, } from "./exploration-cost.js";
20
- import { SessionContext } from "./session-context.js";
21
- import { estimateTokens, smartTruncate, truncateResultList, } from "./smart-truncate.js";
22
- /**
23
- * Tool registry: all tools run locally against CozoDB.
24
- */
25
- const LOCAL_TOOLS = new Set([
26
- "get_function",
27
- "get_class",
28
- "get_entity", // consolidated: replaces get_function + get_class
29
- "get_file",
30
- "get_callers",
31
- "get_callees",
32
- "get_references", // consolidated: replaces get_callers + get_callees
33
- "get_imports",
34
- "search_code",
35
- // "get_rules", // Disabled: no rules detected/stored yet, always returns empty
36
- // "check_rules", // Disabled: alias for get_rules validation mode
37
- // "get_business_context", // Disabled: not properly wired, produces no useful data
38
- "get_conventions",
39
- // "unerr_revert_entity", // Disabled: shadow ledger tool, not active
40
- // Leapfrog Sprint A: Community intelligence tools
41
- "get_cross_boundary_links",
42
- "get_critical_nodes",
43
- // Sprint 11: Phase 22 Blueprint Deep Dive tools (disabled — no tool-definitions wired)
44
- // "unerr_get_plan_context",
45
- // "unerr_get_next_slice",
46
- // "unerr_check_boundary",
47
- // "unerr_get_design_system",
48
- // "unerr_get_next_task",
49
- // "unerr_complete_task",
50
- // "unerr_get_sprint_context",
51
- // "unerr_get_checkpoint_status",
52
- // Local embedding tools (disabled — embedding store never wired in proxy/mcp-server)
53
- // "semantic_search",
54
- // "find_similar",
55
- "get_project_stats",
56
- // Sprint R: File-level graph tools
57
- "file_connections",
58
- "get_test_coverage",
59
- // Sprint FE-B: file read protocol
60
- "file_outline",
61
- "file_read",
62
- ]);
63
- /**
64
- * Legacy: Order context fields by priority for optimal LLM attention.
65
- * Kept for backward compat with session dedup filter which operates on ContextHints.
66
- */
67
- export function orderContextFields(ctx) {
68
- const ordered = {};
69
- if (ctx.blast_radius !== undefined)
70
- ordered.blast_radius = ctx.blast_radius;
71
- if (ctx.pending_violations !== undefined)
72
- ordered.pending_violations = ctx.pending_violations;
73
- if (ctx.drift_alert !== undefined)
74
- ordered.drift_alert = ctx.drift_alert;
75
- if (ctx.durability_warning !== undefined)
76
- ordered.durability_warning = ctx.durability_warning;
77
- if (ctx.anti_patterns !== undefined)
78
- ordered.anti_patterns = ctx.anti_patterns;
79
- if (ctx.corrections !== undefined)
80
- ordered.corrections = ctx.corrections;
81
- if (ctx.session_resume !== undefined)
82
- ordered.session_resume = ctx.session_resume;
83
- if (ctx.reminder !== undefined)
84
- ordered.reminder = ctx.reminder;
85
- if (ctx.community !== undefined)
86
- ordered.community = ctx.community;
87
- if (ctx.conventions !== undefined)
88
- ordered.conventions = ctx.conventions;
89
- if (ctx.relevant_facts !== undefined)
90
- ordered.relevant_facts = ctx.relevant_facts;
91
- if (ctx.co_changes !== undefined)
92
- ordered.co_changes = ctx.co_changes;
93
- if (ctx.hidden_coupling !== undefined)
94
- ordered.hidden_coupling = ctx.hidden_coupling;
95
- if (ctx.related_issues !== undefined)
96
- ordered.related_issues = ctx.related_issues;
97
- if (ctx.session_greeting !== undefined)
98
- ordered.session_greeting = ctx.session_greeting;
99
- if (ctx.value_counter !== undefined)
100
- ordered.value_counter = ctx.value_counter;
101
- return ordered;
102
- }
103
- /**
104
- * Three-Layer Experience System: Assemble final _context output.
105
- *
106
- * Converts raw gathered context fields into ranked IntelligenceSignals,
107
- * preserving one-time injections (greeting, resume, tool_adoption, value_counter)
108
- * as separate fields.
109
- *
110
- * Replaces the flat 15-field dump with max N ranked signals + decision_point.
111
- */
112
- export async function assembleContextOutput(raw, toolName, args, decisionLevel = "medium", sessionContext) {
113
- const { getSignalScorer, signalId } = await import("./signal-scorer.js");
114
- const { getSignalDedup } = await import("../proxy/signal-dedup.js");
115
- const { signalTag } = await import("../proxy/response-envelope.js");
116
- const scorer = getSignalScorer();
117
- const dedup = getSignalDedup();
118
- // Convert internal fields to signals
119
- let signals = scorer.contextToSignals(raw, toolName, args);
120
- // Apply burst multipliers for high decision points
121
- signals = scorer.applyBurstMultipliers(signals, decisionLevel);
122
- // Determine max signals based on decision level. Capped at 2 so the visible
123
- // signal count never exceeds the wire-level MAX_SIGNAL_LINES, even when
124
- // dedup would let more through.
125
- const maxSignals = decisionLevel === "high" ? 2 : decisionLevel === "medium" ? 2 : 1;
126
- // Tier-3 rotational decay: pass session show-counts to ranker so signals
127
- // that have already surfaced N times this session get deprioritized.
128
- const getShowCount = sessionContext
129
- ? (id) => sessionContext.getSignalShowCount(id)
130
- : undefined;
131
- // Pre-filter via the wire-level dedup table (non-mutating peek) so already-
132
- // suppressed signals don't waste rank slots. Mirrors how buildSignalPrefix
133
- // forms the wire body: `<content>` (action is rendered separately).
134
- const entityKey = typeof args.entity === "string" ? args.entity : null;
135
- const wouldEmit = (s) => dedup.wouldEmit(signalTag(s.type), entityKey, s.content);
136
- const ranked = scorer.rank(signals, maxSignals, getShowCount, wouldEmit);
137
- // Record each emitted signal so it decays on subsequent calls.
138
- if (sessionContext) {
139
- for (const s of ranked) {
140
- sessionContext.recordSignalShown(signalId(s));
141
- }
142
- }
143
- // Compute average confidence
144
- const avgConfidence = ranked.length > 0
145
- ? ranked.reduce((sum, s) => sum + s.confidence, 0) / ranked.length
146
- : 0;
147
- // Build output: signals + one-time fields
148
- const output = {};
149
- // Signal output (always present if we have signals)
150
- if (ranked.length > 0) {
151
- output.signals = ranked;
152
- output.decision_point = decisionLevel;
153
- output.confidence = Math.round(avgConfidence * 100) / 100;
154
- }
155
- // One-time injections pass through unchanged
156
- if (raw.session_greeting !== undefined)
157
- output.session_greeting = raw.session_greeting;
158
- if (raw.session_resume !== undefined)
159
- output.session_resume = raw.session_resume;
160
- if (raw.tool_adoption !== undefined)
161
- output.tool_adoption = raw.tool_adoption;
162
- if (raw.value_counter !== undefined)
163
- output.value_counter = raw.value_counter;
164
- return output;
165
- }
166
- /** Tools that should receive blast radius + convention enrichment. */
167
- const ENRICHABLE_TOOLS = new Set([
168
- // Agent-facing tools
169
- "get_entity",
170
- "get_references",
171
- "get_imports",
172
- "search_code",
173
- "get_conventions",
174
- "get_critical_nodes",
175
- "get_cross_boundary_links",
176
- "get_test_coverage",
177
- "file_read",
178
- "file_outline",
179
- "file_connections",
180
- "get_project_stats",
181
- "get_file",
182
- // Router-resolved aliases (still flow through enrichResult)
183
- "get_function",
184
- "get_class",
185
- "get_callers",
186
- "get_callees",
187
- ]);
188
- export class QueryRouter {
189
- localGraph;
190
- ruleEvaluator;
191
- branchContext = null;
192
- currentMode = "local";
193
- modeReason = "";
194
- sessionContext;
195
- /** External session events ref — set by proxy for value counter. */
196
- sessionEvents = null;
197
- /** Health grade string for session greeting. */
198
- healthGrade = null;
199
- /** Graph stats for session greeting. */
200
- graphStats = null;
201
- /** Push-based violation store (Task 7.3) — shared with DriftTracker. */
202
- pendingViolations = null;
203
- /** Project root path for file operations (Task 7.4 revert). */
204
- projectRoot = null;
205
- /** L11.1: Background indexer reference — enables partial graph responses during indexing. */
206
- backgroundIndexer = null;
207
- /** Sprint L3: Local embedding store for semantic search / find_similar. */
208
- embeddingStore = null;
209
- /** L8.3: Deferred embedding computation status. */
210
- embeddingStatus = null;
211
- /** L9.4: DriftTracker for writing changed files to drift overlay in Local Mode. */
212
- driftTracker = null;
213
- /** S1: Session-level context deduplication. */
214
- sessionDedup = null;
215
- /** S1: Compression quality feedback loop. */
216
- compressionMonitor = null;
217
- /** Layer 6 FE-E: one-time legends per session until retry invalidation. */
218
- sessionLegend = createSessionLegendTracker();
219
- /** S1: Recent entity queries for retry detection (entityKey → timestamp). */
220
- recentEntityQueries = new Map();
221
- /** S2: Session health monitor — detects session degradation. */
222
- healthMonitor = null;
223
- /** S2: Exploration cost accumulator — tracks cumulative token savings. */
224
- explorationAccumulator = null;
225
- /** S3: Context rot detector — detects long-session degradation. */
226
- contextRotDetector = null;
227
- /** S4: Token counter — accumulates savings, emits to stderr at interval. */
228
- tokenCounter = null;
229
- /** S4: Efficiency tracker — tracks original vs delivered for session summary. */
230
- efficiencyTracker = null;
231
- /** Layer 10: Token flow writer — unified savings attribution. */
232
- tokenFlow = null;
233
- /** Persistent memory effectiveness — fact/convention/resume verdict scorer. */
234
- effectivenessTracker = null;
235
- /** S7.4: Session resume context — injected on first response of resumed session. */
236
- sessionResumeContext = null;
237
- /** S7.5: Durability scorer — scores entity fragility across sessions. */
238
- durabilityScorer = null;
239
- /** S7.6: Anti-pattern entries from negative knowledge for injection. */
240
- antiPatternEntries = [];
241
- /** S8.5: Value guard — fires once when session dollar threshold crossed. */
242
- valueGuard = null;
243
- /** S8.5: Accumulated session dollar savings (tracked for guard). */
244
- sessionDollarsSaved = 0;
245
- /** S9.1: Circuit breaker — halts repeated failed attempts on same entity. */
246
- circuitBreaker = null;
247
- /** S9.2: Health threshold below which circuit breaker force-triggers. */
248
- HEALTH_CIRCUIT_BREAK_THRESHOLD = 0.3;
249
- /** Q.1: Causal bridge — entity history from prompt→commit→survival chain. */
250
- causalBridge = null;
251
- /** Q.3: Convention learner — conventions learned from corrections. */
252
- learnedConventions = [];
253
- /** Q.1: Prompt durability profiles — which prompt styles produce durable code. */
254
- promptDurabilityProfiles = [];
255
- /** Cross-session context ledger — tracks delivered context across sessions. */
256
- contextLedger = null;
257
- /** Intent token tracker — groups tool calls by intent for token accounting. */
258
- intentTracker = null;
259
- /** Layer 7: Event bus for dashboard SSE — emits on every tool call. */
260
- eventBus = null;
261
- /** Sprint 1.2: Temporal fact store — facts attached to internal `context.signals`, drained to `ur|fct` prefix lines. */
262
- factStore = null;
263
- /** Sprint 1.2: Fact IDs surfaced this session — for session summary tracking. */
264
- factsSurfaced = [];
265
- /** Sprint 9.3: Signal delivery stats — tracked for intelligence health UI. */
266
- signalDeliveryStats = {
267
- total_delivered: 0,
268
- by_type: {},
269
- tool_calls_with_signals: 0,
270
- total_tool_calls: 0,
271
- };
272
- constructor(localGraph, ruleEvaluator) {
273
- this.localGraph = localGraph;
274
- this.ruleEvaluator = ruleEvaluator ?? null;
275
- this.sessionContext = new SessionContext();
276
- }
277
- /**
278
- * Swap the graph reference atomically (called by GraphHolder on rebuild completion).
279
- * All subsequent tool calls will use the new graph instance.
280
- */
281
- swapGraph(newGraph) {
282
- this.localGraph = newGraph;
283
- }
284
- /**
285
- * Set the session events reference (from proxy's SessionStats).
286
- * Used by the value counter (Task 2.7).
287
- */
288
- setSessionEvents(events) {
289
- this.sessionEvents = events;
290
- }
291
- /**
292
- * Set health grade info for session greeting (Task 2.6).
293
- */
294
- setHealthInfo(grade, stats) {
295
- this.healthGrade = grade;
296
- this.graphStats = stats;
297
- }
298
- /**
299
- * Set the pending violation store for push-based rule enforcement (Task 7.3).
300
- */
301
- setPendingViolations(store) {
302
- this.pendingViolations = store;
303
- }
304
- /**
305
- * Set the project root path for file operations (Task 7.4 revert).
306
- */
307
- setProjectRoot(root) {
308
- this.projectRoot = root;
309
- }
310
- /**
311
- * Set the background indexer reference (L11.2).
312
- * When set and indexing is active, tools return partial results with indexing metadata.
313
- */
314
- setBackgroundIndexer(indexer) {
315
- this.backgroundIndexer = indexer;
316
- }
317
- /**
318
- * Set the local embedding store (Sprint L3).
319
- * When set, semantic_search and find_similar route locally.
320
- */
321
- setEmbeddingStore(store) {
322
- this.embeddingStore = store;
323
- }
324
- /**
325
- * Set the deferred embedding computation status (L8.3).
326
- * The proxy updates this object's progress/ready fields as computation proceeds.
327
- */
328
- setEmbeddingStatus(status) {
329
- this.embeddingStatus = status;
330
- }
331
- /**
332
- * Set the drift tracker for drift overlay writes (L9.4).
333
- */
334
- setDriftTracker(tracker) {
335
- this.driftTracker = tracker;
336
- }
337
- /**
338
- * S1: Set session dedup tracker for _context deduplication.
339
- */
340
- setSessionDedup(dedup) {
341
- this.sessionDedup = dedup;
342
- }
343
- /**
344
- * S1: Set compression quality monitor for adaptive compression.
345
- */
346
- setCompressionMonitor(monitor) {
347
- this.compressionMonitor = monitor;
348
- }
349
- /**
350
- * S2: Set session health monitor for degradation detection.
351
- */
352
- setHealthMonitor(monitor) {
353
- this.healthMonitor = monitor;
354
- }
355
- /**
356
- * S2: Set exploration cost accumulator for token savings tracking.
357
- */
358
- setExplorationAccumulator(accumulator) {
359
- this.explorationAccumulator = accumulator;
360
- }
361
- /**
362
- * S2: Get cumulative exploration savings (for session summary at shutdown).
363
- */
364
- getExplorationSavings() {
365
- return this.explorationAccumulator?.getTotal() ?? null;
366
- }
367
- /**
368
- * S3: Set context rot detector for long-session degradation detection.
369
- */
370
- setContextRotDetector(detector) {
371
- this.contextRotDetector = detector;
372
- }
373
- /**
374
- * S4: Set token counter for live stderr emission and session accounting.
375
- */
376
- setTokenCounter(counter) {
377
- this.tokenCounter = counter;
378
- }
379
- /**
380
- * S4: Set efficiency tracker for session-level original vs delivered tracking.
381
- */
382
- setEfficiencyTracker(tracker) {
383
- this.efficiencyTracker = tracker;
384
- }
385
- /**
386
- * Layer 10: Set token flow writer for unified savings attribution.
387
- */
388
- setTokenFlow(writer) {
389
- this.tokenFlow = writer;
390
- }
391
- /**
392
- * Layer 10: Get token flow writer (for external consumers like format-encoder).
393
- */
394
- getTokenFlow() {
395
- return this.tokenFlow;
396
- }
397
- setEffectivenessTracker(tracker) {
398
- this.effectivenessTracker = tracker;
399
- }
400
- getEffectivenessTracker() {
401
- return this.effectivenessTracker;
402
- }
403
- /**
404
- * S7.4: Set session resume context (injected on first response).
405
- */
406
- setSessionResumeContext(ctx) {
407
- this.sessionResumeContext = ctx;
408
- }
409
- /**
410
- * S7.5: Set durability scorer for entity fragility warnings.
411
- */
412
- setDurabilityScorer(scorer) {
413
- this.durabilityScorer = scorer;
414
- }
415
- /**
416
- * S7.6: Set anti-pattern entries from negative knowledge analysis.
417
- */
418
- setAntiPatterns(entries) {
419
- this.antiPatternEntries = entries;
420
- }
421
- /**
422
- * S8.5: Set the value guard instance for dollar threshold notifications.
423
- */
424
- setValueGuard(guard) {
425
- this.valueGuard = guard;
426
- }
427
- /**
428
- * Sprint 1.2: Wire temporal fact store for _context injection.
429
- */
430
- setFactStore(store) {
431
- this.factStore = store;
432
- }
433
- /**
434
- * Wire the persistent rotation store so signal show counts survive restart
435
- * and are coordinated across parallel sessions in the same repo.
436
- */
437
- setSignalShowStore(store) {
438
- this.sessionContext.setSignalShowStore(store);
439
- }
440
- /** Sprint 1.2: Get fact IDs surfaced this session (for session summary). */
441
- getFactsSurfaced() {
442
- return this.factsSurfaced;
443
- }
444
- /** Sprint 9.3: Get signal delivery stats for intelligence health UI. */
445
- getSignalStats() {
446
- const { total_delivered, by_type, tool_calls_with_signals, total_tool_calls, } = this.signalDeliveryStats;
447
- return {
448
- total_delivered,
449
- by_type,
450
- coverage_pct: total_tool_calls > 0
451
- ? Math.round((tool_calls_with_signals / total_tool_calls) * 100)
452
- : 0,
453
- };
454
- }
455
- /**
456
- * S9.1: Wire circuit breaker into query router.
457
- * Records entity attempts and injects halt messages when threshold tripped.
458
- */
459
- setCircuitBreaker(breaker) {
460
- this.circuitBreaker = breaker;
461
- }
462
- /**
463
- * Q.1: Wire causal bridge for entity history — surfaces as `ur|hst` / `ur|fct` prefix lines.
464
- */
465
- setCausalBridge(bridge) {
466
- this.causalBridge = bridge;
467
- }
468
- /**
469
- * Q.3: Set learned conventions from cross-session correction analysis.
470
- */
471
- setLearnedConventions(conventions) {
472
- this.learnedConventions = conventions;
473
- }
474
- /**
475
- * Q.1: Set prompt durability profiles for strategy recommendations.
476
- */
477
- setPromptDurabilityProfiles(profiles) {
478
- this.promptDurabilityProfiles = profiles;
479
- }
480
- /**
481
- * Cross-session context ledger — prevents re-delivering context across sessions.
482
- */
483
- setContextLedger(ledger) {
484
- this.contextLedger = ledger;
485
- }
486
- /**
487
- * Intent token tracker — groups tool calls by intent for accounting.
488
- */
489
- setIntentTracker(tracker) {
490
- this.intentTracker = tracker;
491
- }
492
- /**
493
- * Layer 7: Event bus for dashboard real-time updates.
494
- */
495
- setEventBus(bus) {
496
- this.eventBus = bus;
497
- }
498
- /**
499
- * S8: Get accumulated session dollar savings for scorecard/guard.
500
- */
501
- getSessionDollarsSaved() {
502
- return this.sessionDollarsSaved;
503
- }
504
- /**
505
- * S4: Get efficiency snapshot for session summary at shutdown.
506
- */
507
- getEfficiencySnapshot() {
508
- if (!this.efficiencyTracker)
509
- return null;
510
- const snap = this.efficiencyTracker.getSnapshot();
511
- return {
512
- totalCalls: snap.totalCalls,
513
- savedTokens: snap.savedTokens,
514
- efficiency: snap.efficiency,
515
- };
516
- }
517
- /**
518
- * Layer 7: Intent groups for dashboard session API.
519
- */
520
- getIntentGroups() {
521
- return this.intentTracker?.getAllGroups() ?? [];
522
- }
523
- /**
524
- * Set the current proxy operating mode. Affects _meta on all responses.
525
- */
526
- setMode(mode, reason) {
527
- this.currentMode = mode;
528
- this.modeReason = reason ?? "";
529
- }
530
- getMode() {
531
- return this.currentMode;
532
- }
533
- /**
534
- * Update the branch context (called on startup and branch switch).
535
- */
536
- setBranchContext(ctx) {
537
- this.branchContext = ctx;
538
- }
539
- isKnownTool(toolName) {
540
- return LOCAL_TOOLS.has(toolName);
541
- }
542
- async execute(toolName, args) {
543
- const t0 = performance.now();
544
- // Mode-aware: SETUP mode returns informational response (not an error)
545
- if (this.currentMode === "setup") {
546
- const r = this.buildModeResponse(toolName, t0, `unerr is not yet configured for this repository. Run 'unerr' to complete setup. Tool '${toolName}' will be available after setup.`);
547
- await this.enrichResult(toolName, args, r);
548
- return r;
549
- }
550
- // L11.2: During background indexing, return partial results or progress messages
551
- if (this.backgroundIndexer?.isIndexing()) {
552
- const progress = this.backgroundIndexer.getProgress();
553
- const indexingMeta = {
554
- source: "local",
555
- latency_ms: performance.now() - t0,
556
- indexing: true,
557
- partial: true,
558
- indexed_files: progress.processed,
559
- total_files: progress.total,
560
- };
561
- this.injectModeMeta(indexingMeta);
562
- // Try to serve from partial graph — if entities exist, return them with caveat
563
- if (LOCAL_TOOLS.has(toolName)) {
564
- try {
565
- const result = await this.executeLocal(toolName, args);
566
- if (result !== null && result !== undefined) {
567
- const toolResult = {
568
- content: result,
569
- _meta: indexingMeta,
570
- };
571
- await this.enrichResult(toolName, args, toolResult);
572
- return toolResult;
573
- }
574
- }
575
- catch (err) {
576
- process.stderr.write(`[unerr] ⚠ Graph query failed during tool dispatch: ${formatUnknownError(err)}\n`);
577
- }
578
- }
579
- // Graph empty or non-local tool — return structured progress message
580
- return {
581
- content: {
582
- message: `Indexing in progress: ${progress.processed}/${progress.total} files (${progress.pct}%)`,
583
- indexing: true,
584
- phase: progress.phase,
585
- processed: progress.processed,
586
- total: progress.total,
587
- pct: progress.pct,
588
- tool: toolName,
589
- available: false,
590
- },
591
- _meta: indexingMeta,
592
- };
593
- }
594
- // Unknown tool
595
- if (!LOCAL_TOOLS.has(toolName)) {
596
- const r = this.buildModeResponse(toolName, t0, `Unknown tool '${toolName}'. Run 'unerr status' to see available tools.`);
597
- return r;
598
- }
599
- try {
600
- // Phase 1: Tool-level timeout prevents stuck MCP calls from CozoDB contention.
601
- // Content-heavy tools (file_read, file_outline, search_code) hit CozoDB hard and
602
- // can legitimately exceed 3s under indexer contention, so they get a higher tier.
603
- const HEAVY_TOOLS = new Set(["file_read", "file_outline", "search_code"]);
604
- const TOOL_TIMEOUT_MS = HEAVY_TOOLS.has(toolName) ? 5000 : 3000;
605
- const rawLocal = await Promise.race([
606
- this.executeLocal(toolName, args),
607
- new Promise((_, reject) => setTimeout(() => reject(new Error(`tool_timeout: ${toolName} exceeded ${TOOL_TIMEOUT_MS}ms`)), TOOL_TIMEOUT_MS)),
608
- ]);
609
- const latency_ms = performance.now() - t0;
610
- const meta = { source: "local", latency_ms };
611
- let result = rawLocal;
612
- if (toolName === "file_read" &&
613
- rawLocal &&
614
- typeof rawLocal === "object") {
615
- const fr = rawLocal;
616
- if ("content" in fr) {
617
- result = fr.content;
618
- if (fr._layer6_meta) {
619
- Object.assign(meta, fr._layer6_meta);
620
- // Layer 10: Record file read optimization savings.
621
- // Gate only on total_lines (always set on successful reads) — `optimization`
622
- // is informational and absent on full small-file reads, but those have zero
623
- // savings anyway and are filtered by the `> 0` threshold below.
624
- if (this.tokenFlow && fr._layer6_meta.total_lines) {
625
- const deliveredTokens = fr._layer6_meta.tokens_estimate ?? 0;
626
- const fullFileTokens = fr._layer6_meta.total_chars
627
- ? Math.ceil(fr._layer6_meta.total_chars / 4)
628
- : Math.ceil((fr._layer6_meta.total_lines * 80) / 4);
629
- const fileReadSaved = fullFileTokens - deliveredTokens;
630
- if (fileReadSaved > 0) {
631
- this.tokenFlow.record({
632
- session_id: this.tokenFlow.sessionId,
633
- turn: this.sessionContext.getToolCallCount(),
634
- mechanism: "file_read",
635
- tool: toolName,
636
- tokens_without: fullFileTokens,
637
- tokens_with: deliveredTokens,
638
- tokens_saved: fileReadSaved,
639
- detail: {
640
- optimization: fr._layer6_meta.optimization ?? "file_read full",
641
- total_lines: fr._layer6_meta.total_lines,
642
- },
643
- });
644
- }
645
- }
646
- }
647
- }
648
- }
649
- // P10-MV-01: Inject entity risk metadata for entity-returning tools
650
- const entityRisk = extractEntityRisk(toolName, result);
651
- if (entityRisk) {
652
- meta.entity_risk = entityRisk;
653
- }
654
- // P10-PROXY-02: Inject drift metadata
655
- const driftMeta = await this.extractDriftMeta(toolName, args, result);
656
- if (driftMeta) {
657
- meta.drift = driftMeta;
658
- }
659
- // Persistent-memory effectiveness: subsequent activity on an entity
660
- // counts as the agent "acting on" any open fact/convention signal for
661
- // that entity. Drift / high entity_risk also signal a potential
662
- // correction — record both observations.
663
- if (this.effectivenessTracker) {
664
- const dispatchArgs = args;
665
- const entityKey = dispatchArgs.key ??
666
- dispatchArgs.name ??
667
- null;
668
- if (entityKey) {
669
- this.effectivenessTracker.recordEdit(entityKey);
670
- if (driftMeta?.entityStatus) {
671
- this.effectivenessTracker.recordCorrection(entityKey, "drift");
672
- }
673
- if (entityRisk?.risk_level === "high") {
674
- this.effectivenessTracker.recordCorrection(entityKey, "blast_radius");
675
- }
676
- }
677
- }
678
- // L8.3: Merge inner _meta from executeLocal (e.g. embedding_status)
679
- if (result &&
680
- typeof result === "object" &&
681
- "_meta" in result) {
682
- const innerMeta = result._meta;
683
- Object.assign(meta, innerMeta);
684
- }
685
- this.injectModeMeta(meta);
686
- // S1: Compress large text output before delivering to agent
687
- const compressedContent = await this.maybeCompressContent(toolName, result, meta);
688
- // Strip noise fields that are meaningless to coding agents before encoding
689
- const cleanedContent = stripNoiseFields(toolName, compressedContent);
690
- // Tier-3 wire-cap MUST run BEFORE format encoding. Reason: format-encoder
691
- // converts arrays into columnar STRINGS, after which wire-cap can no
692
- // longer slice (the array is gone). Apply pagination to the raw object,
693
- // then encode the smaller result. Stash the pageHint on `meta` for the
694
- // wire boundary to surface as the leading `ur|` line.
695
- const { applyWireCap: applyCapEarly } = await import("../proxy/wire-cap.js");
696
- const { body: cappedContent, pageHint: cappedHint } = applyCapEarly(toolName, cleanedContent, args);
697
- if (cappedHint) {
698
- // _unerr_page_hint is an internal-only field — wire boundaries
699
- // consume it (prepend to body) and never serialize it on the wire.
700
- meta._unerr_page_hint = cappedHint;
701
- }
702
- // Layer 6 FE-C / FE-E: columnar wire encoding (after compression, before envelope)
703
- const estLayer6 = this.estimateLayer6Tokens(cappedContent);
704
- const layer6Tier = this.compressionMonitor?.getLayer6Tier(toolName) ?? "columnar";
705
- const formattedContent = formatToolOutput(toolName, cappedContent, meta, {
706
- legend: this.sessionLegend,
707
- tier: layer6Tier,
708
- });
709
- // Layer 10: Record format encoding savings
710
- const postEncodingTok = typeof formattedContent === "string"
711
- ? estimateTokens(formattedContent)
712
- : estimateTokens(JSON.stringify(formattedContent));
713
- if (this.compressionMonitor && meta.format === "columnar") {
714
- const ratio = postEncodingTok / Math.max(1, estLayer6);
715
- this.compressionMonitor.recordCompression(`${toolName}-l6-${Date.now()}`, "layer6_columnar", ratio);
716
- }
717
- if (this.tokenFlow && estLayer6 > postEncodingTok) {
718
- const encodingSaved = estLayer6 - postEncodingTok;
719
- this.tokenFlow.record({
720
- session_id: this.tokenFlow.sessionId,
721
- turn: this.sessionContext.getToolCallCount(),
722
- mechanism: "format_encoding",
723
- tool: toolName,
724
- tokens_without: estLayer6,
725
- tokens_with: postEncodingTok,
726
- tokens_saved: encodingSaved,
727
- detail: { format: meta.format },
728
- });
729
- }
730
- const toolResult = {
731
- content: formattedContent,
732
- _meta: meta,
733
- };
734
- const enrichStats = await this.enrichResult(toolName, args, toolResult);
735
- // Layer 7: Emit tool_call event for dashboard SSE — stats stay internal,
736
- // never written to wire `_meta` (vanity-strip pass).
737
- if (this.eventBus) {
738
- const entityKey = args.key ?? args.name ?? toolName;
739
- this.eventBus.emit("tool_call", {
740
- tool: toolName,
741
- entity: entityKey,
742
- latency_ms: toolResult._meta.latency_ms,
743
- tokens_saved: enrichStats.tokensSaved,
744
- });
745
- // Layer 10: Emit token_flow SSE event for dashboard real-time counter
746
- if (enrichStats.tokensSaved > 0) {
747
- const resultStr = typeof toolResult.content === "string"
748
- ? toolResult.content
749
- : JSON.stringify(toolResult.content);
750
- const tokensDelivered = Math.ceil(resultStr.length / 4);
751
- const sessionTotal = this.tokenFlow?.getSessionTokensSaved() ?? 0;
752
- const sessionEff = this.tokenFlow?.getSessionEfficiency() ?? 0;
753
- this.eventBus.emit("token_flow", {
754
- turn: this.sessionContext.getToolCallCount(),
755
- tool: toolName,
756
- mechanism: enrichStats.savingsMechanism ?? "graph_query",
757
- tokens_saved: enrichStats.tokensSaved,
758
- tokens_delivered: tokensDelivered,
759
- session_total: sessionTotal,
760
- session_efficiency: sessionEff,
761
- });
762
- }
763
- }
764
- return toolResult;
765
- }
766
- catch (err) {
767
- // S3.5: Feed errors into context rot detector
768
- if (this.contextRotDetector) {
769
- this.contextRotDetector.recordError();
770
- }
771
- const errMsg = err instanceof Error
772
- ? err.message
773
- : typeof err === "object" && err !== null
774
- ? JSON.stringify(err)
775
- : String(err);
776
- process.stderr.write(`[unerr:tool-error] ${toolName}: ${errMsg}\n`);
777
- const isTimeout = errMsg.startsWith("tool_timeout:");
778
- const meta = {
779
- source: "local",
780
- latency_ms: performance.now() - t0,
781
- };
782
- this.injectModeMeta(meta);
783
- return {
784
- content: {
785
- error: isTimeout
786
- ? `Tool '${toolName}' timed out — the graph may be busy indexing. The tool will work on retry. If this persists, restart the unerr process.`
787
- : `Tool '${toolName}' failed locally: ${errMsg}`,
788
- ...(isTimeout ? { retryable: true, timeout: true } : {}),
789
- },
790
- _meta: meta,
791
- };
792
- }
793
- }
794
- /**
795
- * Build an informational response for degraded modes (isError: false).
796
- */
797
- buildModeResponse(toolName, t0, message) {
798
- const meta = {
799
- source: "local",
800
- latency_ms: performance.now() - t0,
801
- };
802
- this.injectModeMeta(meta);
803
- return {
804
- content: { message, tool: toolName, available: false },
805
- _meta: meta,
806
- };
807
- }
808
- /**
809
- * Inject mode, mode_reason, and tools_degraded into _meta.
810
- */
811
- injectModeMeta(meta) {
812
- if (meta.format === undefined) {
813
- meta.format = "json";
814
- }
815
- meta.mode = this.currentMode;
816
- if (this.modeReason) {
817
- meta.mode_reason = this.modeReason;
818
- }
819
- if (this.currentMode !== "local") {
820
- meta.tools_degraded = this.getDegradedTools();
821
- }
822
- }
823
- /**
824
- * Get list of tools that are degraded in current mode.
825
- */
826
- getDegradedTools() {
827
- switch (this.currentMode) {
828
- case "parse":
829
- return [/* "check_rules", "get_business_context", */ "get_conventions"];
830
- case "setup":
831
- return Array.from(LOCAL_TOOLS);
832
- default:
833
- return [];
834
- }
835
- }
836
- /**
837
- * Sprint 2: Enrich a tool result with blast radius, conventions, drift alerts,
838
- * session greeting, and value counter. Dedup is handled by SessionContext.
839
- *
840
- * Called after execute() for all local entity-returning tools.
841
- */
842
- async enrichResult(toolName, args, result) {
843
- this.sessionContext.recordToolCall();
844
- // S2.4: Feed tool call into health monitor
845
- if (this.healthMonitor) {
846
- const entityKey = args.key ?? args.name;
847
- this.healthMonitor.recordToolCall(toolName, entityKey ?? undefined);
848
- }
849
- // S9.1 + S9.2: Circuit breaker — record attempt and check for halt
850
- if (this.circuitBreaker && ENRICHABLE_TOOLS.has(toolName)) {
851
- const entityKey = args.key ?? args.name;
852
- if (entityKey) {
853
- // Determine if this attempt had violations (e.g., from rule evaluation)
854
- const hasViolations = result._meta.auto_check?.violations
855
- ? result._meta.auto_check.violations.length > 0
856
- : false;
857
- this.circuitBreaker.recordAttempt(entityKey, hasViolations);
858
- if (hasViolations && this.eventBus) {
859
- const ac = result._meta.auto_check?.violations ?? [];
860
- this.eventBus.emit("violation", {
861
- source: "push_rules",
862
- entity: entityKey,
863
- count: ac.length,
864
- tool: toolName,
865
- });
866
- }
867
- // S9.2: Force-trigger if session health is critically low
868
- const healthScore = this.healthMonitor?.getHealth().health ?? 1.0;
869
- const forceBreak = healthScore < this.HEALTH_CIRCUIT_BREAK_THRESHOLD;
870
- const breakerResult = this.circuitBreaker.check([entityKey]);
871
- if (breakerResult || forceBreak) {
872
- const msg = breakerResult
873
- ? breakerResult.message
874
- : `Session health critically low (${(healthScore * 100).toFixed(0)}%). Halting repeated attempts on ${entityKey}.`;
875
- result._meta.circuit_breaker = {
876
- entity: entityKey,
877
- attempts: breakerResult?.attempts ?? 0,
878
- message: msg,
879
- };
880
- this.effectivenessTracker?.recordCorrection(entityKey, "circuit_breaker");
881
- // S9.7: Stderr notification on circuit break
882
- process.stderr.write(`[unerr] Circuit breaker: halting repeated attempts on ${entityKey}\n`);
883
- // S9.5: Increment caught counter for convention violations that triggered breaker
884
- if (this.sessionEvents) {
885
- this.sessionEvents.conventionViolationsCaught++;
886
- }
887
- if (this.eventBus) {
888
- this.eventBus.emit("circuit_breaker", {
889
- entity: entityKey,
890
- attempts: breakerResult?.attempts ?? 0,
891
- forced_by_health: Boolean(forceBreak && !breakerResult),
892
- message: msg,
893
- });
894
- }
895
- }
896
- }
897
- }
898
- // S2.7+S2.8: Track exploration cost \u2014 vanity fields stripped from wire,
899
- // accumulators continue to feed dashboard via direct calls.
900
- let enrichTokensSaved = 0;
901
- let enrichSavingsMechanism;
902
- if (this.explorationAccumulator && LOCAL_TOOLS.has(toolName)) {
903
- const resultSize = this.estimateResultSize(result.content);
904
- const estimate = estimateExplorationCost(toolName, resultSize);
905
- this.explorationAccumulator.record(estimate);
906
- const saved = estimate.tokensWithout - estimate.tokensUsed;
907
- if (saved > 0) {
908
- // File-navigation tools (file_read, file_outline, get_file) save tokens
909
- // by NOT making the agent read the whole file. They belong in the
910
- // "file_read" mechanism bucket, not "graph_query" (which is for entity-
911
- // graph lookups that replaced grep/glob). Categorization is purely a
912
- // measurement/dashboard concern — the agent sees no difference.
913
- const isFileNav = toolName === "file_read" ||
914
- toolName === "file_outline" ||
915
- toolName === "get_file";
916
- const mechanism = isFileNav
917
- ? "file_read"
918
- : "graph_query";
919
- enrichTokensSaved = saved;
920
- enrichSavingsMechanism = mechanism;
921
- // S8.5: Accumulate session dollars and run guard. Surface value_guard nudge
922
- // (anti-drift; one-time when threshold crossed). Other monetary fields stay internal.
923
- const dollarSavings = calculateDollarSavings(saved);
924
- this.sessionDollarsSaved += dollarSavings;
925
- if (this.valueGuard) {
926
- const guardMsg = this.valueGuard.check(this.sessionDollarsSaved);
927
- if (guardMsg) {
928
- result._meta.value_guard = guardMsg;
929
- }
930
- }
931
- // S4.1+S4.2: Feed into token counter and efficiency tracker
932
- if (this.tokenCounter) {
933
- this.tokenCounter.record(saved, estimate.tokensWithout);
934
- }
935
- if (this.efficiencyTracker) {
936
- this.efficiencyTracker.record(estimate.tokensWithout, estimate.tokensUsed);
937
- }
938
- // Layer 10: Record savings to token flow under the appropriate mechanism.
939
- // `file_read` tool has its own more-accurate recording at line ~1217
940
- // (based on actual file-slice delta from _layer6_meta), so skip the
941
- // exploration-cost estimate here for it to avoid double-counting on
942
- // the same call. `file_outline` / `get_file` rely on the estimate here.
943
- if (toolName !== "file_read") {
944
- this.tokenFlow?.record({
945
- session_id: this.tokenFlow.sessionId,
946
- turn: this.sessionContext.getToolCallCount(),
947
- mechanism,
948
- tool: toolName,
949
- tokens_without: estimate.tokensWithout,
950
- tokens_with: estimate.tokensUsed,
951
- tokens_saved: saved,
952
- detail: { counterfactual: estimate.counterfactualMethod },
953
- });
954
- }
955
- // Q: Intent token tracker — attribute savings to active intent
956
- if (this.intentTracker) {
957
- const activeIntent = this.intentTracker.getActiveIntentId();
958
- if (activeIntent) {
959
- const entityKey = args.key ?? args.name;
960
- this.intentTracker.recordToolCall(activeIntent, estimate.tokensUsed, saved, entityKey);
961
- }
962
- }
963
- }
964
- }
965
- // S3.3: Feed token estimates into context rot detector
966
- if (this.contextRotDetector) {
967
- const contentStr = typeof result.content === "string"
968
- ? result.content
969
- : JSON.stringify(result.content);
970
- this.contextRotDetector.recordToolCallTokens(estimateTokens(contentStr));
971
- // S3.4: Detect repeated queries via sessionContext cross-reference
972
- if (ENRICHABLE_TOOLS.has(toolName)) {
973
- const entityKey = args.key ?? args.name;
974
- if (entityKey && this.sessionContext.hasHistory(entityKey)) {
975
- this.contextRotDetector.recordRepeatedQuery(entityKey);
976
- }
977
- }
978
- }
979
- // Skip enrichment in setup mode — no graph available
980
- if (this.currentMode === "setup") {
981
- return {
982
- tokensSaved: enrichTokensSaved,
983
- savingsMechanism: enrichSavingsMechanism,
984
- };
985
- }
986
- // S5.4: Compute token budget ceiling — skip context injection if content already at capacity
987
- const tokenBudget = typeof args.token_budget === "number" && args.token_budget >= 100
988
- ? args.token_budget
989
- : 2000;
990
- const contentForBudget = typeof result.content === "string"
991
- ? result.content
992
- : JSON.stringify(result.content);
993
- const tokensUsed = estimateTokens(contentForBudget);
994
- const atBudgetCeiling = tokensUsed >= tokenBudget;
995
- // S5.4: Skip all context injection if response content is at budget ceiling
996
- if (atBudgetCeiling) {
997
- // Still compute token accounting metadata, but skip _context to avoid bloat
998
- result._meta.tokens_budget = tokenBudget;
999
- result._meta.tokens_used = tokensUsed;
1000
- result._meta.truncated = true;
1001
- result._meta.full_tokens_estimate = tokensUsed;
1002
- result._meta.truncation_level = "full";
1003
- return {
1004
- tokensSaved: enrichTokensSaved,
1005
- savingsMechanism: enrichSavingsMechanism,
1006
- };
1007
- }
1008
- const context = {};
1009
- let hasContext = false;
1010
- // ── Sprint 4: Structured Session Brief (first call only) ──────
1011
- if (this.sessionContext.isFirstCall()) {
1012
- // Build structured brief (replaces flat greeting + resume)
1013
- try {
1014
- const { SessionBriefBuilder } = await import("./session-brief-builder.js");
1015
- const briefBuilder = new SessionBriefBuilder(this.localGraph, this.factStore, this.graphStats, this.healthGrade);
1016
- const brief = await briefBuilder.build(this.sessionResumeContext);
1017
- context.session_brief = brief;
1018
- hasContext = true;
1019
- }
1020
- catch {
1021
- // Fallback to flat greeting if brief builder fails
1022
- const greeting = this.buildSessionGreeting();
1023
- if (greeting) {
1024
- context.session_greeting = greeting;
1025
- hasContext = true;
1026
- }
1027
- if (this.sessionResumeContext) {
1028
- context.session_resume = this.sessionResumeContext;
1029
- hasContext = true;
1030
- }
1031
- }
1032
- this.sessionResumeContext = null; // One-time injection
1033
- this.sessionContext.markGreeted();
1034
- // Inject active skills context on first call so agent knows available intelligence
1035
- try {
1036
- const { getSkillsContext } = await import("../skills/local-pack.js");
1037
- const skillsCtx = getSkillsContext();
1038
- if (skillsCtx) {
1039
- Object.assign(context, skillsCtx);
1040
- hasContext = true;
1041
- }
1042
- }
1043
- catch {
1044
- // Non-blocking — skills context is optional enhancement
1045
- }
1046
- // Tool adoption nudge — keeps agent on unerr tools instead of built-ins.
1047
- // Anti-drift signal; do NOT remove.
1048
- context.tool_adoption = {
1049
- hint: "This project has unerr graph intelligence tools. Use search_code (find entities), get_callers (find references), file_outline (file structure), file_read (read with context) INSTEAD OF built-in Grep/Glob/Read. Graph tools are faster (<5ms) and include project conventions.",
1050
- tools_available: 18,
1051
- };
1052
- hasContext = true;
1053
- }
1054
- // ── Task 2.2 + 2.3 + 2.5 + 7.8: Entity-level enrichment ───
1055
- if (ENRICHABLE_TOOLS.has(toolName)) {
1056
- // Agents pass entity NAMES (e.g. "startMcpServer") but the graph stores
1057
- // 16-char hex keys. Without resolution, getBlastRadius runs against the
1058
- // literal name, returns 0 rows, and emits a misleading "No dependencies"
1059
- // summary alongside a tool result that actually has callers/callees.
1060
- // Resolve once here so every downstream query AND the session-context
1061
- // caches use a single canonical key.
1062
- const rawEntityArg = args.key ?? args.name;
1063
- // File-level tools (get_file, get_imports via key-aliasing, etc.) pass
1064
- // file paths here, not entity names. Running blast-radius on a file path
1065
- // yields 0 rows and prints a misleading "No dependencies" signal. Detect
1066
- // path-shaped args and skip entity enrichment for them.
1067
- const looksLikeFilePath = typeof rawEntityArg === "string" &&
1068
- (rawEntityArg.includes("/") ||
1069
- /\.(ts|tsx|js|jsx|mjs|cjs|json|md|yml|yaml)$/.test(rawEntityArg));
1070
- const entityKey = rawEntityArg && !looksLikeFilePath
1071
- ? await this.resolveKeyArg(rawEntityArg)
1072
- : null;
1073
- if (entityKey) {
1074
- // Tier-3: re-query of previously-seen entity is now signaled via
1075
- // result._meta.context_complete=true → "ur|ctx already delivered" prefix.
1076
- // The legacy `context.reminder = "Previously queried: N callers..."` was
1077
- // redundant noise (showed up twice as ur|ctx + ur|fct). Drop it.
1078
- if (this.sessionContext.hasHistory(entityKey)) {
1079
- result._meta.context_complete = true;
1080
- }
1081
- let blastRadiusCount = 0;
1082
- let riskLevel = "normal";
1083
- // Task 2.2: Blast radius (first query per entity)
1084
- if (this.sessionContext.shouldInjectBlastRadius(entityKey)) {
1085
- try {
1086
- const br = await this.localGraph.getBlastRadius(entityKey);
1087
- const brEntities = await this.localGraph.getBlastRadiusEntities(entityKey);
1088
- result._meta.blast_radius = {
1089
- direct_callers: br.direct_callers,
1090
- direct_callees: br.direct_callees,
1091
- transitive_depth2: br.transitive_count,
1092
- is_chokepoint: br.is_chokepoint,
1093
- affected_entities: brEntities.slice(0, 20),
1094
- };
1095
- context.blast_radius = br.summary;
1096
- hasContext = true;
1097
- blastRadiusCount = br.direct_callers;
1098
- // S2.5: Feed blast radius into health monitor
1099
- if (this.healthMonitor) {
1100
- this.healthMonitor.recordBlastRadius(entityKey, br.direct_callers);
1101
- }
1102
- // Related issues from chokepoint detection
1103
- if (br.is_chokepoint) {
1104
- context.related_issues = context.related_issues ?? [];
1105
- context.related_issues.push("Chokepoint: high fan_in and fan_out \u2014 changes here have wide blast radius");
1106
- riskLevel = "high";
1107
- // S9.5: Wire chokepoint warning into caught counter
1108
- if (this.sessionEvents) {
1109
- this.sessionEvents.chokepointWarningsIssued++;
1110
- }
1111
- }
1112
- }
1113
- catch (err) {
1114
- process.stderr.write(`[unerr] ⚠ Blast radius query failed for ${entityKey}: ${formatUnknownError(err)}\n`);
1115
- }
1116
- }
1117
- // Leapfrog Sprint A.3: Community context injection (after blast radius)
1118
- try {
1119
- const communityInfo = await this.localGraph.getCommunityForEntity(entityKey);
1120
- if (communityInfo && communityInfo.id >= 0) {
1121
- result._meta.community = {
1122
- id: communityInfo.id,
1123
- label: communityInfo.label,
1124
- size: communityInfo.size,
1125
- cohesion: communityInfo.cohesion,
1126
- };
1127
- const crossEdges = await this.localGraph.getCrossCommunityEdges(entityKey);
1128
- if (crossEdges.length > 0) {
1129
- result._meta.cross_community_edges = crossEdges
1130
- .slice(0, 10)
1131
- .map((e) => ({
1132
- entity_key: e.entity_key,
1133
- entity_name: e.entity_name,
1134
- entity_community_label: e.entity_community_label,
1135
- relation: e.relation,
1136
- }));
1137
- result._meta.cross_community_count = crossEdges.length;
1138
- context.community = `Community "${communityInfo.label}" (${communityInfo.size} entities, cohesion ${communityInfo.cohesion}). ${crossEdges.length} cross-community connection${crossEdges.length !== 1 ? "s" : ""} to: ${[...new Set(crossEdges.map((e) => e.entity_community_label))].join(", ")}`;
1139
- }
1140
- else {
1141
- context.community = `Community "${communityInfo.label}" (${communityInfo.size} entities, cohesion ${communityInfo.cohesion}). No cross-community connections.`;
1142
- }
1143
- hasContext = true;
1144
- }
1145
- }
1146
- catch (err) {
1147
- process.stderr.write(`[unerr] ⚠ Community query failed: ${formatUnknownError(err)}\n`);
1148
- }
1149
- // Determine risk level from entity if not already set
1150
- if (riskLevel === "normal") {
1151
- try {
1152
- const entity = await this.localGraph.getEntity(entityKey);
1153
- if (entity?.risk_level) {
1154
- riskLevel = entity.risk_level;
1155
- }
1156
- }
1157
- catch (err) {
1158
- process.stderr.write(`[unerr] ⚠ Entity risk lookup failed: ${formatUnknownError(err)}\n`);
1159
- }
1160
- }
1161
- // Leapfrog Sprint B.3: Correction injection (deduped per entity+errorType)
1162
- try {
1163
- const corrections = await this.localGraph.getCorrections(entityKey, 0.7);
1164
- const newCorrections = corrections.filter((c) => this.sessionContext.shouldInjectCorrection(c.entity_key, c.error_type));
1165
- if (newCorrections.length > 0) {
1166
- result._meta.corrections = newCorrections.map((c) => ({
1167
- entity_key: c.entity_key,
1168
- error_type: c.error_type,
1169
- confidence: c.confidence,
1170
- occurrences: c.occurrences,
1171
- }));
1172
- context.corrections = newCorrections.map((c) => `WARNING: ${c.correction_summary} (confidence: ${c.confidence}, seen ${c.occurrences}x)`);
1173
- hasContext = true;
1174
- }
1175
- }
1176
- catch (err) {
1177
- process.stderr.write(`[unerr] ⚠ Correction query failed: ${formatUnknownError(err)}\n`);
1178
- }
1179
- // S7.5: Durability scoring — warn on fragile entities (score < 0.5)
1180
- if (this.durabilityScorer) {
1181
- try {
1182
- const durScore = this.durabilityScorer.getScore(entityKey);
1183
- if (durScore && durScore.score < 0.5) {
1184
- result._meta.durability = {
1185
- score: durScore.score,
1186
- modificationCount: durScore.modificationCount,
1187
- avgSurvivalMs: durScore.avgSurvivalMs,
1188
- };
1189
- context.durability_warning = `FRAGILE: "${entityKey}" has durability ${durScore.score.toFixed(2)} (modified ${durScore.modificationCount}x, avg survival ${Math.round(durScore.avgSurvivalMs / 60000)}min). AI changes here rarely stick — consider a different approach.`;
1190
- hasContext = true;
1191
- // S7.8: Feed durability into session health monitor
1192
- if (this.healthMonitor) {
1193
- this.healthMonitor.recordDurability(entityKey, durScore.score);
1194
- }
1195
- }
1196
- }
1197
- catch (err) {
1198
- process.stderr.write(`[unerr] ⚠ Durability lookup failed: ${formatUnknownError(err)}\n`);
1199
- }
1200
- }
1201
- // S7.6: Anti-pattern injection from negative knowledge
1202
- if (this.antiPatternEntries.length > 0) {
1203
- const entityPatterns = this.antiPatternEntries.filter((e) => e.entityKey === entityKey);
1204
- if (entityPatterns.length > 0) {
1205
- context.anti_patterns = entityPatterns.map((p) => `ANTI-PATTERN: ${p.pattern} — ${p.reason}`);
1206
- hasContext = true;
1207
- }
1208
- }
1209
- // Q.1: Causal bridge — inject entity interaction history
1210
- if (this.causalBridge) {
1211
- try {
1212
- const chain = await this.causalBridge.buildCausalChain(entityKey);
1213
- if (chain.interactions.length > 0) {
1214
- result._meta.causal_history = {
1215
- durability: chain.durability,
1216
- interactions: chain.interactions.length,
1217
- failure_modes: chain.failureModes,
1218
- };
1219
- const recentInteractions = chain.interactions.slice(-3);
1220
- context.history = recentInteractions.map((i) => `${i.outcome === "survived" ? "✓" : "✗"} ${i.prompt.slice(0, 60)} (${i.outcome}, ${Math.round(i.survivalMs / 3600000)}h)`);
1221
- hasContext = true;
1222
- }
1223
- }
1224
- catch (err) {
1225
- process.stderr.write(`[unerr] ⚠ Causal bridge query failed: ${formatUnknownError(err)}\n`);
1226
- }
1227
- }
1228
- // Q.3: Inject learned conventions from cross-session correction analysis
1229
- if (this.learnedConventions.length > 0) {
1230
- const entityName = entityKey.split("::").pop() ?? entityKey;
1231
- const applicable = this.learnedConventions.filter((c) => c.confidence >= 0.6 &&
1232
- entityKey.includes(c.pattern.split(" ")[0] ?? ""));
1233
- if (applicable.length > 0) {
1234
- context.learned_conventions = applicable.map((c) => `LEARNED: ${c.name} (confidence: ${c.confidence.toFixed(2)})`);
1235
- hasContext = true;
1236
- }
1237
- }
1238
- // Q.1: Prompt durability — inject strategy recommendations for high-risk entities
1239
- if (this.promptDurabilityProfiles.length > 0 &&
1240
- riskLevel !== "normal") {
1241
- const lowDurability = this.promptDurabilityProfiles.filter((p) => p.durability < 0.5 && p.recommendation);
1242
- if (lowDurability.length > 0) {
1243
- context.prompt_strategy = lowDurability
1244
- .slice(0, 2)
1245
- .map((p) => p.recommendation);
1246
- hasContext = true;
1247
- }
1248
- }
1249
- // Task 2.3: Convention injection (deduped per convention ID)
1250
- try {
1251
- const entity = await this.localGraph.getEntity(entityKey);
1252
- if (entity) {
1253
- const conventions = await this.localGraph.getConventionsForEntity(entity.file_path);
1254
- const newConventions = conventions.filter((c) => this.sessionContext.shouldInjectConvention(c.id));
1255
- if (newConventions.length > 0) {
1256
- result._meta.conventions = newConventions.map((c) => ({
1257
- name: c.name,
1258
- adherence_pct: c.adherence_pct,
1259
- rule: c.rule,
1260
- }));
1261
- // Sprint 5: Prescriptive convention mode — actionable guidance instead of flat descriptions
1262
- const { getSignalScorer } = await import("./signal-scorer.js");
1263
- const scorer = getSignalScorer();
1264
- context.conventions = newConventions.map((c) => {
1265
- const signal = scorer.conventionToSignal({
1266
- name: c.name,
1267
- rule: c.rule,
1268
- adherence_pct: c.adherence_pct,
1269
- kind: c.kind ?? "entity",
1270
- }, toolName);
1271
- return signal.action
1272
- ? `${signal.content} — ${signal.action}`
1273
- : signal.content;
1274
- });
1275
- hasContext = true;
1276
- this.sessionContext.recordConventions(newConventions.map((c) => c.id));
1277
- if (this.effectivenessTracker) {
1278
- const turn = this.sessionContext.getToolCallCount();
1279
- const entityKey = args.key ??
1280
- args.name ??
1281
- null;
1282
- for (const c of newConventions) {
1283
- this.effectivenessTracker.recordSignalFired({
1284
- kind: "convention_injected",
1285
- signal_id: c.id,
1286
- entity_key: entityKey,
1287
- turn,
1288
- });
1289
- }
1290
- }
1291
- // S2.6: Feed convention violations into health monitor (adherence < 70%)
1292
- const violations = newConventions.filter((c) => c.adherence_pct < 70);
1293
- if (this.healthMonitor) {
1294
- for (const _v of violations) {
1295
- this.healthMonitor.recordConventionViolation();
1296
- }
1297
- }
1298
- // S9.5: Wire convention violations into caught counter
1299
- if (this.sessionEvents && violations.length > 0) {
1300
- this.sessionEvents.conventionViolationsCaught +=
1301
- violations.length;
1302
- }
1303
- }
1304
- }
1305
- }
1306
- catch (err) {
1307
- process.stderr.write(`[unerr] ⚠ Convention lookup failed: ${formatUnknownError(err)}\n`);
1308
- }
1309
- // Task 2.5: Proactive drift alert (deduped per entity)
1310
- if (this.sessionContext.shouldInjectRisk(entityKey)) {
1311
- try {
1312
- const driftMeta = result._meta.drift;
1313
- if (driftMeta?.entityStatus === "modified" ||
1314
- driftMeta?.entityStatus === "added") {
1315
- const br = result._meta.blast_radius;
1316
- const affectedCount = br ? br.direct_callers : 0;
1317
- context.drift_alert = `WARNING: ${entityKey} has been ${driftMeta.entityStatus} locally${affectedCount > 0 ? `, ${affectedCount} caller${affectedCount !== 1 ? "s" : ""} may be affected` : ""}`;
1318
- hasContext = true;
1319
- this.sessionContext.recordRisk(entityKey);
1320
- }
1321
- }
1322
- catch (err) {
1323
- process.stderr.write(`[unerr] ⚠ Drift alert failed: ${formatUnknownError(err)}\n`);
1324
- }
1325
- }
1326
- // Task 7.8: Record entity history on first query for post-compaction recovery
1327
- if (!this.sessionContext.hasHistory(entityKey)) {
1328
- this.sessionContext.recordEntityHistory(entityKey, blastRadiusCount, riskLevel);
1329
- }
1330
- // Record this entity as queried (dedup future calls)
1331
- this.sessionContext.recordQuery(entityKey);
1332
- }
1333
- }
1334
- // ── Sprint 1.2: Fact injection into _context (visible to agents) ──
1335
- if (this.factStore) {
1336
- const filePath = args.file_path ??
1337
- args.path ??
1338
- args.key?.split("::")[0] ??
1339
- null;
1340
- if (filePath) {
1341
- try {
1342
- let merged;
1343
- if (toolName === "file_read") {
1344
- const entityKeys = await this.getEntityKeysForFile(filePath);
1345
- merged = await this.factStore.recallForFile(filePath, entityKeys);
1346
- }
1347
- else {
1348
- const [fileFacts, negativeFacts] = await Promise.all([
1349
- this.factStore.recallByScope(filePath),
1350
- this.factStore.recallNegative(0.2),
1351
- ]);
1352
- const seen = new Set();
1353
- merged = [];
1354
- for (const f of fileFacts) {
1355
- if (!seen.has(f.fact_id)) {
1356
- seen.add(f.fact_id);
1357
- merged.push(f);
1358
- }
1359
- }
1360
- for (const f of negativeFacts) {
1361
- if (!seen.has(f.fact_id)) {
1362
- seen.add(f.fact_id);
1363
- merged.push(f);
1364
- }
1365
- }
1366
- }
1367
- // Dedup: only inject facts not yet delivered this session
1368
- const newFacts = merged.filter((f) => this.sessionContext.shouldInjectFact(f.fact_id));
1369
- if (newFacts.length > 0) {
1370
- const top = newFacts.slice(0, 5);
1371
- for (const f of top)
1372
- this.factsSurfaced.push(f.fact_id);
1373
- this.sessionContext.recordFacts(top.map((f) => f.fact_id));
1374
- // Table rows #16/17/18 CUT-FLUFF — drop "(confidence:…, source:…)"
1375
- // telemetry suffix from fact emissions. The dashboard reads
1376
- // confidence/source via the structured /api/facts route; the
1377
- // agent's context window does not benefit from these fields.
1378
- context.relevant_facts = top.map((f) => {
1379
- return `[${f.fact_type}] ${f.content}`;
1380
- });
1381
- hasContext = true;
1382
- if (this.effectivenessTracker) {
1383
- const turn = this.sessionContext.getToolCallCount();
1384
- const entityKey = args.key ??
1385
- args.name ??
1386
- null;
1387
- for (const f of top) {
1388
- this.effectivenessTracker.recordSignalFired({
1389
- kind: f.fact_type === "negative"
1390
- ? "negative_warned"
1391
- : "fact_injected",
1392
- signal_id: f.fact_id,
1393
- entity_key: entityKey,
1394
- turn,
1395
- });
1396
- }
1397
- }
1398
- }
1399
- }
1400
- catch {
1401
- // Fact recall failure is non-critical
1402
- }
1403
- }
1404
- }
1405
- // ── Task 2.7: Value Counter (every 3rd caught event) ─────────
1406
- if (this.sessionEvents) {
1407
- const counter = this.sessionContext.getValueCounter(this.sessionEvents);
1408
- if (counter) {
1409
- context.value_counter = counter;
1410
- hasContext = true;
1411
- }
1412
- }
1413
- // ── Sprint 3.2: Co-change prediction (GraphTemporalJoiner) ──
1414
- if (this.localGraph && ENRICHABLE_TOOLS.has(toolName)) {
1415
- const filePath = args.file_path ??
1416
- args.path ??
1417
- args.name;
1418
- if (filePath && typeof filePath === "string" && filePath.includes("/")) {
1419
- try {
1420
- const { GraphTemporalJoiner } = await import("./graph-temporal-joiner.js");
1421
- const joiner = new GraphTemporalJoiner(this.localGraph, this.factStore ? this.factStore : null);
1422
- const coChanges = await joiner.predictCoChanges(filePath);
1423
- const topCoChange = coChanges[0];
1424
- if (coChanges.length > 0 &&
1425
- topCoChange &&
1426
- topCoChange.combined_score > 0.3) {
1427
- const topFiles = coChanges
1428
- .slice(0, 3)
1429
- .map((c) => c.file_b)
1430
- .join(", ");
1431
- // Table row #15 TRIM — "co-changes (N edges): files" is tighter
1432
- // than "Often changed together: files (N shared edges)".
1433
- context.co_changes = `co-changes (${topCoChange.evidence}): ${topFiles}`;
1434
- hasContext = true;
1435
- }
1436
- // ── Sprint 6: Hidden coupling detection ──
1437
- const hidden = await joiner.detectHiddenCouplings();
1438
- const topHidden = hidden.filter((h) => h.file_a === filePath || h.file_b === filePath);
1439
- if (topHidden.length > 0) {
1440
- const hiddenFile = topHidden[0];
1441
- if (hiddenFile) {
1442
- const otherFile = hiddenFile.file_a === filePath
1443
- ? hiddenFile.file_b
1444
- : hiddenFile.file_a;
1445
- context.co_changes = `${context.co_changes ? `${context.co_changes}. ` : ""}Hidden dependency: ${otherFile} (${hiddenFile.evidence})`;
1446
- hasContext = true;
1447
- }
1448
- }
1449
- }
1450
- catch {
1451
- // Co-change prediction is non-critical
1452
- }
1453
- }
1454
- }
1455
- // ── Task 7.3: Push-based pending violations ─────────────────
1456
- if (this.pendingViolations?.hasPending) {
1457
- const violations = this.pendingViolations.drain();
1458
- if (violations) {
1459
- context.pending_violations = violations;
1460
- hasContext = true;
1461
- }
1462
- }
1463
- if (hasContext) {
1464
- // Session dedup operates on raw context fields (before signal conversion)
1465
- let dedupedContext = orderContextFields(context);
1466
- // S1.4: Apply session dedup — filter already-delivered context keys per entity
1467
- if (this.sessionDedup && ENRICHABLE_TOOLS.has(toolName)) {
1468
- const entityKey = args.key ?? args.name;
1469
- if (entityKey) {
1470
- const preFilterKeys = Object.keys(dedupedContext);
1471
- dedupedContext = orderContextFields(this.sessionDedup.filter(entityKey, dedupedContext));
1472
- // Layer 10: Record session dedup savings
1473
- if (this.tokenFlow) {
1474
- const postFilterKeys = new Set(Object.keys(dedupedContext));
1475
- const dedupedKeys = preFilterKeys.filter((k) => !postFilterKeys.has(k));
1476
- if (dedupedKeys.length > 0) {
1477
- const dedupedContent = dedupedKeys
1478
- .map((k) => JSON.stringify(context[k]))
1479
- .join("");
1480
- const dedupedTokens = Math.ceil(dedupedContent.length / 4);
1481
- this.tokenFlow.record({
1482
- session_id: this.tokenFlow.sessionId,
1483
- turn: this.sessionContext.getToolCallCount(),
1484
- mechanism: "session_dedup",
1485
- tool: toolName,
1486
- tokens_without: dedupedTokens,
1487
- tokens_with: 0,
1488
- tokens_saved: dedupedTokens,
1489
- detail: { keys_deduped: dedupedKeys.length },
1490
- });
1491
- }
1492
- }
1493
- // Cross-session context ledger — skip context already delivered in prior sessions
1494
- if (this.contextLedger) {
1495
- const deliveredKeys = [];
1496
- for (const key of Object.keys(dedupedContext)) {
1497
- if (this.contextLedger.hasDelivered(entityKey, key)) {
1498
- delete dedupedContext[key];
1499
- }
1500
- else {
1501
- deliveredKeys.push(key);
1502
- }
1503
- }
1504
- if (deliveredKeys.length > 0) {
1505
- this.contextLedger.markDelivered(entityKey, deliveredKeys);
1506
- }
1507
- }
1508
- }
1509
- }
1510
- // Three-Layer Experience: Detect decision level + convert to ranked signals
1511
- const { getDecisionPointDetector } = await import("./decision-point-detector.js");
1512
- const decisionLevel = getDecisionPointDetector().detect(toolName, args, this.sessionContext);
1513
- const signalOutput = await assembleContextOutput(dedupedContext, toolName, args, decisionLevel, this.sessionContext);
1514
- if (Object.keys(signalOutput).length > 0) {
1515
- result._context = signalOutput;
1516
- }
1517
- else if (hasContext) {
1518
- // BA-4.2: All context was already delivered — signal agent to skip restatement
1519
- result._meta.context_complete = true;
1520
- }
1521
- // Sprint 9.3: Track signal delivery stats
1522
- this.signalDeliveryStats.total_tool_calls++;
1523
- const deliveredSignals = signalOutput
1524
- .signals;
1525
- if (deliveredSignals && deliveredSignals.length > 0) {
1526
- this.signalDeliveryStats.tool_calls_with_signals++;
1527
- this.signalDeliveryStats.total_delivered += deliveredSignals.length;
1528
- for (const s of deliveredSignals) {
1529
- this.signalDeliveryStats.by_type[s.type] =
1530
- (this.signalDeliveryStats.by_type[s.type] ?? 0) + 1;
1531
- }
1532
- }
1533
- }
1534
- if (ENRICHABLE_TOOLS.has(toolName)) {
1535
- const entityKey = args.key ?? args.name;
1536
- if (entityKey) {
1537
- const now = Date.now();
1538
- const lastQuery = this.recentEntityQueries.get(entityKey);
1539
- const isRetry = lastQuery !== undefined && now - lastQuery < 60_000;
1540
- if (isRetry) {
1541
- this.sessionLegend.invalidateAll();
1542
- this.compressionMonitor?.recordLayer6Retry(toolName);
1543
- }
1544
- if (this.compressionMonitor) {
1545
- this.compressionMonitor.recordAgentAction(entityKey, isRetry, false);
1546
- }
1547
- this.recentEntityQueries.set(entityKey, now);
1548
- // RC-5: Evict oldest entries if map exceeds limit
1549
- if (this.recentEntityQueries.size > 500) {
1550
- const iter = this.recentEntityQueries.keys();
1551
- for (let i = 0; i < 100; i++) {
1552
- const k = iter.next().value;
1553
- if (k !== undefined)
1554
- this.recentEntityQueries.delete(k);
1555
- }
1556
- }
1557
- }
1558
- }
1559
- // S3.6-S3.8: Evaluate context rot every 10th tool call
1560
- if (this.contextRotDetector &&
1561
- this.sessionContext.getToolCallCount() % 10 === 0) {
1562
- const rotSignal = this.contextRotDetector.evaluate();
1563
- if (rotSignal.action === "inject_refresh") {
1564
- // S3.7: Add session warning with refresh context
1565
- const refreshData = this.contextRotDetector.getRefreshContext();
1566
- if (refreshData) {
1567
- if (!result._context)
1568
- result._context = {};
1569
- result._context.session_warning =
1570
- refreshData;
1571
- }
1572
- }
1573
- else if (rotSignal.action === "suggest_new_session") {
1574
- // S3.8: Propagate to _meta.session_health with new-session recommendation
1575
- result._meta.session_health = {
1576
- health: Math.round((1 - rotSignal.rotConfidence) * 100) / 100,
1577
- recommendation: "suggest_new_session",
1578
- signals: rotSignal.signals.map((s) => s.type),
1579
- };
1580
- }
1581
- }
1582
- // S2.9: Inject session health warning when health drops below 0.6
1583
- if (this.healthMonitor && !result._meta.session_health) {
1584
- const healthSignal = this.healthMonitor.getHealth();
1585
- if (healthSignal.health < 0.6) {
1586
- result._meta.session_health = {
1587
- health: Math.round(healthSignal.health * 100) / 100,
1588
- recommendation: healthSignal.recommendation,
1589
- signals: healthSignal.signals.map((s) => s.type),
1590
- };
1591
- }
1592
- }
1593
- // ── Leapfrog Sprint C: Token accounting on every response ──────
1594
- result._meta.tokens_budget = tokenBudget;
1595
- result._meta.tokens_used = tokensUsed;
1596
- result._meta.truncated = tokensUsed > tokenBudget;
1597
- if (tokensUsed > tokenBudget) {
1598
- result._meta.full_tokens_estimate = tokensUsed;
1599
- result._meta.truncation_level = "full";
1600
- }
1601
- return {
1602
- tokensSaved: enrichTokensSaved,
1603
- savingsMechanism: enrichSavingsMechanism,
1604
- };
1605
- }
1606
- /**
1607
- * Build session greeting based on health grade (Task 2.6).
1608
- * Max 200 tokens. Content varies by grade and mode.
1609
- */
1610
- buildSessionGreeting() {
1611
- if (this.currentMode === "parse") {
1612
- return "unerr is running in parse mode — basic code structure available. Link your repo with 'unerr' to unlock full graph intelligence.";
1613
- }
1614
- if (!this.healthGrade || !this.graphStats) {
1615
- return "unerr proxy ready. Graph intelligence active.";
1616
- }
1617
- const { entities, edges, rules } = this.graphStats;
1618
- const grade = this.healthGrade;
1619
- if (grade === "A" || grade === "A+") {
1620
- return `Your codebase scores ${grade} — ${entities} entities, ${edges} edges, ${rules} rules tracked. Architecture is healthy. unerr is watching for regressions.`;
1621
- }
1622
- if (grade === "B" || grade === "B+") {
1623
- return `Codebase health: ${grade}. Tracking ${entities} entities across ${edges} edges with ${rules} rules. Some areas could improve — I'll flag specific issues as you work.`;
1624
- }
1625
- if (grade.startsWith("C")) {
1626
- return `Heads up: codebase health is ${grade}. ${entities} entities tracked, ${rules} rules active. There are structural issues that affect maintainability — ask me about high-risk areas.`;
1627
- }
1628
- // D or F
1629
- return `Warning: codebase health is ${grade}. Significant structural issues detected across ${entities} entities. I'll actively flag risks as you work. Consider running 'unerr status' for details.`;
1630
- }
1631
- /** Layer 6 — token estimate for encoding tier / legend budgeting. */
1632
- estimateLayer6Tokens(content) {
1633
- if (typeof content === "string")
1634
- return estimateTokens(content);
1635
- try {
1636
- return estimateTokens(JSON.stringify(content));
1637
- }
1638
- catch {
1639
- return 2000;
1640
- }
1641
- }
1642
- /**
1643
- * S1: Compress large text tool output if it exceeds 2K tokens.
1644
- * Uses graph-aware compression with entity risk map from CozoDB.
1645
- * Feeds compression events into quality monitor for adaptive behavior.
1646
- */
1647
- async maybeCompressContent(toolName, result, meta) {
1648
- // Apply smart truncation to entity objects from get_function/get_class/get_file
1649
- if (typeof result === "object" &&
1650
- result !== null &&
1651
- "signature" in result &&
1652
- "body" in result &&
1653
- (toolName === "get_function" ||
1654
- toolName === "get_class" ||
1655
- toolName === "get_file")) {
1656
- const entity = result;
1657
- // Build metadata section from entity fields
1658
- const metadataLines = [
1659
- entity.name ? `name: ${entity.name}` : "",
1660
- entity.kind ? `kind: ${entity.kind}` : "",
1661
- entity.file_path
1662
- ? `file: ${entity.file_path}${entity.start_line ? `:${entity.start_line}` : ""}`
1663
- : "",
1664
- entity.fan_in !== undefined ? `fan_in: ${entity.fan_in}` : "",
1665
- entity.fan_out !== undefined ? `fan_out: ${entity.fan_out}` : "",
1666
- entity.risk_level ? `risk: ${entity.risk_level}` : "",
1667
- ]
1668
- .filter(Boolean)
1669
- .join("\n");
1670
- const fullContent = [metadataLines, entity.signature, entity.body]
1671
- .filter(Boolean)
1672
- .join("\n\n");
1673
- const fullTokens = estimateTokens(fullContent);
1674
- const budget = meta.token_budget_override
1675
- ? meta.token_budget_override
1676
- : 2000;
1677
- // Only truncate if content exceeds budget
1678
- if (fullTokens > budget) {
1679
- const truncated = smartTruncate({
1680
- metadata: metadataLines,
1681
- imports: "", // Entities don't have a separate imports section
1682
- signatures: entity.signature ?? "",
1683
- bodies: entity.body ?? "",
1684
- budget,
1685
- });
1686
- meta.truncated = truncated.truncated;
1687
- meta.truncation_level = truncated.truncation_level;
1688
- meta.tokens_used = truncated.tokens_used;
1689
- meta.tokens_budget = truncated.tokens_budget;
1690
- meta.full_tokens_estimate = truncated.full_tokens_estimate;
1691
- // Layer 10: Record smart truncation savings
1692
- if (truncated.truncated && this.tokenFlow) {
1693
- const truncSaved = truncated.full_tokens_estimate - truncated.tokens_used;
1694
- if (truncSaved > 0) {
1695
- this.tokenFlow.record({
1696
- session_id: this.tokenFlow.sessionId,
1697
- turn: this.sessionContext.getToolCallCount(),
1698
- mechanism: "smart_truncation",
1699
- tool: toolName,
1700
- tokens_without: truncated.full_tokens_estimate,
1701
- tokens_with: truncated.tokens_used,
1702
- tokens_saved: truncSaved,
1703
- detail: { level: truncated.truncation_level },
1704
- });
1705
- }
1706
- }
1707
- // Return the entity with truncated content inlined
1708
- return {
1709
- ...entity,
1710
- body: truncated.content,
1711
- signature: undefined, // Already included in truncated content
1712
- _truncation: {
1713
- level: truncated.truncation_level,
1714
- tokens_used: truncated.tokens_used,
1715
- full_tokens: truncated.full_tokens_estimate,
1716
- },
1717
- };
1718
- }
1719
- return result;
1720
- }
1721
- // Apply list truncation to array results from get_callers/get_callees/search_code
1722
- if (Array.isArray(result) && result.length > 0) {
1723
- const budget = 2000;
1724
- const truncatedList = truncateResultList(result, budget, (item) => JSON.stringify(item));
1725
- if (truncatedList.truncated) {
1726
- meta.truncated = true;
1727
- meta.tokens_used = truncatedList.tokens_used;
1728
- meta.tokens_budget = budget;
1729
- const fullTokensEst = estimateTokens(result.map((i) => JSON.stringify(i)).join("\n"));
1730
- meta.full_tokens_estimate = fullTokensEst;
1731
- // Layer 10: Record list truncation savings
1732
- if (this.tokenFlow) {
1733
- const listTruncSaved = fullTokensEst - truncatedList.tokens_used;
1734
- if (listTruncSaved > 0) {
1735
- this.tokenFlow.record({
1736
- session_id: this.tokenFlow.sessionId,
1737
- turn: this.sessionContext.getToolCallCount(),
1738
- mechanism: "smart_truncation",
1739
- tool: toolName,
1740
- tokens_without: fullTokensEst,
1741
- tokens_with: truncatedList.tokens_used,
1742
- tokens_saved: listTruncSaved,
1743
- detail: {
1744
- type: "list",
1745
- total: truncatedList.total,
1746
- returned: truncatedList.items.length,
1747
- },
1748
- });
1749
- }
1750
- }
1751
- return {
1752
- items: truncatedList.items,
1753
- total: truncatedList.total,
1754
- returned: truncatedList.items.length,
1755
- _truncation: {
1756
- truncated: true,
1757
- total: truncatedList.total,
1758
- returned: truncatedList.items.length,
1759
- },
1760
- };
1761
- }
1762
- return result;
1763
- }
1764
- // Only compress string content — structured objects pass through
1765
- if (typeof result !== "string" || isStructuredContent(result))
1766
- return result;
1767
- // file_read produces a pre-windowed slice (entity-aware slicing, budget-derived
1768
- // line limits, log-tail optimization, outline fallback). Section compression
1769
- // here would re-order content by score, duplicate the preserved head, and
1770
- // mislabel source lines as "error" sections. Skip it.
1771
- if (toolName === "file_read")
1772
- return result;
1773
- const tokenCount = estimateTokens(result);
1774
- if (tokenCount <= 2000)
1775
- return result;
1776
- // Determine content type from tool name
1777
- const contentType = this.inferContentType(toolName);
1778
- // Get adaptive token budget from quality monitor (or default 2000)
1779
- const retention = this.compressionMonitor?.getRetention(contentType) ?? 0.5;
1780
- const tokenBudget = Math.max(800, Math.floor(tokenCount * retention));
1781
- // Build entity risk map from graph for intelligent prioritization
1782
- const entityRiskMap = await this.buildEntityRiskMap(result);
1783
- const compressed = compressOutput(result, {
1784
- tokenBudget,
1785
- entityRiskMap,
1786
- });
1787
- // Feed compression event into quality monitor
1788
- if (this.compressionMonitor) {
1789
- const compressionId = `${toolName}-${Date.now()}`;
1790
- const ratio = compressed.compressedTokens / compressed.originalTokens;
1791
- this.compressionMonitor.recordCompression(compressionId, contentType, ratio);
1792
- }
1793
- // Apply budget enforcer as final cap (4K token ceiling)
1794
- const enforced = enforceBudget(compressed.output, 4000);
1795
- if (enforced.truncated) {
1796
- meta.truncated = true;
1797
- meta.full_tokens_estimate = compressed.originalTokens;
1798
- }
1799
- return enforced.content;
1800
- }
1801
- /**
1802
- * S1: Infer content type from tool name for compression quality tracking.
1803
- */
1804
- inferContentType(toolName) {
1805
- switch (toolName) {
1806
- case "get_file":
1807
- return "file_content";
1808
- case "search_code":
1809
- return "generic";
1810
- default:
1811
- return "generic";
1812
- }
1813
- }
1814
- /**
1815
- * S2: Estimate result size (number of items/entities) for exploration cost calculation.
1816
- */
1817
- estimateResultSize(content) {
1818
- if (content === null || content === undefined)
1819
- return 0;
1820
- if (Array.isArray(content))
1821
- return content.length;
1822
- if (typeof content === "string")
1823
- return Math.max(1, Math.ceil(content.length / 200));
1824
- if (typeof content === "object") {
1825
- // Entity objects, blast radius results, etc.
1826
- const obj = content;
1827
- if (Array.isArray(obj.entities))
1828
- return obj.entities.length;
1829
- if (Array.isArray(obj.callers))
1830
- return obj.callers.length;
1831
- if (Array.isArray(obj.callees))
1832
- return obj.callees.length;
1833
- if (typeof obj.direct_callers === "number")
1834
- return obj.direct_callers;
1835
- return 1;
1836
- }
1837
- return 1;
1838
- }
1839
- /**
1840
- * S1: Build entity risk map from CozoDB for graph-aware compression.
1841
- * Extracts file paths mentioned in text and queries their risk levels.
1842
- */
1843
- async buildEntityRiskMap(text) {
1844
- const riskMap = new Map();
1845
- try {
1846
- // Extract file paths from text (diff headers, error lines, etc.)
1847
- const filePathPattern = /(?:^|\s)([\w/.]+\.[a-z]{1,4})(?:\s|:|$)/gm;
1848
- const seen = new Set();
1849
- let match;
1850
- match = filePathPattern.exec(text);
1851
- while (match !== null) {
1852
- const filePath = match[1];
1853
- if (seen.has(filePath)) {
1854
- match = filePathPattern.exec(text);
1855
- continue;
1856
- }
1857
- seen.add(filePath);
1858
- if (seen.size > 20)
1859
- break; // Cap to avoid expensive queries
1860
- const entities = await this.localGraph.getEntitiesByFile(filePath);
1861
- for (const entity of entities) {
1862
- if (riskMap.has(entity.key))
1863
- continue;
1864
- const br = await this.localGraph.getBlastRadius(entity.key);
1865
- riskMap.set(entity.key, {
1866
- riskLevel: br.is_chokepoint
1867
- ? "high"
1868
- : br.direct_callers > 5
1869
- ? "medium"
1870
- : "normal",
1871
- fanIn: br.direct_callers,
1872
- isChokepoint: br.is_chokepoint,
1873
- });
1874
- }
1875
- match = filePathPattern.exec(text);
1876
- }
1877
- }
1878
- catch (err) {
1879
- process.stderr.write(`[unerr] ⚠ Risk map construction failed: ${formatUnknownError(err)}\n`);
1880
- }
1881
- return riskMap;
1882
- }
1883
- async executeLocal(toolName, args) {
1884
- switch (toolName) {
1885
- case "get_file": {
1886
- // Per tool description: "Get all entities in a file." Returns the
1887
- // entity list — NOT a single fuzzy-matched entity (which is what the
1888
- // shared get_entity/get_function/get_class path used to do, with
1889
- // wrong results when "file:<path>" entities weren't indexed and the
1890
- // fallback fuzzy resolver landed on similarly-named entities).
1891
- const filePath = args.key ?? args.name;
1892
- if (!filePath) {
1893
- throw new Error("get_file requires a file path in `key`");
1894
- }
1895
- const entities = await this.localGraph.getEntitiesByFile(filePath);
1896
- return {
1897
- file_path: filePath,
1898
- entities,
1899
- total: entities.length,
1900
- };
1901
- }
1902
- case "get_entity": // consolidated: replaces get_function + get_class
1903
- case "get_function": // alias (backward compat)
1904
- case "get_class": {
1905
- const rawArg = args.key ?? args.name;
1906
- // Aliases imply a kind even when the caller didn't pass one
1907
- const aliasKind = toolName === "get_function"
1908
- ? "function"
1909
- : toolName === "get_class"
1910
- ? "class"
1911
- : undefined;
1912
- const kindHint = args.kind ?? aliasKind;
1913
- const key = await this.resolveKeyArg(rawArg, kindHint);
1914
- const entity = await this.resolveEntityWithOverlay(key);
1915
- // Resolve actual body from source file — CozoDB stores body_hash, not body text
1916
- if (entity?.file_path && entity.start_line > 0) {
1917
- try {
1918
- const { readFileSync } = await import("node:fs");
1919
- const { resolve } = await import("node:path");
1920
- const cwd = this.projectRoot ?? process.cwd();
1921
- const abs = resolve(cwd, entity.file_path);
1922
- const lines = readFileSync(abs, "utf-8").split("\n");
1923
- const start = entity.start_line - 1; // 0-based
1924
- const end = entity.end_line ?? lines.length;
1925
- const bodyLines = lines.slice(start, end);
1926
- const CHARS_PER_TOKEN = 4;
1927
- const tokenBudget = typeof args.token_budget === "number" && args.token_budget >= 100
1928
- ? args.token_budget
1929
- : 400;
1930
- // Body inclusion is opt-in. Heuristic: explicit include_body, or
1931
- // budget >= 1500 (caller clearly asked for full body), or aliases
1932
- // that historically returned full bodies. Default => preview only.
1933
- const includeBody = args.include_body === true ||
1934
- tokenBudget >= 1500 ||
1935
- toolName === "get_function" ||
1936
- toolName === "get_class";
1937
- const fullBody = bodyLines.join("\n");
1938
- if (!includeBody) {
1939
- // Structural preview: first ~15 lines as a signature/intro snippet
1940
- const PREVIEW_LINES = 15;
1941
- const previewLines = bodyLines.slice(0, PREVIEW_LINES);
1942
- entity.body_preview =
1943
- previewLines.join("\n");
1944
- if (bodyLines.length > PREVIEW_LINES) {
1945
- entity._preview = {
1946
- shown_lines: PREVIEW_LINES,
1947
- total_lines: bodyLines.length,
1948
- _hint: `Structural preview only — showing first ${PREVIEW_LINES} of ${bodyLines.length} lines. Pass include_body:true (or token_budget:${Math.ceil(fullBody.length / CHARS_PER_TOKEN) + 100}) to get the full body.`,
1949
- };
1950
- }
1951
- }
1952
- else if (fullBody.length > tokenBudget * CHARS_PER_TOKEN) {
1953
- const maxChars = tokenBudget * CHARS_PER_TOKEN;
1954
- const truncatedLines = [];
1955
- let charCount = 0;
1956
- for (const line of bodyLines) {
1957
- if (charCount + line.length + 1 > maxChars)
1958
- break;
1959
- truncatedLines.push(line);
1960
- charCount += line.length + 1;
1961
- }
1962
- entity.body = truncatedLines.join("\n");
1963
- entity._truncated = {
1964
- shown_lines: truncatedLines.length,
1965
- total_lines: bodyLines.length,
1966
- omitted_lines: `${entity.start_line + truncatedLines.length}-${entity.start_line + bodyLines.length - 1}`,
1967
- _hint: `Body truncated: showing ${truncatedLines.length} of ${bodyLines.length} lines (~${tokenBudget} tokens). To see the full entity, pass token_budget: ${Math.ceil(fullBody.length / CHARS_PER_TOKEN) + 100}. Or use file_read with offset: ${entity.start_line + truncatedLines.length}, limit: ${bodyLines.length - truncatedLines.length} to read the remaining lines.`,
1968
- };
1969
- }
1970
- else {
1971
- entity.body = fullBody;
1972
- }
1973
- }
1974
- catch {
1975
- // File may not exist on disk — keep whatever body the DB had
1976
- }
1977
- }
1978
- return entity;
1979
- }
1980
- case "get_references": {
1981
- // consolidated: replaces get_callers + get_callees
1982
- const key = await this.resolveKeyArg(args.key);
1983
- const direction = args.direction ?? "callers";
1984
- const limit = typeof args.limit === "number" && args.limit > 0 ? args.limit : 25;
1985
- const raw = direction === "callees"
1986
- ? await this.localGraph.getCalleesOf(key)
1987
- : await this.localGraph.getCallersOf(key);
1988
- const totalCount = raw.length;
1989
- const capped = raw.slice(0, limit);
1990
- // Strip body from references to reduce token flood — callers/callees
1991
- // only need signature, location, and metadata for navigation
1992
- const results = capped.map(({ body: _body, ...rest }) => rest);
1993
- return {
1994
- references: results,
1995
- direction,
1996
- total: totalCount,
1997
- returned: results.length,
1998
- truncated: totalCount > limit,
1999
- ...(totalCount > limit
2000
- ? {
2001
- _hint: `Showing ${limit} of ${totalCount}. Pass limit: ${totalCount} to see all.`,
2002
- }
2003
- : {}),
2004
- };
2005
- }
2006
- case "get_callers": {
2007
- // alias (backward compat)
2008
- const key = await this.resolveKeyArg(args.key);
2009
- const rawCallers = await this.localGraph.getCallersOf(key);
2010
- return rawCallers.slice(0, 25).map(({ body: _body, ...rest }) => rest);
2011
- }
2012
- case "get_callees": {
2013
- // alias (backward compat)
2014
- const key = await this.resolveKeyArg(args.key);
2015
- const rawCallees = await this.localGraph.getCalleesOf(key);
2016
- return rawCallees.slice(0, 25).map(({ body: _body, ...rest }) => rest);
2017
- }
2018
- case "get_imports": {
2019
- const filePath = args.file_path;
2020
- const rows = await this.localGraph.getImports(filePath);
2021
- // Graph stores file→file edges only — symbol names live in the source.
2022
- // Read the file on demand and pair each resolved path with the symbols
2023
- // imported from it. Failures degrade gracefully to path-only rows.
2024
- const { loadImportSymbols } = await import("./import-symbols.js");
2025
- const symbolMap = await loadImportSymbols(this.projectRoot ?? process.cwd(), filePath);
2026
- const lookup = new Map();
2027
- const stripExt = (p) => p
2028
- .split("/")
2029
- .pop()
2030
- ?.replace(/\.(ts|tsx|js|jsx|mjs|cjs)$/, "") ?? "";
2031
- for (const [spec, syms] of symbolMap.entries()) {
2032
- const base = stripExt(spec);
2033
- if (!base)
2034
- continue;
2035
- const existing = lookup.get(base);
2036
- if (existing)
2037
- existing.push(...syms);
2038
- else
2039
- lookup.set(base, syms.slice());
2040
- }
2041
- return rows.map((r) => {
2042
- const base = stripExt(r.imported_file);
2043
- const symbols = lookup.get(base) ?? [];
2044
- return { imported_file: r.imported_file, symbols };
2045
- });
2046
- }
2047
- case "search_code": {
2048
- const query = args.query;
2049
- const limit = args.limit ?? 20;
2050
- return await this.localGraph.searchEntities(query, limit);
2051
- }
2052
- // Disabled: get_rules + check_rules — no rules detected/stored yet, always returns empty.
2053
- // case "get_rules": { ... }
2054
- // case "check_rules": { ... }
2055
- // Disabled: get_business_context — not properly wired, produces no useful data.
2056
- // case "get_business_context": { ... }
2057
- case "get_conventions": {
2058
- const raw = await this.localGraph.getConventions();
2059
- // Hoist each kind to a top-level array so the format-encoder can emit
2060
- // `_fmt:multi` (per the documented response contract). A nested
2061
- // `conventions: { naming: [...], ... }` shape forces JSON fallback
2062
- // because the encoder only inspects arrays at the top level.
2063
- const naming = [];
2064
- const import_direction = [];
2065
- const structure = [];
2066
- const other = [];
2067
- for (const c of raw) {
2068
- if (c.kind === "naming")
2069
- naming.push(c);
2070
- else if (c.kind === "import_direction")
2071
- import_direction.push(c);
2072
- else if (c.kind === "structure")
2073
- structure.push(c);
2074
- else
2075
- other.push(c);
2076
- }
2077
- return {
2078
- naming,
2079
- import_direction,
2080
- structure,
2081
- ...(other.length > 0 ? { other } : {}),
2082
- guidance: raw
2083
- .filter((c) => c.confidence >= 0.7)
2084
- .map((c) => `${c.name}: ${Math.round(c.adherence_rate * 100)}% adherence — follow for new ${c.kind}s`)
2085
- .slice(0, 5),
2086
- summary: `${raw.length} conventions. ${raw.filter((c) => c.adherence_rate >= 0.8).length} strongly adhered (>80%).`,
2087
- };
2088
- }
2089
- case "get_cross_boundary_links": {
2090
- const communityId = args.community_id;
2091
- const topN = args.top_n ?? 20;
2092
- const fromPath = args.from_path;
2093
- const toPath = args.to_path;
2094
- // If a path filter is given, fetch a wider set then post-filter so the
2095
- // requested `top_n` is still met after filtering. Without this, the
2096
- // community-pair ranking can starve specific directory queries.
2097
- const fetchN = fromPath || toPath ? Math.max(topN * 10, 200) : topN;
2098
- const rows = await this.localGraph.getCrossBoundaryLinks(communityId, fetchN);
2099
- if (!fromPath && !toPath)
2100
- return rows;
2101
- const norm = (p) => (p ? p.replace(/\/+$/, "") : "");
2102
- const f = norm(fromPath);
2103
- const t = norm(toPath);
2104
- const matches = (file, prefix) => !prefix ||
2105
- file === prefix ||
2106
- file.startsWith(prefix.endsWith("/") ? prefix : `${prefix}/`);
2107
- // Either direction may satisfy the pair: an edge connects A↔B.
2108
- const filtered = rows.filter((r) => (matches(r.from_file, f) && matches(r.to_file, t)) ||
2109
- (matches(r.from_file, t) && matches(r.to_file, f)));
2110
- return filtered.slice(0, topN);
2111
- }
2112
- case "get_critical_nodes": {
2113
- const topN = args.top_n ?? 10;
2114
- const communityId = args.community_id;
2115
- return await this.localGraph.getCriticalNodes(topN, communityId);
2116
- }
2117
- // case "unerr_revert_entity" disabled — shadow ledger tool, not active
2118
- case "get_project_stats": {
2119
- const stats = await this.localGraph.getLocalProjectStats();
2120
- return {
2121
- ...stats,
2122
- healthGrade: this.healthGrade ?? "unknown",
2123
- };
2124
- }
2125
- case "file_connections": {
2126
- const filePath = args.file_path;
2127
- if (!filePath)
2128
- throw new Error("file_connections requires file_path");
2129
- const neighbors = await this.localGraph.getFileNeighbors(filePath);
2130
- const entities = await this.localGraph.getFileEntities(filePath);
2131
- return { file: filePath, connections: neighbors, entities };
2132
- }
2133
- case "get_test_coverage": {
2134
- const rawKey = args.key;
2135
- if (!rawKey)
2136
- throw new Error("get_test_coverage requires key");
2137
- const key = await this.resolveKeyArg(rawKey);
2138
- const includeTransitive = args.include_transitive ?? true;
2139
- const coverage = await this.localGraph.getTestCoverage(key, includeTransitive);
2140
- return {
2141
- entity: key,
2142
- test_count: coverage.length,
2143
- tests: coverage,
2144
- summary: coverage.length > 0
2145
- ? `${coverage.length} test${coverage.length !== 1 ? "s" : ""} cover this entity`
2146
- : "No test coverage found",
2147
- };
2148
- }
2149
- // case "semantic_search" and "find_similar" disabled — embedding store never wired
2150
- case "file_outline": {
2151
- const { buildFileOutline } = await import("../tools/coding/file-outline.js");
2152
- const { appendFileReadLog } = await import("../proxy/shell-compression-log.js");
2153
- const fp = args.file_path;
2154
- if (!fp)
2155
- throw new Error("file_outline requires file_path");
2156
- const cwd = this.projectRoot ?? process.cwd();
2157
- const outline = await buildFileOutline({
2158
- cwd,
2159
- filePathArg: fp,
2160
- graph: this.localGraph,
2161
- });
2162
- const savedPct = outline.total_lines > 0
2163
- ? Math.round(((outline.total_lines - outline.entities.length) /
2164
- outline.total_lines) *
2165
- 100)
2166
- : 0;
2167
- appendFileReadLog(cwd, {
2168
- ts: new Date().toISOString(),
2169
- file: outline.file_path,
2170
- mode: "outline",
2171
- totalLines: outline.total_lines,
2172
- returnedLines: outline.entities.length,
2173
- savedPct,
2174
- tokenEstimate: outline.token_estimate,
2175
- });
2176
- return outline;
2177
- }
2178
- case "file_read": {
2179
- const { runFileReadForRouter } = await import("../tools/coding/file-read-protocol.js");
2180
- return runFileReadForRouter(args, {
2181
- cwd: this.projectRoot ?? process.cwd(),
2182
- graph: this.localGraph,
2183
- });
2184
- }
2185
- default:
2186
- throw new Error(`Unknown local tool: ${toolName}`);
2187
- }
2188
- }
2189
- /**
2190
- * Get entity keys for a file path (from file_index).
2191
- * Used by fact injection to call recallForFile with entity-scoped facts.
2192
- */
2193
- async getEntityKeysForFile(filePath) {
2194
- try {
2195
- const results = await this.localGraph.getEntitiesByFile(filePath);
2196
- return results.map((e) => e.key);
2197
- }
2198
- catch {
2199
- return [];
2200
- }
2201
- }
2202
- /**
2203
- * Resolve a key argument: if it looks like a hex hash (entity key), use as-is.
2204
- * Otherwise, search by name and return the best match's key.
2205
- * Falls back to the raw string if no search results (let the graph return its own error).
2206
- */
2207
- async resolveKeyArg(raw, kind) {
2208
- if (!raw)
2209
- return raw;
2210
- // 16-char hex = already a valid entity key
2211
- if (/^[0-9a-f]{16}$/.test(raw))
2212
- return raw;
2213
- // Prefer exact-name match over fuzzy search — fuzzy search ranks by IDF
2214
- // and can score a method ("Class.method") higher than its bare class name
2215
- // when both match the query tokens. An exact name match should always win.
2216
- try {
2217
- const db = this.localGraph.db;
2218
- // Rank exact-name matches: kind-preferred (if specified) > class > function/method > everything else
2219
- // The CASE-WHEN expression maps each kind to a sort weight; lower is better.
2220
- const exact = await db.run(`?[k, kind, rank] := *entities{key: k, kind, name: $n},
2221
- rank = if(kind == "class", 0, if(kind == "function", 1, if(kind == "method", 2, if(kind == "type", 3, if(kind == "interface", 4, if(kind == "variable", 5, 6))))))
2222
- :order rank
2223
- :limit 8`, { n: raw });
2224
- const exactRows = (exact.rows ?? []);
2225
- if (exactRows.length > 0) {
2226
- // If a kind filter is provided, prefer that kind among exact matches
2227
- if (kind) {
2228
- const matchKind = exactRows.find((r) => r[1] === kind);
2229
- if (matchKind)
2230
- return matchKind[0];
2231
- }
2232
- return exactRows[0][0];
2233
- }
2234
- }
2235
- catch {
2236
- // fall through to fuzzy search
2237
- }
2238
- // Fuzzy fallback — use top result's key, or return raw if nothing matches
2239
- try {
2240
- const results = await this.localGraph.searchEntities(raw, 8);
2241
- if (results.length === 0)
2242
- return raw;
2243
- if (kind) {
2244
- const matchKind = results.find((r) => r.kind === kind);
2245
- if (matchKind)
2246
- return matchKind.key;
2247
- }
2248
- return results[0].key;
2249
- }
2250
- catch {
2251
- return raw;
2252
- }
2253
- }
2254
- /**
2255
- * Resolve entity with drift overlay merge.
2256
- * If entity exists in drift_overlay, overlay data replaces/augments base entity.
2257
- */
2258
- async resolveEntityWithOverlay(key) {
2259
- // Check drift overlay first
2260
- const _driftEntities = await this.localGraph.getDriftEntitiesForFile("");
2261
- // Need to check by key across all files - query drift_overlay directly
2262
- let driftEntity = null;
2263
- try {
2264
- const result = await this.localGraph.db.run(`?[key, name, kind, sig, body, fp, ls, le, ch, ds, iid, ma, origin, pb, ps] :=
2265
- *drift_overlay[key, name, kind, sig, body, fp, ls, le, ch, ds, iid, ma, origin, pb, ps],
2266
- key = $key`, { key });
2267
- if (result.rows.length > 0) {
2268
- const [k, name, kind, signature, body, file_path, line_start, line_end, content_hash, drift_status, intent_id, modified_at, origin, previous_body, previous_signature,] = result.rows[0];
2269
- driftEntity = {
2270
- key: k,
2271
- name,
2272
- kind,
2273
- signature,
2274
- body,
2275
- file_path,
2276
- line_start,
2277
- line_end,
2278
- content_hash,
2279
- drift_status: drift_status,
2280
- intent_id,
2281
- modified_at,
2282
- origin: origin,
2283
- previous_body,
2284
- previous_signature,
2285
- };
2286
- }
2287
- }
2288
- catch (err) {
2289
- process.stderr.write(`[unerr] ⚠ Drift overlay query failed: ${formatUnknownError(err)}\n`);
2290
- }
2291
- // Get base entity
2292
- const baseEntity = await this.localGraph.getEntity(key);
2293
- if (driftEntity) {
2294
- if (driftEntity.drift_status === "deleted") {
2295
- // Entity was deleted locally — return null
2296
- return null;
2297
- }
2298
- if (driftEntity.drift_status === "added") {
2299
- // Entity only exists locally — construct from overlay
2300
- return {
2301
- key: driftEntity.key,
2302
- kind: driftEntity.kind,
2303
- name: driftEntity.name,
2304
- file_path: driftEntity.file_path,
2305
- start_line: driftEntity.line_start,
2306
- signature: driftEntity.signature,
2307
- body: driftEntity.body,
2308
- fan_in: 0,
2309
- fan_out: 0,
2310
- risk_level: "normal",
2311
- _drift: driftEntity,
2312
- };
2313
- }
2314
- if (driftEntity.drift_status === "modified" && baseEntity) {
2315
- // Overlay body replaces base body
2316
- return {
2317
- ...baseEntity,
2318
- body: driftEntity.body || baseEntity.body,
2319
- signature: driftEntity.signature || baseEntity.signature,
2320
- start_line: driftEntity.line_start || baseEntity.start_line,
2321
- _drift: driftEntity,
2322
- };
2323
- }
2324
- }
2325
- return baseEntity;
2326
- }
2327
- /**
2328
- * Extract drift metadata for injection into _meta.
2329
- */
2330
- async extractDriftMeta(toolName, args, result) {
2331
- // Only inject drift for entity-returning tools
2332
- if (!ENTITY_TOOLS.has(toolName))
2333
- return null;
2334
- const branch = this.branchContext?.currentBranch ?? "unknown";
2335
- const commitsAhead = this.branchContext?.commitsAhead ?? 0;
2336
- // Check if result has drift info attached
2337
- if (result && typeof result === "object" && "_drift" in result) {
2338
- const drift = result._drift;
2339
- return {
2340
- entityStatus: drift.drift_status,
2341
- branch,
2342
- commitsAhead,
2343
- lastModifiedBy: drift.intent_id || null,
2344
- };
2345
- }
2346
- // Check if any entity in the result file has drift
2347
- const key = args.key;
2348
- if (key) {
2349
- try {
2350
- const driftResult = await this.localGraph.db.run("?[ds, iid] := *drift_overlay[$key, _, _, _, _, _, _, _, _, ds, iid, _, _, _, _]", { key });
2351
- if (driftResult.rows.length > 0) {
2352
- const [ds, iid] = driftResult.rows[0];
2353
- return {
2354
- entityStatus: ds,
2355
- branch,
2356
- commitsAhead,
2357
- lastModifiedBy: iid || null,
2358
- deletedLocally: ds === "deleted",
2359
- };
2360
- }
2361
- }
2362
- catch (err) {
2363
- process.stderr.write(`[unerr] ⚠ Drift data query failed: ${formatUnknownError(err)}\n`);
2364
- }
2365
- }
2366
- // No drift — only inject branch context if there is drift in the repo at all
2367
- const summary = await this.localGraph.getDriftSummary();
2368
- if (summary.total > 0) {
2369
- return {
2370
- entityStatus: null,
2371
- branch,
2372
- commitsAhead,
2373
- lastModifiedBy: null,
2374
- };
2375
- }
2376
- return null;
2377
- }
2378
- }
2379
- /** Entity-returning tool names where risk injection is relevant. */
2380
- const ENTITY_TOOLS = new Set([
2381
- "get_entity",
2382
- "get_function",
2383
- "get_class",
2384
- "get_file",
2385
- "get_callers",
2386
- "get_callees",
2387
- "get_references",
2388
- ]);
2389
- /**
2390
- * Fields that are noise for coding agents — removed before wire encoding.
2391
- * `body` is always empty in caller/callee/search results (never populated).
2392
- * `community` is an internal graph clustering ID with no meaning to agents.
2393
- */
2394
- /** Fields stripped from ALL entity results (arrays and single). */
2395
- const ENTITY_NOISE_FIELDS_ALL = new Set(["community"]);
2396
- /** Fields stripped only from array results (body is empty in list rows but useful in single-entity). */
2397
- const ENTITY_NOISE_FIELDS_ARRAY = new Set(["body", "community"]);
2398
- /** Tools that return entity arrays (or arrays wrapped in a metadata object)
2399
- * where noise fields and sentinel placeholders should be stripped before
2400
- * format encoding. */
2401
- const ENTITY_ARRAY_TOOLS = new Set([
2402
- "get_callers",
2403
- "get_callees",
2404
- "get_references",
2405
- "search_code",
2406
- "file_connections",
2407
- "file_outline",
2408
- ]);
2409
- /** Tools that return single entity objects where noise fields should be stripped. */
2410
- const SINGLE_ENTITY_TOOLS = new Set([
2411
- "get_entity",
2412
- "get_function",
2413
- "get_class",
2414
- "get_file",
2415
- ]);
2416
- /**
2417
- * Strip fields that are meaningless to coding agents (body, community).
2418
- * Also normalizes legacy "normal" risk_level → "low" and drops sentinel
2419
- * placeholders (end_line:0, community:-1) so consumers don't have to special-case
2420
- * them. Rounds excessive float precision in conventions.
2421
- */
2422
- function stripEntityRow(row, noiseSet) {
2423
- const cleaned = {};
2424
- for (const [k, v] of Object.entries(row)) {
2425
- if (noiseSet.has(k))
2426
- continue;
2427
- // Drop sentinel placeholders rather than emit them on the wire.
2428
- if (k === "end_line" && v === 0)
2429
- continue;
2430
- if (k === "community" && v === -1)
2431
- continue;
2432
- if (k === "risk_level" && typeof v === "string") {
2433
- // Normalize legacy "normal" → "low" so downstream consumers see one enum.
2434
- cleaned[k] = v === "normal" ? "low" : v;
2435
- continue;
2436
- }
2437
- cleaned[k] = v;
2438
- }
2439
- return cleaned;
2440
- }
2441
- function stripNoiseFields(toolName, content) {
2442
- // Entity arrays (top-level): strip body + community + sentinels.
2443
- if (ENTITY_ARRAY_TOOLS.has(toolName) && Array.isArray(content)) {
2444
- return content.map((item) => item && typeof item === "object" && !Array.isArray(item)
2445
- ? stripEntityRow(item, ENTITY_NOISE_FIELDS_ARRAY)
2446
- : item);
2447
- }
2448
- // Wrapped array shape: {references|connections|entities: [...], ...}
2449
- // get_references / file_connections / file_outline return this shape, so
2450
- // their inner rows also need sentinel stripping + risk normalization.
2451
- if (ENTITY_ARRAY_TOOLS.has(toolName) &&
2452
- content &&
2453
- typeof content === "object" &&
2454
- !Array.isArray(content)) {
2455
- const obj = content;
2456
- const result = { ...obj };
2457
- for (const k of ["references", "connections", "entities"]) {
2458
- const v = obj[k];
2459
- if (Array.isArray(v)) {
2460
- result[k] = v.map((item) => item && typeof item === "object" && !Array.isArray(item)
2461
- ? stripEntityRow(item, ENTITY_NOISE_FIELDS_ARRAY)
2462
- : item);
2463
- }
2464
- }
2465
- return result;
2466
- }
2467
- // Single entity: strip only community + sentinels (body contains actual code).
2468
- if (SINGLE_ENTITY_TOOLS.has(toolName) &&
2469
- content &&
2470
- typeof content === "object" &&
2471
- !Array.isArray(content)) {
2472
- return stripEntityRow(content, ENTITY_NOISE_FIELDS_ALL);
2473
- }
2474
- // Conventions: round excessive float precision
2475
- if (toolName === "get_conventions" && Array.isArray(content)) {
2476
- return content.map((item) => {
2477
- if (item && typeof item === "object" && !Array.isArray(item)) {
2478
- const obj = item;
2479
- const cleaned = {};
2480
- for (const [k, v] of Object.entries(obj)) {
2481
- if (typeof v === "number" && !Number.isInteger(v)) {
2482
- cleaned[k] = Math.round(v * 1000) / 1000;
2483
- }
2484
- else {
2485
- cleaned[k] = v;
2486
- }
2487
- }
2488
- return cleaned;
2489
- }
2490
- return item;
2491
- });
2492
- }
2493
- return content;
2494
- }
2495
- /**
2496
- * Scan an array of entity-shaped records for the highest-risk member.
2497
- * Returns the max-risk entity's risk metadata along with its `entity_key`
2498
- * (for fine-grained `ur|rsk` dedup), or undefined if nothing exceeds normal.
2499
- */
2500
- function extractMaxRiskFromArray(items) {
2501
- let highestRisk;
2502
- for (const item of items) {
2503
- if (!item || typeof item !== "object" || !("risk_level" in item))
2504
- continue;
2505
- const entity = item;
2506
- if (entity.risk_level === "high") {
2507
- return {
2508
- fan_in: entity.fan_in ?? 0,
2509
- fan_out: entity.fan_out ?? 0,
2510
- risk_level: "high",
2511
- ...(entity.key ? { entity_key: entity.key } : {}),
2512
- };
2513
- }
2514
- if (entity.risk_level === "medium" && !highestRisk) {
2515
- highestRisk = {
2516
- fan_in: entity.fan_in ?? 0,
2517
- fan_out: entity.fan_out ?? 0,
2518
- risk_level: "medium",
2519
- ...(entity.key ? { entity_key: entity.key } : {}),
2520
- };
2521
- }
2522
- }
2523
- return highestRisk;
2524
- }
2525
- /**
2526
- * Extract entity risk metadata from a local tool result.
2527
- * Returns risk info for single entities, or the highest-risk entity for arrays.
2528
- */
2529
- function extractEntityRisk(toolName, result) {
2530
- if (!ENTITY_TOOLS.has(toolName))
2531
- return undefined;
2532
- // Single entity (get_function, get_class, get_file)
2533
- if (result && typeof result === "object" && "fan_in" in result) {
2534
- const entity = result;
2535
- if (entity.risk_level && entity.risk_level !== "normal") {
2536
- return {
2537
- fan_in: entity.fan_in ?? 0,
2538
- fan_out: entity.fan_out ?? 0,
2539
- risk_level: entity.risk_level,
2540
- };
2541
- }
2542
- }
2543
- // Envelope shape: get_references returns { references: [...], direction, ... }
2544
- if (result &&
2545
- typeof result === "object" &&
2546
- !Array.isArray(result) &&
2547
- "references" in result &&
2548
- Array.isArray(result.references)) {
2549
- return extractMaxRiskFromArray(result.references);
2550
- }
2551
- // Entity array (get_callers, get_callees) — surface the highest-risk entity
2552
- if (Array.isArray(result) && result.length > 0) {
2553
- return extractMaxRiskFromArray(result);
2554
- }
2555
- return undefined;
2556
- }