memory-eternal 0.10.19 → 0.10.20
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/index.js +3 -1
- package/lib/client.js +1 -1
- 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.20",
|
|
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.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** |
|
|
364
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** |
|
|
365
|
-
| **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** |
|
|
366
366
|
|
|
367
367
|
<details>
|
|
368
|
-
<summary>Older release notes (v0.10.
|
|
368
|
+
<summary>Older release notes (v0.10.18 and earlier · 45 releases, click to expand)</summary>
|
|
369
369
|
|
|
370
370
|
| Version | Date | Highlights |
|
|
371
371
|
|---|---|---|
|
|
372
|
+
| **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
373
|
| **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
374
|
| **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
375
|
| **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,15 @@ curl http://127.0.0.1:7999/memory-eternal/api/web-info # 独立 web 是否活
|
|
|
393
393
|
|
|
394
394
|
| 版本 | 日期 | 关键改动 |
|
|
395
395
|
|---|---|---|
|
|
396
|
+
| **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 项** |
|
|
396
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 项** |
|
|
397
|
-
| **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 项** |
|
|
398
398
|
|
|
399
399
|
<details>
|
|
400
|
-
<summary>更早的更新日志(v0.10.
|
|
400
|
+
<summary>更早的更新日志(v0.10.18 及以前 · 共 45 版,点开查看)</summary>
|
|
401
401
|
|
|
402
402
|
| 版本 | 日期 | 关键改动 |
|
|
403
403
|
|---|---|---|
|
|
404
|
+
| **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
405
|
| **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
406
|
| **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
407
|
| **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/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/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.20";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/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.20",
|
|
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"
|
|
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.20";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
|
|