dsh-hypatia-auto-memory 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -357,12 +357,23 @@ changes. No card claims it, so it renders nowhere; nothing ever writes to it.
357
357
  (get-before-create); uncovered ranges are re-enqueued from watermarks.
358
358
  4. To check the route warning: consolidation with no selected `models`
359
359
  logs a one-time warning and otherwise stays silent.
360
- 5. `hypatia backfill --status` reports the shelf's embedding debt. With a
360
+ 5. Which model a background call actually ran on is recorded per attempt in
361
+ `~/.dsh/storages/hypatia_auto_memory_diag.json` (`tables.model_calls`,
362
+ newest first by `seq`, bounded to 100). The settings list is not the answer:
363
+ one process-local cursor rotates through `consolidation.models` across every
364
+ span summary, adjudication and cascade call, so consecutive attempts
365
+ alternate. `outcome` is `pending` (in flight, or the process died mid-call),
366
+ `ok` (a usable result was produced), `incomplete` (the call settled without
367
+ one — output cap, abort, unparseable reply) or `error` (the call threw); in
368
+ the last two the cursor was consumed and nothing was stored. `detail` is a
369
+ finish kind, a fixed label, or the provider's message, capped at 200
370
+ characters — never message content and never model output.
371
+ 6. `hypatia backfill --status` reports the shelf's embedding debt. With a
361
372
  current hypatia, logging a turn adds nothing to it; only summaries and work
362
373
  units are pending until the next flush. `hypatia scope list --count` shows
363
374
  the scopes in use — the one this project's `msg-*` entries carry is the one
364
375
  the session seed reads.
365
- 6. Uninstall: `dsh plugin --profile web remove dsh-hypatia-auto-memory` —
376
+ 7. Uninstall: `dsh plugin --profile web remove dsh-hypatia-auto-memory` —
366
377
  hypatia entries themselves are left in `~/.hypatia/`.
367
378
 
368
379
  ## Failure modes
@@ -374,7 +385,7 @@ changes. No card claims it, so it renders nowhere; nothing ever writes to it.
374
385
  | Entries appear only after a turn finishes | By design: spans are cut at `turn/end`, or at the first `step/end` once a turn has run longer than `flushWindowMs`, so an entry never lacks its own tool results |
375
386
  | Logging works, no summaries | `consolidation.models` is empty or invalid — one warning at first trigger |
376
387
  | Summaries but no `sum2-*` | Fewer than `cascade.batchSize` unarchived tier-1 summaries in that project yet |
377
- | Work units have no relationships | No embedding model on the shelf (`similar` fails), or every candidate was beyond `dedupMaxDistance`. Since hypatia #19 the local model is looked up in `~/.hypatia/models/<org>/<name>` (or the Hugging Face cache), no longer beside the shelf: a shelf that used to answer `similar` and now says `is not installed` needs `hypatia model install <model>`, or `hypatia model register <model> <dir>` pointing at the files it already has |
388
+ | Work units have no relationships | No embedding model on the shelf (`similar` fails), every candidate was beyond `dedupMaxDistance`, or the adjudication call itself produced no verdict — `model_calls` shows that as `incomplete` / `max-tokens`, which is what a thinking-enabled route does when it spends the whole output budget on reasoning before answering. The call now asks a route for `reasoningEffort: 'off'` once the adapter advertises it, and carries a 1024-token cap for routes that cannot honour it. Since hypatia #19 the local model is looked up in `~/.hypatia/models/<org>/<name>` (or the Hugging Face cache), no longer beside the shelf: a shelf that used to answer `similar` and now says `is not installed` needs `hypatia model install <model>`, or `hypatia model register <model> <dir>` pointing at the files it already has |
378
389
  | `similar` still returns `msg-*` rows | They were written before this plugin opted the log layer out of embedding, or by a hypatia without `--no-embed`. Retract the knowledge vectors once with `embedding.skip_tags = ["message"]` in the shelf's `shelf.toml` followed by `hypatia backfill` (a binary older than the key refuses to open the shelf, so upgrade every binary sharing it first). Do not add `session` to that list: `skip_tags` matches any entry carrying the tag, and knowledge people write about sessions carries it too — one such entry lost its vector on the shelf this was tried on. The plugin's own `session-*` nodes are few and now opt out per write. Statements have no tags and no update command, so `belongTo` / `summary` edges written before keep their vectors |
379
390
  | Task in `deferred` state | Neither the live store nor persistence could supply its session. Not an error: it spends no attempts and runs as soon as one of them can. Normally storage answers immediately — the state persists only when `sessionPersistence` is absent from the composition, or its read failed (look for `could not be read` in the log) |
380
391
  | Task in `failed` state | A hypatia or model error persisted after `maxAttempts`. Failed `log-message` records are pruned at the next startup, since the watermark re-derives their range; other kinds are kept for inspection — delete one to let the next trigger re-create it |
@@ -386,6 +397,7 @@ changes. No card claims it, so it renders nowhere; nothing ever writes to it.
386
397
  | Duplicate `msg-*` after weird manual edits | Delete the entry in hypatia and lower `lastLoggedSeq` for that session in the state domain — backfill recreates it once |
387
398
  | The agent's own `hypatia` write still asks for approval | `autoApprove: false` (needs a profile reload to change), the command pipes/redirects/chains outside quotes, or its first word is not one of `binaries` — only plain calls are answered, by design. Reads never reach approval at all, so nothing to fix there |
388
399
  | The agent got a memory protocol that tells it to log messages by hand | Another plugin registered `hypatia-memory` first (`dsh-hypatia` still in the profile), or a `hypatia-memory` exists on disk — typically `~/.agents/skills/hypatia-memory`, written by `hypatia skill install --agent codex`. Agent presets load disk skills in a layer nearer than plugins, so it wins in sessions even though the skill center may list this plugin. Remove the copy the startup warning names |
400
+ | Can't tell which model actually ran | By design the choice rotates: one process-local cursor is shared by span summaries, adjudication and cascade, so consecutive calls alternate through `consolidation.models`. Each attempt lands in `~/.dsh/storages/hypatia_auto_memory_diag.json` (`model_calls`) with purpose, provider/model, outcome and duration. The usage ledger cannot answer this — it folds agent turns (`assistant/message`) and never sees a plugin's direct `llm.stream`. A `pending` row means the call is still in flight, or the process died mid-call: the row is written before the call and settled after it |
389
401
  | Startup warnings never appear in the terminal | `dsh web` mounts no log exporter, so plugin log lines of any level go nowhere; the console exporter's default threshold would also drop warnings (warn is level 2, above info's 1). Mount a logger such as `dsh-logbook` or `dsh-boot-doctor` temporarily to read them |
390
402
  | Project scope looks wrong | Scope = basename of the session cwd's git top level (`git rev-parse --show-toplevel`), or of the cwd itself when git finds no work tree or cannot answer (not installed, a repo it refuses as unsafe). A `rev-parse` slower than 3 s leaves that one session on the cwd's own name. Two same-named checkouts share a scope by design; a linked worktree is scoped by its own directory, not the main checkout's; git resolves symlinks, so a checkout opened through a link named differently from its target gets the target's name. A name hypatia would rewrite is normalized first: a session at `/` is scoped `/`, commas become `_`, surrounding whitespace goes. Entries written before these fixes stay where they were stored: a subdirectory session's under that directory's name (`repo/src` wrote under `src` — the git root was never read), a session at `/` with no scope, `a,b` under both `a` and `b`, `foo,` under `foo` and global. Padded names were stored trimmed, which is what the query now asks for. Nothing migrates them; `knowledge-update --scopes` moves one entry and keeps its `created_at`. `hypatia scope list --count` (hypatia #30) shows every spelling in use and how many entries each holds |
391
403
 
@@ -482,6 +494,7 @@ dsh-hypatia-auto-memory/
482
494
  │ ├── writer.js # idempotent get-before-create writes
483
495
  │ ├── consolidator.js # thresholds, prompt, llm.stream, validation
484
496
  │ ├── cascade.js # log₁₆(n) hierarchical summary archive
497
+ │ ├── model-log.js # bounded record of which model each attempt ran on
485
498
  │ ├── recall.js # rules/taboos preload at session start
486
499
  │ ├── auto-approve.js # approves the agent's own plain bash hypatia calls
487
500
  │ ├── skills.js # bundled skill registration (never shadows another provider)
package/README.zh-CN.md CHANGED
@@ -292,11 +292,19 @@ rules/taboos 预载、启动清理。设置卡片把它做成 `hypatia list` 所
292
292
  3. 写入中途重启是安全的:已存储的消息被跳过(get-before-create);未覆盖的范围从
293
293
  水位重新排队。
294
294
  4. 检查路由告警:没选 `models` 的巩固只记录一次告警,其余时间静默。
295
- 5. `hypatia backfill --status` 报告 shelf 的嵌入债务。用当前的 hypatia,记录一个
295
+ 5. 后台调用实际用了哪个模型,逐次记在
296
+ `~/.dsh/storages/hypatia_auto_memory_diag.json`(`tables.model_calls`,按 `seq`
297
+ 倒序,上限 100 条)。设置卡片里的列表不是答案:一个进程内游标被 span 摘要、
298
+ 裁决和 cascade 三者共用,所以相邻几次调用是轮流换模型的。`outcome` 为
299
+ `pending`(在飞行中,或进程中途死了)、`ok`(产出了可用结果)、`incomplete`
300
+ (调用结束了但没产出——撞输出上限、被中止、回复解析不出来)或 `error`(调用本身
301
+ 抛错);后两种都消耗了游标且没有写入任何条目。`detail` 是 finish kind、固定标签
302
+ 或 provider 的错误消息,截断到 200 字符——绝不放消息正文,也绝不放模型输出。
303
+ 6. `hypatia backfill --status` 报告 shelf 的嵌入债务。用当前的 hypatia,记录一个
296
304
  turn 不会增加债务;只有摘要和工作单元在下一次 flush 前处于待处理。`hypatia scope
297
305
  list --count` 显示在用的 scope——本项目 `msg-*` 条目所带的那个,就是会话种子读取
298
306
  的那个。
299
- 6. 卸载:`dsh plugin --profile web remove dsh-hypatia-auto-memory`——hypatia 条目
307
+ 7. 卸载:`dsh plugin --profile web remove dsh-hypatia-auto-memory`——hypatia 条目
300
308
  本身留在 `~/.hypatia/`。
301
309
 
302
310
  ## 故障模式
@@ -308,7 +316,7 @@ rules/taboos 预载、启动清理。设置卡片把它做成 `hypatia list` 所
308
316
  | 条目只在 turn 结束后才出现 | 设计如此:span 在 `turn/end` 切分,或当 turn 跑得比 `flushWindowMs` 久时在第一个 `step/end` 切分,所以条目不会缺它自己的工具结果 |
309
317
  | 有记录,没摘要 | `consolidation.models` 为空或无效——首次触发时告警一次 |
310
318
  | 有摘要但没有 `sum2-*` | 该项目里未归档的 tier-1 摘要还不足 `cascade.batchSize` 条 |
311
- | 工作单元没有关系 | shelf 没有嵌入模型(`similar` 失败),或所有候选都超过了 `dedupMaxDistance`。自 hypatia #19 起,本地模型在 `~/.hypatia/models/<org>/<name>`(或 Hugging Face 缓存)里找,不再挨着 shelf:一个以前能回答 `similar`、现在说 `is not installed` 的 shelf,需要 `hypatia model install <model>`,或 `hypatia model register <model> <dir>` 指向它已有的文件 |
319
+ | 工作单元没有关系 | shelf 没有嵌入模型(`similar` 失败),所有候选都超过了 `dedupMaxDistance`,或裁决调用本身没有产出裁决——`model_calls` 里表现为 `incomplete` / `max-tokens`,也就是开启了思考的路由把整个输出预算花在推理上、还没开始作答就撞了上限。现在裁决会先确认适配器声明了 `off`,再对该路由请求 `reasoningEffort: 'off'`;对无法接受它的路由则把上限提到 1024。自 hypatia #19 起,本地模型在 `~/.hypatia/models/<org>/<name>`(或 Hugging Face 缓存)里找,不再挨着 shelf:一个以前能回答 `similar`、现在说 `is not installed` 的 shelf,需要 `hypatia model install <model>`,或 `hypatia model register <model> <dir>` 指向它已有的文件 |
312
320
  | `similar` 仍返回 `msg-*` 行 | 它们是在本插件让日志层退出嵌入之前写的,或由一个没有 `--no-embed` 的 hypatia 写的。在 shelf 的 `shelf.toml` 里用 `embedding.skip_tags = ["message"]` 加一次 `hypatia backfill` 一次性收回知识向量(比该 key 旧的二进制会拒绝打开 shelf,所以先升级所有共享它的二进制)。不要把 `session` 加进那个列表:`skip_tags` 匹配任何带该 tag 的条目,而人们写的关于会话的知识也带它——在试过的 shelf 上就有一条这样的条目丢了向量。插件自己的 `session-*` 节点很少,现在已逐次写入退出。三元组没有 tags 也没有 update 命令,所以之前写的 `belongTo` / `summary` 边保留向量 |
313
321
  | 任务处于 `deferred` 状态 | live store 和 persistence 都没能提供它的会话。不是错误:它不消耗尝试次数,一旦二者之一能提供就运行。通常 storage 立即回答——只有组合里没有 `sessionPersistence`,或它的读取失败(日志里找 `could not be read`)时才会持久化这个状态 |
314
322
  | 任务处于 `failed` 状态 | 一次 hypatia 或模型错误在 `maxAttempts` 之后持久化了。失败的 `log-message` 记录在下次启动时被修剪,因为水位会重新推导它们的范围;其它类型保留供检查——删掉一条好让下一个触发器重新创建它 |
@@ -320,6 +328,7 @@ rules/taboos 预载、启动清理。设置卡片把它做成 `hypatia list` 所
320
328
  | 手动乱改后出现重复 `msg-*` | 在 hypatia 里删掉该条目,并在 state domain 里把该会话的 `lastLoggedSeq` 调低——回填会重新创建它一次 |
321
329
  | Agent 自己的 `hypatia` 写入仍要审批 | `autoApprove: false`(改动需重载 profile)、命令在引号外有管道/重定向/串联、或首词不是 `binaries` 之一——按设计只回答纯调用。读取根本不会走到审批,所以那里没有要修的 |
322
330
  | Agent 拿到了一份教它手动记录消息的记忆协议 | 另一个插件先注册了 `hypatia-memory`(profile 里还有 `dsh-hypatia`),或磁盘上有 `hypatia-memory`——通常是 `hypatia skill install --agent codex` 写的 `~/.agents/skills/hypatia-memory`。Agent 预设会在比插件更近的一层加载磁盘技能,所以它在会话里胜出,尽管技能中心可能列出本插件。删掉启动告警指出的那份拷贝 |
331
+ | 不知道实际用了哪个模型 | 设计上就是轮流的:span 摘要、裁决、cascade 共用一个进程内游标,所以相邻调用在 `consolidation.models` 里交替。每次尝试都落在 `~/.dsh/storages/hypatia_auto_memory_diag.json`(`model_calls`),带 purpose、provider/model、outcome 和耗时。usage ledger 答不了这个问题——它只折算 Agent 的 turn(`assistant/message`),看不见插件直连的 `llm.stream`。`pending` 行表示调用仍在进行中,或进程在调用中途死了:记录在调用前先落盘、调用结束后回填 |
323
332
  | 启动告警从不出现在终端 | `dsh web` 不挂日志导出器,所以插件任何级别的日志行都没地方去;控制台导出器的默认阈值也会丢掉警告(warn 是 2 级,高于 info 的 1 级)。临时挂一个 logger,如 `dsh-logbook` 或 `dsh-boot-doctor` 来读它们 |
324
333
  | 项目 scope 看起来不对 | Scope = 会话 cwd 的 git 顶层目录的 basename(`git rev-parse --show-toplevel`);git 找不到工作树或答不上(未安装、或某 repo 被判定不安全)时,则是 cwd 本身的 basename。一次慢于 3 秒的 `rev-parse` 会让那个会话留在 cwd 自己的名字上。两个同名 checkout 按设计共享 scope;linked worktree 按它自己的目录而非主 checkout 的目录定 scope;git 会解析符号链接,所以通过一个与目标不同名的链接打开的 checkout 会得到目标的名字。一个会被 hypatia 改写的名字会先归一化:位于 `/` 的会话定 scope 为 `/`,逗号变 `_`,去掉首尾空白。这些修复之前写的条目留在原处:子目录会话写在那个目录名下(`repo/src` 写在 `src` 下——git 根从未被读取)、位于 `/` 的会话无 scope、`a,b` 同时写在 `a` 和 `b` 下、`foo,` 写在 `foo` 和全局下。带空白的名字被去空白存储,这也正是查询现在所请求的。没有任何东西迁移它们;`knowledge-update --scopes` 可移动单条并保留其 `created_at`。`hypatia scope list --count`(hypatia #30)显示每种在用的拼写及其条目数 |
325
334
 
@@ -394,6 +403,7 @@ dsh-hypatia-auto-memory/
394
403
  │ ├── writer.js # 幂等 get-before-create 写入
395
404
  │ ├── consolidator.js # 阈值、prompt、llm.stream、校验
396
405
  │ ├── cascade.js # log₁₆(n) 分层摘要归档
406
+ │ ├── model-log.js # 每次尝试实际用了哪个模型的有界记录
397
407
  │ ├── recall.js # 会话启动时的 rules/taboos 预载
398
408
  │ ├── auto-approve.js # 批准 Agent 自己的纯 bash hypatia 调用
399
409
  │ ├── skills.js # 随包技能注册(从不遮蔽其它提供者)
package/lib/client.js CHANGED
@@ -108,45 +108,45 @@ window.__ModuleLoader__.load({
108
108
  document.head.appendChild(tag);
109
109
  }
110
110
  var SettingsCard_module_css_default = {
111
- "limitsTitle": "YVNYya_limitsTitle",
112
- "modelGroup": "YVNYya_modelGroup",
113
- "model": "YVNYya_model",
114
- "headText": "YVNYya_headText",
111
+ "advancedHint": "YVNYya_advancedHint",
112
+ "body": "YVNYya_body",
115
113
  "card": "YVNYya_card",
116
- "readOnly": "YVNYya_readOnly",
117
114
  "cardOpen": "YVNYya_cardOpen",
118
- "hint": "YVNYya_hint",
119
- "group": "YVNYya_group",
120
- "label": "YVNYya_label",
121
- "providerName": "YVNYya_providerName",
122
115
  "catalogError": "YVNYya_catalogError",
116
+ "chevron": "YVNYya_chevron",
123
117
  "chevronOpen": "YVNYya_chevronOpen",
124
- "pending": "YVNYya_pending",
125
- "unavailable": "YVNYya_unavailable",
126
- "input": "YVNYya_input",
127
- "reset": "YVNYya_reset",
128
118
  "description": "YVNYya_description",
129
- "select": "YVNYya_select",
119
+ "error": "YVNYya_error",
130
120
  "field": "YVNYya_field",
131
- "switchOn": "YVNYya_switchOn",
132
- "advancedHint": "YVNYya_advancedHint",
133
- "name": "YVNYya_name",
134
- "status": "YVNYya_status",
135
- "switchThumb": "YVNYya_switchThumb",
136
- "route": "YVNYya_route",
137
- "models": "YVNYya_models",
121
+ "fieldHead": "YVNYya_fieldHead",
122
+ "footer": "YVNYya_footer",
123
+ "group": "YVNYya_group",
138
124
  "groupTitle": "YVNYya_groupTitle",
139
- "error": "YVNYya_error",
125
+ "headText": "YVNYya_headText",
126
+ "header": "YVNYya_header",
127
+ "hint": "YVNYya_hint",
128
+ "input": "YVNYya_input",
129
+ "label": "YVNYya_label",
130
+ "limitsTitle": "YVNYya_limitsTitle",
131
+ "model": "YVNYya_model",
132
+ "modelGroup": "YVNYya_modelGroup",
133
+ "modelName": "YVNYya_modelName",
140
134
  "modelSelection": "YVNYya_modelSelection",
141
- "footer": "YVNYya_footer",
135
+ "models": "YVNYya_models",
136
+ "name": "YVNYya_name",
137
+ "notice": "YVNYya_notice",
142
138
  "pairedFields": "YVNYya_pairedFields",
143
- "body": "YVNYya_body",
144
- "modelName": "YVNYya_modelName",
139
+ "pending": "YVNYya_pending",
140
+ "providerName": "YVNYya_providerName",
141
+ "readOnly": "YVNYya_readOnly",
142
+ "reset": "YVNYya_reset",
143
+ "route": "YVNYya_route",
144
+ "select": "YVNYya_select",
145
+ "status": "YVNYya_status",
145
146
  "switch": "YVNYya_switch",
146
- "chevron": "YVNYya_chevron",
147
- "notice": "YVNYya_notice",
148
- "header": "YVNYya_header",
149
- "fieldHead": "YVNYya_fieldHead",
147
+ "switchOn": "YVNYya_switchOn",
148
+ "switchThumb": "YVNYya_switchThumb",
149
+ "unavailable": "YVNYya_unavailable",
150
150
  "warning": "YVNYya_warning"
151
151
  };
152
152
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-hypatia-auto-memory",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Event-driven Hypatia memory for DSH: automatic conversation logging via session events, queued background consolidation (span summaries + work-unit extraction) with a dedicated model route, and recall injection — zero per-call model work in the main session.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/cascade.js CHANGED
@@ -22,6 +22,7 @@
22
22
  import { createHash } from 'node:crypto'
23
23
 
24
24
  import { PLUGIN_NAME } from './consolidator.js'
25
+ import { NULL_MODEL_LOG } from './model-log.js'
25
26
 
26
27
  /** Tag marking the tier an entry belongs to. */
27
28
  export function levelTag(level) {
@@ -80,9 +81,10 @@ function archiveInstruction(level, count) {
80
81
  * selectRoute: (routes: any) => any,
81
82
  * getConfig: () => any,
82
83
  * status: import('./status.js').StatusLog,
84
+ * modelLog?: ReturnType<import('./model-log.js').createModelLog>,
83
85
  * }} deps
84
86
  */
85
- export function createCascade({ cli, llm, selectRoute, getConfig, status }) {
87
+ export function createCascade({ cli, llm, selectRoute, getConfig, status, modelLog = NULL_MODEL_LOG }) {
86
88
  /**
87
89
  * Ask the model to archive one batch.
88
90
  * @returns {Promise<{title: string, summary: string} | undefined>}
@@ -93,8 +95,11 @@ export function createCascade({ cli, llm, selectRoute, getConfig, status }) {
93
95
  return `### ${i + 1}. ${row.name}\n${data}`
94
96
  }).join('\n\n')
95
97
 
98
+ const attempt = modelLog.begin('memory-cascade', route)
96
99
  const controller = new AbortController()
97
100
  const timer = setTimeout(() => controller.abort(), timeoutMs)
101
+ let outcome = 'incomplete'
102
+ let detail = ''
98
103
  try {
99
104
  const { createUserMessage, BlockAssembler } = await import('@deepseek-ai/dsh-llm')
100
105
  const assembler = new BlockAssembler()
@@ -112,18 +117,38 @@ export function createCascade({ cli, llm, selectRoute, getConfig, status }) {
112
117
  })) {
113
118
  assembler.push(chunk)
114
119
  }
115
- if (assembler.finish.kind !== 'stop') return undefined
120
+ if (assembler.finish.kind !== 'stop') {
121
+ detail = String(assembler.finish.kind)
122
+ return undefined
123
+ }
116
124
  const text = assembler.blocks().filter((b) => b.type === 'text').map((b) => b.text).join('\n').trim()
117
125
  const fence = /^```(?:json)?\s*([\s\S]*?)```\s*$/.exec(text)
118
- const parsed = JSON.parse(fence ? fence[1].trim() : text)
126
+ // Its own catch: a parse failure is an unusable reply, not a failed call,
127
+ // and Node's parse error quotes the reply's opening characters.
128
+ let parsed
129
+ try {
130
+ parsed = JSON.parse(fence ? fence[1].trim() : text)
131
+ } catch {
132
+ detail = 'unparseable reply'
133
+ return undefined
134
+ }
119
135
  const summary = typeof parsed?.summary === 'string' ? parsed.summary.trim() : ''
120
- if (summary === '') return undefined
136
+ if (summary === '') {
137
+ detail = 'no summary'
138
+ return undefined
139
+ }
121
140
  const title = typeof parsed?.title === 'string' && parsed.title.trim() !== ''
122
141
  ? parsed.title.trim()
123
142
  : `Archive tier ${level}`
143
+ outcome = 'ok'
124
144
  return { title, summary }
145
+ } catch (error) {
146
+ outcome = 'error'
147
+ detail = error instanceof Error ? error.message : String(error)
148
+ throw error
125
149
  } finally {
126
150
  clearTimeout(timer)
151
+ void attempt.finish(outcome, detail)
127
152
  }
128
153
  }
129
154
 
@@ -19,9 +19,32 @@ import { countLoggableMessages, isCompactionReplacement, isLoggableMessage } fro
19
19
  import { messageName, summaryName } from './writer.js'
20
20
  import { EMPTY_PROGRESS, advanceProgress } from './progress.js'
21
21
  import { TaskDeferredError } from './queue.js'
22
+ import { NULL_MODEL_LOG } from './model-log.js'
22
23
 
23
24
  export const PLUGIN_NAME = 'dsh-hypatia-auto-memory'
24
25
 
26
+ /**
27
+ * Output budget for one adjudication.
28
+ *
29
+ * It is not 200 — the value it carried for as long as the call existed — because
30
+ * a thinking-enabled route spends the SAME cap on its reasoning and its answer.
31
+ * `deepseek-*` resolves an omitted `reasoningEffort` to `high` (the
32
+ * `llm-deepseek` profile default) and puts roughly half its output tokens into
33
+ * reasoning, so a 200-token adjudication ended with `max-tokens` before the
34
+ * verdict was ever emitted: every other work unit was silently stored with no
35
+ * relationship at all. `ADJUDICATION_REASONING_EFFORT` below usually removes
36
+ * the need for the headroom, but a route that cannot honour `off` — or a
37
+ * provider that ignores the control — still has to fit thinking plus a verdict.
38
+ */
39
+ export const ADJUDICATION_MAX_TOKENS = 1024
40
+
41
+ /**
42
+ * What the adjudication call asks for on a route that advertises it. A verdict
43
+ * is a six-way classification of two short texts: thinking there spends the
44
+ * output budget and the latency for nothing.
45
+ */
46
+ export const ADJUDICATION_REASONING_EFFORT = 'off'
47
+
25
48
  /** Distinguishes permanent failures (no retry value) from transient ones. */
26
49
  export class PermanentConsolidationError extends Error {
27
50
  constructor(message) {
@@ -263,13 +286,46 @@ export function parseConsolidationOutput(raw, maxWorkUnits) {
263
286
  * writer: ReturnType<import('./writer.js').createWriter>,
264
287
  * getConfig: () => any,
265
288
  * status: import('./status.js').StatusLog,
289
+ * modelLog?: ReturnType<import('./model-log.js').createModelLog>,
266
290
  * projectFor: (session: any) => Promise<string>,
267
291
  * }} deps
268
292
  */
269
- export function createConsolidator({ queue, progress, sessions, llm, cli, writer, getConfig, status, projectFor }) {
293
+ export function createConsolidator({ queue, progress, sessions, llm, cli, writer, getConfig, status, modelLog = NULL_MODEL_LOG, projectFor }) {
270
294
  let warnedNoRoute = false
271
295
  const selectRoute = createConsolidationRouteSelector()
272
296
 
297
+ /**
298
+ * Whether a route may be asked for {@link ADJUDICATION_REASONING_EFFORT},
299
+ * memoized per route for this instance.
300
+ *
301
+ * The core rejects a request whose effort the model does not advertise
302
+ * (`UNSUPPORTED_REASONING_EFFORT`, `llm`'s `resolveCallWithInfo`), so the
303
+ * control only follows a positive capability answer. A route that cannot be
304
+ * inspected — an adapter that throws, or the `llm` double a unit test hands
305
+ * in — simply gets no effort, which is what every call did before this.
306
+ * Capability does not change while an instance lives; a profile reload
307
+ * rebuilds the instance and the cache with it.
308
+ *
309
+ * @param {{provider: string, model: string}} route
310
+ * @param {AbortSignal} signal - the same signal the call carries, so a
311
+ * lookup cannot outlive the timeout it is part of.
312
+ */
313
+ const thinkingOff = new Map()
314
+ async function canDisableThinking(route, signal) {
315
+ const key = `${route.provider}\0${route.model}`
316
+ if (thinkingOff.has(key)) return thinkingOff.get(key)
317
+ let supported = false
318
+ try {
319
+ const info = await llm.resolveModelInfo?.(route.provider, route.model, signal)
320
+ supported = Array.isArray(info?.reasoning?.efforts)
321
+ && info.reasoning.efforts.some((effort) => effort?.id === ADJUDICATION_REASONING_EFFORT)
322
+ } catch (error) {
323
+ status.warn(`reasoning capability lookup failed for ${key.replace('\0', '/')}: ${String(error)}`)
324
+ }
325
+ thinkingOff.set(key, supported)
326
+ return supported
327
+ }
328
+
273
329
  /**
274
330
  * Turn-end trigger check. The turn threshold is a minimum interval between
275
331
  * accepted consolidation tasks, not a polling cadence: once the interval
@@ -388,9 +444,14 @@ export function createConsolidator({ queue, progress, sessions, llm, cli, writer
388
444
  listed,
389
445
  ].join('\n')
390
446
 
447
+ const attempt = modelLog.begin('memory-adjudication', route)
391
448
  const controller = new AbortController()
392
449
  const timer = setTimeout(() => controller.abort(), consolidation.timeoutMs)
450
+ let outcome = 'incomplete'
451
+ let detail = ''
393
452
  try {
453
+ // Asked for only when the route advertises it; see `canDisableThinking`.
454
+ const skipThinking = await canDisableThinking(route, controller.signal)
394
455
  const { createUserMessage, BlockAssembler } = await import('@deepseek-ai/dsh-llm')
395
456
  const assembler = new BlockAssembler()
396
457
  for await (const chunk of llm.stream({
@@ -401,20 +462,41 @@ export function createConsolidator({ queue, progress, sessions, llm, cli, writer
401
462
  content: [{ type: 'text', text: instruction }],
402
463
  source: { kind: 'plugin', plugin: PLUGIN_NAME },
403
464
  })],
404
- maxTokens: 200,
465
+ maxTokens: ADJUDICATION_MAX_TOKENS,
466
+ ...skipThinking ? { reasoningEffort: ADJUDICATION_REASONING_EFFORT } : {},
405
467
  purpose: 'memory-adjudication',
406
468
  signal: controller.signal,
407
469
  })) {
408
470
  assembler.push(chunk)
409
471
  }
410
- if (assembler.finish.kind !== 'stop') return undefined
472
+ if (assembler.finish.kind !== 'stop') {
473
+ detail = String(assembler.finish.kind)
474
+ return undefined
475
+ }
411
476
  const text = assembler.blocks().filter((b) => b.type === 'text').map((b) => b.text).join('\n').trim()
412
477
  const fence = /^```(?:json)?\s*([\s\S]*?)```\s*$/.exec(text)
413
- const parsed = JSON.parse(fence ? fence[1].trim() : text)
414
- if (typeof parsed?.verdict !== 'string') return undefined
478
+ // Its own catch: a parse failure is an unusable reply, not a failed call,
479
+ // and Node's parse error quotes the reply's opening characters.
480
+ let parsed
481
+ try {
482
+ parsed = JSON.parse(fence ? fence[1].trim() : text)
483
+ } catch {
484
+ detail = 'unparseable reply'
485
+ return undefined
486
+ }
487
+ if (typeof parsed?.verdict !== 'string') {
488
+ detail = 'no verdict'
489
+ return undefined
490
+ }
491
+ outcome = 'ok'
415
492
  return { verdict: parsed.verdict, target: typeof parsed.target === 'string' ? parsed.target : '' }
493
+ } catch (error) {
494
+ outcome = 'error'
495
+ detail = error instanceof Error ? error.message : String(error)
496
+ throw error
416
497
  } finally {
417
498
  clearTimeout(timer)
499
+ void attempt.finish(outcome, detail)
418
500
  }
419
501
  }
420
502
 
@@ -491,44 +573,84 @@ export function createConsolidator({ queue, progress, sessions, llm, cli, writer
491
573
  // watermark and the next trigger resumes from exactly there.
492
574
  const coveredToSeq = complete ? task.toSeq : lastSeq + 1
493
575
 
576
+ const attempt = modelLog.begin('memory-consolidation', route)
494
577
  const controller = new AbortController()
495
578
  const timer = setTimeout(() => controller.abort(), consolidation.timeoutMs)
496
- let assembled
579
+ let outcome = 'incomplete'
580
+ let detail = ''
581
+ let parsedOutput
497
582
  try {
498
- const { createUserMessage, BlockAssembler } = await import('@deepseek-ai/dsh-llm')
499
- const assembler = new BlockAssembler()
500
- const request = createUserMessage({
501
- content: [{ type: 'text', text: `${consolidationInstruction(consolidation.maxWorkUnitsPerRun)}\n\n<transcript>\n${transcript}\n</transcript>` }],
502
- source: { kind: 'plugin', plugin: PLUGIN_NAME },
503
- })
504
- for await (const chunk of llm.stream({
505
- provider: route.provider,
506
- model: route.model,
507
- system: 'You produce strict JSON only. You never add prose around it.',
508
- messages: [request],
509
- maxTokens: consolidation.maxOutputTokens,
510
- purpose: 'memory-consolidation',
511
- signal: controller.signal,
512
- })) {
513
- assembler.push(chunk)
583
+ let assembled
584
+ try {
585
+ const { createUserMessage, BlockAssembler } = await import('@deepseek-ai/dsh-llm')
586
+ const assembler = new BlockAssembler()
587
+ const request = createUserMessage({
588
+ content: [{ type: 'text', text: `${consolidationInstruction(consolidation.maxWorkUnitsPerRun)}\n\n<transcript>\n${transcript}\n</transcript>` }],
589
+ source: { kind: 'plugin', plugin: PLUGIN_NAME },
590
+ })
591
+ for await (const chunk of llm.stream({
592
+ provider: route.provider,
593
+ model: route.model,
594
+ system: 'You produce strict JSON only. You never add prose around it.',
595
+ messages: [request],
596
+ maxTokens: consolidation.maxOutputTokens,
597
+ purpose: 'memory-consolidation',
598
+ signal: controller.signal,
599
+ })) {
600
+ assembler.push(chunk)
601
+ }
602
+ assembled = assembler
603
+ } finally {
604
+ clearTimeout(timer)
514
605
  }
515
- assembled = assembler
516
- } finally {
517
- clearTimeout(timer)
518
- }
519
- const finish = assembled.finish
520
- if (finish.kind !== 'stop') {
521
- if (finish.kind === 'max-tokens') {
522
- throw new PermanentConsolidationError('consolidation hit the output token cap (truncated JSON)')
606
+ // The attempt stays open through parsing: a stream that ended with `stop`
607
+ // but yields no usable summary is `incomplete`, not `ok` — the cursor was
608
+ // consumed, nothing was stored, and a table that said otherwise would
609
+ // send a reader looking for a summary that does not exist. The throws are
610
+ // re-issued unchanged, so recording never changes which error the queue
611
+ // sees.
612
+ const finish = assembled.finish
613
+ if (finish.kind !== 'stop') {
614
+ // The thrown message is computed exactly as it was before this attempt
615
+ // was recorded: `error` AND `aborted` carry the provider's reason, and
616
+ // losing it for `aborted` would replace a timeout with a bare
617
+ // 'aborted' in the task row. `detail` uses the same message; `finish`
618
+ // caps it.
619
+ const message = finish.kind === 'error' || finish.kind === 'aborted'
620
+ ? finish.failure?.message ?? finish.kind
621
+ : finish.kind
622
+ if (finish.kind === 'error') outcome = 'error'
623
+ detail = String(message)
624
+ throw finish.kind === 'max-tokens'
625
+ ? new PermanentConsolidationError('consolidation hit the output token cap (truncated JSON)')
626
+ : new Error(`consolidation model call failed: ${message}`)
627
+ }
628
+ const text = assembled.blocks()
629
+ .filter((block) => block.type === 'text')
630
+ .map((block) => block.text)
631
+ .join('\n')
632
+ try {
633
+ parsedOutput = parseConsolidationOutput(text, consolidation.maxWorkUnitsPerRun)
634
+ } catch (parseError) {
635
+ // A parse failure is an unusable reply, not a failed call, and its
636
+ // message quotes the reply — so it never becomes `detail`. See the
637
+ // `detail` contract in model-log.js.
638
+ detail = 'unusable output'
639
+ throw parseError
523
640
  }
524
- const message = finish.kind === 'error' || finish.kind === 'aborted' ? finish.failure?.message ?? finish.kind : finish.kind
525
- throw new Error(`consolidation model call failed: ${message}`)
641
+ outcome = 'ok'
642
+ } catch (error) {
643
+ // Only a throw that arrived with nothing classified is a failed call
644
+ // rather than an unusable reply.
645
+ if (outcome === 'incomplete' && detail === '') {
646
+ outcome = 'error'
647
+ detail = error instanceof Error ? error.message : String(error)
648
+ }
649
+ throw error
650
+ } finally {
651
+ void attempt.finish(outcome, detail)
526
652
  }
527
- const text = assembled.blocks()
528
- .filter((block) => block.type === 'text')
529
- .map((block) => block.text)
530
- .join('\n')
531
- const { summary, workUnits } = parseConsolidationOutput(text, consolidation.maxWorkUnitsPerRun)
653
+ const { summary, workUnits } = parsedOutput
532
654
 
533
655
  const span = summaryName(task.sessionId, fromSeq, coveredToSeq)
534
656
  // The entries the log executor actually wrote for this span. Both sides
package/src/index.js CHANGED
@@ -37,6 +37,7 @@ import { TaskDeferredError, createQueue } from './queue.js'
37
37
  import { countLoggableMessages, createCollector, formatSpan } from './collector.js'
38
38
  import { createCascade } from './cascade.js'
39
39
  import { createConsolidator, PLUGIN_NAME } from './consolidator.js'
40
+ import { openModelLog } from './model-log.js'
40
41
  import { createRecall } from './recall.js'
41
42
  import { createPersistedSessions } from './persisted-session.js'
42
43
  import { createAutoApprove } from './auto-approve.js'
@@ -93,6 +94,20 @@ function applyCollect(ctx, cordisConfig) {
93
94
  progress: shelfTable(openedState.progress, shelf),
94
95
  tasks: shelfTable(openedState.tasks, shelf),
95
96
  }
97
+ // Which model a background attempt ran on is otherwise unknowable from
98
+ // outside the process: the cursor that picks it is process-local, the host
99
+ // logger scrolls away, and the usage ledger cannot see plugin calls. Kept in
100
+ // its own domain so a diagnostics row can never reject the open of the
101
+ // watermarks (see model-log.js). Not shelf-scoped: the route list is config,
102
+ // and the question is about this process, not this shelf.
103
+ const openedModelLog = await openModelLog(ctx, { status })
104
+ if (disposed) {
105
+ // Same race as the state domain above: if the fiber went away while this
106
+ // was opening, its disposer may never run, so close the handle here.
107
+ await openedModelLog.domain?.close().catch(() => {})
108
+ return
109
+ }
110
+ const modelLog = openedModelLog.modelLog
96
111
  let announcedShelf = shelf
97
112
  configHandle.onChange((value) => {
98
113
  if (value.shelf === announcedShelf) return
@@ -364,6 +379,7 @@ function applyCollect(ctx, cordisConfig) {
364
379
  writer,
365
380
  getConfig: configHandle.get,
366
381
  status,
382
+ modelLog,
367
383
  projectFor: collector.projectFor,
368
384
  })
369
385
  queue.registerExecutor('consolidate', consolidator.execute)
@@ -376,6 +392,7 @@ function applyCollect(ctx, cordisConfig) {
376
392
  selectRoute: consolidator.selectRoute,
377
393
  getConfig: configHandle.get,
378
394
  status,
395
+ modelLog,
379
396
  })
380
397
  queue.registerExecutor('cascade', cascade.execute)
381
398
  shared.consolidator = consolidator
@@ -0,0 +1,246 @@
1
+ /**
2
+ * Durable, bounded record of the model attempts the background pipeline makes.
3
+ *
4
+ * Why this exists. The route one consolidation attempt runs on is chosen by a
5
+ * process-local round-robin cursor (`createConsolidationRouteSelector`) and,
6
+ * before this, was dropped the moment the call was made. "Which model actually
7
+ * summarised that span?" was then unanswerable from outside the process: the
8
+ * settings card shows the configured route LIST, the host logger goes to a
9
+ * terminal that scrolls away, and the usage ledger only folds agent turns — it
10
+ * cannot see a plugin's direct `llm.stream` call at all (see
11
+ * `@linxin666/dsh-usage` `usage-service.ts`, which folds `assistant/message`).
12
+ *
13
+ * The record is written at SELECTION time and updated when the attempt settles,
14
+ * so a row left at `pending` is itself evidence: the process died, or the
15
+ * attempt is still in flight. That distinguishes the three states the cursor
16
+ * alone cannot — selected, completed, and consumed-but-produced-nothing.
17
+ *
18
+ * It lives in its OWN storage domain rather than in the one holding watermarks
19
+ * and queued tasks. In that domain a record failing its schema rejects the whole
20
+ * open and silently disables the plugin (see the note on `taskTable` in
21
+ * state.js); diagnostics must never be able to take the authoritative data down
22
+ * with them. Here the blast radius is one bounded table nobody's correctness
23
+ * depends on. `openModelLog` therefore degrades to a logger-only log when the
24
+ * domain will not open, and callers keep working.
25
+ *
26
+ * @module dsh-hypatia-auto-memory/model-log
27
+ */
28
+
29
+ import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain'
30
+ import { z } from 'zod'
31
+
32
+ /** How many attempts to keep. A ring large enough to answer "the last run(s)". */
33
+ export const MODEL_CALL_LIMIT = 100
34
+
35
+ /**
36
+ * One model attempt. `outcome` is the state that matters:
37
+ *
38
+ * - `pending` — selected, not settled (in flight, or the process died).
39
+ * - `ok` — the call settled AND produced a usable result.
40
+ * - `incomplete` — settled without one: output cap, abort, refusal, a reply
41
+ * that did not parse, or one that parsed into nothing usable. The cursor WAS
42
+ * consumed and nothing was stored. This is a deliberate catch-all: from
43
+ * outside, every one of those looks the same, and a caller that reported `ok`
44
+ * for a stop that yielded no summary would make the table lie.
45
+ * - `error` — the call itself threw (provider/transport failure).
46
+ *
47
+ * `detail` carries a finish kind, a fixed label, or a provider error message —
48
+ * never message content and never model output. The plugin's whole log path is
49
+ * built to keep conversation text out of places a later reader can stumble
50
+ * into, and a `JSON.parse` failure is exactly where that leaks by accident:
51
+ * Node quotes the offending input in the message (`Unexpected token 'H', "Here
52
+ * is th"...`). Parse sites therefore record a fixed label, and `finish` caps
53
+ * whatever it is handed, because a provider `failure.message` can carry a whole
54
+ * HTTP body.
55
+ */
56
+ export const modelCallTable = domainTable(z.object({
57
+ /** Monotonic within the process; recovered from the stored rows on open. */
58
+ seq: z.number().step(1).min(1),
59
+ /** Epoch milliseconds at selection. */
60
+ at: z.number().default(0),
61
+ /** The `purpose` the call was made with ('memory-consolidation', …). */
62
+ purpose: z.string().default(''),
63
+ provider: z.string().default(''),
64
+ model: z.string().default(''),
65
+ outcome: z.string().default('pending'),
66
+ /** Wall time from selection to settle; 0 while pending. */
67
+ ms: z.number().min(0).default(0),
68
+ detail: z.string().default(''),
69
+ }))
70
+
71
+ /** Upper bound on `detail`; see the table doc. Applied on every write. */
72
+ export const DETAIL_MAX = 200
73
+
74
+ /**
75
+ * Diagnostics domain. Separate from `hypatia_auto_memory` on purpose (module
76
+ * doc); its own version line, so the authoritative domain's format never has to
77
+ * move for a table only a human reads.
78
+ */
79
+ export const diagnosticsSpec = defineDomain({
80
+ name: 'hypatia_auto_memory_diag',
81
+ version: 1,
82
+ // Table names must match /^[a-z][a-z0-9_]*$/ — the module-load guard rejects
83
+ // camelCase before any medium is touched.
84
+ tables: { model_calls: modelCallTable },
85
+ })
86
+
87
+ /**
88
+ * Fixed-width key so lexicographic order equals sequence order. A bare numeric
89
+ * key would be re-ordered by the JSON object it is stored in (integer-like keys
90
+ * sort first, numerically), which is why ordering is carried by the padded key
91
+ * AND by the `seq` field the reader sorts on.
92
+ * @param {number} seq
93
+ */
94
+ function keyOf(seq) {
95
+ return String(seq).padStart(12, '0')
96
+ }
97
+
98
+ /**
99
+ * The log a caller gets when none was injected (unit tests, a composition
100
+ * without storage). Call sites default to it, so none of them branches on
101
+ * observability being available.
102
+ */
103
+ export const NULL_MODEL_LOG = Object.freeze({
104
+ begin: () => ({ finish: async () => {} }),
105
+ recent: () => [],
106
+ })
107
+
108
+ /**
109
+ * @param {{
110
+ * table?: any,
111
+ * status: import('./status.js').StatusLog,
112
+ * limit?: number,
113
+ * now?: () => number,
114
+ * }} deps - `table` absent (or the domain failed to open) means: log lines
115
+ * still happen, nothing is stored, and no caller has to branch.
116
+ */
117
+ export function createModelLog({ table, status, limit = MODEL_CALL_LIMIT, now = Date.now }) {
118
+ let counter = 0
119
+ if (table !== undefined) {
120
+ // Every stored row passed the schema at open, so `seq` is always the number
121
+ // the padded key was derived from.
122
+ for (const [, row] of table.entries()) {
123
+ if (row.seq > counter) counter = row.seq
124
+ }
125
+ }
126
+
127
+ function onWriteError(error) {
128
+ status.warn(`model-call log write failed: ${String(error)}`)
129
+ }
130
+
131
+ /** Keep the newest `limit` rows; drop the rest oldest-first. */
132
+ async function prune() {
133
+ while (table.size > limit) {
134
+ let oldest
135
+ for (const [key, row] of table.entries()) {
136
+ if (oldest === undefined || row.seq < oldest.row.seq) oldest = { key, row }
137
+ }
138
+ if (oldest === undefined) return
139
+ await table.delete(oldest.key)
140
+ }
141
+ }
142
+
143
+ async function write(key, record) {
144
+ try {
145
+ await table.put(key, record)
146
+ await prune()
147
+ } catch (error) {
148
+ onWriteError(error)
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Record one attempt as it is selected, before the call is made.
154
+ *
155
+ * @param {string} purpose - The same string handed to `llm.stream`.
156
+ * @param {{provider: string, model: string}} route
157
+ * @returns {{seq: number, finish: (outcome: string, detail?: string) => Promise<void>}}
158
+ */
159
+ function begin(purpose, route) {
160
+ counter += 1
161
+ const seq = counter
162
+ const at = now()
163
+ const key = keyOf(seq)
164
+ const record = {
165
+ seq,
166
+ at,
167
+ purpose,
168
+ provider: String(route?.provider ?? ''),
169
+ model: String(route?.model ?? ''),
170
+ outcome: 'pending',
171
+ ms: 0,
172
+ detail: '',
173
+ }
174
+ // Durable before the call runs, so an attempt that never settles still
175
+ // leaves a row. Awaited by `finish`, never by the caller.
176
+ const stored = table === undefined ? Promise.resolve() : write(key, record)
177
+ return {
178
+ seq,
179
+ async finish(outcome, detail = '') {
180
+ // Never rejects, and callers do not await it: every one of them calls
181
+ // this from a `finally`, where a throw here would replace the error the
182
+ // queue was going to see, and a storage stall would hold the queue task
183
+ // open after its own timeout has already been cleared.
184
+ try {
185
+ await stored
186
+ const ms = Math.max(0, now() - at)
187
+ const bounded = String(detail).slice(0, DETAIL_MAX)
188
+ if (table !== undefined) {
189
+ await write(key, { ...record, outcome, ms, detail: bounded })
190
+ }
191
+ const suffix = bounded === '' ? '' : ` (${bounded})`
192
+ status.info(`model call: ${purpose} ${record.provider}/${record.model} ${outcome} ${ms}ms${suffix}`)
193
+ } catch (error) {
194
+ onWriteError(error)
195
+ }
196
+ },
197
+ }
198
+ }
199
+
200
+ /** Newest-first snapshot, for a human reading the state file or a card. */
201
+ function recent(count = 20) {
202
+ if (table === undefined) return []
203
+ return [...table.entries()]
204
+ .map(([, row]) => row)
205
+ .sort((a, b) => Number(b?.seq) - Number(a?.seq))
206
+ .slice(0, count)
207
+ }
208
+
209
+ return { begin, recent }
210
+ }
211
+
212
+ /**
213
+ * Open the diagnostics domain and build the log. Never throws: a domain that
214
+ * will not open costs the stored history, not the memory pipeline.
215
+ *
216
+ * @param {import('@deepseek-ai/cordis').Context} ctx - context injecting `storageDomain`.
217
+ * @param {{status: import('./status.js').StatusLog, limit?: number, now?: () => number}} deps
218
+ * @returns {Promise<{modelLog: ReturnType<typeof createModelLog>, domain?: any}>}
219
+ * `domain` is the open handle when there is one, so a caller that finds itself
220
+ * disposed can close it (mirrors `openState`).
221
+ */
222
+ export async function openModelLog(ctx, { status, limit, now }) {
223
+ let domain
224
+ try {
225
+ domain = await ctx.storageDomain.open(diagnosticsSpec)
226
+ } catch (error) {
227
+ status.warn(`model-call log unavailable, attempts will only be logged: ${String(error)}`)
228
+ return { modelLog: createModelLog({ status, limit, now }) }
229
+ }
230
+ try {
231
+ ctx.effect(() => () => {
232
+ void domain.close().catch(() => {
233
+ // Closing during teardown must never take the fiber down.
234
+ })
235
+ }, 'hypatia-auto-memory: close diagnostics domain')
236
+ } catch {
237
+ // The fiber went away while the domain was opening, so no disposer will
238
+ // ever run for it — closing here is the only thing that prevents a leak.
239
+ // Deliberately silent: the plugin is being torn down, and a warning about
240
+ // an unavailable log would be noise at exactly the moment nothing can act
241
+ // on it.
242
+ await domain.close().catch(() => {})
243
+ return { modelLog: createModelLog({ status, limit, now }) }
244
+ }
245
+ return { modelLog: createModelLog({ table: domain.table('model_calls'), status, limit, now }), domain }
246
+ }