@itookit/dsht 0.6.0 → 0.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/README.zh.md +3 -3
- package/dist/cli/dsht.js +5 -7
- package/dist/contracts.d.ts +3 -6
- package/dist/controller/controller.js +3 -1
- package/dist/controller/loop-prompts-schema.d.ts +3 -5
- package/dist/controller/loop-prompts-schema.js +3 -5
- package/dist/controller/loop-prompts.js +3 -3
- package/dist/controller/loop-protocols.d.ts +1 -2
- package/dist/controller/loop-protocols.js +1 -2
- package/dist/controller/loop-source.d.ts +8 -30
- package/dist/controller/loop-source.js +103 -132
- package/dist/cost/ledger.d.ts +6 -0
- package/dist/cost/ledger.js +16 -1
- package/dist/cost/pricing.d.ts +1 -1
- package/dist/cost/pricing.js +12 -4
- package/dist/cost/types.d.ts +1 -1
- package/dist/session/controller.d.ts +1 -1
- package/dist/session/navigator.d.ts +1 -1
- package/dist/session/navigator.js +3 -1
- package/dist/ui/dialogs/loop.js +1 -2
- package/loop.yaml +8 -11
- package/package.json +2 -1
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 loop record from a list, edit the inputs and limits it starts from, then run the scored loop; records are the
|
|
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 read from the config directory's `loop.yaml`, created and merged from the shipped file; `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,7 +374,7 @@ 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
|
|
377
|
+
The runtime `loop.yaml` lives in the configuration directory (`$DSHT_CONFIG_DIR`, default `~/.config/dsht`). On first startup the client copies the shipped file there. On later starts it merges new or changed shipped records into that file while preserving records and defaults you edited; a same-name record is kept whole. The last merged shipped digests are stored beside it in `loop.yaml.seed.json`. `DSHT_LOOP_FILE` selects another runtime file, which is created and merged in the same way. The record list marks your edits and additions `· yours`. An invalid runtime file stops startup with its path and the faulty field; an unreadable shipped file falls back to the compiled-in records. If a shipped record changes while your edited copy remains, the first start after the update warns that your copy did not receive it.
|
|
378
378
|
|
|
379
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.
|
|
380
380
|
|
|
@@ -430,7 +430,7 @@ History separates semantic message blocks, prompt/reasoning summaries, view-only
|
|
|
430
430
|
|
|
431
431
|
The client reads complete histories in the background on connection, every 60 seconds, at turn completion, and when opening `/cost`. Every new connection re-reads every session once, because the host keeps running while this client is disconnected and a recorded update time cannot show what happened during the gap; within one connection, idle sessions whose host update timestamp has not moved are skipped. No model requests are made by billing. Esc or Ctrl+C cancels an explicit refresh. The ledger counts disjoint uncached input, cache read/write and output buckets; reasoning is already part of output. Retries count separately, replacement samples update their attempt, and fork-inherited history is excluded. A request that reports no usable usage, no settlement timestamp, or a cache-write bucket the official table does not price is counted as unpriced instead of being guessed at, and a model no entry covers stays unpriced; an unlisted provider is never billed from the official table. Every listed session is read independently, so one unreachable or rejected session is reported as a failure count instead of stopping the scan, and a subagent child is read under its durable parent address. Failed scans retain labelled partial cached totals.
|
|
432
432
|
|
|
433
|
-
The bundled CNY rates were checked against the [official pricing page](https://api-docs.deepseek.com/zh-cn/quick_start/pricing/) on 2026-09-
|
|
433
|
+
The bundled CNY rates were checked against the [official pricing page](https://api-docs.deepseek.com/zh-cn/quick_start/pricing/) on 2026-09-24. Beijing peak windows are 09:00–12:00 and 14:00–18:00 on weekdays excluding Chinese public holidays; other times use half-price rates. Holiday dates come from the installed `workday-cn` package, which must be updated when a new year's official schedule is published. Flash peak cache-miss-input/cache-hit-input/output rates are ¥2/¥0.04/¥8 per million tokens and Pro rates are ¥9/¥0.30/¥27. The current Flash model is `deepseek-flash`, and the listed older Flash aliases keep its rates. DeepSeek [continues to bill V4 Pro at its own rates](https://api-docs.deepseek.com/zh-cn/updates/) after September 14. Separate cache writes use the uncached-input rate. Only explicitly listed models and aliases are priced; other providers require their own entries.
|
|
434
434
|
|
|
435
435
|
The default price validity starts at Beijing midnight on the verification date; this is a local estimate policy, not a claim about the official effective date. Earlier usage needs historical price entries. The recorded assistant settlement timestamp selects the rate; requests spanning a tariff boundary may differ from the invoice because the official page does not specify their attribution. Images use provider-reported tokens. A scan folds the history again with the table it holds, so a stored total is a projection of the log and the table: editing `prices.json` re-prices the requests it covers on the next scan, and a request no entry covered is priced as soon as one does.
|
|
436
436
|
|
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` 读取),修改它启动时用的输入与限制后再运行评分循环;`stop`/`abort` 结束当前运行,`answer TEXT` 补上验证者要的判断条件 |
|
|
349
349
|
| `/export [local.zip]` | 下载会话日志 ZIP 到新的本地文件 |
|
|
350
350
|
| `/export-html [local.html]` | 将已加载会话保存为包含图表和公式的离线 HTML |
|
|
351
351
|
| `/coredump [tag]` | 在当前工作目录写出 V8 堆快照,用于内存诊断 |
|
|
@@ -374,7 +374,7 @@ 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
|
-
|
|
377
|
+
运行时的 `loop.yaml` 位于配置目录(`$DSHT_CONFIG_DIR`,默认 `~/.config/dsht`)。首次启动时客户端把随包文件复制到该目录;以后每次启动,把新增或改过的内置记录合入该文件,同时保留你修改过的同名记录与默认值。同名记录作为整体保留,不做字段级拼接。上次合并的内置摘要放在同目录的 `loop.yaml.seed.json`。`DSHT_LOOP_FILE` 可改指另一个运行文件,同样会自动创建并合并。记录列表用 `· yours` 标注你的修改和新增项。运行文件非法时启动失败并指出路径和字段;随包文件不可读时回退到编译进包的记录。若内置记录更新但你的版本仍在使用,升级后的首次启动会提示这次更新没有进入你的副本。
|
|
378
378
|
|
|
379
379
|
文件引用仅在文本块中发送 `@path`。Harness 提示模型按需读取文件或列出目录;TUI 不读取本地文件、不上传字节,也不将文件内容展开进提示词。引用图片路径不会附带图片数据。尚未实现本地附件、图片上传/预览及 `@` 会话引用。
|
|
380
380
|
|
|
@@ -430,7 +430,7 @@ Slash 命令在选择器和对话输入框中均可使用。输入 `/` 会显示
|
|
|
430
430
|
|
|
431
431
|
客户端每次连接后、每 60 秒、任务结束及打开 `/cost` 时在后台通过 HTTP 读取完整历史;每个新连接都会把全部会话完整重读一遍,因为客户端不在时服务端仍在工作,已记录的更新时间无法说明这段空档里发生了什么;同一次连接内,服务端更新时间未变的空闲会话跳过扫描。计费不会发起模型请求。显式刷新时可按 Esc 或 Ctrl+C 取消。账本分别统计未缓存输入、缓存读/写和输出,思考 token 已包含在输出中。重试单独计费,同一次尝试的替换用量更新原记录,fork 继承历史不重复计费。没有可用用量、没有结算时间戳,或带官方价目表并不定价的缓存写入桶的请求只计入未计价而不猜测;没有条目覆盖的模型保持未计价,未列出的供应商不会套用官方价目。每个会话独立读取:某个会话不可达或被拒绝时只计为失败数量并继续扫描,不会中止整轮;子代理会话按其持久父级地址读取。扫描失败保留并标明部分缓存结果。
|
|
432
432
|
|
|
433
|
-
内置人民币价格于 2026-09-
|
|
433
|
+
内置人民币价格于 2026-09-24 根据[官方价格页](https://api-docs.deepseek.com/zh-cn/quick_start/pricing/)核对。北京时间周一至周五(不含中国法定节假日)09:00–12:00、14:00–18:00 为高峰,其余时段半价。节假日日期来自安装的 `workday-cn` 包;国务院公布新年度安排后需要更新此依赖。Flash 高峰未命中输入/缓存命中输入/输出为每百万 token ¥2/¥0.04/¥8,Pro 为 ¥9/¥0.30/¥27;当前 Flash 模型名为 `deepseek-flash`,列出的旧 Flash 别名沿用同一费率。根据[官方更新日志](https://api-docs.deepseek.com/zh-cn/updates/),9 月 14 日之后 V4 Pro 继续按自身费率计费。单列的缓存写入按未命中输入价计算。仅对价格表明确列出的模型与别名计价;其他供应商需要显式配置。
|
|
434
434
|
|
|
435
435
|
默认价格有效期从核对日期的北京时间零点开始,这是本地估算规则,不代表官方价格生效日期。更早用量需要补充历史价格版本。程序按助手请求结算记录的时间选择单价;官方未说明跨时段请求的归属,因此边界附近的估算可能与账单不同。图片使用供应商报告的 token 数。每次扫描都按当前加载的价格表重新折叠历史,因此落盘的总额是日志与价格表的投影:修改 `prices.json` 会在下一次扫描时重新计价它覆盖的请求,此前没有条目覆盖的请求则在出现覆盖后立即计价。
|
|
436
436
|
|
package/dist/cli/dsht.js
CHANGED
|
@@ -57,8 +57,8 @@ Cookies are saved per server origin and reused on later starts. Tokens are never
|
|
|
57
57
|
/prompt lists saved shortcut prompts; /prompt TEXT saves one in <state>/prompts.json.
|
|
58
58
|
!command runs on this machine, not on the host, and prints its output in the transcript.
|
|
59
59
|
DSHT_CONFIG_DIR overrides the prices.json directory; DSHT_STATE_DIR overrides usage storage.
|
|
60
|
-
The
|
|
61
|
-
|
|
60
|
+
The config directory's loop.yaml is created from the shipped file when absent. On startup,
|
|
61
|
+
unedited shipped records are merged into it. DSHT_LOOP_FILE names another runtime file instead.
|
|
62
62
|
The memory log defaults to <state>/memory.log; DSHT_MEMORY_LOG sets another path or 'off'.
|
|
63
63
|
The transition trace defaults to <state>/trace.log; DSHT_TRACE sets another path or 'off'.
|
|
64
64
|
prices.json overrides the shipped rates and is seeded on first use; every scan re-decides the
|
|
@@ -155,13 +155,11 @@ async function main() {
|
|
|
155
155
|
await ensureDirectory(config);
|
|
156
156
|
const { prices, custom } = await loadPrices(config);
|
|
157
157
|
const stateRoot = process.env.DSHT_STATE_DIR ?? join(process.env.XDG_STATE_HOME ?? join(homedir(), '.local', 'state'), 'dsht');
|
|
158
|
-
//
|
|
159
|
-
//
|
|
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.
|
|
158
|
+
// Materialize the merged config file before installing the runtime source. An invalid user file
|
|
159
|
+
// stops startup; a missing or invalid shipped file uses the compiled-in fallback with a warning.
|
|
162
160
|
const loopSource = await loadLoopSource({
|
|
163
161
|
...(process.env.DSHT_LOOP_FILE === undefined ? {} : { overlayFile: process.env.DSHT_LOOP_FILE }),
|
|
164
|
-
configDirectory: config,
|
|
162
|
+
configDirectory: config,
|
|
165
163
|
});
|
|
166
164
|
installLoopSource(loopSource.source, loopSource.info);
|
|
167
165
|
const costDirectory = join(stateRoot, 'cost', createHash('sha256').update(new URL(url).origin).digest('hex'));
|
package/dist/contracts.d.ts
CHANGED
|
@@ -41,16 +41,13 @@ export interface SavedPrompt {
|
|
|
41
41
|
export type PanelName = 'help' | 'cost' | 'status' | 'queue' | 'prompts' | 'thoughts' | 'history' | 'search' | 'model' | 'removal' | 'loop';
|
|
42
42
|
/** Where the loop records one client runs came from.
|
|
43
43
|
*
|
|
44
|
-
* The
|
|
45
|
-
*
|
|
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.
|
|
44
|
+
* The runtime file is in the configuration directory. Shipped updates are merged into that file;
|
|
45
|
+
* this says which records the operator changed or added and reports any missed shipped update.
|
|
49
46
|
*/
|
|
50
47
|
export interface LoopSourceInfo {
|
|
51
48
|
/** Shipped `loop.yaml` that was read; absent when the compiled-in records were used instead. */
|
|
52
49
|
builtin?: string;
|
|
53
|
-
/**
|
|
50
|
+
/** Runtime file read after creation and merging. */
|
|
54
51
|
file?: string;
|
|
55
52
|
/** Shipped records the user's file replaced, in that file's order. */
|
|
56
53
|
overridden: readonly string[];
|
|
@@ -927,7 +927,9 @@ export class Controller {
|
|
|
927
927
|
/** Create a session in the selected workspace. */
|
|
928
928
|
async createSession(signal) {
|
|
929
929
|
this.traceEvent('action', { action: 'createSession', workspace: this.state.workspaceId ?? 'none' });
|
|
930
|
-
await this.session.createSession(signal);
|
|
930
|
+
const sessionId = await this.session.createSession(signal);
|
|
931
|
+
this.costs?.seedNewSession(sessionId);
|
|
932
|
+
this.update({});
|
|
931
933
|
}
|
|
932
934
|
/** Replace the selected transcript and follow the session.
|
|
933
935
|
* @param sessionId - Session to follow.
|
|
@@ -58,12 +58,10 @@ export declare const RESERVED_PROTOCOL_NAMES: readonly string[];
|
|
|
58
58
|
* @returns One message per problem; an empty list means valid.
|
|
59
59
|
*/
|
|
60
60
|
export declare function validateLoopPrompts(source: unknown): string[];
|
|
61
|
-
/** Check
|
|
61
|
+
/** Check an existing runtime file before merging shipped records into it.
|
|
62
62
|
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
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.
|
|
63
|
+
* Older user files may be partial: one record or one changed default is valid, so a missing or empty
|
|
64
|
+
* `protocols` is accepted here. Every declared record follows the shipped record rules.
|
|
67
65
|
* @param source - Parsed overlay file.
|
|
68
66
|
* @returns One message per problem; an empty list means valid.
|
|
69
67
|
*/
|
|
@@ -32,12 +32,10 @@ export function validateLoopPrompts(source) {
|
|
|
32
32
|
validateProtocols(protocols, errors);
|
|
33
33
|
return errors;
|
|
34
34
|
}
|
|
35
|
-
/** Check
|
|
35
|
+
/** Check an existing runtime file before merging shipped records into it.
|
|
36
36
|
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
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.
|
|
37
|
+
* Older user files may be partial: one record or one changed default is valid, so a missing or empty
|
|
38
|
+
* `protocols` is accepted here. Every declared record follows the shipped record rules.
|
|
41
39
|
* @param source - Parsed overlay file.
|
|
42
40
|
* @returns One message per problem; an empty list means valid.
|
|
43
41
|
*/
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/** Typed access to the loop prompts and the placeholder renderer.
|
|
2
2
|
*
|
|
3
|
-
* The loop record is configuration
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* The loop record is configuration: `loop-source.ts` creates or merges the config directory's
|
|
4
|
+
* `loop.yaml` from the shipped table, then reads that runtime file and installs its table here once.
|
|
5
|
+
* `loop-prompts-schema.ts` holds the shape and the rules, and
|
|
6
6
|
* `loop-prompts.generated.ts` is the compiled-in fallback for a package whose file is missing. This
|
|
7
7
|
* module turns one record into the strings a protocol needs, so the dynamic parts (this round's title,
|
|
8
8
|
* its checklist, the record's own vars) are filled here and nowhere else. A template may only use
|
|
@@ -6,8 +6,7 @@ export declare function loopProtocolNames(): string[];
|
|
|
6
6
|
*
|
|
7
7
|
* The list and the run read the same records, so a chooser can show exactly the name, round count,
|
|
8
8
|
* artifact and defaults the runner would use — never a second table that could drift. A record the
|
|
9
|
-
*
|
|
10
|
-
* tell the two apart from the name alone.
|
|
9
|
+
* operator edited is marked, so a shipped update can be distinguished from a local override.
|
|
11
10
|
* @returns One summary per record.
|
|
12
11
|
*/
|
|
13
12
|
export declare function loopRecords(): LoopRecord[];
|
|
@@ -16,8 +16,7 @@ export function loopProtocolNames() {
|
|
|
16
16
|
*
|
|
17
17
|
* The list and the run read the same records, so a chooser can show exactly the name, round count,
|
|
18
18
|
* artifact and defaults the runner would use — never a second table that could drift. A record the
|
|
19
|
-
*
|
|
20
|
-
* tell the two apart from the name alone.
|
|
19
|
+
* operator edited is marked, so a shipped update can be distinguished from a local override.
|
|
21
20
|
* @returns One summary per record.
|
|
22
21
|
*/
|
|
23
22
|
export function loopRecords() {
|
|
@@ -13,19 +13,17 @@ export interface LoopOverlaySource {
|
|
|
13
13
|
}
|
|
14
14
|
/** The records one process runs, and where they came from. */
|
|
15
15
|
export interface LoopSourceLoad {
|
|
16
|
-
/** The
|
|
16
|
+
/** The complete table read back from the configuration file. */
|
|
17
17
|
source: LoopPromptSource;
|
|
18
|
-
/** Which files were read, what the
|
|
18
|
+
/** Which files were read, what the operator changed, and any note for them. */
|
|
19
19
|
info: LoopSourceInfo;
|
|
20
20
|
}
|
|
21
21
|
/** Where the records come from for one process. */
|
|
22
22
|
export interface LoopSourceOptions {
|
|
23
|
-
/**
|
|
23
|
+
/** Alternate runtime file, from `DSHT_LOOP_FILE`. */
|
|
24
24
|
overlayFile?: string;
|
|
25
|
-
/** Configuration directory holding `loop.yaml
|
|
25
|
+
/** Configuration directory holding the default runtime `loop.yaml`. */
|
|
26
26
|
configDirectory: string;
|
|
27
|
-
/** State directory holding the override stamp; omit to skip drift reporting. */
|
|
28
|
-
stateDirectory?: string;
|
|
29
27
|
/** Shipped file to read; defaults to the `loop.yaml` beside the package entry point. */
|
|
30
28
|
builtinFile?: string;
|
|
31
29
|
/** Table used when the shipped file cannot be read; defaults to the compiled-in one. */
|
|
@@ -38,37 +36,17 @@ export interface LoopSourceOptions {
|
|
|
38
36
|
* @returns Absolute path to the shipped record file.
|
|
39
37
|
*/
|
|
40
38
|
export declare function shippedLoopFile(): string;
|
|
41
|
-
/** The
|
|
39
|
+
/** The runtime file a process reads.
|
|
42
40
|
*
|
|
43
41
|
* An explicit file wins over the configuration directory: it is how a script or a second checkout
|
|
44
42
|
* points at another set of records without moving the one the interactive client uses.
|
|
45
43
|
* @param options - Resolved options.
|
|
46
|
-
* @returns
|
|
44
|
+
* @returns Runtime file path.
|
|
47
45
|
*/
|
|
48
46
|
export declare function loopOverlayFile(options: LoopSourceOptions): string;
|
|
49
|
-
/**
|
|
50
|
-
*
|
|
51
|
-
* A record is taken whole: replacing one does not merge its fields with the shipped copy, because a
|
|
52
|
-
* record is a single prompt contract and a half-new brief with a half-old round table is a protocol
|
|
53
|
-
* nobody wrote. Records the file does not name are untouched, which is what lets a shipped fix reach
|
|
54
|
-
* an install that customises something else.
|
|
55
|
-
* @param builtin - Shipped records.
|
|
56
|
-
* @param overlay - Parsed user file.
|
|
57
|
-
* @returns The merged table and the names the user file replaced or added.
|
|
58
|
-
*/
|
|
59
|
-
export declare function mergeLoopSource(builtin: LoopPromptSource, overlay: LoopOverlaySource): {
|
|
60
|
-
source: LoopPromptSource;
|
|
61
|
-
overridden: string[];
|
|
62
|
-
added: string[];
|
|
63
|
-
};
|
|
64
|
-
/** Read the merged records for one process.
|
|
65
|
-
*
|
|
66
|
-
* The shipped file falls back to the compiled-in table with a warning, because a packaging mistake
|
|
67
|
-
* should not stop the client; a user file does not, because an invalid override the operator wrote is
|
|
68
|
-
* a mistake they can fix and a silent fallback would run the shipped records while they believe their
|
|
69
|
-
* own are in force. An absent overlay is ordinary: most installs have none.
|
|
47
|
+
/** Create or merge the configuration file, then read it as the one runtime source.
|
|
70
48
|
* @param options - Resolution options.
|
|
71
49
|
* @returns The merged records, their sources and the notes to show the operator.
|
|
72
|
-
* @throws when the
|
|
50
|
+
* @throws when the runtime file is not a valid loop file.
|
|
73
51
|
*/
|
|
74
52
|
export declare function loadLoopSource(options: LoopSourceOptions): Promise<LoopSourceLoad>;
|
|
@@ -1,28 +1,11 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* table compiled into this build as the last resort.
|
|
3
|
-
*
|
|
4
|
-
* The loop record is configuration, not code: the shipped file travels with the package and is read
|
|
5
|
-
* at startup, so a fix to a rubric reaches an install without a TypeScript build, and `DSHT_LOOP_FILE`
|
|
6
|
-
* or `<config>/loop.yaml` lets an operator add a record or correct one without forking the project.
|
|
7
|
-
*
|
|
8
|
-
* Layering is per record, never per file. A user file that overrides one record leaves every other
|
|
9
|
-
* shipped record following the package, so the next version's fixes still arrive; a whole-file
|
|
10
|
-
* replacement would freeze the shipped records at whatever the operator copied. A record the user does
|
|
11
|
-
* override can never be updated for them — the tool cannot know whether their copy is deliberate — so
|
|
12
|
-
* the shipped record's digest is stamped under the state directory and a change is reported, not
|
|
13
|
-
* applied.
|
|
14
|
-
*
|
|
15
|
-
* `loop-prompts.generated.ts` stays as the fallback: a package whose loop.yaml is missing or corrupt
|
|
16
|
-
* still starts on the records this build was compiled with, which is also what keeps the module
|
|
17
|
-
* self-contained for a test that must not touch the filesystem.
|
|
18
|
-
*/
|
|
1
|
+
/** Keep the runtime loop.yaml in the configuration directory, merging shipped records into it. */
|
|
19
2
|
import { createHash } from 'node:crypto';
|
|
20
|
-
import { join } from 'node:path';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
21
4
|
import { fileURLToPath } from 'node:url';
|
|
22
|
-
import { parse } from 'yaml';
|
|
5
|
+
import { parse, parseDocument, stringify } from 'yaml';
|
|
23
6
|
import { LOOP_PROMPTS } from "./loop-prompts.generated.js";
|
|
24
7
|
import { validateLoopOverlayPrompts, validateLoopPrompts } from "./loop-prompts-schema.js";
|
|
25
|
-
import { ensureDirectory, readText, writePrivateFile } from "../storage/index.js";
|
|
8
|
+
import { createPrivateFile, ensureDirectory, readText, writePrivateFile } from "../storage/index.js";
|
|
26
9
|
/** Path of the `loop.yaml` shipped beside this module.
|
|
27
10
|
*
|
|
28
11
|
* The path is relative to the module, so it resolves both in the source tree (`src/controller/`) and
|
|
@@ -32,81 +15,117 @@ import { ensureDirectory, readText, writePrivateFile } from "../storage/index.js
|
|
|
32
15
|
export function shippedLoopFile() {
|
|
33
16
|
return fileURLToPath(new URL('../../loop.yaml', import.meta.url));
|
|
34
17
|
}
|
|
35
|
-
/** The
|
|
18
|
+
/** The runtime file a process reads.
|
|
36
19
|
*
|
|
37
20
|
* An explicit file wins over the configuration directory: it is how a script or a second checkout
|
|
38
21
|
* points at another set of records without moving the one the interactive client uses.
|
|
39
22
|
* @param options - Resolved options.
|
|
40
|
-
* @returns
|
|
23
|
+
* @returns Runtime file path.
|
|
41
24
|
*/
|
|
42
25
|
export function loopOverlayFile(options) {
|
|
43
26
|
return options.overlayFile ?? join(options.configDirectory, 'loop.yaml');
|
|
44
27
|
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
for (const name of Object.keys(overlay.protocols ?? {})) {
|
|
59
|
-
(Object.hasOwn(builtin.protocols, name) ? overridden : added).push(name);
|
|
28
|
+
function seedFor(source) {
|
|
29
|
+
return { version: 1, defaults: { ...source.defaults },
|
|
30
|
+
records: Object.fromEntries(Object.entries(source.protocols).map(([name, record]) => [name, digestOf(record)])) };
|
|
31
|
+
}
|
|
32
|
+
function seedFile(path) { return `${path}.seed.json`; }
|
|
33
|
+
async function readSeed(path) {
|
|
34
|
+
const raw = await readText(path);
|
|
35
|
+
if (raw === undefined)
|
|
36
|
+
return undefined;
|
|
37
|
+
try {
|
|
38
|
+
const value = JSON.parse(raw);
|
|
39
|
+
if (value?.version === 1 && value.defaults && value.records && typeof value.records === 'object')
|
|
40
|
+
return value;
|
|
60
41
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
version: 1,
|
|
64
|
-
defaults: {
|
|
65
|
-
score: overlay.defaults?.score ?? builtin.defaults.score,
|
|
66
|
-
tries: overlay.defaults?.tries ?? builtin.defaults.tries,
|
|
67
|
-
},
|
|
68
|
-
protocols: { ...builtin.protocols, ...(overlay.protocols ?? {}) },
|
|
69
|
-
},
|
|
70
|
-
overridden,
|
|
71
|
-
added,
|
|
72
|
-
};
|
|
42
|
+
catch { /* An invalid stamp cannot make the user's configuration unreadable. */ }
|
|
43
|
+
return undefined;
|
|
73
44
|
}
|
|
74
|
-
/**
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
45
|
+
/** Update unedited shipped fields while keeping user changes and additions. */
|
|
46
|
+
function mergeRuntime(builtin, file, path, previous) {
|
|
47
|
+
const overridden = [], added = [], warnings = [];
|
|
48
|
+
const protocols = { ...builtin.protocols };
|
|
49
|
+
for (const [name, record] of Object.entries(file.protocols ?? {})) {
|
|
50
|
+
const shipped = builtin.protocols[name];
|
|
51
|
+
if (shipped === undefined) {
|
|
52
|
+
protocols[name] = record;
|
|
53
|
+
added.push(name);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const currentDigest = digestOf(shipped);
|
|
57
|
+
const baseline = previous?.records[name] ?? currentDigest;
|
|
58
|
+
if (digestOf(record) === baseline || digestOf(record) === currentDigest)
|
|
59
|
+
continue;
|
|
60
|
+
protocols[name] = record;
|
|
61
|
+
overridden.push(name);
|
|
62
|
+
if (previous?.records[name] !== undefined && previous.records[name] !== currentDigest) {
|
|
63
|
+
warnings.push(`Your ${name} in ${path} replaces a shipped record that changed in this version; the shipped update is not applied to your copy.`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const defaults = { ...builtin.defaults };
|
|
67
|
+
for (const key of ['score', 'tries']) {
|
|
68
|
+
const value = file.defaults?.[key];
|
|
69
|
+
if (value !== undefined && value !== builtin.defaults[key]
|
|
70
|
+
&& value !== (previous?.defaults[key] ?? builtin.defaults[key]))
|
|
71
|
+
defaults[key] = value;
|
|
72
|
+
}
|
|
73
|
+
return { source: { version: 1, defaults, protocols }, overridden, added, warnings };
|
|
74
|
+
}
|
|
75
|
+
/** Create or merge the configuration file, then read it as the one runtime source.
|
|
80
76
|
* @param options - Resolution options.
|
|
81
77
|
* @returns The merged records, their sources and the notes to show the operator.
|
|
82
|
-
* @throws when the
|
|
78
|
+
* @throws when the runtime file is not a valid loop file.
|
|
83
79
|
*/
|
|
84
80
|
export async function loadLoopSource(options) {
|
|
85
81
|
const fallback = options.fallback ?? LOOP_PROMPTS;
|
|
86
82
|
const warnings = [];
|
|
87
83
|
const shipped = await readShipped(options.builtinFile ?? shippedLoopFile(), fallback, warnings);
|
|
88
84
|
const overlayPath = loopOverlayFile(options);
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
info: {
|
|
94
|
-
...(shipped.file === undefined ? {} : { builtin: shipped.file }),
|
|
95
|
-
overridden: [], added: [], warnings,
|
|
96
|
-
},
|
|
97
|
-
};
|
|
85
|
+
await ensureDirectory(dirname(overlayPath));
|
|
86
|
+
const initial = await readText(overlayPath);
|
|
87
|
+
if (initial === undefined) {
|
|
88
|
+
await createPrivateFile(overlayPath, shipped.raw ?? stringify(shipped.source));
|
|
98
89
|
}
|
|
90
|
+
const raw = await readText(overlayPath);
|
|
91
|
+
if (raw === undefined)
|
|
92
|
+
throw new Error(`Cannot read loop records at ${overlayPath}`);
|
|
99
93
|
const overlay = parseOverlay(overlayPath, raw);
|
|
100
|
-
const
|
|
101
|
-
const
|
|
94
|
+
const previous = await readSeed(seedFile(overlayPath));
|
|
95
|
+
const merged = mergeRuntime(shipped.source, overlay, overlayPath, previous);
|
|
96
|
+
const recordChanged = (name, record) => overlay.protocols?.[name] === undefined || digestOf(overlay.protocols[name]) !== digestOf(record);
|
|
97
|
+
const changed = ['score', 'tries'].some(key => overlay.defaults?.[key] !== merged.source.defaults[key])
|
|
98
|
+
|| Object.entries(merged.source.protocols).some(([name, record]) => recordChanged(name, record));
|
|
99
|
+
if (changed) {
|
|
100
|
+
const document = parseDocument(raw);
|
|
101
|
+
for (const key of ['score', 'tries']) {
|
|
102
|
+
if (overlay.defaults?.[key] !== merged.source.defaults[key])
|
|
103
|
+
document.setIn(['defaults', key], merged.source.defaults[key]);
|
|
104
|
+
}
|
|
105
|
+
for (const [name, record] of Object.entries(merged.source.protocols)) {
|
|
106
|
+
if (recordChanged(name, record))
|
|
107
|
+
document.setIn(['protocols', name], record);
|
|
108
|
+
}
|
|
109
|
+
await writePrivateFile(overlayPath, document.toString());
|
|
110
|
+
}
|
|
111
|
+
const stamp = `${JSON.stringify(seedFor(shipped.source), null, 2)}\n`;
|
|
112
|
+
if (await readText(seedFile(overlayPath)) !== stamp)
|
|
113
|
+
await writePrivateFile(seedFile(overlayPath), stamp);
|
|
114
|
+
const runtime = await readText(overlayPath);
|
|
115
|
+
if (runtime === undefined)
|
|
116
|
+
throw new Error(`Cannot read loop records at ${overlayPath}`);
|
|
117
|
+
const source = parse(runtime);
|
|
118
|
+
const errors = validateLoopPrompts(source);
|
|
119
|
+
if (errors.length > 0)
|
|
120
|
+
throw new Error(`${overlayPath}:\n- ${errors.join('\n- ')}`);
|
|
102
121
|
return {
|
|
103
|
-
source:
|
|
122
|
+
source: source,
|
|
104
123
|
info: {
|
|
105
124
|
...(shipped.file === undefined ? {} : { builtin: shipped.file }),
|
|
106
125
|
file: overlayPath,
|
|
107
126
|
overridden: merged.overridden,
|
|
108
127
|
added: merged.added,
|
|
109
|
-
warnings: [...warnings, ...
|
|
128
|
+
warnings: [...warnings, ...merged.warnings],
|
|
110
129
|
},
|
|
111
130
|
};
|
|
112
131
|
}
|
|
@@ -135,12 +154,12 @@ async function readShipped(path, fallback, warnings) {
|
|
|
135
154
|
warnings.push(`The shipped records at ${path} are invalid: ${errors.join('; ')}. Using the table compiled into this build.`);
|
|
136
155
|
return { source: fallback };
|
|
137
156
|
}
|
|
138
|
-
return { source: parsed, file: path };
|
|
157
|
+
return { source: parsed, file: path, raw };
|
|
139
158
|
}
|
|
140
|
-
/** Parse one
|
|
141
|
-
* @param path -
|
|
159
|
+
/** Parse one runtime file before merging, refusing anything the renderer would misread.
|
|
160
|
+
* @param path - Runtime file path, used in every message.
|
|
142
161
|
* @param raw - File contents.
|
|
143
|
-
* @returns The parsed
|
|
162
|
+
* @returns The parsed document.
|
|
144
163
|
* @throws when the file is not valid YAML or does not satisfy the overlay schema.
|
|
145
164
|
*/
|
|
146
165
|
function parseOverlay(path, raw) {
|
|
@@ -156,69 +175,21 @@ function parseOverlay(path, raw) {
|
|
|
156
175
|
throw new Error(`${path}:\n- ${errors.join('\n- ')}`);
|
|
157
176
|
return parsed;
|
|
158
177
|
}
|
|
159
|
-
/** Report a shipped record that changed under the operator's override.
|
|
160
|
-
*
|
|
161
|
-
* The stamp is the only durable trace of which shipped copy an override was written against. When a
|
|
162
|
-
* later version ships a different definition of a record this install overrides, that update cannot
|
|
163
|
-
* be applied — the operator's file wins by design — so it is reported once per change instead of
|
|
164
|
-
* being lost silently. The write is best effort: a state directory that cannot be written costs a
|
|
165
|
-
* warning, never a failed start.
|
|
166
|
-
* @param stateDirectory - Directory holding the stamp; omitted disables the check.
|
|
167
|
-
* @param builtin - Shipped records, read before merging.
|
|
168
|
-
* @param overridden - Names the overlay replaced.
|
|
169
|
-
* @param overlayPath - Overlay file the names came from, named in the warning.
|
|
170
|
-
* @returns One warning per shipped record that changed since it was last seen.
|
|
171
|
-
*/
|
|
172
|
-
async function stampOverrides(stateDirectory, builtin, overridden, overlayPath) {
|
|
173
|
-
if (stateDirectory === undefined)
|
|
174
|
-
return [];
|
|
175
|
-
const path = join(stateDirectory, 'loop-overrides.json');
|
|
176
|
-
const warnings = [];
|
|
177
|
-
const current = {};
|
|
178
|
-
let previous = {};
|
|
179
|
-
const raw = await readText(path);
|
|
180
|
-
// No override and no stamp: an install that never customised a shipped record leaves no file behind.
|
|
181
|
-
if (raw === undefined && overridden.length === 0)
|
|
182
|
-
return [];
|
|
183
|
-
if (raw !== undefined) {
|
|
184
|
-
try {
|
|
185
|
-
const parsed = JSON.parse(raw);
|
|
186
|
-
if (parsed.records !== null && typeof parsed.records === 'object')
|
|
187
|
-
previous = parsed.records;
|
|
188
|
-
}
|
|
189
|
-
catch {
|
|
190
|
-
previous = {};
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
for (const name of overridden) {
|
|
194
|
-
const record = builtin.protocols[name];
|
|
195
|
-
// A name the shipped table does not have is an addition, not an override: there is no upstream
|
|
196
|
-
// definition for it to fall behind.
|
|
197
|
-
if (record === undefined)
|
|
198
|
-
continue;
|
|
199
|
-
const digest = digestOf(record);
|
|
200
|
-
current[name] = digest;
|
|
201
|
-
if (previous[name] !== undefined && previous[name] !== digest) {
|
|
202
|
-
warnings.push(`Your ${name} in ${overlayPath} replaces a shipped record that changed in this version; `
|
|
203
|
-
+ `the shipped update is not applied to your copy.`);
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
const stamp = `${JSON.stringify({ version: 1, records: current }, null, 2)}\n`;
|
|
207
|
-
if (stamp !== raw) {
|
|
208
|
-
try {
|
|
209
|
-
await ensureDirectory(stateDirectory);
|
|
210
|
-
await writePrivateFile(path, stamp);
|
|
211
|
-
}
|
|
212
|
-
catch { /* The stamp only enables a warning; failing to write it must not stop the client. */ }
|
|
213
|
-
}
|
|
214
|
-
return warnings;
|
|
215
|
-
}
|
|
216
178
|
/** Content digest of one record, stable across key order because it hashes the serialized record.
|
|
217
179
|
* @param record - Shipped record.
|
|
218
180
|
* @returns Lowercase hex sha256.
|
|
219
181
|
*/
|
|
220
182
|
function digestOf(record) {
|
|
221
|
-
return createHash('sha256').update(JSON.stringify(record)).digest('hex');
|
|
183
|
+
return createHash('sha256').update(JSON.stringify(sortKeys(record))).digest('hex');
|
|
184
|
+
}
|
|
185
|
+
function sortKeys(value) {
|
|
186
|
+
if (Array.isArray(value))
|
|
187
|
+
return value.map(sortKeys);
|
|
188
|
+
if (value !== null && typeof value === 'object') {
|
|
189
|
+
return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)
|
|
190
|
+
.map(([key, entry]) => [key, sortKeys(entry)]));
|
|
191
|
+
}
|
|
192
|
+
return value;
|
|
222
193
|
}
|
|
223
194
|
/** Readable text for anything thrown. */
|
|
224
195
|
function message(error) { return error instanceof Error ? error.message : String(error); }
|
package/dist/cost/ledger.d.ts
CHANGED
|
@@ -8,6 +8,8 @@ export declare class CostLedger {
|
|
|
8
8
|
/** Whether the table came from a file the user maintains, rather than the shipped one. */
|
|
9
9
|
readonly customPrices: boolean;
|
|
10
10
|
private sessions;
|
|
11
|
+
/** New sessions are known to begin at zero, but have not yet had their history scanned. */
|
|
12
|
+
private provisional;
|
|
11
13
|
private totals;
|
|
12
14
|
private readonly catalog;
|
|
13
15
|
scannedAt?: number;
|
|
@@ -34,6 +36,10 @@ export declare class CostLedger {
|
|
|
34
36
|
* @param now - Clock that names the window, so a caller or a test can pin the boundary.
|
|
35
37
|
*/
|
|
36
38
|
load(now?: number): Promise<void>;
|
|
39
|
+
/** Show zero immediately for a session this client just created, until a host scan confirms it.
|
|
40
|
+
* The provisional slice is memory only; a later scan replaces it with the actual history.
|
|
41
|
+
*/
|
|
42
|
+
seedNewSession(sessionId: string): void;
|
|
37
43
|
/** Replace one session using all billing events through the opening snapshot cut.
|
|
38
44
|
*
|
|
39
45
|
* The fold is the projection: every sample is decided again with the table loaded now, so a
|
package/dist/cost/ledger.js
CHANGED
|
@@ -9,6 +9,8 @@ export class CostLedger {
|
|
|
9
9
|
directory;
|
|
10
10
|
customPrices;
|
|
11
11
|
sessions = new Map();
|
|
12
|
+
/** New sessions are known to begin at zero, but have not yet had their history scanned. */
|
|
13
|
+
provisional = new Set();
|
|
12
14
|
totals = new Map();
|
|
13
15
|
catalog;
|
|
14
16
|
scannedAt;
|
|
@@ -32,7 +34,7 @@ export class CostLedger {
|
|
|
32
34
|
return 'scanning';
|
|
33
35
|
if (this.error)
|
|
34
36
|
return 'partial';
|
|
35
|
-
return this.scannedAt !== undefined || this.sessions.size > 0 ? 'complete' : 'partial';
|
|
37
|
+
return this.provisional.size === 0 && (this.scannedAt !== undefined || this.sessions.size > 0) ? 'complete' : 'partial';
|
|
36
38
|
}
|
|
37
39
|
/** Load the newest cut per session; a stored total is read as it was decided.
|
|
38
40
|
*
|
|
@@ -43,11 +45,23 @@ export class CostLedger {
|
|
|
43
45
|
*/
|
|
44
46
|
async load(now = Date.now()) {
|
|
45
47
|
this.totals.clear();
|
|
48
|
+
this.provisional.clear();
|
|
46
49
|
this.sessions = await loadLedgers(this.directory);
|
|
47
50
|
for (const sessionId of await pruneLedgers(this.directory, costWindowStart(now), this.sessions)) {
|
|
48
51
|
this.sessions.delete(sessionId);
|
|
49
52
|
}
|
|
50
53
|
}
|
|
54
|
+
/** Show zero immediately for a session this client just created, until a host scan confirms it.
|
|
55
|
+
* The provisional slice is memory only; a later scan replaces it with the actual history.
|
|
56
|
+
*/
|
|
57
|
+
seedNewSession(sessionId) {
|
|
58
|
+
if (this.sessions.has(sessionId))
|
|
59
|
+
return;
|
|
60
|
+
this.sessions.set(sessionId, { version: 4, sessionId, cut: -2, engine: PRICING_ENGINE_VERSION,
|
|
61
|
+
catalog: this.catalog, total: { amount: 0, unknown: 0, records: 0 }, days: [], unpriced: [] });
|
|
62
|
+
this.provisional.add(sessionId);
|
|
63
|
+
this.totals.clear();
|
|
64
|
+
}
|
|
51
65
|
/** Replace one session using all billing events through the opening snapshot cut.
|
|
52
66
|
*
|
|
53
67
|
* The fold is the projection: every sample is decided again with the table loaded now, so a
|
|
@@ -101,6 +115,7 @@ export class CostLedger {
|
|
|
101
115
|
if (this.directory && !await saveLedger(this.directory, saved))
|
|
102
116
|
return;
|
|
103
117
|
this.sessions.set(sessionId, saved);
|
|
118
|
+
this.provisional.delete(sessionId);
|
|
104
119
|
this.totals.clear();
|
|
105
120
|
}
|
|
106
121
|
/** Count the retained ledger so a memory sample can separate it from the transcript window.
|
package/dist/cost/pricing.d.ts
CHANGED
|
@@ -7,7 +7,7 @@ export declare const DEFAULT_PRICES: PriceVersion[];
|
|
|
7
7
|
* to the rules that produced it. Version 1 matched a model by the substring `pro` and priced a
|
|
8
8
|
* request with no settlement time at the cheapest off-peak rate.
|
|
9
9
|
*/
|
|
10
|
-
export declare const PRICING_ENGINE_VERSION =
|
|
10
|
+
export declare const PRICING_ENGINE_VERSION = 3;
|
|
11
11
|
/** Revision of the shipped table, recorded beside a seeded file so a correction can replace it. */
|
|
12
12
|
export declare const PRICES_REVISION = "2026-09-12";
|
|
13
13
|
/** Whether a table is the seed an earlier revision wrote, which a corrected ship must replace.
|
package/dist/cost/pricing.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/** Versioned CNY price tables and the price decision taken for one request sample. */
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
|
+
import workday from 'workday-cn';
|
|
3
4
|
import { object } from "../transport/wire.js";
|
|
4
5
|
import { MISSING_TIME, MISSING_USAGE, UNSUPPORTED_USAGE } from "./types.js";
|
|
5
6
|
const clocks = new Map();
|
|
6
|
-
/** Published rates verified on 2026-09-
|
|
7
|
+
/** Published rates verified on 2026-09-24; preceding dates require historical configuration.
|
|
7
8
|
* Flash and Pro are priced independently, and a separate cache write uses the cache-miss input rate.
|
|
8
9
|
*/
|
|
9
10
|
const OFFICIAL_PRICING = 'https://api-docs.deepseek.com/zh-cn/quick_start/pricing/';
|
|
@@ -34,7 +35,7 @@ export const DEFAULT_PRICES = [
|
|
|
34
35
|
* to the rules that produced it. Version 1 matched a model by the substring `pro` and priced a
|
|
35
36
|
* request with no settlement time at the cheapest off-peak rate.
|
|
36
37
|
*/
|
|
37
|
-
export const PRICING_ENGINE_VERSION =
|
|
38
|
+
export const PRICING_ENGINE_VERSION = 3;
|
|
38
39
|
/** Revision of the shipped table, recorded beside a seeded file so a correction can replace it. */
|
|
39
40
|
export const PRICES_REVISION = '2026-09-12';
|
|
40
41
|
/** Rates the first published revision charged, rebuilt with the same arithmetic so the values compare equal.
|
|
@@ -226,14 +227,21 @@ export function priceAt(prices, provider, model, time) {
|
|
|
226
227
|
const { price, matchedBy } = candidate;
|
|
227
228
|
let clock = clocks.get(price.timezone);
|
|
228
229
|
if (!clock) {
|
|
229
|
-
clock = new Intl.DateTimeFormat('en-US', { timeZone: price.timezone, weekday: 'short', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' });
|
|
230
|
+
clock = new Intl.DateTimeFormat('en-US', { timeZone: price.timezone, weekday: 'short', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' });
|
|
230
231
|
clocks.set(price.timezone, clock);
|
|
231
232
|
}
|
|
232
233
|
const parts = clock.formatToParts(time);
|
|
233
234
|
const part = (name) => parts.find(p => p.type === name).value;
|
|
234
235
|
const day = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].indexOf(part('weekday'));
|
|
236
|
+
const date = `${part('year')}-${part('month')}-${part('day')}`;
|
|
235
237
|
const minute = Number(part('hour')) * 60 + Number(part('minute'));
|
|
236
|
-
|
|
238
|
+
const peakWindow = price.weekdays.includes(day) && price.windows.some(([a, b]) => minute >= a && minute < b);
|
|
239
|
+
// The package reads a Date through local getters. Build local noon from the date already resolved
|
|
240
|
+
// in the price's timezone so the result does not shift to the previous day west of UTC.
|
|
241
|
+
const [year, month, dateOfMonth] = date.split('-').map(Number);
|
|
242
|
+
const holiday = peakWindow && price.timezone === 'Asia/Shanghai'
|
|
243
|
+
&& workday.isHoliday(new Date(year, month - 1, dateOfMonth, 12));
|
|
244
|
+
return { price, matchedBy, rates: peakWindow && !holiday ? price.peak : price.offPeak };
|
|
237
245
|
}
|
|
238
246
|
/** Decide the amount for one request sample using the table loaded at decision time.
|
|
239
247
|
*
|
package/dist/cost/types.d.ts
CHANGED
|
@@ -6,7 +6,7 @@ export interface Rates {
|
|
|
6
6
|
cacheWrite: number;
|
|
7
7
|
output: number;
|
|
8
8
|
}
|
|
9
|
-
/** An explicit validity interval and
|
|
9
|
+
/** An explicit validity interval and peak calendar in the named time zone. */
|
|
10
10
|
export interface PriceVersion {
|
|
11
11
|
id: string;
|
|
12
12
|
provider: string;
|
|
@@ -177,7 +177,7 @@ export declare class SessionController {
|
|
|
177
177
|
*/
|
|
178
178
|
createWorkspace(path: string, signal?: AbortSignal): Promise<void>;
|
|
179
179
|
/** Create a session only after the user explicitly selects New session. */
|
|
180
|
-
createSession(signal?: AbortSignal): Promise<
|
|
180
|
+
createSession(signal?: AbortSignal): Promise<string>;
|
|
181
181
|
/** Create a session for another purpose without selecting it, named so a reader can tell it apart.
|
|
182
182
|
*
|
|
183
183
|
* A verifier runs in its own session while the reviewed session stays selected, so this never
|
|
@@ -40,7 +40,7 @@ export declare class SessionNavigator {
|
|
|
40
40
|
switchWorkspace(query?: string, signal?: AbortSignal): Promise<void>;
|
|
41
41
|
switchSession(query?: string, signal?: AbortSignal): Promise<void>;
|
|
42
42
|
createWorkspace(path: string, signal?: AbortSignal): Promise<void>;
|
|
43
|
-
createSession(signal?: AbortSignal): Promise<
|
|
43
|
+
createSession(signal?: AbortSignal): Promise<string>;
|
|
44
44
|
get visibleSessions(): ObjectValue[];
|
|
45
45
|
/** Longest registered path wins, with whole-segment matching for nested workspaces. */
|
|
46
46
|
adoptLocalWorkspace(directory: string): string | undefined;
|
|
@@ -125,7 +125,9 @@ export class SessionNavigator {
|
|
|
125
125
|
throw new Error('Select a workspace before creating a session');
|
|
126
126
|
const result = object(await context.client.call('session/create', { request: { workspaceId } }, context.signal));
|
|
127
127
|
context.check();
|
|
128
|
-
|
|
128
|
+
const sessionId = string(result.sessionId);
|
|
129
|
+
this.host.follow(sessionId);
|
|
130
|
+
return sessionId;
|
|
129
131
|
});
|
|
130
132
|
}
|
|
131
133
|
get visibleSessions() {
|
package/dist/ui/dialogs/loop.js
CHANGED
|
@@ -53,8 +53,7 @@ function labelWidth(rows) {
|
|
|
53
53
|
export function LoopMenu({ records, index, source }) {
|
|
54
54
|
const theme = useTheme();
|
|
55
55
|
const start = Math.max(0, index - 5);
|
|
56
|
-
//
|
|
57
|
-
// because an operator who overrode a shipped record cannot tell the two apart from the name alone.
|
|
56
|
+
// The runtime file is in the config directory; mark records the operator edited or added.
|
|
58
57
|
const origin = source?.file === undefined ? undefined : [
|
|
59
58
|
`Records from ${source.file}`,
|
|
60
59
|
...(source.overridden.length === 0 ? [] : [`replaced: ${source.overridden.join(', ')}`]),
|
package/loop.yaml
CHANGED
|
@@ -4,15 +4,11 @@
|
|
|
4
4
|
# contract (result block, verdict brief, retry clause) stays in src/controller/loop-contract.ts;
|
|
5
5
|
# this file holds the objective, the rubric, the templates and the fixed inputs of one record.
|
|
6
6
|
#
|
|
7
|
-
# It is configuration
|
|
8
|
-
# and
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
# added; every record you do not name keeps following this file, so upgrades still reach it.
|
|
13
|
-
# $DSHT_LOOP_FILE points at another file instead of the config directory one.
|
|
14
|
-
# - src/controller/loop-source.ts merges the two; src/controller/loop-prompts-schema.ts holds the
|
|
15
|
-
# rules (the overlay is checked by validateLoopOverlayPrompts, which allows a partial file).
|
|
7
|
+
# It is configuration: this shipped copy seeds ~/.config/dsht/loop.yaml (or $DSHT_CONFIG_DIR).
|
|
8
|
+
# On later starts, unedited shipped records and defaults are merged into that runtime file; user
|
|
9
|
+
# edits and additions stay there. $DSHT_LOOP_FILE selects another runtime file with the same rules.
|
|
10
|
+
# The previous shipped digests live beside the runtime file in loop.yaml.seed.json.
|
|
11
|
+
# src/controller/loop-source.ts performs the merge; loop-prompts-schema.ts validates both files.
|
|
16
12
|
# - `npm run build:prompts` inlines this file into the committed fallback module used only when the
|
|
17
13
|
# data file cannot be read, and `npm test` keeps the two in sync.
|
|
18
14
|
#
|
|
@@ -43,8 +39,9 @@
|
|
|
43
39
|
# earlier round's rubric to re-check, and only then may "passed" mean the whole artifact rather than
|
|
44
40
|
# just the rounds that happened to run.
|
|
45
41
|
#
|
|
46
|
-
#
|
|
47
|
-
#
|
|
42
|
+
# When editing this shipped copy in the source repo, run `npm run build:prompts` to regenerate
|
|
43
|
+
# src/controller/loop-prompts.generated.ts; edits to the config directory's runtime copy take effect
|
|
44
|
+
# on restart without a build. A record named `answer` or `abort` is refused,
|
|
48
45
|
# because those are `/loop` subcommands rather than protocols.
|
|
49
46
|
|
|
50
47
|
version: 1
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@itookit/dsht",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Remote-first TUI for DeepSeek Harness with SSH-friendly mobile access and request-level cost tracking",
|
|
6
6
|
"engines": {
|
|
@@ -77,6 +77,7 @@
|
|
|
77
77
|
"react": "^19.2.0",
|
|
78
78
|
"slice-ansi": "^8.0.0",
|
|
79
79
|
"string-width": "^8.2.2",
|
|
80
|
+
"workday-cn": "^1.0.5",
|
|
80
81
|
"wrap-ansi": "^9.0.2",
|
|
81
82
|
"ws": "^8.21.0",
|
|
82
83
|
"yaml": "^2.9.1"
|