@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
@@ -18,7 +18,7 @@ import { mkdirSync, writeFileSync } from 'fs';
18
18
  import { homedir } from 'os';
19
19
  import { debug } from '../logger.js';
20
20
  import { resolveProjectName } from '../utils/project-name.js';
21
- import { priceUsd, providerFor } from '@agentdeck/shared';
21
+ import { AGENT_IDLE_GAP_MS, deriveTaskTitle, isPricedModel, normalizeModelProvider, priceUsd } from '@agentdeck/shared';
22
22
  import { classifyRunSmart, computeSignals, classify } from './classifier.js';
23
23
  import { readOpenTurnEvidence } from './claude-transcript-reader.js';
24
24
  /** A turn_start with the same prompt landing on a fresh, still-empty turn
@@ -32,10 +32,42 @@ const DUPLICATE_TURN_OPEN_WINDOW_MS = 15_000;
32
32
  * a cancel milliseconds into a turn reads as belonging to the previous one.
33
33
  * Matches the watchdog's `TURN_OPEN_SLACK_MS` — same comparison, same risk. */
34
34
  const TURN_INTERRUPT_SLACK_MS = 2_000;
35
+ /**
36
+ * Is `echo` the same user prompt as `original`, arriving a second time?
37
+ *
38
+ * Two producers open a turn for one prompt: our own `chat.send` span, which
39
+ * carries the text verbatim, and the Gateway's `session.message`(user) echo,
40
+ * which has been trimmed, envelope-stripped and capped at 8,000 characters
41
+ * before it reaches us. Exact equality therefore misses on inputs as ordinary
42
+ * as a trailing newline — and a miss is not benign: the duplicate then closes
43
+ * the open turn with `resolveDisplacedTurnSource`, which for a non-`claude-code`
44
+ * agent returns `next_prompt`, i.e. the unrecovered-Stop-loss bucket. A stray
45
+ * "\n" would invent a Stop loss for an agent that has no Stop hook at all,
46
+ * strand an empty turn, and shift every later `turn_index`.
47
+ *
48
+ * Comparing TRIMMED is the whole fix, and deliberately all of it. A first pass
49
+ * here also allowed a prefix match when the shorter side sat at the Gateway's
50
+ * 8,000-character cap — that branch could never fire: both operands reach this
51
+ * function through the caller's own `slice(0, 8_000)`, so "the shorter one is
52
+ * at 8,000" forces both to be exactly 8,000 characters, and two equal-length
53
+ * strings where one is a prefix of the other are identical, which the equality
54
+ * check above already answered. Reintroducing truncation tolerance means
55
+ * comparing BEFORE that slice, not adding a branch after it.
56
+ *
57
+ * Known and NOT closed by this: because the caller slices first, two genuinely
58
+ * different prompts that share their first 8,000 characters compare equal and
59
+ * collapse into one turn — an eval harness sending a long fixed preamble with a
60
+ * differing tail is exactly that shape. That predates this comparison and is
61
+ * fixed by keying on a hash of the unsliced prompt, not here.
62
+ */
63
+ function samePromptEcho(original, echo) {
64
+ return original.trim() === echo.trim();
65
+ }
35
66
  export class ApmeCollector {
36
67
  store;
37
68
  hwSampler;
38
69
  openTurnProbe;
70
+ idleGapMs;
39
71
  sessionToRun = new Map(); // sessionId → runId
40
72
  sessionToAgentType = new Map(); // sessionId → agentType (survives closeRun for late-attributed timeline rows)
41
73
  sessionToTurn = new Map(); // sessionId → current turn
@@ -43,6 +75,13 @@ export class ApmeCollector {
43
75
  sessionToTask = new Map(); // sessionId → current task
44
76
  runTaskCount = new Map(); // runId → next task_index
45
77
  sessionToUsage = new Map(); // last cumulative usage
78
+ /** Running cost total for producers that report PER-MESSAGE cost.
79
+ * Separate from `sessionToUsage` because `updateUsage` writes the cost
80
+ * column absolutely and would otherwise stamp one message's cost as the
81
+ * run's. `seen` keeps a reported ZERO distinct from nothing reported —
82
+ * glm-5.2 answers `usage.cost.total = 0` on every message, so folding the
83
+ * two would record "free" as "unknown" for a whole class of run. */
84
+ sessionToCostTotal = new Map();
46
85
  /** Optional listener fired after `closeTask` persists the row. The runner
47
86
  * wires this to enqueue a task-level judge call. */
48
87
  onTaskClosed = null;
@@ -58,14 +97,21 @@ export class ApmeCollector {
58
97
  /** Last milestone key (`taskId:turnIndex`) per session — a turn can carry
59
98
  * several all-completed TodoWrite calls; surface only the first. */
60
99
  sessionToLastMilestone = new Map();
100
+ /** Per-session idle-gap timers — armed in `closeTurn`, cleared on the next
101
+ * turn open / task close. See `AGENT_IDLE_GAP_MS` for the measurement the
102
+ * constant rests on. */
103
+ sessionToIdleGapTimer = new Map();
61
104
  constructor(store, hwSampler,
62
105
  /** What the transcript proves about a turn open since a timestamp — cancel,
63
106
  * client abort, or never having run at all. Injectable for tests; the
64
107
  * default reads the Claude transcript tail once for all three. */
65
- openTurnProbe = readOpenTurnEvidence) {
108
+ openTurnProbe = readOpenTurnEvidence,
109
+ /** Injectable for tests only — production call sites take the default. */
110
+ idleGapMs = AGENT_IDLE_GAP_MS) {
66
111
  this.store = store;
67
112
  this.hwSampler = hwSampler;
68
113
  this.openTurnProbe = openTurnProbe;
114
+ this.idleGapMs = idleGapMs;
69
115
  }
70
116
  /** Start a new run and return its id. Safe to call if store disabled (returns ''). */
71
117
  openRun(input) {
@@ -78,6 +124,7 @@ export class ApmeCollector {
78
124
  sessionId: input.sessionId,
79
125
  agentType: input.agentType,
80
126
  modelId: input.modelId ?? null,
127
+ provider: input.modelId ? normalizeModelProvider(undefined, input.modelId) : null,
81
128
  projectName: input.projectName ?? (input.projectPath ? resolveProjectName({ cwd: input.projectPath }) : null),
82
129
  projectPath: input.projectPath ?? null,
83
130
  taskPrompt: input.taskPrompt ?? null,
@@ -126,7 +173,8 @@ export class ApmeCollector {
126
173
  // empty phantom turn and shift every later turn_index, so an identical
127
174
  // prompt landing on a fresh, still-empty turn is a no-op.
128
175
  const openTurn = this.sessionToTurn.get(sessionId);
129
- if (openTurn && prompt !== null && openTurn.prompt === prompt &&
176
+ if (openTurn && prompt !== null && openTurn.prompt !== null &&
177
+ samePromptEcho(openTurn.prompt, prompt) &&
130
178
  openTurn.toolCalls === 0 && !openTurn.hasResponse &&
131
179
  Date.now() - openTurn.startedAt < DUPLICATE_TURN_OPEN_WINDOW_MS) {
132
180
  debug('APME', `duplicate turn_start ignored (echo) turn=${openTurn.id.slice(0, 8)}`);
@@ -164,14 +212,18 @@ export class ApmeCollector {
164
212
  prompt, hasResponse: false,
165
213
  };
166
214
  this.sessionToTurn.set(sessionId, turn);
215
+ // A new turn is the "still working" signal the idle-gap timer waits on.
216
+ this.clearIdleGapTimer(sessionId);
167
217
  // Ensure an active task exists so this turn can attach to it. Tasks group
168
218
  // consecutive turns between boundary signals (TodoWrite all-completed,
169
219
  // /clear, session_end). First turn in a run opens task 0.
170
220
  const task = this.openTaskIfNone(sessionId, runId);
171
221
  if (task) {
172
222
  const wasFirstTurn = task.firstTurnIndex === null;
173
- if (wasFirstTurn)
223
+ if (wasFirstTurn) {
174
224
  task.firstTurnIndex = turnIndex;
225
+ task.firstPrompt = prompt ?? null;
226
+ }
175
227
  task.lastTurnIndex = turnIndex;
176
228
  // Second (or later) turn on the same task proves it's a real,
177
229
  // multi-turn work unit → promote the deferred TASK header so the
@@ -415,6 +467,9 @@ export class ApmeCollector {
415
467
  catch (err) {
416
468
  debug('APME', `closeTurn failed: ${String(err)}`);
417
469
  }
470
+ // Idle starts when the turn ends. The timer is cleared by the next turn
471
+ // open (still working) or by closeTask (boundary reached another way).
472
+ this.armIdleGapTimer(sessionId);
418
473
  }
419
474
  /** Get the current active turn ID for a session (if any). */
420
475
  getActiveTurnId(sessionId) {
@@ -479,6 +534,7 @@ export class ApmeCollector {
479
534
  startedAt: Date.now(),
480
535
  firstTurnIndex: null,
481
536
  lastTurnIndex: null,
537
+ firstPrompt: null,
482
538
  timelineEmitted: false,
483
539
  };
484
540
  this.sessionToTask.set(sessionId, task);
@@ -519,12 +575,40 @@ export class ApmeCollector {
519
575
  projectName: run?.projectName ?? null,
520
576
  taskIndex: task.index,
521
577
  startedAt: task.startedAt,
578
+ title: deriveTaskTitle(task.firstPrompt),
522
579
  });
523
580
  }
524
581
  catch (err) {
525
582
  debug('APME', `onTaskOpened listener threw: ${String(err)}`);
526
583
  }
527
584
  }
585
+ /** Arm the per-session idle-gap timer. Called from `closeTurn` — the turn
586
+ * just finished, so the gap being measured is genuine idle time, never the
587
+ * agent's own working time. Re-arming replaces any previous timer. */
588
+ armIdleGapTimer(sessionId) {
589
+ this.clearIdleGapTimer(sessionId);
590
+ const timer = setTimeout(() => {
591
+ this.sessionToIdleGapTimer.delete(sessionId);
592
+ // A turn opened since (and its clear lost a race), or work is mid-turn:
593
+ // never split under an active turn.
594
+ if (this.sessionToTurn.has(sessionId))
595
+ return;
596
+ if (!this.sessionToTask.has(sessionId))
597
+ return;
598
+ debug('APME', `idle gap (${Math.round(this.idleGapMs / 1000)}s) → closing task for ${sessionId.slice(0, 8)}`);
599
+ this.closeTask(sessionId, 'idle_gap');
600
+ }, this.idleGapMs);
601
+ // Never hold the process open for an idle bookkeeping timer.
602
+ timer.unref?.();
603
+ this.sessionToIdleGapTimer.set(sessionId, timer);
604
+ }
605
+ clearIdleGapTimer(sessionId) {
606
+ const timer = this.sessionToIdleGapTimer.get(sessionId);
607
+ if (timer) {
608
+ clearTimeout(timer);
609
+ this.sessionToIdleGapTimer.delete(sessionId);
610
+ }
611
+ }
528
612
  /** Public wrapper for closeTask. Used by the manual-boundary CLI /
529
613
  * HTTP route + the macOS detail-pane button — lets the user declare
530
614
  * "this task is done" without `/clear` (which would also split the
@@ -557,6 +641,7 @@ export class ApmeCollector {
557
641
  return;
558
642
  this.sessionToTask.delete(sessionId);
559
643
  this.sessionToLastMilestone.delete(sessionId);
644
+ this.clearIdleGapTimer(sessionId);
560
645
  // Empty task: no turns ever attached. Drop the row so the dashboard
561
646
  // doesn't show phantom entries from back-to-back boundary signals.
562
647
  if (task.firstTurnIndex === null) {
@@ -599,6 +684,14 @@ export class ApmeCollector {
599
684
  try {
600
685
  this.store.updateTask(task.id, {
601
686
  endedAt: Date.now(),
687
+ // `first_turn_index` too, not just the last. The task row is INSERTed
688
+ // from inside the turn_start handler, i.e. before the turn exists, so
689
+ // it is written NULL and the real index only ever landed in memory —
690
+ // 294 of 1080 closed claude-code tasks carry NULL here, and the task
691
+ // rollup the layer-2 judge reads is keyed on these two columns.
692
+ // `reapAbandonedRun` already COALESCEs a value in at reap time, which
693
+ // is this defect's workaround rather than its fix.
694
+ firstTurnIndex: task.firstTurnIndex,
602
695
  lastTurnIndex: task.lastTurnIndex ?? task.firstTurnIndex,
603
696
  boundarySignal,
604
697
  taskCategory,
@@ -700,6 +793,7 @@ export class ApmeCollector {
700
793
  inputTokens: ev.inputTokens ?? null,
701
794
  outputTokens: ev.outputTokens ?? null,
702
795
  costUsd: ev.costUsd ?? null,
796
+ costKnown: ev.costKnown ?? null,
703
797
  latencyMs: ev.latencyMs ?? null,
704
798
  toolName: ev.toolName ?? null,
705
799
  toolStatus: ev.toolStatus ?? null,
@@ -742,7 +836,7 @@ export class ApmeCollector {
742
836
  * Falls back to the last closed turn if closeTurn() already ran (race with session exit).
743
837
  * Tags turns.efficiency_json.response_kind so the runner can skip tool-only / empty
744
838
  * turns — judging silence produces noise scores. */
745
- setTurnResponse(sessionId, response) {
839
+ setTurnResponse(sessionId, response, source = 'direct') {
746
840
  if (!this.store.enabled)
747
841
  return;
748
842
  const turn = this.sessionToTurn.get(sessionId);
@@ -750,15 +844,27 @@ export class ApmeCollector {
750
844
  debug('APME', `setTurnResponse session=${sessionId.slice(0, 8)} turnId=${turnId?.slice(0, 8) ?? 'null'} respLen=${response.length}`);
751
845
  if (!turnId)
752
846
  return;
847
+ const existingTurn = this.store.getTurn(turnId);
848
+ const existingMeta = parseEfficiencyJson(existingTurn);
849
+ const existingSource = typeof existingMeta.response_source === 'string'
850
+ ? existingMeta.response_source
851
+ : (existingTurn?.response ? 'direct' : undefined);
852
+ if (responseSourcePriority(source) < responseSourcePriority(existingSource)) {
853
+ debug('APME', `setTurnResponse ignored lower-priority source=${source} existing=${existingSource}`);
854
+ return;
855
+ }
753
856
  const trimmedLen = response.trim().length;
754
857
  // Active-turn toolCalls is authoritative; after closeTurn() the counter is
755
858
  // already flushed to the DB row, so we fetch from there instead.
756
859
  const toolCalls = turn?.toolCalls
757
- ?? (this.store.getTurn(turnId)?.tool_calls ?? 0);
860
+ ?? (existingTurn?.tool_calls ?? 0);
758
861
  const kind = trimmedLen >= 1
759
862
  ? 'text'
760
863
  : (toolCalls > 0 ? 'tool_only' : 'empty');
761
- const efficiencyJson = mergeEfficiencyJson(this.store.getTurn(turnId), { response_kind: kind });
864
+ const efficiencyJson = mergeEfficiencyJson(existingTurn, {
865
+ response_kind: kind,
866
+ response_source: source,
867
+ });
762
868
  try {
763
869
  this.store.updateTurn(turnId, {
764
870
  response: response.slice(0, 10_000),
@@ -773,11 +879,18 @@ export class ApmeCollector {
773
879
  // Sample trajectory: the assistant response closes the turn's event arc.
774
880
  const ctx = this.sampleCtxForTurn(sessionId, turnId);
775
881
  if (ctx) {
776
- this.appendSampleEvent(ctx, {
777
- kind: 'assistant_message',
778
- dedupCore: hashCore(response.slice(0, 400)),
779
- payloadObj: { text: response.slice(0, 10_000), responseKind: kind },
780
- });
882
+ const payload = safeStringify({ text: response.slice(0, 10_000), responseKind: kind });
883
+ const existingEvent = this.store.findAssistantMessageEvent(ctx.taskId, turnId);
884
+ if (existingEvent?.id != null) {
885
+ this.store.updateSampleEvent(existingEvent.id, { payload, ts: Date.now() });
886
+ }
887
+ else {
888
+ this.appendSampleEvent(ctx, {
889
+ kind: 'assistant_message',
890
+ dedupCore: `turn:${turnId}`,
891
+ payloadObj: { text: response.slice(0, 10_000), responseKind: kind },
892
+ });
893
+ }
781
894
  }
782
895
  }
783
896
  /** Apply response to the last closed turn if it has no response yet.
@@ -835,16 +948,27 @@ export class ApmeCollector {
835
948
  }
836
949
  case 'turn_response': {
837
950
  const text = a['agentdeck.response_text'] ?? '';
951
+ const source = a['agentdeck.response_source'] ?? 'direct';
838
952
  const fallback = a['agentdeck.fallback_to_last_closed'] === true;
839
953
  if (fallback)
840
954
  this.setLastClosedTurnResponse(sessionId, text);
841
955
  else
842
- this.setTurnResponse(sessionId, text);
956
+ this.setTurnResponse(sessionId, text, source);
843
957
  return;
844
958
  }
845
- case 'turn_end':
846
- // No-op: turns auto-close on the next `turn_start` or session end.
959
+ case 'turn_end': {
960
+ // An explicit close, from a source that HAS a stop signal of its own.
961
+ // This used to be a no-op on the theory that turns auto-close on the
962
+ // next `turn_start` — which is true, and is exactly the shape the
963
+ // `end_source` column exists to avoid: closing at the next prompt puts
964
+ // the user's thinking time inside the turn's duration, and a
965
+ // conversation with one turn never closes it at all. OpenClaw is the
966
+ // first emitter (its `chat.final` is the stop signal); every other
967
+ // adapter still auto-closes, unchanged.
968
+ const source = a['agentdeck.turn_end_source'] ?? 'stop';
969
+ this.closeTurn(sessionId, source);
847
970
  return;
971
+ }
848
972
  case 'tool_call': {
849
973
  const toolName = (a['gen_ai.tool.name'] ?? a['agentdeck.tool_name']);
850
974
  const raw = a['agentdeck.raw_payload'] ?? {};
@@ -891,17 +1015,18 @@ export class ApmeCollector {
891
1015
  }
892
1016
  case 'session_meta': {
893
1017
  const model = a['gen_ai.request.model'];
894
- if (model)
895
- this.updateModel(sessionId, model);
1018
+ const rawProvider = a['gen_ai.system'];
1019
+ if (model || rawProvider)
1020
+ this.updateTurnIdentity(sessionId, model, rawProvider);
896
1021
  const inputTokens = a['agentdeck.usage.input_tokens'];
897
1022
  const outputTokens = a['agentdeck.usage.output_tokens'];
898
1023
  const costUsd = a['agentdeck.usage.cost_usd'];
899
1024
  if (inputTokens !== undefined || outputTokens !== undefined || costUsd !== undefined) {
900
- // Synthesize a UsageSnapshot-compatible shape. Missing fields stay null.
901
- this.updateUsage(sessionId, {
1025
+ // PER-MESSAGE numbers, not a running total see `addUsageIncrement`.
1026
+ this.addUsageIncrement(sessionId, {
902
1027
  inputTokens: inputTokens ?? 0,
903
1028
  outputTokens: outputTokens ?? 0,
904
- estimatedCostUsd: costUsd ?? null,
1029
+ costUsd: costUsd ?? null,
905
1030
  });
906
1031
  }
907
1032
  return;
@@ -957,45 +1082,128 @@ export class ApmeCollector {
957
1082
  * Snapshots carry CUMULATIVE session totals, so we emit a priced ModelEvent
958
1083
  * for the delta and attribute it to the active task (the SessionSample cost
959
1084
  * is the sum of its ModelEvents). */
1085
+ /**
1086
+ * Record usage a producer reports for ONE message.
1087
+ *
1088
+ * `updateUsage` is cumulative by contract: it assigns `inputTokens` to the run
1089
+ * row absolutely and derives its per-task ModelEvent as `max(0, cur - prev)`.
1090
+ * Its original and only other caller is the PTY poller's `UsageTracker`
1091
+ * snapshot, which really is a running total.
1092
+ *
1093
+ * A `session.message`(assistant) frame reports THIS message's tokens, and those
1094
+ * are not monotonic — measured on a real OpenClaw session: 30302, 21375, 335,
1095
+ * 96, 114. Fed to `updateUsage` directly, the run row ends at the LAST
1096
+ * message's count (114 for a session that consumed 52,222), every decrease
1097
+ * clamps its delta to 0 so most messages append no ModelEvent at all, and
1098
+ * `recomputeSampleCost` then prices the run from what survives. Two messages
1099
+ * sharing an `(in,out)` pair are additionally deduped away, because the dedup
1100
+ * key assumes cumulative values are unique.
1101
+ *
1102
+ * So convert here rather than at each producer: keep the running total and
1103
+ * hand `updateUsage` the cumulative series its arithmetic is written for.
1104
+ */
1105
+ addUsageIncrement(sessionId, delta) {
1106
+ if (!this.store.enabled)
1107
+ return;
1108
+ // ONE guard for both accumulators. `updateUsage` drops everything when the
1109
+ // session has no open run, so advancing the cost total before that check
1110
+ // let cost count messages the token total did not: a span arriving before
1111
+ // the run opened, then one after, recorded 7 tokens at $10.50. Two
1112
+ // accumulators with different failure modes is the same defect shape as a
1113
+ // guard cleared only by a `defer` it shares a failure mode with.
1114
+ if (!this.sessionToRun.get(sessionId))
1115
+ return;
1116
+ const prev = this.sessionToUsage.get(sessionId) ?? { in: 0, out: 0, cost: null };
1117
+ const prevCost = this.sessionToCostTotal.get(sessionId) ?? { total: 0, seen: false };
1118
+ const reported = typeof delta.costUsd === 'number';
1119
+ const cost = { total: prevCost.total + (reported ? delta.costUsd : 0),
1120
+ seen: prevCost.seen || reported };
1121
+ this.sessionToCostTotal.set(sessionId, cost);
1122
+ this.updateUsage(sessionId, {
1123
+ // The clamp is deliberate, not defensive: `updateUsage` derives its
1124
+ // ModelEvent as `max(0, cur - prev)`, so the series handed to it must be
1125
+ // monotonic. No producer here reports a negative correction; if one ever
1126
+ // does, it needs its own path rather than silently shrinking the total.
1127
+ inputTokens: prev.in + Math.max(0, delta.inputTokens),
1128
+ outputTokens: prev.out + Math.max(0, delta.outputTokens),
1129
+ estimatedCostUsd: cost.seen ? cost.total : null,
1130
+ });
1131
+ }
960
1132
  updateUsage(sessionId, snapshot) {
961
1133
  if (!this.store.enabled)
962
1134
  return;
963
1135
  const runId = this.sessionToRun.get(sessionId);
964
1136
  if (!runId)
965
1137
  return;
1138
+ const curIn = snapshot.inputTokens ?? 0;
1139
+ const curOut = snapshot.outputTokens ?? 0;
1140
+ const prev = this.sessionToUsage.get(sessionId) ?? { in: 0, out: 0, cost: null };
1141
+ const hadPreviousUsage = prev.in > 0 || prev.out > 0;
1142
+ const dIn = Math.max(0, curIn - prev.in);
1143
+ const dOut = Math.max(0, curOut - prev.out);
1144
+ const run = this.store.getRun(runId);
1145
+ const turnId = this.turnIdFor(sessionId);
1146
+ const turn = turnId ? this.store.getTurn(turnId) : null;
1147
+ const model = turn?.model_id ?? run?.modelId ?? null;
1148
+ const reportedTotal = snapshot.estimatedCostUsd ?? snapshot.costSpent ?? null;
1149
+ const reportedDelta = reportedTotal == null
1150
+ ? null
1151
+ : Math.max(0, reportedTotal - (prev.cost ?? 0));
1152
+ const priced = isPricedModel(model);
1153
+ const eventCostKnown = priced || (reportedDelta != null && reportedDelta > 0);
1154
+ const eventCost = reportedDelta ?? priceUsd(model, dIn, dOut);
1155
+ const hasUsageDelta = dIn > 0 || dOut > 0;
1156
+ const hasAccountingDelta = hasUsageDelta || (reportedDelta ?? 0) > 0;
1157
+ const runCostKnown = !hasAccountingDelta
1158
+ ? Boolean(run?.costKnown)
1159
+ : hadPreviousUsage
1160
+ ? Boolean(run?.costKnown) && eventCostKnown
1161
+ : eventCostKnown;
1162
+ const runCost = reportedTotal ?? (priced ? priceUsd(model, curIn, curOut) : null);
1163
+ this.sessionToUsage.set(sessionId, { in: curIn, out: curOut, cost: reportedTotal });
966
1164
  try {
967
1165
  this.store.updateRun(runId, {
968
1166
  inputTokens: snapshot.inputTokens,
969
1167
  outputTokens: snapshot.outputTokens,
970
- costUsd: snapshot.estimatedCostUsd ?? snapshot.costSpent ?? null,
1168
+ costUsd: runCost,
1169
+ costKnown: runCostKnown,
971
1170
  });
972
1171
  }
973
1172
  catch { /* ignore */ }
974
1173
  // ── Per-task ModelEvent from the cumulative delta ──
975
- const curIn = snapshot.inputTokens ?? 0;
976
- const curOut = snapshot.outputTokens ?? 0;
977
- const prev = this.sessionToUsage.get(sessionId) ?? { in: 0, out: 0 };
978
- const dIn = Math.max(0, curIn - prev.in);
979
- const dOut = Math.max(0, curOut - prev.out);
980
- this.sessionToUsage.set(sessionId, { in: curIn, out: curOut });
981
1174
  if (dIn === 0 && dOut === 0)
982
1175
  return;
983
1176
  const task = this.sessionToTask.get(sessionId);
984
1177
  if (!task)
985
1178
  return;
986
- const run = this.store.getRun(runId);
987
- const model = run?.modelId ?? null;
1179
+ const provider = turn?.provider
1180
+ ?? run?.provider
1181
+ ?? normalizeModelProvider(undefined, model);
1182
+ const taskRow = this.store.getTask(task.id);
1183
+ const taskModel = !model
1184
+ ? taskRow?.modelId
1185
+ : !taskRow?.modelId || taskRow.modelId === model
1186
+ ? model
1187
+ : 'mixed';
1188
+ const taskProvider = !provider || provider === 'unknown'
1189
+ ? taskRow?.provider
1190
+ : !taskRow?.provider || taskRow.provider === provider
1191
+ ? provider
1192
+ : 'mixed';
988
1193
  const turnIndex = this.sessionToTurn.get(sessionId)?.index ?? task.lastTurnIndex ?? 0;
989
- // Prefer the agent-reported marginal cost when available, else price the delta.
990
- const cost = priceUsd(model, dIn, dOut);
991
- this.appendSampleEvent({ taskId: task.id, runId, turnIndex, turnId: this.turnIdFor(sessionId) }, {
1194
+ this.appendSampleEvent({ taskId: task.id, runId, turnIndex, turnId }, {
992
1195
  kind: 'model', model: model ?? undefined, inputTokens: dIn, outputTokens: dOut,
993
- costUsd: cost, latencyMs: 0, dedupCore: `${curIn}:${curOut}`,
1196
+ costUsd: eventCost, costKnown: eventCostKnown,
1197
+ latencyMs: 0, dedupCore: `${curIn}:${curOut}`,
994
1198
  });
995
1199
  try {
996
1200
  this.store.updateTask(task.id, {
997
- modelId: model ?? undefined,
998
- modelConfig: JSON.stringify({ modelId: model ?? 'unknown', provider: providerFor(model) }),
1201
+ modelId: taskModel,
1202
+ provider: taskProvider,
1203
+ modelConfig: JSON.stringify({
1204
+ modelId: taskModel ?? 'unknown',
1205
+ provider: taskProvider ?? null,
1206
+ }),
999
1207
  });
1000
1208
  this.store.recomputeSampleCost(task.id);
1001
1209
  }
@@ -1046,7 +1254,71 @@ export class ApmeCollector {
1046
1254
  if (!runId)
1047
1255
  return;
1048
1256
  try {
1049
- this.store.updateRun(runId, { modelId });
1257
+ this.store.updateRun(runId, {
1258
+ modelId,
1259
+ provider: normalizeModelProvider(undefined, modelId),
1260
+ });
1261
+ }
1262
+ catch { /* ignore */ }
1263
+ }
1264
+ /** Persist the model/provider on the assistant message's own turn.
1265
+ * `session_meta` can arrive after `chat.final` closed that turn, so the same
1266
+ * current→last fallback used by setTurnResponse is required here too. */
1267
+ updateTurnIdentity(sessionId, reportedModel, reportedProvider) {
1268
+ const runId = this.sessionToRun.get(sessionId);
1269
+ if (!runId)
1270
+ return;
1271
+ const turnId = this.turnIdFor(sessionId);
1272
+ const turn = turnId ? this.store.getTurn(turnId) : null;
1273
+ const run = this.store.getRun(runId);
1274
+ const model = reportedModel ?? turn?.model_id ?? run?.modelId ?? undefined;
1275
+ if (!model && !reportedProvider)
1276
+ return;
1277
+ const normalized = normalizeModelProvider(reportedProvider, model);
1278
+ const provider = normalized === 'unknown' ? null : normalized;
1279
+ if (turnId) {
1280
+ try {
1281
+ this.store.updateTurn(turnId, {
1282
+ ...(model ? { modelId: model } : {}),
1283
+ provider,
1284
+ });
1285
+ }
1286
+ catch { /* ignore */ }
1287
+ }
1288
+ // Preserve the established run-level "latest observed model" contract for
1289
+ // legacy consumers. Scorecards no longer depend on it when a turn has its
1290
+ // own identity, so updating this compatibility fallback cannot reassign
1291
+ // earlier turns.
1292
+ if (run) {
1293
+ const patch = {
1294
+ ...(model ? { modelId: model } : {}),
1295
+ provider,
1296
+ };
1297
+ if (Object.keys(patch).length > 0) {
1298
+ try {
1299
+ this.store.updateRun(runId, patch);
1300
+ }
1301
+ catch { /* ignore */ }
1302
+ }
1303
+ }
1304
+ const taskId = turn?.task_id ?? this.sessionToTask.get(sessionId)?.id;
1305
+ if (!taskId || !model)
1306
+ return;
1307
+ const task = this.store.getTask(taskId);
1308
+ if (!task)
1309
+ return;
1310
+ const taskModel = !task.modelId || task.modelId === model ? model : 'mixed';
1311
+ const taskProvider = !provider
1312
+ ? task.provider
1313
+ : task.provider == null || task.provider === provider
1314
+ ? provider
1315
+ : 'mixed';
1316
+ try {
1317
+ this.store.updateTask(taskId, {
1318
+ modelId: taskModel,
1319
+ provider: taskProvider,
1320
+ modelConfig: JSON.stringify({ modelId: taskModel, provider: taskProvider }),
1321
+ });
1050
1322
  }
1051
1323
  catch { /* ignore */ }
1052
1324
  }
@@ -1068,6 +1340,7 @@ export class ApmeCollector {
1068
1340
  this.sessionToLastTurnId.delete(sessionId);
1069
1341
  this.runTaskCount.delete(runId);
1070
1342
  this.sessionToUsage.delete(sessionId);
1343
+ this.sessionToCostTotal.delete(sessionId);
1071
1344
  // Mark empty runs so the dashboard can filter them out.
1072
1345
  // Don't delete — FK constraints and concurrent access make deletion risky.
1073
1346
  const run = this.store.getRun(runId);
@@ -1260,6 +1533,10 @@ function safeStringify(v) {
1260
1533
  * sibling keys. Returns a JSON string suitable for the column. Unparseable
1261
1534
  * existing values are replaced outright. */
1262
1535
  function mergeEfficiencyJson(turn, patch) {
1536
+ const base = parseEfficiencyJson(turn);
1537
+ return JSON.stringify({ ...base, ...patch });
1538
+ }
1539
+ function parseEfficiencyJson(turn) {
1263
1540
  let base = {};
1264
1541
  const raw = turn?.efficiency_json;
1265
1542
  if (typeof raw === 'string' && raw.length > 0) {
@@ -1271,6 +1548,16 @@ function mergeEfficiencyJson(turn, patch) {
1271
1548
  }
1272
1549
  catch { /* replace */ }
1273
1550
  }
1274
- return JSON.stringify({ ...base, ...patch });
1551
+ return base;
1552
+ }
1553
+ /** Full/final responses outrank lossy session-message projections. Unknown
1554
+ * historical provenance is treated as direct so a new projection cannot
1555
+ * downgrade data written before source tracking existed. */
1556
+ function responseSourcePriority(source) {
1557
+ if (source === 'session_message_projection')
1558
+ return 1;
1559
+ if (source === 'chat_final')
1560
+ return 3;
1561
+ return source ? 2 : 0;
1275
1562
  }
1276
1563
  //# sourceMappingURL=collector.js.map