@edspencer/paddock 0.0.1 → 0.57.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.
- package/README.md +69 -0
- package/install-notice.mjs +29 -0
- package/package.json +58 -5
- package/packages/server/dist/adoptable.d.ts +127 -0
- package/packages/server/dist/adoptable.js +315 -0
- package/packages/server/dist/app.d.ts +67 -0
- package/packages/server/dist/app.js +325 -0
- package/packages/server/dist/archive.d.ts +42 -0
- package/packages/server/dist/archive.js +125 -0
- package/packages/server/dist/attachments-config.d.ts +114 -0
- package/packages/server/dist/attachments-config.js +165 -0
- package/packages/server/dist/attachments-hint.d.ts +53 -0
- package/packages/server/dist/attachments-hint.js +140 -0
- package/packages/server/dist/attachments.d.ts +33 -0
- package/packages/server/dist/attachments.js +117 -0
- package/packages/server/dist/auth.d.ts +84 -0
- package/packages/server/dist/auth.js +247 -0
- package/packages/server/dist/background.d.ts +36 -0
- package/packages/server/dist/background.js +137 -0
- package/packages/server/dist/bind-safety.d.ts +56 -0
- package/packages/server/dist/bind-safety.js +51 -0
- package/packages/server/dist/brand.d.ts +44 -0
- package/packages/server/dist/brand.js +88 -0
- package/packages/server/dist/chat-dto.d.ts +210 -0
- package/packages/server/dist/chat-dto.js +308 -0
- package/packages/server/dist/cli/import-chats.d.ts +21 -0
- package/packages/server/dist/cli/import-chats.js +197 -0
- package/packages/server/dist/cli/paddock.d.ts +2 -0
- package/packages/server/dist/cli/paddock.js +247 -0
- package/packages/server/dist/concurrency.d.ts +57 -0
- package/packages/server/dist/concurrency.js +71 -0
- package/packages/server/dist/config.d.ts +538 -0
- package/packages/server/dist/config.js +625 -0
- package/packages/server/dist/curation-config.d.ts +55 -0
- package/packages/server/dist/curation-config.js +64 -0
- package/packages/server/dist/environment-prompt.d.ts +52 -0
- package/packages/server/dist/environment-prompt.js +61 -0
- package/packages/server/dist/event-bus.d.ts +87 -0
- package/packages/server/dist/event-bus.js +48 -0
- package/packages/server/dist/git.d.ts +128 -0
- package/packages/server/dist/git.js +446 -0
- package/packages/server/dist/github-auth.d.ts +44 -0
- package/packages/server/dist/github-auth.js +169 -0
- package/packages/server/dist/herdctl-agent-config.d.ts +98 -0
- package/packages/server/dist/herdctl-agent-config.js +331 -0
- package/packages/server/dist/herdctl-agent-names.d.ts +172 -0
- package/packages/server/dist/herdctl-agent-names.js +228 -0
- package/packages/server/dist/herdctl-jobs-index.d.ts +65 -0
- package/packages/server/dist/herdctl-jobs-index.js +225 -0
- package/packages/server/dist/herdctl-jobs.d.ts +98 -0
- package/packages/server/dist/herdctl-jobs.js +225 -0
- package/packages/server/dist/herdctl.d.ts +623 -0
- package/packages/server/dist/herdctl.js +1279 -0
- package/packages/server/dist/hook-config.d.ts +217 -0
- package/packages/server/dist/hook-config.js +224 -0
- package/packages/server/dist/http-bytes.d.ts +24 -0
- package/packages/server/dist/http-bytes.js +52 -0
- package/packages/server/dist/index.d.ts +1 -0
- package/packages/server/dist/index.js +14 -0
- package/packages/server/dist/instance-config.d.ts +143 -0
- package/packages/server/dist/instance-config.js +385 -0
- package/packages/server/dist/localcommand.d.ts +23 -0
- package/packages/server/dist/localcommand.js +140 -0
- package/packages/server/dist/management-auth.d.ts +99 -0
- package/packages/server/dist/management-auth.js +165 -0
- package/packages/server/dist/management-config.d.ts +164 -0
- package/packages/server/dist/management-config.js +286 -0
- package/packages/server/dist/management-mcp-server.d.ts +20 -0
- package/packages/server/dist/management-mcp-server.js +95 -0
- package/packages/server/dist/management-metadata.d.ts +67 -0
- package/packages/server/dist/management-metadata.js +56 -0
- package/packages/server/dist/management-ops.d.ts +110 -0
- package/packages/server/dist/management-ops.js +449 -0
- package/packages/server/dist/management-policy.d.ts +172 -0
- package/packages/server/dist/management-policy.js +259 -0
- package/packages/server/dist/message-provenance.d.ts +112 -0
- package/packages/server/dist/message-provenance.js +260 -0
- package/packages/server/dist/models.d.ts +164 -0
- package/packages/server/dist/models.js +208 -0
- package/packages/server/dist/openapi.d.ts +66 -0
- package/packages/server/dist/openapi.js +183 -0
- package/packages/server/dist/parent-detach.d.ts +30 -0
- package/packages/server/dist/parent-detach.js +109 -0
- package/packages/server/dist/preload.d.ts +43 -0
- package/packages/server/dist/preload.js +55 -0
- package/packages/server/dist/project-files.d.ts +76 -0
- package/packages/server/dist/project-files.js +191 -0
- package/packages/server/dist/project-mime.d.ts +35 -0
- package/packages/server/dist/project-mime.js +55 -0
- package/packages/server/dist/project-paths.d.ts +88 -0
- package/packages/server/dist/project-paths.js +126 -0
- package/packages/server/dist/project-types.d.ts +316 -0
- package/packages/server/dist/project-types.js +48 -0
- package/packages/server/dist/projects.d.ts +202 -0
- package/packages/server/dist/projects.js +894 -0
- package/packages/server/dist/queued-message.d.ts +35 -0
- package/packages/server/dist/queued-message.js +116 -0
- package/packages/server/dist/read-state.d.ts +46 -0
- package/packages/server/dist/read-state.js +139 -0
- package/packages/server/dist/recovery-config.d.ts +111 -0
- package/packages/server/dist/recovery-config.js +105 -0
- package/packages/server/dist/recovery.d.ts +262 -0
- package/packages/server/dist/recovery.js +490 -0
- package/packages/server/dist/resume-drain.d.ts +122 -0
- package/packages/server/dist/resume-drain.js +158 -0
- package/packages/server/dist/route-context.d.ts +168 -0
- package/packages/server/dist/route-context.js +39 -0
- package/packages/server/dist/route-errors.d.ts +8 -0
- package/packages/server/dist/route-errors.js +13 -0
- package/packages/server/dist/routes/chats.d.ts +12 -0
- package/packages/server/dist/routes/chats.js +1406 -0
- package/packages/server/dist/routes/git.d.ts +13 -0
- package/packages/server/dist/routes/git.js +236 -0
- package/packages/server/dist/routes/mcp.d.ts +83 -0
- package/packages/server/dist/routes/mcp.js +250 -0
- package/packages/server/dist/routes/meta.d.ts +15 -0
- package/packages/server/dist/routes/meta.js +447 -0
- package/packages/server/dist/routes/projects.d.ts +25 -0
- package/packages/server/dist/routes/projects.js +736 -0
- package/packages/server/dist/routes/triggers.d.ts +21 -0
- package/packages/server/dist/routes/triggers.js +324 -0
- package/packages/server/dist/routes/workspace-mount.d.ts +48 -0
- package/packages/server/dist/routes/workspace-mount.js +32 -0
- package/packages/server/dist/routes.d.ts +26 -0
- package/packages/server/dist/routes.js +29 -0
- package/packages/server/dist/run-provenance.d.ts +109 -0
- package/packages/server/dist/run-provenance.js +206 -0
- package/packages/server/dist/runs.d.ts +92 -0
- package/packages/server/dist/runs.js +72 -0
- package/packages/server/dist/schedule-config.d.ts +68 -0
- package/packages/server/dist/schedule-config.js +75 -0
- package/packages/server/dist/schedule-session.d.ts +34 -0
- package/packages/server/dist/schedule-session.js +120 -0
- package/packages/server/dist/self-mcp-descriptions.d.ts +20 -0
- package/packages/server/dist/self-mcp-descriptions.js +145 -0
- package/packages/server/dist/self-mcp-read.d.ts +15 -0
- package/packages/server/dist/self-mcp-read.js +97 -0
- package/packages/server/dist/self-mcp-triggers.d.ts +30 -0
- package/packages/server/dist/self-mcp-triggers.js +144 -0
- package/packages/server/dist/self-mcp-types.d.ts +248 -0
- package/packages/server/dist/self-mcp-types.js +10 -0
- package/packages/server/dist/self-mcp-util.d.ts +73 -0
- package/packages/server/dist/self-mcp-util.js +157 -0
- package/packages/server/dist/self-mcp-write.d.ts +35 -0
- package/packages/server/dist/self-mcp-write.js +215 -0
- package/packages/server/dist/self-mcp.d.ts +95 -0
- package/packages/server/dist/self-mcp.js +448 -0
- package/packages/server/dist/send-file-mcp.d.ts +46 -0
- package/packages/server/dist/send-file-mcp.js +281 -0
- package/packages/server/dist/session-hub.d.ts +186 -0
- package/packages/server/dist/session-hub.js +291 -0
- package/packages/server/dist/spawn-capability.d.ts +99 -0
- package/packages/server/dist/spawn-capability.js +106 -0
- package/packages/server/dist/star.d.ts +26 -0
- package/packages/server/dist/star.js +94 -0
- package/packages/server/dist/start.d.ts +1 -0
- package/packages/server/dist/start.js +21 -0
- package/packages/server/dist/subagents.d.ts +337 -0
- package/packages/server/dist/subagents.js +626 -0
- package/packages/server/dist/sweep.d.ts +122 -0
- package/packages/server/dist/sweep.js +696 -0
- package/packages/server/dist/tooldetails.d.ts +33 -0
- package/packages/server/dist/tooldetails.js +466 -0
- package/packages/server/dist/transcribe.d.ts +69 -0
- package/packages/server/dist/transcribe.js +119 -0
- package/packages/server/dist/transcripts.d.ts +42 -0
- package/packages/server/dist/transcripts.js +164 -0
- package/packages/server/dist/trigger-config.d.ts +278 -0
- package/packages/server/dist/trigger-config.js +417 -0
- package/packages/server/dist/trigger-runtime.d.ts +94 -0
- package/packages/server/dist/trigger-runtime.js +82 -0
- package/packages/server/dist/trigger-session.d.ts +34 -0
- package/packages/server/dist/trigger-session.js +122 -0
- package/packages/server/dist/triggers.d.ts +83 -0
- package/packages/server/dist/triggers.js +124 -0
- package/packages/server/dist/trusted-proxy.d.ts +57 -0
- package/packages/server/dist/trusted-proxy.js +157 -0
- package/packages/server/dist/turn-notice.d.ts +154 -0
- package/packages/server/dist/turn-notice.js +271 -0
- package/packages/server/dist/unread.d.ts +36 -0
- package/packages/server/dist/unread.js +123 -0
- package/packages/server/dist/usage.d.ts +66 -0
- package/packages/server/dist/usage.js +243 -0
- package/packages/server/dist/wake-injection.d.ts +156 -0
- package/packages/server/dist/wake-injection.js +136 -0
- package/packages/server/dist/whisper-local.d.ts +9 -0
- package/packages/server/dist/whisper-local.js +122 -0
- package/packages/server/dist/ws-context.d.ts +158 -0
- package/packages/server/dist/ws-context.js +1 -0
- package/packages/server/dist/ws-protocol.d.ts +423 -0
- package/packages/server/dist/ws-protocol.js +251 -0
- package/packages/server/dist/ws-self-mcp.d.ts +52 -0
- package/packages/server/dist/ws-self-mcp.js +28 -0
- package/packages/server/dist/ws-triggers.d.ts +29 -0
- package/packages/server/dist/ws-triggers.js +261 -0
- package/packages/server/dist/ws-turn.d.ts +86 -0
- package/packages/server/dist/ws-turn.js +751 -0
- package/packages/server/dist/ws.d.ts +62 -0
- package/packages/server/dist/ws.js +1024 -0
- package/packages/server/package.json +6 -0
- package/packages/web/dist/assets/InstanceConfigPage-Bypd_quL.js +2 -0
- package/packages/web/dist/assets/MarkdownRenderer-Co8w9iuX.js +30 -0
- package/packages/web/dist/assets/ProjectRedirect-DXrA24Cy.js +2 -0
- package/packages/web/dist/assets/ProjectView-B9opP7Sv.js +13 -0
- package/packages/web/dist/assets/ProjectsGrid-D2cwb2jY.js +2 -0
- package/packages/web/dist/assets/arc-B--FIRB8.js +2 -0
- package/packages/web/dist/assets/architectureDiagram-3BPJPVTR-B6dRWZZ6.js +37 -0
- package/packages/web/dist/assets/bash-I8pq0VWm.js +2 -0
- package/packages/web/dist/assets/blockDiagram-GPEHLZMM-DLxFAvvf.js +133 -0
- package/packages/web/dist/assets/c-D2F1sQUL.js +2 -0
- package/packages/web/dist/assets/c4Diagram-AAUBKEIU-CNee5ks4.js +11 -0
- package/packages/web/dist/assets/channel-Bw0A52CB.js +2 -0
- package/packages/web/dist/assets/chunk-2J33WTMH-DObw03Xl.js +2 -0
- package/packages/web/dist/assets/chunk-4BX2VUAB-CH2SR6nN.js +2 -0
- package/packages/web/dist/assets/chunk-55IACEB6-WlVpegT3.js +2 -0
- package/packages/web/dist/assets/chunk-727SXJPM-g8GT9Rgd.js +207 -0
- package/packages/web/dist/assets/chunk-AQP2D5EJ-DbN4PsDF.js +232 -0
- package/packages/web/dist/assets/chunk-FMBD7UC4-DSN3BjFG.js +16 -0
- package/packages/web/dist/assets/chunk-ND2GUHAM-D9R4le0z.js +2 -0
- package/packages/web/dist/assets/chunk-QZHKN3VN-D4YgRaKH.js +2 -0
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-itf_kt-q.js +2 -0
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-itf_kt-q.js +2 -0
- package/packages/web/dist/assets/core-CysJ2gLj.js +4 -0
- package/packages/web/dist/assets/cose-bilkent-S5V4N54A-D1PB0KNu.js +2 -0
- package/packages/web/dist/assets/cpp-Bm3ii6kY.js +2 -0
- package/packages/web/dist/assets/csharp-BNpuYZMG.js +2 -0
- package/packages/web/dist/assets/css-DazXZka4.js +2 -0
- package/packages/web/dist/assets/cytoscape.esm-CUqq0XTU.js +332 -0
- package/packages/web/dist/assets/dagre-BM42HDAG-C6Uuq-dq.js +5 -0
- package/packages/web/dist/assets/defaultLocale-DX6XiGOO.js +2 -0
- package/packages/web/dist/assets/diagram-2AECGRRQ-C9JvQ425.js +44 -0
- package/packages/web/dist/assets/diagram-5GNKFQAL-Cu6pdh2d.js +11 -0
- package/packages/web/dist/assets/diagram-KO2AKTUF-PmXcUQhY.js +4 -0
- package/packages/web/dist/assets/diagram-LMA3HP47-9ww6_9Iq.js +25 -0
- package/packages/web/dist/assets/diagram-OG6HWLK6-DWQVjv_n.js +25 -0
- package/packages/web/dist/assets/erDiagram-TEJ5UH35-6F7xTuff.js +86 -0
- package/packages/web/dist/assets/flowDiagram-I6XJVG4X-SCiGu7m6.js +163 -0
- package/packages/web/dist/assets/format-yf7Vf9kR.js +2 -0
- package/packages/web/dist/assets/ganttDiagram-6RSMTGT7-BNpPwxkL.js +293 -0
- package/packages/web/dist/assets/gitGraphDiagram-PVQCEYII-C0gyAJjW.js +107 -0
- package/packages/web/dist/assets/go-BPx_y7j8.js +2 -0
- package/packages/web/dist/assets/graph-BlwPajkw.js +2 -0
- package/packages/web/dist/assets/index-B-YuzkF6.js +71 -0
- package/packages/web/dist/assets/index-CvGiQddO.css +1 -0
- package/packages/web/dist/assets/infoDiagram-5YYISTIA-CJ2CFPfy.js +3 -0
- package/packages/web/dist/assets/ini-B3BVgEsG.js +2 -0
- package/packages/web/dist/assets/init-Gi6I4Gst.js +2 -0
- package/packages/web/dist/assets/ishikawaDiagram-YF4QCWOH-Cs0D32Ry.js +71 -0
- package/packages/web/dist/assets/java-B5VKqtH2.js +2 -0
- package/packages/web/dist/assets/javascript-BKRaQes9.js +2 -0
- package/packages/web/dist/assets/journeyDiagram-JHISSGLW-Bc9fuI2i.js +140 -0
- package/packages/web/dist/assets/json-DIYVocXf.js +2 -0
- package/packages/web/dist/assets/kanban-definition-UN3LZRKU-D_tC7QfC.js +90 -0
- package/packages/web/dist/assets/katex-C5jXJg4s.js +258 -0
- package/packages/web/dist/assets/kotlin-Cw-eF_bm.js +3 -0
- package/packages/web/dist/assets/lastTab-CU1zsF0j.js +2 -0
- package/packages/web/dist/assets/layout-Boy8vhSP.js +2 -0
- package/packages/web/dist/assets/linear-BzGHkF9K.js +2 -0
- package/packages/web/dist/assets/mermaid.core-DlbsYzE-.js +310 -0
- package/packages/web/dist/assets/mindmap-definition-RKZ34NQL-Bxeh-ToD.js +97 -0
- package/packages/web/dist/assets/ordinal-Cboi1Yqb.js +2 -0
- package/packages/web/dist/assets/php-CRdd4dfS.js +3 -0
- package/packages/web/dist/assets/pieDiagram-4H26LBE5-CDmilo8S.js +31 -0
- package/packages/web/dist/assets/python-kSlZHhJb.js +2 -0
- package/packages/web/dist/assets/quadrantDiagram-W4KKPZXB-D14-JyE9.js +8 -0
- package/packages/web/dist/assets/queued-LamEU0GH.js +2 -0
- package/packages/web/dist/assets/requirementDiagram-4Y6WPE33-CJ1PUGu-.js +85 -0
- package/packages/web/dist/assets/ruby-7T4ktxyv.js +2 -0
- package/packages/web/dist/assets/rust-HC3MBUhY.js +2 -0
- package/packages/web/dist/assets/sankeyDiagram-5OEKKPKP-BkUK73W0.js +41 -0
- package/packages/web/dist/assets/scss-DZLA7YXz.js +2 -0
- package/packages/web/dist/assets/sequenceDiagram-3UESZ5HK-sbcvsuLp.js +163 -0
- package/packages/web/dist/assets/sql-BC88IN-V.js +2 -0
- package/packages/web/dist/assets/stateDiagram-AJRCARHV-DV-7BdHK.js +2 -0
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-BlkGdXKW.js +2 -0
- package/packages/web/dist/assets/swift-Dn8kKO_1.js +2 -0
- package/packages/web/dist/assets/timeline-definition-PNZ67QCA-C5su21G_.js +121 -0
- package/packages/web/dist/assets/typescript-C2FFdlUC.js +2 -0
- package/packages/web/dist/assets/vennDiagram-CIIHVFJN-JNkIJAaQ.js +35 -0
- package/packages/web/dist/assets/wardley-L42UT6IY-qcz6t2vR.js +174 -0
- package/packages/web/dist/assets/wardleyDiagram-YWT4CUSO-CMrOIZ-c.js +79 -0
- package/packages/web/dist/assets/xml-BXBhIUeX.js +2 -0
- package/packages/web/dist/assets/xychartDiagram-2RQKCTM6-C9q6Oy9u.js +8 -0
- package/packages/web/dist/assets/yaml-drsDnIKT.js +2 -0
- package/packages/web/dist/favicon.ico +0 -0
- package/packages/web/dist/fonts/inter-latin.woff2 +0 -0
- package/packages/web/dist/fonts/jetbrains-mono-latin.woff2 +0 -0
- package/packages/web/dist/icons/apple-touch-icon.png +0 -0
- package/packages/web/dist/icons/favicon-16.png +0 -0
- package/packages/web/dist/icons/favicon-32.png +0 -0
- package/packages/web/dist/icons/icon-192.png +0 -0
- package/packages/web/dist/icons/icon-512.png +0 -0
- package/packages/web/dist/icons/maskable-512.png +0 -0
- package/packages/web/dist/index.html +56 -0
- package/packages/web/dist/manifest.webmanifest +32 -0
- package/packages/web/dist/sw.js +139 -0
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cumulative per-chat token usage — issue #152.
|
|
3
|
+
*
|
|
4
|
+
* `@herdctl/core`'s `extractSessionUsage` streams a transcript but keeps only the
|
|
5
|
+
* *last* assistant turn's `input + cache_read + cache_creation` — the current
|
|
6
|
+
* context-window fill level that drives the ContextRing / ContextMeter. It
|
|
7
|
+
* deliberately overwrites (`=`) each turn and never reads `output_tokens`, so it
|
|
8
|
+
* can't answer "how many tokens did this whole chat consume?".
|
|
9
|
+
*
|
|
10
|
+
* This module reads the same transcript in one pass and *accumulates* (`+=`)
|
|
11
|
+
* every assistant turn's input, output, cache-read and cache-creation tokens, so
|
|
12
|
+
* a chat's lifetime token cost (and a ballpark dollar figure at API rates) can be
|
|
13
|
+
* shown alongside the context-fill gauge. It lives in paddock — not upstream —
|
|
14
|
+
* because paddock already owns the `.chats/` layout and reads these transcripts
|
|
15
|
+
* directly (see {@link ./transcripts.ts} and {@link ./subagents.ts}); the core
|
|
16
|
+
* `SessionUsage` shape and its consumers are left untouched.
|
|
17
|
+
*
|
|
18
|
+
* Correctness notes:
|
|
19
|
+
* - Dedupe by `message.id` exactly like core: streaming writes the same message
|
|
20
|
+
* id more than once, and both the cumulative sums and the last-turn fill count
|
|
21
|
+
* each assistant message at most once (keep-first, matching core).
|
|
22
|
+
* - The last-turn `contextTokens` is tracked in the same pass so callers get the
|
|
23
|
+
* context-fill number without a second core call.
|
|
24
|
+
*/
|
|
25
|
+
import { createReadStream } from "node:fs";
|
|
26
|
+
import { promises as fs } from "node:fs";
|
|
27
|
+
import { createInterface } from "node:readline";
|
|
28
|
+
import path from "node:path";
|
|
29
|
+
import { projectChatsDir } from "./transcripts.js";
|
|
30
|
+
/** sessionId is a path segment — keep it inside `.chats/`. */
|
|
31
|
+
const SAFE_SEGMENT = /^[A-Za-z0-9._-]+$/;
|
|
32
|
+
const EMPTY = {
|
|
33
|
+
hasData: false,
|
|
34
|
+
turnCount: 0,
|
|
35
|
+
contextTokens: 0,
|
|
36
|
+
inputTotal: 0,
|
|
37
|
+
outputTotal: 0,
|
|
38
|
+
cacheReadTotal: 0,
|
|
39
|
+
cacheCreationTotal: 0,
|
|
40
|
+
byModel: {},
|
|
41
|
+
};
|
|
42
|
+
function num(v) {
|
|
43
|
+
return typeof v === "number" && Number.isFinite(v) ? v : 0;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Stream a transcript file and accumulate cumulative token usage. Deduplicates
|
|
47
|
+
* assistant messages by `message.id` (keep-first) so repeated stream frames for
|
|
48
|
+
* one message are counted once. Returns {@link EMPTY} for a missing/unreadable
|
|
49
|
+
* file (parity with core, which returns a zeroed summary).
|
|
50
|
+
*/
|
|
51
|
+
export async function readSessionTokenUsageFile(file) {
|
|
52
|
+
const seenIds = new Set();
|
|
53
|
+
let hasData = false;
|
|
54
|
+
let contextTokens = 0;
|
|
55
|
+
let inputTotal = 0;
|
|
56
|
+
let outputTotal = 0;
|
|
57
|
+
let cacheReadTotal = 0;
|
|
58
|
+
let cacheCreationTotal = 0;
|
|
59
|
+
const byModel = {};
|
|
60
|
+
const stream = createReadStream(file, { encoding: "utf8" });
|
|
61
|
+
stream.on("error", () => undefined);
|
|
62
|
+
const rl = createInterface({ input: stream, crlfDelay: Infinity });
|
|
63
|
+
try {
|
|
64
|
+
for await (const line of rl) {
|
|
65
|
+
const trimmed = line.trim();
|
|
66
|
+
if (!trimmed)
|
|
67
|
+
continue;
|
|
68
|
+
let parsed;
|
|
69
|
+
try {
|
|
70
|
+
parsed = JSON.parse(trimmed);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (parsed.type !== "assistant")
|
|
76
|
+
continue;
|
|
77
|
+
const message = parsed.message;
|
|
78
|
+
if (!message)
|
|
79
|
+
continue;
|
|
80
|
+
// Deduplicate by message id (keep-first), exactly like core.
|
|
81
|
+
const id = typeof message.id === "string" ? message.id : undefined;
|
|
82
|
+
if (id) {
|
|
83
|
+
if (seenIds.has(id))
|
|
84
|
+
continue;
|
|
85
|
+
seenIds.add(id);
|
|
86
|
+
}
|
|
87
|
+
const usage = message.usage;
|
|
88
|
+
if (!usage)
|
|
89
|
+
continue;
|
|
90
|
+
hasData = true;
|
|
91
|
+
const input = num(usage.input_tokens);
|
|
92
|
+
const output = num(usage.output_tokens);
|
|
93
|
+
const cacheCreation = num(usage.cache_creation_input_tokens);
|
|
94
|
+
const cacheRead = num(usage.cache_read_input_tokens);
|
|
95
|
+
inputTotal += input;
|
|
96
|
+
outputTotal += output;
|
|
97
|
+
cacheCreationTotal += cacheCreation;
|
|
98
|
+
cacheReadTotal += cacheRead;
|
|
99
|
+
// Bucket the same tokens under the model that produced them, so cost is
|
|
100
|
+
// priced per-model rather than at one blended rate. Turns with no recorded
|
|
101
|
+
// model land under "" and are treated as unpriced downstream.
|
|
102
|
+
const model = typeof message.model === "string" ? message.model : "";
|
|
103
|
+
const bucket = (byModel[model] ??= {
|
|
104
|
+
inputTokens: 0,
|
|
105
|
+
outputTokens: 0,
|
|
106
|
+
cacheReadTokens: 0,
|
|
107
|
+
cacheCreationTokens: 0,
|
|
108
|
+
});
|
|
109
|
+
bucket.inputTokens += input;
|
|
110
|
+
bucket.outputTokens += output;
|
|
111
|
+
bucket.cacheReadTokens += cacheRead;
|
|
112
|
+
bucket.cacheCreationTokens += cacheCreation;
|
|
113
|
+
// Context fill of this turn — the last one to run wins (matches core).
|
|
114
|
+
contextTokens = input + cacheCreation + cacheRead;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return EMPTY;
|
|
119
|
+
}
|
|
120
|
+
finally {
|
|
121
|
+
rl.close();
|
|
122
|
+
stream.destroy();
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
hasData,
|
|
126
|
+
turnCount: seenIds.size,
|
|
127
|
+
contextTokens,
|
|
128
|
+
inputTotal,
|
|
129
|
+
outputTotal,
|
|
130
|
+
cacheReadTotal,
|
|
131
|
+
cacheCreationTotal,
|
|
132
|
+
byModel,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* mtime-keyed cache of cumulative usage, keyed by transcript path. A session's
|
|
137
|
+
* totals only change when its transcript grows (and its mtime bumps), so the
|
|
138
|
+
* chat-list build (which needs every session's usage for its rings) skips
|
|
139
|
+
* re-scanning unchanged transcripts. A `stat` is negligible next to a full-file
|
|
140
|
+
* walk, so we always derive the stamp here — one consistent mtime source across
|
|
141
|
+
* both the bulk and single-chat endpoints. One entry per file; a fresh mtime
|
|
142
|
+
* overwrites the stale one.
|
|
143
|
+
*/
|
|
144
|
+
const cache = new Map();
|
|
145
|
+
/**
|
|
146
|
+
* Cumulative token usage for a chat, resolved from `<projectDir>/.chats/`.
|
|
147
|
+
* Memoized on the transcript's mtime. Returns {@link EMPTY} for an unsafe
|
|
148
|
+
* session id or a session with no transcript yet.
|
|
149
|
+
*/
|
|
150
|
+
export async function readSessionTokenUsage(projectDir, sessionId) {
|
|
151
|
+
if (!SAFE_SEGMENT.test(sessionId))
|
|
152
|
+
return EMPTY;
|
|
153
|
+
const file = path.join(projectChatsDir(projectDir), `${sessionId}.jsonl`);
|
|
154
|
+
const mtime = await fs
|
|
155
|
+
.stat(file)
|
|
156
|
+
.then((s) => s.mtimeMs)
|
|
157
|
+
.catch(() => 0);
|
|
158
|
+
if (!mtime)
|
|
159
|
+
return EMPTY;
|
|
160
|
+
const hit = cache.get(file);
|
|
161
|
+
if (hit && hit.mtime === mtime)
|
|
162
|
+
return hit.usage;
|
|
163
|
+
const usage = await readSessionTokenUsageFile(file);
|
|
164
|
+
cache.set(file, { mtime, usage });
|
|
165
|
+
return usage;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Per-message context-window fill, keyed by the transcript record `uuid` that the
|
|
169
|
+
* messages DTO carries (issue #451). For every `type:"assistant"` record we store
|
|
170
|
+
* `input + cache_read + cache_creation` — the context-window fill at that turn.
|
|
171
|
+
*
|
|
172
|
+
* A single assistant message is written across several JSONL records (one per
|
|
173
|
+
* content block: text, tool_use, …), each with its own `uuid` but the SAME
|
|
174
|
+
* message-level `usage`, so keying by record `uuid` resolves any of a message's
|
|
175
|
+
* turn ids to the identical fill. Callers forward-fill the value across the
|
|
176
|
+
* non-assistant records between turns (user prompts, tool results) to answer
|
|
177
|
+
* "how full was the context as of this point" for every message.
|
|
178
|
+
*
|
|
179
|
+
* This is a SEPARATE pass from {@link readSessionTokenUsageFile} on purpose: it
|
|
180
|
+
* only runs on the open-chat messages path, never the chat-list rings, so the
|
|
181
|
+
* list DTO stays lean. It rides its own mtime cache below.
|
|
182
|
+
*/
|
|
183
|
+
export async function readContextSeriesFile(file) {
|
|
184
|
+
const series = new Map();
|
|
185
|
+
const stream = createReadStream(file, { encoding: "utf8" });
|
|
186
|
+
stream.on("error", () => undefined);
|
|
187
|
+
const rl = createInterface({ input: stream, crlfDelay: Infinity });
|
|
188
|
+
try {
|
|
189
|
+
for await (const line of rl) {
|
|
190
|
+
const trimmed = line.trim();
|
|
191
|
+
if (!trimmed)
|
|
192
|
+
continue;
|
|
193
|
+
let parsed;
|
|
194
|
+
try {
|
|
195
|
+
parsed = JSON.parse(trimmed);
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if (parsed.type !== "assistant")
|
|
201
|
+
continue;
|
|
202
|
+
const uuid = typeof parsed.uuid === "string" ? parsed.uuid : undefined;
|
|
203
|
+
const usage = parsed.message?.usage;
|
|
204
|
+
if (!uuid || !usage)
|
|
205
|
+
continue;
|
|
206
|
+
series.set(uuid, num(usage.input_tokens) +
|
|
207
|
+
num(usage.cache_creation_input_tokens) +
|
|
208
|
+
num(usage.cache_read_input_tokens));
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
return series;
|
|
213
|
+
}
|
|
214
|
+
finally {
|
|
215
|
+
rl.close();
|
|
216
|
+
stream.destroy();
|
|
217
|
+
}
|
|
218
|
+
return series;
|
|
219
|
+
}
|
|
220
|
+
/** mtime-keyed cache of the per-message context series (see {@link readContextSeriesFile}). */
|
|
221
|
+
const seriesCache = new Map();
|
|
222
|
+
/**
|
|
223
|
+
* Per-message context series for a chat, resolved from `<projectDir>/.chats/`.
|
|
224
|
+
* Memoized on the transcript's mtime (parity with {@link readSessionTokenUsage}).
|
|
225
|
+
* Returns an empty map for an unsafe session id or a session with no transcript.
|
|
226
|
+
*/
|
|
227
|
+
export async function readContextSeries(projectDir, sessionId) {
|
|
228
|
+
if (!SAFE_SEGMENT.test(sessionId))
|
|
229
|
+
return new Map();
|
|
230
|
+
const file = path.join(projectChatsDir(projectDir), `${sessionId}.jsonl`);
|
|
231
|
+
const mtime = await fs
|
|
232
|
+
.stat(file)
|
|
233
|
+
.then((s) => s.mtimeMs)
|
|
234
|
+
.catch(() => 0);
|
|
235
|
+
if (!mtime)
|
|
236
|
+
return new Map();
|
|
237
|
+
const hit = seriesCache.get(file);
|
|
238
|
+
if (hit && hit.mtime === mtime)
|
|
239
|
+
return hit.series;
|
|
240
|
+
const series = await readContextSeriesFile(file);
|
|
241
|
+
seriesCache.set(file, { mtime, series });
|
|
242
|
+
return series;
|
|
243
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wake-time injected-MCP resolution (edspencer/herdctl#390, wired in
|
|
3
|
+
* @herdctl/core 5.22.1).
|
|
4
|
+
*
|
|
5
|
+
* ── The bug ───────────────────────────────────────────────────────────────────
|
|
6
|
+
* Paddock injects in-process MCP servers — `mcp__paddock_manage__*` (self-
|
|
7
|
+
* management, #214) and `mcp__paddock__*` (send_file, #113) — into keeper turns via
|
|
8
|
+
* `injectedMcpServers`. These are per-turn runtime values Paddock supplies only when
|
|
9
|
+
* IT drives a turn (the human socket path and every server-initiated `startAgentTurn`
|
|
10
|
+
* fire). herdctl's session WAKE path — a `ScheduleWakeup` / `/loop` / `CronCreate`
|
|
11
|
+
* re-fire of an idle/reaped session — drives the turn INSIDE herdctl and, before
|
|
12
|
+
* 5.22.1, re-spawned the `claude` subprocess with the `mcp__paddock*__*` tools still
|
|
13
|
+
* "allowed" (they're on `agent.allowed_tools`) but with NO in-process server behind
|
|
14
|
+
* them. Result: the injected tools vanish from the catalog for the whole autonomous
|
|
15
|
+
* stretch (observed multi-hour episodes; permanent after a host restart, since the
|
|
16
|
+
* durable wake set re-fires MCP-less). See `/tmp/mcp-flap-investigation.md`.
|
|
17
|
+
*
|
|
18
|
+
* ── The fix ───────────────────────────────────────────────────────────────────
|
|
19
|
+
* 5.22.1 added `FleetManager.setResolveInjectedMcpServers(resolve)` — a SYNCHRONOUS
|
|
20
|
+
* resolver herdctl calls on each wake fire and threads into `openChatSession` before
|
|
21
|
+
* the subprocess spawns. This module is Paddock's policy for that resolver.
|
|
22
|
+
*
|
|
23
|
+
* Because Paddock's live-turn builder is ASYNC (it awaits provenance + project state
|
|
24
|
+
* for depth/trigger gating) but the herdctl resolver is SYNC, we resolve from an
|
|
25
|
+
* in-memory cache of the last server set Paddock built for each session
|
|
26
|
+
* ({@link WakeInjectionResolver.remember}, called on every live turn). A wake fire
|
|
27
|
+
* ({@link WakeInjectionResolver.resolve}) replays the cached set synchronously. This
|
|
28
|
+
* closes the LIVE flap fully — including the common case (a human/spawn turn sets up
|
|
29
|
+
* a self-wake, so the cache is warm when that same session wakes).
|
|
30
|
+
*
|
|
31
|
+
* The cold-cache case (a durable wake re-firing after a server RESTART, before any
|
|
32
|
+
* live turn re-populates the cache) can't be served synchronously — the source data
|
|
33
|
+
* is async. On a miss we kick a background {@link WakeInjectionResolver.warm} that
|
|
34
|
+
* rebuilds + caches the set, so the NEXT wake for that session is covered; the FIRST
|
|
35
|
+
* post-restart wake still degrades to no-injection (the next human/Trigger turn — or
|
|
36
|
+
* the next wake — restores it). This is the single documented residual.
|
|
37
|
+
*/
|
|
38
|
+
import type { InjectedMcpServerDef, SessionWakeEntry } from "@herdctl/core";
|
|
39
|
+
import type { TurnOrigin } from "./run-provenance.js";
|
|
40
|
+
/** The per-turn context that decides which injected servers a turn receives. */
|
|
41
|
+
export interface InjectedMcpBuildArgs {
|
|
42
|
+
/** Workspace key this turn runs in (`""` is the root). */
|
|
43
|
+
projectSlug: string;
|
|
44
|
+
/** Keeper cwd — resolves the send_file tool's relative `file_path`. */
|
|
45
|
+
workingDir: string;
|
|
46
|
+
/**
|
|
47
|
+
* The session being resumed, or `null` for a new chat. On a resume, the CHAT's
|
|
48
|
+
* OWN recorded depth (not the caller's describe-the-run value) governs self-MCP
|
|
49
|
+
* gating — a depth-1 child reporting back to its depth-0 root must gate on 0.
|
|
50
|
+
*/
|
|
51
|
+
resume: string | null;
|
|
52
|
+
/** Origin stamped on any child this turn's self-MCP write tools spawn. */
|
|
53
|
+
origin: TurnOrigin;
|
|
54
|
+
/** The depth to gate on when there's no recorded provenance for {@link resume}. */
|
|
55
|
+
depth: number;
|
|
56
|
+
/** Effective spawn bound for the chat this turn runs in (resolved by the caller). */
|
|
57
|
+
maxSpawnDepth: number;
|
|
58
|
+
/** Late-bound accessor for the resolved session id (self-MCP tools attribute by it). */
|
|
59
|
+
currentSessionId: () => string | null;
|
|
60
|
+
}
|
|
61
|
+
/** The (mostly injected) collaborators {@link buildInjectedMcpServers} needs. */
|
|
62
|
+
export interface InjectedMcpBuildContext {
|
|
63
|
+
/** Instance self-MCP gates (resolved from `PaddockConfig`). */
|
|
64
|
+
cfg: {
|
|
65
|
+
selfMcpEnabled: boolean;
|
|
66
|
+
selfMcpWriteEnabled: boolean;
|
|
67
|
+
selfMcpProjectsEnabled: boolean;
|
|
68
|
+
hooksMcpEnabled: boolean;
|
|
69
|
+
};
|
|
70
|
+
/** Persist a file's bytes into the attachment store (send_file's snapshot). */
|
|
71
|
+
saveAttachment: (bytes: Buffer, filenameForExt: string) => Promise<string>;
|
|
72
|
+
/**
|
|
73
|
+
* Resolve a chat's recorded provenance for injection-depth on a resume. Optional
|
|
74
|
+
* (mirrors the optional `runProvenance` dep on the socket handler): absent ⇒ the
|
|
75
|
+
* caller's `depth` is used unchanged, exactly like the pre-extraction inline path.
|
|
76
|
+
*/
|
|
77
|
+
getProvenance?: (sessionId: string) => Promise<{
|
|
78
|
+
depth: number;
|
|
79
|
+
} | undefined>;
|
|
80
|
+
/**
|
|
81
|
+
* Resolve a project's `hooksMcpEnabled` OVERRIDE (undefined when unset/unknown),
|
|
82
|
+
* for the T3 trigger-tool gate. Only consulted when write tools are on.
|
|
83
|
+
*/
|
|
84
|
+
getProjectHooksMcp: (slug: string) => Promise<boolean | undefined>;
|
|
85
|
+
/**
|
|
86
|
+
* Build the self-management MCP server def. Provided by ws.ts because it closes
|
|
87
|
+
* over the hub / `startAgentTurn` spawn path; injected here so this module stays
|
|
88
|
+
* pure + unit-testable.
|
|
89
|
+
*/
|
|
90
|
+
buildSelfMcp: (params: {
|
|
91
|
+
currentProjectSlug: string;
|
|
92
|
+
currentSessionId: () => string | null;
|
|
93
|
+
parentProvenance: {
|
|
94
|
+
origin: TurnOrigin;
|
|
95
|
+
depth: number;
|
|
96
|
+
};
|
|
97
|
+
includeWrite: boolean;
|
|
98
|
+
includeTriggers: boolean;
|
|
99
|
+
includeProjects: boolean;
|
|
100
|
+
}) => InjectedMcpServerDef;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Build the injected-MCP server set for one keeper turn — the SINGLE source of the
|
|
104
|
+
* per-turn injection policy, shared by the live `startAgentTurn` path and the wake
|
|
105
|
+
* rebuild so they can never drift. send_file is ALWAYS present (parity with the human
|
|
106
|
+
* path); the self-management MCP is appended iff the depth-gated
|
|
107
|
+
* {@link spawnedSelfMcpDecision} says so, and its T3 trigger tools iff writes are on
|
|
108
|
+
* AND the project's reused hooks-MCP gate is enabled. Semantics are identical to the
|
|
109
|
+
* previously-inline construction in `startAgentTurn`.
|
|
110
|
+
*/
|
|
111
|
+
export declare function buildInjectedMcpServers(args: InjectedMcpBuildArgs, ctx: InjectedMcpBuildContext): Promise<Record<string, InjectedMcpServerDef>>;
|
|
112
|
+
/** Public surface of the wake-injection cache/resolver. */
|
|
113
|
+
export interface WakeInjectionResolver {
|
|
114
|
+
/**
|
|
115
|
+
* Record the server set Paddock built for a live turn, keyed by its session id, so
|
|
116
|
+
* a later wake of that session can replay it. Idempotent; a null/blank id is a
|
|
117
|
+
* no-op; the newest entry is most-recently-used for eviction.
|
|
118
|
+
*/
|
|
119
|
+
remember(sessionId: string | null | undefined, servers: Record<string, InjectedMcpServerDef>): void;
|
|
120
|
+
/**
|
|
121
|
+
* The SYNCHRONOUS resolver registered with herdctl. On a cache hit it replays the
|
|
122
|
+
* remembered set (refreshing recency); on a miss it kicks a background
|
|
123
|
+
* {@link warm} (so the NEXT wake is covered) and returns `undefined` — no injection
|
|
124
|
+
* for THIS fire. NEVER throws.
|
|
125
|
+
*/
|
|
126
|
+
resolve(entry: SessionWakeEntry): Record<string, InjectedMcpServerDef> | undefined;
|
|
127
|
+
/**
|
|
128
|
+
* Rebuild + cache the server set for a woken session (cold-cache warm after a
|
|
129
|
+
* restart). Deduplicates concurrent warms and is non-throwing.
|
|
130
|
+
*/
|
|
131
|
+
warm(entry: SessionWakeEntry): Promise<Record<string, InjectedMcpServerDef> | undefined>;
|
|
132
|
+
/** True if a session's set is currently cached (test/introspection). */
|
|
133
|
+
has(sessionId: string): boolean;
|
|
134
|
+
/** Number of cached sessions (test/introspection). */
|
|
135
|
+
size(): number;
|
|
136
|
+
}
|
|
137
|
+
export interface WakeInjectionDeps {
|
|
138
|
+
/**
|
|
139
|
+
* Rebuild the injected servers for a woken session — resolves the project + its
|
|
140
|
+
* config and delegates to {@link buildInjectedMcpServers}. Returns `undefined` when
|
|
141
|
+
* the session should receive no injection (a non-keeper agent / unknown project).
|
|
142
|
+
* Should not
|
|
143
|
+
* reject (the cache catches defensively regardless).
|
|
144
|
+
*/
|
|
145
|
+
rebuild: (entry: SessionWakeEntry) => Promise<Record<string, InjectedMcpServerDef> | undefined>;
|
|
146
|
+
/** Soft cap on cached sessions; oldest (LRU) evicted past it. Default 1024. */
|
|
147
|
+
limit?: number;
|
|
148
|
+
}
|
|
149
|
+
/** Default cap — generous vs. any realistic live-session count, bounds memory. */
|
|
150
|
+
export declare const DEFAULT_WAKE_INJECTION_LIMIT = 1024;
|
|
151
|
+
/**
|
|
152
|
+
* Create the wake-injection cache/resolver. Holds an LRU-ish map of the last server
|
|
153
|
+
* set built per session; the sync {@link WakeInjectionResolver.resolve} replays it on
|
|
154
|
+
* a wake, and a background {@link WakeInjectionResolver.warm} rebuilds a cold entry.
|
|
155
|
+
*/
|
|
156
|
+
export declare function createWakeInjectionCache(deps: WakeInjectionDeps): WakeInjectionResolver;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { sendFileServerDef, SEND_FILE_SERVER_KEY } from "./send-file-mcp.js";
|
|
2
|
+
import { SELF_MCP_SERVER_KEY } from "./self-mcp.js";
|
|
3
|
+
import { spawnedSelfMcpDecision } from "./spawn-capability.js";
|
|
4
|
+
import { resolveHooksMcpEnabled } from "./hook-config.js";
|
|
5
|
+
/**
|
|
6
|
+
* Build the injected-MCP server set for one keeper turn — the SINGLE source of the
|
|
7
|
+
* per-turn injection policy, shared by the live `startAgentTurn` path and the wake
|
|
8
|
+
* rebuild so they can never drift. send_file is ALWAYS present (parity with the human
|
|
9
|
+
* path); the self-management MCP is appended iff the depth-gated
|
|
10
|
+
* {@link spawnedSelfMcpDecision} says so, and its T3 trigger tools iff writes are on
|
|
11
|
+
* AND the project's reused hooks-MCP gate is enabled. Semantics are identical to the
|
|
12
|
+
* previously-inline construction in `startAgentTurn`.
|
|
13
|
+
*/
|
|
14
|
+
export async function buildInjectedMcpServers(args, ctx) {
|
|
15
|
+
const { projectSlug, workingDir, resume, origin, depth, maxSpawnDepth, currentSessionId } = args;
|
|
16
|
+
// Always-on send_file (parity with the human path).
|
|
17
|
+
const servers = {
|
|
18
|
+
[SEND_FILE_SERVER_KEY]: sendFileServerDef({
|
|
19
|
+
workingDirectory: workingDir,
|
|
20
|
+
saveAttachment: ctx.saveAttachment,
|
|
21
|
+
}),
|
|
22
|
+
};
|
|
23
|
+
// A resume runs in an EXISTING chat whose own recorded depth governs it; fall back
|
|
24
|
+
// to the caller's `depth` when the chat has no marker (or provenance isn't wired).
|
|
25
|
+
let injectionDepth = depth;
|
|
26
|
+
if (resume !== null && ctx.getProvenance) {
|
|
27
|
+
const rec = await ctx.getProvenance(resume).catch(() => undefined);
|
|
28
|
+
if (rec)
|
|
29
|
+
injectionDepth = rec.depth;
|
|
30
|
+
}
|
|
31
|
+
const selfMcp = spawnedSelfMcpDecision({
|
|
32
|
+
selfMcpEnabled: ctx.cfg.selfMcpEnabled,
|
|
33
|
+
selfMcpWriteEnabled: ctx.cfg.selfMcpWriteEnabled,
|
|
34
|
+
depth: injectionDepth,
|
|
35
|
+
maxSpawnDepth,
|
|
36
|
+
});
|
|
37
|
+
if (selfMcp.inject) {
|
|
38
|
+
// Trigger-management tools (T3) follow the TARGET project's reused hooks-MCP
|
|
39
|
+
// opt-in (override else instance default) — only meaningful with write tools.
|
|
40
|
+
let includeTriggers = false;
|
|
41
|
+
if (selfMcp.includeWrite) {
|
|
42
|
+
const override = await ctx.getProjectHooksMcp(projectSlug).catch(() => undefined);
|
|
43
|
+
includeTriggers = resolveHooksMcpEnabled(override, ctx.cfg.hooksMcpEnabled);
|
|
44
|
+
}
|
|
45
|
+
servers[SELF_MCP_SERVER_KEY] = ctx.buildSelfMcp({
|
|
46
|
+
currentProjectSlug: projectSlug,
|
|
47
|
+
currentSessionId,
|
|
48
|
+
parentProvenance: { origin, depth: injectionDepth },
|
|
49
|
+
includeWrite: selfMcp.includeWrite,
|
|
50
|
+
includeTriggers,
|
|
51
|
+
// The project tool (#467) is a purely instance-level gate — no per-project
|
|
52
|
+
// override — but still rides on the write block, so writes must be on.
|
|
53
|
+
includeProjects: selfMcp.includeWrite && ctx.cfg.selfMcpProjectsEnabled,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return servers;
|
|
57
|
+
}
|
|
58
|
+
/** Default cap — generous vs. any realistic live-session count, bounds memory. */
|
|
59
|
+
export const DEFAULT_WAKE_INJECTION_LIMIT = 1024;
|
|
60
|
+
/**
|
|
61
|
+
* Create the wake-injection cache/resolver. Holds an LRU-ish map of the last server
|
|
62
|
+
* set built per session; the sync {@link WakeInjectionResolver.resolve} replays it on
|
|
63
|
+
* a wake, and a background {@link WakeInjectionResolver.warm} rebuilds a cold entry.
|
|
64
|
+
*/
|
|
65
|
+
export function createWakeInjectionCache(deps) {
|
|
66
|
+
const limit = deps.limit && deps.limit > 0 ? deps.limit : DEFAULT_WAKE_INJECTION_LIMIT;
|
|
67
|
+
const cache = new Map();
|
|
68
|
+
const inflight = new Map();
|
|
69
|
+
const remember = (sessionId, servers) => {
|
|
70
|
+
if (!sessionId)
|
|
71
|
+
return;
|
|
72
|
+
// delete-then-set moves the key to the newest position (Map preserves insertion
|
|
73
|
+
// order), so eviction below drops the genuinely least-recently-touched entry.
|
|
74
|
+
cache.delete(sessionId);
|
|
75
|
+
cache.set(sessionId, servers);
|
|
76
|
+
while (cache.size > limit) {
|
|
77
|
+
const oldest = cache.keys().next().value;
|
|
78
|
+
if (oldest === undefined)
|
|
79
|
+
break;
|
|
80
|
+
cache.delete(oldest);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
const warm = (entry) => {
|
|
84
|
+
const id = entry?.sessionId;
|
|
85
|
+
if (!id)
|
|
86
|
+
return Promise.resolve(undefined);
|
|
87
|
+
const cached = cache.get(id);
|
|
88
|
+
if (cached)
|
|
89
|
+
return Promise.resolve(cached);
|
|
90
|
+
const existing = inflight.get(id);
|
|
91
|
+
if (existing)
|
|
92
|
+
return existing;
|
|
93
|
+
const p = (async () => {
|
|
94
|
+
try {
|
|
95
|
+
const servers = await deps.rebuild(entry);
|
|
96
|
+
if (servers)
|
|
97
|
+
remember(id, servers);
|
|
98
|
+
return servers;
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return undefined; // a rebuild failure degrades to no-injection, never throws
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
inflight.delete(id);
|
|
105
|
+
}
|
|
106
|
+
})();
|
|
107
|
+
inflight.set(id, p);
|
|
108
|
+
return p;
|
|
109
|
+
};
|
|
110
|
+
const resolve = (entry) => {
|
|
111
|
+
try {
|
|
112
|
+
const id = entry?.sessionId;
|
|
113
|
+
if (!id)
|
|
114
|
+
return undefined;
|
|
115
|
+
const hit = cache.get(id);
|
|
116
|
+
if (hit) {
|
|
117
|
+
cache.delete(id);
|
|
118
|
+
cache.set(id, hit); // refresh recency
|
|
119
|
+
return hit;
|
|
120
|
+
}
|
|
121
|
+
// Cold cache (e.g. post-restart): warm for the NEXT fire, no injection now.
|
|
122
|
+
void warm(entry);
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return undefined; // resolve is called sync by herdctl — must never throw
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
return {
|
|
130
|
+
remember,
|
|
131
|
+
resolve,
|
|
132
|
+
warm,
|
|
133
|
+
has: (sessionId) => cache.has(sessionId),
|
|
134
|
+
size: () => cache.size,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { TranscriptionConfig } from "./config.js";
|
|
2
|
+
import type { TranscriptionInput } from "./transcribe.js";
|
|
3
|
+
/**
|
|
4
|
+
* Transcribe an audio blob with whisper.cpp running on this box. Returns the raw
|
|
5
|
+
* transcript text (the caller trims it).
|
|
6
|
+
*/
|
|
7
|
+
export declare function transcribeLocal(input: TranscriptionInput, cfg: TranscriptionConfig): Promise<string>;
|
|
8
|
+
/** Remove whisper.cpp's `[00:00:00.000 --> 00:00:02.000]` line prefixes. */
|
|
9
|
+
export declare function stripTimestamps(raw: string): string;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local whisper.cpp transcription — the `local` backend for {@link makeTranscriber}.
|
|
3
|
+
*
|
|
4
|
+
* This module is imported LAZILY (see transcribe.ts) so its heavy, optional
|
|
5
|
+
* dependencies — `nodejs-whisper` (which builds/bundles whisper.cpp) and a
|
|
6
|
+
* system `ffmpeg` — are only required on instances that actually set
|
|
7
|
+
* `PADDOCK_WHISPER_MODE=local`. Remote-mode and dictation-off instances never
|
|
8
|
+
* touch it.
|
|
9
|
+
*
|
|
10
|
+
* Flow: the browser records WebM/Opus, which whisper.cpp can't read directly, so
|
|
11
|
+
* we first transcode to the 16 kHz mono PCM WAV whisper.cpp wants (via ffmpeg),
|
|
12
|
+
* then run whisper.cpp over it and return the cleaned transcript.
|
|
13
|
+
*/
|
|
14
|
+
import { spawn } from "node:child_process";
|
|
15
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
16
|
+
import os from "node:os";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import { TranscriptionError } from "./transcribe.js";
|
|
19
|
+
/**
|
|
20
|
+
* Transcribe an audio blob with whisper.cpp running on this box. Returns the raw
|
|
21
|
+
* transcript text (the caller trims it).
|
|
22
|
+
*/
|
|
23
|
+
export async function transcribeLocal(input, cfg) {
|
|
24
|
+
const dir = await mkdtemp(path.join(os.tmpdir(), "paddock-whisper-"));
|
|
25
|
+
const srcPath = path.join(dir, safeName(input.filename));
|
|
26
|
+
const wavPath = path.join(dir, "audio.wav");
|
|
27
|
+
try {
|
|
28
|
+
await writeFile(srcPath, input.audio);
|
|
29
|
+
await toWav(srcPath, wavPath);
|
|
30
|
+
return await runWhisper(wavPath, cfg);
|
|
31
|
+
}
|
|
32
|
+
finally {
|
|
33
|
+
await rm(dir, { recursive: true, force: true }).catch(() => undefined);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/** Transcode any input ffmpeg understands to 16 kHz mono PCM WAV. */
|
|
37
|
+
async function toWav(src, dest) {
|
|
38
|
+
await run("ffmpeg", ["-nostdin", "-y", "-i", src, "-ar", "16000", "-ac", "1", "-c:a", "pcm_s16le", dest], "ffmpeg (audio transcode)");
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Run whisper.cpp via nodejs-whisper and return the transcript. nodejs-whisper
|
|
42
|
+
* writes a sibling `<wav>.txt`; we read that and strip whisper's `[hh:mm:ss.mmm
|
|
43
|
+
* --> …]` line prefixes so the composer gets clean prose.
|
|
44
|
+
*/
|
|
45
|
+
async function runWhisper(wavPath, cfg) {
|
|
46
|
+
let nodewhisper;
|
|
47
|
+
try {
|
|
48
|
+
// Optional dependency — only present on instances configured for local
|
|
49
|
+
// whisper. Resolved dynamically so remote/off instances never need it.
|
|
50
|
+
// @ts-ignore — module may be absent at build time (declared optional).
|
|
51
|
+
({ nodewhisper } = (await import("nodejs-whisper")));
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
throw new TranscriptionError("local whisper is not installed on this instance (missing nodejs-whisper). " +
|
|
55
|
+
"Install it, or set PADDOCK_WHISPER_MODE=remote.", 503);
|
|
56
|
+
}
|
|
57
|
+
const modelName = cfg.model || "base";
|
|
58
|
+
try {
|
|
59
|
+
await nodewhisper(wavPath, {
|
|
60
|
+
modelName,
|
|
61
|
+
autoDownloadModelName: modelName,
|
|
62
|
+
removeWavFileAfterTranscription: false,
|
|
63
|
+
// Only the plain-text output; we read the `.txt` and strip timestamps
|
|
64
|
+
// ourselves. (Avoid `splitOnWord`, which nodejs-whisper renders as a bare
|
|
65
|
+
// `-sow true`, making whisper-cli treat `true` as a stray input file.)
|
|
66
|
+
whisperOptions: {
|
|
67
|
+
outputInText: true,
|
|
68
|
+
outputInVtt: false,
|
|
69
|
+
outputInSrt: false,
|
|
70
|
+
outputInCsv: false,
|
|
71
|
+
translateToEnglish: false,
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
77
|
+
throw new TranscriptionError(`whisper.cpp failed: ${truncate(reason, 300)}`, 502);
|
|
78
|
+
}
|
|
79
|
+
const txt = await readFile(`${wavPath}.txt`, "utf-8").catch(() => null);
|
|
80
|
+
if (txt === null) {
|
|
81
|
+
throw new TranscriptionError("whisper.cpp produced no transcript output", 502);
|
|
82
|
+
}
|
|
83
|
+
return stripTimestamps(txt);
|
|
84
|
+
}
|
|
85
|
+
/** Remove whisper.cpp's `[00:00:00.000 --> 00:00:02.000]` line prefixes. */
|
|
86
|
+
export function stripTimestamps(raw) {
|
|
87
|
+
return raw
|
|
88
|
+
.split("\n")
|
|
89
|
+
.map((line) => line.replace(/^\s*\[[0-9:.]+\s*-->\s*[0-9:.]+\]\s*/, ""))
|
|
90
|
+
.join("\n")
|
|
91
|
+
.replace(/\n{2,}/g, "\n")
|
|
92
|
+
.trim();
|
|
93
|
+
}
|
|
94
|
+
/** Spawn a process, rejecting with a clear TranscriptionError on failure. */
|
|
95
|
+
function run(cmd, args, label) {
|
|
96
|
+
return new Promise((resolve, reject) => {
|
|
97
|
+
const child = spawn(cmd, args, { stdio: ["ignore", "ignore", "pipe"] });
|
|
98
|
+
let stderr = "";
|
|
99
|
+
child.stderr.on("data", (d) => {
|
|
100
|
+
stderr += String(d);
|
|
101
|
+
});
|
|
102
|
+
child.on("error", (err) => {
|
|
103
|
+
const hint = err.code === "ENOENT"
|
|
104
|
+
? ` — is ${cmd} installed and on PATH?`
|
|
105
|
+
: "";
|
|
106
|
+
reject(new TranscriptionError(`${label} could not start: ${err.message}${hint}`, 503));
|
|
107
|
+
});
|
|
108
|
+
child.on("close", (code) => {
|
|
109
|
+
if (code === 0)
|
|
110
|
+
resolve();
|
|
111
|
+
else
|
|
112
|
+
reject(new TranscriptionError(`${label} exited ${code}: ${truncate(stderr.trim(), 200)}`, 502));
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
function safeName(name) {
|
|
117
|
+
const base = path.basename(name || "audio").replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
118
|
+
return base || "audio";
|
|
119
|
+
}
|
|
120
|
+
function truncate(s, n) {
|
|
121
|
+
return s.length > n ? `${s.slice(0, n)}…` : s;
|
|
122
|
+
}
|