memory-eternal 0.10.19 → 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 +5 -3
- package/README.md +5 -3
- package/bin/dsh-memory.mjs +33 -0
- package/index.js +3 -1
- package/lib/api.js +20 -4
- package/lib/client.js +1 -1
- package/lib/db.js +75 -0
- package/lib/stall.js +6 -1
- package/lib/watchdog.js +96 -13
- 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,16 @@ 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.
|
|
365
|
-
| **v0.10.
|
|
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** |
|
|
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** |
|
|
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** |
|
|
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** |
|
|
372
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** |
|
|
373
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** |
|
|
374
376
|
| **v0.10.15** | 2026-10-09 | **Three loose ends left by the upgrades.** ① **Recommended plan A no longer reinstalls the call we just saved**: v0.10.12 raised the default distiller output cap to 4000 (removing the wasted "hit 2000, then re-run the same candidate at 4000"), but the panel one-click preset "A (light)" still hard-coded `captureMaxTokens: 2000` — one click brought the retry back; it now matches the default, guarded by a source check (`tests/csrf-origin.test.mjs` asserts planA cap === default). ② **The README "stall watchdog" description now matches the new rule** (both languages): the old wording said "if no turn-stopping event arrives for 15 minutes", while it is actually "**zero stops since this process started** plus turns still starting inside the last 20 minutes → record a `warn`", and it now states that an in-flight **long turn is never mistaken for a stall**. ③ While at it, the other log wording `轮次收尾事件未触发(agent/turn-stopping 没到)` was verified to have last fired on **2026-09-24** — a historical remnant of the old heuristic with no such code path left today (nothing to fix). `npm test` now runs **306 checks** |
|
package/README.md
CHANGED
|
@@ -393,14 +393,16 @@ curl http://127.0.0.1:7999/memory-eternal/api/web-info # 独立 web 是否活
|
|
|
393
393
|
|
|
394
394
|
| 版本 | 日期 | 关键改动 |
|
|
395
395
|
|---|---|---|
|
|
396
|
-
| **v0.10.
|
|
397
|
-
| **v0.10.
|
|
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 项** |
|
|
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 项** |
|
|
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 项** |
|
|
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 项** |
|
|
404
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 项** |
|
|
405
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 项** |
|
|
406
408
|
| **v0.10.15** | 2026-10-09 | **三处「升级留下的尾巴」**。① **推荐方案 A 不再把刚省掉的调用装回来**:v0.10.12 把「蒸馏输出上限」默认提到 4000(省掉「先撞 2000、再以 4000 重跑同一候选」那次白烧),但面板里「一键推荐配置 A(轻量)」仍写死 `captureMaxTokens: 2000` —— 用户点一下就又把重试装回来;现已与默认对齐,并加源码守卫(`tests/csrf-origin.test.mjs` 断言 planA 的上限 === 默认值)。② **README 的「停滞兜底」描述与新判据对齐**(中英两份):旧文案写「15 分钟收不到收尾事件就报」,现在是「**本次运行至今 0 收尾** + 最近 20 分钟内持续有轮次开始 → 记一条 `warn`」,并写明正在进行的**长回合不会被误判**。③ 顺带核实日志里另一种措辞 `轮次收尾事件未触发(agent/turn-stopping 没到)` 最后出现于 **2026-09-24**,属于旧判据的历史残留,现行代码里已无这条路径(无需修复)。`npm test` 共 **306 项** |
|
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/index.js
CHANGED
|
@@ -971,8 +971,10 @@ export function apply(ctx, config) {
|
|
|
971
971
|
|
|
972
972
|
// 沉淀停滞探测:只有「有轮次在跑」且「管线 15 分钟一个活口都没有」才报(真死才报)。
|
|
973
973
|
// 计数器对不上是常态(子代理轮次、被取消的轮次都不发 turn-stopping),单看计数会误报刷屏。
|
|
974
|
+
// 本进程开始观察的时刻:用于 stall 判据的「最短运行时长」保护(刚重启必然 0 收尾,不该报警)
|
|
975
|
+
const stallBaseline = Date.now()
|
|
974
976
|
const stallTimer = setInterval(() => {
|
|
975
|
-
const { alert, started, stopped, windowMs } = evaluateStall({ claimedAt, stoppedAt, everStopped: turnsStopped })
|
|
977
|
+
const { alert, started, stopped, windowMs } = evaluateStall({ claimedAt, stoppedAt, everStopped: turnsStopped, processStartedAt: stallBaseline })
|
|
976
978
|
if (alert && !stallAlerted) {
|
|
977
979
|
stallAlerted = true
|
|
978
980
|
// 用 warn 不用 fail:这是「本次运行至今一次收尾都没收到」的**推断**,不该由它把沉淀健康状态
|
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/lib/stall.js
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* @param {number} [input.everStopped] 本次运行**至今**收到的收尾事件数(缺省 = stoppedAt.length)
|
|
21
21
|
* @returns {{alert:boolean, started:number, stopped:number, windowMs:number, reason:string}}
|
|
22
22
|
*/
|
|
23
|
-
export function evaluateStall({ claimedAt = [], stoppedAt = [], now = Date.now(), windowMs = 20 * 60 * 1000, minStarted = 3, everStopped } = {}) {
|
|
23
|
+
export function evaluateStall({ claimedAt = [], stoppedAt = [], now = Date.now(), windowMs = 20 * 60 * 1000, minStarted = 3, everStopped, processStartedAt, minUptimeMs = 30 * 60 * 1000 } = {}) {
|
|
24
24
|
const from = now - windowMs
|
|
25
25
|
const started = claimedAt.filter((t) => t >= from).length
|
|
26
26
|
const stopped = stoppedAt.filter((t) => t >= from).length
|
|
@@ -29,6 +29,11 @@ export function evaluateStall({ claimedAt = [], stoppedAt = [], now = Date.now()
|
|
|
29
29
|
// (实测:一轮几十分钟的对话里 agent/inbox/claimed 会多次触发,而 turn-stopping 要等回合真的
|
|
30
30
|
// 结束才来 —— 20 分钟窗口必然 3 次 claim / 0 收尾,于是长回合被误报成「收尾事件疑似失效」,
|
|
31
31
|
// 并且把「自动沉淀异常」这个红警报推给了用户)。真的被改名时,本次运行会是 0 收尾。
|
|
32
|
+
// 刚落进程也必然「0 收尾」—— 若不做最短运行时长保护,宿主重启后只要窗口内再有几次 claim
|
|
33
|
+
// 就会误报「事件被改名」(实测:重启 1.5 分钟后,窗口内 4 次 claim / 0 收尾 → 报)。
|
|
34
|
+
if (Number.isFinite(processStartedAt) && now - Number(processStartedAt) < Math.max(0, Number(minUptimeMs) || 0)) {
|
|
35
|
+
return { alert: false, started, stopped, windowMs, reason: 'too-early' }
|
|
36
|
+
}
|
|
32
37
|
const ever = Number.isFinite(everStopped) ? Number(everStopped) : stoppedAt.length
|
|
33
38
|
if (ever > 0) return { alert: false, started, stopped, windowMs, reason: 'wiring-proven' }
|
|
34
39
|
return { alert: started >= minStarted && stopped === 0, started, stopped, windowMs, reason: 'no-stop-ever' }
|
package/lib/watchdog.js
CHANGED
|
@@ -321,17 +321,41 @@ async function listWatchdogProcesses(exec, platform = process.platform, marker =
|
|
|
321
321
|
return parseWatchdogProcesses(out, 'linux', marker)
|
|
322
322
|
}
|
|
323
323
|
|
|
324
|
-
/**
|
|
324
|
+
/**
|
|
325
|
+
* 默认的进程执行器(spawn + 收 stdout;失败返回空串)。
|
|
326
|
+
*
|
|
327
|
+
* issue #31/#33 第③层实测:受限环境(DSH 沙箱)**不允许打开命名管道**,默认的 `stdio:'pipe'`
|
|
328
|
+
* 会在 `spawn()` 时**同步抛 EPERM** —— 老实现只在 `'error'` 事件里兜底,根本没机会执行,
|
|
329
|
+
* 于是 `dsh-memory watchdog --reap` 直接崩(`Error: spawn EPERM`),而 `listWatchdogProcesses()`
|
|
330
|
+
* 在该环境下永远返回空名单(孤儿回收 / findPortListener / looksLikeOurWeb 全部失去依据)。
|
|
331
|
+
* 两条对策:① `try/catch` 包住 spawn,失败就当「枚举不到」;② **用临时文件接 stdout**,完全不碰管道。
|
|
332
|
+
*/
|
|
325
333
|
function defaultExec() {
|
|
326
334
|
return async (cmd, args) => {
|
|
327
335
|
const { spawn } = await import('node:child_process')
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
336
|
+
let outFile = ''
|
|
337
|
+
try {
|
|
338
|
+
const os = await import('node:os')
|
|
339
|
+
const path = await import('node:path')
|
|
340
|
+
const fs = await import('node:fs')
|
|
341
|
+
outFile = path.join(os.tmpdir(), 'me-exec-' + process.pid + '-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8) + '.out')
|
|
342
|
+
const fd = fs.openSync(outFile, 'w')
|
|
343
|
+
let child
|
|
344
|
+
try {
|
|
345
|
+
child = spawn(cmd, args, { windowsHide: true, stdio: ['ignore', fd, 'ignore'] })
|
|
346
|
+
} finally {
|
|
347
|
+
try { fs.closeSync(fd) } catch { /* 子进程已继承则关父进程副本 */ }
|
|
348
|
+
}
|
|
349
|
+
await new Promise((resolve) => {
|
|
350
|
+
child.on('error', () => resolve(''))
|
|
351
|
+
child.on('close', () => resolve(''))
|
|
352
|
+
})
|
|
353
|
+
try { return fs.readFileSync(outFile, 'utf8') } catch { return '' }
|
|
354
|
+
} catch {
|
|
355
|
+
return '' // 受限环境:宁可「枚举不到」,也不能把调用方(如 --reap)崩掉
|
|
356
|
+
} finally {
|
|
357
|
+
if (outFile) { try { (await import('node:fs')).unlinkSync(outFile) } catch { /* 清理失败无妨 */ } }
|
|
358
|
+
}
|
|
335
359
|
}
|
|
336
360
|
}
|
|
337
361
|
|
|
@@ -440,12 +464,41 @@ export async function probe(port, timeoutMs = 1000) {
|
|
|
440
464
|
* @returns {Promise<string>} 取不到返回空串
|
|
441
465
|
*/
|
|
442
466
|
export async function probeServedVersion(port, timeoutMs = 1200) {
|
|
467
|
+
// issue #31/#33 第②层:`/overview` 会碰数据库,库只读/损坏时它 500 —— 于是「端口上明明活着、
|
|
468
|
+
// 版本却拿不到」:verify 报「(无响应)」、status 甚至误判成「v0.10.4 之前的旧版」,进而触发
|
|
469
|
+
// 一轮又一轮无效替换。`/version-check` **不碰库**(只读 package.json + 缓存 + 自探),先问它。
|
|
470
|
+
try {
|
|
471
|
+
const v = await probeViaVersionCheck(port, timeoutMs)
|
|
472
|
+
if (v) return v
|
|
473
|
+
} catch { /* 回落到 /overview */ }
|
|
443
474
|
try {
|
|
444
475
|
const info = await probe(port, timeoutMs)
|
|
445
476
|
return info && typeof info.version === 'string' ? info.version : ''
|
|
446
477
|
} catch { return '' }
|
|
447
478
|
}
|
|
448
479
|
|
|
480
|
+
/** 问 `/version-check` 要「端口上那个进程加载的版本」(不碰数据库;旧版没有该路由时返回空串)。 */
|
|
481
|
+
async function probeViaVersionCheck(port, timeoutMs = 1200) {
|
|
482
|
+
return new Promise((resolve) => {
|
|
483
|
+
const req = http.request({ host: '127.0.0.1', port, path: `${API_PREFIX}/version-check`, method: 'GET', timeout: timeoutMs }, (res) => {
|
|
484
|
+
if (res.statusCode !== 200) return resolve('')
|
|
485
|
+
const chunks = []
|
|
486
|
+
res.on('data', (c) => chunks.push(c))
|
|
487
|
+
res.on('end', () => {
|
|
488
|
+
try {
|
|
489
|
+
const data = JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
490
|
+
if (data && data.ok === true && typeof data.loaded === 'string' && data.loaded) return resolve(data.loaded)
|
|
491
|
+
resolve('')
|
|
492
|
+
} catch { resolve('') }
|
|
493
|
+
})
|
|
494
|
+
res.on('error', () => resolve(''))
|
|
495
|
+
})
|
|
496
|
+
req.on('error', () => resolve(''))
|
|
497
|
+
req.on('timeout', () => { req.destroy(); resolve('') })
|
|
498
|
+
req.end()
|
|
499
|
+
})
|
|
500
|
+
}
|
|
501
|
+
|
|
449
502
|
/**
|
|
450
503
|
* 轮询等到端口上的服务版本等于期望值(restart 后的自检,issue #23 建议 2)。
|
|
451
504
|
*
|
|
@@ -872,6 +925,23 @@ export async function probeTcp(port, timeoutMs = 600) {
|
|
|
872
925
|
})
|
|
873
926
|
}
|
|
874
927
|
|
|
928
|
+
/**
|
|
929
|
+
* 「这一 tick 该拿端口怎么办」的纯决策(便于单测)。
|
|
930
|
+
*
|
|
931
|
+
* 关键:**`/overview` 非 200 ≠ 端口死了**。`/overview` 会碰数据库,库只读/损坏(issue #31/#33)
|
|
932
|
+
* 时它返回 500,而老实现据此判「离线」→ 调 spawnWeb()(开头先 SIGTERM 掉自己的上一个 child)→
|
|
933
|
+
* 自杀自拉、端口从 7999 漂到 8006。所以只要 **TCP 还能连**,就视为存活(API 降级而已)。
|
|
934
|
+
*
|
|
935
|
+
* @returns {{action:'keep'|'respawn'|'stop'|'skip', reason:string}}
|
|
936
|
+
*/
|
|
937
|
+
export function decideTickAction({ overviewOk = false, tcpAlive = false, restartCount = 0, maxRestart = 10, autoStart = true } = {}) {
|
|
938
|
+
if (overviewOk) return { action: 'keep', reason: 'healthy' }
|
|
939
|
+
if (tcpAlive) return { action: 'keep', reason: 'api-degraded-but-listening' }
|
|
940
|
+
if (Number(restartCount) >= Number(maxRestart)) return { action: 'stop', reason: 'max-restart' }
|
|
941
|
+
if (!autoStart) return { action: 'skip', reason: 'no-restart' }
|
|
942
|
+
return { action: 'respawn', reason: 'dead' }
|
|
943
|
+
}
|
|
944
|
+
|
|
875
945
|
/**
|
|
876
946
|
* 启动看门狗。返回对象含 .stop() 优雅退出。
|
|
877
947
|
* @param {object} opts
|
|
@@ -960,22 +1030,29 @@ export function startWatchdog({ port = 7999, vaultRoot, interval = 5000, maxRest
|
|
|
960
1030
|
const tick = async () => {
|
|
961
1031
|
if (stopped) return
|
|
962
1032
|
const alive = await probe(port)
|
|
963
|
-
|
|
964
|
-
|
|
1033
|
+
// issue #31/#33 第①层:`/overview` 非 200 **不等于**端口死了。库只读/损坏时它返回 500,
|
|
1034
|
+
// 而老实现据此判定「离线」→ 走 spawnWeb()(开头先 SIGTERM 掉自己的上一个 child)→
|
|
1035
|
+
// **自杀自拉**、端口一路从 7999 漂到 8006(实测 6 个实例)。所以先补一次不碰库的 TCP 探测。
|
|
1036
|
+
const tcpAlive = alive ? false : await probeTcp(port)
|
|
1037
|
+
const decision = decideTickAction({ overviewOk: Boolean(alive), tcpAlive, restartCount, maxRestart, autoStart })
|
|
1038
|
+
if (decision.action === 'keep') {
|
|
965
1039
|
if (!ownedByUs) {
|
|
966
1040
|
log(`port ${port} 已存活(外部实例)→ 切换为保活模式(不接管)`)
|
|
967
1041
|
ownedByUs = true // 至少逻辑上"知道"它活了
|
|
968
1042
|
}
|
|
1043
|
+
if (decision.reason === 'api-degraded-but-listening') {
|
|
1044
|
+
log(`port ${port} 的 /overview 非 200(多为库不可用),但 TCP 可连 → 视为存活,不重拉`)
|
|
1045
|
+
restartCount = 0
|
|
1046
|
+
}
|
|
969
1047
|
reapExtraChildren()
|
|
970
1048
|
return
|
|
971
1049
|
}
|
|
972
|
-
|
|
973
|
-
if (restartCount >= maxRestart) {
|
|
1050
|
+
if (decision.action === 'stop') {
|
|
974
1051
|
log(`restart 次数达上限 ${maxRestart} → 退出看门狗(请人工排查)`)
|
|
975
1052
|
stop()
|
|
976
1053
|
return
|
|
977
1054
|
}
|
|
978
|
-
if (
|
|
1055
|
+
if (decision.action === 'skip') {
|
|
979
1056
|
log(`port ${port} 已死,但 --no-restart 设置,跳过`)
|
|
980
1057
|
return
|
|
981
1058
|
}
|
|
@@ -992,6 +1069,12 @@ export function startWatchdog({ port = 7999, vaultRoot, interval = 5000, maxRest
|
|
|
992
1069
|
if (newPort !== port) log(`提示:目标端口 ${port} 被占,watchdog 在 ${newPort} 上保活`)
|
|
993
1070
|
return
|
|
994
1071
|
}
|
|
1072
|
+
// 库不可用时新实例同样吐 500 —— 只要端口在监听就算起来了(否则下一个 tick 又会把它杀掉,成死循环)
|
|
1073
|
+
if (await probeTcp(newPort)) {
|
|
1074
|
+
log(`web 在 ${newPort} 已监听(/overview 非 200:多为库不可用)→ 视为就绪`)
|
|
1075
|
+
if (newPort !== port) log(`提示:目标端口 ${port} 被占,watchdog 在 ${newPort} 上保活`)
|
|
1076
|
+
return
|
|
1077
|
+
}
|
|
995
1078
|
}
|
|
996
1079
|
log(`web 未能在 15s 内就绪`)
|
|
997
1080
|
} catch (error) {
|
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"
|
|
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
|
|