memory-eternal 0.9.6 → 0.9.8
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 +35 -6
- package/README.md +36 -7
- package/index.js +71 -19
- package/lib/capture-run.js +29 -3
- package/lib/capture.js +75 -13
- package/lib/client.js +55 -14
- package/lib/node-bin.js +99 -0
- package/lib/stall.js +30 -0
- package/lib/vault.js +76 -8
- package/lib/watchdog.js +65 -3
- package/lib/web.js +10 -3
- package/package.json +6 -4
- package/web/app.js +59 -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.8",
|
|
5
5
|
"description": "璁板繂鏍稿績锛圡emory Eternal锛夛細鐭ヨ瘑鍗¤嚜鍔ㄦ矇娣€/鍙洖锛岀粺涓€ ~/.dsh/memory-vault锛屼細璇濈粨鏉熻嚜鍔ㄥ叆搴撹繘寰呭鏍搞€?,
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "EternalNight996",
|
package/README.en.md
CHANGED
|
@@ -29,10 +29,14 @@
|
|
|
29
29
|
|
|
30
30
|
### 🟦 DeepSeek Harness (DSH) — focus
|
|
31
31
|
|
|
32
|
-
**Install** (
|
|
32
|
+
**Install** (official desktop app via the plugin marketplace, `dsh web` via one CLI line):
|
|
33
33
|
|
|
34
34
|
```bash
|
|
35
|
-
#
|
|
35
|
+
# 1) Official DeepSeek Harness desktop app (recommended): Settings -> Plugins -> search memory-eternal
|
|
36
|
+
# Equivalent CLI (that shell's profile is named "desktop"):
|
|
37
|
+
dsh plugin --profile desktop add memory-eternal
|
|
38
|
+
|
|
39
|
+
# 2) dsh web (browser):
|
|
36
40
|
dsh plugin --profile web add memory-eternal
|
|
37
41
|
|
|
38
42
|
# or update directly in the profile (pnpm workspace). Use pnpm — `npm install` throws EUNSUPPORTEDPROTOCOL
|
|
@@ -41,13 +45,19 @@ cd ~/.dsh/profiles/web && pnpm add memory-eternal@latest
|
|
|
41
45
|
# right after a release, @latest can be held back by pnpm's minimum-release-age gate or a cached
|
|
42
46
|
# packument (it silently stays on the old version): pin the version instead — pnpm adds it to the
|
|
43
47
|
# workspace's minimumReleaseAgeExclude automatically
|
|
44
|
-
cd ~/.dsh/profiles/web && pnpm add memory-eternal@0.9.
|
|
48
|
+
cd ~/.dsh/profiles/web && pnpm add memory-eternal@0.9.6
|
|
45
49
|
|
|
46
|
-
# verify the version, then restart
|
|
50
|
+
# verify the version, then restart the host
|
|
47
51
|
node -e "console.log(require('memory-eternal/package.json').version)"
|
|
48
52
|
```
|
|
49
53
|
|
|
50
|
-
|
|
54
|
+
#### 🖥️ Desktop support policy (read this first)
|
|
55
|
+
|
|
56
|
+
- **The official DeepSeek Harness desktop build is the priority — and the only guaranteed target** (the official Electron release; its profile is named `desktop`). The v0.9.4 → v0.9.6 fixes all target it: the 0.1.7 settings-service rewrite, `schemastery 3.18.4` volatile live references, and window-titlebar avoidance (`data-windows-titlebar`, so a full-screen overlay can no longer cover minimize/maximize/close).
|
|
57
|
+
- **The author's own `dsh-desktop` (`dsh-desktop-shell` / `dsh-ui-*` / `dsh-plugin-marketplace`) is discontinued** and is no longer a support target — the official desktop build covers the same ground, and maintaining two shells only splits compatibility. Note that its profile still pins `dsh-desktop-shell@0.3.0`, which has been unpublished from npm and makes `pnpm install` fail outright; migrating to the official desktop build and dropping those dependencies is recommended.
|
|
58
|
+
- Compatibility matrix (both verified): the official `desktop` profile (schemastery **3.18.4**) and the `web` profile behind `dsh web` (schemastery **3.18.1**) share the same code path.
|
|
59
|
+
|
|
60
|
+
After **restarting the host**, three things are live immediately:
|
|
51
61
|
|
|
52
62
|
| Effect | Where |
|
|
53
63
|
|---|---|
|
|
@@ -144,6 +154,23 @@ Three concepts, don't mix them:
|
|
|
144
154
|
| Auto recall | on | AI auto-queries memory when it needs history |
|
|
145
155
|
| Vault dir | `~/.dsh/memory-vault` | where memory lives, plain Markdown & git-able |
|
|
146
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
|
+
|
|
147
174
|
### 2. Save money (important)
|
|
148
175
|
|
|
149
176
|
| Setting | Default | In plain words |
|
|
@@ -244,12 +271,14 @@ npm run build # builds lib/client.js (DSH embed) + web/app.js (standalone web
|
|
|
244
271
|
|
|
245
272
|
## 📋 Changelog
|
|
246
273
|
|
|
247
|
-
> **Withdrawn versions**: **v0.9.0 – v0.9.
|
|
274
|
+
> **Withdrawn versions**: **v0.9.0 – v0.9.5 are all deprecated on npm, and the release tags for v0.9.1 / v0.9.2 / v0.9.3 / v0.9.4 have been removed from git** (v0.9.5 is merely superseded, so its tag stays) — v0.9.1/v0.9.2 ship the blank-memory-page defect, v0.9.0 false-alarms its capture warnings, v0.9.3 does not support DSH v0.1.7-rc.2 (the plugin stops mounting entirely: the settings service changed and the client-side `settingsScope` is gone), v0.9.4 crashes under the official desktop build (schemastery 3.18.4 volatile live references) with `cfg.vaultDir.trim is not a function`, and **v0.9.5's full-screen overlay covers the desktop shell's window controls (its top-right "×" sits on the window Close button — one click quits the whole shell)**. Use **v0.9.6+** (`npm i memory-eternal@latest`).
|
|
248
275
|
|
|
249
276
|
> **DSH compatibility**: `>=0.1.5-alpha.2 <0.2.0`. **v0.9.4 adds support for DSH v0.1.7-rc.2**, which replaced the settings service with a forms-only API and removed `@deepseek-ai/dsh-client-runtime` / `settingsScope`; **v0.9.5 adds support for schemastery ≥3.18.4 volatile live references** (the shape the official desktop profile uses, while the web profile still ships 3.18.1 — both work); **v0.9.6 keeps full-screen overlays clear of the desktop shell's window controls**. The 0.1.5 line still uses the legacy `settings.register` path; both paths are kept.
|
|
250
277
|
|
|
251
278
|
| Version | Date | Highlights |
|
|
252
279
|
|---|---|---|
|
|
280
|
+
| **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** |
|
|
281
|
+
| **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") |
|
|
253
282
|
| **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 |
|
|
254
283
|
| **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) |
|
|
255
284
|
| **v0.9.4** | 2026-09-28 | **DSH v0.1.7-rc.2 support (fixes "the plugin goes dark after the upgrade")**: (1) **The settings service was replaced** — in 0.1.7 `ctx.settings` is a forms-only API (`describe/update/replace/mutate/configure`) with no `register()`; the old `ctx.settings.register(...)` call throws a `TypeError`, so the plugin never mounts (memory page gone, auto-capture dead). A cross-version shim keeps `register` for older hosts while new hosts read the **live config reference** the loader passes in, subscribe to `loader/volatile-update` for hot updates, and write back through `settings.update(entry id, patch, revision)` (revision conflicts map to HTTP 409); it also registers `configure({ auto:false })` so the host does not generate a duplicate form page. (2) **Volatile field marking moved after construction**: no more chained `.volatile()` — that method only exists in schemastery ≥3.18.4, while the plugin actually resolves **3.18.1** at the profile level, so the chain threw `volatile is not a function` at import time and killed the plugin. (3) **Dead client references removed**: `inject` no longer lists `settingsScope`, which disappeared together with `@deepseek-ai/dsh-client-runtime` (0.1.7 has no such service, so the client plugin waited forever and never activated — taking the memory page and the sidebar button with it); `dsh.client.inject` dropped its non-existent package names. (4) New **`tests/settings-compat.test.mjs`** (8 cases: 0.1.7-shaped mount / live-reference reads / volatile hot update / write-back and revision conflict / legacy `register` path / field volatile markers) wired into `npm test`. Verified by booting a **real DSH v0.1.7-rc.2**: the host plugin mounts (config written to `memory-eternal-config.json`) and the client bundle composes into the boot graph (`plugins/??memory-eternal/client.js`) |
|
package/README.md
CHANGED
|
@@ -29,24 +29,34 @@
|
|
|
29
29
|
|
|
30
30
|
### 🟦 DeepSeek Harness(DSH)—— 重点
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
**装**(官方桌面版走插件市场,`dsh web` 用一条 CLI):
|
|
33
33
|
|
|
34
34
|
```bash
|
|
35
|
-
#
|
|
35
|
+
# ① 官方 DeepSeek Harness 桌面版(推荐):设置 → 插件 → 搜 memory-eternal 安装
|
|
36
|
+
# 等价 CLI(该壳的 profile 名就是 desktop):
|
|
37
|
+
dsh plugin --profile desktop add memory-eternal
|
|
38
|
+
|
|
39
|
+
# ② dsh web(浏览器端):
|
|
36
40
|
dsh plugin --profile web add memory-eternal
|
|
37
41
|
|
|
38
|
-
#
|
|
42
|
+
# 或在 profile(pnpm workspace)里直接更新。注意用 pnpm,npm install 会报 EUNSUPPORTEDPROTOCOL
|
|
39
43
|
cd ~/.dsh/profiles/web && pnpm add memory-eternal@latest
|
|
40
44
|
|
|
41
45
|
# 刚发新版时 @latest 可能被 pnpm 的最小发布年龄门槛/元数据缓存挡住(静默停在旧版):
|
|
42
46
|
# 直接指定版本号最快,pnpm 会自动把它加进 workspace 的 minimumReleaseAgeExclude
|
|
43
|
-
cd ~/.dsh/profiles/web && pnpm add memory-eternal@0.9.
|
|
47
|
+
cd ~/.dsh/profiles/web && pnpm add memory-eternal@0.9.6
|
|
44
48
|
|
|
45
|
-
#
|
|
49
|
+
# 验版本(应等于上面装的号),然后重启对应宿主生效
|
|
46
50
|
node -e "console.log(require('memory-eternal/package.json').version)"
|
|
47
51
|
```
|
|
48
52
|
|
|
49
|
-
|
|
53
|
+
#### 🖥️ 桌面版支持策略(先看这条)
|
|
54
|
+
|
|
55
|
+
- **优先、且只保证支持官方 DeepSeek Harness 桌面版**(官方 Electron 发行版,profile 名 `desktop`)。v0.9.4 → v0.9.6 三轮修复全部围绕它:0.1.7 设置服务换血、`schemastery 3.18.4` 的 volatile 活引用、以及窗口标题栏避让(`data-windows-titlebar`,避免全屏浮层压住最小化/最大化/关闭)。
|
|
56
|
+
- **作者自研的 `dsh-desktop`(`dsh-desktop-shell` / `dsh-ui-*` / `dsh-plugin-marketplace` 那一套)已停止维护**,不再作为支持目标 —— 官方桌面版已覆盖同样能力,继续维护两条壳只会分裂兼容性。注意它的 profile 依赖里还钉着已从 npm 撤下的 `dsh-desktop-shell@0.3.0`,会让 `pnpm install` 直接失败;**建议迁到官方桌面版,并从 profile 里删掉这套依赖**。
|
|
57
|
+
- 兼容矩阵(均已实测):官方桌面版 `desktop` profile(schemastery **3.18.4**)+ `dsh web` 的 `web` profile(schemastery **3.18.1**),两条路径共用同一份代码。
|
|
58
|
+
|
|
59
|
+
**重启宿主**后,三样东西立即生效:
|
|
50
60
|
|
|
51
61
|
| 效果 | 在哪看 |
|
|
52
62
|
|---|---|
|
|
@@ -155,6 +165,23 @@ dsh-memory watchdog [--port 7799] # 看门狗保活 web(独立进程)
|
|
|
155
165
|
| 自动召回 | 开 | AI 需要历史时自动帮你查记忆 |
|
|
156
166
|
| 记忆库目录 | `~/.dsh/memory-vault` | 记忆存哪,纯 Markdown 可 git 管理 |
|
|
157
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
|
+
|
|
158
185
|
### 二、省钱包(重要)
|
|
159
186
|
|
|
160
187
|
| 配置项 | 默认 | 大白话说明 |
|
|
@@ -257,12 +284,14 @@ npm run build # 构建 lib/client.js(DSH 内嵌)+ web/app.js(独立 web
|
|
|
257
284
|
|
|
258
285
|
## 📋 更新日志
|
|
259
286
|
|
|
260
|
-
> **版本撤回说明**:**v0.9.0 – v0.9.
|
|
287
|
+
> **版本撤回说明**:**v0.9.0 – v0.9.5 均已在 npm 标记 deprecated;v0.9.1 / v0.9.2 / v0.9.3 / v0.9.4 的 release tag 已从 git 移除**(v0.9.5 只是被取代、tag 保留)—— v0.9.1/v0.9.2 带记忆页白屏缺陷,v0.9.0 沉淀告警误报刷屏,v0.9.3 不支持 DSH v0.1.7-rc.2(升级后插件整体挂不上:设置服务换血 + 客户端 `settingsScope` 消失),v0.9.4 在官方桌面版(schemastery 3.18.4 的 volatile 活引用)下抛 `cfg.vaultDir.trim is not a function`、插件整体挂不上,**v0.9.5 的全屏浮层会盖住桌面版的窗口控制面板(右上角「×」压在窗口「关闭」上,点一下会退出整个桌面壳)**。请一律使用 **v0.9.6+**(`npm i memory-eternal@latest`)。
|
|
261
288
|
|
|
262
289
|
> **DSH 兼容性**:`>=0.1.5-alpha.2 <0.2.0`。**v0.9.4 起适配 DSH v0.1.7-rc.2**(该版本把设置服务换成纯表单 API,并移除了 `@deepseek-ai/dsh-client-runtime` 与 `settingsScope`);**v0.9.5 起兼容 schemastery ≥3.18.4 的 volatile 活引用**(官方桌面版 profile 即此形态,web profile 仍是 3.18.1,两者都支持);**v0.9.6 起全屏浮层自动让开桌面壳的窗口标题栏**(不再压住最小化/最大化/关闭)。0.1.5 系列仍走旧的 `settings.register` 路径,两条路径都保留。
|
|
263
290
|
|
|
264
291
|
| 版本 | 日期 | 关键改动 |
|
|
265
292
|
|---|---|---|
|
|
293
|
+
| **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 项** |
|
|
294
|
+
| **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 路由(修「设置页永远加载中」) |
|
|
266
295
|
| **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 项渲染回归断言(三处让位规则必须出现在打包产物中) |
|
|
267
296
|
| **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` 验证 |
|
|
268
297
|
| **v0.9.4** | 2026-09-28 | **适配 DSH v0.1.7-rc.2(修复「升级后插件整体失联」)**:① **设置服务换血** —— 0.1.7 的 `ctx.settings` 只剩表单 API(`describe/update/replace/mutate/configure`),不再有 `register()`;旧代码 `ctx.settings.register(...)` 直接抛 `TypeError`,插件根本挂不上(记忆页消失、自动沉淀停摆)。现改为跨版本兼容层:旧版仍走 `register`,新版读 loader 传进来的**活配置引用**、订阅 `loader/volatile-update` 做热更新、写回走 `settings.update(条目 id, patch, revision)`(revision 冲突映射 HTTP 409),并注册 `configure({ auto:false })` 免得宿主再生成一张重复的表单页。② **字段 volatile 标记改为构造后统一打**:不再链式写 `.volatile()` —— 该方法 schemastery ≥3.18.4 才有,而插件在 profile 层实际解析到的是 **3.18.1**,链式调用会在 `import` 期抛 `volatile is not a function`,把插件整个打挂。③ **客户端去死引用**:`inject` 移除已随 `@deepseek-ai/dsh-client-runtime` 一起消失的 `settingsScope`(0.1.7 客户端服务表里已无此服务,插件会永远等待服务而永不激活 → 记忆页与侧边栏按钮全部不见);`dsh.client.inject` 同步去掉不存在的包名。④ 新增 **`tests/settings-compat.test.mjs`**(8 项:0.1.7 形状挂载 / 活引用读值 / volatile 热更新 / 写回与 revision 冲突 / 老版本 `register` 路径 / 字段 volatile 标记),已并入 `npm test`。已在**真实 DSH v0.1.7-rc.2** 上启动验证:宿主插件正常挂载(配置落盘 `memory-eternal-config.json`)、客户端 bundle 正常进入 boot graph(`plugins/??memory-eternal/client.js`) |
|
package/index.js
CHANGED
|
@@ -27,9 +27,11 @@ 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'
|
|
34
|
+
import { nodeBinary, childEnv } from './lib/node-bin.js'
|
|
33
35
|
|
|
34
36
|
export const name = 'memory-eternal'
|
|
35
37
|
export const inject = ['systemPrompt', 'settings']
|
|
@@ -56,6 +58,10 @@ export const Config = z.object({
|
|
|
56
58
|
distillEnabled: z.boolean().default(true),
|
|
57
59
|
// 语义去重:true=把已有卡索引喂 LLM 决定「新建 vs 追加」;false=纯词法去重(省一次蒸馏前的 LLM 调用)
|
|
58
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(''),
|
|
59
65
|
// 蒸馏单次输出上限(token),越高越准越贵
|
|
60
66
|
captureMaxTokens: z.number().min(100).max(4000).default(900),
|
|
61
67
|
// 召回相关性阈值(minScore),越高召回越少越精越省
|
|
@@ -393,13 +399,30 @@ export function apply(ctx, config) {
|
|
|
393
399
|
else logCapture(sessionId, 'fail', `写卡失败:${out.reason || '未知原因'}`)
|
|
394
400
|
return
|
|
395
401
|
}
|
|
396
|
-
|
|
397
|
-
|
|
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 }
|
|
398
405
|
// 语义去重近邻:把已有卡片索引喂给模型,让模型决定新建 vs 追加。
|
|
399
406
|
// 成本控制:dedupByLLM=false 时跳过喂 LLM 的近邻采样(纯词法去重兜底)。
|
|
400
407
|
const draft = { title: '', body: text.slice(0, 400) }
|
|
401
408
|
const neighbors = cfg.dedupByLLM === false ? [] : await pickNeighbors(vaultDir(), draft, 8)
|
|
402
|
-
|
|
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() }
|
|
403
426
|
if (!result) {
|
|
404
427
|
// 蒸馏失败不能让内容白丢:退成原文卡(与「关闭蒸馏」同一条降级路径)。
|
|
405
428
|
const raw = await captureCard(vaultDir(), {
|
|
@@ -413,7 +436,8 @@ export function apply(ctx, config) {
|
|
|
413
436
|
severity: 'info',
|
|
414
437
|
reason: 'AI 自动沉淀(蒸馏无输出 → 原文卡兜底)',
|
|
415
438
|
}, { threshold: cfg.dedupThreshold })
|
|
416
|
-
|
|
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 }) }
|
|
417
441
|
else if (raw.duplicate) { countWrite(); logCapture(sessionId, 'appended', '蒸馏无输出 + 与已有卡重复 → 追加更新', { path: raw.duplicate.path, model: route.model }) }
|
|
418
442
|
else logCapture(sessionId, 'fail', `蒸馏无输出,且兜底原文卡也失败:${raw.reason || '未知原因'}`, { model: route.model })
|
|
419
443
|
return
|
|
@@ -468,10 +492,14 @@ export function apply(ctx, config) {
|
|
|
468
492
|
// 就说明收尾事件没到——这是「监听器整个没被调用」这类静默死亡的兜底探测。
|
|
469
493
|
let turnsStarted = 0
|
|
470
494
|
let turnsStopped = 0
|
|
471
|
-
|
|
472
|
-
|
|
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) })
|
|
473
500
|
ctx.on('agent/turn-stopping', ({ agent }) => {
|
|
474
501
|
turnsStopped += 1
|
|
502
|
+
stamp(stoppedAt)
|
|
475
503
|
const sessionId = agent?.session?.id ?? agent?.id ?? 'unknown'
|
|
476
504
|
try {
|
|
477
505
|
const api = sessionEventApi(agent?.session)
|
|
@@ -593,14 +621,12 @@ export function apply(ctx, config) {
|
|
|
593
621
|
// 沉淀停滞探测:只有「有轮次在跑」且「管线 15 分钟一个活口都没有」才报(真死才报)。
|
|
594
622
|
// 计数器对不上是常态(子代理轮次、被取消的轮次都不发 turn-stopping),单看计数会误报刷屏。
|
|
595
623
|
const stallTimer = setInterval(() => {
|
|
596
|
-
const
|
|
597
|
-
|
|
598
|
-
const suspicious = turnsStarted > turnsStopped && claimIdleMs > 15 * 60 * 1000 && idleMs > 15 * 60 * 1000
|
|
599
|
-
if (suspicious && !stallAlerted) {
|
|
624
|
+
const { alert, started, stopped, windowMs } = evaluateStall({ claimedAt, stoppedAt })
|
|
625
|
+
if (alert && !stallAlerted) {
|
|
600
626
|
stallAlerted = true
|
|
601
|
-
logCapture('system', 'fail',
|
|
627
|
+
logCapture('system', 'fail', `轮次收尾事件疑似失效:最近 ${Math.round(windowMs / 60000)} 分钟内开始 ${started} 个轮次、收尾 ${stopped} 个(累计 ${turnsStarted}/${turnsStopped})——DSH 可能改了事件名或作用域`)
|
|
602
628
|
}
|
|
603
|
-
},
|
|
629
|
+
}, 5 * 60 * 1000)
|
|
604
630
|
ctx.effect(() => () => clearInterval(stallTimer), 'memory-eternal: capture stall watch')
|
|
605
631
|
|
|
606
632
|
// 回收站清理:每 30 分钟永久删除超过保留期(默认 30 天)的软删卡。
|
|
@@ -631,8 +657,12 @@ export function apply(ctx, config) {
|
|
|
631
657
|
let webInfo = { url: 'http://127.0.0.1:7999', port: 7999, alive: false }
|
|
632
658
|
const refreshWebInfo = (info) => { if (info && info.url) webInfo = { ...info, alive: true } }
|
|
633
659
|
|
|
634
|
-
|
|
635
|
-
|
|
660
|
+
// webServer 是「可能晚到」的服务:apply 执行时它常常尚未挂载,同步 ctx.get()
|
|
661
|
+
// 拿不到就整段跳过、且不留任何日志 —— 表现是 /memory-eternal/api/* 恒 404、
|
|
662
|
+
// 客户端设置页永远停在「加载中…」。改用 ctx.inject() 等服务就绪后再注册:
|
|
663
|
+
// 没有该服务的 profile(如 TUI)里子 fiber 保持 pending,主插件照常激活。
|
|
664
|
+
// (DSH 自身的 dsh-client-modules 也是这么写的:ctx.inject(['webServer'], …))
|
|
665
|
+
const registerApiRoutes = (webServer) => {
|
|
636
666
|
const handleApi = createApi({
|
|
637
667
|
vaultDir, vaultRoots, getSettings: settings.get,
|
|
638
668
|
// 自动沉淀运行轨迹 + 健康状态:供「用量/今日」页排查「为什么没写卡」,异常时页面顶部亮红。
|
|
@@ -769,6 +799,15 @@ export function apply(ctx, config) {
|
|
|
769
799
|
})
|
|
770
800
|
}
|
|
771
801
|
|
|
802
|
+
// 等 webServer 就绪再注册宿主侧 API。注册成功留一条 boot 记录,
|
|
803
|
+
// 便于在「设置 → 记忆 → 用量/今日」里确认路由到底挂上没有。
|
|
804
|
+
ctx.inject(['webServer'], (child) => {
|
|
805
|
+
const webServer = child.get('webServer')
|
|
806
|
+
if (webServer === undefined) return
|
|
807
|
+
registerApiRoutes(webServer)
|
|
808
|
+
logCapture('system', 'boot', '记忆 API 路由已注册(/memory-eternal/api)')
|
|
809
|
+
})
|
|
810
|
+
|
|
772
811
|
// -- 4. 多宿主常驻:MCP 挂载 + web server 保活(按设置项分层) -----------------
|
|
773
812
|
// 全部后台异步、静默失败:插件激活不能被外部环境问题卡住。
|
|
774
813
|
let intervalTimer = null
|
|
@@ -835,16 +874,29 @@ export function apply(ctx, config) {
|
|
|
835
874
|
import('./lib/watchdog.js')
|
|
836
875
|
.then((m) => {
|
|
837
876
|
const port = Number(cfg0.webPort) || 7999
|
|
877
|
+
// 用 nodeBinary() 而非 process.execPath:Electron 宿主下后者是 Electron
|
|
878
|
+
// 主程序,spawn 出来不会执行 watchdog.js(见 lib/node-bin.js)。
|
|
879
|
+
const bin = nodeBinary()
|
|
838
880
|
const wd = spawn(
|
|
839
|
-
|
|
881
|
+
bin,
|
|
840
882
|
[path.join(PACKAGE_ROOT, 'lib', 'watchdog.js'), '--port', String(port), '--interval', String(cfg0.webCheckIntervalMs || 5000), '--max-restart', String(cfg0.webMaxRestart || 10)],
|
|
841
|
-
{ detached: true, stdio: 'ignore', env: {
|
|
883
|
+
{ detached: true, stdio: 'ignore', env: childEnv({ MEMORY_VAULT_DIR: vaultDir() }), windowsHide: true },
|
|
842
884
|
)
|
|
885
|
+
// spawn 失败默认静默:既写 stderr,也进自动沉淀日志(面板可见)
|
|
886
|
+
wd.on('error', (error) => {
|
|
887
|
+
const why = `watchdog spawn 失败(${bin}):${error?.message || error}`
|
|
888
|
+
console.error(`[memory-eternal] ${why}`)
|
|
889
|
+
logCapture('system', 'fail', why)
|
|
890
|
+
})
|
|
843
891
|
wd.unref()
|
|
844
892
|
watchdogProc = wd
|
|
845
|
-
console.error(`[memory-eternal] watchdog spawned pid=${wd.pid} port=${port}`)
|
|
893
|
+
console.error(`[memory-eternal] watchdog spawned pid=${wd.pid} bin=${bin} port=${port}`)
|
|
894
|
+
})
|
|
895
|
+
.catch((error) => {
|
|
896
|
+
const why = `watchdog 模块加载失败:${error?.message || error}`
|
|
897
|
+
console.error(`[memory-eternal] ${why}`)
|
|
898
|
+
logCapture('system', 'fail', why)
|
|
846
899
|
})
|
|
847
|
-
.catch(() => {})
|
|
848
900
|
}
|
|
849
901
|
|
|
850
902
|
return () => {
|
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
|
}
|