@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 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 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 |
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 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.
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-10. Beijing weekday peak windows are 09:00–12:00 and 14:00–18:00; other times use half-price rates. 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 model name is `deepseek-flash`, and older Flash names keep those same rates. The provider has announced that from 2026-09-14T12:00+08:00 it serves `deepseek-v4-pro` from Flash and bills it at Flash rates, which the bundled entry records so that date does not overstate Pro usage. Separate cache writes use the uncached-input rate. An exact configured model price takes priority; otherwise `deepseek-official` names containing `pro` (case-insensitive) use Pro and all other names use Flash, including temporary model aliases. Other providers require explicit entries.
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]` | 用列表选择循环记录(随包 `loop.yaml` 与配置目录中的 `loop.yaml` 合并),修改它启动时用的输入与限制后再运行评分循环;`stop`/`abort` 结束当前运行,`answer TEXT` 补上验证者要的判断条件 |
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
- 随包发布的 `loop.yaml` 在启动时读取,而不是编译期写死,因此记录是配置:文件随包分发,配置目录(`$DSHT_CONFIG_DIR`,默认 `~/.config/dsht`)下的 `loop.yaml` 叠加其上。同名记录**整条替换**内置记录,新名字新增一条记录,你没有点名的记录继续跟随包内版本——这正是升级仍能修好你没覆盖的内置记录的原因。`DSHT_LOOP_FILE` 可改指另一个文件。记录列表会把这件事说出来:来自你的文件的行标 `· yours`,并在上方写明文件名。文件非法时启动即失败,并给出行路径与出错字段;内置文件读不到时回退到编译进包的记录。被你覆盖的那条记录无法再为你自动更新,因此其内置定义的摘要会记在 state 目录下,内置定义变化后的第一次启动会提示"内置更新没有到达你的副本"——你的文件仍然生效,且任何情况下都不会被改写。
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-10 根据[官方价格页](https://api-docs.deepseek.com/zh-cn/quick_start/pricing/)核对。北京时间工作日 09:00–12:00、14:00–18:00 为高峰,其余时段半价。Flash 高峰未命中输入/缓存命中输入/输出为每百万 token ¥2/¥0.04/¥8,Pro 为 ¥9/¥0.30/¥27;当前模型名为 `deepseek-flash`,旧 Flash 名称沿用同一费率。供应方已公告自北京时间 2026-09-14 12:00 起将 `deepseek-v4-pro` 交由 Flash 服务并按 Flash 价格计费,内置条目已记录该变更,避免此后高估 Pro 用量。单列的缓存写入按未命中输入价计算。配置中的精确模型价格优先;否则 `deepseek-official` 模型名包含 `pro`(不区分大小写)时按 Pro 计价,其余名称包括临时别名均按 Flash 计价。其他供应商需要显式配置。
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 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.
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
- // 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.
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, stateDirectory: stateRoot,
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'));
@@ -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 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.
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
- /** User file layered over the shipped records; absent when there is none. */
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 a user file that layers over the shipped records.
61
+ /** Check an existing runtime file before merging shipped records into it.
62
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.
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 a user file that layers over the shipped records.
35
+ /** Check an existing runtime file before merging shipped records into it.
36
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.
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, 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
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
- * 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
+ * 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
- * user's own file supplied is marked, because an operator who overrode a shipped record can no longer
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 merged table the renderer reads: shipped records, then the user's on top. */
16
+ /** The complete table read back from the configuration file. */
17
17
  source: LoopPromptSource;
18
- /** Which files were read, what the user's file changed, and any note for the operator. */
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
- /** Overlay to read instead of `<configDirectory>/loop.yaml`, from `--loop-file`/`DSHT_LOOP_FILE`. */
23
+ /** Alternate runtime file, from `DSHT_LOOP_FILE`. */
24
24
  overlayFile?: string;
25
- /** Configuration directory holding `loop.yaml` when no explicit file is given. */
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 overlay file a process reads, if it reads one at all.
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 Absolute path of the overlay to try.
44
+ * @returns Runtime file path.
47
45
  */
48
46
  export declare function loopOverlayFile(options: LoopSourceOptions): string;
49
- /** Layer one user file over the shipped records.
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 overlay exists but is not a valid loop file.
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
- /** Resolve the records `/loop` runs: the shipped loop.yaml, a user file layered over it, and the
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 overlay file a process reads, if it reads one at all.
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 Absolute path of the overlay to try.
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
- /** Layer one user file over the shipped records.
46
- *
47
- * A record is taken whole: replacing one does not merge its fields with the shipped copy, because a
48
- * record is a single prompt contract and a half-new brief with a half-old round table is a protocol
49
- * nobody wrote. Records the file does not name are untouched, which is what lets a shipped fix reach
50
- * an install that customises something else.
51
- * @param builtin - Shipped records.
52
- * @param overlay - Parsed user file.
53
- * @returns The merged table and the names the user file replaced or added.
54
- */
55
- export function mergeLoopSource(builtin, overlay) {
56
- const overridden = [];
57
- const added = [];
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
- return {
62
- source: {
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
- /** Read the merged records for one process.
75
- *
76
- * The shipped file falls back to the compiled-in table with a warning, because a packaging mistake
77
- * should not stop the client; a user file does not, because an invalid override the operator wrote is
78
- * a mistake they can fix and a silent fallback would run the shipped records while they believe their
79
- * own are in force. An absent overlay is ordinary: most installs have none.
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 overlay exists but is not a valid loop file.
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
- const raw = await readText(overlayPath);
90
- if (raw === undefined) {
91
- return {
92
- source: shipped.source,
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 merged = mergeLoopSource(shipped.source, overlay);
101
- const drift = await stampOverrides(options.stateDirectory, shipped.source, merged.overridden, overlayPath);
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: merged.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, ...drift],
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 overlay file, refusing anything the renderer would misread.
141
- * @param path - Overlay file path, used in every message.
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 overlay.
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); }
@@ -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
@@ -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.
@@ -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 = 2;
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.
@@ -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-12; preceding dates require historical configuration.
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 = 2;
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
- return { price, matchedBy, rates: price.weekdays.includes(day) && price.windows.some(([a, b]) => minute >= a && minute < b) ? price.peak : price.offPeak };
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
  *
@@ -6,7 +6,7 @@ export interface Rates {
6
6
  cacheWrite: number;
7
7
  output: number;
8
8
  }
9
- /** An explicit validity interval and weekday peak windows in the named time zone. */
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<void>;
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<void>;
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
- this.host.follow(string(result.sessionId));
128
+ const sessionId = string(result.sessionId);
129
+ this.host.follow(sessionId);
130
+ return sessionId;
129
131
  });
130
132
  }
131
133
  get visibleSessions() {
@@ -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
- // Records are configuration now: whenever a user file supplied or replaced one, the list says so,
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, read at startup, not compiled in: the shipped copy travels with the package
8
- # and a user file layers over it per record.
9
- # - To add or replace records without touching the package, write a loop.yaml in the config
10
- # directory (default ~/.config/dsht, or $DSHT_CONFIG_DIR) with the same `version`, the records
11
- # you want, and optionally `defaults`. A name that exists here is replaced whole; a new name is
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
- # After editing, run `npm run build:prompts` to regenerate src/controller/loop-prompts.generated.ts;
47
- # `npm test` fails when the generated file is stale. A record named `answer` or `abort` is refused,
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.0",
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"