@itookit/dsht 0.5.2 → 0.6.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 +6 -2
- package/README.zh.md +6 -2
- package/dist/cli/dsht.js +18 -1
- package/dist/contracts.d.ts +22 -0
- package/dist/controller/controller.d.ts +3 -1
- package/dist/controller/controller.js +5 -0
- package/dist/controller/loop-prompts-schema.d.ts +16 -2
- package/dist/controller/loop-prompts-schema.js +106 -27
- package/dist/controller/loop-prompts.d.ts +17 -2
- package/dist/controller/loop-prompts.generated.js +2 -1
- package/dist/controller/loop-prompts.js +35 -9
- package/dist/controller/loop-protocols.d.ts +3 -1
- package/dist/controller/loop-protocols.js +8 -3
- package/dist/controller/loop-source.d.ts +74 -0
- package/dist/controller/loop-source.js +224 -0
- package/dist/cost/controller.d.ts +2 -0
- package/dist/cost/controller.js +14 -2
- package/dist/cost/index.d.ts +1 -1
- package/dist/cost/index.js +1 -1
- package/dist/cost/ledger-files.d.ts +20 -0
- package/dist/cost/ledger-files.js +115 -15
- package/dist/cost/ledger.d.ts +31 -6
- package/dist/cost/ledger.js +74 -22
- package/dist/cost/pricing.d.ts +39 -0
- package/dist/cost/pricing.js +46 -0
- package/dist/cost/types.d.ts +9 -3
- package/dist/storage/files.d.ts +8 -0
- package/dist/storage/files.js +18 -1
- package/dist/storage/index.d.ts +1 -1
- package/dist/storage/index.js +1 -1
- package/dist/ui/app.js +3 -1
- package/dist/ui/dialogs/cost.d.ts +6 -0
- package/dist/ui/dialogs/cost.js +5 -1
- package/dist/ui/dialogs/loop.d.ts +5 -4
- package/dist/ui/dialogs/loop.js +14 -6
- package/loop.yaml +230 -0
- package/package.json +5 -4
package/README.md
CHANGED
|
@@ -345,7 +345,7 @@ Tab completes the leading slash command, extending an ambiguous draft to the sha
|
|
|
345
345
|
| `/permission [preset]` | View or switch the host sandbox/approval preset |
|
|
346
346
|
| `/feedback TEXT` | Record feedback about the current session |
|
|
347
347
|
| `/handoff` | Delete the local HANDOFF.md, then have the agent write a fresh session handoff |
|
|
348
|
-
| `/loop [name\|stop\|answer] [score] [tries]` | Pick a
|
|
348
|
+
| `/loop [name\|stop\|answer] [score] [tries]` | Pick a loop record from a list, edit the inputs and limits it starts from, then run the scored loop; records are the shipped `loop.yaml` with any `loop.yaml` from the config directory merged over it; `stop`/`abort` ends the run and `answer TEXT` supplies what a paused verifier asked for |
|
|
349
349
|
| `/export [local.zip]` | Download the session log ZIP to a new local file |
|
|
350
350
|
| `/export-html [local.html]` | Save the loaded conversation as offline HTML with diagrams and math |
|
|
351
351
|
| `/coredump [tag]` | Write a V8 heap snapshot to the working directory for memory diagnosis |
|
|
@@ -374,6 +374,8 @@ A command that would write to the conversation while the agent is working — `/
|
|
|
374
374
|
|
|
375
375
|
`/loop [name|stop|answer] [score] [tries]` runs one record from `loop.yaml` as a **client-driven** loop, and it is meant to be chosen rather than typed. Typing `/loop` lists every record under the composer with its rounds, artifact, declared defaults and any input it is pointed at; ↑/↓ selects, Enter confirms the highlighted record, and Tab inserts its name for anyone who wants to add flags. Confirming opens a parameter list pre-filled with that record's own inputs first — for `designdoc-review`, the `path` of the document under review — followed by the shared limits `From`, `To`, `Pass`, `Tries`; a value is replaced by typing over it, the inputs as free text and the limits as numbers checked exactly as the flags are, leaving a row with the arrows or Enter commits what was typed so no box needs its own confirmation, `Start run` launches, and `← Choose another record` goes back; nothing runs until Start is pressed, so a default is always visible before it is spent. Editing a record's input retargets that one run and leaves `loop.yaml` alone, so `designdoc-review` reviews any document and `design-review` is unaffected. Passing any flag on the line (`/loop design-review 9 3`, `--from`, `--to`, `--score`, `--tries`) skips the form and runs exactly what was typed, which keeps scripted and headless use unchanged, and a name that does not exist lists the records that do. The record itself owns the rounds, their checklists, any extra standard, its fixed inputs and the defaults: this client sends the round's brief, an independent `dsht` process verifies the work in a session of its own and writes a verdict file back, and the client decides what comes next — a score at or above the passing mark advances to the next round, a lower score costs one attempt, and a round that exhausts its budget stops the run. The shipped records are `design-review` (ten rounds over the module design) and `designdoc-review` (the same loop over the document named by the record's `vars.path`, keeping one review file per document next to it — `tui-design.md.review.md`, `loop.md.review.md` — so retargeting a run never reads or overwrites another document's rounds; that file is a template in the record, `{{path}}.review.md`, and it is gitignored). `blocked` stops the run at once instead of spending the attempt budget, a verifier that cannot judge asks for a person instead and stops it the same way (headless runs exit 3), a round is only accepted once the client itself has checked that the round's own section reached the artifact file, and `--deadline <minutes>` bounds the whole run. A `passed` run only ever claims the rounds it covered and says which ones (`rounds 1–3/10 · selected range`); a run over the whole record ends on the consolidation round, whose verifier is handed every earlier round's checklist to re-check, so a later round that broke an earlier requirement cannot pass unnoticed. A round that starts by verifying runs inside the forked verifier's own session, so the selected session's conversation stays empty until a round fails and asks the agent to work; the progress line says `verifying step N · attempt M` while that check runs, the status bar reports the same work (`◐` with the loop's step) instead of claiming Ready, and a verifier that cannot produce a verdict reports a structured reason — its exit code plus what the child itself reported (no JSON verdict in the reply, or no reply committed after the turn) rather than the class of its error output alone — instead of waiting in silence, and the verifier is handed the run's own inputs, so it cannot judge a document the artifact only mentions from an earlier run, and `--trace-verbose` adds the sanitized last line. A progress line above the composer shows the round, attempt and best score; sending an ordinary message, `/loop stop`, `/cancel`, Esc, Ctrl+C, switching sessions or losing the connection all stop the loop, which is never persisted. `/loop stop` is a control command, so it is admitted while the run is in flight; with nothing running it says so instead of failing, and the finished run's progress line stays readable — the line that ended it still shows the result, and the next line you run clears it. A verifier that cannot judge *pauses* the run instead of ending it: the progress line and the status bar say `needs you`, `/loop answer TEXT` adds the missing condition and re-judges the current artifact under a new verification identity without spending an attempt or sending the agent anything, and `/loop abort` (the paused spelling of `/loop stop`) ends the run.
|
|
376
376
|
|
|
377
|
+
The shipped `loop.yaml` is read at startup rather than compiled in, so the records are configuration: the file travels in the package, and a `loop.yaml` in the configuration directory (`$DSHT_CONFIG_DIR`, default `~/.config/dsht`) layers over it. A record with the same name **replaces** the shipped one whole, a new name adds a record, and every record your file does not name keeps following the package — which is why an upgrade still fixes shipped records you did not override. `DSHT_LOOP_FILE` points at another file instead. The record list says so: rows from your file are marked `· yours` and the file is named above them. An invalid file stops the client at startup with the path and the field at fault; a shipped file that cannot be read falls back to the records compiled into the build. Because a record you override can never be updated for you, the shipped definition's digest is stamped under the state directory and the first start after it changes reports that the shipped update is not reaching your copy — your file still wins, and nothing ever rewrites it.
|
|
378
|
+
|
|
377
379
|
A file reference sends only `@path` in a text block. Harness instructs the model to read the referenced file or list the directory when needed; the TUI does not read local files, upload bytes, or expand contents into the prompt. Referencing an image path does not attach image data. Local attachments, image uploads/previews, and `@` session references are not implemented.
|
|
378
380
|
|
|
379
381
|
Pending ordinary messages appear inside the composer, with up to two previews. `/queue` opens the full pending-input picker; ↑/↓ selects and Enter, `d`, or the dedicated Delete key removes an item through the host. Esc closes this picker without cancelling the task. A claimed item is no longer removable; the host reports that race instead of resubmitting it. The control stream owns the list, including reconnect replacement and removal when input is claimed; the client does not keep a second submission queue. Questions and approvals take precedence over queue navigation, and their answers never become steering. Slash commands keep their own execution semantics. Queue previews and deletion require the host `session/control` and `session/updateQueue` capabilities.
|
|
@@ -434,7 +436,9 @@ The default price validity starts at Beijing midnight on the verification date;
|
|
|
434
436
|
|
|
435
437
|
On first interactive launch, the client creates `~/.config/dsht/prices.json` (or `$XDG_CONFIG_HOME/dsht/prices.json`). `DSHT_CONFIG_DIR` overrides that directory. The JSON array contains price versions with `id`, `provider`, `model`, `currency: "CNY"`, `source`, inclusive `from`, optional exclusive `until`, `timezone`, weekday numbers (`0` Sunday), minute-of-day `windows`, and `peak`/`offPeak` rates named `input`, `cacheRead`, `cacheWrite`, `output`, per million tokens. To update prices, close the old interval with `until` and append a new version with a unique ID and matching `from`; overlapping intervals are rejected. Restart to load configuration changes. Price discovery is manual; the TUI does not scrape prices during startup.
|
|
436
438
|
|
|
437
|
-
Usage files live under `~/.local/state/dsht/cost/<origin-hash>/` (respecting `XDG_STATE_HOME`, or `DSHT_STATE_DIR` for the application state root). Each holds one session's folded totals: the session amount with its request and unpriced counts,
|
|
439
|
+
Usage files live under `~/.local/state/dsht/cost/<origin-hash>/` (respecting `XDG_STATE_HOME`, or `DSHT_STATE_DIR` for the application state root). Each holds one session's folded totals: the session amount with its request and unpriced counts, one bucket per Beijing calendar day the session spent inside the retained window, the decision-rules revision and a digest of the price table that produced them, and the reasons a total is inexact. They exclude prompts, tool bodies, credentials, cookies, and every per-request fact. The price file is configuration and these usage files are state, so only the former belongs in a settings backup. Writes use private temporary files and atomic replacement; each session keeps one fixed file whose recorded cut and rules revision are compared before writing, so an older scan cannot displace a newer one. The cache survives restart and does not need access to the host configuration directory. A file of another generation is ignored and rebuilt by the next scan; so is an unreadable one, because a slice is a projection of the host log rather than a system of record.
|
|
440
|
+
|
|
441
|
+
The window is 60 Beijing days. Days older than that are dropped as a slice is written, and a slice whose newest day has left the window is deleted at startup, as is any ledger file — readable or not — whose file time is older than the window, so the directory cannot grow with every session this client has ever seen; letting one go costs a rescan of that session, never data. `/cost` reports the session, today, this week (from Monday) and this month (from the 1st), each counted through the current Beijing day, and the week and month rows reach back only as far as the retained window. A scan pages a session's history only when it could have spent inside that window — a session that is running, or whose host update time is inside it — plus the session on screen, whose own total the panel always reports. Billable activity moves that update time, so usage recorded while this client was away is still read on the next connection.
|
|
438
442
|
|
|
439
443
|
## Client API
|
|
440
444
|
|
package/README.zh.md
CHANGED
|
@@ -345,7 +345,7 @@ Tab 补全开头的 slash 命令,多个候选时补到公共前缀;回车也
|
|
|
345
345
|
| `/permission [preset]` | 查看或切换服务端沙箱与审批预设 |
|
|
346
346
|
| `/feedback TEXT` | 记录当前会话反馈 |
|
|
347
347
|
| `/handoff` | 先删除本地 HANDOFF.md,再让 agent 写出新的会话交接文档 |
|
|
348
|
-
| `/loop [name\|stop\|answer] [score] [tries]` |
|
|
348
|
+
| `/loop [name\|stop\|answer] [score] [tries]` | 用列表选择循环记录(随包 `loop.yaml` 与配置目录中的 `loop.yaml` 合并),修改它启动时用的输入与限制后再运行评分循环;`stop`/`abort` 结束当前运行,`answer TEXT` 补上验证者要的判断条件 |
|
|
349
349
|
| `/export [local.zip]` | 下载会话日志 ZIP 到新的本地文件 |
|
|
350
350
|
| `/export-html [local.html]` | 将已加载会话保存为包含图表和公式的离线 HTML |
|
|
351
351
|
| `/coredump [tag]` | 在当前工作目录写出 V8 堆快照,用于内存诊断 |
|
|
@@ -374,6 +374,8 @@ Slash 命令在选择器和对话输入框中均可使用。输入 `/` 会显示
|
|
|
374
374
|
|
|
375
375
|
`/loop [name|stop|answer] [score] [tries]` 运行 `loop.yaml` 里的一条记录,是一条**客户端驱动**的循环,并且设计成"选"而不是"敲"。输入 `/loop` 会在输入框下方列出所有记录及其轮数、产出物、自带默认值和它指向的输入;↑/↓ 选择,Enter 确认高亮记录,Tab 则把记录名补进草稿以便继续加旗标。确认后会打开参数列表:**先是该记录自己的输入**(如 `designdoc-review` 的 `path`,即被审查的文档),**再是共享的四个值**(`From`、`To`、`Pass`、`Tries`);选中某行直接输入即可覆盖——输入按自由文本,四个值按数字、与旗标同一套校验;用方向键或 Enter 离开某行即提交该行的内容,因此不需要在每个框里各按一次回车;`Start run` 开始运行,`← Choose another record` 返回记录列表;只有按下 Start 才会真正启动,因此每个默认值都在被花掉之前可见。改一条记录的输入只作用于本次运行、不改 `loop.yaml`,所以 `designdoc-review` 可以审查任意文档,而 `design-review` 不受影响。若命令行里写了任一旗标(`/loop design-review 9 3`、`--from`、`--to`、`--score`、`--tries`),则跳过表单、按所写的值直接运行,脚本与 headless 用法因此保持不变;名字不存在时会列出可用记录。轮次、每轮检查要点、附加标准、固定输入与默认值都由记录自带:本客户端发出本轮 Brief,由 dsht fork 出的独立进程在自己的 session 里验证并把 verdict 文件写回,客户端据此决定下一步——达到及格线进入下一轮,低于及格线消耗一次尝试,某轮用尽预算则停止。内置记录有 `design-review`(对模块设计做十轮审查)与 `designdoc-review`(同一循环,审查记录 `vars.path` 指定的文档,并**一份文档一个审查文件、就放在它旁边**(`tui-design.md.review.md`、`loop.md.review.md`),所以换文档重跑既不会读到也不会覆盖另一份文档的轮次;这个文件名在记录里是模板 `{{path}}.review.md`,且已被 `.gitignore` 覆盖)。`status` 为 `blocked` 时立即停止、不再消耗尝试预算;验证者无法判断时会要求人工介入并以同样方式停止(headless 退出码 3);只有客户端自己核对过「本轮小节确实写进了产出物文件」的轮次才算通过;`--deadline <minutes>` 约束整个 run。`passed` 只声称本次 run 覆盖的轮次并写明是哪些(`rounds 1–3/10 · selected range`);跑完整份记录时最后一轮是收束轮,它的验证者会拿到前面每一轮的检查要点逐轮复核,因此后来某一轮改坏了前序要求不会被放过。以"先验证"开始的一轮(`starts: verify` 且有 forked verifier)跑在独立验证进程自己的 session 里,因此当前会话的 history 不会出现内容,直到某一轮未通过、才要求 agent 去工作;验证进行期间进度行显示 `verifying step N · attempt M`,状态栏也如实显示同一件事(`◐` 加本轮 `review N/M`)而不是 Ready;验证者拿不出 verdict 时会给出结构化原因——退出码 + 子进程自己报告的原因(回复里没有 JSON 判定、或回复在 turn 结束后仍未提交),而不是只给错误输出类别,更不是无声等待;同时验证者会拿到本次 run 自己的输入,因此不会去评审产出物里只属于更早那次 run 的文档,`--trace-verbose` 才会附上脱敏后的最后一行。输入框上方的进度行显示轮次、尝试与最高分;发送普通消息、`/loop stop`、`/cancel`、Esc、Ctrl+C、切换会话或断线都会停止循环,该状态不持久化:`/loop stop` 属于控制泳道,运行期间也允许提交;没有运行时它会直接说明而不是报错,且已结束运行的那行进度会保留可读——结束它的那一行仍然显示结果,而你运行下一行时它就被清掉。验证者无法判决时 run 会**暂停**而不是结束:进度行与状态栏显示 `needs you`,`/loop answer TEXT` 补上缺的判断条件、以新的 verification identity 重新判断当前产出物——不消耗尝试次数,也不给 agent 发任何东西;`/loop abort`(`/loop stop` 在暂停期的拼写)结束它。
|
|
376
376
|
|
|
377
|
+
随包发布的 `loop.yaml` 在启动时读取,而不是编译期写死,因此记录是配置:文件随包分发,配置目录(`$DSHT_CONFIG_DIR`,默认 `~/.config/dsht`)下的 `loop.yaml` 叠加其上。同名记录**整条替换**内置记录,新名字新增一条记录,你没有点名的记录继续跟随包内版本——这正是升级仍能修好你没覆盖的内置记录的原因。`DSHT_LOOP_FILE` 可改指另一个文件。记录列表会把这件事说出来:来自你的文件的行标 `· yours`,并在上方写明文件名。文件非法时启动即失败,并给出行路径与出错字段;内置文件读不到时回退到编译进包的记录。被你覆盖的那条记录无法再为你自动更新,因此其内置定义的摘要会记在 state 目录下,内置定义变化后的第一次启动会提示"内置更新没有到达你的副本"——你的文件仍然生效,且任何情况下都不会被改写。
|
|
378
|
+
|
|
377
379
|
文件引用仅在文本块中发送 `@path`。Harness 提示模型按需读取文件或列出目录;TUI 不读取本地文件、不上传字节,也不将文件内容展开进提示词。引用图片路径不会附带图片数据。尚未实现本地附件、图片上传/预览及 `@` 会话引用。
|
|
378
380
|
|
|
379
381
|
待处理的普通消息显示在输入框内,最多预览两条。`/queue` 打开完整的待处理输入列表,↑/↓ 选择,Enter、`d` 或独立 Delete 键通过服务端删除。Esc 仅关闭此列表,不取消任务。消息已被领取后无法删除,服务端会提示该竞争情况,客户端不会重新发送。控制流负责列表、重连替换及消息领取后的移除,客户端不维护第二份发送队列。问题和审批优先于队列导航,其回答不会成为转向输入;slash 命令仍按各自语义执行。队列预览和删除需要服务端提供 `session/control` 与 `session/updateQueue`。
|
|
@@ -434,7 +436,9 @@ Slash 命令在选择器和对话输入框中均可使用。输入 `/` 会显示
|
|
|
434
436
|
|
|
435
437
|
首次交互启动会创建 `~/.config/dsht/prices.json`(或 `$XDG_CONFIG_HOME/dsht/prices.json`),可用 `DSHT_CONFIG_DIR` 覆盖目录。JSON 数组中的价格版本包含 `id`、`provider`、`model`、`currency: "CNY"`、`source`、包含起点的 `from`、可选且不含终点的 `until`、`timezone`、星期数字 `weekdays`(`0` 为周日)、日内分钟区间 `windows`,以及 `peak`/`offPeak` 下每百万 token 的 `input`、`cacheRead`、`cacheWrite`、`output` 单价。调价时用 `until` 结束旧区间,再添加唯一 ID 且 `from` 衔接的新版本;程序拒绝重叠区间。重启后读取配置修改;价格由用户维护,启动时不抓取网页价格。
|
|
436
438
|
|
|
437
|
-
用量文件位于 `~/.local/state/dsht/cost/<origin-hash>/`,遵循 `XDG_STATE_HOME`,也可通过 `DSHT_STATE_DIR`
|
|
439
|
+
用量文件位于 `~/.local/state/dsht/cost/<origin-hash>/`,遵循 `XDG_STATE_HOME`,也可通过 `DSHT_STATE_DIR` 指定应用状态根目录。每个文件保存一个会话折叠后的总额:会话金额及其请求数与未计价数、该会话在保留窗口内每个自然日一个分桶、决策规则版本与该次折叠所用价格表的摘要,以及总额不精确的原因。文件不含提示词、工具正文、凭据、cookie,也不含任何逐请求信息。价格文件属于配置,这些用量文件属于状态,因此只有前者需要纳入设置备份。写入使用私有临时文件及原子替换;每个会话一个固定文件,写入前比较文件中记录的 cut 与规则版本,因此旧扫描无法覆盖较新的一次。缓存跨重启保留,不需要访问服务端配置目录。其他代数的文件会被忽略并由下一次扫描重建;无法解析的文件同样如此,因为切片是服务端日志的投影,而不是账本本身。
|
|
440
|
+
|
|
441
|
+
保留窗口是 60 个北京自然日。更早的日子在写入切片时被丢弃,最新一天已滑出窗口的切片会在启动时删除;任何账本文件——无论能否解析——只要文件时间早于窗口起点也会一并清掉,因此该目录不会随客户端见过的会话数无限增长;放掉一个切片只会让该会话被重扫一次,不会丢数据。`/cost` 显示会话、今天、本周(周一起算)与本自然月(1 日起算)四项,都统计到当前北京自然日;周与月两行只能回溯到保留窗口能覆盖的范围。扫描只在会话「可能在本窗口内产生过花费」时才翻它的历史——会话正在运行,或其服务端更新时间落在窗口内——外加当前打开的会话(面板总要报它自己的总额)。任何计费活动都会推进该更新时间,因此客户端离线期间产生的用量在下次连接时仍会被读到。
|
|
438
442
|
|
|
439
443
|
## 客户端接口
|
|
440
444
|
|
package/dist/cli/dsht.js
CHANGED
|
@@ -15,6 +15,8 @@ import { fileURLToPath } from 'node:url';
|
|
|
15
15
|
import { historyLimits } from "../session/memory.js";
|
|
16
16
|
import { ProcessVerifier } from "./verifier.js";
|
|
17
17
|
import { Controller } from "../controller/controller.js";
|
|
18
|
+
import { loadLoopSource } from "../controller/loop-source.js";
|
|
19
|
+
import { installLoopSource } from "../controller/loop-prompts.js";
|
|
18
20
|
import { endpoint } from "../transport/endpoint.js";
|
|
19
21
|
import { errorText, object, string } from "../transport/wire.js";
|
|
20
22
|
import { formatTraceSummary, summarizeTrace } from "./trace-summary.js";
|
|
@@ -51,10 +53,12 @@ With no command, choose a workspace and session interactively.
|
|
|
51
53
|
The default host is http://127.0.0.1:3080.
|
|
52
54
|
First login: export DSH_TOKEN, or export DSH_URL as the URL printed by dsh web.
|
|
53
55
|
Cookies are saved per server origin and reused on later starts. Tokens are never saved.
|
|
54
|
-
/cost shows the session and
|
|
56
|
+
/cost shows the session, day, week and month CNY estimates.
|
|
55
57
|
/prompt lists saved shortcut prompts; /prompt TEXT saves one in <state>/prompts.json.
|
|
56
58
|
!command runs on this machine, not on the host, and prints its output in the transcript.
|
|
57
59
|
DSHT_CONFIG_DIR overrides the prices.json directory; DSHT_STATE_DIR overrides usage storage.
|
|
60
|
+
The shipped loop.yaml is read at startup; a loop.yaml in the config directory adds to it, and a
|
|
61
|
+
record with the same name replaces the shipped one. DSHT_LOOP_FILE names another file instead.
|
|
58
62
|
The memory log defaults to <state>/memory.log; DSHT_MEMORY_LOG sets another path or 'off'.
|
|
59
63
|
The transition trace defaults to <state>/trace.log; DSHT_TRACE sets another path or 'off'.
|
|
60
64
|
prices.json overrides the shipped rates and is seeded on first use; every scan re-decides the
|
|
@@ -151,6 +155,15 @@ async function main() {
|
|
|
151
155
|
await ensureDirectory(config);
|
|
152
156
|
const { prices, custom } = await loadPrices(config);
|
|
153
157
|
const stateRoot = process.env.DSHT_STATE_DIR ?? join(process.env.XDG_STATE_HOME ?? join(homedir(), '.local', 'state'), 'dsht');
|
|
158
|
+
// The loop records are configuration: the shipped file is read now, a user file layered over it and
|
|
159
|
+
// the result installed before anything can list or run a record. An invalid user file stops the
|
|
160
|
+
// client here rather than running shipped records while the operator believes their own are in
|
|
161
|
+
// force; a shipped file that cannot be read falls back to the compiled-in records with a warning.
|
|
162
|
+
const loopSource = await loadLoopSource({
|
|
163
|
+
...(process.env.DSHT_LOOP_FILE === undefined ? {} : { overlayFile: process.env.DSHT_LOOP_FILE }),
|
|
164
|
+
configDirectory: config, stateDirectory: stateRoot,
|
|
165
|
+
});
|
|
166
|
+
installLoopSource(loopSource.source, loopSource.info);
|
|
154
167
|
const costDirectory = join(stateRoot, 'cost', createHash('sha256').update(new URL(url).origin).digest('hex'));
|
|
155
168
|
const costs = new CostLedger(prices, costDirectory, custom);
|
|
156
169
|
await costs.load();
|
|
@@ -202,6 +215,10 @@ async function main() {
|
|
|
202
215
|
timeoutSeconds: 3600,
|
|
203
216
|
};
|
|
204
217
|
const log = (line) => process.stderr.write(`${line}\n`);
|
|
218
|
+
// Headless runs have no record list, so the loop source's notes would otherwise never be read.
|
|
219
|
+
if (values.headless)
|
|
220
|
+
for (const warning of loopSource.info.warnings)
|
|
221
|
+
log(warning);
|
|
205
222
|
controller.start();
|
|
206
223
|
if (values.headless) {
|
|
207
224
|
// No renderer: run the plan, follow a started loop to its verdict, and report it as the exit code.
|
package/dist/contracts.d.ts
CHANGED
|
@@ -39,6 +39,26 @@ export interface SavedPrompt {
|
|
|
39
39
|
}
|
|
40
40
|
/** One panel-like surface the reader can see; the application names it, the UI renders it. */
|
|
41
41
|
export type PanelName = 'help' | 'cost' | 'status' | 'queue' | 'prompts' | 'thoughts' | 'history' | 'search' | 'model' | 'removal' | 'loop';
|
|
42
|
+
/** Where the loop records one client runs came from.
|
|
43
|
+
*
|
|
44
|
+
* The shipped file travels with the package and a user file may layer over it, so a record list is no
|
|
45
|
+
* longer one file's content: this says which files were read and which records the user's own file
|
|
46
|
+
* replaced or added. The warnings are the client's own notes — a shipped file it could not read, or a
|
|
47
|
+
* record the user overrides that the package has since changed — and the UI shows them rather than
|
|
48
|
+
* acting on them.
|
|
49
|
+
*/
|
|
50
|
+
export interface LoopSourceInfo {
|
|
51
|
+
/** Shipped `loop.yaml` that was read; absent when the compiled-in records were used instead. */
|
|
52
|
+
builtin?: string;
|
|
53
|
+
/** User file layered over the shipped records; absent when there is none. */
|
|
54
|
+
file?: string;
|
|
55
|
+
/** Shipped records the user's file replaced, in that file's order. */
|
|
56
|
+
overridden: readonly string[];
|
|
57
|
+
/** Records the user's file added. */
|
|
58
|
+
added: readonly string[];
|
|
59
|
+
/** Notes to show the operator, in the order they were discovered. */
|
|
60
|
+
warnings: readonly string[];
|
|
61
|
+
}
|
|
42
62
|
/** One `loop.yaml` record as the record list offers it.
|
|
43
63
|
*
|
|
44
64
|
* The name is what `/loop` runs; the rest is what a chooser shows about it, plus the defaults a run
|
|
@@ -64,6 +84,8 @@ export interface LoopRecord {
|
|
|
64
84
|
* document under review, a target, a threshold) is retargeted without editing `loop.yaml`.
|
|
65
85
|
*/
|
|
66
86
|
vars: Readonly<Record<string, string>>;
|
|
87
|
+
/** Set when this record came from the user's own file rather than from the shipped one. */
|
|
88
|
+
fromFile?: true;
|
|
67
89
|
}
|
|
68
90
|
/** The four numbers one loop run uses.
|
|
69
91
|
*
|
|
@@ -15,7 +15,7 @@ import { TraceLog } from './trace-log.ts';
|
|
|
15
15
|
import { PromptStore } from './prompts.ts';
|
|
16
16
|
import { type LoopLimits, type LoopProtocol } from './loop.ts';
|
|
17
17
|
import type { VerifierPort } from './verifier.ts';
|
|
18
|
-
import type { ClientActivity, ForegroundKind, ForegroundSnapshot, LoopProgress, LoopRecord, OutputSource, PeekSnapshot } from '../contracts.ts';
|
|
18
|
+
import type { ClientActivity, ForegroundKind, ForegroundSnapshot, LoopProgress, LoopRecord, LoopSourceInfo, OutputSource, PeekSnapshot } from '../contracts.ts';
|
|
19
19
|
import { SessionPeek } from '../session/peek.ts';
|
|
20
20
|
import { type ControllerStore, type State } from '../state.ts';
|
|
21
21
|
import { ShellController } from '../shell/index.ts';
|
|
@@ -147,6 +147,8 @@ export interface Queries {
|
|
|
147
147
|
readonly peek: PeekSnapshot | undefined;
|
|
148
148
|
/** Every `loop.yaml` record, so the picker can offer names and their defaults without a lookup. */
|
|
149
149
|
readonly loopRecords: readonly LoopRecord[];
|
|
150
|
+
/** Where those records came from, so the picker can say which file supplied or replaced them. */
|
|
151
|
+
readonly loopSource: LoopSourceInfo;
|
|
150
152
|
/** Why the saved prompts could not be read, when the file was malformed. */
|
|
151
153
|
readonly promptsError: string | undefined;
|
|
152
154
|
pendingCounts(): ReadonlyMap<string, number>;
|
|
@@ -17,6 +17,7 @@ import { PromptStore } from "./prompts.js";
|
|
|
17
17
|
import { latestAssistantText } from "./loop.js";
|
|
18
18
|
import { LoopCoordinator } from "./loop-coordinator.js";
|
|
19
19
|
import { loopRecords as listLoopRecords } from "./loop-protocols.js";
|
|
20
|
+
import { loopSourceInfo } from "./loop-prompts.js";
|
|
20
21
|
import { SessionPeek } from "../session/peek.js";
|
|
21
22
|
import { costAddresses } from "../cost/scanner.js";
|
|
22
23
|
import { sessionLabel } from "../session-title.js";
|
|
@@ -172,6 +173,9 @@ export class Controller {
|
|
|
172
173
|
online: () => this.state.online,
|
|
173
174
|
signal: () => this.connection.signal(),
|
|
174
175
|
publish: () => this.update({}),
|
|
176
|
+
// The session on screen is scanned whatever its age: `/cost` reports its own total, and the
|
|
177
|
+
// window filter exists to skip sessions nobody is looking at.
|
|
178
|
+
selectedSessionId: () => this.state.sessionId,
|
|
175
179
|
// The scan already reads every session's whole history; handing its pages to the session
|
|
176
180
|
// domain lets the prompt cache pick them up, so one open does not pay for a second walk.
|
|
177
181
|
scanPage: (sessionId, records) => this.session.rememberScanPage(sessionId, records),
|
|
@@ -388,6 +392,7 @@ export class Controller {
|
|
|
388
392
|
get peek() { return controller.peekSnapshot(); },
|
|
389
393
|
get activity() { return controller.activity; },
|
|
390
394
|
get loopRecords() { return listLoopRecords(); },
|
|
395
|
+
get loopSource() { return loopSourceInfo(); },
|
|
391
396
|
pendingCounts: () => controller.pendingCounts(),
|
|
392
397
|
recall: (direction, current) => controller.recall(direction, current),
|
|
393
398
|
references: (query, signal) => controller.references(query, signal),
|
|
@@ -2,7 +2,11 @@
|
|
|
2
2
|
*
|
|
3
3
|
* Kept apart from `loop-prompts.ts` so the build script can validate a YAML file before the
|
|
4
4
|
* generated module exists: this module imports nothing, while the renderer imports the generated
|
|
5
|
-
* data. The validator is the single copy of the schema — the generator
|
|
5
|
+
* data. The validator is the single copy of the schema — the generator, the runtime loader and the
|
|
6
|
+
* tests all call it, so a file that passes here is the one the renderer will read.
|
|
7
|
+
*
|
|
8
|
+
* Two entry points share one set of rules: `validateLoopPrompts` checks a whole built-in file, and
|
|
9
|
+
* `validateLoopOverlayPrompts` checks a user file that may carry only the records it changes.
|
|
6
10
|
*/
|
|
7
11
|
/** One round: the label the progress line shows, and the checklist that round is judged against. */
|
|
8
12
|
export interface LoopRoundText {
|
|
@@ -34,7 +38,7 @@ export interface LoopProtocolText {
|
|
|
34
38
|
readonly brief: readonly string[];
|
|
35
39
|
readonly followUp: readonly string[];
|
|
36
40
|
}
|
|
37
|
-
/** The whole document; the generated module
|
|
41
|
+
/** The whole document; the generated module and every parsed file are cast to this shape. */
|
|
38
42
|
export interface LoopPromptSource {
|
|
39
43
|
readonly version: number;
|
|
40
44
|
readonly defaults: {
|
|
@@ -54,3 +58,13 @@ export declare const RESERVED_PROTOCOL_NAMES: readonly string[];
|
|
|
54
58
|
* @returns One message per problem; an empty list means valid.
|
|
55
59
|
*/
|
|
56
60
|
export declare function validateLoopPrompts(source: unknown): string[];
|
|
61
|
+
/** Check a user file that layers over the shipped records.
|
|
62
|
+
*
|
|
63
|
+
* It is a whole `loop.yaml` in shape, but partial in content: an overlay that only adds one record
|
|
64
|
+
* or only moves the global defaults is exactly what it is for, so a missing or empty `protocols` is
|
|
65
|
+
* valid here. Every record it does declare is held to the same rules as a shipped one, because the
|
|
66
|
+
* renderer cannot tell the two apart once they are merged.
|
|
67
|
+
* @param source - Parsed overlay file.
|
|
68
|
+
* @returns One message per problem; an empty list means valid.
|
|
69
|
+
*/
|
|
70
|
+
export declare function validateLoopOverlayPrompts(source: unknown): string[];
|
|
@@ -2,7 +2,11 @@
|
|
|
2
2
|
*
|
|
3
3
|
* Kept apart from `loop-prompts.ts` so the build script can validate a YAML file before the
|
|
4
4
|
* generated module exists: this module imports nothing, while the renderer imports the generated
|
|
5
|
-
* data. The validator is the single copy of the schema — the generator
|
|
5
|
+
* data. The validator is the single copy of the schema — the generator, the runtime loader and the
|
|
6
|
+
* tests all call it, so a file that passes here is the one the renderer will read.
|
|
7
|
+
*
|
|
8
|
+
* Two entry points share one set of rules: `validateLoopPrompts` checks a whole built-in file, and
|
|
9
|
+
* `validateLoopOverlayPrompts` checks a user file that may carry only the records it changes.
|
|
6
10
|
*/
|
|
7
11
|
/** Placeholders every template may use; a record's own `vars` add to these. */
|
|
8
12
|
export const LOOP_PLACEHOLDERS = ['from', 'to', 'score', 'tries', 'step', 'attempt', 'title', 'artifact', 'checks'];
|
|
@@ -16,18 +20,94 @@ export const RESERVED_PROTOCOL_NAMES = ['answer', 'abort', 'stop'];
|
|
|
16
20
|
*/
|
|
17
21
|
export function validateLoopPrompts(source) {
|
|
18
22
|
const errors = [];
|
|
19
|
-
const document = source;
|
|
20
|
-
if (document
|
|
23
|
+
const document = asDocument(source);
|
|
24
|
+
if (document.version !== 1)
|
|
21
25
|
errors.push('version must be 1');
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
if (
|
|
25
|
-
errors.push('defaults.tries must be a number');
|
|
26
|
-
const protocols = document?.protocols;
|
|
27
|
-
if (protocols === null || typeof protocols !== 'object' || Object.keys(protocols ?? {}).length === 0) {
|
|
26
|
+
validateDefaults(document.defaults, errors, true);
|
|
27
|
+
const protocols = document.protocols;
|
|
28
|
+
if (protocols === null || typeof protocols !== 'object' || Array.isArray(protocols) || Object.keys(protocols ?? {}).length === 0) {
|
|
28
29
|
errors.push('protocols must be a non-empty mapping');
|
|
29
30
|
return errors;
|
|
30
31
|
}
|
|
32
|
+
validateProtocols(protocols, errors);
|
|
33
|
+
return errors;
|
|
34
|
+
}
|
|
35
|
+
/** Check a user file that layers over the shipped records.
|
|
36
|
+
*
|
|
37
|
+
* It is a whole `loop.yaml` in shape, but partial in content: an overlay that only adds one record
|
|
38
|
+
* or only moves the global defaults is exactly what it is for, so a missing or empty `protocols` is
|
|
39
|
+
* valid here. Every record it does declare is held to the same rules as a shipped one, because the
|
|
40
|
+
* renderer cannot tell the two apart once they are merged.
|
|
41
|
+
* @param source - Parsed overlay file.
|
|
42
|
+
* @returns One message per problem; an empty list means valid.
|
|
43
|
+
*/
|
|
44
|
+
export function validateLoopOverlayPrompts(source) {
|
|
45
|
+
const errors = [];
|
|
46
|
+
const document = asDocument(source);
|
|
47
|
+
// A version is required even from an overlay: a file written for a later shape must fail loudly
|
|
48
|
+
// instead of having its fields read as this shape's, which is how a silent misread becomes a run
|
|
49
|
+
// judged against a rubric nobody wrote.
|
|
50
|
+
if (document.version !== 1)
|
|
51
|
+
errors.push('version must be 1');
|
|
52
|
+
validateDefaults(document.defaults, errors, false);
|
|
53
|
+
const protocols = document.protocols;
|
|
54
|
+
if (protocols === undefined || protocols === null)
|
|
55
|
+
return errors;
|
|
56
|
+
if (typeof protocols !== 'object' || Array.isArray(protocols)) {
|
|
57
|
+
errors.push('protocols must be a mapping');
|
|
58
|
+
return errors;
|
|
59
|
+
}
|
|
60
|
+
validateProtocols(protocols, errors);
|
|
61
|
+
return errors;
|
|
62
|
+
}
|
|
63
|
+
/** A parsed document with every unsafe value normalized to `undefined`.
|
|
64
|
+
* @param source - Anything a caller parsed out of a file.
|
|
65
|
+
* @returns The document, or an empty one for a null, scalar or array root.
|
|
66
|
+
*/
|
|
67
|
+
function asDocument(source) {
|
|
68
|
+
return source !== null && typeof source === 'object' && !Array.isArray(source) ? source : {};
|
|
69
|
+
}
|
|
70
|
+
/** Check the global defaults.
|
|
71
|
+
*
|
|
72
|
+
* A shipped file must state them, because every record falls back to them. An overlay may state
|
|
73
|
+
* neither, one or both, but a value it does state reaches real runs, so it is checked for range
|
|
74
|
+
* even though the shipped file only has to be numeric.
|
|
75
|
+
* @param defaults - The `defaults` field as parsed.
|
|
76
|
+
* @param errors - Collector to append messages to.
|
|
77
|
+
* @param required - Whether this document must carry both numbers.
|
|
78
|
+
*/
|
|
79
|
+
function validateDefaults(defaults, errors, required) {
|
|
80
|
+
if (defaults === undefined || defaults === null) {
|
|
81
|
+
if (required) {
|
|
82
|
+
errors.push('defaults.score must be a number');
|
|
83
|
+
errors.push('defaults.tries must be a number');
|
|
84
|
+
}
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (typeof defaults !== 'object' || Array.isArray(defaults)) {
|
|
88
|
+
errors.push('defaults must be a mapping');
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const { score, tries } = defaults;
|
|
92
|
+
if (required) {
|
|
93
|
+
if (!Number.isFinite(score))
|
|
94
|
+
errors.push('defaults.score must be a number');
|
|
95
|
+
if (!Number.isFinite(tries))
|
|
96
|
+
errors.push('defaults.tries must be a number');
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (score !== undefined && (!Number.isFinite(score) || score < 0 || score > 10)) {
|
|
100
|
+
errors.push('defaults.score must be a number in 0-10');
|
|
101
|
+
}
|
|
102
|
+
if (tries !== undefined && (!Number.isInteger(tries) || tries < 1)) {
|
|
103
|
+
errors.push('defaults.tries must be a positive integer');
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/** Check every record of one document against the rules the renderer relies on.
|
|
107
|
+
* @param protocols - The `protocols` mapping.
|
|
108
|
+
* @param errors - Collector to append messages to.
|
|
109
|
+
*/
|
|
110
|
+
function validateProtocols(protocols, errors) {
|
|
31
111
|
const runtime = new Set(LOOP_PLACEHOLDERS);
|
|
32
112
|
const reserved = new Set(RESERVED_PROTOCOL_NAMES);
|
|
33
113
|
const placeholders = (text) => [...text.matchAll(/\{\{(\w+)\}\}/g)].map(match => match[1]);
|
|
@@ -36,7 +116,7 @@ export function validateLoopPrompts(source) {
|
|
|
36
116
|
if (reserved.has(kind))
|
|
37
117
|
errors.push(`${at} is a reserved name: ${RESERVED_PROTOCOL_NAMES.join(', ')} belong to /loop itself`);
|
|
38
118
|
// A template may name a runtime value or one of this record's own vars, and nothing else.
|
|
39
|
-
const vars = protocol
|
|
119
|
+
const vars = protocol?.vars;
|
|
40
120
|
if (vars !== undefined && (vars === null || typeof vars !== 'object' || Array.isArray(vars))) {
|
|
41
121
|
errors.push(`${at}.vars must be a mapping of names to strings`);
|
|
42
122
|
}
|
|
@@ -55,15 +135,15 @@ export function validateLoopPrompts(source) {
|
|
|
55
135
|
if (!allowed.has(name))
|
|
56
136
|
errors.push(`${where}: unknown placeholder {{${name}}}`);
|
|
57
137
|
};
|
|
58
|
-
checkPlaceholders(typeof protocol
|
|
59
|
-
if (typeof protocol
|
|
138
|
+
checkPlaceholders(typeof protocol?.title === 'string' ? protocol.title : '', `${at}.title`);
|
|
139
|
+
if (typeof protocol?.title !== 'string' || protocol.title === '')
|
|
60
140
|
errors.push(`${at}.title must be a non-empty string`);
|
|
61
|
-
if (!Number.isInteger(protocol
|
|
141
|
+
if (!Number.isInteger(protocol?.steps) || (protocol?.steps ?? 0) < 1)
|
|
62
142
|
errors.push(`${at}.steps must be a positive integer`);
|
|
63
|
-
if (protocol
|
|
143
|
+
if (protocol?.artifact !== undefined && (typeof protocol.artifact !== 'string' || protocol.artifact === '')) {
|
|
64
144
|
errors.push(`${at}.artifact must be a non-empty string when present`);
|
|
65
145
|
}
|
|
66
|
-
else if (typeof protocol
|
|
146
|
+
else if (typeof protocol?.artifact === 'string') {
|
|
67
147
|
// The file name may use the record's vars — that is how one run per input gets its own file —
|
|
68
148
|
// but never a runtime placeholder: the artifact must not move between rounds.
|
|
69
149
|
for (const name of placeholders(protocol.artifact)) {
|
|
@@ -72,7 +152,7 @@ export function validateLoopPrompts(source) {
|
|
|
72
152
|
}
|
|
73
153
|
}
|
|
74
154
|
}
|
|
75
|
-
if (protocol
|
|
155
|
+
if (protocol?.artifactMarker !== undefined) {
|
|
76
156
|
if (typeof protocol.artifactMarker !== 'string' || protocol.artifactMarker.trim() === '') {
|
|
77
157
|
errors.push(`${at}.artifactMarker must be a non-empty string when present`);
|
|
78
158
|
}
|
|
@@ -84,28 +164,28 @@ export function validateLoopPrompts(source) {
|
|
|
84
164
|
errors.push(`${at}.artifactMarker needs ${at}.artifact: there is no file to look in`);
|
|
85
165
|
}
|
|
86
166
|
}
|
|
87
|
-
if (typeof protocol
|
|
167
|
+
if (typeof protocol?.fallbackLabel !== 'string' || protocol.fallbackLabel === '')
|
|
88
168
|
errors.push(`${at}.fallbackLabel must be a non-empty string`);
|
|
89
|
-
if (protocol
|
|
169
|
+
if (protocol?.standard !== undefined && (typeof protocol.standard !== 'string' || protocol.standard.trim() === '')) {
|
|
90
170
|
errors.push(`${at}.standard must be a non-empty string when present`);
|
|
91
171
|
}
|
|
92
|
-
if (protocol
|
|
172
|
+
if (protocol?.starts !== undefined && protocol.starts !== 'verify' && protocol.starts !== 'work') {
|
|
93
173
|
errors.push(`${at}.starts must be 'verify' or 'work'`);
|
|
94
174
|
}
|
|
95
|
-
if (protocol
|
|
175
|
+
if (protocol?.defaults !== undefined) {
|
|
96
176
|
const { score, tries } = protocol.defaults;
|
|
97
177
|
if (score !== undefined && (!Number.isFinite(score) || score < 0 || score > 10))
|
|
98
178
|
errors.push(`${at}.defaults.score must be a number in 0-10`);
|
|
99
179
|
if (tries !== undefined && (!Number.isInteger(tries) || tries < 1))
|
|
100
180
|
errors.push(`${at}.defaults.tries must be a positive integer`);
|
|
101
181
|
}
|
|
102
|
-
const rounds = protocol
|
|
182
|
+
const rounds = protocol?.rounds;
|
|
103
183
|
if (!Array.isArray(rounds)) {
|
|
104
184
|
errors.push(`${at}.rounds must be a list`);
|
|
105
185
|
}
|
|
106
186
|
else {
|
|
107
|
-
if (rounds.length > 0 && rounds.length !== protocol
|
|
108
|
-
errors.push(`${at}.rounds has ${rounds.length} entries but steps is ${protocol
|
|
187
|
+
if (rounds.length > 0 && rounds.length !== protocol?.steps) {
|
|
188
|
+
errors.push(`${at}.rounds has ${rounds.length} entries but steps is ${protocol?.steps}`);
|
|
109
189
|
}
|
|
110
190
|
rounds.forEach((round, index) => {
|
|
111
191
|
if (typeof round?.title !== 'string' || round.title === '')
|
|
@@ -115,7 +195,7 @@ export function validateLoopPrompts(source) {
|
|
|
115
195
|
});
|
|
116
196
|
}
|
|
117
197
|
for (const key of ['brief', 'followUp']) {
|
|
118
|
-
const lines = protocol[key];
|
|
198
|
+
const lines = protocol?.[key];
|
|
119
199
|
if (!Array.isArray(lines) || lines.length === 0) {
|
|
120
200
|
errors.push(`${at}.${key} must be a non-empty list`);
|
|
121
201
|
continue;
|
|
@@ -128,17 +208,16 @@ export function validateLoopPrompts(source) {
|
|
|
128
208
|
checkPlaceholders(line, `${at}.${key}[${index}]`);
|
|
129
209
|
});
|
|
130
210
|
}
|
|
131
|
-
const checksLines = (protocol
|
|
211
|
+
const checksLines = (protocol?.brief ?? []).filter(line => typeof line === 'string' && line.trim() === '{{checks}}');
|
|
132
212
|
if ((rounds?.length ?? 0) > 0 && checksLines.length !== 1)
|
|
133
213
|
errors.push(`${at}.brief must contain exactly one line that is just {{checks}}`);
|
|
134
214
|
if ((rounds?.length ?? 0) === 0 && checksLines.length > 0)
|
|
135
215
|
errors.push(`${at}.brief uses {{checks}} but defines no rounds`);
|
|
136
|
-
if (protocol
|
|
216
|
+
if (protocol?.verifyFocus !== undefined) {
|
|
137
217
|
if (typeof protocol.verifyFocus !== 'string' || protocol.verifyFocus === '')
|
|
138
218
|
errors.push(`${at}.verifyFocus must be a non-empty string when present`);
|
|
139
219
|
else
|
|
140
220
|
checkPlaceholders(protocol.verifyFocus, `${at}.verifyFocus`);
|
|
141
221
|
}
|
|
142
222
|
}
|
|
143
|
-
return errors;
|
|
144
223
|
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { LoopSourceInfo } from '../contracts.ts';
|
|
2
|
+
import type { LoopPromptSource } from './loop-prompts-schema.ts';
|
|
1
3
|
export type { LoopPromptSource, LoopProtocolText, LoopRoundText } from './loop-prompts-schema.ts';
|
|
2
4
|
/** Values one render call supplies; a record's `vars` are merged in on top of these. */
|
|
3
5
|
export interface LoopPromptValues {
|
|
@@ -49,7 +51,20 @@ export interface LoopPrompts {
|
|
|
49
51
|
/** One record's declared `vars`, unrendered, so a form can offer them before a run starts. */
|
|
50
52
|
vars(kind: string): Readonly<Record<string, string>>;
|
|
51
53
|
}
|
|
52
|
-
/**
|
|
53
|
-
*
|
|
54
|
+
/** Put the records this process runs in place, before any run or record list reads them.
|
|
55
|
+
*
|
|
56
|
+
* The files are read and merged at startup rather than at module load, so a bad user file can be
|
|
57
|
+
* reported and refused before the client opens, and a run keeps the rubric it started with even if
|
|
58
|
+
* the file is edited underneath it.
|
|
59
|
+
* @param source - Merged records: shipped ones with the user's file layered on top.
|
|
60
|
+
* @param sourceInfo - Where they came from, and what the user's file changed.
|
|
61
|
+
*/
|
|
62
|
+
export declare function installLoopSource(source: LoopPromptSource, sourceInfo: LoopSourceInfo): void;
|
|
63
|
+
/** Where the installed records came from.
|
|
64
|
+
* @returns The installed source info; the compiled-in records are the empty default.
|
|
65
|
+
*/
|
|
66
|
+
export declare function loopSourceInfo(): LoopSourceInfo;
|
|
67
|
+
/** The records in force.
|
|
68
|
+
* @returns Names, lookups and the declared vars of each record, built once per installed source.
|
|
54
69
|
*/
|
|
55
70
|
export declare function loopPrompts(): LoopPrompts;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// GENERATED FILE — do not edit. Edit loop.yaml and run `npm run build:prompts`.
|
|
2
|
-
//
|
|
2
|
+
// The fallback table: loop.yaml is read at runtime, and this is what a package whose file is
|
|
3
|
+
// missing or unreadable starts on. Kept in sync by tests/controller/loop-prompts.test.ts.
|
|
3
4
|
export const LOOP_PROMPTS = {
|
|
4
5
|
"version": 1,
|
|
5
6
|
"defaults": {
|
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
/** Typed access to the loop prompts and the placeholder renderer.
|
|
2
2
|
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
3
|
+
* The loop record is configuration, not code: the shipped `loop.yaml` is read at startup from beside
|
|
4
|
+
* the package and a user file may be layered over it (`loop-source.ts`), then the merged table is
|
|
5
|
+
* installed here once. `loop-prompts-schema.ts` holds the shape and the rules, and
|
|
6
|
+
* `loop-prompts.generated.ts` is the compiled-in fallback for a package whose file is missing. This
|
|
7
|
+
* module turns one record into the strings a protocol needs, so the dynamic parts (this round's title,
|
|
8
|
+
* its checklist, the record's own vars) are filled here and nowhere else. A template may only use
|
|
9
|
+
* placeholders the caller can supply; anything else throws rather than putting a literal `{{name}}`
|
|
10
|
+
* into a prompt.
|
|
8
11
|
*/
|
|
9
12
|
import { LOOP_PROMPTS } from "./loop-prompts.generated.js";
|
|
10
13
|
const PLACEHOLDER = /\{\{(\w+)\}\}/g;
|
|
11
|
-
|
|
14
|
+
/** The records in force: the shipped file with the user's layered on top, installed once at startup
|
|
15
|
+
* and replaced only by another install, never by a reload while a run is in flight. */
|
|
16
|
+
let SOURCE = LOOP_PROMPTS;
|
|
12
17
|
/** Replace every `{{name}}` in one template list.
|
|
13
18
|
* @param lines - Template lines.
|
|
14
19
|
* @param values - Values keyed by placeholder name.
|
|
@@ -75,10 +80,31 @@ function render(kind, protocol, overrides) {
|
|
|
75
80
|
followUp: runtime => fill(protocol.followUp, values(runtime), kind),
|
|
76
81
|
};
|
|
77
82
|
}
|
|
78
|
-
/** All records, rendered
|
|
83
|
+
/** All records, rendered when the source is installed: a file edited mid-run cannot change a brief. */
|
|
79
84
|
let cache;
|
|
80
|
-
/**
|
|
81
|
-
|
|
85
|
+
/** Where the installed records came from, for the record list to show. */
|
|
86
|
+
let info = { overridden: [], added: [], warnings: [] };
|
|
87
|
+
/** Put the records this process runs in place, before any run or record list reads them.
|
|
88
|
+
*
|
|
89
|
+
* The files are read and merged at startup rather than at module load, so a bad user file can be
|
|
90
|
+
* reported and refused before the client opens, and a run keeps the rubric it started with even if
|
|
91
|
+
* the file is edited underneath it.
|
|
92
|
+
* @param source - Merged records: shipped ones with the user's file layered on top.
|
|
93
|
+
* @param sourceInfo - Where they came from, and what the user's file changed.
|
|
94
|
+
*/
|
|
95
|
+
export function installLoopSource(source, sourceInfo) {
|
|
96
|
+
SOURCE = source;
|
|
97
|
+
info = { ...sourceInfo, overridden: [...sourceInfo.overridden], added: [...sourceInfo.added], warnings: [...sourceInfo.warnings] };
|
|
98
|
+
cache = undefined;
|
|
99
|
+
}
|
|
100
|
+
/** Where the installed records came from.
|
|
101
|
+
* @returns The installed source info; the compiled-in records are the empty default.
|
|
102
|
+
*/
|
|
103
|
+
export function loopSourceInfo() {
|
|
104
|
+
return info;
|
|
105
|
+
}
|
|
106
|
+
/** The records in force.
|
|
107
|
+
* @returns Names, lookups and the declared vars of each record, built once per installed source.
|
|
82
108
|
*/
|
|
83
109
|
export function loopPrompts() {
|
|
84
110
|
if (cache !== undefined)
|
|
@@ -5,7 +5,9 @@ export declare function loopProtocolNames(): string[];
|
|
|
5
5
|
/** Every record `/loop` may run, in file order, with the defaults a run would start from.
|
|
6
6
|
*
|
|
7
7
|
* The list and the run read the same records, so a chooser can show exactly the name, round count,
|
|
8
|
-
* artifact and defaults the runner would use — never a second table that could drift.
|
|
8
|
+
* artifact and defaults the runner would use — never a second table that could drift. A record the
|
|
9
|
+
* user's own file supplied is marked, because an operator who overrode a shipped record can no longer
|
|
10
|
+
* tell the two apart from the name alone.
|
|
9
11
|
* @returns One summary per record.
|
|
10
12
|
*/
|
|
11
13
|
export declare function loopRecords(): LoopRecord[];
|