memory-eternal 0.9.7 → 0.9.9
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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/README.en.md +36 -0
- package/README.md +36 -0
- package/index.js +38 -13
- package/lib/capture-run.js +29 -3
- package/lib/capture.js +75 -13
- package/lib/client.js +19 -14
- package/lib/stall.js +30 -0
- package/lib/vault.js +76 -8
- package/lib/watchdog.js +57 -0
- package/package.json +2 -2
- package/web/app.js +23 -18
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://anthropic.com/claude-code/plugin.schema.json",
|
|
3
3
|
"name": "memory-eternal",
|
|
4
|
-
"version": "0.9.
|
|
4
|
+
"version": "0.9.9",
|
|
5
5
|
"description": "璁板繂鏍稿績锛圡emory Eternal锛夛細鐭ヨ瘑鍗¤嚜鍔ㄦ矇娣€/鍙洖锛岀粺涓€ ~/.dsh/memory-vault锛屼細璇濈粨鏉熻嚜鍔ㄥ叆搴撹繘寰呭鏍搞€?,
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "EternalNight996",
|
package/README.en.md
CHANGED
|
@@ -154,6 +154,23 @@ Three concepts, don't mix them:
|
|
|
154
154
|
| Auto recall | on | AI auto-queries memory when it needs history |
|
|
155
155
|
| Vault dir | `~/.dsh/memory-vault` | where memory lives, plain Markdown & git-able |
|
|
156
156
|
|
|
157
|
+
#### Multiple vaults (per-project isolation)
|
|
158
|
+
|
|
159
|
+
Two extra fields in `memory-eternal-config.json`:
|
|
160
|
+
|
|
161
|
+
```json
|
|
162
|
+
{
|
|
163
|
+
"vaultProfiles": [{ "name": "work", "path": "D:/vaults/work" }],
|
|
164
|
+
"activeVault": "work"
|
|
165
|
+
}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Resolution order: `MEMORY_VAULT_DIR` env → the `path` of the `vaultProfiles` entry whose `name` matches `activeVault` → `vaultDir` → default `~/.dsh/memory-vault`.
|
|
169
|
+
|
|
170
|
+
**Since v0.9.8 the CLI / MCP / hooks / standalone web / sweep share the host's exact order** — previously only the host honoured `activeVault`, so switching vaults left the terminal and hooks writing to the default vault and split your memory in two.
|
|
171
|
+
|
|
172
|
+
> Note: the settings page still has no form for `vaultProfiles` / `activeVault` (edit the config file by hand), and per-workspace auto-selection is not implemented yet.
|
|
173
|
+
|
|
157
174
|
### 2. Save money (important)
|
|
158
175
|
|
|
159
176
|
| Setting | Default | In plain words |
|
|
@@ -260,6 +277,8 @@ npm run build # builds lib/client.js (DSH embed) + web/app.js (standalone web
|
|
|
260
277
|
|
|
261
278
|
| Version | Date | Highlights |
|
|
262
279
|
|---|---|---|
|
|
280
|
+
| **v0.9.9** | 2026-09-28 | **Fixed the sidebar "Memory" entry being pushed out of view (issue #2)**: the slot host comes in two shapes — the shell's own horizontal row (`.footerActions`) and a `div[data-slot="sidebar.footer.action"]` that other plugins (`dsh-diff-approval`, `dsh-footer-order`, …) turn into `flex-direction: column`. The old CSS applied `flex-wrap: wrap` plus `.me-footer { flex: 1 1 100% }` to both: inside a column container `wrap` means "new column" and `basis: 100%` means **100% height**, so the button was pushed into an overflow column and clipped to a sliver. `flex-wrap` is now scoped to the horizontal `.footerActions` only and `.me-footer` uses the direction-agnostic `flex: 0 0 auto`, guarded by a new SSR assertion. Also: bilingual READMEs gained a "Community & Support" section (group QR + WeChat pay/reward codes), plus `SPONSOR.md` and `.github/FUNDING.yml` |
|
|
281
|
+
| **v0.9.8** | 2026-09-28 | **Backlog burn-down release.** ① **Capture reliability** (issues #3 / #4): distillation no longer hardcodes `providers[0]` — new `captureProvider` / `captureModel` config plus ordered fallback across providers; adapter errors carried by the stream's terminal `finish` block (missing credential, rate limit, …) are now **visible failures** (code/message in the capture log, red health state, raw-card fallback annotated with the cause) instead of a vague "distillation produced nothing"; the stall detector moved from cumulative-counter deltas to a **sliding window** (alerts only when turns keep starting with zero finishes inside 20 min), fixing both false alarms and misses. ② **Independent-process vault consistency** (dsh-memory-eternal#5): CLI / MCP / hooks / standalone web / sweep now share the host's resolution order (`MEMORY_VAULT_DIR` → `activeVault` → `vaultDir` → default), so switching vaults no longer splits your memory in two. ③ **Watchdog singleton lock** (dsh-memory-eternal#6): a new process exits when a live watchdog already owns the port, and stale pid records are cleaned, ending the orphan pile-up on every restart. ④ **Graph performance**: tag edges became "Top-K by shared tags" (measured **24,193 → 2,763** edges at 593 cards), similarity switched to a **rare-bigram inverted index** (cold **948 → ~520ms**, warm 2-4ms), payload **3,531KB → 579KB**, and the client dim pass now uses an **adjacency set** (**52ms → <1ms** per frame). ⑤ Five new test files (routing/finish, stall window, vault resolution, watchdog lock, graph perf baseline); `npm test` now covers **85 checks** |
|
|
263
282
|
| **v0.9.7** | 2026-09-28 | **Cards open fully expanded (and can be collapsed) + new body typography**: the reader no longer folds to 300px by default — it **opens showing the whole card**, with a one-click collapse for long ones (and an "Expand ⌄" hint when folded). Frontmatter is lifted out of the body into **colored chips** (kind / audit status / tags / dates / source; kind reuses the graph palette) instead of leaking raw YAML. Body rendering is rewritten (`src/client/markdown.js`, shared by the host and the standalone web page): headings get an **emoji + color group by meaning** (conclusion ✅green / risk ⚠️amber / root cause 🔍blue / plan 🛠️purple / example 💡pink), consecutive `- ` lines become a real `<ul>`, `1.` becomes an `<ol>` with numbered circles, `- [x]` becomes a checkbox, `>` becomes an emoji callout, `---` becomes a divider, short `key: value` lines get an aligned style, and `**bold**` gets a highlighter underline; fenced code is protected so `#` / `-` inside it are no longer re-typeset. **Adds 7 typography unit tests** (XSS escaping, link-protocol allowlist, prose colons must not be misread as key/value). Also carries the PR #8 port: Electron-host subprocess node resolution (`lib/node-bin.js`, fixing "standalone web server never starts → sidebar popup white screen") and API routes registered after `webServer` is ready (fixing "settings page stuck loading") |
|
|
264
283
|
| **v0.9.6** | 2026-09-28 | **Fixed the full-screen overlay covering the desktop shell's window controls (clicking Close quit the whole shell)**: the desktop shell draws minimize / maximize / close at the top of the page and, per the DSH convention, marks `<html>` with `data-windows-titlebar` and publishes `--dsh-windows-titlebar-height` (both confirmed present inside the official desktop build's `app.asar`); the memory overlay used `inset:0` plus an inline `100vh`, covering that whole strip, and our own top-right "×" landed exactly on the shell's window Close — one click quit the entire desktop shell. The full-screen state now uses the `.me-modal-full` class (no inline `100vh`, which CSS cannot override), and under `[data-windows-titlebar]` the `.me-overlay-top` / `.me-overlay` / `.me-modal-full` surfaces are pushed down by `--dsh-windows-titlebar-height` with height `calc(100vh - that)`. Browsers without the attribute get a 0px offset, so behaviour is unchanged there. The export-graph full-screen preview also moved from `100vh` to `100%` so it cannot overflow the shortened overlay. Adds 1 render regression asserting all three avoidance rules ship in the bundle |
|
|
265
284
|
| **v0.9.5** | 2026-09-28 | **Fixed the plugin failing to mount under the official desktop build (schemastery 3.18.4)**: the desktop profile resolves **schemastery 3.18.4**, which turns every `volatile` Config field into a cosmokit **live reference** (`{ get(): snapshot }` branded with `Symbol.for('cosmokit.volatile.write')`), while 0.9.4 treated those fields as plain values — `cfg.vaultDir.trim()` threw `TypeError: cfg.vaultDir.trim is not a function` and the plugin died inside `apply` (the exact desktop error). The plugin now **deep-unwraps** references per the cosmokit protocol (snapshot each reference, recurse through arrays/objects), so `settings.get()` only ever exposes plain values; each read unwraps afresh, so in-place volatile hot updates stay immediately visible, and `JSON.stringify` no longer writes `{}` into the shared config file. Adds 2 regressions (the real 3.18.4 reference shape + nested arrays/objects) and verifies `apply` under the **desktop build's real module-resolution layout** (schemastery 3.18.4 + real cosmokit) |
|
|
@@ -278,6 +297,23 @@ npm run build # builds lib/client.js (DSH embed) + web/app.js (standalone web
|
|
|
278
297
|
|
|
279
298
|
---
|
|
280
299
|
|
|
300
|
+
## 💬 Community & Support
|
|
301
|
+
|
|
302
|
+
**Questions, feature requests, or just want to talk about DSH plugin development — join the group chat:**
|
|
303
|
+
|
|
304
|
+
<img src="https://raw.githubusercontent.com/EternalNight996/memory-eternal/main/assets/support/group-qr.jpg" width="260" alt="DeepSeek Harness community group QR code" />
|
|
305
|
+
|
|
306
|
+
**If this plugin saved you some time, you can buy the author a coffee (either code works):**
|
|
307
|
+
|
|
308
|
+
| WeChat Pay | WeChat Reward |
|
|
309
|
+
|---|---|
|
|
310
|
+
| <img src="https://raw.githubusercontent.com/EternalNight996/memory-eternal/main/assets/support/wechat-pay.jpg" width="230" alt="WeChat Pay QR code" /> | <img src="https://raw.githubusercontent.com/EternalNight996/memory-eternal/main/assets/support/wechat-reward.jpg" width="230" alt="WeChat reward QR code" /> |
|
|
311
|
+
|
|
312
|
+
> Donations are entirely optional and change nothing about the plugin; it stays open source and free (MIT).
|
|
313
|
+
> Picking the first custom entry in the repo's **Sponsor** dropdown lands on [SPONSOR.md](SPONSOR.md), which shows the same codes.
|
|
314
|
+
|
|
315
|
+
---
|
|
316
|
+
|
|
281
317
|
## 📄 License
|
|
282
318
|
|
|
283
319
|
MIT
|
package/README.md
CHANGED
|
@@ -165,6 +165,23 @@ dsh-memory watchdog [--port 7799] # 看门狗保活 web(独立进程)
|
|
|
165
165
|
| 自动召回 | 开 | AI 需要历史时自动帮你查记忆 |
|
|
166
166
|
| 记忆库目录 | `~/.dsh/memory-vault` | 记忆存哪,纯 Markdown 可 git 管理 |
|
|
167
167
|
|
|
168
|
+
#### 多库(按项目隔离记忆)
|
|
169
|
+
|
|
170
|
+
配置里还有两个字段(写在 `memory-eternal-config.json`,或设置面板同步过来的共享配置):
|
|
171
|
+
|
|
172
|
+
```json
|
|
173
|
+
{
|
|
174
|
+
"vaultProfiles": [{ "name": "work", "path": "D:/vaults/work" }],
|
|
175
|
+
"activeVault": "work"
|
|
176
|
+
}
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
解析顺序:`MEMORY_VAULT_DIR` 环境变量 → `activeVault` 命中 `vaultProfiles[].name` 的 `path` → `vaultDir` → 默认 `~/.dsh/memory-vault`。
|
|
180
|
+
|
|
181
|
+
**v0.9.8 起,CLI / MCP / hooks / 独立 web / sweep 与 DSH 宿主共用同一套顺序**:以前只有宿主认 `activeVault`,切库后终端与 hooks 仍往默认库写,记忆会被劈成两份。
|
|
182
|
+
|
|
183
|
+
> 说明:配置页目前还没有 `vaultProfiles` / `activeVault` 的可视化表单(要手工写配置文件),按 workspace 自动选库也还没做。
|
|
184
|
+
|
|
168
185
|
### 二、省钱包(重要)
|
|
169
186
|
|
|
170
187
|
| 配置项 | 默认 | 大白话说明 |
|
|
@@ -273,6 +290,8 @@ npm run build # 构建 lib/client.js(DSH 内嵌)+ web/app.js(独立 web
|
|
|
273
290
|
|
|
274
291
|
| 版本 | 日期 | 关键改动 |
|
|
275
292
|
|---|---|---|
|
|
293
|
+
| **v0.9.9** | 2026-09-28 | **修复侧边栏「记忆」入口被挤出可视区(issue #2)**:槽位宿主有两种形态 —— 壳自己的横向行容器 `.footerActions`,以及被其他插件(`dsh-diff-approval` / `dsh-footer-order` 等)改成 `flex-direction: column` 的 `div[data-slot="sidebar.footer.action"]`。旧 CSS 对两者都套了 `flex-wrap: wrap` + `.me-footer { flex: 1 1 100% }`:纵向容器里 wrap 的语义是「换列」、`basis: 100%` 指的是**高度 100%**,叠加后按钮被挤进右侧溢出列、裁切到只剩右边缘一条缝。现在 `flex-wrap` 只留给横向 `.footerActions`,`.me-footer` 改用方向无关的 `flex: 0 0 auto`,并新增 SSR 回归断言。另:README 双语新增「交流群 & 打赏」(群二维码 + 微信收款码 / 赞赏码),新增 `SPONSOR.md` 与 `.github/FUNDING.yml`(FUNDING 只支持文字链接,故 custom 第一项指向 SPONSOR.md 页面直接展示二维码) |
|
|
294
|
+
| **v0.9.8** | 2026-09-28 | **集中解决存量问题**。① **采集可信性**(issue #3 / #4):蒸馏路由不再写死 `providers[0]` —— 新增 `captureProvider` / `captureModel` 配置,并按注册顺序**依次重试**;**流末尾 `finish` 块里的适配器错误**(缺凭证 `MISSING_CREDENTIAL`、限流等)现在成为**可见失败**(沉淀日志写明 code/message + 健康态亮红 + 兜底原文卡注明原因),不再笼统记成「蒸馏无输出」;停滞探测从「累积计数差额」改为**滑动窗口**(20 分钟内持续开始且零收尾才告警),误报与漏报一并修掉。② **独立进程 vault 一致性**(dsh-memory-eternal#5):CLI / MCP / hooks / 独立 web / sweep 与宿主共用同一套优先级(`MEMORY_VAULT_DIR` → `activeVault` → `vaultDir` → 默认库),切库后不再数据分裂。③ **看门狗单例锁**(dsh-memory-eternal#6):同端口已有活着的 watchdog 时新进程直接退出并清理陈旧 pid 记录,止住「每次重启多一个孤儿进程」。④ **图谱性能**:标签边改「共享标签数 Top-K」(593 卡实测 **24,193 → 2,763** 条边)、相似度改**稀有 bigram 倒排候选**(冷启动 **948 → ~520ms**,warm 2-4ms)、payload **3,531KB → 579KB**;客户端 dim 判定改**邻接 Set**(每帧 **52ms → <1ms**)。⑤ 新增 5 个测试文件(路由/finish、停滞窗口、vault 解析、看门狗锁、图谱性能基线),`npm test` **85 项** |
|
|
276
295
|
| **v0.9.7** | 2026-09-28 | **打开卡片默认全部展开(可收起)+ 正文排版重做**:卡片阅读器不再默认折成 300px,而是**打开即读全文**,长卡可一键收起(折叠后底部给「展开 ⌄」提示)。frontmatter 从正文里抽出来,改成 kind / 审核状态 / 标签 / 时间 / 来源的**彩色 chips**(kind 沿用图谱配色),不再把一段 YAML 当正文露出来。正文排版换成新方案(`src/client/markdown.js`,宿主与独立 web 共用同一套):小标题按语义自动配 **emoji + 颜色分组**(结论 ✅绿 / 风险 ⚠️琥珀 / 根因 🔍蓝 / 方案 🛠️紫 / 示例 💡粉),连续 `- ` 合成真正的 `<ul>`、`1.` 合成带圆形序号的 `<ol>`、`- [x]` 变复选框、`>` 引用升级成带 emoji 的 callout、`---` 变分隔线、行首「短键:值」渲染成对齐样式、`**加粗**` 加荧光笔下划线;围栏代码整段保护,内部 `#` / `-` 不再被误排版。**新增 7 项排版单测**(含 XSS 转义、链接协议白名单、散文冒号不得误判成 key/value)。另含 PR #8 移植:Electron 宿主下的子进程 node 解析(`lib/node-bin.js`,修「独立 web server 拉不起来 → 侧边栏弹窗白屏」)与 `webServer` 就绪后再注册 API 路由(修「设置页永远加载中」) |
|
|
277
296
|
| **v0.9.6** | 2026-09-28 | **修复官方桌面版全屏浮层挡住窗口控制面板(点「关闭」会退出整个桌面壳)**:桌面壳把「最小化 / 最大化 / 关闭」画在页面顶部,并按 DSH 约定在 `<html>` 上打 `data-windows-titlebar` + 给出 `--dsh-windows-titlebar-height`(已在官方桌面版 `app.asar` 里确认这两个标记确实存在);记忆库全屏浮层原本是 `inset:0` + 内联 `100vh`,整条盖住标题栏,而我们右上角的「×」恰好压在窗口「关闭」上 —— 用户一点就直接退出整个桌面壳。现全屏态改用 `.me-modal-full` 类(不再写死内联 `100vh`:内联样式无法被 CSS 覆盖),并在 `[data-windows-titlebar]` 下把 `.me-overlay-top` / `.me-overlay` / `.me-modal-full` 一律下移 `--dsh-windows-titlebar-height`、高度改为 `calc(100vh - 该高度)`;浏览器里没有该属性时偏移 0px,行为与之前完全一致。导出图谱的全屏预览同步从 `100vh` 改为 `100%`,避免在缩短后的浮层里溢出。新增 1 项渲染回归断言(三处让位规则必须出现在打包产物中) |
|
|
278
297
|
| **v0.9.5** | 2026-09-28 | **修复官方桌面版(schemastery 3.18.4)下插件整体挂不上**:桌面版 profile 解析到的是 **schemastery 3.18.4**,它会把标了 `volatile` 的 Config 字段解析成 cosmokit 的**活引用**(`{ get(): snapshot }`,品牌 `Symbol.for('cosmokit.volatile.write')`),而 0.9.4 的业务代码把字段当普通值用 —— `cfg.vaultDir.trim()` 当场抛 `TypeError: cfg.vaultDir.trim is not a function`,插件在 `apply` 里就崩(桌面版实测报错原文)。现按 cosmokit 协议统一**深解引用**(引用取快照、数组/对象递归展开),`settings.get()` 对外只给普通值;每次读取都重新解,loader 原地提交的 volatile 热更新照样立刻可见,`JSON.stringify` 写共享配置也不再变成 `{}`。新增 2 项回归(3.18.4 真实引用形态 + 嵌套数组/对象),并用**桌面版真实模块解析布局**(schemastery 3.18.4 + 真 cosmokit)跑通 `apply` 验证 |
|
|
@@ -291,6 +310,23 @@ npm run build # 构建 lib/client.js(DSH 内嵌)+ web/app.js(独立 web
|
|
|
291
310
|
|
|
292
311
|
---
|
|
293
312
|
|
|
313
|
+
## 💬 交流群 & 打赏
|
|
314
|
+
|
|
315
|
+
**遇到问题、想提需求、或者想聊聊 DSH 插件开发 —— 欢迎进群:**
|
|
316
|
+
|
|
317
|
+
<img src="https://raw.githubusercontent.com/EternalNight996/memory-eternal/main/assets/support/group-qr.jpg" width="260" alt="DeepSeek Harness 交流群二维码" />
|
|
318
|
+
|
|
319
|
+
**如果这个插件帮你省了时间,可以请作者喝杯咖啡(两个码任选其一):**
|
|
320
|
+
|
|
321
|
+
| 微信收款码 | 微信赞赏码 |
|
|
322
|
+
|---|---|
|
|
323
|
+
| <img src="https://raw.githubusercontent.com/EternalNight996/memory-eternal/main/assets/support/wechat-pay.jpg" width="230" alt="微信收款码" /> | <img src="https://raw.githubusercontent.com/EternalNight996/memory-eternal/main/assets/support/wechat-reward.jpg" width="230" alt="微信赞赏码" /> |
|
|
324
|
+
|
|
325
|
+
> 打赏完全自愿,不影响任何功能;插件本体始终开源免费(MIT)。
|
|
326
|
+
> 仓库页的 **Sponsor** 按钮下拉里选 custom 第一项,会落到 [SPONSOR.md](SPONSOR.md)(同样是这一页的码)。
|
|
327
|
+
|
|
328
|
+
---
|
|
329
|
+
|
|
294
330
|
## 📄 License
|
|
295
331
|
|
|
296
332
|
MIT
|
package/index.js
CHANGED
|
@@ -27,7 +27,8 @@ import z from '@deepseek-ai/schemastery'
|
|
|
27
27
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
28
28
|
import { ensureVault, search } from './lib/vault.js'
|
|
29
29
|
import { migrateFromMarkdown, setAuditConfig, backupDb } from './lib/db.js'
|
|
30
|
-
import {
|
|
30
|
+
import { evaluateStall, MAX_STAMPS } from './lib/stall.js'
|
|
31
|
+
import { summarizeTurn, summarizeTurnDetailed, routeCandidates, extractLastTurn, sliceNewEvents, sessionEvents, sessionEventApi, createCaptureHealth, resolveRoute, captureCard, captureUpdate, pickNeighbors } from './lib/capture.js'
|
|
31
32
|
import { createApi, json, encodeBody } from './lib/api.js'
|
|
32
33
|
import { appendCaptureLog, readCaptureLog, rotateCaptureLog } from './lib/capture-log.js'
|
|
33
34
|
import { nodeBinary, childEnv } from './lib/node-bin.js'
|
|
@@ -57,6 +58,10 @@ export const Config = z.object({
|
|
|
57
58
|
distillEnabled: z.boolean().default(true),
|
|
58
59
|
// 语义去重:true=把已有卡索引喂 LLM 决定「新建 vs 追加」;false=纯词法去重(省一次蒸馏前的 LLM 调用)
|
|
59
60
|
dedupByLLM: z.boolean().default(true),
|
|
61
|
+
// 蒸馏用的 provider / model(留空 = 按注册顺序自动挑,见 routeCandidates)
|
|
62
|
+
// 多 provider 环境下务必显式指定,否则可能撞上没配 key 的官方 provider(issue #3)
|
|
63
|
+
captureProvider: z.string().default(''),
|
|
64
|
+
captureModel: z.string().default(''),
|
|
60
65
|
// 蒸馏单次输出上限(token),越高越准越贵
|
|
61
66
|
captureMaxTokens: z.number().min(100).max(4000).default(900),
|
|
62
67
|
// 召回相关性阈值(minScore),越高召回越少越精越省
|
|
@@ -394,13 +399,30 @@ export function apply(ctx, config) {
|
|
|
394
399
|
else logCapture(sessionId, 'fail', `写卡失败:${out.reason || '未知原因'}`)
|
|
395
400
|
return
|
|
396
401
|
}
|
|
397
|
-
|
|
398
|
-
|
|
402
|
+
// 候选路由:显式配置优先,其余按注册顺序兜底(多 provider 下 providers[0] 可能是没配 key 的官方 provider → issue #3)。
|
|
403
|
+
const routes = await routeCandidates(llm, { provider: cfg.captureProvider, model: cfg.captureModel })
|
|
404
|
+
if (!routes.length) { logCapture(sessionId, 'fail', '取不到模型路由(llm.listProviders 为空)'); return }
|
|
399
405
|
// 语义去重近邻:把已有卡片索引喂给模型,让模型决定新建 vs 追加。
|
|
400
406
|
// 成本控制:dedupByLLM=false 时跳过喂 LLM 的近邻采样(纯词法去重兜底)。
|
|
401
407
|
const draft = { title: '', body: text.slice(0, 400) }
|
|
402
408
|
const neighbors = cfg.dedupByLLM === false ? [] : await pickNeighbors(vaultDir(), draft, 8)
|
|
403
|
-
|
|
409
|
+
let route = routes[0]
|
|
410
|
+
let result = null
|
|
411
|
+
let failure = null
|
|
412
|
+
for (let i = 0; i < routes.length; i++) {
|
|
413
|
+
route = routes[i]
|
|
414
|
+
const detailed = await summarizeTurnDetailed(llm, route, text, { signal: AbortSignal.timeout(45000), existing: neighbors, maxTokens: cfg.captureMaxTokens ?? 900 })
|
|
415
|
+
if (detailed.card !== undefined) { result = detailed.card; failure = null; break }
|
|
416
|
+
if (detailed.failure) {
|
|
417
|
+
failure = { ...detailed.failure, provider: route.provider, model: route.model }
|
|
418
|
+
const more = i < routes.length - 1
|
|
419
|
+
logCapture(sessionId, 'fail', `蒸馏调用失败(${route.provider}/${route.model}):${failure.code} ${failure.message}${more ? ' → 换下一个 provider 重试' : ''}`)
|
|
420
|
+
if (more) continue
|
|
421
|
+
}
|
|
422
|
+
break // skip(太短 / 不值得保存)或没有更多候选
|
|
423
|
+
}
|
|
424
|
+
// 真失败要亮红并进提示段:此前只有笼统的「蒸馏无输出」,真实原因(如缺凭证)完全不可见。
|
|
425
|
+
if (failure) { health.fail(`蒸馏失败(${failure.provider}):${failure.code} ${failure.message}`); touchPrompt() }
|
|
404
426
|
if (!result) {
|
|
405
427
|
// 蒸馏失败不能让内容白丢:退成原文卡(与「关闭蒸馏」同一条降级路径)。
|
|
406
428
|
const raw = await captureCard(vaultDir(), {
|
|
@@ -414,7 +436,8 @@ export function apply(ctx, config) {
|
|
|
414
436
|
severity: 'info',
|
|
415
437
|
reason: 'AI 自动沉淀(蒸馏无输出 → 原文卡兜底)',
|
|
416
438
|
}, { threshold: cfg.dedupThreshold })
|
|
417
|
-
|
|
439
|
+
const why = failure ? `${failure.code} ${failure.message}` : '无可用产出'
|
|
440
|
+
if (raw.ok) { countWrite(); logCapture(sessionId, 'created', `蒸馏失败(${why})→ 原文卡兜底`, { path: raw.path ?? raw.rel, kind: 'content', model: route.model }) }
|
|
418
441
|
else if (raw.duplicate) { countWrite(); logCapture(sessionId, 'appended', '蒸馏无输出 + 与已有卡重复 → 追加更新', { path: raw.duplicate.path, model: route.model }) }
|
|
419
442
|
else logCapture(sessionId, 'fail', `蒸馏无输出,且兜底原文卡也失败:${raw.reason || '未知原因'}`, { model: route.model })
|
|
420
443
|
return
|
|
@@ -469,10 +492,14 @@ export function apply(ctx, config) {
|
|
|
469
492
|
// 就说明收尾事件没到——这是「监听器整个没被调用」这类静默死亡的兜底探测。
|
|
470
493
|
let turnsStarted = 0
|
|
471
494
|
let turnsStopped = 0
|
|
472
|
-
|
|
473
|
-
|
|
495
|
+
// 滑动窗口用的时间戳(旧版只留累积计数,导致误报+漏报,见 lib/stall.js)
|
|
496
|
+
const claimedAt = []
|
|
497
|
+
const stoppedAt = []
|
|
498
|
+
const stamp = (arr) => { arr.push(Date.now()); if (arr.length > MAX_STAMPS) arr.shift() }
|
|
499
|
+
ctx.on('agent/inbox/claimed', () => { turnsStarted += 1; stamp(claimedAt) })
|
|
474
500
|
ctx.on('agent/turn-stopping', ({ agent }) => {
|
|
475
501
|
turnsStopped += 1
|
|
502
|
+
stamp(stoppedAt)
|
|
476
503
|
const sessionId = agent?.session?.id ?? agent?.id ?? 'unknown'
|
|
477
504
|
try {
|
|
478
505
|
const api = sessionEventApi(agent?.session)
|
|
@@ -594,14 +621,12 @@ export function apply(ctx, config) {
|
|
|
594
621
|
// 沉淀停滞探测:只有「有轮次在跑」且「管线 15 分钟一个活口都没有」才报(真死才报)。
|
|
595
622
|
// 计数器对不上是常态(子代理轮次、被取消的轮次都不发 turn-stopping),单看计数会误报刷屏。
|
|
596
623
|
const stallTimer = setInterval(() => {
|
|
597
|
-
const
|
|
598
|
-
|
|
599
|
-
const suspicious = turnsStarted > turnsStopped && claimIdleMs > 15 * 60 * 1000 && idleMs > 15 * 60 * 1000
|
|
600
|
-
if (suspicious && !stallAlerted) {
|
|
624
|
+
const { alert, started, stopped, windowMs } = evaluateStall({ claimedAt, stoppedAt })
|
|
625
|
+
if (alert && !stallAlerted) {
|
|
601
626
|
stallAlerted = true
|
|
602
|
-
logCapture('system', 'fail',
|
|
627
|
+
logCapture('system', 'fail', `轮次收尾事件疑似失效:最近 ${Math.round(windowMs / 60000)} 分钟内开始 ${started} 个轮次、收尾 ${stopped} 个(累计 ${turnsStarted}/${turnsStopped})——DSH 可能改了事件名或作用域`)
|
|
603
628
|
}
|
|
604
|
-
},
|
|
629
|
+
}, 5 * 60 * 1000)
|
|
605
630
|
ctx.effect(() => () => clearInterval(stallTimer), 'memory-eternal: capture stall watch')
|
|
606
631
|
|
|
607
632
|
// 回收站清理:每 30 分钟永久删除超过保留期(默认 30 天)的软删卡。
|
package/lib/capture-run.js
CHANGED
|
@@ -8,12 +8,31 @@
|
|
|
8
8
|
import path from 'node:path'
|
|
9
9
|
import os from 'node:os'
|
|
10
10
|
import fs from 'node:fs'
|
|
11
|
-
import { captureCard, captureUpdate, pickNeighbors,
|
|
11
|
+
import { captureCard, captureUpdate, pickNeighbors, summarizeTurnDetailed, routeCandidates } from './capture.js'
|
|
12
12
|
import { createLlmFromEnv } from './llm-openai.js'
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* 解析当前 vault 目录。
|
|
16
|
+
*
|
|
17
|
+
* 优先级必须与宿主 index.js 的 vaultDir() **完全一致**:
|
|
18
|
+
* MEMORY_VAULT_DIR → activeVault 命中 vaultProfiles → vaultDir → 默认 ~/.dsh/memory-vault。
|
|
19
|
+
* 旧实现只认 MEMORY_VAULT_DIR / 默认路径,于是用户在宿主里切库后,CLI / MCP / hooks /
|
|
20
|
+
* 独立 web / sweep 仍然写默认库 —— 同一台机器上记忆被劈成两份(dsh-memory-eternal#5)。
|
|
21
|
+
*
|
|
22
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
23
|
+
* @returns {string} 绝对路径
|
|
24
|
+
*/
|
|
14
25
|
export function defaultVaultDir(env = process.env) {
|
|
15
26
|
if (env.MEMORY_VAULT_DIR && env.MEMORY_VAULT_DIR.trim()) return path.resolve(env.MEMORY_VAULT_DIR.trim())
|
|
16
27
|
const home = env.DSH_HOME || path.join(os.homedir(), '.dsh')
|
|
28
|
+
try {
|
|
29
|
+
const cfg = readConfig(env) || {}
|
|
30
|
+
const profiles = Array.isArray(cfg.vaultProfiles) ? cfg.vaultProfiles : []
|
|
31
|
+
const active = String(cfg.activeVault || '').trim()
|
|
32
|
+
const hit = active && profiles.find((p) => p && p.name === active)
|
|
33
|
+
if (hit && typeof hit.path === 'string' && hit.path.trim()) return path.resolve(hit.path.trim())
|
|
34
|
+
if (typeof cfg.vaultDir === 'string' && cfg.vaultDir.trim()) return path.resolve(cfg.vaultDir.trim())
|
|
35
|
+
} catch { /* 读不到共享配置就回落默认库 */ }
|
|
17
36
|
return path.join(home, 'memory-vault')
|
|
18
37
|
}
|
|
19
38
|
|
|
@@ -50,13 +69,20 @@ export async function runStandaloneCapture(vaultRoot, text, { source = '', llm,
|
|
|
50
69
|
}
|
|
51
70
|
|
|
52
71
|
const llmClient = llm ?? createLlmFromEnv(env)
|
|
53
|
-
|
|
72
|
+
// 与宿主同样的候选路由:显式配置优先,其余按注册顺序兜底(别写死 providers[0],见 issue #3)
|
|
73
|
+
const routes = llmClient ? await routeCandidates(llmClient, { provider: shared?.captureProvider, model: shared?.captureModel }) : []
|
|
74
|
+
const route = routes[0] ?? null
|
|
54
75
|
|
|
55
76
|
// 有 LLM:蒸馏判定链(与 DSH 侧一致)
|
|
56
77
|
if (llmClient && route) {
|
|
57
78
|
try {
|
|
58
79
|
const neighbors = await pickNeighbors(vaultRoot, { title: '', body: clean.slice(0, 400) }, 8)
|
|
59
|
-
const
|
|
80
|
+
const detailed = await summarizeTurnDetailed(llmClient, route, clean, { existing: neighbors, signal: AbortSignal.timeout(45000) })
|
|
81
|
+
const result = detailed.card
|
|
82
|
+
// 独立进程也必须把真实失败暴露出来(走 stderr),别再静默降级成「无输出」
|
|
83
|
+
if (detailed.failure) {
|
|
84
|
+
process.stderr.write(`[memory-eternal] 蒸馏失败(${route.provider}/${route.model}):${detailed.failure.code} ${detailed.failure.message}\n`)
|
|
85
|
+
}
|
|
60
86
|
if (result && result.save === true) {
|
|
61
87
|
if (result.append_to) {
|
|
62
88
|
await captureUpdate(vaultRoot, result.append_to, result.update, { threshold: 0.62 })
|
package/lib/capture.js
CHANGED
|
@@ -41,9 +41,23 @@ export function looksDurable(text) {
|
|
|
41
41
|
* @returns Promise<null | { save:boolean } | { save:true, title, kind, tags, body } | { append_to, update }>
|
|
42
42
|
*/
|
|
43
43
|
export async function summarizeTurn(llm, route, conversation, opts = {}) {
|
|
44
|
+
const r = await summarizeTurnDetailed(llm, route, conversation, opts)
|
|
45
|
+
return r.card ?? null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* summarizeTurn 的「带诊断」版本:把「跳过」与「真失败」分开。
|
|
50
|
+
*
|
|
51
|
+
* 关键:适配器错误(缺凭证 / 网络 / 限流)不会抛给调用方,而是以
|
|
52
|
+
* \`{ kind:'error'|'aborted', failure:{ code, message } }\` 终结流。旧实现只收集 text 块,
|
|
53
|
+
* 于是空文本被笼统记成「蒸馏无输出」,真实原因(例如 MISSING_CREDENTIAL)完全不可见。
|
|
54
|
+
*
|
|
55
|
+
* @returns {Promise<{card?:any, skip?:string, failure?:{code:string,message:string}}>}
|
|
56
|
+
*/
|
|
57
|
+
export async function summarizeTurnDetailed(llm, route, conversation, opts = {}) {
|
|
44
58
|
const text = String(conversation || '').trim()
|
|
45
|
-
if (text.length < 120) return
|
|
46
|
-
if (!looksDurable(text)) return
|
|
59
|
+
if (text.length < 120) return { skip: 'too-short' } // 太短,没有沉淀价值
|
|
60
|
+
if (!looksDurable(text)) return { skip: 'not-durable' } // 启发式预筛:无可复用信号则跳过 LLM 判定(省 token)
|
|
47
61
|
|
|
48
62
|
const existing = Array.isArray(opts.existing) ? opts.existing.slice(0, DEDUP_NEIGHBORS) : []
|
|
49
63
|
const existingBlock = existing.length
|
|
@@ -86,8 +100,23 @@ export async function summarizeTurn(llm, route, conversation, opts = {}) {
|
|
|
86
100
|
signal: opts.signal ?? AbortSignal.timeout(45000),
|
|
87
101
|
}
|
|
88
102
|
for await (const chunk of llm.stream(options)) assembler.push(chunk)
|
|
103
|
+
// finish 块才是失败真相(见上);没有 finish 的兼容适配器按 stop 处理。
|
|
104
|
+
const finish = assembler.finish && typeof assembler.finish === 'object' ? assembler.finish : { kind: 'stop' }
|
|
105
|
+
if (finish.kind === 'error' || finish.kind === 'aborted') {
|
|
106
|
+
const f = finish.failure || {}
|
|
107
|
+
return {
|
|
108
|
+
failure: {
|
|
109
|
+
code: String(f.code || f.kind || finish.kind),
|
|
110
|
+
message: String(f.message || f.detail || '模型调用失败'),
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
}
|
|
89
114
|
const out = assembler.blocks().filter((b) => b.type === 'text').map((b) => b.text).join('').trim()
|
|
90
|
-
|
|
115
|
+
const parsed = parseCaptureJson(out)
|
|
116
|
+
if (!parsed) {
|
|
117
|
+
return { failure: { code: 'UNPARSEABLE_OUTPUT', message: out ? '模型输出无法解析为 JSON:' + out.slice(0, 160) : '模型返回空文本' } }
|
|
118
|
+
}
|
|
119
|
+
return { card: parsed }
|
|
91
120
|
}
|
|
92
121
|
|
|
93
122
|
/**
|
|
@@ -321,15 +350,48 @@ export function compressExcerpt(text, maxChars = 2400) {
|
|
|
321
350
|
return out
|
|
322
351
|
}
|
|
323
352
|
|
|
324
|
-
/**
|
|
325
|
-
|
|
353
|
+
/**
|
|
354
|
+
* 按优先级给出候选路由列表。
|
|
355
|
+
*
|
|
356
|
+
* 为什么不直接取 \`providers[0]\`:DSH 的 listProviders() 按**注册顺序**返回,内置的
|
|
357
|
+
* deepseek-official 通常排第一;多 provider 用户(例如自建中转)本机没配官方 key 时,
|
|
358
|
+
* 请求会在**发出去之前**就以「缺凭证」失败,而这条失败只体现在流末尾的 finish 块里
|
|
359
|
+
* —— 旧代码只看 text,于是被误报成「蒸馏无输出」,管线 100% 静默失败(issue #3)。
|
|
360
|
+
* 配置了 captureProvider / captureModel 时以配置为准,其余按注册顺序依次兜底尝试。
|
|
361
|
+
*
|
|
362
|
+
* @param {object} llm DSH llm 服务(或 lib/llm-openai.js 的兼容适配器)
|
|
363
|
+
* @param {{provider?:string, model?:string, limit?:number}} [opts]
|
|
364
|
+
* @returns {Promise<Array<{provider:string, model:string}>>}
|
|
365
|
+
*/
|
|
366
|
+
export async function routeCandidates(llm, opts = {}) {
|
|
367
|
+
const out = []
|
|
368
|
+
const want = String(opts.provider || '').trim()
|
|
369
|
+
const wantModel = String(opts.model || '').trim()
|
|
370
|
+
const limit = Number(opts.limit) > 0 ? Number(opts.limit) : 4
|
|
326
371
|
try {
|
|
327
|
-
const providers = llm.listProviders()
|
|
328
|
-
if (!providers
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
372
|
+
const providers = llm.listProviders() || []
|
|
373
|
+
if (!providers.length) return out
|
|
374
|
+
if (want) {
|
|
375
|
+
const hit = providers.find((p) => p && p.id === want)
|
|
376
|
+
if (hit) {
|
|
377
|
+
const models = (await llm.listModels(hit.id)) || []
|
|
378
|
+
const model = wantModel || (models[0] && models[0].id)
|
|
379
|
+
if (model) out.push({ provider: hit.id, model })
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
for (const p of providers) {
|
|
383
|
+
if (!p || !p.id || out.some((r) => r.provider === p.id)) continue
|
|
384
|
+
const models = (await llm.listModels(p.id)) || []
|
|
385
|
+
const model = (p.id === want && wantModel) || (models[0] && models[0].id)
|
|
386
|
+
if (model) out.push({ provider: p.id, model })
|
|
387
|
+
if (out.length >= limit) break
|
|
388
|
+
}
|
|
389
|
+
} catch { /* 路由解析失败按空列表返回,由调用方兜底 */ }
|
|
390
|
+
return out
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** 解析当前模型路由:显式配置优先,其次第一个 provider 的首个模型(保持旧行为作兜底)。 */
|
|
394
|
+
export async function resolveRoute(llm, opts = {}) {
|
|
395
|
+
const list = await routeCandidates(llm, opts)
|
|
396
|
+
return list[0] ?? null
|
|
335
397
|
}
|