@agentdeck/bridge 0.2.1 → 0.2.3
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.
- package/assets/fonts/IBMPlexSans-Bold.ttf +0 -0
- package/assets/fonts/IBMPlexSans-Regular.ttf +0 -0
- package/assets/fonts/JetBrainsMono-Bold.ttf +0 -0
- package/assets/fonts/JetBrainsMono-Regular.ttf +0 -0
- package/assets/fonts/LICENSES.md +22 -0
- package/dist/adapters/claude-code.d.ts +6 -0
- package/dist/adapters/claude-code.d.ts.map +1 -1
- package/dist/adapters/claude-code.js +8 -0
- package/dist/adapters/claude-code.js.map +1 -1
- package/dist/adapters/openclaw.d.ts +24 -0
- package/dist/adapters/openclaw.d.ts.map +1 -1
- package/dist/adapters/openclaw.js +291 -35
- package/dist/adapters/openclaw.js.map +1 -1
- package/dist/adapters/opencode-adapter.d.ts +59 -0
- package/dist/adapters/opencode-adapter.d.ts.map +1 -1
- package/dist/adapters/opencode-adapter.js +297 -35
- package/dist/adapters/opencode-adapter.js.map +1 -1
- package/dist/adb-reverse.d.ts +1 -1
- package/dist/adb-reverse.js +1 -1
- package/dist/antigravity-local.d.ts +1 -0
- package/dist/antigravity-local.d.ts.map +1 -1
- package/dist/antigravity-local.js +33 -0
- package/dist/antigravity-local.js.map +1 -1
- package/dist/apme/adapters/claude-hook.d.ts.map +1 -1
- package/dist/apme/adapters/claude-hook.js +5 -0
- package/dist/apme/adapters/claude-hook.js.map +1 -1
- package/dist/apme/adapters/codex-hook.d.ts +17 -0
- package/dist/apme/adapters/codex-hook.d.ts.map +1 -0
- package/dist/apme/adapters/codex-hook.js +95 -0
- package/dist/apme/adapters/codex-hook.js.map +1 -0
- package/dist/apme/adapters/codex-turn-manager.d.ts +86 -0
- package/dist/apme/adapters/codex-turn-manager.d.ts.map +1 -0
- package/dist/apme/adapters/codex-turn-manager.js +496 -0
- package/dist/apme/adapters/codex-turn-manager.js.map +1 -0
- package/dist/apme/adapters/openclaw-hook.d.ts +99 -0
- package/dist/apme/adapters/openclaw-hook.d.ts.map +1 -0
- package/dist/apme/adapters/openclaw-hook.js +234 -0
- package/dist/apme/adapters/openclaw-hook.js.map +1 -0
- package/dist/apme/adapters/opencode-hook.d.ts +56 -0
- package/dist/apme/adapters/opencode-hook.d.ts.map +1 -0
- package/dist/apme/adapters/opencode-hook.js +182 -0
- package/dist/apme/adapters/opencode-hook.js.map +1 -0
- package/dist/apme/adapters/timeline.d.ts.map +1 -1
- package/dist/apme/adapters/timeline.js +6 -1
- package/dist/apme/adapters/timeline.js.map +1 -1
- package/dist/apme/antigravity-transcript.d.ts +18 -0
- package/dist/apme/antigravity-transcript.d.ts.map +1 -0
- package/dist/apme/antigravity-transcript.js +94 -0
- package/dist/apme/antigravity-transcript.js.map +1 -0
- package/dist/apme/classify-turn.d.ts +13 -0
- package/dist/apme/classify-turn.d.ts.map +1 -0
- package/dist/apme/classify-turn.js +49 -0
- package/dist/apme/classify-turn.js.map +1 -0
- package/dist/apme/claude-transcript-reader.d.ts +10 -0
- package/dist/apme/claude-transcript-reader.d.ts.map +1 -1
- package/dist/apme/claude-transcript-reader.js +35 -0
- package/dist/apme/claude-transcript-reader.js.map +1 -1
- package/dist/apme/collector.d.ts +129 -6
- package/dist/apme/collector.d.ts.map +1 -1
- package/dist/apme/collector.js +470 -23
- package/dist/apme/collector.js.map +1 -1
- package/dist/apme/dashboard-html.d.ts.map +1 -1
- package/dist/apme/dashboard-html.js +87 -11
- package/dist/apme/dashboard-html.js.map +1 -1
- package/dist/apme/http.d.ts.map +1 -1
- package/dist/apme/http.js +43 -6
- package/dist/apme/http.js.map +1 -1
- package/dist/apme/index.d.ts +26 -5
- package/dist/apme/index.d.ts.map +1 -1
- package/dist/apme/index.js +135 -9
- package/dist/apme/index.js.map +1 -1
- package/dist/apme/pareto.d.ts +39 -0
- package/dist/apme/pareto.d.ts.map +1 -0
- package/dist/apme/pareto.js +59 -0
- package/dist/apme/pareto.js.map +1 -0
- package/dist/apme/recommend.d.ts +3 -0
- package/dist/apme/recommend.d.ts.map +1 -1
- package/dist/apme/recommend.js +43 -1
- package/dist/apme/recommend.js.map +1 -1
- package/dist/apme/runner.d.ts +92 -4
- package/dist/apme/runner.d.ts.map +1 -1
- package/dist/apme/runner.js +586 -70
- package/dist/apme/runner.js.map +1 -1
- package/dist/apme/sample-to-timeline.d.ts +26 -0
- package/dist/apme/sample-to-timeline.d.ts.map +1 -0
- package/dist/apme/sample-to-timeline.js +95 -0
- package/dist/apme/sample-to-timeline.js.map +1 -0
- package/dist/apme/scorers/index.d.ts +47 -0
- package/dist/apme/scorers/index.d.ts.map +1 -0
- package/dist/apme/scorers/index.js +96 -0
- package/dist/apme/scorers/index.js.map +1 -0
- package/dist/apme/settings.d.ts +14 -10
- package/dist/apme/settings.d.ts.map +1 -1
- package/dist/apme/settings.js +34 -8
- package/dist/apme/settings.js.map +1 -1
- package/dist/apme/store.d.ts +33 -1
- package/dist/apme/store.d.ts.map +1 -1
- package/dist/apme/store.js +334 -25
- package/dist/apme/store.js.map +1 -1
- package/dist/awaiting-overlay.d.ts +99 -0
- package/dist/awaiting-overlay.d.ts.map +1 -0
- package/dist/awaiting-overlay.js +148 -0
- package/dist/awaiting-overlay.js.map +1 -0
- package/dist/ble-sync-spawn.d.ts +63 -0
- package/dist/ble-sync-spawn.d.ts.map +1 -0
- package/dist/ble-sync-spawn.js +196 -0
- package/dist/ble-sync-spawn.js.map +1 -0
- package/dist/bridge-core.d.ts +24 -1
- package/dist/bridge-core.d.ts.map +1 -1
- package/dist/bridge-core.js +157 -24
- package/dist/bridge-core.js.map +1 -1
- package/dist/broadcast.d.ts +3 -0
- package/dist/broadcast.d.ts.map +1 -0
- package/dist/broadcast.js +122 -0
- package/dist/broadcast.js.map +1 -0
- package/dist/check-deps.d.ts.map +1 -1
- package/dist/check-deps.js +25 -9
- package/dist/check-deps.js.map +1 -1
- package/dist/cli.d.ts +15 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +1025 -94
- package/dist/cli.js.map +1 -1
- package/dist/codex-auth.d.ts +21 -0
- package/dist/codex-auth.d.ts.map +1 -1
- package/dist/codex-auth.js +52 -1
- package/dist/codex-auth.js.map +1 -1
- package/dist/codex-output-parser.d.ts +8 -0
- package/dist/codex-output-parser.d.ts.map +1 -1
- package/dist/codex-output-parser.js +110 -25
- package/dist/codex-output-parser.js.map +1 -1
- package/dist/codex-rate-limits.d.ts +16 -0
- package/dist/codex-rate-limits.d.ts.map +1 -0
- package/dist/codex-rate-limits.js +197 -0
- package/dist/codex-rate-limits.js.map +1 -0
- package/dist/codex-rollout-response.d.ts +31 -0
- package/dist/codex-rollout-response.d.ts.map +1 -0
- package/dist/codex-rollout-response.js +135 -0
- package/dist/codex-rollout-response.js.map +1 -0
- package/dist/daemon-server.d.ts +85 -0
- package/dist/daemon-server.d.ts.map +1 -1
- package/dist/daemon-server.js +1627 -159
- package/dist/daemon-server.js.map +1 -1
- package/dist/daemon.js +15 -6
- package/dist/daemon.js.map +1 -1
- package/dist/display-dim.d.ts +9 -0
- package/dist/display-dim.d.ts.map +1 -0
- package/dist/display-dim.js +53 -0
- package/dist/display-dim.js.map +1 -0
- package/dist/display-monitor.d.ts +15 -2
- package/dist/display-monitor.d.ts.map +1 -1
- package/dist/display-monitor.js +104 -26
- package/dist/display-monitor.js.map +1 -1
- package/dist/esp32-serial.d.ts +160 -1
- package/dist/esp32-serial.d.ts.map +1 -1
- package/dist/esp32-serial.js +950 -153
- package/dist/esp32-serial.js.map +1 -1
- package/dist/fallback-task-timeline.d.ts +42 -0
- package/dist/fallback-task-timeline.d.ts.map +1 -0
- package/dist/fallback-task-timeline.js +112 -0
- package/dist/fallback-task-timeline.js.map +1 -0
- package/dist/foundation-models-helper.d.ts +11 -0
- package/dist/foundation-models-helper.d.ts.map +1 -0
- package/dist/foundation-models-helper.js +241 -0
- package/dist/foundation-models-helper.js.map +1 -0
- package/dist/hook-migration.js +1 -1
- package/dist/hook-migration.js.map +1 -1
- package/dist/hook-server.d.ts +2 -3
- package/dist/hook-server.d.ts.map +1 -1
- package/dist/hook-server.js +22 -10
- package/dist/hook-server.js.map +1 -1
- package/dist/idotmatrix/idotmatrix-daemon-sync.d.ts +33 -0
- package/dist/idotmatrix/idotmatrix-daemon-sync.d.ts.map +1 -0
- package/dist/idotmatrix/idotmatrix-daemon-sync.js +164 -0
- package/dist/idotmatrix/idotmatrix-daemon-sync.js.map +1 -0
- package/dist/idotmatrix/idotmatrix-discover.d.ts +18 -0
- package/dist/idotmatrix/idotmatrix-discover.d.ts.map +1 -0
- package/dist/idotmatrix/idotmatrix-discover.js +85 -0
- package/dist/idotmatrix/idotmatrix-discover.js.map +1 -0
- package/dist/idotmatrix/idotmatrix-settings.d.ts +20 -0
- package/dist/idotmatrix/idotmatrix-settings.d.ts.map +1 -0
- package/dist/idotmatrix/idotmatrix-settings.js +57 -0
- package/dist/idotmatrix/idotmatrix-settings.js.map +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +215 -179
- package/dist/index.js.map +1 -1
- package/dist/log-stream.d.ts +15 -16
- package/dist/log-stream.d.ts.map +1 -1
- package/dist/log-stream.js +18 -96
- package/dist/log-stream.js.map +1 -1
- package/dist/logger.d.ts.map +1 -1
- package/dist/logger.js +12 -3
- package/dist/logger.js.map +1 -1
- package/dist/mdns.d.ts +17 -0
- package/dist/mdns.d.ts.map +1 -1
- package/dist/mdns.js +52 -18
- package/dist/mdns.js.map +1 -1
- package/dist/modules/adb-module.d.ts +1 -1
- package/dist/modules/adb-module.d.ts.map +1 -1
- package/dist/modules/adb-module.js +4 -3
- package/dist/modules/adb-module.js.map +1 -1
- package/dist/modules/broadcast-module.d.ts +17 -0
- package/dist/modules/broadcast-module.d.ts.map +1 -0
- package/dist/modules/broadcast-module.js +26 -0
- package/dist/modules/broadcast-module.js.map +1 -0
- package/dist/modules/idotmatrix-module.d.ts +9 -0
- package/dist/modules/idotmatrix-module.d.ts.map +1 -0
- package/dist/modules/idotmatrix-module.js +46 -0
- package/dist/modules/idotmatrix-module.js.map +1 -0
- package/dist/modules/index.d.ts +3 -1
- package/dist/modules/index.d.ts.map +1 -1
- package/dist/modules/index.js +9 -3
- package/dist/modules/index.js.map +1 -1
- package/dist/modules/pixoo-module.d.ts.map +1 -1
- package/dist/modules/pixoo-module.js +14 -4
- package/dist/modules/pixoo-module.js.map +1 -1
- package/dist/modules/serial-module.d.ts +4 -0
- package/dist/modules/serial-module.d.ts.map +1 -1
- package/dist/modules/serial-module.js +14 -12
- package/dist/modules/serial-module.js.map +1 -1
- package/dist/modules/timebox-module.d.ts +9 -0
- package/dist/modules/timebox-module.d.ts.map +1 -0
- package/dist/modules/timebox-module.js +46 -0
- package/dist/modules/timebox-module.js.map +1 -0
- package/dist/modules/types.d.ts +4 -2
- package/dist/modules/types.d.ts.map +1 -1
- package/dist/openclaw-session.d.ts +18 -0
- package/dist/openclaw-session.d.ts.map +1 -0
- package/dist/openclaw-session.js +34 -0
- package/dist/openclaw-session.js.map +1 -0
- package/dist/output-parser.d.ts +30 -0
- package/dist/output-parser.d.ts.map +1 -1
- package/dist/output-parser.js +125 -9
- package/dist/output-parser.js.map +1 -1
- package/dist/passive-observer.d.ts +26 -0
- package/dist/passive-observer.d.ts.map +1 -1
- package/dist/passive-observer.js +258 -49
- package/dist/passive-observer.js.map +1 -1
- package/dist/permission-resolver.d.ts +47 -0
- package/dist/permission-resolver.d.ts.map +1 -0
- package/dist/permission-resolver.js +103 -0
- package/dist/permission-resolver.js.map +1 -0
- package/dist/pixoo/micro-glyphs.d.ts +49 -0
- package/dist/pixoo/micro-glyphs.d.ts.map +1 -0
- package/dist/pixoo/micro-glyphs.js +276 -0
- package/dist/pixoo/micro-glyphs.js.map +1 -0
- package/dist/pixoo/pixoo-bridge.d.ts +2 -2
- package/dist/pixoo/pixoo-bridge.d.ts.map +1 -1
- package/dist/pixoo/pixoo-bridge.js +93 -25
- package/dist/pixoo/pixoo-bridge.js.map +1 -1
- package/dist/pixoo/pixoo-camera.d.ts +19 -3
- package/dist/pixoo/pixoo-camera.d.ts.map +1 -1
- package/dist/pixoo/pixoo-camera.js +62 -27
- package/dist/pixoo/pixoo-camera.js.map +1 -1
- package/dist/pixoo/pixoo-client.d.ts +13 -3
- package/dist/pixoo/pixoo-client.d.ts.map +1 -1
- package/dist/pixoo/pixoo-client.js +51 -22
- package/dist/pixoo/pixoo-client.js.map +1 -1
- package/dist/pixoo/pixoo-discover.d.ts +37 -0
- package/dist/pixoo/pixoo-discover.d.ts.map +1 -0
- package/dist/pixoo/pixoo-discover.js +105 -0
- package/dist/pixoo/pixoo-discover.js.map +1 -0
- package/dist/pixoo/pixoo-renderer.d.ts +8 -3
- package/dist/pixoo/pixoo-renderer.d.ts.map +1 -1
- package/dist/pixoo/pixoo-renderer.js +189 -60
- package/dist/pixoo/pixoo-renderer.js.map +1 -1
- package/dist/pixoo/pixoo-settings.d.ts +6 -0
- package/dist/pixoo/pixoo-settings.d.ts.map +1 -1
- package/dist/pixoo/pixoo-settings.js +9 -0
- package/dist/pixoo/pixoo-settings.js.map +1 -1
- package/dist/pixoo/pixoo-sprites.d.ts +60 -11
- package/dist/pixoo/pixoo-sprites.d.ts.map +1 -1
- package/dist/pixoo/pixoo-sprites.js +512 -209
- package/dist/pixoo/pixoo-sprites.js.map +1 -1
- package/dist/pty-manager.d.ts.map +1 -1
- package/dist/pty-manager.js +8 -2
- package/dist/pty-manager.js.map +1 -1
- package/dist/session-activity.d.ts +14 -0
- package/dist/session-activity.d.ts.map +1 -0
- package/dist/session-activity.js +160 -0
- package/dist/session-activity.js.map +1 -0
- package/dist/session-aggregator.d.ts +17 -0
- package/dist/session-aggregator.d.ts.map +1 -1
- package/dist/session-aggregator.js +48 -9
- package/dist/session-aggregator.js.map +1 -1
- package/dist/session-registry.d.ts +71 -3
- package/dist/session-registry.d.ts.map +1 -1
- package/dist/session-registry.js +137 -8
- package/dist/session-registry.js.map +1 -1
- package/dist/session-timeline-relay.d.ts.map +1 -1
- package/dist/session-timeline-relay.js +13 -4
- package/dist/session-timeline-relay.js.map +1 -1
- package/dist/session-transcript-timeline.d.ts +49 -0
- package/dist/session-transcript-timeline.d.ts.map +1 -0
- package/dist/session-transcript-timeline.js +276 -0
- package/dist/session-transcript-timeline.js.map +1 -0
- package/dist/state-machine.d.ts +6 -1
- package/dist/state-machine.d.ts.map +1 -1
- package/dist/state-machine.js +86 -28
- package/dist/state-machine.js.map +1 -1
- package/dist/timebox/timebox-daemon-sync.d.ts +19 -0
- package/dist/timebox/timebox-daemon-sync.d.ts.map +1 -0
- package/dist/timebox/timebox-daemon-sync.js +135 -0
- package/dist/timebox/timebox-daemon-sync.js.map +1 -0
- package/dist/timebox/timebox-discover.d.ts +19 -0
- package/dist/timebox/timebox-discover.d.ts.map +1 -0
- package/dist/timebox/timebox-discover.js +87 -0
- package/dist/timebox/timebox-discover.js.map +1 -0
- package/dist/timebox/timebox-settings.d.ts +32 -0
- package/dist/timebox/timebox-settings.d.ts.map +1 -0
- package/dist/timebox/timebox-settings.js +80 -0
- package/dist/timebox/timebox-settings.js.map +1 -0
- package/dist/timeline-store.d.ts +63 -3
- package/dist/timeline-store.d.ts.map +1 -1
- package/dist/timeline-store.js +292 -14
- package/dist/timeline-store.js.map +1 -1
- package/dist/timeline-summarizer.d.ts.map +1 -1
- package/dist/timeline-summarizer.js +29 -5
- package/dist/timeline-summarizer.js.map +1 -1
- package/dist/tui/ansi.d.ts +0 -4
- package/dist/tui/ansi.d.ts.map +1 -1
- package/dist/tui/ansi.js +14 -7
- package/dist/tui/ansi.js.map +1 -1
- package/dist/tui/dashboard.d.ts +1 -0
- package/dist/tui/dashboard.d.ts.map +1 -1
- package/dist/tui/dashboard.js +24 -7
- package/dist/tui/dashboard.js.map +1 -1
- package/dist/tui/renderer.d.ts +36 -0
- package/dist/tui/renderer.d.ts.map +1 -1
- package/dist/tui/renderer.js +303 -119
- package/dist/tui/renderer.js.map +1 -1
- package/dist/tui/terrarium.d.ts.map +1 -1
- package/dist/tui/terrarium.js +37 -49
- package/dist/tui/terrarium.js.map +1 -1
- package/dist/types.d.ts +2 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +2 -2
- package/dist/types.js.map +1 -1
- package/dist/usage-api.d.ts.map +1 -1
- package/dist/usage-api.js +7 -1
- package/dist/usage-api.js.map +1 -1
- package/dist/usage-event.d.ts +10 -3
- package/dist/usage-event.d.ts.map +1 -1
- package/dist/usage-event.js +74 -12
- package/dist/usage-event.js.map +1 -1
- package/dist/utility-proxy.d.ts.map +1 -1
- package/dist/utility-proxy.js +8 -0
- package/dist/utility-proxy.js.map +1 -1
- package/dist/utils/project-name.d.ts +3 -0
- package/dist/utils/project-name.d.ts.map +1 -1
- package/dist/utils/project-name.js +44 -2
- package/dist/utils/project-name.js.map +1 -1
- package/dist/windows-service.d.ts +26 -0
- package/dist/windows-service.d.ts.map +1 -0
- package/dist/windows-service.js +145 -0
- package/dist/windows-service.js.map +1 -0
- package/dist/ws-server.d.ts +22 -1
- package/dist/ws-server.d.ts.map +1 -1
- package/dist/ws-server.js +89 -4
- package/dist/ws-server.js.map +1 -1
- package/fm-helper/AgentDeckFMHelper.swift +112 -0
- package/package.json +9 -3
- package/scripts/build-fm-helper.mjs +26 -0
- package/LICENSE +0 -21
- package/dist/apme/tuner.d.ts +0 -64
- package/dist/apme/tuner.d.ts.map +0 -1
- package/dist/apme/tuner.js +0 -297
- package/dist/apme/tuner.js.map +0 -1
- package/dist/d200h/hid-protocol.d.ts +0 -62
- package/dist/d200h/hid-protocol.d.ts.map +0 -1
- package/dist/d200h/hid-protocol.js +0 -124
- package/dist/d200h/hid-protocol.js.map +0 -1
- package/dist/d200h/image-renderer.d.ts +0 -36
- package/dist/d200h/image-renderer.d.ts.map +0 -1
- package/dist/d200h/image-renderer.js +0 -487
- package/dist/d200h/image-renderer.js.map +0 -1
- package/dist/modules/d200h-module.d.ts +0 -43
- package/dist/modules/d200h-module.d.ts.map +0 -1
- package/dist/modules/d200h-module.js +0 -360
- package/dist/modules/d200h-module.js.map +0 -1
- package/dist/modules/d200h-renderer.d.ts +0 -7
- package/dist/modules/d200h-renderer.d.ts.map +0 -1
- package/dist/modules/d200h-renderer.js +0 -321
- package/dist/modules/d200h-renderer.js.map +0 -1
package/dist/apme/collector.js
CHANGED
|
@@ -18,18 +18,38 @@ 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 {
|
|
21
|
+
import { priceUsd, providerFor } from '@agentdeck/shared';
|
|
22
|
+
import { classifyRunSmart, computeSignals, classify } from './classifier.js';
|
|
23
|
+
/** A turn_start with the same prompt landing on a fresh, still-empty turn
|
|
24
|
+
* within this window is treated as a transport echo (e.g. OpenClaw
|
|
25
|
+
* `chat.send` span + the gateway's `session.message` role=user re-delivery
|
|
26
|
+
* of the same text), not a new turn. */
|
|
27
|
+
const DUPLICATE_TURN_OPEN_WINDOW_MS = 15_000;
|
|
22
28
|
export class ApmeCollector {
|
|
23
29
|
store;
|
|
24
30
|
hwSampler;
|
|
25
31
|
sessionToRun = new Map(); // sessionId → runId
|
|
32
|
+
sessionToAgentType = new Map(); // sessionId → agentType (survives closeRun for late-attributed timeline rows)
|
|
26
33
|
sessionToTurn = new Map(); // sessionId → current turn
|
|
27
34
|
sessionToLastTurnId = new Map(); // survives closeTurn()
|
|
28
35
|
sessionToTask = new Map(); // sessionId → current task
|
|
29
36
|
runTaskCount = new Map(); // runId → next task_index
|
|
37
|
+
sessionToUsage = new Map(); // last cumulative usage
|
|
30
38
|
/** Optional listener fired after `closeTask` persists the row. The runner
|
|
31
39
|
* wires this to enqueue a task-level judge call. */
|
|
32
40
|
onTaskClosed = null;
|
|
41
|
+
/** Optional listener fired after `openTaskIfNone` inserts a fresh task row.
|
|
42
|
+
* The dashboard timeline emitter wires this to push a `task_start` entry. */
|
|
43
|
+
onTaskOpened = null;
|
|
44
|
+
/** Optional listener fired after a typed trajectory event is persisted.
|
|
45
|
+
* The timeline projection (Phase 3) consumes this. */
|
|
46
|
+
onSampleEvent = null;
|
|
47
|
+
/** Optional listener fired on the TodoWrite-all-completed soft hint.
|
|
48
|
+
* The timeline emitter wires this to push a `task_milestone` row. */
|
|
49
|
+
onTaskMilestone = null;
|
|
50
|
+
/** Last milestone key (`taskId:turnIndex`) per session — a turn can carry
|
|
51
|
+
* several all-completed TodoWrite calls; surface only the first. */
|
|
52
|
+
sessionToLastMilestone = new Map();
|
|
33
53
|
constructor(store, hwSampler) {
|
|
34
54
|
this.store = store;
|
|
35
55
|
this.hwSampler = hwSampler;
|
|
@@ -54,6 +74,7 @@ export class ApmeCollector {
|
|
|
54
74
|
try {
|
|
55
75
|
this.store.insertRun(row);
|
|
56
76
|
this.sessionToRun.set(input.sessionId, runId);
|
|
77
|
+
this.sessionToAgentType.set(input.sessionId, input.agentType);
|
|
57
78
|
debug('APME', `openRun ${runId} session=${input.sessionId} agent=${input.agentType} model=${input.modelId ?? '-'}`);
|
|
58
79
|
}
|
|
59
80
|
catch (err) {
|
|
@@ -61,13 +82,23 @@ export class ApmeCollector {
|
|
|
61
82
|
}
|
|
62
83
|
return runId;
|
|
63
84
|
}
|
|
64
|
-
/** Record a hook event as a step + manage turn lifecycle.
|
|
65
|
-
|
|
85
|
+
/** Record a hook event as a step + manage turn lifecycle.
|
|
86
|
+
*
|
|
87
|
+
* `event` is normalized up-front so callers may pass EITHER the PascalCase
|
|
88
|
+
* hook name (`UserPromptSubmit`, managed session bridge + span ingest) OR
|
|
89
|
+
* the snake_case name the daemon's `/hooks/` handler maps to
|
|
90
|
+
* (`user_prompt_submit`, `tool_start`, `tool_end`). Before this, snake_case
|
|
91
|
+
* silently fell through every branch — the daemon's direct-`claude` hooks
|
|
92
|
+
* never opened a turn, so those sessions produced no tasks and the device
|
|
93
|
+
* timeline showed every prompt as a bare top-level chat. Making the matcher
|
|
94
|
+
* case-tolerant means no caller can quietly no-op the segmentation. */
|
|
95
|
+
ingestHook(sessionId, rawEvent, data) {
|
|
66
96
|
if (!this.store.enabled)
|
|
67
97
|
return;
|
|
68
98
|
const runId = this.sessionToRun.get(sessionId);
|
|
69
99
|
if (!runId)
|
|
70
100
|
return;
|
|
101
|
+
const event = normalizeHookEventName(rawEvent);
|
|
71
102
|
const toolName = typeof data.tool_name === 'string' ? data.tool_name : null;
|
|
72
103
|
// ── Turn management ──
|
|
73
104
|
if (event === 'UserPromptSubmit') {
|
|
@@ -77,8 +108,31 @@ export class ApmeCollector {
|
|
|
77
108
|
? data.message?.content
|
|
78
109
|
: undefined);
|
|
79
110
|
const prompt = typeof rawPrompt === 'string' ? rawPrompt.slice(0, 8_000) : null;
|
|
80
|
-
//
|
|
81
|
-
|
|
111
|
+
// Duplicate-open guard: one user prompt can reach the collector more
|
|
112
|
+
// than once within moments. Closing and reopening here would strand an
|
|
113
|
+
// empty phantom turn and shift every later turn_index, so an identical
|
|
114
|
+
// prompt landing on a fresh, still-empty turn is a no-op.
|
|
115
|
+
const openTurn = this.sessionToTurn.get(sessionId);
|
|
116
|
+
if (openTurn && prompt !== null && openTurn.prompt === prompt &&
|
|
117
|
+
openTurn.toolCalls === 0 && !openTurn.hasResponse &&
|
|
118
|
+
Date.now() - openTurn.startedAt < DUPLICATE_TURN_OPEN_WINDOW_MS) {
|
|
119
|
+
debug('APME', `duplicate turn_start ignored (echo) turn=${openTurn.id.slice(0, 8)}`);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
// Close previous turn if open. Resolve prevIndex carefully: the active
|
|
123
|
+
// turn may already have been closed by an explicit closeTurnForSession
|
|
124
|
+
// (Codex `codex_stop` hook), in which case sessionToTurn is empty. Fall
|
|
125
|
+
// back to the last closed turn's row in the store so subsequent turns
|
|
126
|
+
// keep monotonically increasing turn_index instead of resetting to 0.
|
|
127
|
+
let prevIndex = this.sessionToTurn.get(sessionId)?.index ?? -1;
|
|
128
|
+
if (prevIndex === -1) {
|
|
129
|
+
const lastTurnId = this.sessionToLastTurnId.get(sessionId);
|
|
130
|
+
if (lastTurnId) {
|
|
131
|
+
const lastIdx = this.store.getTurn(lastTurnId)?.turn_index;
|
|
132
|
+
if (typeof lastIdx === 'number')
|
|
133
|
+
prevIndex = lastIdx;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
82
136
|
this.closeTurn(sessionId);
|
|
83
137
|
// Open new turn
|
|
84
138
|
const turnIndex = prevIndex + 1;
|
|
@@ -90,6 +144,7 @@ export class ApmeCollector {
|
|
|
90
144
|
startedAt: Date.now(), toolCalls: 0,
|
|
91
145
|
filesModified: 0, filesCreated: 0,
|
|
92
146
|
gitBefore: readGitHead(projectPath),
|
|
147
|
+
prompt, hasResponse: false,
|
|
93
148
|
};
|
|
94
149
|
this.sessionToTurn.set(sessionId, turn);
|
|
95
150
|
// Ensure an active task exists so this turn can attach to it. Tasks group
|
|
@@ -97,9 +152,16 @@ export class ApmeCollector {
|
|
|
97
152
|
// /clear, session_end). First turn in a run opens task 0.
|
|
98
153
|
const task = this.openTaskIfNone(sessionId, runId);
|
|
99
154
|
if (task) {
|
|
100
|
-
|
|
155
|
+
const wasFirstTurn = task.firstTurnIndex === null;
|
|
156
|
+
if (wasFirstTurn)
|
|
101
157
|
task.firstTurnIndex = turnIndex;
|
|
102
158
|
task.lastTurnIndex = turnIndex;
|
|
159
|
+
// Second (or later) turn on the same task proves it's a real,
|
|
160
|
+
// multi-turn work unit → promote the deferred TASK header so the
|
|
161
|
+
// follow-up prompts render grouped under one row instead of as
|
|
162
|
+
// separate top-level chats. Single-turn Q&A never reaches here.
|
|
163
|
+
if (!wasFirstTurn)
|
|
164
|
+
this.emitDeferredTaskStartIfNeeded(sessionId);
|
|
103
165
|
}
|
|
104
166
|
try {
|
|
105
167
|
this.store.insertTurn({
|
|
@@ -111,6 +173,10 @@ export class ApmeCollector {
|
|
|
111
173
|
catch (err) {
|
|
112
174
|
debug('APME', `insertTurn failed: ${String(err)}`);
|
|
113
175
|
}
|
|
176
|
+
// Sample trajectory: the user message opens the turn's typed event log.
|
|
177
|
+
if (task) {
|
|
178
|
+
this.appendSampleEvent({ taskId: task.id, runId, turnIndex }, { kind: 'user_message', ts: turn.startedAt, dedupCore: hashCore(prompt ?? `turn${turnIndex}`), payloadObj: { text: prompt ?? '' } });
|
|
179
|
+
}
|
|
114
180
|
// Also set run's task_prompt from first prompt
|
|
115
181
|
try {
|
|
116
182
|
if (run && !run.taskPrompt && prompt) {
|
|
@@ -127,16 +193,70 @@ export class ApmeCollector {
|
|
|
127
193
|
activeTurn.filesModified++;
|
|
128
194
|
if (toolName === 'Write')
|
|
129
195
|
activeTurn.filesCreated++;
|
|
196
|
+
// Sample trajectory: a tool call starts as a pending ToolEvent; its
|
|
197
|
+
// PostToolUse result resolves the SAME row (one row, not two).
|
|
198
|
+
const task = this.sessionToTask.get(sessionId);
|
|
199
|
+
if (task && toolName) {
|
|
200
|
+
this.appendSampleEvent({ taskId: task.id, runId, turnIndex: activeTurn.index }, {
|
|
201
|
+
kind: 'tool', toolName, toolStatus: 'pending',
|
|
202
|
+
dedupCore: `${toolName}:${activeTurn.toolCalls}`,
|
|
203
|
+
payloadObj: { input: extractToolInput(data) },
|
|
204
|
+
});
|
|
205
|
+
}
|
|
130
206
|
}
|
|
131
|
-
//
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
207
|
+
// Sample trajectory: resolve the pending ToolEvent on PostToolUse / tool_result.
|
|
208
|
+
if ((event === 'PostToolUse' || event === 'tool_result') && toolName) {
|
|
209
|
+
const task = this.sessionToTask.get(sessionId);
|
|
210
|
+
const turnIndex = this.sessionToTurn.get(sessionId)?.index;
|
|
211
|
+
if (task && turnIndex !== undefined) {
|
|
212
|
+
const isError = Boolean(data.is_error || data.error);
|
|
213
|
+
const output = extractToolOutput(data);
|
|
214
|
+
const pending = this.store.findPendingToolEvent(task.id, turnIndex, toolName);
|
|
215
|
+
if (pending?.id != null) {
|
|
216
|
+
let payload = pending.payload;
|
|
217
|
+
try {
|
|
218
|
+
const obj = pending.payload ? JSON.parse(pending.payload) : {};
|
|
219
|
+
if (output !== undefined)
|
|
220
|
+
obj.output = output;
|
|
221
|
+
payload = safeStringify(obj);
|
|
222
|
+
}
|
|
223
|
+
catch { /* keep existing payload */ }
|
|
224
|
+
this.store.updateSampleEvent(pending.id, {
|
|
225
|
+
toolStatus: isError ? 'error' : 'success',
|
|
226
|
+
toolError: isError ? String(data.error ?? 'error').slice(0, 500) : null,
|
|
227
|
+
payload,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
// No pending row (PostToolUse without a matching PreToolUse) — record
|
|
232
|
+
// a resolved tool event directly.
|
|
233
|
+
this.appendSampleEvent({ taskId: task.id, runId, turnIndex }, {
|
|
234
|
+
kind: 'tool', toolName,
|
|
235
|
+
toolStatus: isError ? 'error' : 'success',
|
|
236
|
+
toolError: isError ? String(data.error ?? 'error').slice(0, 500) : null,
|
|
237
|
+
dedupCore: `${toolName}:resolved:${turnIndex}:${this.store.nextSampleSeq(task.id)}`,
|
|
238
|
+
payloadObj: { output },
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
// ── Task boundary HINT: TodoWrite all-completed ──
|
|
244
|
+
// Demoted from a hard boundary to a non-segmenting hint (2026-06).
|
|
245
|
+
// TodoWrite-all-complete fires unreliably (~18% on Claude Code v2.1) and,
|
|
246
|
+
// when it did fire, fragmented a single logical task into several units.
|
|
247
|
+
// Tasks now segment only on EXPLICIT boundaries (`/task close`, `/clear`)
|
|
248
|
+
// or `session_end` — a stable, user-controlled unit. We still record the
|
|
249
|
+
// milestone in the trajectory so the task rollup can see that the agent
|
|
250
|
+
// declared its todos done, without splitting the task.
|
|
136
251
|
if (event === 'PostToolUse' && toolName === 'TodoWrite') {
|
|
137
252
|
const todos = extractTodos(data);
|
|
138
253
|
if (todos && todos.length > 0 && todos.every((t) => t.status === 'completed')) {
|
|
139
|
-
this.
|
|
254
|
+
const task = this.sessionToTask.get(sessionId);
|
|
255
|
+
const turnIndex = this.sessionToTurn.get(sessionId)?.index;
|
|
256
|
+
if (task && turnIndex !== undefined) {
|
|
257
|
+
this.appendSampleEvent({ taskId: task.id, runId, turnIndex }, { kind: 'state', dedupCore: `todos_complete:${turnIndex}:${todos.length}`, payloadObj: { state: 'todos_completed', count: todos.length } });
|
|
258
|
+
this.fireTaskMilestone(sessionId, task.id, runId, turnIndex, todos.length);
|
|
259
|
+
}
|
|
140
260
|
}
|
|
141
261
|
}
|
|
142
262
|
// Record step
|
|
@@ -150,6 +270,14 @@ export class ApmeCollector {
|
|
|
150
270
|
debug('APME', `ingestHook failed: ${String(err)}`);
|
|
151
271
|
}
|
|
152
272
|
}
|
|
273
|
+
/** Public wrapper for the private `closeTurn`. Used by adapters that
|
|
274
|
+
* see an explicit turn-end signal (Codex `codex_stop` hook) and want
|
|
275
|
+
* to finalize the turn row immediately rather than wait for the next
|
|
276
|
+
* UserPromptSubmit / closeRun to flush endedAt + buffered counters.
|
|
277
|
+
* Idempotent — no-op when no active turn for the session. */
|
|
278
|
+
closeTurnForSession(sessionId) {
|
|
279
|
+
this.closeTurn(sessionId);
|
|
280
|
+
}
|
|
153
281
|
/** Close the current turn for a session (called on new prompt or session end). */
|
|
154
282
|
closeTurn(sessionId) {
|
|
155
283
|
const turn = this.sessionToTurn.get(sessionId);
|
|
@@ -182,9 +310,48 @@ export class ApmeCollector {
|
|
|
182
310
|
getActiveTaskId(sessionId) {
|
|
183
311
|
return this.sessionToTask.get(sessionId)?.id ?? null;
|
|
184
312
|
}
|
|
313
|
+
/** Fire the `onTaskMilestone` listener for a TodoWrite-all-completed hint,
|
|
314
|
+
* at most once per (task, turn) — the agent may rewrite an all-completed
|
|
315
|
+
* todo list several times inside one turn. */
|
|
316
|
+
fireTaskMilestone(sessionId, taskId, runId, turnIndex, todoCount) {
|
|
317
|
+
if (!this.onTaskMilestone)
|
|
318
|
+
return;
|
|
319
|
+
const key = `${taskId}:${turnIndex}`;
|
|
320
|
+
if (this.sessionToLastMilestone.get(sessionId) === key)
|
|
321
|
+
return;
|
|
322
|
+
this.sessionToLastMilestone.set(sessionId, key);
|
|
323
|
+
// A TodoWrite plan implies a task worth showing — promote the deferred
|
|
324
|
+
// task_start first so the milestone row never renders orphaned above a
|
|
325
|
+
// task with no header.
|
|
326
|
+
this.emitDeferredTaskStartIfNeeded(sessionId);
|
|
327
|
+
const run = this.store.getRun(runId);
|
|
328
|
+
try {
|
|
329
|
+
this.onTaskMilestone({
|
|
330
|
+
taskId,
|
|
331
|
+
runId,
|
|
332
|
+
sessionId,
|
|
333
|
+
agentType: (run?.agentType ?? null),
|
|
334
|
+
projectName: run?.projectName ?? null,
|
|
335
|
+
turnIndex,
|
|
336
|
+
todoCount,
|
|
337
|
+
at: Date.now(),
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
catch (err) {
|
|
341
|
+
debug('APME', `onTaskMilestone listener threw: ${String(err)}`);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
185
344
|
/** Open a new task if none is active for this session. Returns the active
|
|
186
345
|
* task (new or existing), or null if no run is open. Idempotent: repeat
|
|
187
|
-
* calls while a task is already active are no-ops.
|
|
346
|
+
* calls while a task is already active are no-ops.
|
|
347
|
+
*
|
|
348
|
+
* The `task_start` timeline row is NOT emitted here — it is deferred to
|
|
349
|
+
* `emitDeferredTaskStartIfNeeded`, fired when a "real task" signal lands
|
|
350
|
+
* (a second turn on the same task, or a TodoWrite plan). Short single-turn
|
|
351
|
+
* conversations therefore never produce a TASK header on the dashboard,
|
|
352
|
+
* keeping the timeline focused on the turn rows worth evaluating. The DB
|
|
353
|
+
* `tasks` row is inserted immediately so eval/analytics see every unit.
|
|
354
|
+
* Mirrors ApmeCollector.swift `openTaskIfNone` + `emitDeferredTaskStartIfNeeded`. */
|
|
188
355
|
openTaskIfNone(sessionId, runId) {
|
|
189
356
|
const existing = this.sessionToTask.get(sessionId);
|
|
190
357
|
if (existing)
|
|
@@ -198,6 +365,7 @@ export class ApmeCollector {
|
|
|
198
365
|
startedAt: Date.now(),
|
|
199
366
|
firstTurnIndex: null,
|
|
200
367
|
lastTurnIndex: null,
|
|
368
|
+
timelineEmitted: false,
|
|
201
369
|
};
|
|
202
370
|
this.sessionToTask.set(sessionId, task);
|
|
203
371
|
try {
|
|
@@ -214,6 +382,57 @@ export class ApmeCollector {
|
|
|
214
382
|
}
|
|
215
383
|
return task;
|
|
216
384
|
}
|
|
385
|
+
/** Emit the deferred `task_start` timeline row for the session's active task,
|
|
386
|
+
* once. Idempotent — repeat calls after the first are no-ops. Uses the
|
|
387
|
+
* task's original `startedAt` as the row ts so the TASK header anchors above
|
|
388
|
+
* the first turn it groups instead of jumping in mid-conversation. Called
|
|
389
|
+
* when a task proves itself "real" (second turn, or TodoWrite plan).
|
|
390
|
+
* Mirrors ApmeCollector.swift `emitDeferredTaskStartIfNeeded`. */
|
|
391
|
+
emitDeferredTaskStartIfNeeded(sessionId) {
|
|
392
|
+
const task = this.sessionToTask.get(sessionId);
|
|
393
|
+
if (!task || task.timelineEmitted)
|
|
394
|
+
return;
|
|
395
|
+
task.timelineEmitted = true;
|
|
396
|
+
if (!this.onTaskOpened)
|
|
397
|
+
return;
|
|
398
|
+
const run = this.store.getRun(task.runId);
|
|
399
|
+
try {
|
|
400
|
+
this.onTaskOpened({
|
|
401
|
+
taskId: task.id,
|
|
402
|
+
runId: task.runId,
|
|
403
|
+
sessionId,
|
|
404
|
+
agentType: (run?.agentType ?? null),
|
|
405
|
+
projectName: run?.projectName ?? null,
|
|
406
|
+
taskIndex: task.index,
|
|
407
|
+
startedAt: task.startedAt,
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
catch (err) {
|
|
411
|
+
debug('APME', `onTaskOpened listener threw: ${String(err)}`);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
/** Public wrapper for closeTask. Used by the manual-boundary CLI /
|
|
415
|
+
* HTTP route + the macOS detail-pane button — lets the user declare
|
|
416
|
+
* "this task is done" without `/clear` (which would also split the
|
|
417
|
+
* run). No-op when no task is active. Passing `outcome` overrides the
|
|
418
|
+
* judge's coarse score-derived class — handy for `task cancel` where
|
|
419
|
+
* the user wants the row tagged "abandoned" regardless of partial
|
|
420
|
+
* progress. */
|
|
421
|
+
closeTaskExternal(sessionId, boundarySignal = 'manual', outcome) {
|
|
422
|
+
const task = this.sessionToTask.get(sessionId);
|
|
423
|
+
if (!task)
|
|
424
|
+
return false;
|
|
425
|
+
this.closeTask(sessionId, boundarySignal);
|
|
426
|
+
if (outcome) {
|
|
427
|
+
try {
|
|
428
|
+
this.store.updateTask(task.id, { outcome });
|
|
429
|
+
}
|
|
430
|
+
catch (err) {
|
|
431
|
+
debug('APME', `manual outcome write failed: ${String(err)}`);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
return true;
|
|
435
|
+
}
|
|
217
436
|
/** Close the current task for a session, persisting boundary metadata.
|
|
218
437
|
* No-op if no task is active. Fires `onTaskClosed` so the runner can
|
|
219
438
|
* enqueue a task-level judge call. Tasks that never saw a turn
|
|
@@ -223,6 +442,7 @@ export class ApmeCollector {
|
|
|
223
442
|
if (!task)
|
|
224
443
|
return;
|
|
225
444
|
this.sessionToTask.delete(sessionId);
|
|
445
|
+
this.sessionToLastMilestone.delete(sessionId);
|
|
226
446
|
// Empty task: no turns ever attached. Drop the row so the dashboard
|
|
227
447
|
// doesn't show phantom entries from back-to-back boundary signals.
|
|
228
448
|
if (task.firstTurnIndex === null) {
|
|
@@ -237,10 +457,31 @@ export class ApmeCollector {
|
|
|
237
457
|
debug('APME', `closeTask ${task.id.slice(0, 8)} — empty, dropped`);
|
|
238
458
|
return;
|
|
239
459
|
}
|
|
240
|
-
//
|
|
241
|
-
//
|
|
460
|
+
// Category, present-at-close. Prefer the run's already-resolved category;
|
|
461
|
+
// otherwise classify synchronously from the run's signals so the task row
|
|
462
|
+
// (and its rollup judge rubric) always carries a stable category. The
|
|
463
|
+
// async run-level classifier (classifyRunSmart at closeRun) frequently
|
|
464
|
+
// resolves AFTER the task has already closed — leaving taskCategory null
|
|
465
|
+
// and the judge falling back to the wrong generic rubric.
|
|
242
466
|
const run = this.store.getRun(task.runId);
|
|
243
|
-
|
|
467
|
+
let taskCategory = run?.taskCategory ?? null;
|
|
468
|
+
if (!taskCategory || taskCategory === 'unknown') {
|
|
469
|
+
try {
|
|
470
|
+
const signals = computeSignals(this.store, task.runId);
|
|
471
|
+
// run.endedAt is still null at session_end close (updateRun runs after
|
|
472
|
+
// closeTask), so sessionDurationSec would be 0 and skew the duration
|
|
473
|
+
// rules — derive it from the task span instead.
|
|
474
|
+
if (signals.sessionDurationSec === 0) {
|
|
475
|
+
signals.sessionDurationSec = Math.max(0, Math.round((Date.now() - task.startedAt) / 1000));
|
|
476
|
+
}
|
|
477
|
+
const category = classify(signals);
|
|
478
|
+
if (category && category !== 'unknown')
|
|
479
|
+
taskCategory = category;
|
|
480
|
+
}
|
|
481
|
+
catch (err) {
|
|
482
|
+
debug('APME', `closeTask classify failed: ${String(err)}`);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
244
485
|
try {
|
|
245
486
|
this.store.updateTask(task.id, {
|
|
246
487
|
endedAt: Date.now(),
|
|
@@ -253,14 +494,20 @@ export class ApmeCollector {
|
|
|
253
494
|
debug('APME', `updateTask failed: ${String(err)}`);
|
|
254
495
|
}
|
|
255
496
|
debug('APME', `closeTask ${task.id.slice(0, 8)} signal=${boundarySignal} turns=${task.firstTurnIndex}..${task.lastTurnIndex}`);
|
|
256
|
-
// Notify listeners (runner enqueueTask
|
|
497
|
+
// Notify listeners (runner enqueueTask + timeline emitter wired via apme/index.ts).
|
|
257
498
|
if (this.onTaskClosed) {
|
|
258
499
|
try {
|
|
259
500
|
this.onTaskClosed({
|
|
260
501
|
taskId: task.id,
|
|
261
502
|
runId: task.runId,
|
|
503
|
+
sessionId,
|
|
504
|
+
agentType: (run?.agentType ?? null),
|
|
505
|
+
projectName: run?.projectName ?? null,
|
|
506
|
+
startedAt: task.startedAt,
|
|
507
|
+
endedAt: Date.now(),
|
|
262
508
|
boundarySignal,
|
|
263
509
|
taskCategory,
|
|
510
|
+
timelineEmitted: task.timelineEmitted,
|
|
264
511
|
});
|
|
265
512
|
}
|
|
266
513
|
catch (err) {
|
|
@@ -272,6 +519,92 @@ export class ApmeCollector {
|
|
|
272
519
|
getRunId(sessionId) {
|
|
273
520
|
return this.sessionToRun.get(sessionId) ?? null;
|
|
274
521
|
}
|
|
522
|
+
/** Agent type established for a session at `openRun` (survives closeRun so a
|
|
523
|
+
* late-attributed timeline row still resolves its brand). This is the single
|
|
524
|
+
* authoritative agentType source the timeline attributor backfills from, so
|
|
525
|
+
* every timeline entry ships with an agentType regardless of which emitter
|
|
526
|
+
* produced it (observed hook `chat_start`, relayed managed `tool_exec`, …). */
|
|
527
|
+
getRunAgentType(sessionId) {
|
|
528
|
+
return this.sessionToAgentType.get(sessionId) ?? null;
|
|
529
|
+
}
|
|
530
|
+
// ─── SessionSample trajectory (the normalizer's typed event log) ────────────
|
|
531
|
+
/** Resolve the (taskId, runId, turnIndex) a sample event should attach to.
|
|
532
|
+
* Prefers the active task/turn; falls back to a turn row in the DB when the
|
|
533
|
+
* task already closed (e.g. PTY response captured after session_end). */
|
|
534
|
+
sampleCtxForTurn(sessionId, turnId) {
|
|
535
|
+
const active = this.sessionToTask.get(sessionId);
|
|
536
|
+
if (turnId) {
|
|
537
|
+
const row = this.store.getTurn(turnId);
|
|
538
|
+
const taskId = row?.task_id ?? active?.id;
|
|
539
|
+
const runId = row?.run_id ?? active?.runId;
|
|
540
|
+
const turnIndex = row?.turn_index
|
|
541
|
+
?? this.sessionToTurn.get(sessionId)?.index ?? active?.lastTurnIndex ?? 0;
|
|
542
|
+
if (taskId && runId)
|
|
543
|
+
return { taskId, runId, turnIndex };
|
|
544
|
+
}
|
|
545
|
+
if (active) {
|
|
546
|
+
const turnIndex = this.sessionToTurn.get(sessionId)?.index ?? active.lastTurnIndex ?? 0;
|
|
547
|
+
return { taskId: active.id, runId: active.runId, turnIndex };
|
|
548
|
+
}
|
|
549
|
+
return null;
|
|
550
|
+
}
|
|
551
|
+
/** Append one typed trajectory event to the active sample. Storage-time dedup
|
|
552
|
+
* is handled by the UNIQUE (task_id, dedup_key) index. Fires `onSampleEvent`
|
|
553
|
+
* only when a row was actually inserted (not a dup), so the timeline
|
|
554
|
+
* projection never double-emits. */
|
|
555
|
+
appendSampleEvent(ctx, ev) {
|
|
556
|
+
if (!this.store.enabled)
|
|
557
|
+
return;
|
|
558
|
+
const dedupKey = createHash('sha1').update(`${ev.kind}|${ctx.turnIndex}|${ev.dedupCore}`).digest('hex').slice(0, 24);
|
|
559
|
+
const row = {
|
|
560
|
+
taskId: ctx.taskId,
|
|
561
|
+
runId: ctx.runId,
|
|
562
|
+
turnIndex: ctx.turnIndex,
|
|
563
|
+
seq: this.store.nextSampleSeq(ctx.taskId),
|
|
564
|
+
ts: ev.ts ?? Date.now(),
|
|
565
|
+
kind: ev.kind,
|
|
566
|
+
model: ev.model ?? null,
|
|
567
|
+
inputTokens: ev.inputTokens ?? null,
|
|
568
|
+
outputTokens: ev.outputTokens ?? null,
|
|
569
|
+
costUsd: ev.costUsd ?? null,
|
|
570
|
+
latencyMs: ev.latencyMs ?? null,
|
|
571
|
+
toolName: ev.toolName ?? null,
|
|
572
|
+
toolStatus: ev.toolStatus ?? null,
|
|
573
|
+
toolError: ev.toolError ?? null,
|
|
574
|
+
payload: ev.payloadObj ? safeStringify(ev.payloadObj) : null,
|
|
575
|
+
dedupKey,
|
|
576
|
+
};
|
|
577
|
+
let inserted = false;
|
|
578
|
+
try {
|
|
579
|
+
inserted = this.store.insertSampleEvent(row);
|
|
580
|
+
}
|
|
581
|
+
catch (err) {
|
|
582
|
+
debug('APME', `appendSampleEvent failed: ${String(err)}`);
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
if (inserted && this.onSampleEvent) {
|
|
586
|
+
const run = this.store.getRun(ctx.runId);
|
|
587
|
+
try {
|
|
588
|
+
this.onSampleEvent({
|
|
589
|
+
taskId: ctx.taskId, runId: ctx.runId,
|
|
590
|
+
sessionId: this.runToSessionId(ctx.runId),
|
|
591
|
+
agentType: (run?.agentType ?? null),
|
|
592
|
+
projectName: run?.projectName ?? null,
|
|
593
|
+
event: row,
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
catch (err) {
|
|
597
|
+
debug('APME', `onSampleEvent listener threw: ${String(err)}`);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
/** Reverse-lookup sessionId for a runId (best-effort; only the live map). */
|
|
602
|
+
runToSessionId(runId) {
|
|
603
|
+
for (const [sid, rid] of this.sessionToRun)
|
|
604
|
+
if (rid === runId)
|
|
605
|
+
return sid;
|
|
606
|
+
return '';
|
|
607
|
+
}
|
|
275
608
|
/** Store Claude's response text on the current turn.
|
|
276
609
|
* Falls back to the last closed turn if closeTurn() already ran (race with session exit).
|
|
277
610
|
* Tags turns.efficiency_json.response_kind so the runner can skip tool-only / empty
|
|
@@ -298,10 +631,21 @@ export class ApmeCollector {
|
|
|
298
631
|
response: response.slice(0, 10_000),
|
|
299
632
|
efficiencyJson,
|
|
300
633
|
});
|
|
634
|
+
if (turn)
|
|
635
|
+
turn.hasResponse = true;
|
|
301
636
|
}
|
|
302
637
|
catch (err) {
|
|
303
638
|
debug('APME', `setTurnResponse failed: ${String(err)}`);
|
|
304
639
|
}
|
|
640
|
+
// Sample trajectory: the assistant response closes the turn's event arc.
|
|
641
|
+
const ctx = this.sampleCtxForTurn(sessionId, turnId);
|
|
642
|
+
if (ctx) {
|
|
643
|
+
this.appendSampleEvent(ctx, {
|
|
644
|
+
kind: 'assistant_message',
|
|
645
|
+
dedupCore: hashCore(response.slice(0, 400)),
|
|
646
|
+
payloadObj: { text: response.slice(0, 10_000), responseKind: kind },
|
|
647
|
+
});
|
|
648
|
+
}
|
|
305
649
|
}
|
|
306
650
|
/** Apply response to the last closed turn if it has no response yet.
|
|
307
651
|
* Used as fallback when Stop hook doesn't fire (PTY output capture). */
|
|
@@ -327,6 +671,14 @@ export class ApmeCollector {
|
|
|
327
671
|
});
|
|
328
672
|
}
|
|
329
673
|
catch { /* ignore */ }
|
|
674
|
+
const ctx = this.sampleCtxForTurn(sessionId, turnId);
|
|
675
|
+
if (ctx) {
|
|
676
|
+
this.appendSampleEvent(ctx, {
|
|
677
|
+
kind: 'assistant_message',
|
|
678
|
+
dedupCore: hashCore(response.slice(0, 400)),
|
|
679
|
+
payloadObj: { text: response.slice(0, 10_000), responseKind: kind },
|
|
680
|
+
});
|
|
681
|
+
}
|
|
330
682
|
}
|
|
331
683
|
/** Single-entrypoint ingestion using the shared TelemetrySpan envelope.
|
|
332
684
|
*
|
|
@@ -381,11 +733,27 @@ export class ApmeCollector {
|
|
|
381
733
|
this.splitRun(sessionId, a['agentdeck.cwd']);
|
|
382
734
|
return;
|
|
383
735
|
}
|
|
384
|
-
//
|
|
385
|
-
//
|
|
386
|
-
//
|
|
387
|
-
//
|
|
388
|
-
|
|
736
|
+
// Adapter-emitted boundaries (OpenClaw chat.aborted → 'manual',
|
|
737
|
+
// OpenClaw idle-gap timer → 'idle_gap') close the active task.
|
|
738
|
+
// `session_end` is intentionally excluded: closeRun fires that
|
|
739
|
+
// path itself, and a duplicate here would double-emit onTaskClosed.
|
|
740
|
+
if (signal === 'manual' || signal === 'idle_gap') {
|
|
741
|
+
this.closeTask(sessionId, signal);
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
// `todo_complete` is a soft hint, not a boundary (see ingestHook
|
|
745
|
+
// TodoWrite handling) — record it in the trajectory without splitting
|
|
746
|
+
// the task. OpenCode's TodoWrite-all-completed routes here.
|
|
747
|
+
if (signal === 'todo_complete') {
|
|
748
|
+
const task = this.sessionToTask.get(sessionId);
|
|
749
|
+
const turnIndex = this.sessionToTurn.get(sessionId)?.index;
|
|
750
|
+
if (task && turnIndex !== undefined) {
|
|
751
|
+
this.appendSampleEvent({ taskId: task.id, runId: task.runId, turnIndex }, { kind: 'state', dedupCore: `todos_complete:${turnIndex}`, payloadObj: { state: 'todos_completed' } });
|
|
752
|
+
this.fireTaskMilestone(sessionId, task.id, task.runId, turnIndex, null);
|
|
753
|
+
}
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
debug('APME', `task_boundary span dropped: unknown signal=${signal ?? '<none>'}`);
|
|
389
757
|
return;
|
|
390
758
|
}
|
|
391
759
|
case 'session_meta': {
|
|
@@ -429,7 +797,10 @@ export class ApmeCollector {
|
|
|
429
797
|
}
|
|
430
798
|
catch { /* ignore */ }
|
|
431
799
|
}
|
|
432
|
-
/** Update token / cost columns from the bridge's UsageTracker snapshot.
|
|
800
|
+
/** Update token / cost columns from the bridge's UsageTracker snapshot.
|
|
801
|
+
* Snapshots carry CUMULATIVE session totals, so we emit a priced ModelEvent
|
|
802
|
+
* for the delta and attribute it to the active task (the SessionSample cost
|
|
803
|
+
* is the sum of its ModelEvents). */
|
|
433
804
|
updateUsage(sessionId, snapshot) {
|
|
434
805
|
if (!this.store.enabled)
|
|
435
806
|
return;
|
|
@@ -444,6 +815,35 @@ export class ApmeCollector {
|
|
|
444
815
|
});
|
|
445
816
|
}
|
|
446
817
|
catch { /* ignore */ }
|
|
818
|
+
// ── Per-task ModelEvent from the cumulative delta ──
|
|
819
|
+
const curIn = snapshot.inputTokens ?? 0;
|
|
820
|
+
const curOut = snapshot.outputTokens ?? 0;
|
|
821
|
+
const prev = this.sessionToUsage.get(sessionId) ?? { in: 0, out: 0 };
|
|
822
|
+
const dIn = Math.max(0, curIn - prev.in);
|
|
823
|
+
const dOut = Math.max(0, curOut - prev.out);
|
|
824
|
+
this.sessionToUsage.set(sessionId, { in: curIn, out: curOut });
|
|
825
|
+
if (dIn === 0 && dOut === 0)
|
|
826
|
+
return;
|
|
827
|
+
const task = this.sessionToTask.get(sessionId);
|
|
828
|
+
if (!task)
|
|
829
|
+
return;
|
|
830
|
+
const run = this.store.getRun(runId);
|
|
831
|
+
const model = run?.modelId ?? null;
|
|
832
|
+
const turnIndex = this.sessionToTurn.get(sessionId)?.index ?? task.lastTurnIndex ?? 0;
|
|
833
|
+
// Prefer the agent-reported marginal cost when available, else price the delta.
|
|
834
|
+
const cost = priceUsd(model, dIn, dOut);
|
|
835
|
+
this.appendSampleEvent({ taskId: task.id, runId, turnIndex }, {
|
|
836
|
+
kind: 'model', model: model ?? undefined, inputTokens: dIn, outputTokens: dOut,
|
|
837
|
+
costUsd: cost, latencyMs: 0, dedupCore: `${curIn}:${curOut}`,
|
|
838
|
+
});
|
|
839
|
+
try {
|
|
840
|
+
this.store.updateTask(task.id, {
|
|
841
|
+
modelId: model ?? undefined,
|
|
842
|
+
modelConfig: JSON.stringify({ modelId: model ?? 'unknown', provider: providerFor(model) }),
|
|
843
|
+
});
|
|
844
|
+
this.store.recomputeSampleCost(task.id);
|
|
845
|
+
}
|
|
846
|
+
catch { /* ignore */ }
|
|
447
847
|
}
|
|
448
848
|
/** Split the current run — closes the active run and opens a fresh one.
|
|
449
849
|
* Triggered on `/clear` or other context-reset events so each logical
|
|
@@ -500,6 +900,7 @@ export class ApmeCollector {
|
|
|
500
900
|
this.sessionToRun.delete(sessionId);
|
|
501
901
|
this.sessionToLastTurnId.delete(sessionId);
|
|
502
902
|
this.runTaskCount.delete(runId);
|
|
903
|
+
this.sessionToUsage.delete(sessionId);
|
|
503
904
|
// Mark empty runs so the dashboard can filter them out.
|
|
504
905
|
// Don't delete — FK constraints and concurrent access make deletion risky.
|
|
505
906
|
const run = this.store.getRun(runId);
|
|
@@ -593,6 +994,21 @@ export class ApmeCollector {
|
|
|
593
994
|
/** Extract the todos array from a TodoWrite PostToolUse payload. Returns null
|
|
594
995
|
* if the shape doesn't match (payload malformed, older CC versions). Accepts
|
|
595
996
|
* `tool_input.todos` (hook standard) and `todos` (legacy flat shape). */
|
|
997
|
+
/** Canonicalize a hook event name to the PascalCase form `ingestHook` matches
|
|
998
|
+
* on. Accepts the daemon's snake_case (`user_prompt_submit`, `tool_start`,
|
|
999
|
+
* `tool_end`, `pre/post_tool_use`) and passes PascalCase / span aliases
|
|
1000
|
+
* (`tool_result`) through unchanged. Keeps segmentation working regardless of
|
|
1001
|
+
* which surface (managed bridge, daemon `/hooks/`, OTel span) drives it. */
|
|
1002
|
+
function normalizeHookEventName(event) {
|
|
1003
|
+
switch (event) {
|
|
1004
|
+
case 'user_prompt_submit': return 'UserPromptSubmit';
|
|
1005
|
+
case 'tool_start':
|
|
1006
|
+
case 'pre_tool_use': return 'PreToolUse';
|
|
1007
|
+
case 'tool_end':
|
|
1008
|
+
case 'post_tool_use': return 'PostToolUse';
|
|
1009
|
+
default: return event;
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
596
1012
|
function extractTodos(data) {
|
|
597
1013
|
const fromToolInput = data.tool_input?.todos;
|
|
598
1014
|
const fromFlat = data.todos;
|
|
@@ -615,6 +1031,37 @@ function extractTodos(data) {
|
|
|
615
1031
|
}
|
|
616
1032
|
return items;
|
|
617
1033
|
}
|
|
1034
|
+
/** Short stable hash for sample-event dedup keys (content-derived). */
|
|
1035
|
+
function hashCore(s) {
|
|
1036
|
+
return createHash('sha1').update(s).digest('hex').slice(0, 16);
|
|
1037
|
+
}
|
|
1038
|
+
/** Best-effort tool-input extraction from a hook/span payload. Claude Code
|
|
1039
|
+
* hooks carry `tool_input`; spans pass the raw payload directly. Capped to
|
|
1040
|
+
* keep the trajectory row small. */
|
|
1041
|
+
function extractToolInput(data) {
|
|
1042
|
+
const ti = data.tool_input ?? data.input ?? null;
|
|
1043
|
+
return clampPayload(ti);
|
|
1044
|
+
}
|
|
1045
|
+
/** Best-effort tool-output extraction. Claude Code PostToolUse carries
|
|
1046
|
+
* `tool_response`; other sources use `output` / `result`. */
|
|
1047
|
+
function extractToolOutput(data) {
|
|
1048
|
+
const out = data.tool_response ?? data.output ?? data.result;
|
|
1049
|
+
return out === undefined ? undefined : clampPayload(out);
|
|
1050
|
+
}
|
|
1051
|
+
/** Trim large tool payloads so a single event row stays bounded (≤4KB JSON). */
|
|
1052
|
+
function clampPayload(v) {
|
|
1053
|
+
if (v == null)
|
|
1054
|
+
return v;
|
|
1055
|
+
try {
|
|
1056
|
+
const s = typeof v === 'string' ? v : JSON.stringify(v);
|
|
1057
|
+
if (s.length <= 4_000)
|
|
1058
|
+
return v;
|
|
1059
|
+
return (typeof v === 'string' ? v : s).slice(0, 4_000) + '…';
|
|
1060
|
+
}
|
|
1061
|
+
catch {
|
|
1062
|
+
return undefined;
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
618
1065
|
function readGitHead(cwd) {
|
|
619
1066
|
if (!cwd)
|
|
620
1067
|
return null;
|