@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.
- package/README.md +36 -45
- package/dist/cli.js +37443 -36022
- package/package.json +2 -1
- package/dist/behaviors/agent-llm-bridge.js +0 -166
- package/dist/behaviors/architecture-guard.js +0 -256
- package/dist/behaviors/auto-doc.js +0 -247
- package/dist/behaviors/cascade-guard.js +0 -289
- package/dist/behaviors/change-narrative.js +0 -270
- package/dist/behaviors/convention-drift.js +0 -290
- package/dist/behaviors/framework.js +0 -235
- package/dist/behaviors/guard-formatter.js +0 -44
- package/dist/behaviors/incomplete-work.js +0 -270
- package/dist/behaviors/loop-breaker.js +0 -300
- package/dist/behaviors/session-continuity.js +0 -208
- package/dist/commands/branches.js +0 -97
- package/dist/commands/check-commit.js +0 -225
- package/dist/commands/compress-output.js +0 -64
- package/dist/commands/config-verify.js +0 -243
- package/dist/commands/daemon.js +0 -905
- package/dist/commands/dashboard.js +0 -52
- package/dist/commands/debug.js +0 -200
- package/dist/commands/enrich.js +0 -184
- package/dist/commands/exec.js +0 -233
- package/dist/commands/gain.js +0 -156
- package/dist/commands/hook.js +0 -88
- package/dist/commands/index.js +0 -88
- package/dist/commands/init.js +0 -74
- package/dist/commands/install.js +0 -505
- package/dist/commands/learn.js +0 -116
- package/dist/commands/manifest.js +0 -193
- package/dist/commands/rewind.js +0 -103
- package/dist/commands/serve.js +0 -19
- package/dist/commands/setup-wizard.js +0 -414
- package/dist/commands/skills.js +0 -64
- package/dist/commands/stats.js +0 -20
- package/dist/commands/status.js +0 -654
- package/dist/commands/timeline.js +0 -139
- package/dist/commands/uninstall.js +0 -230
- package/dist/components/App.js +0 -109
- package/dist/components/Banner.js +0 -12
- package/dist/components/ConfirmPrompt.js +0 -25
- package/dist/components/DriftSummary.js +0 -23
- package/dist/components/GradeBadge.js +0 -15
- package/dist/components/HealthCard.js +0 -18
- package/dist/components/InkSpinner.js +0 -22
- package/dist/components/InputBox.js +0 -17
- package/dist/components/KeyValue.js +0 -13
- package/dist/components/MessageList.js +0 -14
- package/dist/components/ProgressBar.js +0 -26
- package/dist/components/Section.js +0 -16
- package/dist/components/SessionSummaryCard.js +0 -73
- package/dist/components/StartupDisplay.js +0 -24
- package/dist/components/StatusDashboard.js +0 -57
- package/dist/components/StatusLine.js +0 -8
- package/dist/components/StepLine.js +0 -22
- package/dist/components/Theme.js +0 -20
- package/dist/components/ToolProgress.js +0 -8
- package/dist/components/ViolationList.js +0 -21
- package/dist/components/render.js +0 -13
- package/dist/config/agent-registry.js +0 -237
- package/dist/config/claude-settings-hooks.js +0 -304
- package/dist/config/hook-installer.js +0 -65
- package/dist/config/instruction-writer.js +0 -388
- package/dist/config/mcp-config-writer.js +0 -266
- package/dist/config/settings.js +0 -174
- package/dist/config/tool-detector.js +0 -42
- package/dist/config/value-surfacing.js +0 -119
- package/dist/core/context-assembly.js +0 -108
- package/dist/core/conversation.js +0 -33
- package/dist/core/local-chat-provider.js +0 -475
- package/dist/core/provider-factory.js +0 -55
- package/dist/core/providers.js +0 -90
- package/dist/core/query-engine.js +0 -174
- package/dist/daemon/api.js +0 -312
- package/dist/daemon/autostart.js +0 -119
- package/dist/daemon/bootstrap.js +0 -39
- package/dist/daemon/client.js +0 -164
- package/dist/daemon/detect-ci.js +0 -81
- package/dist/daemon/platform-linux.js +0 -146
- package/dist/daemon/platform-macos.js +0 -134
- package/dist/daemon/platform-windows.js +0 -116
- package/dist/daemon/process-manager.js +0 -299
- package/dist/daemon/protocol.js +0 -23
- package/dist/daemon/registry.js +0 -270
- package/dist/daemon/settings-schema.js +0 -72
- package/dist/daemon/system-health.js +0 -134
- package/dist/daemon/version-checker.js +0 -262
- package/dist/daemon/warm-start.js +0 -223
- package/dist/entrypoints/cli.js +0 -1043
- package/dist/entrypoints/daemon.js +0 -380
- package/dist/entrypoints/repl.js +0 -147
- package/dist/hooks/adapters/claude-code.js +0 -90
- package/dist/hooks/adapters/cline.js +0 -100
- package/dist/hooks/adapters/cursor.js +0 -98
- package/dist/hooks/hook-dedup.js +0 -79
- package/dist/hooks/hook-runner.js +0 -113
- package/dist/hooks/navigation-hooks.js +0 -175
- package/dist/hooks/prompt-hooks.js +0 -63
- package/dist/hooks/shell-hooks.js +0 -47
- package/dist/ignore.js +0 -111
- package/dist/intelligence/approach-suggester.js +0 -61
- package/dist/intelligence/ast-extractor.js +0 -2615
- package/dist/intelligence/ast-worker.js +0 -34
- package/dist/intelligence/background-indexer.js +0 -121
- package/dist/intelligence/blast-radius.js +0 -200
- package/dist/intelligence/community-detection.js +0 -691
- package/dist/intelligence/community-detector.js +0 -184
- package/dist/intelligence/computation-scheduler.js +0 -75
- package/dist/intelligence/confidence-propagation.js +0 -47
- package/dist/intelligence/convention-detector.js +0 -242
- package/dist/intelligence/convention-learner.js +0 -205
- package/dist/intelligence/convention-matcher.js +0 -205
- package/dist/intelligence/cozo-schema.js +0 -376
- package/dist/intelligence/decision-point-detector.js +0 -90
- package/dist/intelligence/deep-dive-tools.js +0 -586
- package/dist/intelligence/durability-scorer.js +0 -84
- package/dist/intelligence/exploration-cost.js +0 -204
- package/dist/intelligence/exploration-pattern-tracker.js +0 -61
- package/dist/intelligence/fact-generator.js +0 -322
- package/dist/intelligence/facts-schema.js +0 -90
- package/dist/intelligence/file-intelligence.js +0 -59
- package/dist/intelligence/graph-holder.js +0 -220
- package/dist/intelligence/graph-temporal-joiner.js +0 -238
- package/dist/intelligence/health-grade.js +0 -423
- package/dist/intelligence/health-grader.js +0 -200
- package/dist/intelligence/health-map-data.js +0 -259
- package/dist/intelligence/import-symbols.js +0 -136
- package/dist/intelligence/incremental-indexer.js +0 -658
- package/dist/intelligence/indexer/centrality.js +0 -62
- package/dist/intelligence/indexer/cfg-context.js +0 -95
- package/dist/intelligence/indexer/confidence.js +0 -34
- package/dist/intelligence/indexer/cross-file-resolver.js +0 -104
- package/dist/intelligence/indexer/edge-repair.js +0 -89
- package/dist/intelligence/indexer/entity-key.js +0 -17
- package/dist/intelligence/indexer/export-map.js +0 -132
- package/dist/intelligence/indexer/git-cochange.js +0 -128
- package/dist/intelligence/indexer/graph-patch.js +0 -147
- package/dist/intelligence/indexer/incremental.js +0 -78
- package/dist/intelligence/indexer/ingest.js +0 -160
- package/dist/intelligence/indexer/language-detect.js +0 -226
- package/dist/intelligence/indexer/metadata.js +0 -63
- package/dist/intelligence/indexer/mutation-tracker.js +0 -79
- package/dist/intelligence/indexer/orchestrator.js +0 -155
- package/dist/intelligence/indexer/plugin-interface.js +0 -31
- package/dist/intelligence/indexer/plugins/csharp.js +0 -440
- package/dist/intelligence/indexer/plugins/go.js +0 -335
- package/dist/intelligence/indexer/plugins/java.js +0 -370
- package/dist/intelligence/indexer/plugins/python.js +0 -358
- package/dist/intelligence/indexer/plugins/regex-fallback.js +0 -82
- package/dist/intelligence/indexer/plugins/ruby.js +0 -290
- package/dist/intelligence/indexer/plugins/rust.js +0 -484
- package/dist/intelligence/indexer/plugins/tier2-generic.js +0 -310
- package/dist/intelligence/indexer/plugins/typescript.js +0 -456
- package/dist/intelligence/indexer/resource-monitor.js +0 -93
- package/dist/intelligence/indexer/scip/decoder.js +0 -253
- package/dist/intelligence/indexer/scip/detector.js +0 -232
- package/dist/intelligence/indexer/scip/downloader.js +0 -427
- package/dist/intelligence/indexer/scip/fallback.js +0 -34
- package/dist/intelligence/indexer/scip/merger.js +0 -109
- package/dist/intelligence/indexer/scip/orchestrator.js +0 -433
- package/dist/intelligence/indexer/scip/runner.js +0 -98
- package/dist/intelligence/indexer/snapshot.js +0 -66
- package/dist/intelligence/indexer/test-detector.js +0 -196
- package/dist/intelligence/indexer/watch-integration.js +0 -61
- package/dist/intelligence/indexer/worker.js +0 -85
- package/dist/intelligence/local-convention-detector.js +0 -437
- package/dist/intelligence/local-embeddings.js +0 -190
- package/dist/intelligence/local-graph.js +0 -1946
- package/dist/intelligence/local-indexer.js +0 -1575
- package/dist/intelligence/local-llm.js +0 -163
- package/dist/intelligence/local-rule-generator.js +0 -154
- package/dist/intelligence/local-snapshot.js +0 -213
- package/dist/intelligence/negative-knowledge.js +0 -103
- package/dist/intelligence/persistent-db.js +0 -85
- package/dist/intelligence/query-router.js +0 -2556
- package/dist/intelligence/risk-classifier.js +0 -116
- package/dist/intelligence/rule-evaluator.js +0 -380
- package/dist/intelligence/rule-generator.js +0 -49
- package/dist/intelligence/search-index.js +0 -173
- package/dist/intelligence/semantic/docstring-extractor.js +0 -67
- package/dist/intelligence/semantic/embedding-store.js +0 -52
- package/dist/intelligence/semantic/enrichment-orchestrator.js +0 -48
- package/dist/intelligence/semantic/git-message-miner.js +0 -114
- package/dist/intelligence/semantic/identifier-tokenizer.js +0 -51
- package/dist/intelligence/semantic/node2vec-embeddings.js +0 -71
- package/dist/intelligence/semantic/node2vec-walks.js +0 -103
- package/dist/intelligence/semantic/path-domain-inference.js +0 -112
- package/dist/intelligence/semantic/similarity-engine.js +0 -60
- package/dist/intelligence/semantic/tfidf-vectors.js +0 -88
- package/dist/intelligence/session-brief-builder.js +0 -159
- package/dist/intelligence/session-context.js +0 -221
- package/dist/intelligence/session-health-monitor.js +0 -211
- package/dist/intelligence/session-narrative.js +0 -197
- package/dist/intelligence/session-pattern-analyzer.js +0 -218
- package/dist/intelligence/signal-scorer.js +0 -390
- package/dist/intelligence/signal-show-store.js +0 -182
- package/dist/intelligence/smart-truncate.js +0 -158
- package/dist/intelligence/subgraph-cache.js +0 -88
- package/dist/intelligence/temporal-facts.js +0 -494
- package/dist/intelligence/token-estimator.js +0 -100
- package/dist/intelligence/tool-injector.js +0 -87
- package/dist/intelligence/tree-sitter-loader.js +0 -71
- package/dist/intelligence/worker-pool.js +0 -116
- package/dist/proxy/arg-validator.js +0 -79
- package/dist/proxy/auto-bootstrap.js +0 -167
- package/dist/proxy/bridge.js +0 -147
- package/dist/proxy/budget-enforcer.js +0 -70
- package/dist/proxy/compression-quality-monitor.js +0 -160
- package/dist/proxy/compression-stats.js +0 -51
- package/dist/proxy/context-rot-detector.js +0 -137
- package/dist/proxy/drift-detector.js +0 -139
- package/dist/proxy/efficiency-tracker.js +0 -79
- package/dist/proxy/fact-ranking.js +0 -154
- package/dist/proxy/format-encoder.js +0 -266
- package/dist/proxy/http-transport.js +0 -90
- package/dist/proxy/lifecycle-actor.js +0 -55
- package/dist/proxy/lifecycle-machine.js +0 -187
- package/dist/proxy/log-tailer.js +0 -265
- package/dist/proxy/model-pricing.js +0 -98
- package/dist/proxy/network-firewall.js +0 -141
- package/dist/proxy/nudge-state.js +0 -93
- package/dist/proxy/output-compressor.js +0 -185
- package/dist/proxy/pid-lock.js +0 -291
- package/dist/proxy/proxy-context.js +0 -11
- package/dist/proxy/proxy.js +0 -2633
- package/dist/proxy/response-enrichment.js +0 -32
- package/dist/proxy/response-envelope.js +0 -313
- package/dist/proxy/session-dedup.js +0 -82
- package/dist/proxy/session-legend.js +0 -30
- package/dist/proxy/session-persistence.js +0 -210
- package/dist/proxy/session-resume.js +0 -94
- package/dist/proxy/session-stats.js +0 -513
- package/dist/proxy/shell-classifier.js +0 -1346
- package/dist/proxy/shell-compression-log.js +0 -93
- package/dist/proxy/shell-compressor.js +0 -390
- package/dist/proxy/shell-graph-boost.js +0 -202
- package/dist/proxy/shell-monitor-map.js +0 -18
- package/dist/proxy/shell-stats.js +0 -54
- package/dist/proxy/shell-strategies/cloud.js +0 -215
- package/dist/proxy/shell-strategies/diff.js +0 -159
- package/dist/proxy/shell-strategies/error-diagnostic.js +0 -796
- package/dist/proxy/shell-strategies/filter-dsl.js +0 -358
- package/dist/proxy/shell-strategies/git-status.js +0 -177
- package/dist/proxy/shell-strategies/key-value.js +0 -193
- package/dist/proxy/shell-strategies/log-text.js +0 -154
- package/dist/proxy/shell-strategies/omni.js +0 -188
- package/dist/proxy/shell-strategies/progress.js +0 -55
- package/dist/proxy/shell-strategies/redact.js +0 -76
- package/dist/proxy/shell-strategies/structured.js +0 -241
- package/dist/proxy/shell-strategies/tabular.js +0 -243
- package/dist/proxy/shell-strategies/test-results-types.js +0 -13
- package/dist/proxy/shell-strategies/test-results.js +0 -784
- package/dist/proxy/shell-strategies/tree-paths.js +0 -144
- package/dist/proxy/shell-strategies/yaml.js +0 -182
- package/dist/proxy/shell-tee.js +0 -111
- package/dist/proxy/signal-dedup.js +0 -171
- package/dist/proxy/startup-renderer.js +0 -158
- package/dist/proxy/task-token-display.js +0 -38
- package/dist/proxy/token-counter.js +0 -61
- package/dist/proxy/tool-clusters.js +0 -273
- package/dist/proxy/tool-definitions.js +0 -525
- package/dist/proxy/transport-mux.js +0 -229
- package/dist/proxy/wire-cap.js +0 -268
- package/dist/rules/developer.mozilla.org.json +0 -9
- package/dist/rules/github.com.json +0 -21
- package/dist/schemas/api/skills.js +0 -19
- package/dist/schemas/common/errors.js +0 -7
- package/dist/schemas/common/headers.js +0 -5
- package/dist/schemas/entities/edge.js +0 -25
- package/dist/schemas/entities/entity.js +0 -22
- package/dist/schemas/entities/rule.js +0 -18
- package/dist/schemas/index.js +0 -14
- package/dist/server/event-bus.js +0 -59
- package/dist/server/http.js +0 -156
- package/dist/server/middleware.js +0 -70
- package/dist/server/routes/drift.js +0 -97
- package/dist/server/routes/intelligence.js +0 -1217
- package/dist/server/routes/reasoning-quality.js +0 -444
- package/dist/server/routes/session.js +0 -86
- package/dist/server/routes/stream.js +0 -120
- package/dist/server/routes/system.js +0 -73
- package/dist/server/routes/temporal.js +0 -170
- package/dist/server/routes/timeline.js +0 -232
- package/dist/server/routes/token-flow.js +0 -403
- package/dist/skills/effectiveness-tracker.js +0 -93
- package/dist/skills/local-pack.js +0 -380
- package/dist/skills/resolver.js +0 -495
- package/dist/state-detector.js +0 -83
- package/dist/timeline/intent-detector.js +0 -263
- package/dist/timeline/loop-miner.js +0 -140
- package/dist/timeline/open-threads.js +0 -49
- package/dist/timeline/signal-reinforcer.js +0 -62
- package/dist/timeline/timeline-bootstrap.js +0 -151
- package/dist/timeline/timeline-store.js +0 -618
- package/dist/tools/coding/bash.js +0 -49
- package/dist/tools/coding/file-edit.js +0 -72
- package/dist/tools/coding/file-outline.js +0 -227
- package/dist/tools/coding/file-read-protocol.js +0 -425
- package/dist/tools/coding/file-read.js +0 -35
- package/dist/tools/coding/file-write.js +0 -43
- package/dist/tools/coding/glob-tool.js +0 -109
- package/dist/tools/coding/grep.js +0 -162
- package/dist/tools/coding/index.js +0 -27
- package/dist/tools/intelligence/index.js +0 -269
- package/dist/tools/intelligence/record-fact.js +0 -48
- package/dist/tools/intelligence/timeline-markers.js +0 -130
- package/dist/tools/registry.js +0 -47
- package/dist/tools/types.js +0 -8
- package/dist/tracking/auto-snapshot-triggers.js +0 -246
- package/dist/tracking/branch-context.js +0 -115
- package/dist/tracking/branch-snapshot.js +0 -217
- package/dist/tracking/causal-bridge.js +0 -317
- package/dist/tracking/circuit-breaker.js +0 -147
- package/dist/tracking/commit-watcher.js +0 -114
- package/dist/tracking/context-ledger.js +0 -119
- package/dist/tracking/correction-detector.js +0 -324
- package/dist/tracking/drift-tracker.js +0 -874
- package/dist/tracking/durability-tracker.js +0 -94
- package/dist/tracking/entity-rewind.js +0 -200
- package/dist/tracking/file-hash-state.js +0 -114
- package/dist/tracking/git-attribution.js +0 -132
- package/dist/tracking/git-trailers.js +0 -171
- package/dist/tracking/intelligence-counter.js +0 -46
- package/dist/tracking/intent-correlator.js +0 -202
- package/dist/tracking/intent-encoder.js +0 -52
- package/dist/tracking/intent-token-tracker.js +0 -159
- package/dist/tracking/ledger-archiver.js +0 -94
- package/dist/tracking/ledger-chains.js +0 -245
- package/dist/tracking/metrics-store.js +0 -361
- package/dist/tracking/native-watcher.js +0 -131
- package/dist/tracking/offline-rewind.js +0 -295
- package/dist/tracking/pending-violations.js +0 -74
- package/dist/tracking/persistence-effectiveness.js +0 -167
- package/dist/tracking/prompt-durability.js +0 -202
- package/dist/tracking/quality-signals.js +0 -213
- package/dist/tracking/redactor.js +0 -73
- package/dist/tracking/rewind-engine.js +0 -161
- package/dist/tracking/session-history.js +0 -128
- package/dist/tracking/session-receipt.js +0 -88
- package/dist/tracking/session-summary-writer.js +0 -157
- package/dist/tracking/shadow-ledger.js +0 -321
- package/dist/tracking/stash-manager.js +0 -258
- package/dist/tracking/timeline-fork.js +0 -213
- package/dist/tracking/timeline.js +0 -69
- package/dist/tracking/token-flow.js +0 -276
- package/dist/tracking/turn-segmenter.js +0 -122
- package/dist/tracking/weekly-accumulator.js +0 -179
- package/dist/tracking/working-snapshots.js +0 -188
- package/dist/tracking/workspace-manifest.js +0 -176
- package/dist/transport/http.js +0 -102
- package/dist/utils/counterfactual.js +0 -65
- package/dist/utils/deep-link.js +0 -34
- package/dist/utils/detect.js +0 -193
- package/dist/utils/exec.js +0 -73
- package/dist/utils/file-logger.js +0 -87
- package/dist/utils/format-error.js +0 -29
- package/dist/utils/git.js +0 -181
- package/dist/utils/log.js +0 -57
- package/dist/utils/logger.js +0 -35
- package/dist/utils/mcp-content-json.js +0 -8
- package/dist/utils/session-logger.js +0 -154
- package/dist/utils/startup-log.js +0 -512
- 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
|
-
}
|