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,301 @@
1
+ /**
2
+ * src/detector/detectors/d6_tool_result_bloat.ts — D6 TOOL_RESULT_BLOAT.
3
+ *
4
+ * Fires PER-SESSION when a session carries a large volume of tool-result output
5
+ * (size only, never content — the privacy boundary holds). A tool_result rides
6
+ * in context and is re-processed at the cached rate on every later turn, so a
7
+ * bloated session pays a recurring carry cost.
8
+ *
9
+ * A session qualifies when (UNVALIDATED defaults, labeled in every rec):
10
+ * - summed tool_result_bytes ≥ 30% of the session's cap-weighted total, AND
11
+ * - summed tool_result_bytes ≥ 200 KB (absolute floor; skips tiny sessions).
12
+ * The detector only surfaces recs when ≥ 3 such sessions exist in the window
13
+ * (a recurrence gate, so one-off large dumps don't fire).
14
+ *
15
+ * When event metadata is available, D6 annotates the session with the largest
16
+ * recurring tool-name class and a directional carry estimate. This remains size-only:
17
+ * no tool input/output content is read or persisted.
18
+ *
19
+ * modeled_savings (R12 — opt-in calibration gate):
20
+ * modeled_savings_u_per_wk is non-null ONLY when a calibrated bytes_per_token
21
+ * is present in user_config (requires explicit opt-in). Without calibration it
22
+ * stays null — an honest default: we make no $ claim on the raw heuristic.
23
+ *
24
+ * scopeKey: "D6|<session_id>".
25
+ */
26
+ import { LIST_PRICES, modelTier } from "../../ingest/pricing.js";
27
+ import { capWeightedTokens } from "../../query/cap-weighted.js";
28
+ import { resolveBytesPerToken } from "../calibration.js";
29
+ import { D6_ABS_FLOOR_BYTES, D6_AVOIDANCE_FRACTION, D6_BLOAT_SHARE, D6_MIN_SESSIONS, d6Savings, } from "../savings.js";
30
+ /** Resolve cache-read price ($/MTok = µUSD/token) from a pricing snapshot or list price. */
31
+ function cacheReadPrice(unitPricesJson, model) {
32
+ if (unitPricesJson) {
33
+ try {
34
+ const arr = JSON.parse(unitPricesJson);
35
+ if (typeof arr[2] === "number")
36
+ return arr[2];
37
+ }
38
+ catch {
39
+ // fall through to list price
40
+ }
41
+ }
42
+ const tier = modelTier(model);
43
+ const lp = tier ? LIST_PRICES[tier] : undefined;
44
+ return lp ? lp[2] : 0;
45
+ }
46
+ /**
47
+ * Compute the blended cache-read price ($/MTok) for a session, weighted by
48
+ * cache_read_tokens across all turns — mirrors D8's blended write-price approach.
49
+ */
50
+ function blendedCacheReadPriceForSession(db, sessionId, fromIso, toIso) {
51
+ const rows = db
52
+ .prepare(`SELECT t.cache_read_tokens, t.model, ps.unit_prices_json
53
+ FROM turns t
54
+ LEFT JOIN pricing_snapshots ps ON ps.snapshot_id = t.pricing_snapshot_id
55
+ WHERE t.session_id = ? AND t.ts >= ? AND t.ts < ? AND t.provisional = 0`)
56
+ .all(sessionId, fromIso, toIso);
57
+ let totalCrTokens = 0;
58
+ let totalCrSpend = 0;
59
+ for (const r of rows) {
60
+ const price = cacheReadPrice(r.unit_prices_json, r.model);
61
+ totalCrTokens += r.cache_read_tokens;
62
+ totalCrSpend += r.cache_read_tokens * price;
63
+ }
64
+ return totalCrTokens > 0 ? totalCrSpend / totalCrTokens : 0;
65
+ }
66
+ function d6DirectionalFormula(toolResultBytes, bloatShare, sessionCapWeightedTokens, bytesPerToken) {
67
+ return {
68
+ model: "D6_TOOL_RESULT_BLOAT_V1",
69
+ kind: "DIRECTIONAL",
70
+ inputs: {
71
+ tool_result_bytes: toolResultBytes,
72
+ bytes_per_token: bytesPerToken,
73
+ bloat_share: Number(bloatShare.toFixed(4)),
74
+ session_cap_weighted_tokens: Math.round(sessionCapWeightedTokens),
75
+ },
76
+ expression: "tool_result_bytes / bytes_per_token compared with session_cap_weighted_tokens; heuristic directional exposure only (no avoidable-token or USD estimate)",
77
+ };
78
+ }
79
+ /** True only for SQLite "no such table/column" errors from an un-migrated DB. */
80
+ function isMissingSchemaError(error) {
81
+ return (error instanceof Error &&
82
+ error.code === "SQLITE_ERROR" &&
83
+ /no such (table|column)/i.test(error.message));
84
+ }
85
+ /** Keep event-provided tool identity structural and bounded. */
86
+ function boundedToolNameClass(toolName) {
87
+ const trimmed = toolName.trim();
88
+ return trimmed === "" ? null : trimmed.slice(0, 64);
89
+ }
90
+ function d6Attribution(db, sessionId, fromIso, toIso, bytesPerToken) {
91
+ const empty = {
92
+ attributed_tool: null,
93
+ attributed_result_bytes: null,
94
+ carry_turns: null,
95
+ carry_exposure_tokens_directional: null,
96
+ };
97
+ try {
98
+ const events = db
99
+ .prepare(`SELECT e.event_id, e.tool_name, e.result_bytes,
100
+ m.owner_message_id, m.block_index
101
+ FROM tool_events e
102
+ LEFT JOIN tool_event_metadata m ON m.event_id = e.event_id
103
+ LEFT JOIN turns owner
104
+ ON owner.message_id = m.owner_message_id
105
+ AND owner.session_id = e.session_id
106
+ AND owner.ts >= ? AND owner.ts < ?
107
+ AND owner.provisional = 0
108
+ WHERE e.session_id = ?
109
+ AND e.ts >= ? AND e.ts < ?
110
+ AND e.result_bytes IS NOT NULL
111
+ AND e.result_bytes > 0
112
+ AND m.owner_message_id IS NOT NULL
113
+ AND owner.message_id IS NOT NULL
114
+ ORDER BY e.ts ASC, m.block_index ASC, e.event_id ASC`)
115
+ .all(fromIso, toIso, sessionId, fromIso, toIso);
116
+ if (events.length === 0)
117
+ return empty;
118
+ const laterTurns = db
119
+ .prepare(`SELECT message_id, ts
120
+ FROM turns
121
+ WHERE session_id = ?
122
+ AND ts >= ? AND ts < ?
123
+ AND provisional = 0
124
+ ORDER BY ts ASC, message_id ASC`)
125
+ .all(sessionId, fromIso, toIso);
126
+ const turnIndexByMessage = new Map(laterTurns.map((turn, index) => [turn.message_id, index]));
127
+ const byTool = new Map();
128
+ for (const event of events) {
129
+ if (event.result_bytes === null || event.owner_message_id === null)
130
+ continue;
131
+ const ownerIndex = turnIndexByMessage.get(event.owner_message_id);
132
+ if (ownerIndex === undefined)
133
+ continue;
134
+ const tool = boundedToolNameClass(event.tool_name);
135
+ if (tool === null)
136
+ continue;
137
+ const existing = byTool.get(tool);
138
+ if (existing === undefined) {
139
+ byTool.set(tool, {
140
+ bytes: event.result_bytes,
141
+ eventCount: 1,
142
+ firstOwnerIndex: ownerIndex,
143
+ });
144
+ }
145
+ else {
146
+ existing.bytes += event.result_bytes;
147
+ existing.eventCount += 1;
148
+ if (ownerIndex < existing.firstOwnerIndex)
149
+ existing.firstOwnerIndex = ownerIndex;
150
+ }
151
+ }
152
+ const recurring = [...byTool.entries()]
153
+ .filter(([, value]) => value.eventCount >= 2)
154
+ .sort(([toolA, valueA], [toolB, valueB]) => {
155
+ return valueB.bytes - valueA.bytes || (toolA < toolB ? -1 : toolA > toolB ? 1 : 0);
156
+ });
157
+ const winner = recurring[0];
158
+ if (winner === undefined)
159
+ return empty;
160
+ const [attributedTool, value] = winner;
161
+ // Directional upper bound: assumes the result stays in context for every later
162
+ // turn. Real carry is shorter when context is trimmed, so this over-estimates
163
+ // (~20% observed); it is exposure, never an avoidable-token or savings claim.
164
+ const carryTurns = Math.max(0, laterTurns.length - value.firstOwnerIndex - 1);
165
+ return {
166
+ attributed_tool: attributedTool,
167
+ attributed_result_bytes: value.bytes,
168
+ carry_turns: carryTurns,
169
+ carry_exposure_tokens_directional: Number(((value.bytes * carryTurns) / bytesPerToken).toFixed(4)),
170
+ };
171
+ }
172
+ catch (error) {
173
+ // Older or partially migrated databases lack event metadata; keep the
174
+ // already-qualified session-level recommendation instead of failing D6.
175
+ // Only swallow the missing-schema case — a genuine query defect must surface.
176
+ if (isMissingSchemaError(error))
177
+ return empty;
178
+ throw error;
179
+ }
180
+ }
181
+ export const d6Detector = {
182
+ id: "D6",
183
+ name: "TOOL_RESULT_BLOAT",
184
+ evaluate(db, ctx) {
185
+ // Resolve bytes-per-token: calibrated value from user_config, fallback to 4.
186
+ const bptResolution = resolveBytesPerToken(db);
187
+ const bytesPerToken = bptResolution.ratio;
188
+ const capRows = capWeightedTokens(db, {
189
+ fromIso: ctx.fromIso,
190
+ toIso: ctx.toIso,
191
+ groupBy: "session_id",
192
+ });
193
+ const capBySession = new Map(capRows.map((r) => [r.group ?? "", r.cap_weighted_tokens]));
194
+ const sessions = db
195
+ .prepare(`SELECT t.session_id, t.workspace_id,
196
+ COALESCE(SUM(t.tool_result_bytes), 0) AS tool_result_bytes,
197
+ COUNT(*) AS turns
198
+ FROM turns t
199
+ WHERE t.ts >= ? AND t.ts < ? AND t.provisional = 0
200
+ GROUP BY t.session_id
201
+ HAVING SUM(t.tool_result_bytes) IS NOT NULL`)
202
+ .all(ctx.fromIso, ctx.toIso);
203
+ // Identify qualifying sessions (bloat share + absolute floor).
204
+ // Convert bytes → tokens before computing the share so the ratio is unitless.
205
+ const qualifying = [];
206
+ for (const s of sessions) {
207
+ const capTotal = capBySession.get(s.session_id) ?? 0;
208
+ if (capTotal <= 0)
209
+ continue;
210
+ const bloatTokens = s.tool_result_bytes / bytesPerToken;
211
+ const bloatShare = bloatTokens / capTotal;
212
+ if (bloatShare >= D6_BLOAT_SHARE && s.tool_result_bytes >= D6_ABS_FLOOR_BYTES) {
213
+ qualifying.push({ row: s, capTotal, bloatShare });
214
+ }
215
+ }
216
+ // Recurrence gate: only surface when the pattern repeats across ≥ N sessions.
217
+ if (qualifying.length < D6_MIN_SESSIONS) {
218
+ return {
219
+ fired: [],
220
+ status: "INACTIVE",
221
+ note: `${qualifying.length} bloated session(s) (< ${D6_MIN_SESSIONS} threshold)`,
222
+ };
223
+ }
224
+ const fired = [];
225
+ for (const { row, capTotal, bloatShare } of qualifying) {
226
+ const attribution = d6Attribution(db, row.session_id, ctx.fromIso, ctx.toIso, bytesPerToken);
227
+ // Compute modeled savings only when calibration is present AND attribution
228
+ // provided a carry estimate. Without calibration the heuristic ratio is
229
+ // unvalidated, so we make no $ claim (honest default: stays null).
230
+ let modeledSavingsU = null;
231
+ let savingsFormula = null;
232
+ let calibrationCaveat = null;
233
+ if (bptResolution.calibrated &&
234
+ attribution.carry_exposure_tokens_directional !== null &&
235
+ attribution.carry_exposure_tokens_directional > 0) {
236
+ const blendedReadPrice = blendedCacheReadPriceForSession(db, row.session_id, ctx.fromIso, ctx.toIso);
237
+ const { savingsU, formula } = d6Savings(attribution.carry_exposure_tokens_directional, D6_AVOIDANCE_FRACTION, blendedReadPrice);
238
+ if (savingsU > 0) {
239
+ modeledSavingsU = savingsU;
240
+ savingsFormula = formula;
241
+ calibrationCaveat = {
242
+ tokenizer: "anthropic count_tokens",
243
+ model: bptResolution.model,
244
+ measured_at: bptResolution.measuredAt,
245
+ note: "conservative — proxy corpora undercount Claude tokens, so real savings are likely ≥ shown",
246
+ };
247
+ }
248
+ }
249
+ // When calibrated with savings, use the savings formula; otherwise directional.
250
+ const formula = savingsFormula !== null
251
+ ? savingsFormula
252
+ : d6DirectionalFormula(row.tool_result_bytes, bloatShare, capTotal, bytesPerToken);
253
+ const bytesPerTokenNote = bptResolution.calibrated
254
+ ? `calibrated via count_tokens (${bptResolution.provenance ?? ""})`
255
+ : "heuristic estimate; actual tokenization is not observed";
256
+ fired.push({
257
+ scopeKey: `D6|${row.session_id}`,
258
+ category: "TOOLING",
259
+ scope_workspace_id: row.workspace_id,
260
+ lever: "Use scoped reads (line ranges) and head/filters over full dumps; truncate or summarize verbose tool output; move exploration to subagents to keep bloat off the premium main thread.",
261
+ target_metric: "tool_result_byte_share",
262
+ modeled_savings_u_per_wk: modeledSavingsU,
263
+ modeled_formula: formula,
264
+ evidence: {
265
+ title: `Trim tool output: ${Math.round(bloatShare * 100)}% bloat share in session`,
266
+ session_id: row.session_id,
267
+ workspace_id: row.workspace_id,
268
+ tool_result_bytes: row.tool_result_bytes,
269
+ session_cap_weighted_tokens: capTotal,
270
+ bloat_share: Number(bloatShare.toFixed(4)),
271
+ turn_count: row.turns,
272
+ bloat_share_threshold: D6_BLOAT_SHARE,
273
+ abs_floor_bytes_threshold: D6_ABS_FLOOR_BYTES,
274
+ min_sessions_threshold: D6_MIN_SESSIONS,
275
+ bytes_per_token: bytesPerToken,
276
+ bytes_per_token_note: bytesPerTokenNote,
277
+ thresholds_unvalidated: true,
278
+ attributed_tool: attribution.attributed_tool,
279
+ attributed_result_bytes: attribution.attributed_result_bytes,
280
+ carry_turns: attribution.carry_turns,
281
+ carry_exposure_tokens_directional: attribution.carry_exposure_tokens_directional,
282
+ ...(calibrationCaveat !== null ? { calibration: calibrationCaveat } : {}),
283
+ steps: [
284
+ "Open the session and find the turns carrying large tool-result output",
285
+ "Replace full-file dumps with scoped reads (line ranges) and head/grep filters",
286
+ "Truncate or summarize verbose command output before it enters context",
287
+ "Move broad exploration to a subagent so the bloat stays off the main thread",
288
+ ],
289
+ },
290
+ });
291
+ }
292
+ if (fired.length === 0) {
293
+ return { fired: [], status: "INACTIVE", note: "no qualifying bloated session" };
294
+ }
295
+ return {
296
+ fired,
297
+ status: "ACTIVE",
298
+ note: `${fired.length} session(s) with tool-result bloat`,
299
+ };
300
+ },
301
+ };
@@ -0,0 +1,345 @@
1
+ /**
2
+ * D7 LOOP_RETRY_WASTE.
3
+ *
4
+ * Detects three privacy-safe structural loop signals in reconciled sessions:
5
+ * repeated identical tool calls, repeated TEST_FAIL results, and repeated reads
6
+ * of one path/region identity without an intervening edit/write to that path.
7
+ * Raw tool inputs, outputs, commands, paths, and path hashes never leave the
8
+ * query/detection boundary.
9
+ */
10
+ import { capWeightForTurn, resolveCapReadCoeff } from "../../query/cap-weighted.js";
11
+ const MIN_RUN = 3;
12
+ const MIN_TRIGRAM_REPEATS = 3;
13
+ function identicalCallKey(row) {
14
+ return row === undefined || row.input_hash === null
15
+ ? null
16
+ : `${row.tool_name.length}:${row.tool_name}${row.input_hash}`;
17
+ }
18
+ function markRuns(rows, keyFor, target, excessTarget) {
19
+ let run = [];
20
+ let runKey = null;
21
+ const flush = () => {
22
+ if (run.length >= MIN_RUN) {
23
+ for (const row of run)
24
+ target.add(row.event_id);
25
+ // The first event is the necessary baseline attempt. Only later events
26
+ // contribute to the conservative repeat-excess exposure estimate.
27
+ for (const row of run.slice(1))
28
+ excessTarget.add(row.event_id);
29
+ }
30
+ run = [];
31
+ runKey = null;
32
+ };
33
+ for (const row of rows) {
34
+ const key = keyFor(row);
35
+ if (key !== null && key === runKey) {
36
+ run.push(row);
37
+ }
38
+ else {
39
+ flush();
40
+ if (key !== null) {
41
+ run = [row];
42
+ runKey = key;
43
+ }
44
+ }
45
+ }
46
+ flush();
47
+ }
48
+ function markRedundantReads(rows, target, excessTarget) {
49
+ const readsSinceWrite = new Map();
50
+ const markIfQualifying = (reads) => {
51
+ if (reads && reads.length >= MIN_RUN) {
52
+ for (const row of reads)
53
+ target.add(row.event_id);
54
+ // Preserve one baseline read; only repeated reads are excess exposure.
55
+ for (const row of reads.slice(1))
56
+ excessTarget.add(row.event_id);
57
+ }
58
+ };
59
+ const flushPath = (pathIdentity) => {
60
+ const regions = readsSinceWrite.get(pathIdentity);
61
+ if (regions !== undefined) {
62
+ for (const reads of regions.values())
63
+ markIfQualifying(reads);
64
+ }
65
+ readsSinceWrite.delete(pathIdentity);
66
+ };
67
+ for (const row of rows) {
68
+ const pathIdentity = row.file_path_hash;
69
+ if (pathIdentity === null)
70
+ continue;
71
+ const tool = row.tool_name.toLowerCase();
72
+ if (tool === "edit" || tool === "write") {
73
+ flushPath(pathIdentity);
74
+ continue;
75
+ }
76
+ if (tool !== "read" || row.input_hash === null)
77
+ continue;
78
+ // input_hash includes normalized file_path plus Read offset/limit, so
79
+ // different chunks of one file remain distinct and do not look redundant.
80
+ const regions = readsSinceWrite.get(pathIdentity) ?? new Map();
81
+ const reads = regions.get(row.input_hash) ?? [];
82
+ reads.push(row);
83
+ regions.set(row.input_hash, reads);
84
+ readsSinceWrite.set(pathIdentity, regions);
85
+ }
86
+ for (const regions of readsSinceWrite.values()) {
87
+ for (const reads of regions.values())
88
+ markIfQualifying(reads);
89
+ }
90
+ }
91
+ function markThreeGramLoops(rows, target, excessTarget) {
92
+ const tripleLength = 3;
93
+ const minimumRunLength = tripleLength * MIN_TRIGRAM_REPEATS;
94
+ for (let start = 0; start <= rows.length - minimumRunLength;) {
95
+ const first = identicalCallKey(rows[start]);
96
+ const second = identicalCallKey(rows[start + 1]);
97
+ const third = identicalCallKey(rows[start + 2]);
98
+ if (first === null ||
99
+ second === null ||
100
+ third === null ||
101
+ (first === second && second === third)) {
102
+ start += 1;
103
+ continue;
104
+ }
105
+ let end = start + tripleLength;
106
+ while (end + tripleLength <= rows.length &&
107
+ identicalCallKey(rows[end]) === first &&
108
+ identicalCallKey(rows[end + 1]) === second &&
109
+ identicalCallKey(rows[end + 2]) === third) {
110
+ end += tripleLength;
111
+ }
112
+ if (end - start < minimumRunLength) {
113
+ start += 1;
114
+ continue;
115
+ }
116
+ for (const row of rows.slice(start, end))
117
+ target.add(row.event_id);
118
+ for (const row of rows.slice(start + tripleLength, end))
119
+ excessTarget.add(row.event_id);
120
+ start = end;
121
+ }
122
+ }
123
+ export const d7Detector = {
124
+ id: "D7",
125
+ name: "LOOP_RETRY_WASTE",
126
+ evaluate(db, ctx) {
127
+ const candidateRows = db
128
+ .prepare(`WITH metadata AS (
129
+ SELECT event_id, COUNT(*) AS metadata_rows,
130
+ MAX(file_path_hash) AS file_path_hash,
131
+ MAX(owner_message_id) AS owner_message_id,
132
+ MAX(block_index) AS block_index
133
+ FROM tool_event_metadata
134
+ GROUP BY event_id
135
+ ),
136
+ owners AS (
137
+ SELECT message_id, COUNT(*) AS owner_rows,
138
+ MAX(session_id) AS owner_session_id,
139
+ MAX(workspace_id) AS owner_workspace_id,
140
+ MAX(ts) AS owner_ts,
141
+ MAX(provisional) AS owner_provisional,
142
+ MAX(input_tokens) AS input_tokens,
143
+ MAX(output_tokens) AS output_tokens,
144
+ MAX(cache_read_tokens) AS cache_read_tokens,
145
+ MAX(cache_write_5m) AS cache_write_5m,
146
+ MAX(cache_write_1h) AS cache_write_1h,
147
+ MAX(cache_write_other) AS cache_write_other
148
+ FROM turns
149
+ GROUP BY message_id
150
+ ),
151
+ params(from_ts, as_of_ts) AS (VALUES (?, ?)),
152
+ candidates AS (
153
+ SELECT e.event_id, e.session_id, e.ts AS event_ts, e.tool_name,
154
+ e.input_hash, e.exit_class,
155
+ s.workspace_id AS session_workspace_id,
156
+ m.file_path_hash, m.owner_message_id, m.block_index,
157
+ COALESCE(m.metadata_rows, 0) AS metadata_rows,
158
+ COALESCE(t.owner_rows, 0) AS owner_rows,
159
+ t.owner_session_id, t.owner_workspace_id, t.owner_ts,
160
+ t.owner_provisional,
161
+ t.input_tokens, t.output_tokens, t.cache_read_tokens,
162
+ t.cache_write_5m, t.cache_write_1h, t.cache_write_other,
163
+ p.from_ts, p.as_of_ts
164
+ FROM tool_events e INDEXED BY idx_tool_ts_session_event
165
+ JOIN sessions s
166
+ ON s.session_id = e.session_id
167
+ AND s.state = 'RECONCILED'
168
+ LEFT JOIN metadata m ON m.event_id = e.event_id
169
+ LEFT JOIN owners t ON t.message_id = m.owner_message_id
170
+ CROSS JOIN params p
171
+ ),
172
+ classified AS (
173
+ SELECT *,
174
+ CASE WHEN strftime('%s', event_ts) IS NULL THEN 1 ELSE 0 END AS event_malformed,
175
+ CASE WHEN strftime('%s', event_ts) IS NOT NULL
176
+ AND julianday(event_ts) >= julianday(from_ts)
177
+ AND julianday(event_ts) < julianday(as_of_ts)
178
+ THEN 1 ELSE 0 END AS in_window,
179
+ CASE WHEN metadata_rows = 1 AND owner_rows = 1
180
+ AND owner_session_id = session_id
181
+ AND owner_workspace_id = session_workspace_id
182
+ AND owner_provisional = 0
183
+ AND strftime('%s', owner_ts) IS NOT NULL
184
+ AND julianday(owner_ts) >= julianday(from_ts)
185
+ AND julianday(owner_ts) < julianday(as_of_ts)
186
+ THEN 1 ELSE 0 END AS owner_eligible
187
+ FROM candidates
188
+ ),
189
+ eligible AS (
190
+ SELECT *, in_window AS denominator_event,
191
+ CASE WHEN in_window = 1 AND owner_eligible = 1
192
+ THEN 1 ELSE 0 END AS covered_event
193
+ FROM classified
194
+ )
195
+ SELECT event_id, session_id, session_workspace_id AS workspace_id, event_ts AS ts,
196
+ tool_name, input_hash, exit_class, file_path_hash, owner_message_id, block_index,
197
+ input_tokens, output_tokens, cache_read_tokens,
198
+ cache_write_5m, cache_write_1h, cache_write_other,
199
+ metadata_rows, owner_eligible, covered_event
200
+ FROM eligible
201
+ WHERE denominator_event = 1
202
+ ORDER BY session_id ASC, ts ASC, block_index ASC, event_id ASC`)
203
+ .all(ctx.fromIso, ctx.toIso);
204
+ const coverageBySession = new Map();
205
+ for (const row of candidateRows) {
206
+ const coverage = coverageBySession.get(row.session_id) ?? { denominator: 0, covered: 0 };
207
+ coverage.denominator += 1;
208
+ if (row.covered_event === 1)
209
+ coverage.covered += 1;
210
+ coverageBySession.set(row.session_id, coverage);
211
+ }
212
+ const rows = candidateRows.filter((row) => row.covered_event === 1);
213
+ if (rows.length === 0) {
214
+ return {
215
+ fired: [],
216
+ status: "NOT_EVALUATED",
217
+ note: "0 enriched tool events available in the trailing window; forward-only D7 coverage has not started",
218
+ };
219
+ }
220
+ const bySession = new Map();
221
+ for (const row of rows) {
222
+ const session = bySession.get(row.session_id) ?? {
223
+ workspaceId: row.workspace_id,
224
+ rows: [],
225
+ };
226
+ session.rows.push(row);
227
+ bySession.set(row.session_id, session);
228
+ }
229
+ const totalRows = db
230
+ .prepare(`SELECT t.session_id, COUNT(*) AS turn_count
231
+ FROM turns t INDEXED BY idx_turns_ts_provisional_session
232
+ JOIN sessions s ON s.session_id = t.session_id
233
+ WHERE t.ts >= ? AND t.ts < ?
234
+ AND t.provisional = 0
235
+ AND s.state = 'RECONCILED'
236
+ GROUP BY t.session_id`)
237
+ .all(ctx.fromIso, ctx.toIso);
238
+ const totalTurnsBySession = new Map(totalRows.map((row) => [row.session_id, row.turn_count]));
239
+ const capReadCoeff = resolveCapReadCoeff(db);
240
+ const fired = [];
241
+ for (const [sessionId, session] of bySession) {
242
+ const identicalCalls = new Set();
243
+ const testFails = new Set();
244
+ const redundantReads = new Set();
245
+ const threeGramLoops = new Set();
246
+ const repeatExcessEventIds = new Set();
247
+ markRuns(session.rows, identicalCallKey, identicalCalls, repeatExcessEventIds);
248
+ markRuns(session.rows, (row) => (row.exit_class === "TEST_FAIL" ? "TEST_FAIL" : null), testFails, repeatExcessEventIds);
249
+ markRedundantReads(session.rows, redundantReads, repeatExcessEventIds);
250
+ markThreeGramLoops(session.rows, threeGramLoops, repeatExcessEventIds);
251
+ const flaggedEventIds = new Set([
252
+ ...identicalCalls,
253
+ ...testFails,
254
+ ...redundantReads,
255
+ ...threeGramLoops,
256
+ ]);
257
+ if (flaggedEventIds.size === 0)
258
+ continue;
259
+ const flaggedTurns = new Map();
260
+ const repeatExcessTurns = new Map();
261
+ for (const row of session.rows) {
262
+ if (flaggedEventIds.has(row.event_id))
263
+ flaggedTurns.set(row.owner_message_id, row);
264
+ if (repeatExcessEventIds.has(row.event_id)) {
265
+ repeatExcessTurns.set(row.owner_message_id, row);
266
+ }
267
+ }
268
+ let repeatExcessTurnCapWeightedTokens = 0;
269
+ for (const row of repeatExcessTurns.values()) {
270
+ repeatExcessTurnCapWeightedTokens += capWeightForTurn(row, capReadCoeff);
271
+ }
272
+ const totalTurns = totalTurnsBySession.get(sessionId) ?? 0;
273
+ const loopTurnShare = totalTurns > 0 ? flaggedTurns.size / totalTurns : 0;
274
+ const coverage = coverageBySession.get(sessionId);
275
+ if (!coverage || coverage.denominator === 0) {
276
+ throw new Error("D7 coverage denominator missing for fired session");
277
+ }
278
+ const ownerTurnMetadataCoverage = Number((coverage.covered / coverage.denominator).toFixed(6));
279
+ fired.push({
280
+ scopeKey: `D7|${sessionId}`,
281
+ category: "SESSION_HYGIENE",
282
+ scope_workspace_id: session.workspaceId,
283
+ lever: "Interrupt repeated attempts, restate the goal and latest failure, split the task when the loop persists, and read a file before editing it while avoiding redundant re-reads.",
284
+ target_metric: "loop_flagged_turn_share",
285
+ modeled_savings_u_per_wk: null,
286
+ modeled_formula: {
287
+ model: "D7_LOOP_RETRY_WASTE_EXPOSURE_V2",
288
+ kind: "DIRECTIONAL_UNVALIDATED",
289
+ inputs: {
290
+ repeat_excess_turn_cap_weighted_tokens: repeatExcessTurnCapWeightedTokens,
291
+ repeat_excess_event_count: repeatExcessEventIds.size,
292
+ repeat_excess_turn_count: repeatExcessTurns.size,
293
+ loop_flagged_turn_count: flaggedTurns.size,
294
+ total_turn_count: totalTurns,
295
+ loop_flagged_turn_share: Number(loopTurnShare.toFixed(6)),
296
+ owner_turn_metadata_coverage: ownerTurnMetadataCoverage,
297
+ cap_read_coefficient: capReadCoeff,
298
+ },
299
+ expression: "cap-weighted exposure of turns owning repeat-excess events; not an avoidable-token or USD savings estimate",
300
+ },
301
+ evidence: {
302
+ title: `Break retry loops: ${flaggedTurns.size} flagged turn${flaggedTurns.size === 1 ? "" : "s"} in session`,
303
+ session_id: sessionId,
304
+ workspace_id: session.workspaceId,
305
+ loop_flagged_event_count: flaggedEventIds.size,
306
+ loop_flagged_turn_count: flaggedTurns.size,
307
+ repeat_excess_event_count: repeatExcessEventIds.size,
308
+ repeat_excess_turn_count: repeatExcessTurns.size,
309
+ total_turn_count: totalTurns,
310
+ loop_flagged_turn_share: Number(loopTurnShare.toFixed(6)),
311
+ owner_turn_metadata_coverage: ownerTurnMetadataCoverage,
312
+ owner_turn_metadata_covered_event_count: coverage.covered,
313
+ owner_turn_metadata_denominator_event_count: coverage.denominator,
314
+ identical_call_event_count: identicalCalls.size,
315
+ test_fail_event_count: testFails.size,
316
+ redundant_read_event_count: redundantReads.size,
317
+ three_gram_loop_event_count: threeGramLoops.size,
318
+ repeat_excess_turn_cap_weighted_tokens: repeatExcessTurnCapWeightedTokens,
319
+ thresholds_unvalidated: true,
320
+ savings_claim: "repeat-excess owning-turn exposure only; no avoidable-token or USD savings claim",
321
+ privacy_caveat: "Evidence contains structural counts and identifiers only; raw paths, path hashes, tool input/output, commands, and transcript content are excluded.",
322
+ coverage_caveat: "Forward-only enrichment: historical or malformed events without owner-turn metadata are ignored safely.",
323
+ steps: [
324
+ "Interrupt after the same attempt or failure repeats",
325
+ "Restate the goal, constraints, and latest failure before retrying",
326
+ "Split the task into a smaller independently verifiable step",
327
+ "Read the target before editing, then avoid re-reading it unless an edit changed it",
328
+ ],
329
+ },
330
+ });
331
+ }
332
+ if (fired.length === 0) {
333
+ return {
334
+ fired: [],
335
+ status: "INACTIVE",
336
+ note: "no reconciled session met a loop/retry threshold",
337
+ };
338
+ }
339
+ return {
340
+ fired,
341
+ status: "ACTIVE",
342
+ note: `${fired.length} session(s) with loop/retry waste`,
343
+ };
344
+ },
345
+ };