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,509 @@
1
+ /**
2
+ * src/ingest/ingestor.ts — the WP1 ingestion orchestrator.
3
+ *
4
+ * Wires discovery → tail → parse → price → persist → reconcile. Holds the small
5
+ * in-process correlation state (tool_use → owning turn) that lets tool_result
6
+ * sizes and commit SHAs attach to the right rows. SEC-101: only tokens, ids,
7
+ * sizes, model names, and structural markers are ever written.
8
+ *
9
+ * Idempotency (NFR-107): turns upsert-ignore on message_id; session aggregates
10
+ * advance only when a turn is genuinely new (changes === 1). A dropped DB
11
+ * re-scanned from scratch reproduces identical RECONCILED aggregates.
12
+ */
13
+ import * as crypto from "node:crypto";
14
+ import * as fs from "node:fs";
15
+ import { runPostIngestHook } from "./detector-hook.js";
16
+ import { createDiscoveryCache, refreshDiscoveryCache, registerWorkspace, sessionStemFor, } from "./discovery.js";
17
+ import { Health } from "./health.js";
18
+ import { PARSER_VERSION, projectLine } from "./parser.js";
19
+ import { PricingSnapshotStore, seedListPrices } from "./pricing.js";
20
+ import { DEFAULT_RECONCILE_OPTIONS, reconcileSessions, } from "./reconcile.js";
21
+ import { loadOffset, saveOffset, tailFile } from "./tail.js";
22
+ import { LONG_GAP_THRESHOLD_S } from "./types.js";
23
+ import { backfillDiscoveredCwd, defaultReadCwd, defaultReadRemote, resolveWorkspaceMappings, } from "./workspace-mapping.js";
24
+ export const DEFAULT_INGESTOR_OPTIONS = {
25
+ activityWindowSecs: 5 * 60,
26
+ reconcile: DEFAULT_RECONCILE_OPTIONS,
27
+ tailIntervalMs: 2_000,
28
+ discoveryIntervalMs: 30_000,
29
+ now: () => new Date(),
30
+ };
31
+ const INITIAL_SCAN_BATCH_SIZE = 100;
32
+ export class Ingestor {
33
+ db;
34
+ roots;
35
+ opts;
36
+ health = new Health();
37
+ pricing;
38
+ // Correlation state (process lifetime; rebuilt fresh on a cold re-scan).
39
+ toolUseOwner = new Map(); // toolUseId → messageId
40
+ gitUseIds = new Set(); // toolUseIds that looked like git commit/push
41
+ countedResults = new Set(); // toolUseIds already summed
42
+ resultBytesByMsg = new Map(); // messageId → running byte sum
43
+ lineCursor = new Map(); // filePath → complete lines consumed
44
+ lastSize = new Map(); // filePath → last-seen size (skip unchanged files)
45
+ discoveryCache = createDiscoveryCache();
46
+ unresolvedRemotes = new Set();
47
+ userTurnTsBySession = new Map(); // sessionId → epoch-ms of user turns
48
+ // Prepared statements.
49
+ stInsertTurn;
50
+ stInsertSession;
51
+ stBumpSession;
52
+ stBumpUserTurnCount;
53
+ stBumpFrictionCounts;
54
+ stSetGapAggregates;
55
+ stGetGapN;
56
+ stInsertToolEvent;
57
+ stUpsertToolEventMetadata;
58
+ stSetToolResult;
59
+ stRefreshOwnerResultBytes;
60
+ stSetResultBytes;
61
+ stSetCommitSha;
62
+ stInsertQuarantine;
63
+ stSetDiscoveredCwd;
64
+ constructor(db, roots, options = {}) {
65
+ this.db = db;
66
+ this.roots = roots;
67
+ this.opts = { ...DEFAULT_INGESTOR_OPTIONS, ...options };
68
+ // Seed the canonical list prices, then load the pricing store.
69
+ seedListPrices(db, this.opts.now().toISOString());
70
+ this.pricing = new PricingSnapshotStore(db, this.opts.now().toISOString());
71
+ this.stInsertTurn = db.prepare(`INSERT OR IGNORE INTO turns
72
+ (message_id, session_id, workspace_id, ts, model, is_sidechain,
73
+ input_tokens, output_tokens, thinking_tokens, cache_read_tokens,
74
+ cache_write_5m, cache_write_1h, cache_write_other,
75
+ tool_result_bytes, pricing_snapshot_id, cost_equiv_u, cost_claim,
76
+ provisional, effort, parser_version)
77
+ VALUES (?,?,?,?,?,?, ?,?,?,?, ?,?,?, ?,?,?,?,?,?,?)`);
78
+ this.stInsertSession = db.prepare(`INSERT OR IGNORE INTO sessions
79
+ (session_id, workspace_id, file_path, first_turn_at, last_turn_at,
80
+ state, turn_count, cost_equiv_u, hygiene_flags)
81
+ VALUES (?,?,?,?,?,'LIVE',0,0,'[]')`);
82
+ this.stBumpSession = db.prepare(`UPDATE sessions
83
+ SET turn_count = turn_count + 1,
84
+ cost_equiv_u = cost_equiv_u + ?,
85
+ first_turn_at = MIN(COALESCE(first_turn_at, ?), ?),
86
+ last_turn_at = MAX(COALESCE(last_turn_at, ?), ?),
87
+ state = CASE WHEN state = 'RECONCILED' THEN 'LIVE' ELSE state END
88
+ WHERE session_id = ?`);
89
+ this.stBumpUserTurnCount = db.prepare("UPDATE sessions SET user_turn_count = user_turn_count + 1 WHERE session_id = ?");
90
+ this.stBumpFrictionCounts = db.prepare(`UPDATE sessions SET
91
+ compaction_count = compaction_count + ?,
92
+ api_error_count = api_error_count + ?,
93
+ interrupt_count = interrupt_count + ?
94
+ WHERE session_id = ?`);
95
+ this.stSetGapAggregates = db.prepare("UPDATE sessions SET gap_median_s=?, gap_p90_s=?, long_gap_count=?, gap_n=? WHERE session_id=?");
96
+ this.stGetGapN = db.prepare("SELECT gap_n FROM sessions WHERE session_id=?");
97
+ this.stInsertToolEvent = db.prepare(`INSERT INTO tool_events
98
+ (event_id, session_id, ts, tool_name, input_bytes, result_bytes, input_hash, exit_class, commit_sha)
99
+ VALUES (?,?,?,?,?,?,?,?,?)
100
+ ON CONFLICT(event_id) DO UPDATE SET
101
+ input_bytes = COALESCE(tool_events.input_bytes, excluded.input_bytes),
102
+ input_hash = COALESCE(tool_events.input_hash, excluded.input_hash)`);
103
+ this.stUpsertToolEventMetadata = db.prepare(`INSERT INTO tool_event_metadata
104
+ (event_id, file_path_hash, owner_message_id, block_index, is_test_command)
105
+ VALUES (?,?,?,?,?)
106
+ ON CONFLICT(event_id) DO UPDATE SET
107
+ file_path_hash = COALESCE(tool_event_metadata.file_path_hash, excluded.file_path_hash),
108
+ owner_message_id = COALESCE(tool_event_metadata.owner_message_id, excluded.owner_message_id),
109
+ block_index = excluded.block_index,
110
+ is_test_command = excluded.is_test_command`);
111
+ this.stSetToolResult = db.prepare(`UPDATE tool_events
112
+ SET result_bytes = ?,
113
+ exit_class = CASE
114
+ WHEN ? = 0 THEN 'OK'
115
+ WHEN EXISTS (
116
+ SELECT 1 FROM tool_event_metadata AS metadata
117
+ WHERE metadata.event_id = tool_events.event_id
118
+ AND metadata.is_test_command = 1
119
+ ) THEN 'TEST_FAIL'
120
+ ELSE 'ERROR'
121
+ END
122
+ WHERE event_id = ?`);
123
+ this.stRefreshOwnerResultBytes = db.prepare(`UPDATE turns
124
+ SET tool_result_bytes = (
125
+ SELECT SUM(events.result_bytes)
126
+ FROM tool_event_metadata AS owned
127
+ JOIN tool_events AS events ON events.event_id = owned.event_id
128
+ WHERE owned.owner_message_id = turns.message_id
129
+ AND events.result_bytes IS NOT NULL
130
+ )
131
+ WHERE message_id = (
132
+ SELECT owner_message_id FROM tool_event_metadata WHERE event_id = ?
133
+ )`);
134
+ this.stSetResultBytes = db.prepare("UPDATE turns SET tool_result_bytes = ? WHERE message_id = ?");
135
+ this.stSetCommitSha = db.prepare("UPDATE tool_events SET commit_sha = ? WHERE event_id = ?");
136
+ this.stInsertQuarantine = db.prepare(`INSERT OR IGNORE INTO ingest_quarantine
137
+ (q_id, file_path, line_no, error_class, parser_version, seen_at)
138
+ VALUES (?,?,?,?,?,?)`);
139
+ this.stSetDiscoveredCwd = db.prepare("UPDATE workspaces SET discovered_cwd=? WHERE workspace_id=? AND discovered_cwd IS NULL");
140
+ }
141
+ /** Full back-scan: ingest every discovered file once, then reconcile. */
142
+ runBackscan() {
143
+ this.ingestAllKnown();
144
+ this.reconcileNow();
145
+ this.runPostIngest();
146
+ return this.health.snapshot();
147
+ }
148
+ /** Async full back-scan that yields between bounded batches, then reconciles. */
149
+ async runBackscanBatched(batchSize = INITIAL_SCAN_BATCH_SIZE) {
150
+ await this.ingestAllKnownBatched(batchSize);
151
+ this.reconcileNow();
152
+ this.runPostIngest();
153
+ return this.health.snapshot();
154
+ }
155
+ /** Start incremental tailing: initial pass, then discovery + tail intervals. */
156
+ startTail() {
157
+ this.ingestAllKnown();
158
+ this.reconcileNow();
159
+ this.runPostIngest();
160
+ return this.startTailTimers();
161
+ }
162
+ /** Start tailing after an async, yielding initial scan. */
163
+ async startTailBatched(batchSize = INITIAL_SCAN_BATCH_SIZE) {
164
+ await this.ingestAllKnownBatched(batchSize);
165
+ this.reconcileNow();
166
+ this.runPostIngest();
167
+ return this.startTailTimers();
168
+ }
169
+ startTailTimers() {
170
+ let busy = false;
171
+ // Fast tail cadence: advance byte offsets on every known file, then reconcile.
172
+ const tailTimer = setInterval(() => {
173
+ if (busy)
174
+ return;
175
+ busy = true;
176
+ try {
177
+ for (const f of refreshDiscoveryCache(this.roots, this.discoveryCache)) {
178
+ this.ingestFile(f.filePath, f.projectSlug);
179
+ }
180
+ this.reconcileNow();
181
+ }
182
+ catch (e) {
183
+ console.warn(`tail tick failed: ${e instanceof Error ? e.message : String(e)}`);
184
+ }
185
+ finally {
186
+ busy = false;
187
+ }
188
+ }, this.opts.tailIntervalMs);
189
+ // Slower discovery cadence: register any newly-appeared workspace slugs, then
190
+ // run a full detector pass (30s freshness matches the recommendations surface;
191
+ // a pass on every 2s tail tick would be wasteful).
192
+ const discoveryTimer = setInterval(() => {
193
+ try {
194
+ const discovered = refreshDiscoveryCache(this.roots, this.discoveryCache, true);
195
+ for (const f of discovered)
196
+ registerWorkspace(this.db, f.projectSlug);
197
+ backfillDiscoveredCwd(this.db, discovered, defaultReadCwd);
198
+ const newlyMapped = resolveWorkspaceMappings(this.db, {
199
+ readRemote: this.opts.readRemote ?? defaultReadRemote,
200
+ unresolved: this.unresolvedRemotes,
201
+ });
202
+ this.runPostIngest();
203
+ if (newlyMapped > 0 && this.opts.onNewMappings !== undefined) {
204
+ try {
205
+ this.opts.onNewMappings(newlyMapped);
206
+ }
207
+ catch (e) {
208
+ console.warn(`new mappings callback failed: ${e instanceof Error ? e.message : String(e)}`);
209
+ }
210
+ }
211
+ }
212
+ catch (e) {
213
+ console.warn(`discovery tick failed: ${e instanceof Error ? e.message : String(e)}`);
214
+ }
215
+ }, this.opts.discoveryIntervalMs);
216
+ return {
217
+ stop: () => {
218
+ clearInterval(tailTimer);
219
+ clearInterval(discoveryTimer);
220
+ },
221
+ };
222
+ }
223
+ /** Current parser-health counters (surfaced in Settings by WP4). */
224
+ healthSnapshot() {
225
+ return this.health.snapshot();
226
+ }
227
+ /**
228
+ * Clear in-memory correlation/offset caches. Called after a DB reset so the
229
+ * running tailer re-ingests the still-present transcript files faithfully
230
+ * (identical to a cold rescan) instead of re-inserting rows against stale
231
+ * correlation state (which would leave tool_events bytes/SHAs NULL).
232
+ */
233
+ clearRuntimeState() {
234
+ this.toolUseOwner.clear();
235
+ this.gitUseIds.clear();
236
+ this.countedResults.clear();
237
+ this.resultBytesByMsg.clear();
238
+ this.lineCursor.clear();
239
+ this.lastSize.clear();
240
+ this.unresolvedRemotes.clear();
241
+ this.userTurnTsBySession.clear();
242
+ }
243
+ // ── internals ──────────────────────────────────────────────────────────────
244
+ ingestAllKnown() {
245
+ for (const f of refreshDiscoveryCache(this.roots, this.discoveryCache, true)) {
246
+ this.ingestFile(f.filePath, f.projectSlug);
247
+ }
248
+ }
249
+ async ingestAllKnownBatched(batchSize) {
250
+ const files = refreshDiscoveryCache(this.roots, this.discoveryCache, true);
251
+ const normalizedBatchSize = Number.isFinite(batchSize) && batchSize >= 1
252
+ ? Math.max(1, Math.floor(batchSize))
253
+ : INITIAL_SCAN_BATCH_SIZE;
254
+ for (let start = 0; start < files.length; start += normalizedBatchSize) {
255
+ const end = Math.min(start + normalizedBatchSize, files.length);
256
+ for (let i = start; i < end; i++) {
257
+ const f = files[i];
258
+ if (f === undefined)
259
+ continue;
260
+ this.ingestFile(f.filePath, f.projectSlug);
261
+ }
262
+ if (end < files.length) {
263
+ await new Promise((resolve) => setImmediate(resolve));
264
+ }
265
+ }
266
+ }
267
+ reconcileNow() {
268
+ const cutoff = new Date(this.opts.now().getTime() - this.opts.activityWindowSecs * 1000);
269
+ reconcileSessions(this.db, cutoff.toISOString(), this.opts.reconcile);
270
+ }
271
+ /**
272
+ * Post-ingest detector pass. Passes the ingestor's INJECTED clock (never
273
+ * new Date()) so recommendation windows/ids are deterministic (NFR-107 /
274
+ * Review F4). No-op unless the daemon wired the post-ingest hook.
275
+ */
276
+ runPostIngest() {
277
+ runPostIngestHook(this.db, this.opts.now());
278
+ }
279
+ /** Tail one file from its stored offset and ingest the complete new lines. */
280
+ ingestFile(filePath, projectSlug) {
281
+ // Cheap size guard: transcripts are append-only, so an unchanged file size
282
+ // means zero new complete lines. Skip it before touching the DB — this keeps
283
+ // the 2s tail tick from doing ~4,700 sqlite writes across ~2,352 idle files
284
+ // every tick, which was starving the daemon's event loop (outcomes pass).
285
+ // Keep the per-file stat: directory mtimes only decide when to refresh paths;
286
+ // they cannot safely replace append/rotation change detection for each file.
287
+ let curSize;
288
+ try {
289
+ curSize = fs.statSync(filePath).size;
290
+ }
291
+ catch {
292
+ return; // file vanished between discovery and tail; nothing to ingest
293
+ }
294
+ if (this.lastSize.get(filePath) === curSize)
295
+ return; // unchanged since last tick — no new bytes
296
+ this.lastSize.set(filePath, curSize);
297
+ this.health.fileSeen();
298
+ registerWorkspace(this.db, projectSlug);
299
+ const stored = loadOffset(this.db, filePath);
300
+ const result = tailFile(filePath, stored);
301
+ if (result.wasReset)
302
+ this.lineCursor.set(filePath, 0);
303
+ if (result.lines.length === 0) {
304
+ // Still persist head-hash/offset so first-touch rotation detection is armed.
305
+ saveOffset(this.db, filePath, result.newOffset, result.newHeadHash);
306
+ return;
307
+ }
308
+ const defaultSessionId = sessionStemFor(filePath);
309
+ const base = this.lineCursor.get(filePath) ?? 0;
310
+ const tx = this.db.transaction(() => {
311
+ for (let i = 0; i < result.lines.length; i++) {
312
+ const raw = result.lines[i];
313
+ if (raw === undefined)
314
+ continue;
315
+ this.applyLine(raw, { defaultSessionId }, projectSlug, filePath, base + i + 1);
316
+ }
317
+ });
318
+ tx();
319
+ this.lineCursor.set(filePath, base + result.lines.length);
320
+ saveOffset(this.db, filePath, result.newOffset, result.newHeadHash);
321
+ this.health.fileParsed();
322
+ }
323
+ applyLine(raw, ctx, workspaceId, filePath, lineNo) {
324
+ const proj = projectLine(raw, ctx);
325
+ if (proj.kind === "quarantine") {
326
+ this.quarantine(filePath, lineNo, proj.errorClass);
327
+ return;
328
+ }
329
+ this.health.unknownFields(proj.unknownFields);
330
+ if (proj.cwd !== null) {
331
+ this.stSetDiscoveredCwd.run(proj.cwd, workspaceId);
332
+ }
333
+ // A session row must exist before any session-scoped FK insert (tool_events,
334
+ // commands, turns). Resolve the line's session + a timestamp and ensure it.
335
+ const lineSessionId = proj.isUserTurn
336
+ ? proj.sessionId
337
+ : (proj.turn?.sessionId ?? proj.command?.sessionId ?? proj.toolEvents[0]?.sessionId ?? null);
338
+ const lineTs = proj.turn?.ts ?? proj.command?.ts ?? proj.toolEvents[0]?.ts ?? "";
339
+ if (lineSessionId !== null) {
340
+ this.ensureSession(lineSessionId, workspaceId, filePath, lineTs === "" ? null : lineTs);
341
+ }
342
+ if (proj.isUserTurn === true) {
343
+ this.stBumpUserTurnCount.run(proj.sessionId);
344
+ if (typeof proj.ts === "string" && proj.ts.length > 0) {
345
+ const epochMs = Date.parse(proj.ts);
346
+ if (Number.isFinite(epochMs)) {
347
+ const list = this.userTurnTsBySession.get(proj.sessionId);
348
+ if (list !== undefined) {
349
+ list.push(epochMs);
350
+ }
351
+ else {
352
+ this.userTurnTsBySession.set(proj.sessionId, [epochMs]);
353
+ }
354
+ this.applyGapAggregates(proj.sessionId);
355
+ }
356
+ }
357
+ }
358
+ // Friction counters (RV2a): compact summary, API error, interrupt.
359
+ if (proj.isCompactSummary || proj.isApiErrorMessage || proj.isInterrupt) {
360
+ // Ensure session row exists for lines that carry only top-level flags
361
+ // (no turn, command, or tool events — so lineSessionId would be null).
362
+ if (lineSessionId === null) {
363
+ this.ensureSession(proj.sessionId, workspaceId, filePath, null);
364
+ }
365
+ this.stBumpFrictionCounts.run(proj.isCompactSummary ? 1 : 0, proj.isApiErrorMessage ? 1 : 0, proj.isInterrupt ? 1 : 0, proj.sessionId);
366
+ }
367
+ // Command markers → tool_events(local_command) for hygiene evaluation.
368
+ if (proj.command !== null) {
369
+ this.recordCommand(proj.command.sessionId, proj.command.ts, proj.command.command);
370
+ }
371
+ // Tool-use blocks → tool_events; remember owner + git hint for correlation.
372
+ for (const te of proj.toolEvents) {
373
+ this.insertToolEvent(te);
374
+ if (te.toolUseId !== null) {
375
+ if (te.ownerMessageId !== null)
376
+ this.toolUseOwner.set(te.toolUseId, te.ownerMessageId);
377
+ if (te.gitCommandHint)
378
+ this.gitUseIds.add(te.toolUseId);
379
+ }
380
+ }
381
+ // Tool-result blocks → attach bytes to owning turn; harvest git commit SHAs.
382
+ for (const tr of proj.toolResults) {
383
+ this.applyToolResult(tr.toolUseId, tr.resultBytes, tr.commitSha, tr.isError);
384
+ }
385
+ if (proj.synthetic) {
386
+ this.health.synthetic();
387
+ return;
388
+ }
389
+ if (proj.turn === null)
390
+ return;
391
+ this.writeTurn(proj.turn, workspaceId);
392
+ }
393
+ /** INSERT OR IGNORE a session row so FK-bearing child rows can be written. */
394
+ ensureSession(sessionId, workspaceId, filePath, ts) {
395
+ this.stInsertSession.run(sessionId, workspaceId, filePath, ts, ts);
396
+ }
397
+ writeTurn(turn, workspaceId) {
398
+ const priced = this.pricing.price(turn.model, {
399
+ inputTokens: turn.inputTokens,
400
+ outputTokens: turn.outputTokens,
401
+ cacheReadTokens: turn.cacheReadTokens,
402
+ cacheWrite5m: turn.cacheWrite5m,
403
+ cacheWrite1h: turn.cacheWrite1h,
404
+ cacheWriteOther: turn.cacheWriteOther,
405
+ });
406
+ const res = this.stInsertTurn.run(turn.messageId, turn.sessionId, workspaceId, turn.ts, turn.model, turn.isSidechain ? 1 : 0, turn.inputTokens, turn.outputTokens, turn.thinkingTokens, turn.cacheReadTokens, turn.cacheWrite5m, turn.cacheWrite1h, turn.cacheWriteOther, null, // tool_result_bytes filled by correlation
407
+ priced.snapshotId, priced.costU, priced.claim, 1, // provisional until reconciled
408
+ turn.effort, PARSER_VERSION);
409
+ if (res.changes === 0) {
410
+ // Duplicate message_id — a true no-op for aggregates.
411
+ this.health.duplicateDrop();
412
+ return;
413
+ }
414
+ // New turn: the session is already ensured; advance its aggregates.
415
+ this.stBumpSession.run(priced.costU ?? 0, turn.ts, turn.ts, turn.ts, turn.ts, turn.sessionId);
416
+ this.health.turnIngested(PARSER_VERSION);
417
+ // If tool_result bytes already accumulated for this turn (results seen first
418
+ // is impossible within a file, but the owner map may hold late updates).
419
+ const bytes = this.resultBytesByMsg.get(turn.messageId);
420
+ if (bytes !== undefined)
421
+ this.stSetResultBytes.run(bytes, turn.messageId);
422
+ }
423
+ insertToolEvent(te) {
424
+ this.stInsertToolEvent.run(te.eventId, te.sessionId, te.ts, te.toolName, te.inputBytes, null, // result_bytes filled on correlation
425
+ te.inputHash, null, // exit_class
426
+ null);
427
+ this.stUpsertToolEventMetadata.run(te.eventId, te.filePathHash, te.ownerMessageId, te.blockIndex, te.testCommandHint ? 1 : 0);
428
+ }
429
+ applyToolResult(toolUseId, resultBytes, commitSha, isError) {
430
+ if (toolUseId === null)
431
+ return;
432
+ // Always enrich the event row, including after a restart or deliberate operator
433
+ // re-scan. Persisted structural metadata is authoritative for TEST_FAIL.
434
+ this.stSetToolResult.run(resultBytes, isError ? 1 : 0, toolUseId);
435
+ // Persisted ownership is also authoritative: a result can arrive after a
436
+ // daemon restart, when the process-local toolUseOwner map is empty.
437
+ this.stRefreshOwnerResultBytes.run(toolUseId);
438
+ if (this.countedResults.has(toolUseId))
439
+ return; // turn aggregate stays idempotent
440
+ this.countedResults.add(toolUseId);
441
+ const owner = this.toolUseOwner.get(toolUseId);
442
+ if (owner !== undefined) {
443
+ const next = (this.resultBytesByMsg.get(owner) ?? 0) + resultBytes;
444
+ this.resultBytesByMsg.set(owner, next);
445
+ this.stSetResultBytes.run(next, owner);
446
+ }
447
+ // Persist a commit SHA only for tool_uses that looked like git commit/push.
448
+ if (commitSha !== null && this.gitUseIds.has(toolUseId)) {
449
+ this.stSetCommitSha.run(commitSha, toolUseId);
450
+ }
451
+ }
452
+ recordCommand(sessionId, ts, command) {
453
+ const eventId = `cmd-${crypto
454
+ .createHash("sha1")
455
+ .update(`${sessionId}|${ts}|${command}`)
456
+ .digest("hex")
457
+ .slice(0, 20)}`;
458
+ this.stInsertToolEvent.run(eventId, sessionId, ts, "local_command", null, null, command, null, null);
459
+ }
460
+ applyGapAggregates(sessionId) {
461
+ const tsList = this.userTurnTsBySession.get(sessionId);
462
+ if (tsList === undefined || tsList.length === 0) {
463
+ this.clearGapAggregatesUnlessRicher(sessionId);
464
+ return;
465
+ }
466
+ const sorted = [...tsList].sort((a, b) => a - b);
467
+ const gaps = [];
468
+ for (let i = 1; i < sorted.length; i++) {
469
+ gaps.push((sorted[i] - sorted[i - 1]) / 1000);
470
+ }
471
+ const gapN = gaps.length;
472
+ if (gapN === 0) {
473
+ this.clearGapAggregatesUnlessRicher(sessionId);
474
+ return;
475
+ }
476
+ // gapN >= 1: a real gap was observed in-process. Always write it — even a partial,
477
+ // post-restart sample reflects genuine current friction (e.g. a new long gap) and
478
+ // must not be suppressed just because it's smaller than a pre-restart gap_n.
479
+ const g = [...gaps].sort((a, b) => a - b);
480
+ const mid = g.length;
481
+ const gapMedianS = mid % 2 === 1
482
+ ? g[Math.floor(mid / 2)]
483
+ : (g[mid / 2 - 1] + g[mid / 2]) / 2;
484
+ const p90Idx = Math.min(Math.ceil(0.9 * g.length) - 1, g.length - 1);
485
+ const gapP90S = g[Math.max(0, p90Idx)];
486
+ const longGapCount = g.filter((x) => x > LONG_GAP_THRESHOLD_S).length;
487
+ this.stSetGapAggregates.run(gapMedianS, gapP90S, longGapCount, gapN, sessionId);
488
+ }
489
+ // Write-guard for the "no computable gap" collapse (0 or 1 in-process user turns):
490
+ // the in-memory turn-timestamp map resets on every daemon restart, so this state is
491
+ // reached on the first post-restart user turn even when a prior cold runBackscan()
492
+ // already persisted a richer gap_n. Skip the null/zero write in that case so it
493
+ // doesn't clobber the richer aggregate.
494
+ clearGapAggregatesUnlessRicher(sessionId) {
495
+ const storedRow = this.stGetGapN.get(sessionId);
496
+ if ((storedRow?.gap_n ?? 0) > 0)
497
+ return;
498
+ this.stSetGapAggregates.run(null, null, 0, 0, sessionId);
499
+ }
500
+ quarantine(filePath, lineNo, errorClass) {
501
+ const qId = crypto
502
+ .createHash("sha1")
503
+ .update(`${filePath}|${lineNo}|${errorClass}`)
504
+ .digest("hex")
505
+ .slice(0, 24);
506
+ this.stInsertQuarantine.run(qId, filePath, lineNo, errorClass, PARSER_VERSION, this.opts.now().toISOString());
507
+ this.health.quarantined();
508
+ }
509
+ }