agentwrangler 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/LICENSE +191 -0
  2. package/README.md +116 -0
  3. package/dist/apply/jobs.js +429 -0
  4. package/dist/apply/open-terminal-child.mjs +98 -0
  5. package/dist/apply/open-terminal.js +221 -0
  6. package/dist/apply/settings-gen.js +35 -0
  7. package/dist/cli/agentwrangler.js +18 -0
  8. package/dist/daemon/config.js +51 -0
  9. package/dist/daemon/http.js +258 -0
  10. package/dist/daemon/index.js +372 -0
  11. package/dist/daemon/outcomes-pass.js +82 -0
  12. package/dist/daemon/readiness.js +15 -0
  13. package/dist/daemon/router.js +756 -0
  14. package/dist/daemon/static.js +146 -0
  15. package/dist/db/migrate.js +72 -0
  16. package/dist/db/migrations/001_observe.sql +196 -0
  17. package/dist/db/migrations/002_indexes.sql +6 -0
  18. package/dist/db/migrations/003_context_inventory_history.sql +20 -0
  19. package/dist/db/migrations/004_apply_jobs.sql +17 -0
  20. package/dist/db/migrations/005_tool_event_metadata.sql +17 -0
  21. package/dist/db/migrations/006_d7_query_indexes.sql +9 -0
  22. package/dist/db/migrations/007_work_item_branch_keys.sql +11 -0
  23. package/dist/db/migrations/008_thinking_tokens.sql +1 -0
  24. package/dist/db/migrations/009_user_turn_count.sql +1 -0
  25. package/dist/db/migrations/010_workspace_cwd.sql +1 -0
  26. package/dist/db/migrations/011_reports.sql +1 -0
  27. package/dist/db/migrations/012_reconcile_indexes.sql +2 -0
  28. package/dist/db/migrations/013_friction_fields.sql +5 -0
  29. package/dist/db/migrations/014_session_churn.sql +11 -0
  30. package/dist/db/migrations/015_gap_aggregates.sql +6 -0
  31. package/dist/db/open.js +30 -0
  32. package/dist/detector/benchmark-anchors.js +36 -0
  33. package/dist/detector/calibration.js +302 -0
  34. package/dist/detector/context-history-retention.js +312 -0
  35. package/dist/detector/context-probe.js +574 -0
  36. package/dist/detector/d1-source-identity.js +25 -0
  37. package/dist/detector/detectors/d10_catalog_footprint.js +146 -0
  38. package/dist/detector/detectors/d1_ctx_always_loaded.js +203 -0
  39. package/dist/detector/detectors/d2_session_long_full_context.js +119 -0
  40. package/dist/detector/detectors/d4_model_mismatch.js +258 -0
  41. package/dist/detector/detectors/d5_limit_burn_forecast.js +138 -0
  42. package/dist/detector/detectors/d6_tool_result_bloat.js +301 -0
  43. package/dist/detector/detectors/d7_loop_retry_waste.js +345 -0
  44. package/dist/detector/detectors/d8_cache_write_churn.js +201 -0
  45. package/dist/detector/detectors/d9_idle_background_session.js +101 -0
  46. package/dist/detector/engine.js +88 -0
  47. package/dist/detector/index.js +17 -0
  48. package/dist/detector/measurement.js +426 -0
  49. package/dist/detector/practice-registry.js +259 -0
  50. package/dist/detector/registry.js +32 -0
  51. package/dist/detector/savings.js +249 -0
  52. package/dist/detector/types.js +14 -0
  53. package/dist/evidence/common/approved-input.js +632 -0
  54. package/dist/evidence/common/boundary.js +84 -0
  55. package/dist/evidence/common/canonical.js +55 -0
  56. package/dist/evidence/common/redaction.js +321 -0
  57. package/dist/evidence/common/sqlite.js +25 -0
  58. package/dist/evidence/common/state.js +29 -0
  59. package/dist/evidence/cond1/cli.js +289 -0
  60. package/dist/evidence/cond1/packet.js +407 -0
  61. package/dist/evidence/cond1/prepare.js +295 -0
  62. package/dist/evidence/cond1/score.js +349 -0
  63. package/dist/evidence/cond1/types.js +1 -0
  64. package/dist/evidence/create-approval.js +365 -0
  65. package/dist/evidence/create-scratch.js +542 -0
  66. package/dist/evidence/d7/cli.js +113 -0
  67. package/dist/evidence/d7/measure.js +193 -0
  68. package/dist/evidence/d7/types.js +1 -0
  69. package/dist/evidence/discover-approval.js +492 -0
  70. package/dist/evidence/g2/adjudicate.js +20 -0
  71. package/dist/evidence/g2/cli.js +207 -0
  72. package/dist/evidence/g2/kappa.js +39 -0
  73. package/dist/evidence/g2/pipeline.js +92 -0
  74. package/dist/evidence/g2/store.js +14 -0
  75. package/dist/evidence/github/client.js +1 -0
  76. package/dist/evidence/github/gh-cli-client.js +301 -0
  77. package/dist/evidence/r3/cli.js +209 -0
  78. package/dist/evidence/r3/evaluate.js +417 -0
  79. package/dist/evidence/r3/packet.js +162 -0
  80. package/dist/evidence/r3/prepare.js +405 -0
  81. package/dist/evidence/r3/score.js +341 -0
  82. package/dist/evidence/r3/transcript.js +155 -0
  83. package/dist/evidence/r3/types.js +4 -0
  84. package/dist/hook/context-budget-hook.mjs +138 -0
  85. package/dist/hook/danger-guard-denylist.json +27 -0
  86. package/dist/hook/danger-guard-hook.mjs +167 -0
  87. package/dist/hook/install.js +0 -0
  88. package/dist/hook/limit-burn-hook.mjs +127 -0
  89. package/dist/hook/loop-guard-hook.mjs +104 -0
  90. package/dist/hook/precompact-checkpoint-hook.mjs +123 -0
  91. package/dist/ingest/churn-collector.js +122 -0
  92. package/dist/ingest/detector-hook.js +52 -0
  93. package/dist/ingest/discovery.js +207 -0
  94. package/dist/ingest/health.js +43 -0
  95. package/dist/ingest/index.js +28 -0
  96. package/dist/ingest/ingestor.js +509 -0
  97. package/dist/ingest/parser.js +344 -0
  98. package/dist/ingest/pricing.js +153 -0
  99. package/dist/ingest/reconcile.js +52 -0
  100. package/dist/ingest/tail.js +152 -0
  101. package/dist/ingest/types.js +24 -0
  102. package/dist/ingest/workspace-mapping.js +114 -0
  103. package/dist/oauth/anthropic-api-key.js +88 -0
  104. package/dist/oauth/count-tokens.js +86 -0
  105. package/dist/oauth/credentials.js +171 -0
  106. package/dist/oauth/judge-g2-client.js +154 -0
  107. package/dist/oauth/usage.js +167 -0
  108. package/dist/outcomes/branch-key.js +49 -0
  109. package/dist/outcomes/conclusions.js +45 -0
  110. package/dist/outcomes/derive.js +94 -0
  111. package/dist/outcomes/finding-extractors.js +131 -0
  112. package/dist/outcomes/findings.js +237 -0
  113. package/dist/outcomes/github/client.js +367 -0
  114. package/dist/outcomes/github/credential.js +195 -0
  115. package/dist/outcomes/github/gh-cli-client.js +340 -0
  116. package/dist/outcomes/linker.js +486 -0
  117. package/dist/outcomes/pool.js +24 -0
  118. package/dist/outcomes/sync.js +276 -0
  119. package/dist/query/api/agents-liveness.js +182 -0
  120. package/dist/query/api/burn-status.js +50 -0
  121. package/dist/query/api/context-budget.js +114 -0
  122. package/dist/query/api/context-composition.js +67 -0
  123. package/dist/query/api/cost-per-success.js +104 -0
  124. package/dist/query/api/delivery.js +92 -0
  125. package/dist/query/api/effectiveness.js +254 -0
  126. package/dist/query/api/efficiency-headroom.js +74 -0
  127. package/dist/query/api/headroom-trend.js +105 -0
  128. package/dist/query/api/hook-config.js +75 -0
  129. package/dist/query/api/hook-install.js +8 -0
  130. package/dist/query/api/hot-sessions.js +17 -0
  131. package/dist/query/api/idle-sessions.js +52 -0
  132. package/dist/query/api/index.js +40 -0
  133. package/dist/query/api/loop-guard.js +90 -0
  134. package/dist/query/api/offload-share.js +41 -0
  135. package/dist/query/api/outcomes.js +218 -0
  136. package/dist/query/api/overview.js +535 -0
  137. package/dist/query/api/rec-prompt.js +138 -0
  138. package/dist/query/api/recommendations-ledger.js +111 -0
  139. package/dist/query/api/recommendations.js +514 -0
  140. package/dist/query/api/reports.js +78 -0
  141. package/dist/query/api/self-churn.js +77 -0
  142. package/dist/query/api/self-percentiles.js +109 -0
  143. package/dist/query/api/session-drivers.js +153 -0
  144. package/dist/query/api/settings.js +85 -0
  145. package/dist/query/api/spend-flavor.js +234 -0
  146. package/dist/query/api/trends.js +155 -0
  147. package/dist/query/cap-weighted.js +119 -0
  148. package/dist/query/db-context.js +42 -0
  149. package/dist/query/envelope.js +71 -0
  150. package/dist/query/forecast.js +191 -0
  151. package/dist/query/settings-store.js +441 -0
  152. package/dist/query/spend.js +171 -0
  153. package/dist/query/trends.js +194 -0
  154. package/dist/ui/assets/index-DnRKgc21.css +1 -0
  155. package/dist/ui/assets/index-h1Q1wWq5.js +168 -0
  156. package/dist/ui/index.html +39 -0
  157. package/package.json +59 -0
@@ -0,0 +1,146 @@
1
+ /**
2
+ * D10 CATALOG_FOOTPRINT.
3
+ *
4
+ * This detector reports the derived MCP/plugin/skill catalog as an estimate.
5
+ * The ContextInventoryProbe records Tool-Search configuration facts separately
6
+ * in a zero-token SETTINGS_SYSTEM row, so D10 can distinguish deferred tools
7
+ * from server-level alwaysLoad exemptions without persisting catalog content.
8
+ * Per-turn loaded-tool attribution and economic savings remain gated on R11.
9
+ */
10
+ import { GLOBAL_WORKSPACE_ID, parseToolSearchState, } from "../context-probe.js";
11
+ /** Retained as the unvalidated catalog-size review threshold. */
12
+ export const D10_CATALOG_FOOTPRINT_TARGET_TOKENS = 40_000;
13
+ function catalogStateLabel(state) {
14
+ switch (state.effective_catalog_state) {
15
+ case "alwaysLoad":
16
+ return `alwaysLoad keeps ${state.always_load_count} MCP server(s) upfront`;
17
+ case "deferred":
18
+ return "Tool Search defers MCP tool definitions until they are needed";
19
+ case "threshold":
20
+ return "Tool Search uses threshold loading, so some MCP definitions may be upfront";
21
+ case "upfront":
22
+ return "MCP tool definitions load upfront because Tool Search is disabled";
23
+ default:
24
+ return "MCP tool load behavior is unknown";
25
+ }
26
+ }
27
+ function statusNote(state, catalogTokens, withinTarget) {
28
+ const target = D10_CATALOG_FOOTPRINT_TARGET_TOKENS;
29
+ const comparison = withinTarget ? "is within" : "exceeds";
30
+ return `catalog estimate ${catalogTokens} token(s) ${comparison} ${target} target; ${catalogStateLabel(state)}; loaded-tool economics are not measured`;
31
+ }
32
+ function stateRow(db) {
33
+ const row = db
34
+ .prepare(`SELECT attribution_version
35
+ FROM context_inventory
36
+ WHERE workspace_id = ? AND component = 'SETTINGS_SYSTEM'
37
+ ORDER BY probed_at DESC
38
+ LIMIT 1`)
39
+ .get(GLOBAL_WORKSPACE_ID);
40
+ return row === undefined ? null : parseToolSearchState(row.attribution_version);
41
+ }
42
+ function catalogRows(db) {
43
+ return db
44
+ .prepare(`SELECT file_ref, file_hash, tokens, probed_at
45
+ FROM context_inventory
46
+ WHERE workspace_id = ? AND component = 'MCP_SCHEMAS'
47
+ ORDER BY file_ref`)
48
+ .all(GLOBAL_WORKSPACE_ID);
49
+ }
50
+ function configEvidence(state) {
51
+ return {
52
+ tool_search_mode: state.tool_search_mode,
53
+ effective_catalog_state: state.effective_catalog_state,
54
+ configured_value: state.configured_value,
55
+ always_load_flags: state.always_load_flags,
56
+ always_load_count: state.always_load_count,
57
+ always_load_flags_truncated: state.always_load_flags_truncated,
58
+ catalog_item_count: state.catalog_item_count,
59
+ catalog_item_count_truncated: state.catalog_item_count_truncated,
60
+ catalog_hash: state.catalog_hash,
61
+ };
62
+ }
63
+ export const d10Detector = {
64
+ id: "D10",
65
+ name: "CATALOG_FOOTPRINT",
66
+ evaluate(db, _ctx) {
67
+ const rows = catalogRows(db);
68
+ const state = stateRow(db);
69
+ if (rows.length === 0) {
70
+ return {
71
+ fired: [],
72
+ status: "NOT_EVALUATED",
73
+ note: state === null
74
+ ? "catalog estimate unavailable; Tool-Search config state is not measured"
75
+ : `catalog estimate unavailable; ${catalogStateLabel(state)}`,
76
+ };
77
+ }
78
+ if (state === null) {
79
+ return {
80
+ fired: [],
81
+ status: "NOT_EVALUATED",
82
+ note: "catalog inventory is estimated, but Tool-Search config state is not measured",
83
+ };
84
+ }
85
+ const catalogTokens = rows.reduce((sum, row) => sum + row.tokens, 0);
86
+ const withinTarget = catalogTokens <= D10_CATALOG_FOOTPRINT_TARGET_TOKENS;
87
+ if (withinTarget) {
88
+ return {
89
+ fired: [],
90
+ status: "INACTIVE",
91
+ note: statusNote(state, catalogTokens, true),
92
+ };
93
+ }
94
+ const delta = catalogTokens - D10_CATALOG_FOOTPRINT_TARGET_TOKENS;
95
+ const latestProbedAt = rows.reduce((latest, row) => (latest === null || row.probed_at > latest ? row.probed_at : latest), null);
96
+ const refs = rows.map((row) => row.file_ref);
97
+ const evidence = {
98
+ title: `Review ${state.effective_catalog_state} tool catalog: ${Math.round(catalogTokens / 1000)}K tokens`,
99
+ component: "MCP_SCHEMAS",
100
+ file_ref: refs.length === 1 ? refs[0] : null,
101
+ file_refs: refs,
102
+ source_count: rows.length,
103
+ catalog_tokens: catalogTokens,
104
+ catalog_target_tokens: D10_CATALOG_FOOTPRINT_TARGET_TOKENS,
105
+ delta_context_tokens: delta,
106
+ estimate: true,
107
+ priority: "down-ranked",
108
+ thresholds_unvalidated: true,
109
+ loaded_tool_telemetry: "unavailable",
110
+ probed_at: latestProbedAt,
111
+ ...configEvidence(state),
112
+ steps: [
113
+ "Review enabled MCP servers, plugins, and skills in the local Claude catalog",
114
+ "Keep only the small set of MCP servers that need alwaysLoad on every turn",
115
+ "Disable rarely used plugins or leave infrequent tools deferred",
116
+ "Re-measure: ContextInventoryProbe refreshes catalog and config facts on the next daemon pass",
117
+ ],
118
+ };
119
+ const fired = {
120
+ scopeKey: "D10|global|MCP_SCHEMAS",
121
+ category: "TOOLING",
122
+ scope_workspace_id: null,
123
+ lever: "Too many connected tools, plugins, and skills",
124
+ target_metric: "catalog_context_tokens",
125
+ // R11 is required before catalog size can become a freed-headroom claim.
126
+ modeled_savings_u_per_wk: null,
127
+ modeled_formula: {
128
+ model: "D10_CATALOG_FOOTPRINT_CONFIG_V1",
129
+ inputs: {
130
+ catalog_tokens: catalogTokens,
131
+ catalog_target_tokens: D10_CATALOG_FOOTPRINT_TARGET_TOKENS,
132
+ delta_context_tokens: delta,
133
+ catalog_item_count: state.catalog_item_count,
134
+ always_load_count: state.always_load_count,
135
+ },
136
+ kind: "estimate",
137
+ },
138
+ evidence,
139
+ };
140
+ return {
141
+ fired: [fired],
142
+ status: "ACTIVE",
143
+ note: statusNote(state, catalogTokens, false),
144
+ };
145
+ },
146
+ };
@@ -0,0 +1,203 @@
1
+ /**
2
+ * src/detector/detectors/d1_ctx_always_loaded.ts — D1 CTX_ALWAYS_LOADED_OVERSIZE.
3
+ *
4
+ * Fires PER-SOURCE against per-source token targets (UNVALIDATED defaults).
5
+ * For each context_inventory row:
6
+ * - fire when source_tokens > D1_SOURCE_TARGETS[component]
7
+ * - delta = source_tokens - target (trimmable portion)
8
+ * - weekly savings µUSD = delta × turns_per_week × blended_cache_read_price
9
+ *
10
+ * GLOBAL sources (workspace_id = '__global__'): multiply turns_per_week by the
11
+ * count of active (non-global) workspaces; set scope_workspace_id = null.
12
+ * PER-WORKSPACE sources: keep the real workspace_id as scope_workspace_id.
13
+ *
14
+ * scopeKey = `D1|<workspace_id_or_global>|<component>|<file_ref-basename>`
15
+ * so rec_id is stable and unique per source.
16
+ *
17
+ * When context_inventory is empty, returns NOT_EVALUATED (ContextInventoryProbe
18
+ * not yet run or no sources found).
19
+ */
20
+ import * as path from "node:path";
21
+ import { LIST_PRICES } from "../../ingest/pricing.js";
22
+ import { GLOBAL_WORKSPACE_ID } from "../context-probe.js";
23
+ import { D1_SOURCE_TARGETS, d1Savings } from "../savings.js";
24
+ /** Opus list cache-read price ($/MTok) — anchor when a workspace has no cache reads. */
25
+ const OPUS_CACHE_READ_PRICE = LIST_PRICES.opus?.[2] ?? 0.5;
26
+ /** Steps[] wording per component — numbered, source-specific action plan. */
27
+ function stepsFor(component, fileRef) {
28
+ switch (component) {
29
+ case "CLAUDE_MD":
30
+ return [
31
+ `Open ${fileRef}`,
32
+ "Move changelog/history/rationale prose to a linked doc",
33
+ "Keep current-state rules + pointers only",
34
+ "Re-measure: probe re-sizes on next daemon pass",
35
+ ];
36
+ case "MEMORY":
37
+ return [
38
+ `Review memory files under ${fileRef}`,
39
+ "Delete stale or duplicate memories",
40
+ "Consolidate overlapping facts into concise entries",
41
+ "Re-measure: probe re-sizes on next daemon pass",
42
+ ];
43
+ case "MCP_SCHEMAS":
44
+ return [
45
+ "List enabled plugins/skills in ~/.claude/plugins and ~/.claude/skills",
46
+ "Identify rarely-used skills from recent session activity",
47
+ "Extract to on-demand usage or disable the plugin",
48
+ "Re-measure: probe re-sizes on next daemon pass",
49
+ ];
50
+ default:
51
+ return [`Trim ${fileRef} to reduce always-loaded context`];
52
+ }
53
+ }
54
+ /** Short imperative title per component — written into evidence.title (read by toCard). */
55
+ function titleFor(component, tokens, target) {
56
+ const label = component === "CLAUDE_MD" ? "CLAUDE.md" : component === "MEMORY" ? "memory" : "MCP schemas";
57
+ const fromK = Math.round(tokens / 1_000);
58
+ const toK = Math.round(target / 1_000);
59
+ return `Trim ${label}: ${fromK}K→${toK}K tokens`;
60
+ }
61
+ /** Lever label per component. */
62
+ function leverFor(component) {
63
+ switch (component) {
64
+ case "CLAUDE_MD":
65
+ return "Move changelog/history prose out of CLAUDE.md; keep current-state + pointers.";
66
+ case "MEMORY":
67
+ return "Prune stale/duplicate memories; consolidate overlapping facts.";
68
+ case "MCP_SCHEMAS":
69
+ return "Identify rarely-used skills/plugins; extract to on-demand or disable.";
70
+ default:
71
+ return "Trim always-loaded context source to the per-source target.";
72
+ }
73
+ }
74
+ export const d1Detector = {
75
+ id: "D1",
76
+ name: "CTX_ALWAYS_LOADED_OVERSIZE",
77
+ evaluate(db, ctx) {
78
+ // Early exit when there is nothing to evaluate.
79
+ const inventoryCount = db.prepare("SELECT COUNT(*) AS n FROM context_inventory").get().n;
80
+ if (inventoryCount === 0) {
81
+ return {
82
+ fired: [],
83
+ status: "NOT_EVALUATED",
84
+ note: "no context_inventory rows — run ContextInventoryProbe first",
85
+ };
86
+ }
87
+ // Only sources with an active D1 contract are evaluable. MCP_SCHEMAS is a
88
+ // catalog estimate and is intentionally owned by the gated D10 detector.
89
+ const rows = db
90
+ .prepare(`SELECT probe_id, workspace_id, component, file_ref, tokens, probed_at
91
+ FROM context_inventory
92
+ WHERE component IN ('CLAUDE_MD', 'MEMORY')`)
93
+ .all();
94
+ if (rows.length === 0) {
95
+ return {
96
+ fired: [],
97
+ status: "NOT_EVALUATED",
98
+ note: inventoryCount === 0
99
+ ? "no context_inventory rows — run ContextInventoryProbe first"
100
+ : "no evaluable context_inventory rows — catalog estimates are gated behind D10 load-state telemetry",
101
+ };
102
+ }
103
+ // Count active (non-global) workspaces for the global multiplier.
104
+ const activeWorkspaceCount = db
105
+ .prepare("SELECT COUNT(*) AS n FROM workspaces WHERE workspace_id != ?")
106
+ .get(GLOBAL_WORKSPACE_ID).n;
107
+ // Turn metrics for ALL non-global workspaces (used for global sources).
108
+ const globalMetrics = db
109
+ .prepare(`SELECT COUNT(*) AS turns_per_week,
110
+ COALESCE(SUM(cache_read_tokens), 0) AS cache_read_tokens,
111
+ COALESCE(SUM(cache_read_tokens *
112
+ (SELECT CAST(json_extract(ps.unit_prices_json, '$[2]') AS REAL)
113
+ FROM pricing_snapshots ps
114
+ WHERE ps.snapshot_id = t.pricing_snapshot_id)), 0) AS cache_read_spend_u
115
+ FROM turns t
116
+ WHERE t.workspace_id != ? AND t.ts >= ? AND t.ts < ?`)
117
+ .get(GLOBAL_WORKSPACE_ID, ctx.fromIso, ctx.toIso);
118
+ const fired = [];
119
+ for (const row of rows) {
120
+ const target = D1_SOURCE_TARGETS[row.component];
121
+ if (target === undefined)
122
+ continue; // unsupported component — skip
123
+ if (row.tokens <= target)
124
+ continue; // within target — no rec
125
+ const delta = row.tokens - target;
126
+ const isGlobal = row.workspace_id === GLOBAL_WORKSPACE_ID;
127
+ const scope = isGlobal ? "global" : "workspace";
128
+ // Turn metrics: global sources use all-workspace aggregate; per-workspace
129
+ // sources query only their own workspace.
130
+ let metrics;
131
+ if (isGlobal) {
132
+ metrics = globalMetrics;
133
+ }
134
+ else {
135
+ metrics = db
136
+ .prepare(`SELECT COUNT(*) AS turns_per_week,
137
+ COALESCE(SUM(cache_read_tokens), 0) AS cache_read_tokens,
138
+ COALESCE(SUM(cache_read_tokens *
139
+ (SELECT CAST(json_extract(ps.unit_prices_json, '$[2]') AS REAL)
140
+ FROM pricing_snapshots ps
141
+ WHERE ps.snapshot_id = t.pricing_snapshot_id)), 0) AS cache_read_spend_u
142
+ FROM turns t
143
+ WHERE t.workspace_id = ? AND t.ts >= ? AND t.ts < ?`)
144
+ .get(row.workspace_id, ctx.fromIso, ctx.toIso);
145
+ }
146
+ // Effective turns: for global, the total already spans all workspaces.
147
+ // workspace_multiplier is stored in evidence for display transparency.
148
+ const effectiveTurns = isGlobal ? metrics.turns_per_week : metrics.turns_per_week;
149
+ const blendedPrice = metrics.cache_read_tokens > 0
150
+ ? metrics.cache_read_spend_u / metrics.cache_read_tokens
151
+ : OPUS_CACHE_READ_PRICE;
152
+ const { savingsU, formula } = d1Savings(delta, effectiveTurns, Number(blendedPrice.toFixed(6)));
153
+ // Skip dormant workspaces: no turns in-window ⇒ zero modeled savings this week,
154
+ // so the rec would be noise ("prune memory · 0 tokens/wk freed"). Latent bloat in
155
+ // an inactive workspace resurfaces on a later pass once it has activity again.
156
+ if (savingsU === 0)
157
+ continue;
158
+ const fileBasename = path.basename(row.file_ref);
159
+ // scopeKey uses workspace_id (or 'global' for __global__) to keep it unique
160
+ // across workspaces with the same component+basename.
161
+ const scopeSegment = isGlobal ? "global" : row.workspace_id;
162
+ const scopeKey = `D1|${scopeSegment}|${row.component}|${fileBasename}`;
163
+ const evidenceBase = {
164
+ title: titleFor(row.component, row.tokens, target),
165
+ component: row.component,
166
+ file_ref: row.file_ref,
167
+ source_tokens: row.tokens,
168
+ source_target: target,
169
+ delta_context_tokens: delta,
170
+ turns_per_week: effectiveTurns,
171
+ modeled_savings_basis: "LIST_EQUIV",
172
+ billed_cost_claim: "UNAVAILABLE",
173
+ scope,
174
+ steps: stepsFor(row.component, row.file_ref),
175
+ };
176
+ if (isGlobal) {
177
+ evidenceBase.workspace_multiplier = activeWorkspaceCount;
178
+ }
179
+ fired.push({
180
+ scopeKey,
181
+ category: "CONTEXT",
182
+ scope_workspace_id: isGlobal ? null : row.workspace_id,
183
+ lever: leverFor(row.component),
184
+ target_metric: "avg_context_per_turn",
185
+ modeled_savings_u_per_wk: savingsU,
186
+ modeled_formula: formula,
187
+ evidence: evidenceBase,
188
+ });
189
+ }
190
+ if (fired.length === 0) {
191
+ return {
192
+ fired: [],
193
+ status: "INACTIVE",
194
+ note: "all context_inventory sources within per-source targets",
195
+ };
196
+ }
197
+ return {
198
+ fired,
199
+ status: "ACTIVE",
200
+ note: `${fired.length} source(s) exceed their per-source target (CLAUDE_MD=${D1_SOURCE_TARGETS.CLAUDE_MD}, MEMORY=${D1_SOURCE_TARGETS.MEMORY}); catalog estimates are gated behind D10 load-state telemetry`,
201
+ };
202
+ },
203
+ };
@@ -0,0 +1,119 @@
1
+ /**
2
+ * src/detector/detectors/d2_session_long_full_context.ts — D2 SESSION_LONG_FULL_CONTEXT.
3
+ *
4
+ * Fires (global scope) when >= N qualifying long-context sessions appear in the
5
+ * trailing week. Qualifying = more than 150 reconciled turns in the same
6
+ * evaluation window AND AVG(context_tokens) > 180_000 over those same rows.
7
+ *
8
+ * Savings: cache-read spend the qualifying sessions generate × an (unvalidated)
9
+ * reduction fraction. The cache-read price is derived PER-MODEL from each turn's
10
+ * own pricing snapshot (unit_prices_json[2]), never a hard-coded scalar (§1 D2 (b)).
11
+ */
12
+ import { capWeightedTokens, resolveCapReadCoeff } from "../../query/cap-weighted.js";
13
+ import { D2_AVG_CONTEXT_THRESHOLD, D2_MIN_QUALIFYING_SESSIONS, D2_REDUCTION_FRACTION, D2_TURN_COUNT_THRESHOLD, d2Savings, } from "../savings.js";
14
+ /** Cache-read price is index 2 of unit_prices_json ([in, out, cacheRead, cw5m, cw1h]). */
15
+ function cacheReadPrice(unitPricesJson) {
16
+ if (unitPricesJson === null)
17
+ return 0;
18
+ try {
19
+ const arr = JSON.parse(unitPricesJson);
20
+ return arr[2] ?? 0;
21
+ }
22
+ catch {
23
+ return 0;
24
+ }
25
+ }
26
+ export const d2Detector = {
27
+ id: "D2",
28
+ name: "SESSION_LONG_FULL_CONTEXT",
29
+ evaluate(db, ctx) {
30
+ const qualifying = db
31
+ .prepare(`SELECT t.session_id AS session_id,
32
+ AVG(t.context_tokens) AS avg_context,
33
+ COUNT(*) AS qualifying_turns
34
+ FROM turns t
35
+ WHERE t.ts >= ? AND t.ts < ? AND t.provisional = 0
36
+ GROUP BY t.session_id
37
+ HAVING COUNT(*) > ? AND AVG(t.context_tokens) > ?
38
+ ORDER BY t.session_id ASC`)
39
+ .all(ctx.fromIso, ctx.toIso, D2_TURN_COUNT_THRESHOLD, D2_AVG_CONTEXT_THRESHOLD);
40
+ if (qualifying.length < D2_MIN_QUALIFYING_SESSIONS) {
41
+ return {
42
+ fired: [],
43
+ status: "INACTIVE",
44
+ note: `${qualifying.length} qualifying long-context session(s) in window (trigger ≥ ${D2_MIN_QUALIFYING_SESSIONS})`,
45
+ };
46
+ }
47
+ const sessionIds = qualifying.map((q) => q.session_id).sort();
48
+ const placeholders = sessionIds.map(() => "?").join(",");
49
+ const qualifyingTurnCount = qualifying.reduce((sum, session) => sum + session.qualifying_turns, 0);
50
+ const rawContextAverageTokensPerTurn = qualifyingTurnCount > 0
51
+ ? qualifying.reduce((sum, session) => sum + session.avg_context * session.qualifying_turns, 0) / qualifyingTurnCount
52
+ : 0;
53
+ // Cap-weighted burn is a separate annotation on the raw-context rec. It
54
+ // never participates in the qualifying-session gate above.
55
+ const capReadCoefficient = resolveCapReadCoeff(db);
56
+ const capBySession = new Map(capWeightedTokens(db, {
57
+ fromIso: ctx.fromIso,
58
+ toIso: ctx.toIso,
59
+ groupBy: "session_id",
60
+ coeff: capReadCoefficient,
61
+ }).map((row) => [row.group, row.cap_weighted_tokens]));
62
+ const capWeightedBurnTokensPerWeek = sessionIds.reduce((sum, sessionId) => sum + (capBySession.get(sessionId) ?? 0), 0);
63
+ // Cache-read spend over the qualifying sessions' turns in the window, priced
64
+ // per-model from each turn's own pricing snapshot.
65
+ const rows = db
66
+ .prepare(`SELECT t.cache_read_tokens AS cache_read_tokens, ps.unit_prices_json AS unit_prices_json
67
+ FROM turns t
68
+ LEFT JOIN pricing_snapshots ps ON ps.snapshot_id = t.pricing_snapshot_id
69
+ WHERE t.session_id IN (${placeholders})
70
+ AND t.ts >= ? AND t.ts < ? AND t.provisional = 0`)
71
+ .all(...sessionIds, ctx.fromIso, ctx.toIso);
72
+ let cacheReadTokensPerWeek = 0;
73
+ let cacheReadSpendU = 0;
74
+ for (const r of rows) {
75
+ cacheReadTokensPerWeek += r.cache_read_tokens;
76
+ cacheReadSpendU += r.cache_read_tokens * cacheReadPrice(r.unit_prices_json);
77
+ }
78
+ const { savingsU, formula } = d2Savings(cacheReadTokensPerWeek, cacheReadSpendU, D2_REDUCTION_FRACTION);
79
+ const n = qualifying.length;
80
+ const fired = {
81
+ scopeKey: `D2|global|${formula.model}`,
82
+ category: "CONTEXT",
83
+ scope_workspace_id: null,
84
+ lever: "/clear between unrelated tasks; split long work; avoid mid-task /compact.",
85
+ target_metric: "avg_context_per_turn",
86
+ modeled_savings_u_per_wk: savingsU,
87
+ modeled_formula: formula,
88
+ evidence: {
89
+ title: `Shorten sessions: ${n} long-context run${n === 1 ? "" : "s"} this week`,
90
+ qualifying_session_count: qualifying.length,
91
+ qualifying_turn_count: qualifyingTurnCount,
92
+ session_ids: sessionIds,
93
+ denominator: "reconciled turns in the evaluation window",
94
+ turn_count_threshold: D2_TURN_COUNT_THRESHOLD,
95
+ avg_context_threshold: D2_AVG_CONTEXT_THRESHOLD,
96
+ raw_context_average_tokens_per_turn: Number(rawContextAverageTokensPerTurn.toFixed(2)),
97
+ raw_context_basis: "RAW_USAGE",
98
+ cap_weighted_burn_tokens_per_week: capWeightedBurnTokensPerWeek,
99
+ cap_weighted_burn_basis: "CAP_PROXY",
100
+ cap_read_coefficient: capReadCoefficient,
101
+ cap_read_coefficient_unvalidated: true,
102
+ cache_read_tokens_per_week: cacheReadTokensPerWeek,
103
+ cache_read_spend_u_per_week: Math.round(cacheReadSpendU),
104
+ cache_read_exposure_tokens_per_week: cacheReadTokensPerWeek,
105
+ cache_read_exposure_spend_u_per_week: Math.round(cacheReadSpendU),
106
+ cache_read_exposure_spend_basis: "LIST_EQUIV",
107
+ modeled_savings_basis: "LIST_EQUIV",
108
+ billed_cost_claim: "UNAVAILABLE",
109
+ reduction_fraction: D2_REDUCTION_FRACTION,
110
+ thresholds_unvalidated: true,
111
+ },
112
+ };
113
+ return {
114
+ fired: [fired],
115
+ status: "ACTIVE",
116
+ note: `${qualifying.length} qualifying long-context sessions this week`,
117
+ };
118
+ },
119
+ };