@agentdeck/bridge 1.0.24 → 1.2.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 (184) hide show
  1. package/README.md +14 -6
  2. package/assets/fonts/NOTICE-PocketSansWorld.md +10 -0
  3. package/assets/fonts/OFL-PocketSansWorld.txt +93 -0
  4. package/assets/fonts/PocketSansWorld_12.cpfont +0 -0
  5. package/assets/fonts/pocket-sans-world.manifest.json +49 -0
  6. package/assets/learning/LICENSE-CC-BY-SA-4.0.txt +10 -0
  7. package/assets/learning/NOTICE.md +33 -0
  8. package/assets/learning/jp-n3-ko.manifest.json +27 -0
  9. package/assets/learning/jp-n3-ko.pdl +0 -0
  10. package/dist/adapters/openclaw.d.ts +74 -4
  11. package/dist/adapters/openclaw.d.ts.map +1 -1
  12. package/dist/adapters/openclaw.js +235 -43
  13. package/dist/adapters/openclaw.js.map +1 -1
  14. package/dist/agent-cli-diagnostics.d.ts +20 -0
  15. package/dist/agent-cli-diagnostics.d.ts.map +1 -0
  16. package/dist/agent-cli-diagnostics.js +100 -0
  17. package/dist/agent-cli-diagnostics.js.map +1 -0
  18. package/dist/apme/adapters/openclaw-hook.d.ts +38 -10
  19. package/dist/apme/adapters/openclaw-hook.d.ts.map +1 -1
  20. package/dist/apme/adapters/openclaw-hook.js +181 -37
  21. package/dist/apme/adapters/openclaw-hook.js.map +1 -1
  22. package/dist/apme/collector.d.ts +55 -2
  23. package/dist/apme/collector.d.ts.map +1 -1
  24. package/dist/apme/collector.js +324 -37
  25. package/dist/apme/collector.js.map +1 -1
  26. package/dist/apme/dashboard-html.d.ts.map +1 -1
  27. package/dist/apme/dashboard-html.js +275 -35
  28. package/dist/apme/dashboard-html.js.map +1 -1
  29. package/dist/apme/http.d.ts.map +1 -1
  30. package/dist/apme/http.js +33 -5
  31. package/dist/apme/http.js.map +1 -1
  32. package/dist/apme/index.d.ts.map +1 -1
  33. package/dist/apme/index.js +6 -2
  34. package/dist/apme/index.js.map +1 -1
  35. package/dist/apme/pareto.d.ts +2 -1
  36. package/dist/apme/pareto.d.ts.map +1 -1
  37. package/dist/apme/pareto.js +15 -4
  38. package/dist/apme/pareto.js.map +1 -1
  39. package/dist/apme/recommend.d.ts +24 -1
  40. package/dist/apme/recommend.d.ts.map +1 -1
  41. package/dist/apme/recommend.js +43 -7
  42. package/dist/apme/recommend.js.map +1 -1
  43. package/dist/apme/store.d.ts +47 -1
  44. package/dist/apme/store.d.ts.map +1 -1
  45. package/dist/apme/store.js +397 -99
  46. package/dist/apme/store.js.map +1 -1
  47. package/dist/bridge-core.d.ts +25 -1
  48. package/dist/bridge-core.d.ts.map +1 -1
  49. package/dist/bridge-core.js +50 -9
  50. package/dist/bridge-core.js.map +1 -1
  51. package/dist/card-feed.d.ts +46 -1
  52. package/dist/card-feed.d.ts.map +1 -1
  53. package/dist/card-feed.js +166 -6
  54. package/dist/card-feed.js.map +1 -1
  55. package/dist/cli.d.ts +56 -0
  56. package/dist/cli.d.ts.map +1 -1
  57. package/dist/cli.js +442 -34
  58. package/dist/cli.js.map +1 -1
  59. package/dist/codex-rate-limits-live.d.ts +180 -10
  60. package/dist/codex-rate-limits-live.d.ts.map +1 -1
  61. package/dist/codex-rate-limits-live.js +475 -22
  62. package/dist/codex-rate-limits-live.js.map +1 -1
  63. package/dist/daemon-build-identity.d.ts +124 -0
  64. package/dist/daemon-build-identity.d.ts.map +1 -0
  65. package/dist/daemon-build-identity.js +310 -0
  66. package/dist/daemon-build-identity.js.map +1 -0
  67. package/dist/daemon-server.d.ts +34 -6
  68. package/dist/daemon-server.d.ts.map +1 -1
  69. package/dist/daemon-server.js +876 -109
  70. package/dist/daemon-server.js.map +1 -1
  71. package/dist/daemon-takeover.d.ts +11 -0
  72. package/dist/daemon-takeover.d.ts.map +1 -1
  73. package/dist/daemon-takeover.js +62 -17
  74. package/dist/daemon-takeover.js.map +1 -1
  75. package/dist/daemon-ws-client.d.ts +1 -0
  76. package/dist/daemon-ws-client.d.ts.map +1 -1
  77. package/dist/daemon-ws-client.js +25 -16
  78. package/dist/daemon-ws-client.js.map +1 -1
  79. package/dist/daemon.js +1 -1
  80. package/dist/daemon.js.map +1 -1
  81. package/dist/esp32-serial.d.ts +18 -0
  82. package/dist/esp32-serial.d.ts.map +1 -1
  83. package/dist/esp32-serial.js +23 -1
  84. package/dist/esp32-serial.js.map +1 -1
  85. package/dist/font-pack.d.ts +15 -0
  86. package/dist/font-pack.d.ts.map +1 -0
  87. package/dist/font-pack.js +75 -0
  88. package/dist/font-pack.js.map +1 -0
  89. package/dist/glance-frame.d.ts.map +1 -1
  90. package/dist/glance-frame.js +5 -1
  91. package/dist/glance-frame.js.map +1 -1
  92. package/dist/hook-codex-sessions.d.ts +3 -1
  93. package/dist/hook-codex-sessions.d.ts.map +1 -1
  94. package/dist/hook-codex-sessions.js +27 -7
  95. package/dist/hook-codex-sessions.js.map +1 -1
  96. package/dist/http-auth-gate.d.ts.map +1 -1
  97. package/dist/http-auth-gate.js +9 -1
  98. package/dist/http-auth-gate.js.map +1 -1
  99. package/dist/index.js +2 -2
  100. package/dist/index.js.map +1 -1
  101. package/dist/learning-pack.d.ts +24 -0
  102. package/dist/learning-pack.d.ts.map +1 -0
  103. package/dist/learning-pack.js +125 -0
  104. package/dist/learning-pack.js.map +1 -0
  105. package/dist/legacy-rearm-ledger.d.ts +56 -0
  106. package/dist/legacy-rearm-ledger.d.ts.map +1 -0
  107. package/dist/legacy-rearm-ledger.js +66 -0
  108. package/dist/legacy-rearm-ledger.js.map +1 -0
  109. package/dist/mdns.d.ts +33 -0
  110. package/dist/mdns.d.ts.map +1 -1
  111. package/dist/mdns.js +92 -3
  112. package/dist/mdns.js.map +1 -1
  113. package/dist/openclaw-session.d.ts.map +1 -1
  114. package/dist/openclaw-session.js +13 -0
  115. package/dist/openclaw-session.js.map +1 -1
  116. package/dist/openclaw-timeline-feed.d.ts +84 -0
  117. package/dist/openclaw-timeline-feed.d.ts.map +1 -0
  118. package/dist/openclaw-timeline-feed.js +162 -0
  119. package/dist/openclaw-timeline-feed.js.map +1 -0
  120. package/dist/openclaw-transcript-timeline.d.ts +103 -0
  121. package/dist/openclaw-transcript-timeline.d.ts.map +1 -0
  122. package/dist/openclaw-transcript-timeline.js +328 -0
  123. package/dist/openclaw-transcript-timeline.js.map +1 -0
  124. package/dist/pairing-knocks.d.ts +80 -0
  125. package/dist/pairing-knocks.d.ts.map +1 -0
  126. package/dist/pairing-knocks.js +312 -0
  127. package/dist/pairing-knocks.js.map +1 -0
  128. package/dist/passive-observer.d.ts +21 -1
  129. package/dist/passive-observer.d.ts.map +1 -1
  130. package/dist/passive-observer.js +37 -3
  131. package/dist/passive-observer.js.map +1 -1
  132. package/dist/pixoo/micro-glyphs.d.ts +4 -0
  133. package/dist/pixoo/micro-glyphs.d.ts.map +1 -1
  134. package/dist/pixoo/micro-glyphs.js +29 -0
  135. package/dist/pixoo/micro-glyphs.js.map +1 -1
  136. package/dist/pixoo/pixoo-font.d.ts.map +1 -1
  137. package/dist/pixoo/pixoo-font.js +3 -1
  138. package/dist/pixoo/pixoo-font.js.map +1 -1
  139. package/dist/pixoo/pixoo-renderer.d.ts +1 -1
  140. package/dist/pixoo/pixoo-renderer.d.ts.map +1 -1
  141. package/dist/pixoo/pixoo-renderer.js +17 -2
  142. package/dist/pixoo/pixoo-renderer.js.map +1 -1
  143. package/dist/pty-manager.d.ts +9 -0
  144. package/dist/pty-manager.d.ts.map +1 -1
  145. package/dist/pty-manager.js +63 -4
  146. package/dist/pty-manager.js.map +1 -1
  147. package/dist/relayed-usage.d.ts +98 -0
  148. package/dist/relayed-usage.d.ts.map +1 -0
  149. package/dist/relayed-usage.js +103 -0
  150. package/dist/relayed-usage.js.map +1 -0
  151. package/dist/session-aggregator.d.ts +3 -0
  152. package/dist/session-aggregator.d.ts.map +1 -1
  153. package/dist/session-aggregator.js.map +1 -1
  154. package/dist/session-registry.d.ts +1 -0
  155. package/dist/session-registry.d.ts.map +1 -1
  156. package/dist/surface-protocol.d.ts +84 -0
  157. package/dist/surface-protocol.d.ts.map +1 -0
  158. package/dist/surface-protocol.js +237 -0
  159. package/dist/surface-protocol.js.map +1 -0
  160. package/dist/timeline-store.d.ts +1 -1
  161. package/dist/timeline-store.d.ts.map +1 -1
  162. package/dist/timeline-store.js +16 -1
  163. package/dist/timeline-store.js.map +1 -1
  164. package/dist/tui/dashboard.d.ts.map +1 -1
  165. package/dist/tui/dashboard.js +8 -1
  166. package/dist/tui/dashboard.js.map +1 -1
  167. package/dist/tui/renderer.d.ts.map +1 -1
  168. package/dist/tui/renderer.js +35 -0
  169. package/dist/tui/renderer.js.map +1 -1
  170. package/dist/weather-config.d.ts +17 -0
  171. package/dist/weather-config.d.ts.map +1 -0
  172. package/dist/weather-config.js +54 -0
  173. package/dist/weather-config.js.map +1 -0
  174. package/dist/weather.d.ts +44 -4
  175. package/dist/weather.d.ts.map +1 -1
  176. package/dist/weather.js +223 -15
  177. package/dist/weather.js.map +1 -1
  178. package/dist/ws-server.d.ts +18 -0
  179. package/dist/ws-server.d.ts.map +1 -1
  180. package/dist/ws-server.js +95 -7
  181. package/dist/ws-server.js.map +1 -1
  182. package/fm-helper/AgentDeckFMHelper.swift +51 -5
  183. package/package.json +4 -3
  184. package/src/timebox/sync_ble.py +35 -16
@@ -15,13 +15,143 @@ import { debug } from '../logger.js';
15
15
  // workspace symlinks, regardless of the process CWD (vitest runs from the
16
16
  // repo root, where the symlink doesn't exist).
17
17
  const require = createRequire(import.meta.url);
18
+ import { TASK_ATTENTION_WINDOW_MS, TASK_ATTENTION_RED_SCORE } from '@agentdeck/shared';
18
19
  // ─── Schema ────────────────────────────────────────────────────────────────────
20
+ const SCORECARD_DDL = `
21
+ -- Pre-aggregate metrics at the level they actually describe. New rows prefer
22
+ -- turn attribution; runs with no turn identity remain available as a legacy
23
+ -- fallback instead of being silently dropped after the schema migration.
24
+ CREATE VIEW IF NOT EXISTS v_run_metrics AS
25
+ SELECT
26
+ run_id,
27
+ MAX(CASE WHEN metric='overall' AND layer='llm_judge' THEN score END) AS overall,
28
+ MAX(CASE WHEN metric='tests_pass' AND layer='deterministic' THEN score END) AS tests_pass
29
+ FROM evals
30
+ GROUP BY run_id;
31
+
32
+ CREATE VIEW IF NOT EXISTS v_model_scorecard AS
33
+ WITH turn_eval AS (
34
+ SELECT
35
+ turn_id,
36
+ MAX(CASE WHEN metric='overall' THEN score END) AS overall,
37
+ MAX(CASE WHEN metric='tests_pass' THEN score END) AS tests_pass
38
+ FROM evals
39
+ WHERE turn_id IS NOT NULL
40
+ GROUP BY turn_id
41
+ ), turn_cost AS (
42
+ SELECT
43
+ turn_id,
44
+ CASE WHEN MIN(COALESCE(cost_known, 0))=1 THEN SUM(cost_usd) ELSE NULL END AS cost_usd,
45
+ MIN(COALESCE(cost_known, 0)) AS cost_known
46
+ FROM sample_events
47
+ WHERE turn_id IS NOT NULL AND kind='model'
48
+ GROUP BY turn_id
49
+ ), attributed_units AS (
50
+ SELECT
51
+ r.agent_type AS agent_type,
52
+ t.run_id AS run_id,
53
+ t.id AS turn_id,
54
+ t.model_id AS model_id,
55
+ COALESCE(t.provider, r.provider) AS provider,
56
+ COALESCE(
57
+ t.composite_score,
58
+ e.overall
59
+ ) AS overall,
60
+ e.tests_pass AS tests_pass,
61
+ c.cost_usd AS cost_usd,
62
+ COALESCE(c.cost_known, 0) AS cost_known
63
+ FROM turns t
64
+ JOIN runs r ON r.id=t.run_id
65
+ LEFT JOIN turn_eval e ON e.turn_id=t.id
66
+ LEFT JOIN turn_cost c ON c.turn_id=t.id
67
+ WHERE t.model_id IS NOT NULL AND t.model_id != ''
68
+ ), legacy_units AS (
69
+ SELECT
70
+ r.agent_type AS agent_type,
71
+ r.id AS run_id,
72
+ NULL AS turn_id,
73
+ COALESCE(r.model_id, 'unknown') AS model_id,
74
+ r.provider AS provider,
75
+ m.overall AS overall,
76
+ m.tests_pass AS tests_pass,
77
+ CASE WHEN r.cost_known=1 THEN r.cost_usd ELSE NULL END AS cost_usd,
78
+ COALESCE(r.cost_known, 0) AS cost_known
79
+ FROM runs r
80
+ LEFT JOIN v_run_metrics m ON m.run_id=r.id
81
+ WHERE NOT EXISTS (
82
+ SELECT 1 FROM turns t
83
+ WHERE t.run_id=r.id AND t.model_id IS NOT NULL AND t.model_id != ''
84
+ )
85
+ ), units AS (
86
+ SELECT * FROM attributed_units
87
+ UNION ALL
88
+ SELECT * FROM legacy_units
89
+ )
90
+ SELECT
91
+ agent_type,
92
+ model_id,
93
+ provider,
94
+ COUNT(DISTINCT run_id) AS runs,
95
+ AVG(overall) AS avg_overall,
96
+ AVG(tests_pass) AS avg_tests_pass,
97
+ CASE WHEN MIN(cost_known)=1 THEN SUM(cost_usd) ELSE NULL END AS total_cost,
98
+ MIN(cost_known) AS cost_known,
99
+ CASE WHEN MIN(cost_known)=1 AND AVG(overall)>0 THEN SUM(cost_usd)/AVG(overall) ELSE NULL END AS cost_per_quality
100
+ FROM units
101
+ GROUP BY agent_type, model_id, provider;
102
+
103
+ CREATE VIEW IF NOT EXISTS v_category_scorecard AS
104
+ WITH task_metrics AS (
105
+ SELECT
106
+ task_id,
107
+ MAX(CASE WHEN metric='tests_pass' AND layer='deterministic' THEN score END) AS tests_pass
108
+ FROM evals
109
+ WHERE task_id IS NOT NULL
110
+ GROUP BY task_id
111
+ )
112
+ SELECT
113
+ t.task_category AS task_category,
114
+ COALESCE(t.model_id, r.model_id, 'unknown') AS model_id,
115
+ COALESCE(t.provider, r.provider) AS provider,
116
+ COUNT(DISTINCT t.run_id) AS runs,
117
+ AVG(t.composite_score) AS avg_overall,
118
+ AVG(m.tests_pass) AS avg_tests_pass,
119
+ CASE WHEN MIN(COALESCE(t.cost_known, 0))=1 THEN SUM(t.cost_usd) ELSE NULL END AS total_cost,
120
+ MIN(COALESCE(t.cost_known, 0)) AS cost_known
121
+ FROM tasks t
122
+ JOIN runs r ON r.id=t.run_id
123
+ LEFT JOIN task_metrics m ON m.task_id=t.id
124
+ WHERE t.task_category IS NOT NULL AND t.task_category != 'unknown'
125
+ GROUP BY t.task_category, COALESCE(t.model_id, r.model_id, 'unknown'), COALESCE(t.provider, r.provider);
126
+
127
+ -- Sample-granularity scorecard: quality vs cost per (agent, provider, model,
128
+ -- category). A task that used more than one model is stored as 'mixed' rather
129
+ -- than attributed to whichever assistant message happened to arrive last.
130
+ CREATE VIEW IF NOT EXISTS v_sample_scorecard AS
131
+ SELECT
132
+ r.agent_type AS agent_type,
133
+ COALESCE(t.model_id, r.model_id, 'unknown') AS model_id,
134
+ COALESCE(t.provider, r.provider) AS provider,
135
+ t.task_category AS task_category,
136
+ COUNT(*) AS samples,
137
+ AVG(t.composite_score) AS avg_quality,
138
+ CASE WHEN MIN(COALESCE(t.cost_known, 0))=1 THEN SUM(t.cost_usd) ELSE NULL END AS total_cost,
139
+ MIN(COALESCE(t.cost_known, 0)) AS cost_known,
140
+ AVG(t.latency_ms) AS avg_latency_ms,
141
+ CASE WHEN MIN(COALESCE(t.cost_known, 0))=1 AND AVG(t.composite_score)>0
142
+ THEN SUM(t.cost_usd)/AVG(t.composite_score) ELSE NULL END AS cost_per_quality
143
+ FROM tasks t
144
+ JOIN runs r ON r.id=t.run_id
145
+ WHERE t.ended_at IS NOT NULL AND t.composite_score IS NOT NULL
146
+ GROUP BY r.agent_type, COALESCE(t.model_id, r.model_id, 'unknown'), COALESCE(t.provider, r.provider), t.task_category;
147
+ `;
19
148
  const DDL = `
20
149
  CREATE TABLE IF NOT EXISTS runs (
21
150
  id TEXT PRIMARY KEY,
22
151
  session_id TEXT NOT NULL,
23
152
  agent_type TEXT NOT NULL,
24
153
  model_id TEXT,
154
+ provider TEXT,
25
155
  project_name TEXT,
26
156
  project_path TEXT,
27
157
  task_prompt TEXT,
@@ -30,6 +160,7 @@ CREATE TABLE IF NOT EXISTS runs (
30
160
  input_tokens INTEGER,
31
161
  output_tokens INTEGER,
32
162
  cost_usd REAL,
163
+ cost_known INTEGER NOT NULL DEFAULT 0,
33
164
  exit_code INTEGER,
34
165
  git_before TEXT,
35
166
  git_after TEXT,
@@ -57,6 +188,8 @@ CREATE TABLE IF NOT EXISTS turns (
57
188
  run_id TEXT NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
58
189
  task_id TEXT,
59
190
  turn_index INTEGER NOT NULL,
191
+ model_id TEXT,
192
+ provider TEXT,
60
193
  prompt TEXT,
61
194
  response TEXT,
62
195
  started_at INTEGER NOT NULL,
@@ -99,10 +232,12 @@ CREATE TABLE IF NOT EXISTS tasks (
99
232
  task_category TEXT,
100
233
  notes_json TEXT,
101
234
  model_id TEXT,
235
+ provider TEXT,
102
236
  model_config TEXT,
103
237
  input_tokens INTEGER,
104
238
  output_tokens INTEGER,
105
239
  cost_usd REAL,
240
+ cost_known INTEGER NOT NULL DEFAULT 0,
106
241
  latency_ms INTEGER
107
242
  );
108
243
 
@@ -123,6 +258,7 @@ CREATE TABLE IF NOT EXISTS sample_events (
123
258
  input_tokens INTEGER,
124
259
  output_tokens INTEGER,
125
260
  cost_usd REAL,
261
+ cost_known INTEGER NOT NULL DEFAULT 0,
126
262
  latency_ms INTEGER,
127
263
  tool_name TEXT,
128
264
  tool_status TEXT,
@@ -181,67 +317,7 @@ CREATE INDEX IF NOT EXISTS idx_runs_started ON runs(started_at);
181
317
  CREATE INDEX IF NOT EXISTS idx_evals_run ON evals(run_id);
182
318
  CREATE INDEX IF NOT EXISTS idx_steps_run ON steps(run_id);
183
319
 
184
- -- Pre-aggregate per-run eval metrics to avoid inflating cost_usd when
185
- -- multiple eval rows exist per run (e.g. 3 deterministic + 5 judge axes).
186
- CREATE VIEW IF NOT EXISTS v_run_metrics AS
187
- SELECT
188
- run_id,
189
- MAX(CASE WHEN metric='overall' AND layer='llm_judge' THEN score END) AS overall,
190
- MAX(CASE WHEN metric='tests_pass' AND layer='deterministic' THEN score END) AS tests_pass
191
- FROM evals
192
- GROUP BY run_id;
193
-
194
- CREATE VIEW IF NOT EXISTS v_model_scorecard AS
195
- SELECT
196
- r.agent_type AS agent_type,
197
- COALESCE(r.model_id, 'unknown') AS model_id,
198
- COUNT(*) AS runs,
199
- AVG(m.overall) AS avg_overall,
200
- AVG(m.tests_pass) AS avg_tests_pass,
201
- SUM(r.cost_usd) AS total_cost,
202
- CASE
203
- WHEN AVG(m.overall) > 0
204
- THEN SUM(r.cost_usd) / AVG(m.overall)
205
- ELSE NULL
206
- END AS cost_per_quality
207
- FROM runs r
208
- LEFT JOIN v_run_metrics m ON m.run_id = r.id
209
- GROUP BY r.agent_type, r.model_id;
210
-
211
- CREATE VIEW IF NOT EXISTS v_category_scorecard AS
212
- SELECT
213
- r.task_category AS task_category,
214
- COALESCE(r.model_id, 'unknown') AS model_id,
215
- COUNT(*) AS runs,
216
- AVG(m.overall) AS avg_overall,
217
- AVG(m.tests_pass) AS avg_tests_pass,
218
- SUM(r.cost_usd) AS total_cost
219
- FROM runs r
220
- LEFT JOIN v_run_metrics m ON m.run_id = r.id
221
- WHERE r.task_category IS NOT NULL AND r.task_category != 'unknown'
222
- GROUP BY r.task_category, r.model_id;
223
-
224
- -- Sample-granularity scorecard: quality vs cost per (agent, model, category).
225
- -- The recommender + Pareto frontier read this. Uses the task's own model_id /
226
- -- cost (the sample header), falling back to the run's model when unset.
227
- CREATE VIEW IF NOT EXISTS v_sample_scorecard AS
228
- SELECT
229
- r.agent_type AS agent_type,
230
- COALESCE(t.model_id, r.model_id, 'unknown') AS model_id,
231
- t.task_category AS task_category,
232
- COUNT(*) AS samples,
233
- AVG(t.composite_score) AS avg_quality,
234
- SUM(t.cost_usd) AS total_cost,
235
- AVG(t.latency_ms) AS avg_latency_ms,
236
- CASE
237
- WHEN AVG(t.composite_score) > 0
238
- THEN SUM(t.cost_usd) / AVG(t.composite_score)
239
- ELSE NULL
240
- END AS cost_per_quality
241
- FROM tasks t
242
- JOIN runs r ON r.id = t.run_id
243
- WHERE t.ended_at IS NOT NULL AND t.composite_score IS NOT NULL
244
- GROUP BY r.agent_type, COALESCE(t.model_id, r.model_id, 'unknown'), t.task_category;
320
+ ${SCORECARD_DDL}
245
321
  `;
246
322
  // ─── Default rubric v1 (seeded on first boot) ──────────────────────────────────
247
323
  const DEFAULT_RUBRIC_V1 = {
@@ -455,6 +531,81 @@ Examples of well-calibrated overall scores:
455
531
  notes: 'task-unit rollup (TodoWrite all-completed / /clear / session_end)',
456
532
  },
457
533
  };
534
+ /** The latest overall judge score for a task, else its composite. Inlined into
535
+ * several `TASK_VIEW_SQL` buckets so "judged" means one thing everywhere. */
536
+ const TASK_SCORE_SQL = `COALESCE((SELECT e.score FROM evals e WHERE e.task_id = t.id AND e.metric = 'overall'
537
+ ORDER BY e.created_at DESC LIMIT 1), t.composite_score)`;
538
+ /** The recency window and red-score band live in shared/src/eval-schema.ts
539
+ * (TASK_ATTENTION_WINDOW_MS / TASK_ATTENTION_RED_SCORE): both daemons build
540
+ * the same attention SQL, so the numbers are cross-daemon contracts, not
541
+ * store-local tuning knobs. Re-exported for existing importers. */
542
+ export { TASK_ATTENTION_WINDOW_MS } from '@agentdeck/shared';
543
+ /** The attention bucket, anchored at a caller-supplied cutoff (epoch ms) so
544
+ * the filter, the row flag and the badge are computed against the SAME
545
+ * instant within one request. Needs eyes = recent AND (reaper-closed, a
546
+ * closed task with an unarchived reply the judge can only partly see, or a
547
+ * judged score below the dashboard's red band). `cutoffMs` is always a
548
+ * number we computed — never user input — so inlining it is safe. */
549
+ function taskAttentionSql(cutoffMs) {
550
+ // IFNULL is load-bearing: a NULL score makes `score < 0.4` NULL, NULL OR
551
+ // false is NULL, and SQLite sorts NULL LAST under DESC — so without it,
552
+ // recent unjudged rows sank BELOW week-old rows (which get a determinate 0
553
+ // from the cutoff test) on the attention-first ordering.
554
+ return `IFNULL((t.started_at >= ${Math.floor(cutoffMs)}
555
+ AND (t.boundary_signal = 'orphaned'
556
+ OR (t.ended_at IS NOT NULL AND EXISTS (
557
+ SELECT 1 FROM turns tu WHERE tu.task_id = t.id AND tu.response IS NULL))
558
+ OR ${TASK_SCORE_SQL} < ${TASK_ATTENTION_RED_SCORE})), 0)`;
559
+ }
560
+ /** The NARROWING filters `listTaskPage` and `taskViewCounts` share — one
561
+ * builder so a filtered board's badges and its rows read the same WHERE. */
562
+ function buildTaskFilterWhere(opts) {
563
+ const where = [];
564
+ const args = [];
565
+ // `_empty` runs are bookkeeping shells, never work the user did.
566
+ where.push("COALESCE(r.task_category, '') != '_empty'");
567
+ if (opts.agentType) {
568
+ where.push('r.agent_type = ?');
569
+ args.push(opts.agentType);
570
+ }
571
+ if (opts.sessionId) {
572
+ where.push('r.session_id = ?');
573
+ args.push(opts.sessionId);
574
+ }
575
+ if (opts.projectName) {
576
+ where.push('r.project_name = ?');
577
+ args.push(opts.projectName);
578
+ }
579
+ if (opts.category) {
580
+ where.push('COALESCE(t.task_category, r.task_category) = ?');
581
+ args.push(opts.category);
582
+ }
583
+ if (opts.outcome) {
584
+ where.push('t.outcome = ?');
585
+ args.push(opts.outcome);
586
+ }
587
+ if (opts.q) {
588
+ where.push('(t.summary LIKE ? OR r.task_prompt LIKE ?)');
589
+ const like = `%${opts.q}%`;
590
+ args.push(like, like);
591
+ }
592
+ return { where, args };
593
+ }
594
+ /** ONE SQL definition per Work-board lifecycle bucket — the filter, the row's
595
+ * `attention` flag and the tab badges all read these same expressions, so a
596
+ * badge count can never disagree with the rows its tab lists.
597
+ *
598
+ * attention — see `taskAttentionSql` (recency-windowed, hence a function).
599
+ * inprogress — still accumulating turns.
600
+ * judged — carries an overall judge score or a composite.
601
+ * reported — closed but never scored (no judge configured / skipped).
602
+ * orphaned — reaper-closed; the un-windowed ledger of segmentation debt. */
603
+ const TASK_VIEW_SQL = {
604
+ inprogress: 't.ended_at IS NULL',
605
+ judged: `${TASK_SCORE_SQL} IS NOT NULL`,
606
+ reported: `(t.ended_at IS NOT NULL AND ${TASK_SCORE_SQL} IS NULL)`,
607
+ orphaned: `t.boundary_signal = 'orphaned'`,
608
+ };
458
609
  export class ApmeStore {
459
610
  db = null;
460
611
  enabled = false;
@@ -517,6 +668,8 @@ export class ApmeStore {
517
668
  return;
518
669
  const cols = this.db.prepare("PRAGMA table_info(runs)").all().map(c => c.name);
519
670
  const migrations = [
671
+ ['provider', 'ALTER TABLE runs ADD COLUMN provider TEXT'],
672
+ ['cost_known', 'ALTER TABLE runs ADD COLUMN cost_known INTEGER NOT NULL DEFAULT 0'],
520
673
  ['task_signals', 'ALTER TABLE runs ADD COLUMN task_signals TEXT'],
521
674
  ['task_category', 'ALTER TABLE runs ADD COLUMN task_category TEXT'],
522
675
  ['task_category_source', "ALTER TABLE runs ADD COLUMN task_category_source TEXT DEFAULT 'auto'"],
@@ -560,6 +713,18 @@ export class ApmeStore {
560
713
  }
561
714
  catch { /* ignore */ }
562
715
  }
716
+ if (!turnCols.includes('model_id')) {
717
+ try {
718
+ this.db.exec('ALTER TABLE turns ADD COLUMN model_id TEXT');
719
+ }
720
+ catch { /* ignore */ }
721
+ }
722
+ if (!turnCols.includes('provider')) {
723
+ try {
724
+ this.db.exec('ALTER TABLE turns ADD COLUMN provider TEXT');
725
+ }
726
+ catch { /* ignore */ }
727
+ }
563
728
  const evalCols = this.db.prepare("PRAGMA table_info(evals)").all().map(c => c.name);
564
729
  if (!evalCols.includes('task_id')) {
565
730
  try {
@@ -572,10 +737,12 @@ export class ApmeStore {
572
737
  const taskCols = this.db.prepare("PRAGMA table_info(tasks)").all().map(c => c.name);
573
738
  for (const [col, sql] of [
574
739
  ['model_id', 'ALTER TABLE tasks ADD COLUMN model_id TEXT'],
740
+ ['provider', 'ALTER TABLE tasks ADD COLUMN provider TEXT'],
575
741
  ['model_config', 'ALTER TABLE tasks ADD COLUMN model_config TEXT'],
576
742
  ['input_tokens', 'ALTER TABLE tasks ADD COLUMN input_tokens INTEGER'],
577
743
  ['output_tokens', 'ALTER TABLE tasks ADD COLUMN output_tokens INTEGER'],
578
744
  ['cost_usd', 'ALTER TABLE tasks ADD COLUMN cost_usd REAL'],
745
+ ['cost_known', 'ALTER TABLE tasks ADD COLUMN cost_known INTEGER NOT NULL DEFAULT 0'],
579
746
  ['latency_ms', 'ALTER TABLE tasks ADD COLUMN latency_ms INTEGER'],
580
747
  ]) {
581
748
  if (!taskCols.includes(col)) {
@@ -597,6 +764,12 @@ export class ApmeStore {
597
764
  // pointer to the run it continues, so one conversation shows up as N
598
765
  // disconnected components. One live session here had 127 such runs.
599
766
  const sevCols = this.db.prepare("PRAGMA table_info(sample_events)").all().map(c => c.name);
767
+ if (!sevCols.includes('cost_known')) {
768
+ try {
769
+ this.db.exec('ALTER TABLE sample_events ADD COLUMN cost_known INTEGER NOT NULL DEFAULT 0');
770
+ }
771
+ catch { /* ignore */ }
772
+ }
600
773
  if (!sevCols.includes('turn_id')) {
601
774
  try {
602
775
  this.db.exec('ALTER TABLE sample_events ADD COLUMN turn_id TEXT');
@@ -626,6 +799,21 @@ export class ApmeStore {
626
799
  }
627
800
  catch { /* ignore */ }
628
801
  }
802
+ // A positive historical value proves that a price existed. Historical
803
+ // zeroes remain unknown because they could be either a free local model or
804
+ // the old UNKNOWN_PRICE fallback — the ambiguity this column removes.
805
+ try {
806
+ this.db.exec('UPDATE runs SET cost_known=1 WHERE cost_usd > 0');
807
+ }
808
+ catch { /* ignore */ }
809
+ try {
810
+ this.db.exec('UPDATE tasks SET cost_known=1 WHERE cost_usd > 0');
811
+ }
812
+ catch { /* ignore */ }
813
+ try {
814
+ this.db.exec('UPDATE sample_events SET cost_known=1 WHERE cost_usd > 0');
815
+ }
816
+ catch { /* ignore */ }
629
817
  // ── Covering indexes for the per-run/per-task rollups ──
630
818
  // better-sqlite3 is synchronous on a single connection, so ANY slow query
631
819
  // here stalls the daemon's whole HTTP path — this is a latency budget, not
@@ -647,6 +835,16 @@ export class ApmeStore {
647
835
  }
648
836
  catch { /* ignore */ }
649
837
  }
838
+ // CREATE VIEW IF NOT EXISTS preserves stale executable SQL forever. Rebuild
839
+ // after the ALTERs above so both fresh and upgraded databases group by the
840
+ // turn/provider identity this version actually writes.
841
+ for (const view of ['v_sample_scorecard', 'v_category_scorecard', 'v_model_scorecard', 'v_run_metrics']) {
842
+ try {
843
+ this.db.exec(`DROP VIEW IF EXISTS ${view}`);
844
+ }
845
+ catch { /* ignore */ }
846
+ }
847
+ this.db.exec(SCORECARD_DDL);
650
848
  }
651
849
  seedDefaultRubric() {
652
850
  if (!this.db)
@@ -688,11 +886,12 @@ export class ApmeStore {
688
886
  insertRun(row) {
689
887
  if (!this.db)
690
888
  return;
889
+ const costKnown = row.costKnown === true || (row.costUsd != null && row.costUsd > 0);
691
890
  this.db.prepare(`INSERT INTO runs
692
- (id, session_id, agent_type, model_id, project_name, project_path, task_prompt,
693
- started_at, ended_at, input_tokens, output_tokens, cost_usd, exit_code,
891
+ (id, session_id, agent_type, model_id, provider, project_name, project_path, task_prompt,
892
+ started_at, ended_at, input_tokens, output_tokens, cost_usd, cost_known, exit_code,
694
893
  git_before, git_after, hw_profile)
695
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(row.id, row.sessionId, row.agentType, row.modelId ?? null, row.projectName ?? null, row.projectPath ?? null, row.taskPrompt ?? null, row.startedAt, row.endedAt ?? null, row.inputTokens ?? null, row.outputTokens ?? null, row.costUsd ?? null, row.exitCode ?? null, row.gitBefore ?? null, row.gitAfter ?? null, row.hwProfile ?? null);
894
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(row.id, row.sessionId, row.agentType, row.modelId ?? null, row.provider ?? null, row.projectName ?? null, row.projectPath ?? null, row.taskPrompt ?? null, row.startedAt, row.endedAt ?? null, row.inputTokens ?? null, row.outputTokens ?? null, row.costUsd ?? null, costKnown ? 1 : 0, row.exitCode ?? null, row.gitBefore ?? null, row.gitAfter ?? null, row.hwProfile ?? null);
696
895
  }
697
896
  updateRun(id, patch) {
698
897
  if (!this.db)
@@ -701,6 +900,7 @@ export class ApmeStore {
701
900
  const values = [];
702
901
  const map = {
703
902
  modelId: 'model_id',
903
+ provider: 'provider',
704
904
  projectName: 'project_name',
705
905
  projectPath: 'project_path',
706
906
  taskPrompt: 'task_prompt',
@@ -708,6 +908,7 @@ export class ApmeStore {
708
908
  inputTokens: 'input_tokens',
709
909
  outputTokens: 'output_tokens',
710
910
  costUsd: 'cost_usd',
911
+ costKnown: 'cost_known',
711
912
  exitCode: 'exit_code',
712
913
  gitBefore: 'git_before',
713
914
  gitAfter: 'git_after',
@@ -726,7 +927,11 @@ export class ApmeStore {
726
927
  if (!col || v === undefined)
727
928
  continue;
728
929
  fields.push(`${col} = ?`);
729
- values.push(v);
930
+ values.push(k === 'costKnown' ? (v ? 1 : 0) : v);
931
+ }
932
+ if (patch.costUsd != null && patch.costUsd > 0 && patch.costKnown === undefined) {
933
+ fields.push('cost_known = ?');
934
+ values.push(1);
730
935
  }
731
936
  if (fields.length === 0)
732
937
  return;
@@ -784,6 +989,7 @@ export class ApmeStore {
784
989
  outcome: 'outcome', compositeScore: 'composite_score', efficiencyJson: 'efficiency_json',
785
990
  prompt: 'prompt', response: 'response', taskId: 'task_id',
786
991
  endSource: 'end_source',
992
+ modelId: 'model_id', provider: 'provider',
787
993
  };
788
994
  const sets = [];
789
995
  const vals = [];
@@ -886,10 +1092,12 @@ export class ApmeStore {
886
1092
  notesJson: 'notes_json',
887
1093
  boundarySignal: 'boundary_signal',
888
1094
  modelId: 'model_id',
1095
+ provider: 'provider',
889
1096
  modelConfig: 'model_config',
890
1097
  inputTokens: 'input_tokens',
891
1098
  outputTokens: 'output_tokens',
892
1099
  costUsd: 'cost_usd',
1100
+ costKnown: 'cost_known',
893
1101
  latencyMs: 'latency_ms',
894
1102
  };
895
1103
  const sets = [];
@@ -899,7 +1107,11 @@ export class ApmeStore {
899
1107
  if (!col || v === undefined)
900
1108
  continue;
901
1109
  sets.push(`${col} = ?`);
902
- vals.push(v);
1110
+ vals.push(k === 'costKnown' ? (v ? 1 : 0) : v);
1111
+ }
1112
+ if (patch.costUsd != null && patch.costUsd > 0 && patch.costKnown === undefined) {
1113
+ sets.push('cost_known = ?');
1114
+ vals.push(1);
903
1115
  }
904
1116
  if (sets.length === 0)
905
1117
  return;
@@ -945,36 +1157,18 @@ export class ApmeStore {
945
1157
  return { total: 0, tasks: [] };
946
1158
  const limit = Math.min(Math.max(opts.limit ?? 50, 1), 500);
947
1159
  const offset = Math.max(opts.offset ?? 0, 0);
948
- const where = [];
949
- const args = [];
950
- // `_empty` runs are bookkeeping shells, never work the user did.
951
- where.push("COALESCE(r.task_category, '') != '_empty'");
952
- if (opts.agentType) {
953
- where.push('r.agent_type = ?');
954
- args.push(opts.agentType);
955
- }
956
- if (opts.projectName) {
957
- where.push('r.project_name = ?');
958
- args.push(opts.projectName);
959
- }
960
- if (opts.category) {
961
- where.push('COALESCE(t.task_category, r.task_category) = ?');
962
- args.push(opts.category);
963
- }
964
- if (opts.outcome) {
965
- where.push('t.outcome = ?');
966
- args.push(opts.outcome);
967
- }
1160
+ const { where, args } = buildTaskFilterWhere(opts);
1161
+ const attentionSql = taskAttentionSql(Date.now() - TASK_ATTENTION_WINDOW_MS);
1162
+ if (opts.view)
1163
+ where.push(opts.view === 'attention' ? attentionSql : TASK_VIEW_SQL[opts.view]);
968
1164
  if (opts.state === 'closed')
969
1165
  where.push('t.ended_at IS NOT NULL');
970
1166
  if (opts.state === 'open')
971
1167
  where.push('t.ended_at IS NULL');
972
- if (opts.q) {
973
- where.push('(t.summary LIKE ? OR r.task_prompt LIKE ?)');
974
- const like = `%${opts.q}%`;
975
- args.push(like, like);
976
- }
977
1168
  const whereSql = `WHERE ${where.join(' AND ')}`;
1169
+ const orderSql = opts.order === 'attention'
1170
+ ? 'ORDER BY attention DESC, t.started_at DESC'
1171
+ : 'ORDER BY t.started_at DESC';
978
1172
  const total = this.db.prepare(`SELECT COUNT(*) AS n FROM tasks t JOIN runs r ON r.id = t.run_id ${whereSql}`).get(...args).n;
979
1173
  const rows = this.db.prepare(`SELECT t.*,
980
1174
  r.session_id, r.agent_type, r.model_id AS run_model_id, r.project_name,
@@ -983,15 +1177,78 @@ export class ApmeStore {
983
1177
  (SELECT COUNT(*) FROM turns tu WHERE tu.task_id = t.id AND tu.response IS NOT NULL) AS answered_turns,
984
1178
  (SELECT COUNT(*) FROM sample_events se WHERE se.task_id = t.id) AS event_count,
985
1179
  (SELECT COUNT(*) FROM sample_events se WHERE se.task_id = t.id AND se.kind = 'tool') AS tool_count,
1180
+ (SELECT COALESCE(SUM(COALESCE(tu.files_modified, 0) + COALESCE(tu.files_created, 0)), 0)
1181
+ FROM turns tu WHERE tu.task_id = t.id) AS files_touched,
986
1182
  (SELECT tu.prompt FROM turns tu WHERE tu.task_id = t.id ORDER BY tu.turn_index ASC LIMIT 1) AS first_prompt,
987
1183
  (SELECT e.score FROM evals e WHERE e.task_id = t.id AND e.metric = 'overall' ORDER BY e.created_at DESC LIMIT 1) AS overall_score,
988
- (SELECT COUNT(*) FROM evals e WHERE e.task_id = t.id) AS eval_count
1184
+ (SELECT COUNT(*) FROM evals e WHERE e.task_id = t.id) AS eval_count,
1185
+ (${attentionSql}) AS attention
989
1186
  FROM tasks t JOIN runs r ON r.id = t.run_id
990
1187
  ${whereSql}
991
- ORDER BY t.started_at DESC
1188
+ ${orderSql}
992
1189
  LIMIT ? OFFSET ?`).all(...args, limit, offset);
993
1190
  return { total, tasks: rows.map(rowToTaskListRow) };
994
1191
  }
1192
+ /** 10 s TTL cache for `taskViewCounts`, keyed by the filter tuple. The Work
1193
+ * board polls every 15 s and refetches on every interaction; the badges
1194
+ * are a full-table aggregate with correlated eval subqueries, so paying it
1195
+ * at most once per TTL bounds the cost of leaving the dashboard open. */
1196
+ viewCountsCache = new Map();
1197
+ /** Per-lifecycle-bucket totals for the Work board's tab badges. Takes the
1198
+ * same NARROWING filters as `listTaskPage` (agent/project/category/
1199
+ * outcome/q) so a filtered board's badges count what its tabs would list.
1200
+ * Buckets and filters read the same SQL definitions; the TTL means a badge
1201
+ * may lag its rows by up to 10 s after a task moves buckets — a bounded
1202
+ * staleness, not a different definition. */
1203
+ taskViewCounts(filters = {}) {
1204
+ const empty = { all: 0, attention: 0, inprogress: 0, judged: 0, reported: 0, orphaned: 0 };
1205
+ if (!this.db)
1206
+ return { ...empty };
1207
+ const key = JSON.stringify([filters.agentType, filters.sessionId, filters.projectName, filters.category, filters.outcome, filters.q]);
1208
+ const cached = this.viewCountsCache.get(key);
1209
+ if (cached && Date.now() - cached.at < 10_000)
1210
+ return cached.value;
1211
+ const attentionSql = taskAttentionSql(Date.now() - TASK_ATTENTION_WINDOW_MS);
1212
+ const { where, args } = buildTaskFilterWhere(filters);
1213
+ const row = this.db.prepare(`SELECT COUNT(*) AS all_n,
1214
+ SUM(CASE WHEN ${attentionSql} THEN 1 ELSE 0 END) AS attention_n,
1215
+ SUM(CASE WHEN ${TASK_VIEW_SQL.inprogress} THEN 1 ELSE 0 END) AS inprogress_n,
1216
+ SUM(CASE WHEN ${TASK_VIEW_SQL.judged} THEN 1 ELSE 0 END) AS judged_n,
1217
+ SUM(CASE WHEN ${TASK_VIEW_SQL.reported} THEN 1 ELSE 0 END) AS reported_n,
1218
+ SUM(CASE WHEN ${TASK_VIEW_SQL.orphaned} THEN 1 ELSE 0 END) AS orphaned_n
1219
+ FROM tasks t JOIN runs r ON r.id = t.run_id
1220
+ WHERE ${where.join(' AND ')}`).get(...args);
1221
+ const value = {
1222
+ all: row.all_n ?? 0,
1223
+ attention: row.attention_n ?? 0,
1224
+ inprogress: row.inprogress_n ?? 0,
1225
+ judged: row.judged_n ?? 0,
1226
+ reported: row.reported_n ?? 0,
1227
+ orphaned: row.orphaned_n ?? 0,
1228
+ };
1229
+ if (this.viewCountsCache.size > 20)
1230
+ this.viewCountsCache.clear();
1231
+ this.viewCountsCache.set(key, { at: Date.now(), value });
1232
+ return value;
1233
+ }
1234
+ /** Per-tool call counts for a page of tasks, in one grouped query. Feeds the
1235
+ * shared `foldActionCounts` projection — the store returns raw counts, the
1236
+ * HTTP layer folds. */
1237
+ toolCountsForTasks(taskIds) {
1238
+ const out = new Map();
1239
+ if (!this.db || taskIds.length === 0)
1240
+ return out;
1241
+ const placeholders = taskIds.map(() => '?').join(',');
1242
+ const rows = this.db.prepare(`SELECT task_id, tool_name, COUNT(*) AS n FROM sample_events
1243
+ WHERE kind = 'tool' AND tool_name IS NOT NULL AND task_id IN (${placeholders})
1244
+ GROUP BY task_id, tool_name`).all(...taskIds);
1245
+ for (const r of rows) {
1246
+ const list = out.get(r.task_id) ?? [];
1247
+ list.push({ name: r.tool_name, count: r.n });
1248
+ out.set(r.task_id, list);
1249
+ }
1250
+ return out;
1251
+ }
995
1252
  /** Distinct values behind the task list's filters, so the UI offers what the
996
1253
  * data actually contains rather than a hardcoded menu. */
997
1254
  taskFacets() {
@@ -1280,10 +1537,14 @@ export class ApmeStore {
1280
1537
  insertSampleEvent(row) {
1281
1538
  if (!this.db)
1282
1539
  return false;
1540
+ // A positive monetary amount is self-proving provenance even for legacy
1541
+ // callers that predate costKnown. Zero remains ambiguous and must only be
1542
+ // marked known by a pricing table / known-local producer.
1543
+ const costKnown = row.costKnown === true || (row.costUsd != null && row.costUsd > 0);
1283
1544
  const res = this.db.prepare(`INSERT OR IGNORE INTO sample_events
1284
1545
  (task_id, run_id, turn_index, turn_id, seq, ts, kind, model, input_tokens, output_tokens,
1285
- cost_usd, latency_ms, tool_name, tool_status, tool_error, payload, dedup_key)
1286
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`).run(row.taskId, row.runId, row.turnIndex ?? null, row.turnId ?? null, row.seq, row.ts, row.kind, row.model ?? null, row.inputTokens ?? null, row.outputTokens ?? null, row.costUsd ?? null, row.latencyMs ?? null, row.toolName ?? null, row.toolStatus ?? null, row.toolError ?? null, row.payload ?? null, row.dedupKey ?? null);
1546
+ cost_usd, cost_known, latency_ms, tool_name, tool_status, tool_error, payload, dedup_key)
1547
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`).run(row.taskId, row.runId, row.turnIndex ?? null, row.turnId ?? null, row.seq, row.ts, row.kind, row.model ?? null, row.inputTokens ?? null, row.outputTokens ?? null, row.costUsd ?? null, costKnown ? 1 : 0, row.latencyMs ?? null, row.toolName ?? null, row.toolStatus ?? null, row.toolError ?? null, row.payload ?? null, row.dedupKey ?? null);
1287
1548
  return res.changes > 0;
1288
1549
  }
1289
1550
  /** Update a previously-inserted event (e.g. a tool pending→resolved) by id. */
@@ -1292,7 +1553,7 @@ export class ApmeStore {
1292
1553
  return;
1293
1554
  const map = {
1294
1555
  toolStatus: 'tool_status', toolError: 'tool_error', payload: 'payload',
1295
- costUsd: 'cost_usd', latencyMs: 'latency_ms', model: 'model',
1556
+ costUsd: 'cost_usd', costKnown: 'cost_known', latencyMs: 'latency_ms', model: 'model',
1296
1557
  inputTokens: 'input_tokens', outputTokens: 'output_tokens', ts: 'ts',
1297
1558
  };
1298
1559
  const sets = [];
@@ -1302,7 +1563,11 @@ export class ApmeStore {
1302
1563
  if (!col || v === undefined)
1303
1564
  continue;
1304
1565
  sets.push(`${col} = ?`);
1305
- vals.push(v);
1566
+ vals.push(k === 'costKnown' ? (v ? 1 : 0) : v);
1567
+ }
1568
+ if (fields.costUsd != null && fields.costUsd > 0 && fields.costKnown === undefined) {
1569
+ sets.push('cost_known = ?');
1570
+ vals.push(1);
1306
1571
  }
1307
1572
  if (sets.length === 0)
1308
1573
  return;
@@ -1319,6 +1584,17 @@ export class ApmeStore {
1319
1584
  ORDER BY seq DESC LIMIT 1`).get(taskId, turnIndex, toolName);
1320
1585
  return row ? rowToSampleEvent(row) : null;
1321
1586
  }
1587
+ /** One assistant-message slot per turn. Response producers overlap, so text
1588
+ * hashes cannot be the identity: a truncated projection and the full final
1589
+ * are two payload versions of the same logical event. */
1590
+ findAssistantMessageEvent(taskId, turnId) {
1591
+ if (!this.db)
1592
+ return null;
1593
+ const row = this.db.prepare(`SELECT * FROM sample_events
1594
+ WHERE task_id = ? AND turn_id = ? AND kind = 'assistant_message'
1595
+ ORDER BY seq ASC LIMIT 1`).get(taskId, turnId);
1596
+ return row ? rowToSampleEvent(row) : null;
1597
+ }
1322
1598
  listSampleEventRows(taskId) {
1323
1599
  if (!this.db)
1324
1600
  return [];
@@ -1369,6 +1645,7 @@ export class ApmeStore {
1369
1645
  inputTokens: task.inputTokens ?? 0,
1370
1646
  outputTokens: task.outputTokens ?? 0,
1371
1647
  costUsd: task.costUsd ?? 0,
1648
+ costKnown: task.costKnown ?? false,
1372
1649
  latencyMs: task.latencyMs ?? 0,
1373
1650
  },
1374
1651
  summary: task.summary ?? null,
@@ -1382,10 +1659,15 @@ export class ApmeStore {
1382
1659
  if (!this.db)
1383
1660
  return;
1384
1661
  const row = this.db.prepare(`SELECT COALESCE(SUM(input_tokens),0) AS it, COALESCE(SUM(output_tokens),0) AS ot,
1385
- COALESCE(SUM(cost_usd),0) AS cu, COALESCE(SUM(latency_ms),0) AS lm
1662
+ CASE WHEN COUNT(*) > 0 AND MIN(COALESCE(cost_known,0))=1
1663
+ THEN SUM(cost_usd) ELSE NULL END AS cu,
1664
+ CASE WHEN COUNT(*) > 0 AND MIN(COALESCE(cost_known,0))=1
1665
+ THEN 1 ELSE 0 END AS ck,
1666
+ COALESCE(SUM(latency_ms),0) AS lm
1386
1667
  FROM sample_events WHERE task_id = ? AND kind = 'model'`).get(taskId);
1387
1668
  this.updateTask(taskId, {
1388
- inputTokens: row.it, outputTokens: row.ot, costUsd: row.cu, latencyMs: row.lm,
1669
+ inputTokens: row.it, outputTokens: row.ot, costUsd: row.cu,
1670
+ costKnown: row.ck === 1, latencyMs: row.lm,
1389
1671
  });
1390
1672
  }
1391
1673
  sampleScorecard() {
@@ -1395,10 +1677,12 @@ export class ApmeStore {
1395
1677
  return rows.map((r) => ({
1396
1678
  agentType: r.agent_type,
1397
1679
  modelId: r.model_id,
1680
+ provider: r.provider ?? null,
1398
1681
  taskCategory: r.task_category ?? null,
1399
1682
  samples: r.samples,
1400
1683
  avgQuality: r.avg_quality ?? null,
1401
1684
  totalCost: r.total_cost ?? null,
1685
+ costKnown: r.cost_known === 1,
1402
1686
  avgLatencyMs: r.avg_latency_ms ?? null,
1403
1687
  costPerQuality: r.cost_per_quality ?? null,
1404
1688
  }));
@@ -1411,10 +1695,12 @@ export class ApmeStore {
1411
1695
  return rows.map((r) => ({
1412
1696
  agentType: r.agent_type,
1413
1697
  modelId: r.model_id,
1698
+ provider: r.provider ?? null,
1414
1699
  runs: r.runs,
1415
1700
  avgOverall: r.avg_overall ?? null,
1416
1701
  avgTestsPass: r.avg_tests_pass ?? null,
1417
1702
  totalCost: r.total_cost ?? null,
1703
+ costKnown: r.cost_known === 1,
1418
1704
  costPerQuality: r.cost_per_quality ?? null,
1419
1705
  }));
1420
1706
  }
@@ -1425,10 +1711,12 @@ export class ApmeStore {
1425
1711
  return rows.map((r) => ({
1426
1712
  taskCategory: r.task_category,
1427
1713
  modelId: r.model_id,
1714
+ provider: r.provider ?? null,
1428
1715
  runs: r.runs,
1429
1716
  avgOverall: r.avg_overall ?? null,
1430
1717
  avgTestsPass: r.avg_tests_pass ?? null,
1431
1718
  totalCost: r.total_cost ?? null,
1719
+ costKnown: r.cost_known === 1,
1432
1720
  }));
1433
1721
  }
1434
1722
  }
@@ -1439,6 +1727,7 @@ function rowToRun(r) {
1439
1727
  sessionId: r.session_id,
1440
1728
  agentType: r.agent_type,
1441
1729
  modelId: r.model_id ?? null,
1730
+ provider: r.provider ?? null,
1442
1731
  projectName: r.project_name ?? null,
1443
1732
  projectPath: r.project_path ?? null,
1444
1733
  taskPrompt: r.task_prompt ?? null,
@@ -1447,6 +1736,7 @@ function rowToRun(r) {
1447
1736
  inputTokens: r.input_tokens ?? null,
1448
1737
  outputTokens: r.output_tokens ?? null,
1449
1738
  costUsd: r.cost_usd ?? null,
1739
+ costKnown: r.cost_known === 1,
1450
1740
  exitCode: r.exit_code ?? null,
1451
1741
  gitBefore: r.git_before ?? null,
1452
1742
  gitAfter: r.git_after ?? null,
@@ -1477,10 +1767,12 @@ function rowToTask(r) {
1477
1767
  taskCategory: r.task_category ?? null,
1478
1768
  notesJson: r.notes_json ?? null,
1479
1769
  modelId: r.model_id ?? null,
1770
+ provider: r.provider ?? null,
1480
1771
  modelConfig: r.model_config ?? null,
1481
1772
  inputTokens: r.input_tokens ?? null,
1482
1773
  outputTokens: r.output_tokens ?? null,
1483
1774
  costUsd: r.cost_usd ?? null,
1775
+ costKnown: r.cost_known === 1,
1484
1776
  latencyMs: r.latency_ms ?? null,
1485
1777
  };
1486
1778
  }
@@ -1497,12 +1789,17 @@ function rowToTaskListRow(r) {
1497
1789
  projectPath: r.project_path ?? null,
1498
1790
  parentRunId: r.parent_run_id ?? null,
1499
1791
  firstPrompt: r.first_prompt ?? r.run_prompt ?? null,
1792
+ // The task's OWN first turn prompt, with NO run-prompt fallback: title
1793
+ // derivation must never name task 2+ of a split run after task 0's intent.
1794
+ ownFirstPrompt: r.first_prompt ?? null,
1500
1795
  turnCount: r.turn_count ?? 0,
1501
1796
  answeredTurns: r.answered_turns ?? 0,
1502
1797
  eventCount: r.event_count ?? 0,
1503
1798
  toolCount: r.tool_count ?? 0,
1799
+ filesTouched: r.files_touched ?? 0,
1504
1800
  evalCount: r.eval_count ?? 0,
1505
1801
  overallScore: r.overall_score ?? r.composite_score ?? null,
1802
+ attention: r.attention === 1,
1506
1803
  };
1507
1804
  }
1508
1805
  function rowToSampleEvent(r) {
@@ -1519,6 +1816,7 @@ function rowToSampleEvent(r) {
1519
1816
  inputTokens: r.input_tokens ?? null,
1520
1817
  outputTokens: r.output_tokens ?? null,
1521
1818
  costUsd: r.cost_usd ?? null,
1819
+ costKnown: r.cost_known === 1,
1522
1820
  latencyMs: r.latency_ms ?? null,
1523
1821
  toolName: r.tool_name ?? null,
1524
1822
  toolStatus: r.tool_status ?? null,