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,234 @@
1
+ /**
2
+ * src/query/api/spend-flavor.ts — Token-flavor decomposition + cache efficiency.
3
+ *
4
+ * Surface 1 (§1): getFlavorDecomposition() — breakdown by token flavor with cap-proxy weights.
5
+ * Surface 2 (§2): getCacheEfficiency() — cache read:write ratio diagnostic.
6
+ *
7
+ * Subscription cap-proxy weights displayed:
8
+ * fresh input 1×
9
+ * output 1×
10
+ * cache writes 1×
11
+ * cache read 0.1× (UNVERIFIED coefficient)
12
+ *
13
+ * API list-equivalent dollars remain a separate pricing-snapshot metric. This view
14
+ * deliberately reconciles to capWeightedTokens(); it does not pretend that output
15
+ * tokens share the model's input price or apply API write premiums to a subscription cap.
16
+ *
17
+ * Vocabulary: docs/plans/blog-dashboard-taxonomy-IA.md §1a
18
+ */
19
+ import { capWeightedTokens, resolveCapReadCoeff } from "../cap-weighted.js";
20
+ import { getQueryDb } from "../db-context.js";
21
+ import { buildResponse } from "../envelope.js";
22
+ /** Display labels — verbatim from taxonomy §1a / A1 §04 */
23
+ const FLAVOR_LABELS = {
24
+ fresh_input: "fresh input",
25
+ output: "output (incl. thinking)",
26
+ cache_write_5m: "cache write (5 min)",
27
+ cache_write_1h: "cache write (1 hr)",
28
+ cache_write_other: "cache write (unspecified)",
29
+ cache_read: "cache read",
30
+ };
31
+ /** Cap-proxy weights. API prices are intentionally not represented by this chart. */
32
+ const CAP_WEIGHTS = {
33
+ fresh_input: 1.0,
34
+ output: 1.0,
35
+ cache_write_5m: 1.0,
36
+ cache_write_1h: 1.0,
37
+ cache_write_other: 1.0,
38
+ cache_read: 0.1, // default; overridden by coeff at runtime
39
+ };
40
+ // ---------------------------------------------------------------------------
41
+ // Reuse-band logic
42
+ // ---------------------------------------------------------------------------
43
+ /**
44
+ * Classify raw cache read:creation values without assigning a health label.
45
+ * A zero creation denominator remains distinct from no data and reuse dominance.
46
+ */
47
+ export function classifyCacheReuseBand(cacheReadTokens, cacheCreationTokens, turns) {
48
+ if (turns === 0)
49
+ return "NO_DATA";
50
+ if (cacheCreationTokens === 0)
51
+ return "NO_DENOMINATOR";
52
+ const readToCreation = cacheReadTokens / cacheCreationTokens;
53
+ if (readToCreation < 1)
54
+ return "WRITE_HEAVY";
55
+ if (readToCreation < 4)
56
+ return "MIXED_REUSE";
57
+ return "REUSE_DOMINANT";
58
+ }
59
+ /** @deprecated Use classifyCacheReuseBand with raw counts and turns. */
60
+ export const classifyCacheEfficiency = classifyCacheReuseBand;
61
+ // ---------------------------------------------------------------------------
62
+ // Window resolution (matches the pattern in trends.ts)
63
+ // ---------------------------------------------------------------------------
64
+ const PRESET_DAYS = {
65
+ "24h": 1,
66
+ "7d": 7,
67
+ "30d": 30,
68
+ };
69
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
70
+ function resolveWindow(filter, now = new Date()) {
71
+ const nowIso = now.toISOString();
72
+ if (filter.preset !== undefined) {
73
+ const from = new Date(now.getTime() - PRESET_DAYS[filter.preset] * MS_PER_DAY).toISOString();
74
+ return { from, to: nowIso, preset: filter.preset };
75
+ }
76
+ if (filter.from !== undefined && filter.to !== undefined) {
77
+ return { from: filter.from, to: filter.to };
78
+ }
79
+ if (filter.from !== undefined) {
80
+ return { from: filter.from, to: nowIso };
81
+ }
82
+ if (filter.to !== undefined) {
83
+ const from = new Date(new Date(filter.to).getTime() - 7 * MS_PER_DAY).toISOString();
84
+ return { from, to: filter.to };
85
+ }
86
+ const from = new Date(now.getTime() - 7 * MS_PER_DAY).toISOString();
87
+ return { from, to: nowIso, preset: "7d" };
88
+ }
89
+ const FLAVOR_SQL = `
90
+ SELECT
91
+ COALESCE(SUM(input_tokens), 0) AS raw_input,
92
+ COALESCE(SUM(output_tokens), 0) AS raw_output,
93
+ COALESCE(SUM(cache_write_5m), 0) AS raw_cw5m,
94
+ COALESCE(SUM(cache_write_1h), 0) AS raw_cw1h,
95
+ COALESCE(SUM(cache_write_other), 0) AS raw_cw_other,
96
+ COALESCE(SUM(cache_read_tokens), 0) AS raw_cr,
97
+ COUNT(*) AS turns
98
+ FROM turns
99
+ WHERE ts >= ? AND ts < ? AND provisional = 0
100
+ `;
101
+ // ---------------------------------------------------------------------------
102
+ // Internal helpers
103
+ // ---------------------------------------------------------------------------
104
+ function buildFlavors(agg, coeff) {
105
+ const raws = {
106
+ fresh_input: agg.raw_input,
107
+ output: agg.raw_output,
108
+ cache_write_5m: agg.raw_cw5m,
109
+ cache_write_1h: agg.raw_cw1h,
110
+ cache_write_other: agg.raw_cw_other,
111
+ cache_read: agg.raw_cr,
112
+ };
113
+ const weights = {
114
+ ...CAP_WEIGHTS,
115
+ cache_read: coeff, // override with resolved coeff
116
+ };
117
+ const totalRaw = Object.values(raws).reduce((s, v) => s + v, 0);
118
+ const totalWeighted = Object.keys(raws).reduce((s, k) => s + raws[k] * weights[k], 0);
119
+ const keys = [
120
+ "fresh_input",
121
+ "output",
122
+ "cache_write_5m",
123
+ "cache_write_1h",
124
+ "cache_write_other",
125
+ "cache_read",
126
+ ];
127
+ return keys.map((flavor) => {
128
+ const raw_tokens = raws[flavor];
129
+ const weight = weights[flavor];
130
+ const weighted_tokens = raw_tokens * weight;
131
+ return {
132
+ flavor,
133
+ label: FLAVOR_LABELS[flavor],
134
+ weight,
135
+ raw_tokens,
136
+ weighted_tokens,
137
+ weighted_share: totalWeighted > 0 ? weighted_tokens / totalWeighted : 0,
138
+ raw_share: totalRaw > 0 ? raw_tokens / totalRaw : 0,
139
+ };
140
+ });
141
+ }
142
+ // ---------------------------------------------------------------------------
143
+ // Public API methods
144
+ // ---------------------------------------------------------------------------
145
+ /**
146
+ * Token-flavor decomposition over a window.
147
+ *
148
+ * Endpoint: GET /api/overview/flavor?preset=7d
149
+ *
150
+ * claim_kind: PROXY (reconciled turns only, provisional=0).
151
+ *
152
+ * Internal reconciliation: total_weighted_tokens === SUM(flavors[].weighted_tokens).
153
+ * total_weighted_tokens uses the same cap-proxy weights as capWeightedTokens().
154
+ */
155
+ export function getFlavorDecomposition(filter) {
156
+ const db = getQueryDb();
157
+ const win = resolveWindow(filter);
158
+ const { from, to } = win;
159
+ const coeff = resolveCapReadCoeff(db);
160
+ const agg = db.prepare(FLAVOR_SQL).get(from, to);
161
+ const flavors = buildFlavors(agg, coeff);
162
+ const total_raw_tokens = flavors.reduce((s, f) => s + f.raw_tokens, 0);
163
+ const total_weighted_tokens = flavors.reduce((s, f) => s + f.weighted_tokens, 0);
164
+ const cacheCreation = agg.raw_cw5m + agg.raw_cw1h + agg.raw_cw_other;
165
+ const cacheTotal = agg.raw_cr + cacheCreation;
166
+ const cache_efficiency_ratio = cacheTotal > 0 ? agg.raw_cr / cacheTotal : null;
167
+ const capRow = capWeightedTokens(db, { fromIso: from, toIso: to, coeff })[0];
168
+ const data = {
169
+ flavors,
170
+ total_raw_tokens,
171
+ total_weighted_tokens,
172
+ cache_efficiency_ratio,
173
+ cache_read_share: cache_efficiency_ratio,
174
+ cache_read_tokens: agg.raw_cr,
175
+ cache_creation_tokens: cacheCreation,
176
+ reuse_band: classifyCacheReuseBand(agg.raw_cr, cacheCreation, agg.turns),
177
+ cap_weighted_tokens: capRow?.cap_weighted_tokens ?? 0,
178
+ coeff_used: coeff,
179
+ coeff_unverified: true,
180
+ turns: agg.turns,
181
+ };
182
+ return buildResponse(data, {
183
+ claim_kind: "PROXY",
184
+ n: 6,
185
+ window: win,
186
+ qualification: {
187
+ provisional_excluded: true,
188
+ unpriced_turns: 0,
189
+ claim_kinds_count: 1,
190
+ note: "",
191
+ },
192
+ });
193
+ }
194
+ /**
195
+ * Cache read:write efficiency KPI over a window.
196
+ *
197
+ * Derives from capWeightedTokens() — no additional SQL.
198
+ * Uses raw token counts (not pricing-weighted) so the ratio is exact.
199
+ *
200
+ * Endpoint: GET /api/overview/flavor (embedded in FlavorDecomposition).
201
+ * Also exported as a standalone function for independent use.
202
+ */
203
+ export function getCacheEfficiency(filter) {
204
+ const db = getQueryDb();
205
+ const win = resolveWindow(filter);
206
+ const { from, to } = win;
207
+ const rows = capWeightedTokens(db, { fromIso: from, toIso: to });
208
+ const row = rows[0];
209
+ const cache_read_tokens = row?.cache_read_tokens ?? 0;
210
+ const cache_creation_tokens = row?.cache_creation_tokens ?? 0;
211
+ const cacheTotal = cache_read_tokens + cache_creation_tokens;
212
+ const ratio = cacheTotal > 0 ? cache_read_tokens / cacheTotal : null;
213
+ const data = {
214
+ ratio,
215
+ cache_read_tokens,
216
+ cache_creation_tokens,
217
+ reuse_band: classifyCacheReuseBand(cache_read_tokens, cache_creation_tokens, row?.turns ?? 0),
218
+ cap_weighted_tokens: row?.cap_weighted_tokens ?? 0,
219
+ coeff_used: resolveCapReadCoeff(db),
220
+ coeff_unverified: true,
221
+ turns: row?.turns ?? 0,
222
+ };
223
+ return buildResponse(data, {
224
+ claim_kind: "PROXY",
225
+ n: 1,
226
+ window: win,
227
+ qualification: {
228
+ provisional_excluded: true,
229
+ unpriced_turns: 0,
230
+ claim_kinds_count: 1,
231
+ note: "",
232
+ },
233
+ });
234
+ }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * src/query/api/trends.ts — LocalQueryAPI: spend-over-time trends method.
3
+ *
4
+ * Mirrors the window resolution and response-building pattern in overview.ts.
5
+ * The FROZEN overview contract (api/index.ts) is not modified by this file.
6
+ */
7
+ import { resolveCapReadCoeff } from "../cap-weighted.js";
8
+ import { getQueryDb } from "../db-context.js";
9
+ import { buildResponse } from "../envelope.js";
10
+ import { cacheWriteByBucket, capWeightedByBucket, sessionCostSeries, spendByBucket, spendByBucketAndModel, spendByBucketAndWorkspace, } from "../trends.js";
11
+ import { cachedQuery } from "./overview.js";
12
+ /**
13
+ * Detect spike buckets: mean + 2 × population stddev.
14
+ * Returns empty set when fewer than 3 buckets (insufficient baseline).
15
+ */
16
+ export function detectSpikes(rows) {
17
+ if (rows.length < 3)
18
+ return new Set();
19
+ const vals = rows.map((r) => r.cache_creation_tokens);
20
+ const mean = vals.reduce((s, v) => s + v, 0) / vals.length;
21
+ const variance = vals.reduce((s, v) => s + (v - mean) ** 2, 0) / vals.length;
22
+ const stddev = Math.sqrt(variance);
23
+ const threshold = mean + 2 * stddev;
24
+ return new Set(rows.filter((r) => r.cache_creation_tokens > threshold).map((r) => r.bucket));
25
+ }
26
+ // ---------------------------------------------------------------------------
27
+ // Internal helpers (mirrors overview.ts pattern, not re-exported from index)
28
+ // ---------------------------------------------------------------------------
29
+ const PRESET_DAYS = {
30
+ "24h": 1,
31
+ "7d": 7,
32
+ "30d": 30,
33
+ };
34
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
35
+ function adoptionBucketExpr(bucket) {
36
+ if (bucket === "day")
37
+ return "date(adopted_at, 'localtime')";
38
+ if (bucket === "week")
39
+ return "strftime('%Y-%W', adopted_at)";
40
+ return "strftime('%Y-%m', adopted_at)";
41
+ }
42
+ function adoptionMarkers(db, from, to, bucket, workspaceId) {
43
+ const bucketSql = adoptionBucketExpr(bucket);
44
+ const base = `SELECT rec_id, detector_id, lever, adopted_at, ${bucketSql} AS bucket
45
+ FROM recommendations
46
+ WHERE adopted_at IS NOT NULL
47
+ AND adopted_at >= ? AND adopted_at < ?
48
+ AND state IN ('ADOPTED', 'MEASURING', 'MEASURED_EFFECTIVE',
49
+ 'MEASURED_NO_EFFECT')`;
50
+ const tail = "ORDER BY adopted_at ASC, rec_id ASC";
51
+ if (workspaceId !== undefined) {
52
+ return db
53
+ .prepare(`${base} AND scope_workspace_id = ? ${tail}`)
54
+ .all(from, to, workspaceId);
55
+ }
56
+ return db.prepare(`${base} ${tail}`).all(from, to);
57
+ }
58
+ function resolveWindow(filter, now = new Date()) {
59
+ const nowIso = now.toISOString();
60
+ if (filter.preset !== undefined) {
61
+ const from = new Date(now.getTime() - PRESET_DAYS[filter.preset] * MS_PER_DAY).toISOString();
62
+ return { from, to: nowIso, preset: filter.preset };
63
+ }
64
+ if (filter.from !== undefined && filter.to !== undefined) {
65
+ return { from: filter.from, to: filter.to };
66
+ }
67
+ if (filter.from !== undefined) {
68
+ return { from: filter.from, to: nowIso };
69
+ }
70
+ if (filter.to !== undefined) {
71
+ const from = new Date(new Date(filter.to).getTime() - 7 * MS_PER_DAY).toISOString();
72
+ return { from, to: filter.to };
73
+ }
74
+ const from = new Date(now.getTime() - 7 * MS_PER_DAY).toISOString();
75
+ return { from, to: nowIso, preset: "7d" };
76
+ }
77
+ // ---------------------------------------------------------------------------
78
+ // Public API method
79
+ // ---------------------------------------------------------------------------
80
+ /**
81
+ * Cache-write spike timeline over a window.
82
+ *
83
+ * Endpoint: GET /api/trends/cache-write?preset=7d&bucket=day
84
+ *
85
+ * Reconciliation: SUM(data.buckets[].cache_creation_tokens) equals
86
+ * cache_creation_tokens from capWeightedTokens() for the same window.
87
+ *
88
+ * spike_buckets: server-computed via detectSpikes() (mean + 2σ).
89
+ * Skipped when < 3 buckets in the window.
90
+ */
91
+ export function getCacheWriteTrend(filter, bucket = "day", workspaceId) {
92
+ const db = getQueryDb();
93
+ const win = resolveWindow(filter);
94
+ const { from, to } = win;
95
+ const buckets = cacheWriteByBucket(db, from, to, bucket, workspaceId);
96
+ const spikes = detectSpikes(buckets);
97
+ const data = {
98
+ buckets,
99
+ spike_buckets: [...spikes],
100
+ };
101
+ return buildResponse(data, {
102
+ claim_kind: "LIST_EQUIV",
103
+ n: buckets.length,
104
+ window: win,
105
+ qualification: {
106
+ provisional_excluded: true,
107
+ unpriced_turns: 0,
108
+ claim_kinds_count: 1,
109
+ note: "",
110
+ },
111
+ });
112
+ }
113
+ /**
114
+ * Fetch spend-over-time data grouped by the requested bucket size.
115
+ *
116
+ * Endpoint: GET /api/trends?preset=&bucket=day|week|month&workspace_id=
117
+ *
118
+ * Reconciliation: SUM(data.buckets[].cost_equiv_u) equals the cost_equiv_u
119
+ * returned by GET /api/overview for the same window (both exclude provisional).
120
+ */
121
+ export function getTrends(filter, bucket = "day", workspaceId) {
122
+ const db = getQueryDb();
123
+ const win = resolveWindow(filter);
124
+ const { from, to } = win;
125
+ const coeff = cachedQuery(db, "resolveCapReadCoeff", from, to, workspaceId, () => resolveCapReadCoeff(db));
126
+ const buckets = cachedQuery(db, `spendByBucket:${bucket}`, from, to, workspaceId, () => spendByBucket(db, from, to, bucket, workspaceId));
127
+ const by_model = cachedQuery(db, `spendByBucketAndModel:${bucket}`, from, to, workspaceId, () => spendByBucketAndModel(db, from, to, bucket, workspaceId));
128
+ const by_workspace = workspaceId === undefined
129
+ ? cachedQuery(db, `spendByBucketAndWorkspace:${bucket}`, from, to, undefined, () => spendByBucketAndWorkspace(db, from, to, bucket))
130
+ : [];
131
+ const sessions = cachedQuery(db, "sessionCostSeries", from, to, workspaceId, () => sessionCostSeries(db, from, to, workspaceId));
132
+ const cap_weighted = cachedQuery(db, `capWeightedByBucket:${bucket}`, from, to, workspaceId, () => capWeightedByBucket(db, from, to, bucket, coeff, workspaceId));
133
+ const adoption_markers = cachedQuery(db, `adoptionMarkers:${bucket}`, from, to, workspaceId, () => adoptionMarkers(db, from, to, bucket, workspaceId));
134
+ const data = {
135
+ bucket,
136
+ buckets,
137
+ by_model,
138
+ by_workspace,
139
+ sessions,
140
+ cap_weighted,
141
+ cap_read_coeff: coeff,
142
+ adoption_markers,
143
+ };
144
+ return buildResponse(data, {
145
+ claim_kind: "LIST_EQUIV",
146
+ n: buckets.length,
147
+ window: win,
148
+ qualification: {
149
+ provisional_excluded: true,
150
+ unpriced_turns: 0,
151
+ claim_kinds_count: 1,
152
+ note: "",
153
+ },
154
+ });
155
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * src/query/cap-weighted.ts — cap-weighted token meter (Data Model v2 §2A).
3
+ *
4
+ * A single scalar that estimates a turn's draw on the *rate-limit cap* (not its
5
+ * dollar cost). It exists because the frozen `context_tokens` generated column
6
+ * and the §2 spend rollups sum `input + cache_read + cache_write_*` at FULL
7
+ * weight, which over-counts cache reads ~10× for cap attribution (economics
8
+ * brief verdict A2). This module is ADDITIVE and QUERY-SIDE: it never alters
9
+ * `context_tokens` (frozen) and adds nothing to the schema.
10
+ *
11
+ * cap_weighted_tokens
12
+ * = full(cache_write_5m + cache_write_1h + cache_write_other) -- cache CREATES: full weight
13
+ * + COEFF × cache_read_tokens -- cached re-reads: ~0.1× (unverified)
14
+ * + full(input_tokens + output_tokens) -- fresh input + generation: full weight
15
+ *
16
+ * COEFF lives in `user_config.cap_read_coeff` (TEXT; cast to REAL), default 0.1,
17
+ * carrying a visible "unverified" caveat wherever a cap-weighted figure is
18
+ * surfaced. Both regimes ship — 0.1× (default) and 1.0× (upper bound) — selected
19
+ * at runtime by passing `coeff` explicitly.
20
+ *
21
+ * The weighting expression is exported once (`capWeightExprSql` / `capWeightForTurn`)
22
+ * so the D8/D6/D9 detectors reuse a single source of truth instead of re-deriving it.
23
+ */
24
+ /**
25
+ * Default cap-read coefficient. UNVERIFIED — Anthropic has not published a cap
26
+ * coefficient for cache reads. Surface the caveat wherever a cap-weighted figure
27
+ * is shown. Callers may override with the 1.0× upper-bound regime.
28
+ */
29
+ export const DEFAULT_CAP_READ_COEFF = 0.1;
30
+ /**
31
+ * Resolve the cap-read coefficient from `user_config.cap_read_coeff`.
32
+ * Falls back to DEFAULT_CAP_READ_COEFF when the row is absent or unparseable
33
+ * (mirrors the `limit_tokens` read pattern — config rows are not seeded by a
34
+ * migration, callers default). Never throws.
35
+ */
36
+ export function resolveCapReadCoeff(db) {
37
+ const row = db.prepare("SELECT value FROM user_config WHERE key = 'cap_read_coeff'").get();
38
+ if (!row || row.value === null)
39
+ return DEFAULT_CAP_READ_COEFF;
40
+ // Treat empty/whitespace-only as absent.
41
+ const trimmed = row.value.trim();
42
+ if (trimmed === "")
43
+ return DEFAULT_CAP_READ_COEFF;
44
+ const n = Number(trimmed);
45
+ // Valid range: (0, 1] — a cap-read weight can't be ≤0 or exceed full weight.
46
+ if (!Number.isFinite(n) || n <= 0 || n > 1)
47
+ return DEFAULT_CAP_READ_COEFF;
48
+ return n;
49
+ }
50
+ /**
51
+ * The per-turn cap-weighted token SQL expression for a `turns` row aliased `alias`.
52
+ * `coeff` is validated and inlined as a numeric literal so callers keep using
53
+ * anonymous `?` params for their own predicates (better-sqlite3 forbids mixing
54
+ * named and anonymous params in one statement). Deliberately NOT `context_tokens`.
55
+ */
56
+ export function capWeightExprSql(alias, coeff) {
57
+ if (!Number.isFinite(coeff)) {
58
+ throw new Error(`capWeightExprSql: coeff must be finite, got ${coeff}`);
59
+ }
60
+ return (`((${alias}.cache_write_5m + ${alias}.cache_write_1h + ${alias}.cache_write_other)` +
61
+ ` + ${coeff} * ${alias}.cache_read_tokens` +
62
+ ` + (${alias}.input_tokens + ${alias}.output_tokens))`);
63
+ }
64
+ /** Pure JS mirror of the SQL expression — for unit tests and in-memory callers. */
65
+ export function capWeightForTurn(t, coeff) {
66
+ return (t.cache_write_5m +
67
+ t.cache_write_1h +
68
+ t.cache_write_other +
69
+ coeff * t.cache_read_tokens +
70
+ (t.input_tokens + t.output_tokens));
71
+ }
72
+ /** Whitelisted GROUP BY dimensions (guards against SQL injection via `groupBy`). */
73
+ const GROUP_COLUMNS = {
74
+ workspace_id: "t.workspace_id",
75
+ session_id: "t.session_id",
76
+ model: "t.model",
77
+ };
78
+ /**
79
+ * Cap-weighted tokens over [fromIso, toIso), optionally grouped by
80
+ * workspace_id / session_id / model. Reads the raw usage fields directly; never
81
+ * references `context_tokens`. Returns a single group=null row when `groupBy` is
82
+ * omitted. This is the entry point T5/T6/T2 consume.
83
+ */
84
+ export function capWeightedTokens(db, opts) {
85
+ const coeff = opts.coeff ?? resolveCapReadCoeff(db);
86
+ const expr = capWeightExprSql("t", coeff);
87
+ const groupCol = opts.groupBy ? GROUP_COLUMNS[opts.groupBy] : null;
88
+ const selectGroup = groupCol ?? "NULL";
89
+ const provClause = opts.includeProvisional ? "" : "AND t.provisional = 0";
90
+ const groupByClause = groupCol ? `GROUP BY ${groupCol}` : "";
91
+ const rows = db
92
+ .prepare(`SELECT ${selectGroup} AS "group",
93
+ SUM(${expr}) AS cap_weighted_raw,
94
+ COALESCE(SUM(t.cache_write_5m + t.cache_write_1h
95
+ + t.cache_write_other), 0) AS cache_creation_tokens,
96
+ COALESCE(SUM(t.cache_read_tokens), 0) AS cache_read_tokens,
97
+ COALESCE(SUM(t.input_tokens + t.output_tokens), 0) AS input_output_tokens,
98
+ COUNT(*) AS turns
99
+ FROM turns t
100
+ WHERE t.ts >= ? AND t.ts < ? ${provClause}
101
+ ${groupByClause}
102
+ ORDER BY cap_weighted_raw DESC, "group" ASC`)
103
+ .all(opts.fromIso, opts.toIso);
104
+ // A no-group query always yields one row (aggregate over zero turns → group=null,
105
+ // sums 0). Drop that empty global row so callers see an empty array when there is
106
+ // no data, matching the grouped case.
107
+ return rows
108
+ .filter((r) => r.turns > 0)
109
+ .map((r) => ({
110
+ group: r.group,
111
+ cap_weighted_tokens: Math.round(r.cap_weighted_raw ?? 0),
112
+ cache_creation_tokens: r.cache_creation_tokens,
113
+ cache_read_weighted: Math.round(coeff * r.cache_read_tokens),
114
+ cache_read_tokens: r.cache_read_tokens,
115
+ input_output_tokens: r.input_output_tokens,
116
+ cache_read_to_creation_ratio: r.cache_creation_tokens > 0 ? r.cache_read_tokens / r.cache_creation_tokens : null,
117
+ turns: r.turns,
118
+ }));
119
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * src/query/db-context.ts — DB handle for the LocalQueryAPI read layer.
3
+ *
4
+ * WHY THIS EXISTS (interface note for reviewers):
5
+ * The frozen LocalQueryAPI method signatures (query/api/overview.ts) take no
6
+ * `db` parameter, and the daemon router (src/daemon/router.ts, off-limits to
7
+ * WP2) calls them without forwarding the `db` it receives. So the query layer
8
+ * needs its own way to reach the database. This module provides it:
9
+ *
10
+ * - Tests call `setQueryDb(fixtureDb)` to inject the seeded fixture DB.
11
+ * - In production, `getQueryDb()` lazily opens a read connection to the same
12
+ * on-disk DB file the daemon uses (resolved via daemon config / AW_DB_PATH).
13
+ * better-sqlite3 in WAL mode supports multiple connections to one file, so
14
+ * a second read-only-in-practice connection alongside the daemon's writer
15
+ * is safe.
16
+ *
17
+ * The lazy-open is only reached when no DB has been injected. Ideally the daemon
18
+ * boot would call `setQueryDb(db)` so the query layer shares the daemon's single
19
+ * connection — but that is a daemon/** change outside WP2's ownership. Flagged,
20
+ * not blocking: the lazy read connection is correct for the spend-path reads.
21
+ */
22
+ import { loadConfig } from "../daemon/config.js";
23
+ import { openDb } from "../db/open.js";
24
+ let active = null;
25
+ /** Inject the DB the query layer should use (tests; optional daemon wiring). */
26
+ export function setQueryDb(db) {
27
+ active = db;
28
+ }
29
+ /** Clear the injected DB (tests, between cases). Does not close the handle. */
30
+ export function resetQueryDb() {
31
+ active = null;
32
+ }
33
+ /**
34
+ * Return the active query DB, lazily opening the configured on-disk DB the
35
+ * first time if none was injected.
36
+ */
37
+ export function getQueryDb() {
38
+ if (active !== null)
39
+ return active;
40
+ active = openDb(loadConfig().dbPath);
41
+ return active;
42
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * src/query/envelope.ts — FROZEN API response envelope for the LocalQueryAPI.
3
+ *
4
+ * FROZEN in WP0 — changing this shape invalidates every downstream track;
5
+ * amend only via plan decision.
6
+ *
7
+ * All LocalQueryAPI methods return `ApiResponse<T>` so the UI always has the
8
+ * same structural envelope to read. The UI never inspects `meta` for business
9
+ * logic (that is the API's job); it uses it for display labels and drilldown.
10
+ *
11
+ * WP2 fills `overview.ts`, WP4 fills `settings.ts`. WP3 and WP4 build against
12
+ * this frozen shape — do not add fields silently.
13
+ */
14
+ // ---------------------------------------------------------------------------
15
+ // Builder
16
+ // ---------------------------------------------------------------------------
17
+ /**
18
+ * Default meta for stub responses (WP2/WP4 will replace with real values).
19
+ * The window defaults to the last 7 days.
20
+ */
21
+ function defaultWindow() {
22
+ const to = new Date();
23
+ const from = new Date(to.getTime() - 7 * 24 * 60 * 60 * 1000);
24
+ return {
25
+ from: from.toISOString(),
26
+ to: to.toISOString(),
27
+ preset: "7d",
28
+ };
29
+ }
30
+ function defaultQualification() {
31
+ return {
32
+ provisional_excluded: false,
33
+ unpriced_turns: 0,
34
+ claim_kinds_count: 1,
35
+ note: "",
36
+ };
37
+ }
38
+ /**
39
+ * Build a stub `ApiResponse<null>` for method stubs.
40
+ * WP2/WP4 replace `data` and fill in real `meta` values.
41
+ */
42
+ export function stubResponse(claim_kind = "N_A", drilldown_ids = {}) {
43
+ return {
44
+ data: null,
45
+ meta: {
46
+ n: 0,
47
+ window: defaultWindow(),
48
+ qualification: defaultQualification(),
49
+ metric_definition_version: "observe-1",
50
+ claim_kind,
51
+ drilldown_ids,
52
+ },
53
+ };
54
+ }
55
+ /**
56
+ * Build a complete `ApiResponse<T>` with the given data and meta overrides.
57
+ * Callers provide the parts they know; the rest gets safe defaults.
58
+ */
59
+ export function buildResponse(data, meta) {
60
+ return {
61
+ data,
62
+ meta: {
63
+ n: 0,
64
+ window: defaultWindow(),
65
+ qualification: defaultQualification(),
66
+ metric_definition_version: "observe-1",
67
+ drilldown_ids: {},
68
+ ...meta,
69
+ },
70
+ };
71
+ }