memory-eternal 0.10.20 → 0.10.21
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 +3 -2
- package/README.md +3 -2
- package/bin/dsh-memory.mjs +33 -0
- package/lib/api.js +20 -4
- package/lib/client.js +1 -1
- package/lib/db.js +75 -0
- package/package.json +2 -2
- package/web/app.js +1 -1
|
@@ -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.10.
|
|
4
|
+
"version": "0.10.21",
|
|
5
5
|
"description": "记忆核心(Memory Eternal):对话结束后自动沉淀知识卡到本地记忆库(~/.dsh/memory-vault),支持检索 / 召回 / 审核中心。",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "EternalNight996",
|
package/README.en.md
CHANGED
|
@@ -361,14 +361,15 @@ curl http://127.0.0.1:7999/memory-eternal/api/web-info # is the standalone we
|
|
|
361
361
|
|
|
362
362
|
| Version | Date | Highlights |
|
|
363
363
|
|---|---|---|
|
|
364
|
+
| **v0.10.21** | 2026-10-13 | **"DB unavailable" goes from silent to visible (issue #33, scoped by review)**. ① **Health check at open**: the old code ran `PRAGMA journal_mode=WAL` and assumed success (that pragma does not throw on failure — it just returns the actual mode), so a read-only file or unwritable directory let the plugin run on silently; now the real journal mode is **read back** and a **write probe** runs (create then drop a table, in the main DB rather than temp), the verdict lands in `dbStatus()`, and an unwritable DB immediately logs the reason plus where to look. ② **`/overview` degrades instead of 500**: when the DB or directory is unavailable it no longer returns 500 (probes, the panel and scripts read 500 as "this port is broken / not our service", which historically started the restart storm and port drift); it now returns 200 with `degraded:true`, `dbError` and `db:{journalMode,writable,error}` while keeping the `vaultDir` / `version` identity fields — so "this is us, but the DB is broken" can finally be expressed separately. ③ **New `dsh-memory doctor`**: DB health self-check (real journal mode / write probe / backup list / recovery guidance) with `--json` for scripting; when the DB is unwritable or suspected corrupt it prints **guidance that never touches data** (stop writers → copy the scene aside → list backups → swap → re-verify). **Deliberately not done** (review conclusion, now guarded by tests so nobody adds it back casually): `busy_timeout` (`DatabaseSync` is a synchronous API, so waiting on a lock blocks the host event loop) and automatic WAL→DELETE downgrade (it **permanently** changes the DB mode, and a rollback journal has worse concurrency, which could worsen lock contention). `npm test` now runs **322 checks** |
|
|
364
365
|
| **v0.10.20** | 2026-10-12 | **Fixing the three-layer "DB unavailable → the watchdog kills its own web → the port drifts to 8006" scene (issues #31 / #33, measured by AkiMei0721)**. ① **A non-200 `/overview` is not a dead port**: `/overview` touches the database, so a read-only / WAL-corrupted DB makes it 500, and the watchdog declared the port offline → `spawnWeb()` (which first SIGTERMs its own previous child) → **it killed and respawned its own healthy web, drifting the port from 7999 all the way to 8006 (six instances)**; a new pure `decideTickAction()` treats **a reachable TCP port as alive** (`api-degraded-but-listening`, resetting the restart counter), and the readiness check for a new instance likewise accepts "listening but `/overview` non-200". ② **The version probe now asks `/version-check`** (no database access; falls back to `/overview`) — the root symptom was "the port is clearly alive yet no version comes back", so verify reported "(no response)" and `status` even misjudged it as "a pre-v0.10.4 build". ③ **No more crash in restricted sandboxes**: the default `stdio:'pipe'` makes `spawn()` **throw EPERM synchronously** (the old code only guarded the `'error'` event, which never ran), so `dsh-memory watchdog --reap` crashed and process enumeration stayed permanently empty; `spawn` is now wrapped in `try/catch` and stdout goes through a **temp file**, touching no named pipes. ④ The stall check also gained a "minimum uptime" (30 minutes): a freshly restarted process necessarily has zero stops, so it must not report "turn-stop events are broken" (a false alarm was measured 1.5 minutes after a restart). Adds `tests/issue-fixes-8.test.mjs` (4 checks) plus a too-early case in `tests/stall.test.mjs`. `npm test` now runs **318 checks** |
|
|
365
|
-
| **v0.10.19** | 2026-10-12 | **`UNSUPPORTED_REASONING_EFFORT` no longer kills the whole distillation (issue #34)**. The scene (zafir-zhong and others): many **gateways** claim reasoning-effort support in their capability list and only reject `reasoningEffort: 'off'` when it is actually sent (measured: workbuddy/hy3, FallbacksChain/Auto, new-api-resp/gpt-6-luna), so the entire candidate is marked failed and the pipeline moves to the next provider — auto-capture stops dead for those users (that FAIL shows up in the diagnostics of #37 / #38 / #40 too). The existing design only covered "the provider **says** it does not support it → do not send the field" (#15); the runtime half was missing. Fix: an in-process memo `markEffortUnsupported()\/isEffortUnsupported()` — on the first rejection the call **retries once without the field** (same candidate, same provider), remembers that `provider/model`, and from then on the first request for it carries no effort at all; the memo is **keyed by provider/model**, so other providers are untouched. Guards added in `tests/capture-route.test.mjs` (sent → rejected → retried without and got a card → later only one request → other providers unaffected). `npm test` now runs **313 checks** |
|
|
366
366
|
|
|
367
367
|
<details>
|
|
368
|
-
<summary>Older release notes (v0.10.
|
|
368
|
+
<summary>Older release notes (v0.10.19 and earlier · 46 releases, click to expand)</summary>
|
|
369
369
|
|
|
370
370
|
| Version | Date | Highlights |
|
|
371
371
|
|---|---|---|
|
|
372
|
+
| **v0.10.19** | 2026-10-12 | **`UNSUPPORTED_REASONING_EFFORT` no longer kills the whole distillation (issue #34)**. The scene (zafir-zhong and others): many **gateways** claim reasoning-effort support in their capability list and only reject `reasoningEffort: 'off'` when it is actually sent (measured: workbuddy/hy3, FallbacksChain/Auto, new-api-resp/gpt-6-luna), so the entire candidate is marked failed and the pipeline moves to the next provider — auto-capture stops dead for those users (that FAIL shows up in the diagnostics of #37 / #38 / #40 too). The existing design only covered "the provider **says** it does not support it → do not send the field" (#15); the runtime half was missing. Fix: an in-process memo `markEffortUnsupported()\/isEffortUnsupported()` — on the first rejection the call **retries once without the field** (same candidate, same provider), remembers that `provider/model`, and from then on the first request for it carries no effort at all; the memo is **keyed by provider/model**, so other providers are untouched. Guards added in `tests/capture-route.test.mjs` (sent → rejected → retried without and got a card → later only one request → other providers unaffected). `npm test` now runs **313 checks** |
|
|
372
373
|
| **v0.10.18** | 2026-10-12 | **"Save config" was being rejected unconditionally (issue #35)**. The scene (Dzifxazy): a fresh install, change nothing, hit save → **HTTP 400 "field must not be empty: secretHint"**, the whole submission rejected, so none of the numbers or toggles the user did change were stored; the wording also pointed troubleshooting at "some numeric box left blank". Root cause: the host-side pre-check treated **any empty string** as "numeric field not filled" and only exempted a hard-coded list (vaultDir / captureProvider / captureModel / recallEmbedding / activeVault) — while `secretHint`'s default **is** an empty string (`z.string().default('')`) and was not on that list; since the panel submits the whole form, every save hit it. Fix: new `emptyNumericFields(patch, Config.dict)` decides which keys were cleared by looking at the schema (`type === 'number'`), and the hard-coded list is gone (future nullable string fields need nobody to remember an exemption). Adds `tests/config-validate.test.mjs` (2 checks: empty strings only count for number fields — including a brand-new string field that appears on no list as a regression nail, plus a source guard against reintroducing the list). `npm test` now runs **312 checks** |
|
|
373
374
|
| **v0.10.17** | 2026-10-12 | **Root fix for the "resident is serving but nobody manages it" self-contradiction (issue #28)**. The scene: after upgrading to 0.10.16 the reporter had no resident instance at all, and the panel showed **neither a restart button nor any hint** (the banner only rendered when `vc.stale`, which is false when the host and the port report the same version), only an "auto-capture anomaly"; reproduced the other half locally: after `stop` the port was **still serving**, the lock was empty, and `status` said "no registered watchdog" — because that web had been spawned directly by the host's `ensureWeb()` (no `webPid` in the lock) and `stop` only killed the watchdog. Five fixes: ① **`/version-check` now reports `resident.managed`** (a live watchdog in the lock is what makes it managed) — "serving" is not "managed"; ② **panel**: both the **not-running** and the **unmanaged** resident show a restart button with dedicated wording ("not running" / "unmanaged") instead of only the version-drift case; ③ **`/restart-self`**: `alreadyCurrent` now requires a version match **and** a live manager, otherwise it replaces as usual (an orphan web does not get to stay just because its version is new); ④ **an explicit `dsh-memory stop` frees the port** (`forcePortOccupant: true`, same gate as ever: only processes `looksLikeOurWeb` recognises are terminated); ⑤ **the self-check now demands a fresh takeover**: `restartResident` no longer accepts "the port serves the expected version" (before and after an upgrade the version is often identical, so the **old** instance fooled the check — measuring "self-check passed (waited 0s)" immediately followed by `status` reporting no registered watchdog); the new `waitForTakeover()` requires a matching version **and** a live watchdog whose pid is not in the pre-replacement set, and failure reasons are split accordingly into "wrong version" vs "nobody took over". Measured on this machine in the same scenario: `41672/17432` before → `28136/31976` after, `restart` waited 13s and passed, and the **very next `status` showed the new watchdog registered** (no contradiction). `npm test` now runs **310 checks** |
|
|
374
375
|
| **v0.10.16** | 2026-10-09 | **`--reap` no longer says "nothing was reaped" when it did reap something** (reported by alario-tang, measured on macOS with 0.10.13). The scene: after `kill -9` on the watchdog leaves a `PPID=1` orphan web, `dsh-memory watchdog --reap --port 7999` really freed the port and really killed the web, yet the screen read "**scanned 1 watchdog process, cleaned 0 orphans**" — because 0.10.13 made `scanned` include webs while `webKilled` was never printed. Fix: a pure `formatReapSummary()` unifies the wording (scan counts split into `watchdog A / web B`, cleaned entries listed per kind with pids), `reapStaleWatchdogs` gains `scannedWatchdogs` / `scannedWebs` (`scanned` stays their sum for older callers), and the CLI uses it. Two checks added (never say "cleaned 0 orphans" when a web was reaped / scan counts must be split), and `tests/watchdog-orphan-web.test.mjs` now records the reproduction note: **such an orphan can only be created with `kill -9` — SIGTERM runs the watchdog's graceful exit, which also stops the web it spawned** (the same coin as #19's "on Windows SIGTERM is unconditional termination"). `npm test` now runs **308 checks** |
|
package/README.md
CHANGED
|
@@ -393,14 +393,15 @@ curl http://127.0.0.1:7999/memory-eternal/api/web-info # 独立 web 是否活
|
|
|
393
393
|
|
|
394
394
|
| 版本 | 日期 | 关键改动 |
|
|
395
395
|
|---|---|---|
|
|
396
|
+
| **v0.10.21** | 2026-10-13 | **「库不可用」从「静默运行」变成「看得清」(issue #33,按评审裁剪范围)**。① **打开即体检**:老实现执行完 `PRAGMA journal_mode=WAL` 就当成功(这条 PRAGMA 失败时**不抛错**,只把真实模式当一行返回),文件只读 / 目录不可写时插件会一路静默跑下去;现在打开后**读回真实 journal 模式 + 真写一次探针**(建表→删表,写主库而非 temp 库),结论记进 `dbStatus()`,不可写时立即把原因与排查方向打到日志。② **`/overview` 降级返回**:库/目录不可用时不再吐 500(500 会被探针、面板、脚本读成「这个端口坏了 / 不是我们的服务」,历史上正是重启风暴与端口漂移的入口),改成 200 + `degraded:true` + `dbError` + `db:{journalMode,writable,error}`,并保住 `vaultDir` / `version` 身份字段 —— 「这是我们、但库有问题」从此可以被分开表达。③ **新增 `dsh-memory doctor`**:库健康自检(真实 journal 模式 / 写探针 / 备份清单 / 恢复引导),带 `--json` 可脚本化;不可写或疑似损坏时给出**只引导、不动数据**的步骤(先停写者 → 复制现场留证 → 查备份 → 替换 → 复核)。**刻意不做**(评审结论,已写成测试守卫防止顺手加回):`busy_timeout`(`DatabaseSync` 是同步 API,撞锁会阻塞宿主事件循环)与 WAL→DELETE 自动降级(会**永久**改变库模式,且 rollback journal 并发更差、可能反过来加剧锁竞争)。`npm test` 共 **322 项** |
|
|
396
397
|
| **v0.10.20** | 2026-10-12 | **「库不可用 → 看门狗自杀自拉 → 端口漂到 8006」三层现场一起修(issue #31 / #33,AkiMei0721 实测)**。① **`/overview` 非 200 ≠ 端口死了**:`/overview` 会碰数据库,库只读 / WAL 损坏时它返回 500,而看门狗据此判「离线」→ 走 `spawnWeb()`(开头先 SIGTERM 掉自己的上一个 child)→ **自杀自拉、端口从 7999 一路漂到 8006(6 个实例)**;新增 `decideTickAction()` 纯决策:只要 **TCP 还能连**就视为存活(`api-degraded-but-listening`,并重置重启计数),新实例的就绪判定同样接受「已监听但 `/overview` 非 200」。② **版本探测改走 `/version-check`**(不碰库;拿不到才回落 `/overview`)—— 病根是「端口上明明活着、版本却拿不到」,于是 verify 报「(无响应)」、`status` 甚至误判成「v0.10.4 之前的旧版」。③ **受限沙箱里不再崩**:默认 `stdio:'pipe'` 会在 `spawn()` 时**同步抛 EPERM**(老实现只在 `'error'` 事件里兜底,根本没机会执行)→ `dsh-memory watchdog --reap` 直接崩、进程枚举永远空名单;现在 `try/catch` 包住 spawn,并**用临时文件接 stdout**、完全不碰命名管道。④ 顺带给 stall 判据加「最短运行时长(30 分钟)」:刚落进程必然 0 收尾,不该据此报「收尾事件失效」(实测重启 1.5 分钟后就误报过)。新增 `tests/issue-fixes-8.test.mjs`(4 项)+ `tests/stall.test.mjs` 的 too-early 用例。`npm test` 共 **318 项** |
|
|
397
|
-
| **v0.10.19** | 2026-10-12 | **`UNSUPPORTED_REASONING_EFFORT` 不再让整条蒸馏熔断(issue #34)**。现场(zafir-zhong 等):很多**网关**在能力自述里说支持 reasoning effort,真把 `reasoningEffort: 'off'` 发出去才回 `UNSUPPORTED_REASONING_EFFORT`(实测:workbuddy/hy3、FallbacksChain/Auto、new-api-resp/gpt-6-luna),于是整个候选被判失败、换下一个 provider —— 用户的自动沉淀直接停摆(#37 / #38 / #40 的诊断信息里都能看到这条 FAIL)。既有设计只覆盖「provider **自述**不支持 → 不传该字段」(#15),缺了运行时这一半。修法:新增进程内记忆 `markEffortUnsupported()\/isEffortUnsupported()` —— 首次被拒时**不带该字段重试一次**(同一候选、同一 provider),并记住这个 `provider/model`,此后同一进程内它第一次请求就不再带 effort;记忆**按 provider/model 分开**,不误伤别的 provider。回归守卫加在 `tests/capture-route.test.mjs`(第一次带 → 被拒 → 第二次不带并拿到卡 → 后续只发一次 → 别的 provider 不受影响)。`npm test` 共 **313 项** |
|
|
398
398
|
|
|
399
399
|
<details>
|
|
400
|
-
<summary>更早的更新日志(v0.10.
|
|
400
|
+
<summary>更早的更新日志(v0.10.19 及以前 · 共 46 版,点开查看)</summary>
|
|
401
401
|
|
|
402
402
|
| 版本 | 日期 | 关键改动 |
|
|
403
403
|
|---|---|---|
|
|
404
|
+
| **v0.10.19** | 2026-10-12 | **`UNSUPPORTED_REASONING_EFFORT` 不再让整条蒸馏熔断(issue #34)**。现场(zafir-zhong 等):很多**网关**在能力自述里说支持 reasoning effort,真把 `reasoningEffort: 'off'` 发出去才回 `UNSUPPORTED_REASONING_EFFORT`(实测:workbuddy/hy3、FallbacksChain/Auto、new-api-resp/gpt-6-luna),于是整个候选被判失败、换下一个 provider —— 用户的自动沉淀直接停摆(#37 / #38 / #40 的诊断信息里都能看到这条 FAIL)。既有设计只覆盖「provider **自述**不支持 → 不传该字段」(#15),缺了运行时这一半。修法:新增进程内记忆 `markEffortUnsupported()\/isEffortUnsupported()` —— 首次被拒时**不带该字段重试一次**(同一候选、同一 provider),并记住这个 `provider/model`,此后同一进程内它第一次请求就不再带 effort;记忆**按 provider/model 分开**,不误伤别的 provider。回归守卫加在 `tests/capture-route.test.mjs`(第一次带 → 被拒 → 第二次不带并拿到卡 → 后续只发一次 → 别的 provider 不受影响)。`npm test` 共 **313 项** |
|
|
404
405
|
| **v0.10.18** | 2026-10-12 | **「保存配置」被无条件拒绝的修复(issue #35)**。现场(Dzifxazy):全新安装、什么都不动、直接点保存 → **HTTP 400「字段不能为空:secretHint」**,整单被拒,用户改的数字与开关**一个都存不进去**;文案还会把排查方向误导到「某个数字格子没填」。根因:宿主侧的前置校验把**任何空字符串**都当成「数字没填」,只豁免一份硬编码名单(vaultDir / captureProvider / captureModel / recallEmbedding / activeVault)—— 而 `secretHint` 的默认值**就是**空串(`z.string().default('')`)又不在名单里;面板提交的是整张表单,于是每次保存必然命中。修法:新增 `emptyNumericFields(patch, Config.dict)`,**按 schema 的 `type === 'number'` 判定**「哪些键被清空」,彻底删掉硬编码名单(以后新增可空字符串字段不需要谁记得去补)。新增 `tests/config-validate.test.mjs`(2 项:空串只对 number 字段报「没填」——里面放了一个「不在任何名单里」的新字符串字段做回归钉子;源码守卫不许退回名单)。`npm test` 共 **312 项** |
|
|
405
406
|
| **v0.10.17** | 2026-10-12 | **「常驻实例在服务、却没人托管」这类状态自相矛盾的根治(issue #28)**。现场:用户升级到 0.10.16 后常驻实例没起来,面板**既没有重启按钮也没有任何提示**(横幅只在 `vc.stale` 时渲染,而宿主与端口版本号一致时 `stale` 为假),只看到一条「自动沉淀异常」;本机复现出另一半:`stop` 之后端口上**还活着**、锁里却空了、`status` 说「没有登记的看门狗」—— 因为端口上那个 web 是宿主 `ensureWeb()` 直接拉起的(锁里没有 `webPid`),`stop` 只杀了看门狗。五处一起修:① **`/version-check` 报出 `resident.managed`**(锁里有活着的看门狗才算被托管)—— 「在服务」≠「被托管」;② **面板**:常驻实例**没在跑**或**没被托管**时都显示重启按钮与专门文案(「未运行」/「未托管」),不再只认「版本落后」这一种;③ **`/restart-self`**:`alreadyCurrent` 必须同时满足「版本一致 **且** 被托管」,否则照常替换(孤儿 web 版本再新也得重新托管);④ **显式 `dsh-memory stop` 把端口腾出来**(`forcePortOccupant: true`,闸门不变:只终止 `looksLikeOurWeb` 认得出是自家的进程);⑤ **自检改为「新实例接管」**:`restartResident` 不再只看「端口服着期望版本」(升级前后版本号常常相同、旧实例本就是这个版本 → 自检会被**旧实例**骗过,实测打印「自检通过(等待 0s)」后紧接着 `status` 说没有登记的看门狗),新增 `waitForTakeover()`:必须「版本一致 **且** 锁里出现一个不在替换前 pid 集合里的活看门狗」才算成功,失败原因也据此分离成「版本不对」与「没人接管」。本机同一现场实测:替换前 `41672/17432` → 替换后 `28136/31976`,`restart` 等待 13s 后自检通过,**紧接着的 `status` 立刻显示新看门狗已登记**(不再矛盾)。`npm test` 共 **310 项** |
|
|
406
407
|
| **v0.10.16** | 2026-10-09 | **`--reap` 的输出不再「明明收了却说没收」**(alario-tang 反馈,macOS + 0.10.13 实测)。现场:`kill -9` 掉 watchdog 造出 `PPID=1` 的孤儿 web 后跑 `dsh-memory watchdog --reap --port 7999`,端口确实被释放、web 确实被收掉,屏幕上却写着「**扫描到 1 个 watchdog 进程,清理孤儿 0 个**」—— 因为 0.10.13 让 `scanned` 含了 web、而 `webKilled` 一个字都没打印。修法:新增纯函数 `formatReapSummary()` 统一口径(扫描数拆成 `watchdog A / web B`、清理项按类分别列出 pid),`reapStaleWatchdogs` 返回值补 `scannedWatchdogs` / `scannedWebs`(`scanned` 仍是两者之和,兼容旧调用方),CLI 改用它。测试补 2 项(收了 web 时不许再说「清理孤儿 0 个」/ 扫描计数必须拆开),并在 `tests/watchdog-orphan-web.test.mjs` 头部记下复现要点:**这类孤儿只能用 `kill -9` 造 —— SIGTERM 会走 watchdog 的优雅退出、把它拉起的 web 一起停掉**(与 #19 里「Windows 上 SIGTERM 即无条件终止」是同一枚硬币的两面)。`npm test` 共 **308 项** |
|
package/bin/dsh-memory.mjs
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// dsh-memory setup [--claude-only|--codex-only|--cursor-only] [--dry-run] [--no-hooks]
|
|
11
11
|
// 自动挂载 MCP 到已装的 agent
|
|
12
12
|
// dsh-memory sweep <dir> 挖掘 Claude Code 会话 JSONL
|
|
13
|
+
// dsh-memory doctor [--vault DIR] [--json] 库健康自检(journal 模式 / 写探针 / 备份清单 / 恢复引导)
|
|
13
14
|
//
|
|
14
15
|
// 环境变量:
|
|
15
16
|
// MEMORY_VAULT_DIR vault 目录(默认 ~/.dsh/memory-vault)
|
|
@@ -163,6 +164,38 @@ async function main() {
|
|
|
163
164
|
}
|
|
164
165
|
return
|
|
165
166
|
}
|
|
167
|
+
case 'doctor': {
|
|
168
|
+
// ⑤ 损坏/不可写时的「引导」(issue #33):只体检、只列备份、只打印下一步 —— 绝不自动替换/删除数据。
|
|
169
|
+
const { defaultVaultDir } = await import('../lib/capture-run.js')
|
|
170
|
+
const { getDb, dbStatus, checkDbHealth, rebuildGuidance } = await import('../lib/db.js')
|
|
171
|
+
const fsPromises = await import('node:fs/promises')
|
|
172
|
+
const root = argOf('--vault') ? path.resolve(argOf('--vault')) : defaultVaultDir()
|
|
173
|
+
let health = null
|
|
174
|
+
try {
|
|
175
|
+
const db = getDb(root) // 打开即体检(写探针在建表之后跑一次)
|
|
176
|
+
health = dbStatus(root) || checkDbHealth(db)
|
|
177
|
+
} catch (e) {
|
|
178
|
+
health = { journalMode: '', writable: false, error: String((e && e.message) || e), corrupt: /malformed|not a database|corrupt/i.test(String((e && e.message) || e)), checkedAt: Date.now() }
|
|
179
|
+
}
|
|
180
|
+
const backupDir = path.join(root, 'backups')
|
|
181
|
+
const backups = (await fsPromises.readdir(backupDir).catch(() => []))
|
|
182
|
+
.filter((x) => x.startsWith('memory-eternal-') && x.endsWith('.db')).sort().reverse()
|
|
183
|
+
const guidance = rebuildGuidance(root, health)
|
|
184
|
+
if (has('--json')) {
|
|
185
|
+
console.log(JSON.stringify({ ok: Boolean(health.writable), vault: root, health, backups, guidance }, null, 2))
|
|
186
|
+
if (!health.writable) process.exitCode = 1
|
|
187
|
+
return
|
|
188
|
+
}
|
|
189
|
+
console.log('记忆核心 · 自检(doctor)')
|
|
190
|
+
console.log('vault:' + root)
|
|
191
|
+
console.log('journal_mode:' + (health.journalMode || '(未知)'))
|
|
192
|
+
console.log('写探针:' + (health.writable ? 'OK(这个库现在可写)' : '失败 → ' + health.error))
|
|
193
|
+
if (health.corrupt) console.log('损坏判定:疑似文件损坏(见下方引导)')
|
|
194
|
+
console.log('备份:' + (backups.length ? backups.length + ' 个(最近:' + backups[0] + ')' : '(还没有备份 —— 可在面板或 backupDb 生成)'))
|
|
195
|
+
if (guidance.length) { console.log(''); for (const line of guidance) console.log('· ' + line) }
|
|
196
|
+
if (!health.writable) process.exitCode = 1
|
|
197
|
+
return
|
|
198
|
+
}
|
|
166
199
|
case 'stop': {
|
|
167
200
|
const { stopWatchdogs } = await import('../lib/watchdog.js')
|
|
168
201
|
const port = argOf('--port')
|
package/lib/api.js
CHANGED
|
@@ -18,7 +18,7 @@ import { listCards, countCards, readCard, search, searchAll, graph, graphAll, ov
|
|
|
18
18
|
import { compressExcerpt, DEFAULT_CAPTURE_MAX_TOKENS } from './capture.js'
|
|
19
19
|
import { buildDiagnostics } from './feedback.js'
|
|
20
20
|
import { getSetupStatus, runSetup, mcpAgentAction } from './setup.js'
|
|
21
|
-
import { backupDb } from './db.js'
|
|
21
|
+
import { backupDb, dbStatus, rebuildGuidance } from './db.js'
|
|
22
22
|
import { createHub } from './sse.js'
|
|
23
23
|
import { probeServedVersion, restartResident, currentPkgVersion, readWatchdogLock, isPidAlive } from './watchdog.js'
|
|
24
24
|
|
|
@@ -156,15 +156,31 @@ export function createApi(deps = {}) {
|
|
|
156
156
|
|
|
157
157
|
switch (route) {
|
|
158
158
|
case '/overview': {
|
|
159
|
-
await ensureVault(vaultRoot)
|
|
160
159
|
// version = **当前正在服务这个端口的进程**加载的包版本(issue #23):
|
|
161
160
|
// 磁盘上升级了、端口上跑的还是旧进程时,只有这个字段说得清真相(锁文件里的
|
|
162
161
|
// pkgVersion 是 watchdog 自己写的,与端口上真正服务的代码无关)。
|
|
162
|
+
const servingVersion = (getDshInfo() || {}).version || process.env.MEMORY_ETERNAL_VERSION || ''
|
|
163
|
+
const degraded = (error) => {
|
|
164
|
+
// #33:库/目录不可用时**不再吐 500**。500 会被上层(探针、面板、脚本)读成
|
|
165
|
+
// 「这个端口坏了 / 不是我们的服务」,历史上就是这么引出重启风暴与端口漂移的。
|
|
166
|
+
// 降级成 200 + degraded 标记,身份字段(vaultDir/version)照旧带上 ——
|
|
167
|
+
// 「这是我们、但库有问题」从此可以被分开表达,用户也能看到真实原因。
|
|
168
|
+
const status = { writable: false, error: String(error), ...(dbStatus(vaultRoot) || {}) }
|
|
169
|
+
json(res, 200, {
|
|
170
|
+
ok: true, degraded: true, vaultDir: vaultRoot, version: servingVersion,
|
|
171
|
+
total: 0, byKind: {}, roots: {}, db: status, dbError: status.error,
|
|
172
|
+
guidance: rebuildGuidance(vaultRoot, status),
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
try { await ensureVault(vaultRoot) } catch (e) { return degraded((e && e.message) || e) }
|
|
176
|
+
let data
|
|
177
|
+
try { data = await overview(vaultRoot) } catch (e) { return degraded((e && e.message) || e) }
|
|
163
178
|
json(res, 200, {
|
|
164
179
|
ok: true,
|
|
165
180
|
vaultDir: vaultRoot,
|
|
166
|
-
version:
|
|
167
|
-
|
|
181
|
+
version: servingVersion,
|
|
182
|
+
db: dbStatus(vaultRoot),
|
|
183
|
+
...data,
|
|
168
184
|
})
|
|
169
185
|
return
|
|
170
186
|
}
|
package/lib/client.js
CHANGED
|
@@ -298,7 +298,7 @@ Option A, because:
|
|
|
298
298
|
height: calc(100vh - var(--dsh-windows-titlebar-height, 0px));
|
|
299
299
|
max-height: calc(100vh - var(--dsh-windows-titlebar-height, 0px));
|
|
300
300
|
}
|
|
301
|
-
`;function Sa(t){t.effect(()=>t.locale.register(lt,{zh:Xt,en:Jt}),"memory-eternal: locale");let o=t.locale.bind(lt);t.effect(()=>t.slots.inject("settings.section",()=>t.slots.register({name:"settings.section",id:lt,order:25,label:()=>o("nav"),locale:lt,inject:()=>({})},()=>n.default.createElement(Na,{t:o}))),"memory-eternal: settings section"),t.effect(()=>t.slots.inject("sidebar.footer.action",()=>t.slots.register({name:"sidebar.footer.action",id:`${lt}:footer`,order:100,label:()=>o("nav"),locale:lt,inject:()=>({})},r=>n.default.createElement(Ma,{t:o,locale:t.locale,wide:!(r&&r.wide===!1)}))),"memory-eternal: sidebar footer action")}function Ca(){let[t,o]=(0,n.useState)(_+"/ui/app");return(0,n.useEffect)(()=>{let r=!0,m=()=>fetch("/memory-eternal/api/web-info").then(d=>d.json()).then(d=>{r&&d&&d.ok&&d.alive!==!1&&d.url&&o(d.url.endsWith("/")?d.url:d.url+"/")}).catch(()=>{});return fetch(_+"/ui/app",{method:"HEAD"}).then(d=>{if(r&&!d.ok)return m()}).catch(()=>r?m():void 0),()=>{r=!1}},[]),t}function Na({t}){return(0,e.jsxs)("div",{style:{display:"flex",flexDirection:"column",height:"100%",minHeight:0},children:[(0,e.jsx)("style",{children:He}),(0,e.jsx)(ta,{t,onReload:()=>{},version:0})]})}function Ma({t,locale:o,wide:r}){let[m,d]=(0,n.useState)(!1);return(0,e.jsxs)("div",{className:`me-footer${r?"":" rail"}`,children:[(0,e.jsx)("style",{children:He}),(0,e.jsxs)("button",{type:"button",className:"me-footer-btn",onClick:()=>d(!0),"aria-label":t("nav"),title:t("nav"),children:[(0,e.jsx)("span",{className:"me-footer-ico","aria-hidden":"true",children:(0,e.jsx)(Qt,{})}),(0,e.jsx)("span",{className:"me-footer-label",children:t("nav")})]}),m&&(0,e.jsx)(Da,{t,locale:o,onClose:()=>d(!1)})]})}function Da({t,locale:o,onClose:r,tab:m}){let[d,i]=(0,n.useState)(!1),S=(0,n.useRef)(null),M=Ca(),k=(0,n.useCallback)(I=>o&&typeof o.subscribe=="function"?o.subscribe(I):()=>{},[o]),E=(0,n.useCallback)(()=>o&&typeof o.getSnapshot=="function"?o.getSnapshot():null,[o]),C=(0,n.useSyncExternalStore)(k,E),h=C&&(C.active==="zh"||C.active==="en")?C.active:null,u=(0,n.useRef)(null);u.current===null&&h&&(u.current=h);let z=(0,n.useMemo)(()=>{let I=u.current?`lang=${u.current}`:"";if(m==="config")return`${_}/ui/config?tab=config${I?`&${I}`:""}`;let w=M.replace(/\/$/,""),R=w.endsWith("/ui/app")?"?":"/?";return m?`${w}${R}tab=${m}${I?`&${I}`:""}`:I?`${w}${R}${I}`:M},[M,m]);return(0,n.useEffect)(()=>{let I=S.current&&S.current.contentWindow;I&&h&&I.postMessage({source:"memory-eternal",type:"locale",lang:h},"*")},[h]),(0,n.useEffect)(()=>{let I=w=>{w.key==="Escape"&&r()};return window.addEventListener("keydown",I),()=>window.removeEventListener("keydown",I)},[r]),(0,e.jsxs)("div",{className:"me-overlay-top",onClick:r,children:[(0,e.jsx)("style",{children:He}),(0,e.jsx)("div",{className:"me-modal"+(d?" me-modal-full":""),onClick:I=>I.stopPropagation(),children:(0,e.jsxs)("div",{style:{display:"flex",flexDirection:"column",height:"100%",minHeight:0,position:"relative"},children:[(0,e.jsxs)("div",{style:{position:"absolute",top:8,right:8,zIndex:10,display:"flex",gap:4,padding:"4px 6px",background:"var(--dsw-alias-bg-overlay, rgba(17,24,39,0.45))",borderRadius:8,backdropFilter:"blur(4px)"},children:[(0,e.jsx)("button",{type:"button",onClick:()=>i(I=>!I),"aria-label":t(d?"exitFullscreen":"enterFullscreen"),title:t(d?"exitFullscreen":"enterFullscreen"),style:{width:26,height:26,borderRadius:6,border:"1px solid rgba(255,255,255,0.18)",background:"rgba(255,255,255,0.08)",color:"#fff",cursor:"pointer",fontSize:14,lineHeight:1,display:"flex",alignItems:"center",justifyContent:"center"},children:d?"\u2921":"\u2922"}),(0,e.jsx)("button",{type:"button",onClick:r,"aria-label":t("close"),title:t("close"),style:{width:26,height:26,borderRadius:6,border:"1px solid rgba(255,255,255,0.18)",background:"rgba(255,255,255,0.08)",color:"#fff",cursor:"pointer",fontSize:16,lineHeight:1,display:"flex",alignItems:"center",justifyContent:"center"},children:"\xD7"})]}),(0,e.jsx)("iframe",{ref:S,src:z,title:"memory-eternal",allow:"clipboard-write; clipboard-read",style:{width:"100%",flex:1,border:0,borderRadius:"inherit",background:"var(--dsw-alias-bg-base, #fff)"}})]})})]})}function Qt(){return(0,e.jsxs)("svg",{viewBox:"0 0 24 24","aria-hidden":"true",children:[(0,e.jsx)("defs",{children:(0,e.jsxs)("linearGradient",{id:"mg-brain",x1:"0",y1:"0",x2:"1",y2:"1",children:[(0,e.jsx)("stop",{offset:"0%",stopColor:"#FFC46B"}),(0,e.jsx)("stop",{offset:"50%",stopColor:"#F97316"}),(0,e.jsx)("stop",{offset:"100%",stopColor:"#EA580C"})]})}),(0,e.jsx)("path",{fill:"url(#mg-brain)",d:"M12 4.5a3.2 3.2 0 0 0-6.4.1 4.1 4.1 0 0 0-2.7 5.9 4.1 4.1 0 0 0 .6 6.7A4.2 4.2 0 0 0 12 18.2Z"}),(0,e.jsx)("path",{fill:"url(#mg-brain)",d:"M12 4.5a3.2 3.2 0 0 1 6.4.1 4.1 4.1 0 0 1 2.7 5.9 4.1 4.1 0 0 1-.6 6.7A4.2 4.2 0 0 1 12 18.2Z"}),(0,e.jsx)("path",{d:"M12 9.2c-.8-1.4-1.7-2-3.1-2.2M12 9.2c.8-1.4 1.7-2 3.1-2.2M12 12.4c-1.2 1-3.2 1.6-5 1.4M12 12.4c1.2 1 3.2 1.6 5 1.4M12 13.2v4",fill:"none",stroke:"rgba(255,255,255,0.62)",strokeWidth:"1",strokeLinecap:"round"}),(0,e.jsx)("circle",{cx:"12",cy:"8.6",r:"1.1",fill:"rgba(255,255,255,0.85)"})]})}var bt="0.10.
|
|
301
|
+
`;function Sa(t){t.effect(()=>t.locale.register(lt,{zh:Xt,en:Jt}),"memory-eternal: locale");let o=t.locale.bind(lt);t.effect(()=>t.slots.inject("settings.section",()=>t.slots.register({name:"settings.section",id:lt,order:25,label:()=>o("nav"),locale:lt,inject:()=>({})},()=>n.default.createElement(Na,{t:o}))),"memory-eternal: settings section"),t.effect(()=>t.slots.inject("sidebar.footer.action",()=>t.slots.register({name:"sidebar.footer.action",id:`${lt}:footer`,order:100,label:()=>o("nav"),locale:lt,inject:()=>({})},r=>n.default.createElement(Ma,{t:o,locale:t.locale,wide:!(r&&r.wide===!1)}))),"memory-eternal: sidebar footer action")}function Ca(){let[t,o]=(0,n.useState)(_+"/ui/app");return(0,n.useEffect)(()=>{let r=!0,m=()=>fetch("/memory-eternal/api/web-info").then(d=>d.json()).then(d=>{r&&d&&d.ok&&d.alive!==!1&&d.url&&o(d.url.endsWith("/")?d.url:d.url+"/")}).catch(()=>{});return fetch(_+"/ui/app",{method:"HEAD"}).then(d=>{if(r&&!d.ok)return m()}).catch(()=>r?m():void 0),()=>{r=!1}},[]),t}function Na({t}){return(0,e.jsxs)("div",{style:{display:"flex",flexDirection:"column",height:"100%",minHeight:0},children:[(0,e.jsx)("style",{children:He}),(0,e.jsx)(ta,{t,onReload:()=>{},version:0})]})}function Ma({t,locale:o,wide:r}){let[m,d]=(0,n.useState)(!1);return(0,e.jsxs)("div",{className:`me-footer${r?"":" rail"}`,children:[(0,e.jsx)("style",{children:He}),(0,e.jsxs)("button",{type:"button",className:"me-footer-btn",onClick:()=>d(!0),"aria-label":t("nav"),title:t("nav"),children:[(0,e.jsx)("span",{className:"me-footer-ico","aria-hidden":"true",children:(0,e.jsx)(Qt,{})}),(0,e.jsx)("span",{className:"me-footer-label",children:t("nav")})]}),m&&(0,e.jsx)(Da,{t,locale:o,onClose:()=>d(!1)})]})}function Da({t,locale:o,onClose:r,tab:m}){let[d,i]=(0,n.useState)(!1),S=(0,n.useRef)(null),M=Ca(),k=(0,n.useCallback)(I=>o&&typeof o.subscribe=="function"?o.subscribe(I):()=>{},[o]),E=(0,n.useCallback)(()=>o&&typeof o.getSnapshot=="function"?o.getSnapshot():null,[o]),C=(0,n.useSyncExternalStore)(k,E),h=C&&(C.active==="zh"||C.active==="en")?C.active:null,u=(0,n.useRef)(null);u.current===null&&h&&(u.current=h);let z=(0,n.useMemo)(()=>{let I=u.current?`lang=${u.current}`:"";if(m==="config")return`${_}/ui/config?tab=config${I?`&${I}`:""}`;let w=M.replace(/\/$/,""),R=w.endsWith("/ui/app")?"?":"/?";return m?`${w}${R}tab=${m}${I?`&${I}`:""}`:I?`${w}${R}${I}`:M},[M,m]);return(0,n.useEffect)(()=>{let I=S.current&&S.current.contentWindow;I&&h&&I.postMessage({source:"memory-eternal",type:"locale",lang:h},"*")},[h]),(0,n.useEffect)(()=>{let I=w=>{w.key==="Escape"&&r()};return window.addEventListener("keydown",I),()=>window.removeEventListener("keydown",I)},[r]),(0,e.jsxs)("div",{className:"me-overlay-top",onClick:r,children:[(0,e.jsx)("style",{children:He}),(0,e.jsx)("div",{className:"me-modal"+(d?" me-modal-full":""),onClick:I=>I.stopPropagation(),children:(0,e.jsxs)("div",{style:{display:"flex",flexDirection:"column",height:"100%",minHeight:0,position:"relative"},children:[(0,e.jsxs)("div",{style:{position:"absolute",top:8,right:8,zIndex:10,display:"flex",gap:4,padding:"4px 6px",background:"var(--dsw-alias-bg-overlay, rgba(17,24,39,0.45))",borderRadius:8,backdropFilter:"blur(4px)"},children:[(0,e.jsx)("button",{type:"button",onClick:()=>i(I=>!I),"aria-label":t(d?"exitFullscreen":"enterFullscreen"),title:t(d?"exitFullscreen":"enterFullscreen"),style:{width:26,height:26,borderRadius:6,border:"1px solid rgba(255,255,255,0.18)",background:"rgba(255,255,255,0.08)",color:"#fff",cursor:"pointer",fontSize:14,lineHeight:1,display:"flex",alignItems:"center",justifyContent:"center"},children:d?"\u2921":"\u2922"}),(0,e.jsx)("button",{type:"button",onClick:r,"aria-label":t("close"),title:t("close"),style:{width:26,height:26,borderRadius:6,border:"1px solid rgba(255,255,255,0.18)",background:"rgba(255,255,255,0.08)",color:"#fff",cursor:"pointer",fontSize:16,lineHeight:1,display:"flex",alignItems:"center",justifyContent:"center"},children:"\xD7"})]}),(0,e.jsx)("iframe",{ref:S,src:z,title:"memory-eternal",allow:"clipboard-write; clipboard-read",style:{width:"100%",flex:1,border:0,borderRadius:"inherit",background:"var(--dsw-alias-bg-base, #fff)"}})]})})]})}function Qt(){return(0,e.jsxs)("svg",{viewBox:"0 0 24 24","aria-hidden":"true",children:[(0,e.jsx)("defs",{children:(0,e.jsxs)("linearGradient",{id:"mg-brain",x1:"0",y1:"0",x2:"1",y2:"1",children:[(0,e.jsx)("stop",{offset:"0%",stopColor:"#FFC46B"}),(0,e.jsx)("stop",{offset:"50%",stopColor:"#F97316"}),(0,e.jsx)("stop",{offset:"100%",stopColor:"#EA580C"})]})}),(0,e.jsx)("path",{fill:"url(#mg-brain)",d:"M12 4.5a3.2 3.2 0 0 0-6.4.1 4.1 4.1 0 0 0-2.7 5.9 4.1 4.1 0 0 0 .6 6.7A4.2 4.2 0 0 0 12 18.2Z"}),(0,e.jsx)("path",{fill:"url(#mg-brain)",d:"M12 4.5a3.2 3.2 0 0 1 6.4.1 4.1 4.1 0 0 1 2.7 5.9 4.1 4.1 0 0 1-.6 6.7A4.2 4.2 0 0 1 12 18.2Z"}),(0,e.jsx)("path",{d:"M12 9.2c-.8-1.4-1.7-2-3.1-2.2M12 9.2c.8-1.4 1.7-2 3.1-2.2M12 12.4c-1.2 1-3.2 1.6-5 1.4M12 12.4c1.2 1 3.2 1.6 5 1.4M12 13.2v4",fill:"none",stroke:"rgba(255,255,255,0.62)",strokeWidth:"1",strokeLinecap:"round"}),(0,e.jsx)("circle",{cx:"12",cy:"8.6",r:"1.1",fill:"rgba(255,255,255,0.85)"})]})}var bt="0.10.21";function za({t,inModal:o,onClose:r,onFull:m,full:d}){let[i,S]=(0,n.useState)(null),[M,k]=(0,n.useState)([]),[E,C]=(0,n.useState)(!0),[h,u]=(0,n.useState)(null),[z,I]=(0,n.useState)(""),[w,R]=(0,n.useState)("all"),[D,O]=(0,n.useState)(""),[J,ve]=(0,n.useState)("all"),[V,Le]=(0,n.useState)("all"),[U,ue]=(0,n.useState)(()=>{try{let g=new URLSearchParams(typeof location<"u"?location.search:"").get("tab"),x={cards:"cards",graph:"graph",usage:"stats",stats:"stats",audit:"audit",recycle:"optimize",optimize:"optimize",config:"config"};if(g&&x[g])return x[g]}catch{}return"cards"}),[re,H]=(0,n.useState)(()=>new Set([U])),y=(0,n.useCallback)(g=>{H(x=>x.has(g)?x:new Set(x).add(g)),ue(g)},[]),[j,v]=(0,n.useState)("recent"),[te,K]=(0,n.useState)(null),X=(0,n.useRef)(null),oe=(0,n.useRef)(null),[b,$]=(0,n.useState)(!1),[se,Y]=(0,n.useState)(null),[ae,he]=(0,n.useState)(!1),[Ae,De]=(0,n.useState)(!1),[we,Be]=(0,n.useState)(!0),[Pe,ze]=(0,n.useState)(null),[Ce,tt]=(0,n.useState)(0),at=(0,n.useCallback)(()=>tt(g=>g+1),[]),Ke=i?.quarantine?.pending??0,qe=i?.quarantine?.deleted??0,de=(0,n.useRef)(null),[Re,ke]=(0,n.useState)(0),[c,s]=(0,n.useState)(!1),B=(0,n.useRef)(null),ie=(0,n.useRef)(null),ce=(0,n.useCallback)(async(g=w,x=D,L=V,ne={})=>{let q=ne.append===!0;try{q?s(!0):(C(!0),I(""));let ge=new URLSearchParams;g&&g!=="all"&&ge.set("kind",g),x.trim()&&ge.set("q",x.trim()),ge.set("status",L||"all"),ge.set("limit",String(va)),ge.set("offset",String(ne.offset||0)),j==="title"&&ge.set("sort","title");let Te=await(await fetch(`${_}/cards?${ge.toString()}`)).json();if(Te.ok){let Ve=Te.cards||[];k(je=>q?[...je,...Ve.filter(Ye=>!je.some(vt=>vt.path===Ye.path))]:Ve),ke(Number(Te.total)||Ve.length)}}catch(ge){I(String(ge&&ge.message?ge.message:ge))}finally{q?s(!1):C(!1)}},[w,D,V,j]),me=(0,n.useCallback)(async()=>{try{let[g,x]=await Promise.all([fetch(`${_}/overview`).then(L=>L.json()),fetch(`${_}/capture-log`).then(L=>L.json()).catch(()=>null)]);g.ok&&S(g),u(x&&x.ok&&x.available&&x.health||null)}catch(g){I(String(g&&g.message?g.message:g))}finally{C(!1)}},[]),a=async g=>{try{let L=await(await fetch(`${_}/delete?path=${encodeURIComponent(g)}`)).json();L.ok?(K({ok:!0,msg:t("deleted")}),await me(),at()):K({ok:!1,msg:L.error||t("deleteFail")})}catch{K({ok:!1,msg:t("deleteFail")})}X.current&&clearTimeout(X.current),X.current=setTimeout(()=>K(null),2e3)};(0,n.useEffect)(()=>(me(),()=>{de.current&&clearTimeout(de.current)}),[me]);let be=g=>{O(g),de.current&&clearTimeout(de.current),de.current=setTimeout(()=>ce(w,g,V),280)},it=g=>{R(g),ce(g,D,V)};(0,n.useEffect)(()=>{U==="cards"&&ce(w,D,V)},[V,U,Ce,j]),(0,n.useEffect)(()=>{if(U!=="cards"||E||c||M.length>=Re)return;let g=B.current,x=ie.current;if(!g)return;let L=new IntersectionObserver(ne=>{ne[0]&&ne[0].isIntersecting&&ce(w,D,V,{append:!0,offset:M.length})},{root:x||null,rootMargin:"400px"});return L.observe(g),()=>L.disconnect()},[U,M,Re,E,c,w,D,V,ce]);let Ne=async g=>{try{let x=g.status;if(!x&&g.path)try{let Me=((await fetch(`${_}/cards?status=all&q=${encodeURIComponent(g.title||"")}&limit=50`).then(Te=>Te.json())).cards||[]).find(Te=>Te.path===g.path);Me&&(x=Me.status)}catch{}let L=x&&x!=="approved"?`&status=${encodeURIComponent(x)}`:"",ne=await fetch(`${_}/card?path=${encodeURIComponent(g.path||g.id)}${L}`),q=await ne.json();q.ok?Y({path:q.path,title:g.title,text:q.text}):I(q.error||`HTTP ${ne.status}`)}catch(x){I(String(x&&x.message?x.message:x))}},l=async(g,x)=>{$(!0);try{let L=await fetch(`${_}/export`);if(!L.ok)throw new Error("HTTP "+L.status);let ne=await L.json();if(!ne.ok)throw new Error(ne.error||"export failed");let q,ge,Me;if(g==="json"){ge="memory-vault.json",Me="application/json";let je=ne.cards.map(Ye=>({path:Ye.path,title:Ye.title,kind:Ye.kind,status:Ye.status,store:Ye.store,text:Ye.text}));q=JSON.stringify({format:ne.format||"memory-eternal-vault",formatVersion:ne.formatVersion||1,exportedAt:ne.exportedAt||new Date().toISOString(),count:je.length,cards:je},null,2)}else ge="memory-vault.md",Me="text/markdown;charset=utf-8",q=ne.cards.map(je=>je.text.trim()).filter(Boolean).join(`
|
|
302
302
|
|
|
303
303
|
---
|
|
304
304
|
|
package/lib/db.js
CHANGED
|
@@ -10,6 +10,75 @@ import { parseCard, KIND_ROOTS } from './vault.js'
|
|
|
10
10
|
|
|
11
11
|
const DB_FILE = 'memory-eternal.db'
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* 每个库的「打开时健康检查」结果(issue #33)。
|
|
15
|
+
*
|
|
16
|
+
* 为什么需要:老实现打开后只执行 `PRAGMA journal_mode=WAL` 就往下走 —— 而这条 PRAGMA
|
|
17
|
+
* **失败时不抛错**(只是把实际模式当一行返回),文件/目录不可写时也不会当场抱怨;于是插件
|
|
18
|
+
* 会以「写不进去」的状态一路静默运行:探针仍报健康、看门狗仍在保活、面板只是统计空掉,
|
|
19
|
+
* 用户看到的是一句没有上下文的 `attempt to write a readonly database`(有时还伴随看门狗误判)。
|
|
20
|
+
* 现在打开时**读回真实 journal 模式 + 真写一次探针**,把结论记在这里,由 /overview 与
|
|
21
|
+
* `dsh-memory doctor` 如实报出来。
|
|
22
|
+
*/
|
|
23
|
+
const healthByRoot = new Map()
|
|
24
|
+
|
|
25
|
+
const CORRUPT_RE = /malformed|not a database|corrupt|disk image/i
|
|
26
|
+
|
|
27
|
+
/** 真的写一次主库(建探针表→删掉):验证「这个库现在能不能写」。temp 库不算,必须是主库。 */
|
|
28
|
+
function probeWrite(db) {
|
|
29
|
+
try {
|
|
30
|
+
db.exec('CREATE TABLE IF NOT EXISTS _me_write_probe(x INTEGER)')
|
|
31
|
+
db.exec('DROP TABLE IF EXISTS _me_write_probe')
|
|
32
|
+
return { writable: true, error: '' }
|
|
33
|
+
} catch (e) {
|
|
34
|
+
return { writable: false, error: String((e && e.message) || e) }
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 打开后的健康检查:真实的 journal 模式 + 写探针(+ 损坏判定)。
|
|
40
|
+
* @returns {{journalMode:string, writable:boolean, error:string, corrupt:boolean, checkedAt:number}}
|
|
41
|
+
*/
|
|
42
|
+
export function checkDbHealth(db) {
|
|
43
|
+
const out = { journalMode: '', writable: false, error: '', corrupt: false, checkedAt: Date.now() }
|
|
44
|
+
try {
|
|
45
|
+
out.journalMode = String(db.prepare('PRAGMA journal_mode').get().journal_mode || '')
|
|
46
|
+
} catch (e) {
|
|
47
|
+
out.error = String((e && e.message) || e)
|
|
48
|
+
}
|
|
49
|
+
const w = probeWrite(db)
|
|
50
|
+
out.writable = w.writable
|
|
51
|
+
if (!w.writable) out.error = out.error || w.error
|
|
52
|
+
out.corrupt = CORRUPT_RE.test(out.error || '')
|
|
53
|
+
return out
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 某个库最近一次打开时的健康结论(没打开过返回 null)。 */
|
|
57
|
+
export function dbStatus(root) {
|
|
58
|
+
return healthByRoot.get(path.resolve(root)) || null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** 损坏 / 不可写时给人看的下一步(引导,不自动动数据)。 */
|
|
62
|
+
export function rebuildGuidance(root, status) {
|
|
63
|
+
const dir = path.resolve(root)
|
|
64
|
+
const lines = []
|
|
65
|
+
if (status && status.corrupt) {
|
|
66
|
+
lines.push('数据库文件疑似损坏:' + status.error)
|
|
67
|
+
lines.push('修复方式(不会自动删你的数据):')
|
|
68
|
+
lines.push('1) 先停下会写它的进程(退出 DSH / `dsh-memory stop --port 7999`),免得边恢复边写;')
|
|
69
|
+
lines.push('2) 备份现场:把 ' + path.join(dir, DB_FILE) + '(连同 -wal / -shm)复制到别处留证;')
|
|
70
|
+
lines.push('3) 查看可用备份:`dsh-memory doctor --vault "' + dir + '"`;')
|
|
71
|
+
lines.push('4) 用备份替换(备份文件在 ' + path.join(dir, 'backups') + '):把选中的 .db 复制成 ' + DB_FILE + ',并删掉同名 -wal / -shm;')
|
|
72
|
+
lines.push('5) 重开 DSH,然后用 `dsh-memory doctor` 复核 writable=true。')
|
|
73
|
+
} else if (status && !status.writable) {
|
|
74
|
+
lines.push('数据库当前**不可写**:' + status.error)
|
|
75
|
+
lines.push('配置仍可保存(配置不经过数据库),但卡片读写/审核/图谱会失败。')
|
|
76
|
+
lines.push('排查方向:目录与文件的 ACL(' + dir + ')、盘符是否只读挂载、是否有别的进程以独占方式打开它。')
|
|
77
|
+
lines.push('确认方式:`dsh-memory doctor --vault "' + dir + '"`。')
|
|
78
|
+
}
|
|
79
|
+
return lines
|
|
80
|
+
}
|
|
81
|
+
|
|
13
82
|
/** 获取或创建 SQLite 数据库连接(单例 per root)。 */
|
|
14
83
|
const instances = new Map()
|
|
15
84
|
export function getDb(root) {
|
|
@@ -22,6 +91,12 @@ export function getDb(root) {
|
|
|
22
91
|
db.exec('PRAGMA journal_mode=WAL')
|
|
23
92
|
db.exec('PRAGMA foreign_keys=ON')
|
|
24
93
|
initTables(db)
|
|
94
|
+
// 打开即体检:WAL 到底生效没有、这个库现在能不能写(#33)。结论供 /overview 与 doctor 使用。
|
|
95
|
+
const health = checkDbHealth(db)
|
|
96
|
+
healthByRoot.set(resolved, health)
|
|
97
|
+
if (!health.writable || (health.journalMode && health.journalMode !== 'wal')) {
|
|
98
|
+
for (const line of rebuildGuidance(resolved, health)) console.error('[memory-eternal] ' + line)
|
|
99
|
+
}
|
|
25
100
|
instances.set(resolved, db)
|
|
26
101
|
return db
|
|
27
102
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "memory-eternal",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.21",
|
|
4
4
|
"description": "记忆核心(Memory Eternal):自研的 DeepSeek Harness 记忆插件,不移植任何既有记忆框架——对话结束后自动沉淀知识卡到本地 Markdown Vault(自研去重、自研 CJK 检索、可 git 管理),设置页提供图形化知识库(统计 / 搜索 / 知识图谱 + 侧边栏一键弹窗),Agent 通过 memory_recall 工具按需召回历史上下文。零人工干预。",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
"prepublishOnly": "node build.mjs",
|
|
65
65
|
"release": "node scripts/release.mjs",
|
|
66
66
|
"release:dry": "node scripts/release.mjs --dry-run",
|
|
67
|
-
"test": "node build.mjs && node tests/vault.test.mjs && node tests/capture.test.mjs && node tests/capture-route.test.mjs && node tests/stall.test.mjs && node tests/vault-path.test.mjs && node tests/vault-resolve.test.mjs && node tests/minhash.test.mjs && node tests/settings-save.test.mjs && node tests/config-coverage.test.mjs && node tests/config-sync.test.mjs && node tests/config-merge.test.mjs && node tests/sse.test.mjs && node tests/api.test.mjs && node tests/import-roundtrip.test.mjs && node tests/graph-perf.test.mjs && node tests/settings-compat.test.mjs && node tests/node-bin.test.mjs && node tests/watchdog-lock.test.mjs && node tests/markdown.test.mjs && node tests/feedback.test.mjs && node tests/graph-lod.test.mjs && node tests/ssr.test.mjs && node tests/web-assets.test.mjs && node tests/manifests.test.mjs && node tests/two-store.test.mjs && node tests/noise-gate.test.mjs && node tests/e2e-recall.test.mjs && node tests/issue-fixes.test.mjs && node tests/issue-fixes-2.test.mjs && node tests/issue-fixes-3.test.mjs && node tests/watchdog-orphans.test.mjs && node tests/watchdog-restart.test.mjs && node tests/readme-i18n.test.mjs && node tests/issue-fixes-4.test.mjs && node tests/host-drain.test.mjs && node tests/e2e-host-drain.mjs && node tests/issue-fixes-5.test.mjs && node tests/issue-fixes-6.test.mjs && node tests/capture-json-repair.test.mjs && node tests/csrf-origin.test.mjs && node tests/watchdog-orphan-web.test.mjs && node tests/config-validate.test.mjs && node tests/issue-fixes-8.test.mjs"
|
|
67
|
+
"test": "node build.mjs && node tests/vault.test.mjs && node tests/capture.test.mjs && node tests/capture-route.test.mjs && node tests/stall.test.mjs && node tests/vault-path.test.mjs && node tests/vault-resolve.test.mjs && node tests/minhash.test.mjs && node tests/settings-save.test.mjs && node tests/config-coverage.test.mjs && node tests/config-sync.test.mjs && node tests/config-merge.test.mjs && node tests/sse.test.mjs && node tests/api.test.mjs && node tests/import-roundtrip.test.mjs && node tests/graph-perf.test.mjs && node tests/settings-compat.test.mjs && node tests/node-bin.test.mjs && node tests/watchdog-lock.test.mjs && node tests/markdown.test.mjs && node tests/feedback.test.mjs && node tests/graph-lod.test.mjs && node tests/ssr.test.mjs && node tests/web-assets.test.mjs && node tests/manifests.test.mjs && node tests/two-store.test.mjs && node tests/noise-gate.test.mjs && node tests/e2e-recall.test.mjs && node tests/issue-fixes.test.mjs && node tests/issue-fixes-2.test.mjs && node tests/issue-fixes-3.test.mjs && node tests/watchdog-orphans.test.mjs && node tests/watchdog-restart.test.mjs && node tests/readme-i18n.test.mjs && node tests/issue-fixes-4.test.mjs && node tests/host-drain.test.mjs && node tests/e2e-host-drain.mjs && node tests/issue-fixes-5.test.mjs && node tests/issue-fixes-6.test.mjs && node tests/capture-json-repair.test.mjs && node tests/csrf-origin.test.mjs && node tests/watchdog-orphan-web.test.mjs && node tests/config-validate.test.mjs && node tests/issue-fixes-8.test.mjs && node tests/issue-fixes-9.test.mjs"
|
|
68
68
|
},
|
|
69
69
|
"dependencies": {
|
|
70
70
|
"@deepseek-ai/schemastery": "^3.18.1"
|
package/web/app.js
CHANGED
|
@@ -300,7 +300,7 @@ Option A, because:
|
|
|
300
300
|
height: calc(100vh - var(--dsh-windows-titlebar-height, 0px));
|
|
301
301
|
max-height: calc(100vh - var(--dsh-windows-titlebar-height, 0px));
|
|
302
302
|
}
|
|
303
|
-
`;function Vg(){return(0,i.jsxs)("svg",{viewBox:"0 0 24 24","aria-hidden":"true",children:[(0,i.jsx)("defs",{children:(0,i.jsxs)("linearGradient",{id:"mg-brain",x1:"0",y1:"0",x2:"1",y2:"1",children:[(0,i.jsx)("stop",{offset:"0%",stopColor:"#FFC46B"}),(0,i.jsx)("stop",{offset:"50%",stopColor:"#F97316"}),(0,i.jsx)("stop",{offset:"100%",stopColor:"#EA580C"})]})}),(0,i.jsx)("path",{fill:"url(#mg-brain)",d:"M12 4.5a3.2 3.2 0 0 0-6.4.1 4.1 4.1 0 0 0-2.7 5.9 4.1 4.1 0 0 0 .6 6.7A4.2 4.2 0 0 0 12 18.2Z"}),(0,i.jsx)("path",{fill:"url(#mg-brain)",d:"M12 4.5a3.2 3.2 0 0 1 6.4.1 4.1 4.1 0 0 1 2.7 5.9 4.1 4.1 0 0 1-.6 6.7A4.2 4.2 0 0 1 12 18.2Z"}),(0,i.jsx)("path",{d:"M12 9.2c-.8-1.4-1.7-2-3.1-2.2M12 9.2c.8-1.4 1.7-2 3.1-2.2M12 12.4c-1.2 1-3.2 1.6-5 1.4M12 12.4c1.2 1 3.2 1.6 5 1.4M12 13.2v4",fill:"none",stroke:"rgba(255,255,255,0.62)",strokeWidth:"1",strokeLinecap:"round"}),(0,i.jsx)("circle",{cx:"12",cy:"8.6",r:"1.1",fill:"rgba(255,255,255,0.85)"})]})}var Ko="0.10.
|
|
303
|
+
`;function Vg(){return(0,i.jsxs)("svg",{viewBox:"0 0 24 24","aria-hidden":"true",children:[(0,i.jsx)("defs",{children:(0,i.jsxs)("linearGradient",{id:"mg-brain",x1:"0",y1:"0",x2:"1",y2:"1",children:[(0,i.jsx)("stop",{offset:"0%",stopColor:"#FFC46B"}),(0,i.jsx)("stop",{offset:"50%",stopColor:"#F97316"}),(0,i.jsx)("stop",{offset:"100%",stopColor:"#EA580C"})]})}),(0,i.jsx)("path",{fill:"url(#mg-brain)",d:"M12 4.5a3.2 3.2 0 0 0-6.4.1 4.1 4.1 0 0 0-2.7 5.9 4.1 4.1 0 0 0 .6 6.7A4.2 4.2 0 0 0 12 18.2Z"}),(0,i.jsx)("path",{fill:"url(#mg-brain)",d:"M12 4.5a3.2 3.2 0 0 1 6.4.1 4.1 4.1 0 0 1 2.7 5.9 4.1 4.1 0 0 1-.6 6.7A4.2 4.2 0 0 1 12 18.2Z"}),(0,i.jsx)("path",{d:"M12 9.2c-.8-1.4-1.7-2-3.1-2.2M12 9.2c.8-1.4 1.7-2 3.1-2.2M12 12.4c-1.2 1-3.2 1.6-5 1.4M12 12.4c1.2 1 3.2 1.6 5 1.4M12 13.2v4",fill:"none",stroke:"rgba(255,255,255,0.62)",strokeWidth:"1",strokeLinecap:"round"}),(0,i.jsx)("circle",{cx:"12",cy:"8.6",r:"1.1",fill:"rgba(255,255,255,0.85)"})]})}var Ko="0.10.21";function ef({t:e,inModal:t,onClose:n,onFull:r,full:o}){let[l,a]=(0,v.useState)(null),[s,u]=(0,v.useState)([]),[p,y]=(0,v.useState)(!0),[h,m]=(0,v.useState)(null),[k,T]=(0,v.useState)(""),[w,F]=(0,v.useState)("all"),[d,c]=(0,v.useState)(""),[g,N]=(0,v.useState)("all"),[P,B]=(0,v.useState)("all"),[D,A]=(0,v.useState)(()=>{try{let E=new URLSearchParams(typeof location<"u"?location.search:"").get("tab"),I={cards:"cards",graph:"graph",usage:"stats",stats:"stats",audit:"audit",recycle:"optimize",optimize:"optimize",config:"config"};if(E&&I[E])return I[E]}catch{}return"cards"}),[X,O]=(0,v.useState)(()=>new Set([D])),L=(0,v.useCallback)(E=>{O(I=>I.has(E)?I:new Set(I).add(E)),A(E)},[]),[Q,_]=(0,v.useState)("recent"),[se,Z]=(0,v.useState)(null),re=(0,v.useRef)(null),pe=(0,v.useRef)(null),[M,q]=(0,v.useState)(!1),[me,te]=(0,v.useState)(null),[ue,Re]=(0,v.useState)(!1),[ht,nt]=(0,v.useState)(!1),[Ae,yt]=(0,v.useState)(!0),[vt,rt]=(0,v.useState)(null),[Ye,Un]=(0,v.useState)(0),Vn=(0,v.useCallback)(()=>Un(E=>E+1),[]),Bt=l?.quarantine?.pending??0,Ht=l?.quarantine?.deleted??0,ye=(0,v.useRef)(null),[ut,$e]=(0,v.useState)(0),[S,x]=(0,v.useState)(!1),V=(0,v.useRef)(null),fe=(0,v.useRef)(null),ve=(0,v.useCallback)(async(E=w,I=d,$=P,ce={})=>{let J=ce.append===!0;try{J?x(!0):(y(!0),T(""));let Se=new URLSearchParams;E&&E!=="all"&&Se.set("kind",E),I.trim()&&Se.set("q",I.trim()),Se.set("status",$||"all"),Se.set("limit",String(Wg)),Se.set("offset",String(ce.offset||0)),Q==="title"&&Se.set("sort","title");let ot=await(await fetch(`${ee}/cards?${Se.toString()}`)).json();if(ot.ok){let Wt=ot.cards||[];u(zt=>J?[...zt,...Wt.filter($t=>!zt.some(Xo=>Xo.path===$t.path))]:Wt),$e(Number(ot.total)||Wt.length)}}catch(Se){T(String(Se&&Se.message?Se.message:Se))}finally{J?x(!1):y(!1)}},[w,d,P,Q]),we=(0,v.useCallback)(async()=>{try{let[E,I]=await Promise.all([fetch(`${ee}/overview`).then($=>$.json()),fetch(`${ee}/capture-log`).then($=>$.json()).catch(()=>null)]);E.ok&&a(E),m(I&&I.ok&&I.available&&I.health||null)}catch(E){T(String(E&&E.message?E.message:E))}finally{y(!1)}},[]),f=async E=>{try{let $=await(await fetch(`${ee}/delete?path=${encodeURIComponent(E)}`)).json();$.ok?(Z({ok:!0,msg:e("deleted")}),await we(),Vn()):Z({ok:!1,msg:$.error||e("deleteFail")})}catch{Z({ok:!1,msg:e("deleteFail")})}re.current&&clearTimeout(re.current),re.current=setTimeout(()=>Z(null),2e3)};(0,v.useEffect)(()=>(we(),()=>{ye.current&&clearTimeout(ye.current)}),[we]);let ze=E=>{c(E),ye.current&&clearTimeout(ye.current),ye.current=setTimeout(()=>ve(w,E,P),280)},cr=E=>{F(E),ve(E,d,P)};(0,v.useEffect)(()=>{D==="cards"&&ve(w,d,P)},[P,D,Ye,Q]),(0,v.useEffect)(()=>{if(D!=="cards"||p||S||s.length>=ut)return;let E=V.current,I=fe.current;if(!E)return;let $=new IntersectionObserver(ce=>{ce[0]&&ce[0].isIntersecting&&ve(w,d,P,{append:!0,offset:s.length})},{root:I||null,rootMargin:"400px"});return $.observe(E),()=>$.disconnect()},[D,s,ut,p,S,w,d,P,ve]);let Xe=async E=>{try{let I=E.status;if(!I&&E.path)try{let Ge=((await fetch(`${ee}/cards?status=all&q=${encodeURIComponent(E.title||"")}&limit=50`).then(ot=>ot.json())).cards||[]).find(ot=>ot.path===E.path);Ge&&(I=Ge.status)}catch{}let $=I&&I!=="approved"?`&status=${encodeURIComponent(I)}`:"",ce=await fetch(`${ee}/card?path=${encodeURIComponent(E.path||E.id)}${$}`),J=await ce.json();J.ok?te({path:J.path,title:E.title,text:J.text}):T(J.error||`HTTP ${ce.status}`)}catch(I){T(String(I&&I.message?I.message:I))}},b=async(E,I)=>{q(!0);try{let $=await fetch(`${ee}/export`);if(!$.ok)throw new Error("HTTP "+$.status);let ce=await $.json();if(!ce.ok)throw new Error(ce.error||"export failed");let J,Se,Ge;if(E==="json"){Se="memory-vault.json",Ge="application/json";let zt=ce.cards.map($t=>({path:$t.path,title:$t.title,kind:$t.kind,status:$t.status,store:$t.store,text:$t.text}));J=JSON.stringify({format:ce.format||"memory-eternal-vault",formatVersion:ce.formatVersion||1,exportedAt:ce.exportedAt||new Date().toISOString(),count:zt.length,cards:zt},null,2)}else Se="memory-vault.md",Ge="text/markdown;charset=utf-8",J=ce.cards.map(zt=>zt.text.trim()).filter(Boolean).join(`
|
|
304
304
|
|
|
305
305
|
---
|
|
306
306
|
|