@edspencer/paddock 0.0.1 → 0.56.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 +512 -0
- package/packages/server/dist/config.js +593 -0
- package/packages/server/dist/curation-config.d.ts +55 -0
- package/packages/server/dist/curation-config.js +64 -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 +572 -0
- package/packages/server/dist/herdctl.js +1218 -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 +135 -0
- package/packages/server/dist/instance-config.js +345 -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-4cQGTj9Y.js +2 -0
- package/packages/web/dist/assets/MarkdownRenderer-CJZFLj_8.js +30 -0
- package/packages/web/dist/assets/ProjectRedirect-BHwVbbqB.js +2 -0
- package/packages/web/dist/assets/ProjectView-BOvuAITg.js +13 -0
- package/packages/web/dist/assets/ProjectsGrid-BCojZOGY.js +2 -0
- package/packages/web/dist/assets/arc-BVyxvnkl.js +2 -0
- package/packages/web/dist/assets/architectureDiagram-3BPJPVTR-Cd9Puqg1.js +37 -0
- package/packages/web/dist/assets/bash-I8pq0VWm.js +2 -0
- package/packages/web/dist/assets/blockDiagram-GPEHLZMM-B_AQt_Rx.js +133 -0
- package/packages/web/dist/assets/c-D2F1sQUL.js +2 -0
- package/packages/web/dist/assets/c4Diagram-AAUBKEIU-7e0TQtXB.js +11 -0
- package/packages/web/dist/assets/channel-C_1aISNn.js +2 -0
- package/packages/web/dist/assets/chunk-2J33WTMH-Dz4WZmUZ.js +2 -0
- package/packages/web/dist/assets/chunk-4BX2VUAB-DVWYldo6.js +2 -0
- package/packages/web/dist/assets/chunk-55IACEB6-BIMrizQd.js +2 -0
- package/packages/web/dist/assets/chunk-727SXJPM-CL2q9fF1.js +207 -0
- package/packages/web/dist/assets/chunk-AQP2D5EJ-BSq3HlQ9.js +232 -0
- package/packages/web/dist/assets/chunk-FMBD7UC4-Bgt64pLE.js +16 -0
- package/packages/web/dist/assets/chunk-ND2GUHAM-C8Pr2A38.js +2 -0
- package/packages/web/dist/assets/chunk-QZHKN3VN-BAOqh-MN.js +2 -0
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-B8JE7cHJ.js +2 -0
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-B8JE7cHJ.js +2 -0
- package/packages/web/dist/assets/core-lJeGACQg.js +4 -0
- package/packages/web/dist/assets/cose-bilkent-S5V4N54A-BF4tameA.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-DdsLM6ql.js +5 -0
- package/packages/web/dist/assets/defaultLocale-DX6XiGOO.js +2 -0
- package/packages/web/dist/assets/diagram-2AECGRRQ-BQ78S_RM.js +44 -0
- package/packages/web/dist/assets/diagram-5GNKFQAL-CAKE6nHE.js +11 -0
- package/packages/web/dist/assets/diagram-KO2AKTUF-fEvHRXgr.js +4 -0
- package/packages/web/dist/assets/diagram-LMA3HP47-CtWsgeSm.js +25 -0
- package/packages/web/dist/assets/diagram-OG6HWLK6-Buog8k7_.js +25 -0
- package/packages/web/dist/assets/erDiagram-TEJ5UH35-CWvVVPZS.js +86 -0
- package/packages/web/dist/assets/flowDiagram-I6XJVG4X-DsCGXtp4.js +163 -0
- package/packages/web/dist/assets/format-DamJQ9LO.js +2 -0
- package/packages/web/dist/assets/ganttDiagram-6RSMTGT7-CsdwIvvC.js +293 -0
- package/packages/web/dist/assets/gitGraphDiagram-PVQCEYII-hZvrX1sD.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-CGbZdrm8.js +71 -0
- package/packages/web/dist/assets/index-DJDQ210-.css +1 -0
- package/packages/web/dist/assets/infoDiagram-5YYISTIA-nE_Vtz_l.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-2Nlqs0oH.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-mjU1fMc4.js +140 -0
- package/packages/web/dist/assets/json-DIYVocXf.js +2 -0
- package/packages/web/dist/assets/kanban-definition-UN3LZRKU-C_9xNaa3.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-BxKd91ho.js +2 -0
- package/packages/web/dist/assets/mermaid.core-C-CuhD8I.js +310 -0
- package/packages/web/dist/assets/mindmap-definition-RKZ34NQL-Bs6yfcZv.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-DNdqX7O5.js +31 -0
- package/packages/web/dist/assets/python-kSlZHhJb.js +2 -0
- package/packages/web/dist/assets/quadrantDiagram-W4KKPZXB-ZMJl9OdZ.js +8 -0
- package/packages/web/dist/assets/queued-LamEU0GH.js +2 -0
- package/packages/web/dist/assets/requirementDiagram-4Y6WPE33-RmxMdm2q.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-OAAhWcfK.js +41 -0
- package/packages/web/dist/assets/scss-DZLA7YXz.js +2 -0
- package/packages/web/dist/assets/sequenceDiagram-3UESZ5HK-BWF7rCyN.js +163 -0
- package/packages/web/dist/assets/sql-BC88IN-V.js +2 -0
- package/packages/web/dist/assets/stateDiagram-AJRCARHV-M3b_zqqo.js +2 -0
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-CI7saFDa.js +2 -0
- package/packages/web/dist/assets/swift-Dn8kKO_1.js +2 -0
- package/packages/web/dist/assets/timeline-definition-PNZ67QCA-BpOCh63N.js +121 -0
- package/packages/web/dist/assets/typescript-C2FFdlUC.js +2 -0
- package/packages/web/dist/assets/vennDiagram-CIIHVFJN-D_iPH45Q.js +35 -0
- package/packages/web/dist/assets/wardley-L42UT6IY-DVD9I2Om.js +174 -0
- package/packages/web/dist/assets/wardleyDiagram-YWT4CUSO-Bn1IKwjn.js +79 -0
- package/packages/web/dist/assets/xml-BXBhIUeX.js +2 -0
- package/packages/web/dist/assets/xychartDiagram-2RQKCTM6-DwtI0bkR.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,696 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SweepService — the post-turn overview + changelog curation engine
|
|
3
|
+
* (GitHub issues #2 + #6).
|
|
4
|
+
*
|
|
5
|
+
* After a USER chat turn completes, ws.ts calls `enqueue(slug)`. The service:
|
|
6
|
+
*
|
|
7
|
+
* 1. COALESCES bursts per project: at most one sweep per project per
|
|
8
|
+
* `minIntervalMs` (default 5 min). Rapid turns collapse into a single
|
|
9
|
+
* trailing sweep that runs once the interval has elapsed.
|
|
10
|
+
* 2. SKIPS when there's no new activity since the last sweep — tracked by a
|
|
11
|
+
* per-project watermark (the max keeper-session mtime + the time of the
|
|
12
|
+
* last successful sweep) persisted in a small state file under the data
|
|
13
|
+
* dir, so it survives restarts.
|
|
14
|
+
* 3. Runs OUT OF BAND via a dedicated lightweight sweeper agent (cheap model,
|
|
15
|
+
* Read/Write/Glob/Grep only) whose working_directory is the project dir.
|
|
16
|
+
* The trigger uses HerdctlService.runSweeper (triggerType "manual"), NOT
|
|
17
|
+
* the WS user-chat path, so a sweep can NEVER enqueue another sweep — no
|
|
18
|
+
* recursion.
|
|
19
|
+
*
|
|
20
|
+
* The sweeper is TOOL-LESS: it returns the curated content as plain text in
|
|
21
|
+
* marked sections (`<<<OVERVIEW>>> ... <<<CHANGELOG>>> ... <<<CLAUDE>>> ...
|
|
22
|
+
* <<<END>>>`), and THIS service parses that text and writes the files itself.
|
|
23
|
+
* Each section is either a FULL replacement for that file or the literal
|
|
24
|
+
* NOCHANGE (issue #379): the sweeper is shown each file IN FULL (bounded only by
|
|
25
|
+
* a generous per-file TOKEN BUDGET) and maintains it like a human maintainer —
|
|
26
|
+
* regenerating OVERVIEW.md wholesale, rewriting CHANGELOG.md (add a dated entry
|
|
27
|
+
* only for genuinely-new activity, coalesce duplicates, drop the oldest to fit
|
|
28
|
+
* budget), and rewriting the CLAUDE.md "Curated notes" section (dedup against the
|
|
29
|
+
* whole file; human-authored content above the heading is preserved). This
|
|
30
|
+
* replaces the old design where the sweeper saw only the first 2000 chars of
|
|
31
|
+
* CHANGELOG/CLAUDE and blind-appended — which grew both files (and the per-chat
|
|
32
|
+
* context they feed) without bound. `SweepService` enforces each budget as a
|
|
33
|
+
* backstop after the model returns. If the returned text is structurally
|
|
34
|
+
* unparseable the sweep throws (so the mtime watermark doesn't advance and the
|
|
35
|
+
* next sweep retries) — no partial/garbage writes. All sweep failures are
|
|
36
|
+
* non-fatal: they're logged and never break chat.
|
|
37
|
+
*/
|
|
38
|
+
import { promises as fs } from "node:fs";
|
|
39
|
+
import path from "node:path";
|
|
40
|
+
import { keeperAgentName } from "./herdctl.js";
|
|
41
|
+
import { DEFAULT_CURATION, resolveCurationConfig, } from "./curation-config.js";
|
|
42
|
+
import { curatorTriggerOf, triggerPromptFileAbsPath, } from "./trigger-config.js";
|
|
43
|
+
const STATE_FILE = "sweep-state.json";
|
|
44
|
+
const DEFAULT_MIN_INTERVAL_MS = 5 * 60 * 1000;
|
|
45
|
+
/**
|
|
46
|
+
* Optional per-project sweeper-instruction file (issue #G2). Project-relative
|
|
47
|
+
* (resolved inside the project's metadata dir, the sweeper's cwd); git-tracked
|
|
48
|
+
* and keeper-editable. When present, its content is appended to the sweeper's
|
|
49
|
+
* curation prompt; when absent, sweep behaviour is unchanged.
|
|
50
|
+
*/
|
|
51
|
+
const SWEEP_INSTRUCTIONS_FILE = ".paddock/hooks/sweep.md";
|
|
52
|
+
/**
|
|
53
|
+
* Generous size cap (chars) for the appended sweep-instruction file. Every other
|
|
54
|
+
* project-controlled field folded into the curation prompt is bounded
|
|
55
|
+
* (changelog/claudeMd via `trim(_, 2000)`, digest via `slice(-40)`/`trim(_, 600)`)
|
|
56
|
+
* so prompt/token cost stays predictable; this keeps a pathologically large
|
|
57
|
+
* `.paddock/hooks/sweep.md` from bloating every sweep. The cap is large enough
|
|
58
|
+
* that any realistic instruction file passes through untouched — and unlike the
|
|
59
|
+
* `trim(...)` helper (which flattens all whitespace to single spaces), it
|
|
60
|
+
* preserves the file's markdown line structure and only truncates the overflow.
|
|
61
|
+
*/
|
|
62
|
+
const SWEEP_INSTRUCTIONS_MAX = 8000;
|
|
63
|
+
/**
|
|
64
|
+
* Approximate chars-per-token used to convert a curation TOKEN budget (issue
|
|
65
|
+
* #379) into a char bound for the prompt view + write-time enforcement. English
|
|
66
|
+
* prose/markdown is ~3.5–4 chars/token; 4 is a deliberate slight over-estimate so
|
|
67
|
+
* the char bound is generous (we never under-show the sweeper its own files).
|
|
68
|
+
*/
|
|
69
|
+
const TOKENS_TO_CHARS = 4;
|
|
70
|
+
/**
|
|
71
|
+
* Max recent sessions folded into one sweep's digest (issue #379 concurrency
|
|
72
|
+
* fix). The old sweep read only the newest 3 sessions but advanced the watermark
|
|
73
|
+
* past ALL of them, so a 4th+ chat active within the same debounce window was
|
|
74
|
+
* silently never curated. We now digest every session newer than the watermark,
|
|
75
|
+
* capped here so a burst of concurrent chats can't unbound the prompt.
|
|
76
|
+
*/
|
|
77
|
+
const MAX_DIGEST_SESSIONS = 6;
|
|
78
|
+
/**
|
|
79
|
+
* Whether post-turn curation is ON for a project. A project that has NOT overridden the
|
|
80
|
+
* default (no curator trigger) → ON (behaves exactly as today). A project that DECLARES
|
|
81
|
+
* a `curate-overview` trigger uses its `enabled` flag authoritatively — so curation can
|
|
82
|
+
* be switched OFF by declaring the trigger `enabled: false` (and, per the frozen T1
|
|
83
|
+
* default of `enabled: false`, a declared curator must set `enabled: true` to sweep).
|
|
84
|
+
*/
|
|
85
|
+
export function isCurationEnabled(project) {
|
|
86
|
+
const t = curatorTriggerOf(project.triggers);
|
|
87
|
+
return t ? t.enabled === true : true;
|
|
88
|
+
}
|
|
89
|
+
export class SweepService {
|
|
90
|
+
herdctl;
|
|
91
|
+
projects;
|
|
92
|
+
stateFile;
|
|
93
|
+
minIntervalMs;
|
|
94
|
+
instanceCuration;
|
|
95
|
+
log;
|
|
96
|
+
/** In-memory watermark cache (loaded lazily, written through on change). */
|
|
97
|
+
state = null;
|
|
98
|
+
/** Pending trailing-sweep timers, keyed by slug (coalescing/debounce). */
|
|
99
|
+
timers = new Map();
|
|
100
|
+
/** Slugs with a sweep currently executing (prevents overlap). */
|
|
101
|
+
running = new Set();
|
|
102
|
+
constructor(opts) {
|
|
103
|
+
this.herdctl = opts.herdctl;
|
|
104
|
+
this.projects = opts.projects;
|
|
105
|
+
this.stateFile = path.join(opts.dataDir, STATE_FILE);
|
|
106
|
+
// `PADDOCK_SWEEP_MIN_INTERVAL_MS` is now folded into PaddockConfig (issue
|
|
107
|
+
// #269) and passed in as `minIntervalMs`; the default applies when unset.
|
|
108
|
+
this.minIntervalMs = opts.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS;
|
|
109
|
+
this.instanceCuration = opts.budget ?? DEFAULT_CURATION;
|
|
110
|
+
this.log = opts.logger ?? consoleLogger();
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Request a sweep for a project after a user turn completed. Coalesced and
|
|
114
|
+
* debounced: schedules a single trailing sweep at most once per
|
|
115
|
+
* `minIntervalMs`. Safe to call on every turn. Never throws.
|
|
116
|
+
*/
|
|
117
|
+
enqueue(slug) {
|
|
118
|
+
void this.schedule(slug).catch((err) => {
|
|
119
|
+
this.log.warn({ err, slug }, "sweep: enqueue failed (non-fatal)");
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
/** Stop all pending timers (call on shutdown). */
|
|
123
|
+
stop() {
|
|
124
|
+
for (const t of this.timers.values())
|
|
125
|
+
clearTimeout(t);
|
|
126
|
+
this.timers.clear();
|
|
127
|
+
}
|
|
128
|
+
// --- scheduling --------------------------------------------------------
|
|
129
|
+
async schedule(slug) {
|
|
130
|
+
// Already a trailing sweep pending for this project -> this turn is folded
|
|
131
|
+
// into it (coalesced). Nothing more to do.
|
|
132
|
+
if (this.timers.has(slug))
|
|
133
|
+
return;
|
|
134
|
+
const wm = await this.watermark(slug);
|
|
135
|
+
const elapsed = Date.now() - wm.lastSweptAt;
|
|
136
|
+
const delay = Math.max(0, this.minIntervalMs - elapsed);
|
|
137
|
+
// Schedule the (single) trailing sweep. If we're past the interval, delay
|
|
138
|
+
// is 0 and it runs on the next tick; otherwise it waits out the remainder
|
|
139
|
+
// so bursts within the window collapse into one sweep at the boundary.
|
|
140
|
+
const timer = setTimeout(() => {
|
|
141
|
+
this.timers.delete(slug);
|
|
142
|
+
void this.runIfActivity(slug).catch((err) => {
|
|
143
|
+
this.log.warn({ err, slug }, "sweep: run failed (non-fatal)");
|
|
144
|
+
});
|
|
145
|
+
}, delay);
|
|
146
|
+
// Don't keep the process alive just for a pending sweep.
|
|
147
|
+
if (typeof timer.unref === "function")
|
|
148
|
+
timer.unref();
|
|
149
|
+
this.timers.set(slug, timer);
|
|
150
|
+
}
|
|
151
|
+
/** Run a sweep only if there's new activity since the last one. */
|
|
152
|
+
async runIfActivity(slug) {
|
|
153
|
+
if (this.running.has(slug)) {
|
|
154
|
+
// A sweep is mid-flight; coalesce by re-arming once it finishes.
|
|
155
|
+
this.enqueue(slug);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
let project;
|
|
159
|
+
try {
|
|
160
|
+
project = await this.projects.get(slug);
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return; // project deleted between enqueue and run — drop silently.
|
|
164
|
+
}
|
|
165
|
+
// T5: the sweeper is the default `curate-overview` (event/afterTurn) trigger. A
|
|
166
|
+
// project that declares it `enabled: false` has switched curation OFF — skip
|
|
167
|
+
// entirely (no sweep, no watermark churn). Absent trigger ⇒ implicit default ⇒ ON,
|
|
168
|
+
// so an un-customised project is unaffected.
|
|
169
|
+
if (!isCurationEnabled(project)) {
|
|
170
|
+
this.log.info({ slug }, "sweep: skipped (curate-overview trigger disabled)");
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const sessions = await this.herdctl.recentSessions(project, 10).catch(() => []);
|
|
174
|
+
const newestMtime = sessions.reduce((max, s) => {
|
|
175
|
+
return !max || s.mtime > max ? s.mtime : max;
|
|
176
|
+
}, null);
|
|
177
|
+
const wm = await this.watermark(slug);
|
|
178
|
+
// Skip if nothing newer than the last sweep watermark.
|
|
179
|
+
if (newestMtime !== null && wm.lastSweptSessionMtime !== null) {
|
|
180
|
+
if (newestMtime <= wm.lastSweptSessionMtime) {
|
|
181
|
+
this.log.info({ slug }, "sweep: skipped (no new activity)");
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (newestMtime === null) {
|
|
186
|
+
this.log.info({ slug }, "sweep: skipped (no sessions yet)");
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
this.running.add(slug);
|
|
190
|
+
try {
|
|
191
|
+
await this.runSweep(project, sessions, wm.lastSweptSessionMtime);
|
|
192
|
+
// Stamp the watermark so a follow-up sweep with no new activity is
|
|
193
|
+
// skipped, and the interval clock restarts.
|
|
194
|
+
await this.setWatermark(slug, {
|
|
195
|
+
lastSweptSessionMtime: newestMtime,
|
|
196
|
+
lastSweptAt: Date.now(),
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
catch (err) {
|
|
200
|
+
// Non-fatal: still stamp lastSweptAt so we don't hot-loop on a failing
|
|
201
|
+
// project, but DON'T advance the session-mtime watermark (so the next
|
|
202
|
+
// sweep retries the same activity).
|
|
203
|
+
await this.setWatermark(slug, {
|
|
204
|
+
lastSweptSessionMtime: wm.lastSweptSessionMtime,
|
|
205
|
+
lastSweptAt: Date.now(),
|
|
206
|
+
});
|
|
207
|
+
this.log.error({ err, slug }, "sweep: sweeper run errored (non-fatal)");
|
|
208
|
+
}
|
|
209
|
+
finally {
|
|
210
|
+
this.running.delete(slug);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
// --- the sweep itself --------------------------------------------------
|
|
214
|
+
async runSweep(project, sessions, sinceMtime) {
|
|
215
|
+
const [overview, changelog, claudeMd, digest, fileInstructions, triggerInstructions] = await Promise.all([
|
|
216
|
+
this.projects.readOverview(project.slug),
|
|
217
|
+
this.projects.readFile(project.slug, "CHANGELOG.md").catch(() => ""),
|
|
218
|
+
this.projects.readClaudeMd(project.slug).catch(() => ""),
|
|
219
|
+
this.buildDigest(project, sessions, sinceMtime),
|
|
220
|
+
this.readSweepInstructions(project.slug),
|
|
221
|
+
this.readTriggerInstructions(project),
|
|
222
|
+
]);
|
|
223
|
+
// The curator trigger's own prompt extension (design §2.1 #4) and the git-tracked
|
|
224
|
+
// `.paddock/hooks/sweep.md` are two channels of the SAME per-project curator
|
|
225
|
+
// guidance — fold both under the one `=== EXTRA … ===` heading (trigger first).
|
|
226
|
+
const extraInstructions = [triggerInstructions, fileInstructions]
|
|
227
|
+
.filter((s) => s.length > 0)
|
|
228
|
+
.join("\n\n");
|
|
229
|
+
// Effective budgets for THIS project (issue #384): a `project.yaml` `curation`
|
|
230
|
+
// override wins field-by-field, else the instance default.
|
|
231
|
+
const budget = resolveCurationConfig(project.curation, this.instanceCuration);
|
|
232
|
+
const prompt = this.curationPrompt({
|
|
233
|
+
project,
|
|
234
|
+
overview,
|
|
235
|
+
changelog,
|
|
236
|
+
claudeMd,
|
|
237
|
+
digest,
|
|
238
|
+
extraInstructions,
|
|
239
|
+
budget,
|
|
240
|
+
});
|
|
241
|
+
const { result, text } = await this.herdctl.runSweeper(project.slug, prompt);
|
|
242
|
+
if (!result.success) {
|
|
243
|
+
throw result.error ?? new Error("sweeper trigger reported failure");
|
|
244
|
+
}
|
|
245
|
+
// The sweeper is tool-less: it returns the three marked sections and THIS
|
|
246
|
+
// service writes the files (issue #379). Each section is either a full
|
|
247
|
+
// replacement or the literal NOCHANGE (→ null). Throw on structurally-
|
|
248
|
+
// unparseable output so the watermark doesn't advance and the next sweep
|
|
249
|
+
// retries (no partial writes).
|
|
250
|
+
const parsed = parseSweeperOutput(text);
|
|
251
|
+
if (!parsed) {
|
|
252
|
+
this.log.warn({ slug: project.slug, sessionId: result.sessionId, jobId: result.jobId }, "sweep: sweeper output missing/unparseable markers — not writing");
|
|
253
|
+
throw new Error("sweeper output missing OVERVIEW/CHANGELOG markers");
|
|
254
|
+
}
|
|
255
|
+
// OVERVIEW.md — regenerated wholesale (unchanged model), box-conventions
|
|
256
|
+
// stripped (#42) and bounded to its token budget as a backstop (#379).
|
|
257
|
+
// NOCHANGE (null) leaves the existing file untouched.
|
|
258
|
+
if (parsed.overview !== null) {
|
|
259
|
+
const clean = enforceHeadBudget(stripBoxConventions(parsed.overview), budgetChars(budget, "overviewMaxTokens"));
|
|
260
|
+
await this.projects.writeOverview(project.slug, clean);
|
|
261
|
+
}
|
|
262
|
+
// CHANGELOG.md — wholesale replace with the sweeper's curated full file
|
|
263
|
+
// (add/coalesce/prune), bounded to its budget by dropping the oldest whole
|
|
264
|
+
// dated sections (#379). NOCHANGE leaves it untouched — the change-detection
|
|
265
|
+
// gate that kills the old "one near-duplicate bullet per sweep" spam.
|
|
266
|
+
if (parsed.changelog !== null) {
|
|
267
|
+
const bounded = enforceChangelogBudget(parsed.changelog, budgetChars(budget, "changelogMaxTokens"));
|
|
268
|
+
await this.projects.writeChangelog(project.slug, bounded);
|
|
269
|
+
}
|
|
270
|
+
// CLAUDE.md — the sweeper now sees the FULL file and returns the entire
|
|
271
|
+
// curated-notes body (dedup'd/pruned), which replaces only that managed
|
|
272
|
+
// section; human-authored content above `## Curated notes` is preserved.
|
|
273
|
+
// NOCHANGE leaves it untouched. A write failure is non-fatal (OVERVIEW/
|
|
274
|
+
// CHANGELOG already landed; the watermark should still advance) → warn.
|
|
275
|
+
//
|
|
276
|
+
// REPO-BACKED projects (issue #187): the CLAUDE.md is the external repo's
|
|
277
|
+
// OWN, upstream-owned file — the sweeper must NEVER write it (that would
|
|
278
|
+
// dirty the checkout and, if pushed, leak curation upstream). OVERVIEW.md +
|
|
279
|
+
// CHANGELOG.md are still curated (sidecarred), just not CLAUDE.md.
|
|
280
|
+
if (parsed.claude !== null && !project.repoBacked) {
|
|
281
|
+
const bounded = enforceHeadBudget(parsed.claude, budgetChars(budget, "claudeMaxTokens"));
|
|
282
|
+
await this.projects.writeClaudeCurated(project.slug, bounded).catch((err) => {
|
|
283
|
+
this.log.warn({ err, slug: project.slug }, "sweep: CLAUDE.md write failed (non-fatal)");
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
this.log.info({
|
|
287
|
+
slug: project.slug,
|
|
288
|
+
sessionId: result.sessionId,
|
|
289
|
+
jobId: result.jobId,
|
|
290
|
+
wroteOverview: parsed.overview !== null,
|
|
291
|
+
wroteChangelog: parsed.changelog !== null,
|
|
292
|
+
wroteClaude: parsed.claude !== null,
|
|
293
|
+
}, "sweep: completed");
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Build a compact digest of recent session activity to hand to the sweeper,
|
|
297
|
+
* so it doesn't have to discover transcripts itself. We read the messages of
|
|
298
|
+
* every session newer than the last sweep's watermark (issue #379 concurrency
|
|
299
|
+
* fix — so a 4th+ chat active within a debounce window is no longer dropped),
|
|
300
|
+
* capped at {@link MAX_DIGEST_SESSIONS}. On the first sweep (no watermark) we
|
|
301
|
+
* fall back to the newest few. Summarizes roles + tool usage + trimmed text.
|
|
302
|
+
*/
|
|
303
|
+
async buildDigest(project, sessions, sinceMtime) {
|
|
304
|
+
// All sessions with activity since the watermark; if none (first sweep or a
|
|
305
|
+
// clock edge), fall back to the newest few so we still curate something.
|
|
306
|
+
const fresh = sinceMtime
|
|
307
|
+
? sessions.filter((s) => s.mtime > sinceMtime)
|
|
308
|
+
: sessions;
|
|
309
|
+
const recent = (fresh.length > 0 ? fresh : sessions).slice(0, MAX_DIGEST_SESSIONS);
|
|
310
|
+
const parts = [];
|
|
311
|
+
for (const s of recent) {
|
|
312
|
+
const messages = await this.herdctl
|
|
313
|
+
.sessionMessages(keeperAgentName(project.slug), s.sessionId)
|
|
314
|
+
.catch(() => []);
|
|
315
|
+
if (messages.length === 0)
|
|
316
|
+
continue;
|
|
317
|
+
const lines = [];
|
|
318
|
+
for (const m of messages.slice(-40)) {
|
|
319
|
+
if (m.role === "tool") {
|
|
320
|
+
const t = m.toolCall;
|
|
321
|
+
const summary = t?.inputSummary ? ` ${trim(t.inputSummary, 120)}` : "";
|
|
322
|
+
lines.push(` [tool ${t?.toolName ?? "?"}${summary}]${t?.isError ? " (error)" : ""}`);
|
|
323
|
+
}
|
|
324
|
+
else {
|
|
325
|
+
const text = trim(m.content, 600);
|
|
326
|
+
if (text)
|
|
327
|
+
lines.push(` ${m.role}: ${text}`);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
if (lines.length > 0) {
|
|
331
|
+
const label = s.autoName ?? s.preview ?? s.sessionId.slice(0, 8);
|
|
332
|
+
parts.push(`Session "${label}" (updated ${s.mtime}):\n${lines.join("\n")}`);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return parts.length > 0 ? parts.join("\n\n") : "(no readable recent transcripts)";
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Read the OPTIONAL per-project sweeper-instruction file
|
|
339
|
+
* (`.paddock/hooks/sweep.md`, issue #G2) — extra curator guidance a
|
|
340
|
+
* user/keeper commits to shape THIS project's OVERVIEW/CHANGELOG curation.
|
|
341
|
+
*
|
|
342
|
+
* The file is git-tracked and keeper-editable, and lives alongside the
|
|
343
|
+
* project's `project.yaml`/OVERVIEW.md/CHANGELOG.md in the metadata dir — the
|
|
344
|
+
* same directory the sweeper agent runs in (its cwd) and that `readFile`
|
|
345
|
+
* resolves against (traversal-guarded). When absent or blank the sweep behaves
|
|
346
|
+
* exactly as before (no append). Never throws: a read error is treated as "no
|
|
347
|
+
* extra instructions" so curation is never broken by a bad/removed file.
|
|
348
|
+
*
|
|
349
|
+
* This is a sweeper-LOCAL convenience: it only appends text to the tool-less
|
|
350
|
+
* curator's prompt (shaping its output), granting no new capability. It is
|
|
351
|
+
* deliberately NOT routed through the generic hook framework.
|
|
352
|
+
*/
|
|
353
|
+
async readSweepInstructions(slug) {
|
|
354
|
+
const raw = await this.projects
|
|
355
|
+
.readFile(slug, SWEEP_INSTRUCTIONS_FILE)
|
|
356
|
+
.catch(() => "");
|
|
357
|
+
const text = raw.trim();
|
|
358
|
+
// Bound the size like every sibling prompt field so a huge file can't bloat
|
|
359
|
+
// every sweep's token cost. Realistic instruction files are well under the
|
|
360
|
+
// cap; only pathological ones truncate (with a warning so it's not silent).
|
|
361
|
+
if (text.length > SWEEP_INSTRUCTIONS_MAX) {
|
|
362
|
+
this.log.warn({ slug, length: text.length, cap: SWEEP_INSTRUCTIONS_MAX }, "sweep: .paddock/hooks/sweep.md exceeds cap — truncating for the prompt");
|
|
363
|
+
return text.slice(0, SWEEP_INSTRUCTIONS_MAX) + "\n…[truncated]";
|
|
364
|
+
}
|
|
365
|
+
return text;
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* Read the OPTIONAL curator-trigger prompt extension (T5) — the `run.prompt` /
|
|
369
|
+
* `run.promptFile` of the project's `curate-overview` (event/afterTurn) trigger,
|
|
370
|
+
* appended to the sweeper's curation prompt exactly like `.paddock/hooks/sweep.md`.
|
|
371
|
+
* This is how the design's #4 example (`"Also maintain GLOSSARY.md of domain terms."`)
|
|
372
|
+
* extends the built-in sweep. `run.promptFile` resolves under `.paddock/triggers/`
|
|
373
|
+
* (traversal-guarded, `.md`-only) and is read fresh at fire time, exactly as the
|
|
374
|
+
* generic trigger fire path does. Absent trigger / neither field / a read error ⇒ ""
|
|
375
|
+
* (no extra instructions), so curation is never broken by a bad/removed file. Capped
|
|
376
|
+
* like every sibling prompt field so it can't bloat the sweep's token cost.
|
|
377
|
+
*/
|
|
378
|
+
async readTriggerInstructions(project) {
|
|
379
|
+
const trigger = curatorTriggerOf(project.triggers);
|
|
380
|
+
if (!trigger)
|
|
381
|
+
return "";
|
|
382
|
+
let body = typeof trigger.run.prompt === "string" ? trigger.run.prompt : "";
|
|
383
|
+
if (trigger.run.promptFile) {
|
|
384
|
+
const abs = triggerPromptFileAbsPath(project.workingDir, trigger.run.promptFile);
|
|
385
|
+
if (abs) {
|
|
386
|
+
const content = await fs.readFile(abs, "utf8").catch(() => null);
|
|
387
|
+
if (content !== null)
|
|
388
|
+
body = content;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
const text = body.trim();
|
|
392
|
+
if (text.length > SWEEP_INSTRUCTIONS_MAX) {
|
|
393
|
+
this.log.warn({ slug: project.slug, length: text.length, cap: SWEEP_INSTRUCTIONS_MAX }, "sweep: curate-overview trigger prompt exceeds cap — truncating");
|
|
394
|
+
return text.slice(0, SWEEP_INSTRUCTIONS_MAX) + "\n…[truncated]";
|
|
395
|
+
}
|
|
396
|
+
return text;
|
|
397
|
+
}
|
|
398
|
+
/** The curation prompt handed to the sweeper agent. */
|
|
399
|
+
curationPrompt(args) {
|
|
400
|
+
const { project, overview, changelog, claudeMd, digest, extraInstructions, budget } = args;
|
|
401
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
402
|
+
// Budgets in tokens (for the instructions the model reads) and the matching
|
|
403
|
+
// char bound for the FULL-FILE views below. Generous vs the old flat 2000-char
|
|
404
|
+
// truncation, so the model can actually see (and therefore dedup) its files.
|
|
405
|
+
const b = budget;
|
|
406
|
+
const changelogView = boundedView(changelog, budgetChars(budget, "changelogMaxTokens"));
|
|
407
|
+
const claudeView = boundedView(claudeMd, budgetChars(budget, "claudeMaxTokens"));
|
|
408
|
+
return [
|
|
409
|
+
`Project: ${project.name} (slug: ${project.slug})`,
|
|
410
|
+
project.summary ? `Summary: ${project.summary}` : "",
|
|
411
|
+
"",
|
|
412
|
+
"You are curating this project's three context files from recent chat " +
|
|
413
|
+
"activity. You are shown each file IN FULL (a curator that only sees a " +
|
|
414
|
+
"fragment re-adds things it already wrote). For each file you return " +
|
|
415
|
+
"either its complete new contents OR the literal NOCHANGE — never a " +
|
|
416
|
+
"fragment to blindly append.",
|
|
417
|
+
"",
|
|
418
|
+
"=== RECENT SESSION ACTIVITY (digest) ===",
|
|
419
|
+
"(This may span SEVERAL chats active since the last sweep — cover them all.)",
|
|
420
|
+
digest,
|
|
421
|
+
"",
|
|
422
|
+
"=== CURRENT OVERVIEW.md (full) ===",
|
|
423
|
+
overview || "(none yet — you are creating it for the first time)",
|
|
424
|
+
"",
|
|
425
|
+
"=== CURRENT CHANGELOG.md (full) ===",
|
|
426
|
+
changelogView || "(empty)",
|
|
427
|
+
"",
|
|
428
|
+
"=== CURRENT CLAUDE.md (full — durable identity & conventions) ===",
|
|
429
|
+
claudeView || "(none yet)",
|
|
430
|
+
"",
|
|
431
|
+
"=== YOUR TASKS ===",
|
|
432
|
+
"Do NOT use any tools. Output ONLY the three sections below, nothing else " +
|
|
433
|
+
"(no preamble, no explanation). Use these LITERAL markers exactly:",
|
|
434
|
+
"",
|
|
435
|
+
"<<<OVERVIEW>>>",
|
|
436
|
+
"The FULL markdown OVERVIEW.md, REPLACING the current one wholesale: a " +
|
|
437
|
+
"concise synthesized snapshot of the project's CURRENT state for an LLM " +
|
|
438
|
+
"to read at the start of a NEW chat — what the project is, key decisions/" +
|
|
439
|
+
"facts, open questions, next steps. NOT a changelog or per-session " +
|
|
440
|
+
`history. Keep it under ~${b.overviewMaxTokens} tokens. (Output NOCHANGE ` +
|
|
441
|
+
"only if the existing overview is already accurate and complete.)",
|
|
442
|
+
"<<<CHANGELOG>>>",
|
|
443
|
+
"The FULL updated CHANGELOG.md. If this activity is a genuinely NEW, user-" +
|
|
444
|
+
`visible change, add ONE bullet under a \`## ${today}\` heading at the ` +
|
|
445
|
+
"TOP (newest-first); reuse the heading if it's already today. Otherwise — " +
|
|
446
|
+
"if the activity is already captured by a recent entry (e.g. continued/" +
|
|
447
|
+
"repeated work, polling, minor follow-ups) — output NOCHANGE and add " +
|
|
448
|
+
"nothing. COALESCE near-duplicate recent bullets into one. Keep the whole " +
|
|
449
|
+
`file under ~${b.changelogMaxTokens} tokens by summarizing or dropping the ` +
|
|
450
|
+
"OLDEST entries; preserve the rest verbatim. Do NOT re-log unchanged state.",
|
|
451
|
+
"<<<CLAUDE>>>",
|
|
452
|
+
"The FULL curated-notes body for CLAUDE.md (everything that should appear " +
|
|
453
|
+
"under the `## Curated notes` heading) — ONLY genuinely durable facts " +
|
|
454
|
+
"about the project's identity/conventions (what it fundamentally is, key " +
|
|
455
|
+
"architectural decisions, how we work on it). You can see the whole file " +
|
|
456
|
+
"above, so DEDUP: fold repeated/near-duplicate notes into one, drop stale " +
|
|
457
|
+
`ones, and keep it under ~${b.claudeMaxTokens} tokens. Do NOT restate ` +
|
|
458
|
+
"current state/tasks/history (those are OVERVIEW/CHANGELOG). If nothing " +
|
|
459
|
+
"durable changed, output exactly NOCHANGE and nothing else in this section.",
|
|
460
|
+
"<<<END>>>",
|
|
461
|
+
"",
|
|
462
|
+
"IMPORTANT — OVERVIEW.md describes the PROJECT, not the box it runs on. Do " +
|
|
463
|
+
"NOT record box/environment operational conventions: how to run, build, " +
|
|
464
|
+
"start, preview, or expose a dev server; port numbers; localhost vs. dev " +
|
|
465
|
+
"hostnames or URLs; where to clone repos; process managers. Those are " +
|
|
466
|
+
"governed authoritatively by the box's own CLAUDE.md and must never be " +
|
|
467
|
+
"re-described, pinned, or contradicted here — a past chat mentioning a " +
|
|
468
|
+
"specific port or a localhost URL is NOT a project fact to record. Capture " +
|
|
469
|
+
"what the project IS plus its decisions, open questions, and next steps.",
|
|
470
|
+
"",
|
|
471
|
+
// Optional per-project curator instructions (`.paddock/hooks/sweep.md`,
|
|
472
|
+
// issue #G2): appended verbatim so a project can steer its own curation
|
|
473
|
+
// (e.g. "always keep a Glossary section", "note API changes prominently").
|
|
474
|
+
// These refine HOW to curate; they do NOT override the literal output
|
|
475
|
+
// markers or the box-conventions rule above.
|
|
476
|
+
extraInstructions
|
|
477
|
+
? "=== EXTRA PROJECT-SPECIFIC CURATOR INSTRUCTIONS ===\n" +
|
|
478
|
+
"The following instructions were provided by this project to guide its " +
|
|
479
|
+
"curation. Honor them while still obeying the output format and rules " +
|
|
480
|
+
"above.\n\n" +
|
|
481
|
+
extraInstructions
|
|
482
|
+
: "",
|
|
483
|
+
"",
|
|
484
|
+
"Be factual and terse; do not invent details not supported by the activity.",
|
|
485
|
+
]
|
|
486
|
+
.filter((l) => l !== "")
|
|
487
|
+
.join("\n");
|
|
488
|
+
}
|
|
489
|
+
// --- watermark state ---------------------------------------------------
|
|
490
|
+
async watermark(slug) {
|
|
491
|
+
const state = await this.loadState();
|
|
492
|
+
return state[slug] ?? { lastSweptSessionMtime: null, lastSweptAt: 0 };
|
|
493
|
+
}
|
|
494
|
+
async setWatermark(slug, wm) {
|
|
495
|
+
const state = await this.loadState();
|
|
496
|
+
state[slug] = wm;
|
|
497
|
+
await this.saveState(state);
|
|
498
|
+
}
|
|
499
|
+
async loadState() {
|
|
500
|
+
if (this.state)
|
|
501
|
+
return this.state;
|
|
502
|
+
try {
|
|
503
|
+
const raw = await fs.readFile(this.stateFile, "utf8");
|
|
504
|
+
const parsed = JSON.parse(raw);
|
|
505
|
+
this.state = parsed && typeof parsed === "object" ? parsed : {};
|
|
506
|
+
}
|
|
507
|
+
catch {
|
|
508
|
+
this.state = {};
|
|
509
|
+
}
|
|
510
|
+
return this.state;
|
|
511
|
+
}
|
|
512
|
+
async saveState(state) {
|
|
513
|
+
this.state = state;
|
|
514
|
+
try {
|
|
515
|
+
await fs.writeFile(this.stateFile, JSON.stringify(state, null, 2), "utf8");
|
|
516
|
+
}
|
|
517
|
+
catch (err) {
|
|
518
|
+
this.log.warn({ err }, "sweep: failed to persist watermark state (non-fatal)");
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
function trim(s, max) {
|
|
523
|
+
if (!s)
|
|
524
|
+
return "";
|
|
525
|
+
const flat = s.replace(/\s+/g, " ").trim();
|
|
526
|
+
return flat.length > max ? flat.slice(0, max) + "…" : flat;
|
|
527
|
+
}
|
|
528
|
+
/** A curated file's byte budget: its token budget × approximate chars-per-token. */
|
|
529
|
+
function budgetChars(budget, key) {
|
|
530
|
+
return budget[key] * TOKENS_TO_CHARS;
|
|
531
|
+
}
|
|
532
|
+
/**
|
|
533
|
+
* A structure-preserving bounded view of a file for the sweeper's prompt (issue
|
|
534
|
+
* #379) — the head (which, for a newest-first CHANGELOG, is the most recent
|
|
535
|
+
* history) up to `maxChars`, cut on a line boundary with a marker. Unlike the old
|
|
536
|
+
* `trim(_, 2000)` this does NOT flatten whitespace, so the model sees real
|
|
537
|
+
* markdown and can dedup against it. Budgets are generous, so most files pass
|
|
538
|
+
* through whole.
|
|
539
|
+
*/
|
|
540
|
+
function boundedView(s, maxChars) {
|
|
541
|
+
if (!s)
|
|
542
|
+
return "";
|
|
543
|
+
if (s.length <= maxChars)
|
|
544
|
+
return s;
|
|
545
|
+
const cut = s.lastIndexOf("\n", maxChars);
|
|
546
|
+
const head = s.slice(0, cut > maxChars * 0.5 ? cut : maxChars);
|
|
547
|
+
return `${head.trimEnd()}\n\n…[older content truncated from this view — preserve it]`;
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* Backstop enforcement of a head-oriented budget for OVERVIEW / CLAUDE curated
|
|
551
|
+
* body (issue #379): if the model ignored its budget, keep the head up to
|
|
552
|
+
* `maxChars` (line-aligned). The model is asked to stay under budget, so this
|
|
553
|
+
* rarely fires.
|
|
554
|
+
*/
|
|
555
|
+
function enforceHeadBudget(s, maxChars) {
|
|
556
|
+
if (s.length <= maxChars)
|
|
557
|
+
return s;
|
|
558
|
+
const cut = s.lastIndexOf("\n", maxChars);
|
|
559
|
+
return `${s.slice(0, cut > maxChars * 0.5 ? cut : maxChars).trimEnd()}\n`;
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* Backstop enforcement of the CHANGELOG budget (issue #379). CHANGELOG is
|
|
563
|
+
* newest-first (`## YYYY-MM-DD` sections at the top), so we keep whole leading
|
|
564
|
+
* sections until adding the next would exceed `maxChars`, then drop the rest and
|
|
565
|
+
* leave a compaction marker. This guarantees the file (and the per-chat preload
|
|
566
|
+
* that injects it) stays bounded even if the model returns an over-budget file.
|
|
567
|
+
*/
|
|
568
|
+
function enforceChangelogBudget(body, maxChars) {
|
|
569
|
+
const trimmed = body.trim();
|
|
570
|
+
if (trimmed.length <= maxChars)
|
|
571
|
+
return trimmed;
|
|
572
|
+
// Split into leading preamble + `## `-headed sections, keeping newest first.
|
|
573
|
+
const lines = trimmed.split("\n");
|
|
574
|
+
const sections = [];
|
|
575
|
+
let current = [];
|
|
576
|
+
for (const line of lines) {
|
|
577
|
+
if (/^##\s/.test(line) && current.length > 0) {
|
|
578
|
+
sections.push(current.join("\n"));
|
|
579
|
+
current = [line];
|
|
580
|
+
}
|
|
581
|
+
else {
|
|
582
|
+
current.push(line);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
if (current.length > 0)
|
|
586
|
+
sections.push(current.join("\n"));
|
|
587
|
+
const kept = [];
|
|
588
|
+
let used = 0;
|
|
589
|
+
const marker = "\n\n_[older changelog entries compacted to stay within budget]_";
|
|
590
|
+
for (const section of sections) {
|
|
591
|
+
const add = (kept.length > 0 ? 2 : 0) + section.length;
|
|
592
|
+
if (used + add > maxChars - marker.length && kept.length > 0)
|
|
593
|
+
break;
|
|
594
|
+
kept.push(section);
|
|
595
|
+
used += add;
|
|
596
|
+
}
|
|
597
|
+
return kept.join("\n\n").trimEnd() + marker;
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
* Parse the tool-less sweeper's marked output (issue #379). Each of the three
|
|
601
|
+
* sections is either a FULL replacement for that file or the literal `NOCHANGE`:
|
|
602
|
+
*
|
|
603
|
+
* <<<OVERVIEW>>>
|
|
604
|
+
* ...full OVERVIEW.md (or NOCHANGE)...
|
|
605
|
+
* <<<CHANGELOG>>>
|
|
606
|
+
* ...full CHANGELOG.md body (or NOCHANGE)...
|
|
607
|
+
* <<<CLAUDE>>>
|
|
608
|
+
* ...full CLAUDE.md curated-notes body (or NOCHANGE)...
|
|
609
|
+
* <<<END>>>
|
|
610
|
+
*
|
|
611
|
+
* Returns `null` (→ caller retries, no partial writes) only on STRUCTURAL failure
|
|
612
|
+
* — the OVERVIEW or CHANGELOG marker is missing/misordered. Otherwise returns the
|
|
613
|
+
* three sections, each `null` when it is empty or `NOCHANGE` (→ leave that file
|
|
614
|
+
* untouched). `<<<CLAUDE>>>` and `<<<END>>>` remain optional (EOF accepted); a
|
|
615
|
+
* reply with no CLAUDE marker yields `claude: null`.
|
|
616
|
+
*/
|
|
617
|
+
function parseSweeperOutput(text) {
|
|
618
|
+
if (!text)
|
|
619
|
+
return null;
|
|
620
|
+
const overviewIdx = text.indexOf("<<<OVERVIEW>>>");
|
|
621
|
+
const changelogIdx = text.indexOf("<<<CHANGELOG>>>");
|
|
622
|
+
if (overviewIdx === -1 || changelogIdx === -1 || changelogIdx < overviewIdx)
|
|
623
|
+
return null;
|
|
624
|
+
// The CLAUDE section (if present) closes the changelog; else <<<END>>>/EOF.
|
|
625
|
+
// Match "<<<CLAUDE" so a `<<<CLAUDE:NOCHANGE>>>` variant still delimits it.
|
|
626
|
+
const endIdx = text.indexOf("<<<END>>>", changelogIdx);
|
|
627
|
+
const claudeMarkerIdx = text.indexOf("<<<CLAUDE", changelogIdx);
|
|
628
|
+
const changelogEnd = [claudeMarkerIdx, endIdx]
|
|
629
|
+
.filter((i) => i !== -1)
|
|
630
|
+
.reduce((min, i) => (min === undefined || i < min ? i : min), undefined);
|
|
631
|
+
const overview = sectionOrNull(text.slice(overviewIdx + "<<<OVERVIEW>>>".length, changelogIdx));
|
|
632
|
+
const changelog = sectionOrNull(text.slice(changelogIdx + "<<<CHANGELOG>>>".length, changelogEnd));
|
|
633
|
+
let claude = null;
|
|
634
|
+
const claudeIdx = text.indexOf("<<<CLAUDE>>>", changelogIdx);
|
|
635
|
+
if (claudeIdx !== -1) {
|
|
636
|
+
const claudeEnd = text.indexOf("<<<END>>>", claudeIdx);
|
|
637
|
+
claude = sectionOrNull(text.slice(claudeIdx + "<<<CLAUDE>>>".length, claudeEnd === -1 ? undefined : claudeEnd));
|
|
638
|
+
}
|
|
639
|
+
return { overview, changelog, claude };
|
|
640
|
+
}
|
|
641
|
+
/** A trimmed section, or `null` when it is empty or the literal `NOCHANGE`. */
|
|
642
|
+
function sectionOrNull(raw) {
|
|
643
|
+
const s = raw.trim();
|
|
644
|
+
if (s.length === 0 || /^NOCHANGE$/i.test(s))
|
|
645
|
+
return null;
|
|
646
|
+
return s;
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* Belt-and-suspenders normalizer for issue #42: keep box/environment
|
|
650
|
+
* operational conventions out of a curated OVERVIEW.md even when the sweeper
|
|
651
|
+
* slips them in despite the prompt. OVERVIEW.md is prepended to every new chat,
|
|
652
|
+
* so a stray "run the dev server on localhost:4100" line there silently
|
|
653
|
+
* overrides the box CLAUDE.md — a self-reinforcing wrong-setup loop.
|
|
654
|
+
*
|
|
655
|
+
* This drops whole markdown sections whose heading denotes "how to run / build /
|
|
656
|
+
* expose a dev server on this box" (heading + body, down to the next heading of
|
|
657
|
+
* the same or higher level). Scope is deliberately narrow: only sections headed
|
|
658
|
+
* by a recognized dev-ops phrase are removed; prose elsewhere is untouched. If
|
|
659
|
+
* stripping would empty the document, the original is returned (we never write a
|
|
660
|
+
* blank OVERVIEW).
|
|
661
|
+
*/
|
|
662
|
+
export function stripBoxConventions(overview) {
|
|
663
|
+
if (!overview)
|
|
664
|
+
return overview;
|
|
665
|
+
const out = [];
|
|
666
|
+
let dropAtLevel = null; // set while inside a dropped section
|
|
667
|
+
for (const line of overview.split("\n")) {
|
|
668
|
+
const h = /^(#{1,6})\s+(.*\S)\s*$/.exec(line);
|
|
669
|
+
if (h) {
|
|
670
|
+
const level = h[1].length;
|
|
671
|
+
// A heading at the same or higher level closes the dropped section.
|
|
672
|
+
if (dropAtLevel !== null && level <= dropAtLevel)
|
|
673
|
+
dropAtLevel = null;
|
|
674
|
+
if (dropAtLevel === null && BOX_OPS_HEADING.test(h[2])) {
|
|
675
|
+
dropAtLevel = level;
|
|
676
|
+
continue; // drop the heading itself
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
if (dropAtLevel !== null)
|
|
680
|
+
continue; // inside a dropped section
|
|
681
|
+
out.push(line);
|
|
682
|
+
}
|
|
683
|
+
const stripped = out.join("\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
684
|
+
return stripped.length > 0 ? stripped : overview;
|
|
685
|
+
}
|
|
686
|
+
/** Headings that denote box-level "how to run a dev server here" content. */
|
|
687
|
+
const BOX_OPS_HEADING = /\b(local[-\s]dev(elopment)?|dev(elopment)?\s+server|preview\s+server|running\s+locally|run\s+locally|running\s+the\s+app|serving\s+the\s+app|how\s+to\s+run|local\s+setup|dev\s+environment)\b/i;
|
|
688
|
+
function consoleLogger() {
|
|
689
|
+
/* eslint-disable no-console */
|
|
690
|
+
return {
|
|
691
|
+
info: (o, m) => console.log("[sweep]", m ?? "", o),
|
|
692
|
+
warn: (o, m) => console.warn("[sweep]", m ?? "", o),
|
|
693
|
+
error: (o, m) => console.error("[sweep]", m ?? "", o),
|
|
694
|
+
};
|
|
695
|
+
/* eslint-enable no-console */
|
|
696
|
+
}
|