memory-eternal 0.10.28 → 0.10.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/README.en.md +3 -2
- package/README.md +3 -2
- package/bin/dsh-memory.mjs +6 -0
- package/lib/client.js +1 -1
- package/lib/watchdog.js +88 -15
- package/package.json +1 -1
- 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.29",
|
|
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.29** | 2026-10-14 | **A failed takeover no longer wrecks the crime scene (key finding from the issue #30 retest)**. The reporter measured on 0.10.26 that after a failed `restart` the **lock file was deleted** while the same web kept listening — the scene then looked as if a resident had never existed, leaving only an orphan. Four fixes: ① **releasing the lock no longer deletes the file** (`releaseWatchdogLock` leaves an "ended" record with `endedAt`; `status` shows "exited (stale record)", and such slots **never block** a new instance, even with pid reuse); ② **a failed restart writes the pre-replacement registration back** (`restoreWatchdogLock`, only when no live registration exists for that port); ③ **failing to enumerate the occupant no longer counts as "port free"** — `clearResidentForTakeover` now **double-checks with TCP** (in restricted environments the process query returns nothing, and the old code concluded the port was free, so the new web could not bind, the self-check reported "(no response)" and the lock was wiped); if something is still listening the takeover aborts and **touches no registration at all**; ④ **refuse to take over when the lock file is unwritable** (an unregisterable instance would only become another orphan — it now errors out and points at running the watchdog in the foreground). The restart failure message also names **who is on the port now** and whether the lock was written back, and `status` now **labels the port probe and the lock registration as separate sections** (the reporter pointed out the old output mixed the two and misled readers). `npm test` now runs **348 checks** |
|
|
364
365
|
| **v0.10.28** | 2026-10-14 | **New "version alignment check": see at a glance what stays stale after a plugin upgrade**. Upgrading only replaces **files on disk**, and two kinds of "old" do not go away by themselves: ① **runtime** — the event subscriptions the DSH host registered in memory (`agent/turn-stopping`, `agent/inbox/claimed`, …) come from **the code it loaded at startup**, so without restarting DSH it keeps using the old interfaces (that "turn-stop event appears broken" alert is almost certainly this: measured here, host **v0.10.8** against disk v0.10.27 — and the alert text itself, with its cumulative "135/75", proves the running code predates 0.10.20); ② **persisted wiring** — the MCP and hook entries the plugin writes into `~/.claude.json`, `~/.claude/settings.json`, `~/.codex/config.toml` and `~/.cursor/mcp.json` carry **old install paths, old node paths and old arguments** that an upgrade never rewrites. Adds `lib/align.js`, `GET /align`, `dsh-memory align [--json] [--fix]` and a panel card (usage/today → 🔧 version alignment check) that lists each finding with an **actionable fix** (`fully quit and reopen DSH` / `dsh-memory restart --port N` / `dsh-memory setup` to rewrite the wiring / update the plugin); `--fix` rewrites only the plugin's own keys (your data and config are untouched). Adds `tests/align.test.mjs` (6 checks, including a wording guard for "same node but stale entry"). `npm test` now runs **345 checks** |
|
|
365
|
-
| **v0.10.27** | 2026-10-13 | **"Rebuild from backup" goes from guidance to something you can run (the practical way out for issue #31)**. Once a WAL header is corrupt, code cannot repair the **data** — the previous version could only tell you which backups exist and in what order to swap files; now `dsh-memory doctor --restore latest\|<name>` does it: **without `--yes` it is a dry run** (not a single file touched, and it tells you how many cards the backup holds and whether it would overwrite a writable database). Execution passes four gates: ① **validate the backup** (open read-only + `PRAGMA quick_check` + count cards); ② require an explicit `--force` when the current database is healthy **right now** (decided by a live probe, not a cached health verdict); ③ **abort** if a resident watchdog is still alive (no swapping while writers are active, unless `--force`); ④ **re-verify** afterwards (reopen + write probe + card count). The original scene is **always renamed to `*.corrupt-<timestamp>` and never deleted** (the DB plus `-wal` and `-shm`). Adds `tests/db-restore.test.mjs` (4 checks: rebuild from a broken DB / healthy DB needs force and a dry run touches nothing / dangerous sources refused / health verdict refreshed). `npm test` now runs **339 checks** |
|
|
366
366
|
|
|
367
367
|
<details>
|
|
368
|
-
<summary>Older release notes (v0.10.
|
|
368
|
+
<summary>Older release notes (v0.10.27 and earlier · 54 releases, click to expand)</summary>
|
|
369
369
|
|
|
370
370
|
| Version | Date | Highlights |
|
|
371
371
|
|---|---|---|
|
|
372
|
+
| **v0.10.27** | 2026-10-13 | **"Rebuild from backup" goes from guidance to something you can run (the practical way out for issue #31)**. Once a WAL header is corrupt, code cannot repair the **data** — the previous version could only tell you which backups exist and in what order to swap files; now `dsh-memory doctor --restore latest\|<name>` does it: **without `--yes` it is a dry run** (not a single file touched, and it tells you how many cards the backup holds and whether it would overwrite a writable database). Execution passes four gates: ① **validate the backup** (open read-only + `PRAGMA quick_check` + count cards); ② require an explicit `--force` when the current database is healthy **right now** (decided by a live probe, not a cached health verdict); ③ **abort** if a resident watchdog is still alive (no swapping while writers are active, unless `--force`); ④ **re-verify** afterwards (reopen + write probe + card count). The original scene is **always renamed to `*.corrupt-<timestamp>` and never deleted** (the DB plus `-wal` and `-shm`). Adds `tests/db-restore.test.mjs` (4 checks: rebuild from a broken DB / healthy DB needs force and a dry run touches nothing / dangerous sources refused / health verdict refreshed). `npm test` now runs **339 checks** |
|
|
372
373
|
| **v0.10.26** | 2026-10-13 | **Lock contention now retries asynchronously at the application level instead of using `busy_timeout` (the measured remedy for issues #31/#33)**. Local controlled experiment: while an external process held a write transaction we immediately got `database is locked`, and it **stayed locked even after that process was killed** — the old code had no waiting at all, so a single collision failed permanently (on restricted or externally-occupied drives like A: this shows up as "the whole memory database is broken"). The review rejected `PRAGMA busy_timeout` (`DatabaseSync` from `node:sqlite` is a **synchronous API**, so busy-waiting blocks the host event loop and freezes all of DSH), so we added `withDbRetry()` instead: on a lock, **yield the event loop, sleep briefly and retry** (linear backoff, up to 3 attempts), only on **write routes**; once headers have been sent retrying is explicitly forbidden (`e.noRetry`, preventing double writes), and non-lock errors (data/syntax) are tried once and rethrown unchanged. Tests cover: retry until success / one attempt for non-busy errors / `noRetry` honoured / exhaustion / a source guard against sneaking `busy_timeout` back in. `npm test` now runs **335 checks** |
|
|
373
374
|
| **v0.10.25** | 2026-10-13 | **"Cannot be determined" no longer masquerades as "unmanaged", plus a unified UX for an unwritable DB (closing layer ③ of issue #33)**. ① **Managed state becomes three-valued**: `/version-check` now also reports `resident.managedKnown`, `lock:{path,writable,error}` and `pid` — in restricted environments (sandbox write bans, ACLs, non-system drives) where the watchdog lock file cannot be written, the panel no longer says "unmanaged" (which made users think the instance was broken and restart it repeatedly) but "**management state unknown**: the lock file cannot be written, so it cannot be determined; keep-alive usually still works", together with the real cause and path; `writeWatchdogLock` failures are no longer fully silent either (one traceable line per process). ② **Unified UX when the DB is unwritable**: write routes (delete / merge / card / refine-apply / optimize-execute / …) now **fail fast at the entrance** (503 + `DB_UNAVAILABLE` + recovery guidance), and any write path not enumerated is caught by the outermost wrapper translating raw SQLite text into the same reply; the panel shows a **cross-tab banner** at the top: "the memory database is currently not writable — card reads/writes will fail, but **settings can still be saved**". ③ `dsh-memory doctor` now reports watchdog lock writability (visible at a glance in restricted environments). `npm test` now runs **334 checks** |
|
|
374
375
|
| **v0.10.24** | 2026-10-13 | **"Same goal, different approach" now clusters too (third and final slice of issue #36)**. The two previous versions covered "merge duplicates" and "judge conflicts", but the last piece — one purpose, several workflows/codebases — is **undetectable by body similarity** ("compress with 7z", "distribute as tar.gz", "slim the npm package" read very differently yet share a goal). Approach: **the model first writes one line per card stating what problem it solves, then we cluster by goal** — goals are cached in `refine-goals.json` (each card extracted once; a second scan makes zero calls) and clustered with union-find; hits are grouped as "one goal, N cards" with two actions: **keep all** (records pairwise rulings so it stops asking) or **merge this group** (via the existing /merge; the new card enters the audit queue). *Read-only throughout — the only write is the goal cache.* Entry points: the panel (organize → 🎯 same goal, different approach) and APIs `/refine-goals`, `/refine-goals-keep`; CLI `dsh-memory refine --goals --keep --path A --path B` (the clustering itself needs a model, run it in the panel). `npm test` now runs **332 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.29** | 2026-10-14 | **失败的接管不再破坏现场(issue #30 复测的关键发现)**。报告者在 0.10.26 上实测:`restart` 失败后**锁文件被删除**、端口上还是同一个 web —— 现场从此看起来像「从来没有过常驻实例」,只剩一个孤儿。四处修复:① **释放锁不再删文件**(`releaseWatchdogLock` 留一条带 `endedAt` 的「已结束」记录,`status` 会显示「已退出(陈旧记录)」;这种槽**永不阻挡**新实例,pid 复用也不怕);② **失败的 restart 把替换前的登记写回去**(`restoreWatchdogLock`,只在本端口没有活着的登记时才写);③ **枚举不到占用者时不再误判「端口已空」**——`clearResidentForTakeover` 用 **TCP 复核**(受限环境里进程查询被拒会返回空,旧实现据此认为端口空出 → 新 web 绑不上 → 自检「(无响应)」+ 锁被清空),还在监听就中止接管且**一个登记都不动**;④ **锁写不进去时前置拒绝接管**(写不进锁 → 新实例无法登记 → 只会留孤儿;直接报错并给前台观察的出路)。另外 `restart` 失败信息补上「**端口上现在是谁**」+「锁有没有写回」,`status` 把「端口探测」与「锁登记」**分段标注**(报告者指出旧输出两者并列会误导)。`npm test` 共 **348 项** |
|
|
396
397
|
| **v0.10.28** | 2026-10-14 | **新增「版本对齐检查」:把「插件迭代后旧接口 / 旧接线还在」一次查清**。插件升级只换**磁盘文件**,两类「旧」不会自己消失:① **运行时** —— DSH 宿主进程内存里注册的事件订阅(`agent/turn-stopping`、`agent/inbox/claimed`…)来自它**启动时加载的那份代码**,不重启 DSH 就会一直用旧接口(那条「轮次收尾事件疑似失效」多半就是它:本机实测宿主 **v0.10.8** / 磁盘 v0.10.27,而且该告警文案里的「累计 135/75」本身就证明跑的是 ≥0.10.20 之前的旧判据);② **持久接线** —— 插件写进 `~/.claude.json`、`~/.claude/settings.json`、`~/.codex/config.toml`、`~/.cursor/mcp.json` 的 MCP 与 hooks 条目含**旧安装路径 / 旧 node 路径 / 旧参数**,升级后不会自动改写。新增 `lib/align.js` + `GET /align` + `dsh-memory align [--json] [--fix]` + 面板「用量/今日 → 🔧 版本对齐检查」:逐条给结论与**可执行动作**(`完全退出并重开 DSH` / `dsh-memory restart --port N` / `dsh-memory setup` 重写接线 / 更新插件),`--fix` 只重写插件自己的键(不动数据与配置)。新增 `tests/align.test.mjs`(6 项,含「同一 node 但条目是旧写法」的措辞准确性守卫)。`npm test` 共 **345 项** |
|
|
397
|
-
| **v0.10.27** | 2026-10-13 | **「损坏时从备份重建」从引导升级成可执行(issue #31 的现实出路)**。WAL 头一旦损坏,代码修不了**数据** —— 上一版只能告诉你「有哪些备份、按什么顺序替换」,现在多了 `dsh-memory doctor --restore latest\|备份名`:**不带 `--yes` 只演练**(一个文件都不动,先告诉你备份里有几张卡、会不会覆盖可写库);执行时四道闸门:① **校验备份**(只读打开 + `PRAGMA quick_check` + 数卡片);② 当前库**此刻**完好时要求显式 `--force`(用**当场探测**判,不信缓存的体检结论);③ 检测到常驻看门狗还活着就**中止**(避免边写边换,除非 `--force`);④ 恢复后**复核**(重新打开 + 写探针 + 卡片数)。原现场**一律改名 `*.corrupt-<时间戳>` 留证,绝不删除**(库 + `-wal` + `-shm` 一起)。新增 `tests/db-restore.test.mjs`(4 项:坏库重建 / 完好库需 force 且演练不动任何文件 / 拒绝危险来源 / 体检结论刷新)。`npm test` 共 **339 项** |
|
|
398
398
|
|
|
399
399
|
<details>
|
|
400
|
-
<summary>更早的更新日志(v0.10.
|
|
400
|
+
<summary>更早的更新日志(v0.10.27 及以前 · 共 54 版,点开查看)</summary>
|
|
401
401
|
|
|
402
402
|
| 版本 | 日期 | 关键改动 |
|
|
403
403
|
|---|---|---|
|
|
404
|
+
| **v0.10.27** | 2026-10-13 | **「损坏时从备份重建」从引导升级成可执行(issue #31 的现实出路)**。WAL 头一旦损坏,代码修不了**数据** —— 上一版只能告诉你「有哪些备份、按什么顺序替换」,现在多了 `dsh-memory doctor --restore latest\|备份名`:**不带 `--yes` 只演练**(一个文件都不动,先告诉你备份里有几张卡、会不会覆盖可写库);执行时四道闸门:① **校验备份**(只读打开 + `PRAGMA quick_check` + 数卡片);② 当前库**此刻**完好时要求显式 `--force`(用**当场探测**判,不信缓存的体检结论);③ 检测到常驻看门狗还活着就**中止**(避免边写边换,除非 `--force`);④ 恢复后**复核**(重新打开 + 写探针 + 卡片数)。原现场**一律改名 `*.corrupt-<时间戳>` 留证,绝不删除**(库 + `-wal` + `-shm` 一起)。新增 `tests/db-restore.test.mjs`(4 项:坏库重建 / 完好库需 force 且演练不动任何文件 / 拒绝危险来源 / 体检结论刷新)。`npm test` 共 **339 项** |
|
|
404
405
|
| **v0.10.26** | 2026-10-13 | **撞锁改用「应用层异步重试」,而不是 `busy_timeout`(issue #31/#33 实测对症)**。本机对照实验:外部进程持写事务时我方立刻拿到 `database is locked`,而且**对方被强杀之后仍然锁着** —— 老实现没有忙等,一撞锁就永久失败(在 A: 盘那类受限 / 被外部占用的现场,表现出来就是「整个记忆库坏了」)。评审已否决 `PRAGMA busy_timeout`(`node:sqlite` 的 `DatabaseSync` 是**同步 API**,忙等会阻塞宿主事件循环、把整个 DSH 卡住),所以改成新增 `withDbRetry()`:撞锁时**让出事件循环睡一小会儿再试**(线性退避,最多 3 次),只用**写路由**;「响应已发出」时显式禁止重试(`e.noRetry`,避免重复写),非锁类错误(数据 / 语法)只试一次并原样抛。测试覆盖:重试到成功 / 非忙错误只试一次 / `noRetry` 生效 / 试满仍失败 / 源码守卫(不许偷偷加 `busy_timeout`)。`npm test` 共 **335 项** |
|
|
405
406
|
| **v0.10.25** | 2026-10-13 | **「判不出来」不再冒充「未托管」+ 库不可写时的统一 UX(issue #33 第③层收口)**。① **托管状态变三态**:`/version-check` 增报 `resident.managedKnown`、`lock:{path,writable,error}` 与 `pid` —— 受限环境(沙箱禁写 / ACL / 非系统盘)写不进看门狗锁文件时,面板不再显示「未托管」(那会让用户以为实例坏了、反复重启),改为「**托管状态未知**:锁文件写不进去 → 无法判断;保活通常仍然有效」,并同时显示真实原因与路径;`writeWatchdogLock` 的失败也不再全静默(每个进程留一条可查的痕)。② **库不可写时统一 UX**:写路由(delete / merge / card / refine-apply / optimize-execute / …)在入口**短路径失败**(503 + `DB_UNAVAILABLE` + 恢复指引),没枚举到的写路径由最外层兜底把 SQLite 原文翻译成同一套答复;面板**跨标签顶部**明示「记忆库当前不可写 —— 卡片读写会失败,但**配置仍可保存**」。③ `dsh-memory doctor` 增报看门狗锁可写性(受限环境一眼可见)。`npm test` 共 **334 项** |
|
|
406
407
|
| **v0.10.24** | 2026-10-13 | **「同目标、不同做法」也能归到一起了(issue #36 第三块,闭环)**。前两版解决了「冗余合并」与「矛盾判别」,但最后一块 —— 同一目的、不同工作流/代码 —— 按正文相似度**根本抓不到**(「用 7z 压缩」「用 tar.gz 分发」「npm 包瘦身」字面差很远、目标却一样)。做法:**先让模型给每张卡抽一句「这张卡解决什么问题」,再按目标聚类** —— 目标抽取结果缓存在 `refine-goals.json`(同一张卡只抽一次,第二次扫描 0 次调用),聚类用并查集;命中后按「同一目标 N 张卡」成组给出,动作二选一:**都保留**(记两两决策,下次不再提示)或**合并这一组**(走现有 /merge,新卡进审核队列)。*全程只读,唯一写入是目标缓存*。入口:面板「整理 → 🎯 同目标不同做法」+ API `/refine-goals`、`/refine-goals-keep`;CLI `dsh-memory refine --goals --keep --path A --path B`(聚类本身需要模型,在面板跑)。`npm test` 共 **332 项** |
|
package/bin/dsh-memory.mjs
CHANGED
|
@@ -153,11 +153,17 @@ async function main() {
|
|
|
153
153
|
if (has('--json')) { console.log(JSON.stringify({ ok: true, ...st, servedPort: probePort, servedVersion: served, portOccupant: occupant, portOccupantIsOurs: oursNoVersion }, null, 2)); return }
|
|
154
154
|
console.log(`锁文件:${st.lockPath}`)
|
|
155
155
|
console.log(`本机包版本:${st.pkgVersion || '?'}`)
|
|
156
|
+
// issue #30:报告者指出旧输出把「端口探测」与「锁登记」并列,读起来像「端口上是旧版」。
|
|
157
|
+
// 两者是**两个独立来源**,必须显式分段标注,否则会把人引向错误的排查方向。
|
|
158
|
+
console.log('')
|
|
159
|
+
console.log('— 端口探测(直接问端口上那个进程,最可靠)—')
|
|
156
160
|
if (probePort) {
|
|
157
161
|
if (served) console.log(`端口 ${probePort} 实际服务版本:${served}`)
|
|
158
162
|
else if (oursNoVersion) console.log(`端口 ${probePort} 上跑的是本插件的 web(pid ${occupant.pid}),但它不会自报版本 —— 说明是 v0.10.4 之前的旧版,升级尚未生效 → 执行 dsh-memory restart --port ${probePort}`)
|
|
159
163
|
else console.log(`端口 ${probePort} 上没有本插件的服务在响应${occupant && occupant.pid ? `(被 pid ${occupant.pid} 占用:${occupant.command || '命令行未知'})` : ''}`)
|
|
160
164
|
}
|
|
165
|
+
console.log('')
|
|
166
|
+
console.log('— 锁登记(看门狗自己写的记录,可能滞后/陈旧)—')
|
|
161
167
|
if (!st.watchdogs.length) { console.log('锁文件里没有登记的看门狗(没有常驻实例)。'); return }
|
|
162
168
|
for (const w of st.watchdogs) {
|
|
163
169
|
console.log(`- pid ${w.pid} · 端口 ${w.port} · ${w.alive ? '运行中' : '已退出(陈旧记录)'}${w.pkgVersion ? ' · v' + w.pkgVersion : ''}${w.startedAt ? ' · 启动于 ' + w.startedAt : ''}${w.vault ? ' · vault ' + w.vault : ''}`)
|
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(rt,{zh:Xt,en:Jt}),"memory-eternal: locale");let o=t.locale.bind(rt);t.effect(()=>t.slots.inject("settings.section",()=>t.slots.register({name:"settings.section",id:rt,order:25,label:()=>o("nav"),locale:rt,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:`${rt}:footer`,order:100,label:()=>o("nav"),locale:rt,inject:()=>({})},l=>n.default.createElement(Ma,{t:o,locale:t.locale,wide:!(l&&l.wide===!1)}))),"memory-eternal: sidebar footer action")}function Ca(){let[t,o]=(0,n.useState)(O+"/ui/app");return(0,n.useEffect)(()=>{let l=!0,f=()=>fetch("/memory-eternal/api/web-info").then(c=>c.json()).then(c=>{l&&c&&c.ok&&c.alive!==!1&&c.url&&o(c.url.endsWith("/")?c.url:c.url+"/")}).catch(()=>{});return fetch(O+"/ui/app",{method:"HEAD"}).then(c=>{if(l&&!c.ok)return f()}).catch(()=>l?f():void 0),()=>{l=!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:Oe}),(0,e.jsx)(ta,{t,onReload:()=>{},version:0})]})}function Ma({t,locale:o,wide:l}){let[f,c]=(0,n.useState)(!1);return(0,e.jsxs)("div",{className:`me-footer${l?"":" rail"}`,children:[(0,e.jsx)("style",{children:Oe}),(0,e.jsxs)("button",{type:"button",className:"me-footer-btn",onClick:()=>c(!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")})]}),f&&(0,e.jsx)(Ta,{t,locale:o,onClose:()=>c(!1)})]})}function Ta({t,locale:o,onClose:l,tab:f}){let[c,i]=(0,n.useState)(!1),w=(0,n.useRef)(null),D=Ca(),k=(0,n.useCallback)(T=>o&&typeof o.subscribe=="function"?o.subscribe(T):()=>{},[o]),H=(0,n.useCallback)(()=>o&&typeof o.getSnapshot=="function"?o.getSnapshot():null,[o]),S=(0,n.useSyncExternalStore)(k,H),h=S&&(S.active==="zh"||S.active==="en")?S.active:null,g=(0,n.useRef)(null);g.current===null&&h&&(g.current=h);let I=(0,n.useMemo)(()=>{let T=g.current?`lang=${g.current}`:"";if(f==="config")return`${O}/ui/config?tab=config${T?`&${T}`:""}`;let v=D.replace(/\/$/,""),B=v.endsWith("/ui/app")?"?":"/?";return f?`${v}${B}tab=${f}${T?`&${T}`:""}`:T?`${v}${B}${T}`:D},[D,f]);return(0,n.useEffect)(()=>{let T=w.current&&w.current.contentWindow;T&&h&&T.postMessage({source:"memory-eternal",type:"locale",lang:h},"*")},[h]),(0,n.useEffect)(()=>{let T=v=>{v.key==="Escape"&&l()};return window.addEventListener("keydown",T),()=>window.removeEventListener("keydown",T)},[l]),(0,e.jsxs)("div",{className:"me-overlay-top",onClick:l,children:[(0,e.jsx)("style",{children:Oe}),(0,e.jsx)("div",{className:"me-modal"+(c?" me-modal-full":""),onClick:T=>T.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(T=>!T),"aria-label":t(c?"exitFullscreen":"enterFullscreen"),title:t(c?"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:c?"\u2921":"\u2922"}),(0,e.jsx)("button",{type:"button",onClick:l,"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:w,src:I,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(rt,{zh:Xt,en:Jt}),"memory-eternal: locale");let o=t.locale.bind(rt);t.effect(()=>t.slots.inject("settings.section",()=>t.slots.register({name:"settings.section",id:rt,order:25,label:()=>o("nav"),locale:rt,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:`${rt}:footer`,order:100,label:()=>o("nav"),locale:rt,inject:()=>({})},l=>n.default.createElement(Ma,{t:o,locale:t.locale,wide:!(l&&l.wide===!1)}))),"memory-eternal: sidebar footer action")}function Ca(){let[t,o]=(0,n.useState)(O+"/ui/app");return(0,n.useEffect)(()=>{let l=!0,f=()=>fetch("/memory-eternal/api/web-info").then(c=>c.json()).then(c=>{l&&c&&c.ok&&c.alive!==!1&&c.url&&o(c.url.endsWith("/")?c.url:c.url+"/")}).catch(()=>{});return fetch(O+"/ui/app",{method:"HEAD"}).then(c=>{if(l&&!c.ok)return f()}).catch(()=>l?f():void 0),()=>{l=!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:Oe}),(0,e.jsx)(ta,{t,onReload:()=>{},version:0})]})}function Ma({t,locale:o,wide:l}){let[f,c]=(0,n.useState)(!1);return(0,e.jsxs)("div",{className:`me-footer${l?"":" rail"}`,children:[(0,e.jsx)("style",{children:Oe}),(0,e.jsxs)("button",{type:"button",className:"me-footer-btn",onClick:()=>c(!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")})]}),f&&(0,e.jsx)(Ta,{t,locale:o,onClose:()=>c(!1)})]})}function Ta({t,locale:o,onClose:l,tab:f}){let[c,i]=(0,n.useState)(!1),w=(0,n.useRef)(null),D=Ca(),k=(0,n.useCallback)(T=>o&&typeof o.subscribe=="function"?o.subscribe(T):()=>{},[o]),H=(0,n.useCallback)(()=>o&&typeof o.getSnapshot=="function"?o.getSnapshot():null,[o]),S=(0,n.useSyncExternalStore)(k,H),h=S&&(S.active==="zh"||S.active==="en")?S.active:null,g=(0,n.useRef)(null);g.current===null&&h&&(g.current=h);let I=(0,n.useMemo)(()=>{let T=g.current?`lang=${g.current}`:"";if(f==="config")return`${O}/ui/config?tab=config${T?`&${T}`:""}`;let v=D.replace(/\/$/,""),B=v.endsWith("/ui/app")?"?":"/?";return f?`${v}${B}tab=${f}${T?`&${T}`:""}`:T?`${v}${B}${T}`:D},[D,f]);return(0,n.useEffect)(()=>{let T=w.current&&w.current.contentWindow;T&&h&&T.postMessage({source:"memory-eternal",type:"locale",lang:h},"*")},[h]),(0,n.useEffect)(()=>{let T=v=>{v.key==="Escape"&&l()};return window.addEventListener("keydown",T),()=>window.removeEventListener("keydown",T)},[l]),(0,e.jsxs)("div",{className:"me-overlay-top",onClick:l,children:[(0,e.jsx)("style",{children:Oe}),(0,e.jsx)("div",{className:"me-modal"+(c?" me-modal-full":""),onClick:T=>T.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(T=>!T),"aria-label":t(c?"exitFullscreen":"enterFullscreen"),title:t(c?"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:c?"\u2921":"\u2922"}),(0,e.jsx)("button",{type:"button",onClick:l,"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:w,src:I,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.29";function Ra({t,inModal:o,onClose:l,onFull:f,full:c}){let[i,w]=(0,n.useState)(null),[D,k]=(0,n.useState)([]),[H,S]=(0,n.useState)(!0),[h,g]=(0,n.useState)(null),[I,T]=(0,n.useState)(""),[v,B]=(0,n.useState)("all"),[A,K]=(0,n.useState)(""),[te,Y]=(0,n.useState)("all"),[j,Ae]=(0,n.useState)("all"),[X,be]=(0,n.useState)(()=>{try{let u=new URLSearchParams(typeof location<"u"?location.search:"").get("tab"),z={cards:"cards",graph:"graph",usage:"stats",stats:"stats",audit:"audit",recycle:"optimize",optimize:"optimize",config:"config"};if(u&&z[u])return z[u]}catch{}return"cards"}),[Z,U]=(0,n.useState)(()=>new Set([X])),ie=(0,n.useCallback)(u=>{U(z=>z.has(u)?z:new Set(z).add(u)),be(u)},[]),[ve,C]=(0,n.useState)("recent"),[ke,Q]=(0,n.useState)(null),pe=(0,n.useRef)(null),Pe=(0,n.useRef)(null),[y,W]=(0,n.useState)(!1),[re,J]=(0,n.useState)(null),[le,ye]=(0,n.useState)(!1),[Be,Ne]=(0,n.useState)(!1),[Me,De]=(0,n.useState)(!0),[s,x]=(0,n.useState)(null),[R,G]=(0,n.useState)(0),Se=(0,n.useCallback)(()=>G(u=>u+1),[]),Ce=i?.quarantine?.pending??0,de=i?.quarantine?.deleted??0,ee=(0,n.useRef)(null),[We,Re]=(0,n.useState)(0),[p,r]=(0,n.useState)(!1),$=(0,n.useRef)(null),se=(0,n.useRef)(null),ce=(0,n.useCallback)(async(u=v,z=A,M=j,q={})=>{let _=q.append===!0;try{_?r(!0):(S(!0),T(""));let me=new URLSearchParams;u&&u!=="all"&&me.set("kind",u),z.trim()&&me.set("q",z.trim()),me.set("status",M||"all"),me.set("limit",String(va)),me.set("offset",String(q.offset||0)),ve==="title"&&me.set("sort","title");let je=await(await fetch(`${O}/cards?${me.toString()}`)).json();if(je.ok){let Ye=je.cards||[];k($e=>_?[...$e,...Ye.filter(Ge=>!$e.some(vt=>vt.path===Ge.path))]:Ye),Re(Number(je.total)||Ye.length)}}catch(me){T(String(me&&me.message?me.message:me))}finally{_?r(!1):S(!1)}},[v,A,j,ve]),ge=(0,n.useCallback)(async()=>{try{let[u,z]=await Promise.all([fetch(`${O}/overview`).then(M=>M.json()),fetch(`${O}/capture-log`).then(M=>M.json()).catch(()=>null)]);u.ok&&w(u),g(z&&z.ok&&z.available&&z.health||null)}catch(u){T(String(u&&u.message?u.message:u))}finally{S(!1)}},[]),a=async u=>{try{let M=await(await fetch(`${O}/delete?path=${encodeURIComponent(u)}`)).json();M.ok?(Q({ok:!0,msg:t("deleted")}),await ge(),Se()):Q({ok:!1,msg:M.error||t("deleteFail")})}catch{Q({ok:!1,msg:t("deleteFail")})}pe.current&&clearTimeout(pe.current),pe.current=setTimeout(()=>Q(null),2e3)};(0,n.useEffect)(()=>(ge(),()=>{ee.current&&clearTimeout(ee.current)}),[ge]);let tt=u=>{K(u),ee.current&&clearTimeout(ee.current),ee.current=setTimeout(()=>ce(v,u,j),280)},we=u=>{B(u),ce(u,A,j)};(0,n.useEffect)(()=>{X==="cards"&&ce(v,A,j)},[j,X,R,ve]),(0,n.useEffect)(()=>{if(X!=="cards"||H||p||D.length>=We)return;let u=$.current,z=se.current;if(!u)return;let M=new IntersectionObserver(q=>{q[0]&&q[0].isIntersecting&&ce(v,A,j,{append:!0,offset:D.length})},{root:z||null,rootMargin:"400px"});return M.observe(u),()=>M.disconnect()},[X,D,We,H,p,v,A,j,ce]);let nt=async u=>{try{let z=u.status;if(!z&&u.path)try{let ze=((await fetch(`${O}/cards?status=all&q=${encodeURIComponent(u.title||"")}&limit=50`).then(je=>je.json())).cards||[]).find(je=>je.path===u.path);ze&&(z=ze.status)}catch{}let M=z&&z!=="approved"?`&status=${encodeURIComponent(z)}`:"",q=await fetch(`${O}/card?path=${encodeURIComponent(u.path||u.id)}${M}`),_=await q.json();_.ok?J({path:_.path,title:u.title,text:_.text}):T(_.error||`HTTP ${q.status}`)}catch(z){T(String(z&&z.message?z.message:z))}},Le=async(u,z)=>{W(!0);try{let M=await fetch(`${O}/export`);if(!M.ok)throw new Error("HTTP "+M.status);let q=await M.json();if(!q.ok)throw new Error(q.error||"export failed");let _,me,ze;if(u==="json"){me="memory-vault.json",ze="application/json";let $e=q.cards.map(Ge=>({path:Ge.path,title:Ge.title,kind:Ge.kind,status:Ge.status,store:Ge.store,text:Ge.text}));_=JSON.stringify({format:q.format||"memory-eternal-vault",formatVersion:q.formatVersion||1,exportedAt:q.exportedAt||new Date().toISOString(),count:$e.length,cards:$e},null,2)}else me="memory-vault.md",ze="text/markdown;charset=utf-8",_=q.cards.map($e=>$e.text.trim()).filter(Boolean).join(`
|
|
302
302
|
|
|
303
303
|
---
|
|
304
304
|
|
package/lib/watchdog.js
CHANGED
|
@@ -112,14 +112,16 @@ export function acquireWatchdogLock({ env = process.env, port, vault = '', pid =
|
|
|
112
112
|
const file = watchdogLockPath(env)
|
|
113
113
|
const cur = readWatchdogLock(env)
|
|
114
114
|
const samePort = cur.watchdogs.filter((w) => Number(w.port) === Number(port) && w.pid !== pid)
|
|
115
|
-
|
|
115
|
+
// endedAt 的槽是「已结束」的记录(releaseWatchdogLock 留下的痕迹,见 #30):**绝不能挡住新实例**
|
|
116
|
+
// —— pid 可能已被复用、那个进程还活着,但那条记录明确表示它已经交出这把锁。
|
|
117
|
+
const alive = samePort.find((w) => !w.endedAt && isPidAlive(w.pid))
|
|
116
118
|
if (alive) return { acquired: false, file, previous: alive, reaped: [] }
|
|
117
119
|
// 清掉死掉的槽(含其它端口的陈旧记录),保留仍然存活的其它端口
|
|
118
120
|
const reaped = []
|
|
119
121
|
const kept = []
|
|
120
122
|
for (const w of cur.watchdogs) {
|
|
121
123
|
if (Number(w.port) === Number(port)) { reaped.push(w.pid); continue }
|
|
122
|
-
if (isPidAlive(w.pid)) kept.push(w)
|
|
124
|
+
if (!w.endedAt && isPidAlive(w.pid)) kept.push(w)
|
|
123
125
|
else reaped.push(w.pid)
|
|
124
126
|
}
|
|
125
127
|
const previous = samePort[0] || (reaped.length ? { pid: reaped[0] } : null)
|
|
@@ -128,13 +130,36 @@ export function acquireWatchdogLock({ env = process.env, port, vault = '', pid =
|
|
|
128
130
|
return { acquired: true, file, previous, reaped }
|
|
129
131
|
}
|
|
130
132
|
|
|
131
|
-
/**
|
|
132
|
-
|
|
133
|
+
/**
|
|
134
|
+
* 释放锁(只清自己那一槽)。
|
|
135
|
+
*
|
|
136
|
+
* issue #30 实测:旧实现在「没有其它槽」时**直接删文件** —— 于是一次失败的重启/意外退出之后,
|
|
137
|
+
* 现场看起来像「从来没有过常驻实例」,而端口上那个 web 还在跑(孤儿)。现在**保留文件**并留一个
|
|
138
|
+
* `endedAt` 标记的槽:status 会显示「已退出(陈旧记录)」,下次 acquire 再正常回收它。
|
|
139
|
+
*/
|
|
140
|
+
export function releaseWatchdogLock({ env = process.env, pid = process.pid, keepEnded = true } = {}) {
|
|
133
141
|
const cur = readWatchdogLock(env)
|
|
134
142
|
const kept = cur.watchdogs.filter((w) => w.pid !== pid)
|
|
135
143
|
if (kept.length === cur.watchdogs.length) return false
|
|
136
|
-
|
|
137
|
-
writeWatchdogLock(env, kept)
|
|
144
|
+
const ended = cur.watchdogs.filter((w) => w.pid === pid).map((w) => ({ ...w, endedAt: new Date().toISOString() }))
|
|
145
|
+
writeWatchdogLock(env, kept.length ? kept : (keepEnded ? ended.slice(-1) : []))
|
|
146
|
+
return true
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* 把「替换前的锁登记」写回去(issue #30:失败的 restart 不能把登记抹掉)。
|
|
151
|
+
* 只补本端口、且当前没有**活着的**同端口登记时才写,避免覆盖新实例的登记。
|
|
152
|
+
* @returns {boolean} 是否真的写回
|
|
153
|
+
*/
|
|
154
|
+
export function restoreWatchdogLock({ env = process.env, port, watchdogs = [] } = {}) {
|
|
155
|
+
const p = Number(port) || 0
|
|
156
|
+
const want = (Array.isArray(watchdogs) ? watchdogs : []).filter((w) => Number(w.port) === p)
|
|
157
|
+
if (!want.length) return false
|
|
158
|
+
const cur = readWatchdogLock(env)
|
|
159
|
+
const liveSame = cur.watchdogs.filter((w) => Number(w.port) === p && isPidAlive(w.pid))
|
|
160
|
+
if (liveSame.length) return false
|
|
161
|
+
const others = cur.watchdogs.filter((w) => Number(w.port) !== p && isPidAlive(w.pid))
|
|
162
|
+
writeWatchdogLock(env, [...others, ...want.map((w) => ({ ...w, restoredAt: new Date().toISOString() }))])
|
|
138
163
|
return true
|
|
139
164
|
}
|
|
140
165
|
|
|
@@ -225,7 +250,8 @@ export async function stopWatchdogs({ env = process.env, port, timeoutMs = 5000,
|
|
|
225
250
|
const failed = []
|
|
226
251
|
const skipped = []
|
|
227
252
|
for (const w of slots) {
|
|
228
|
-
|
|
253
|
+
// 显式清理路径:陈旧槽直接清掉(不留 endedAt 尾巴),与「看门狗自己退出」区分开
|
|
254
|
+
if (!alive(w.pid)) { skipped.push(Number(w.pid)); releaseWatchdogLock({ env, pid: w.pid, keepEnded: false }); continue }
|
|
229
255
|
if (!killFn(w.pid)) { failed.push(Number(w.pid)); continue }
|
|
230
256
|
stopped.push(Number(w.pid))
|
|
231
257
|
}
|
|
@@ -251,7 +277,7 @@ export async function stopWatchdogs({ env = process.env, port, timeoutMs = 5000,
|
|
|
251
277
|
|
|
252
278
|
for (const pid of [...stopped, ...failed, ...webStopped]) {
|
|
253
279
|
if (alive(pid)) { if (pid !== 0) failed.push(pid); continue }
|
|
254
|
-
releaseWatchdogLock({ env, pid })
|
|
280
|
+
releaseWatchdogLock({ env, pid, keepEnded: false })
|
|
255
281
|
}
|
|
256
282
|
|
|
257
283
|
// 端口兜底(issue #23):锁里登记的 webPid 可能压根没写、或者写了却枚举不到,
|
|
@@ -718,6 +744,23 @@ export async function restartResident({
|
|
|
718
744
|
const before = readWatchdogLock(env).watchdogs.filter((w) => Number(w.port) === p)
|
|
719
745
|
// 替换**前**就存在的 pid(看门狗 + 它拉起的 web):自检时用来区分「还是那个旧实例」与「新实例接管了」
|
|
720
746
|
const beforePids = new Set(before.flatMap((w) => [Number(w.pid), Number(w.webPid) || 0]).filter((n) => n > 0))
|
|
747
|
+
// 替换前的登记快照:失败时写回去(issue #30 实测:失败的 restart 会把锁文件删掉,
|
|
748
|
+
// 现场从此看起来像「从来没有过常驻实例」,而端口上那个 web 还在跑)。
|
|
749
|
+
const lockSnapshot = before.slice()
|
|
750
|
+
// issue #30(受限环境现场):锁文件写不进去时,接管**无法登记**新实例 —— 继续做只会留下
|
|
751
|
+
// 「有 web 在跑、却没有任何登记」的孤儿(机器上实测:锁目录 EPERM,重启后现场什么都没变、
|
|
752
|
+
// 用户却以为已经替换过)。这里前置拒绝,并说清两种出路。
|
|
753
|
+
if (!deps.skipLockPreflight) {
|
|
754
|
+
const probeLock = deps.probeLockWritable || probeLockWritable
|
|
755
|
+
let lockOk = true
|
|
756
|
+
try { lockOk = Boolean(probeLock(env).writable) } catch { lockOk = true }
|
|
757
|
+
if (!lockOk) {
|
|
758
|
+
return {
|
|
759
|
+
ok: false, stage: 'lock-unwritable', expect, served: '', waitedMs: 0, helperPid: 0, before, lockRestored: false, occupant: null,
|
|
760
|
+
error: '看门狗锁文件写不进去(' + (() => { try { return probeLock(env).path || '' } catch { return '' } })() + ')→ 接管无法登记,已拒绝执行(避免留下"有 web 在跑、却没登记"的孤儿)。请先修复该目录权限,或前台观察:dsh-memory watchdog --port ' + p,
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
}
|
|
721
764
|
let helperPid = 0
|
|
722
765
|
try {
|
|
723
766
|
const child = (deps.spawnHelper || spawnReplaceHelper)({ port: p, vaultRoot, interval, maxRestart })
|
|
@@ -735,6 +778,21 @@ export async function restartResident({
|
|
|
735
778
|
readLock: deps.readLock,
|
|
736
779
|
})
|
|
737
780
|
} catch { check = { ok: false, version: '', waitedMs: 0 } }
|
|
781
|
+
// 失败善后:把替换前的登记写回去 + 报告端口上现在是谁(免得用户面对「锁没了、web 还在」的孤儿现场)
|
|
782
|
+
let lockRestored = false
|
|
783
|
+
let occupant = null
|
|
784
|
+
if (!check.ok) {
|
|
785
|
+
try { lockRestored = restoreWatchdogLock({ env, port: p, watchdogs: lockSnapshot }) } catch { lockRestored = false }
|
|
786
|
+
try { occupant = await (deps.findListener || findPortListener)(p) } catch { occupant = null }
|
|
787
|
+
}
|
|
788
|
+
const who = occupant && occupant.pid ? occupant.pid + '(' + (occupant.command || '命令行未知') + ')' : ''
|
|
789
|
+
const occupantText = who ? ';端口上现在是 pid ' + who : ''
|
|
790
|
+
const lockText = (!check.ok && lockSnapshot.length)
|
|
791
|
+
? (lockRestored ? ';已把替换前的锁登记写回(不再出现「锁没了、web 还在」)' : ';锁登记未能写回(受限环境禁止写锁文件)')
|
|
792
|
+
: ''
|
|
793
|
+
const failMsg = (check.version === expect && !check.tookOver)
|
|
794
|
+
? '端口 ' + p + ' 服务着 v' + check.version + ',但没有新看门狗接管它(端口上可能仍是替换前的旧实例或孤儿)'
|
|
795
|
+
: '端口 ' + p + ' 上服务的版本是 ' + (check.version || '(无响应)') + ',本机是 ' + (expect || '?')
|
|
738
796
|
return {
|
|
739
797
|
ok: Boolean(check.ok),
|
|
740
798
|
stage: check.ok ? 'done' : 'verify',
|
|
@@ -743,11 +801,11 @@ export async function restartResident({
|
|
|
743
801
|
waitedMs: Number(check.waitedMs) || 0,
|
|
744
802
|
helperPid,
|
|
745
803
|
before,
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
804
|
+
lockRestored,
|
|
805
|
+
occupant,
|
|
806
|
+
// 失败原因:版本不对(旧实例还在服务)vs 没人接管(端口服务着期望版本却没有新看门狗);
|
|
807
|
+
// 并补上「端口上现在是谁」与「锁有没有写回」—— #30 现场最缺的就是这两句。
|
|
808
|
+
error: check.ok ? undefined : (failMsg + occupantText + lockText),
|
|
751
809
|
tookOver: Boolean(check.tookOver),
|
|
752
810
|
}
|
|
753
811
|
}
|
|
@@ -792,8 +850,23 @@ export async function clearResidentForTakeover({ port = 7999, env = process.env,
|
|
|
792
850
|
await wait(200)
|
|
793
851
|
try { left = await findListener(p) } catch { left = null }
|
|
794
852
|
}
|
|
795
|
-
|
|
796
|
-
|
|
853
|
+
if (left && left.pid) {
|
|
854
|
+
out.portFree = false
|
|
855
|
+
out.warnings.push(`端口 ${p} 仍被 pid ${left.pid} 占用(${left.command || '命令行未知'})`)
|
|
856
|
+
} else {
|
|
857
|
+
// 枚举不到 ≠ 端口是空的(issue #30 现场):受限环境/被拒的进程查询会让 findListener 返回 null,
|
|
858
|
+
// 旧实现据此认为"端口已空出" → 新 web 绑不上 7999 → 退让或失败 → 自检「(无响应)」+ 锁被清空。
|
|
859
|
+
// 这里用**不依赖枚举**的 TCP 探测复核:还在监听就坚决不算空。
|
|
860
|
+
const probe = deps.probeTcp || probeTcp
|
|
861
|
+
let listening = false
|
|
862
|
+
try { listening = Boolean(await probe(p)) } catch { listening = false }
|
|
863
|
+
if (listening) {
|
|
864
|
+
out.portFree = false
|
|
865
|
+
out.warnings.push(`端口 ${p} 仍在监听,但占用者无法识别(进程枚举被拒/不可用)—— 已中止接管,且未改动任何锁登记`)
|
|
866
|
+
} else {
|
|
867
|
+
out.portFree = true
|
|
868
|
+
}
|
|
869
|
+
}
|
|
797
870
|
return out
|
|
798
871
|
}
|
|
799
872
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "memory-eternal",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.29",
|
|
4
4
|
"description": "记忆核心(Memory Eternal):自研的 DeepSeek Harness 记忆插件,不移植任何既有记忆框架——对话结束后自动沉淀知识卡到本地 Markdown Vault(自研去重、自研 CJK 检索、可 git 管理),设置页提供图形化知识库(统计 / 搜索 / 知识图谱 + 侧边栏一键弹窗),Agent 通过 memory_recall 工具按需召回历史上下文。零人工干预。",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
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.29";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,h]=(0,v.useState)(!0),[y,m]=(0,v.useState)(null),[S,T]=(0,v.useState)(""),[k,j]=(0,v.useState)("all"),[d,c]=(0,v.useState)(""),[g,b]=(0,v.useState)("all"),[z,W]=(0,v.useState)("all"),[P,O]=(0,v.useState)(()=>{try{let E=new URLSearchParams(typeof location<"u"?location.search:"").get("tab"),U={cards:"cards",graph:"graph",usage:"stats",stats:"stats",audit:"audit",recycle:"optimize",optimize:"optimize",config:"config"};if(E&&U[E])return U[E]}catch{}return"cards"}),[K,I]=(0,v.useState)(()=>new Set([P])),ne=(0,v.useCallback)(E=>{I(U=>U.has(E)?U:new Set(U).add(E)),O(E)},[]),[Se,F]=(0,v.useState)("recent"),[Pe,le]=(0,v.useState)(null),he=(0,v.useRef)(null),mt=(0,v.useRef)(null),[L,X]=(0,v.useState)(!1),[fe,oe]=(0,v.useState)(null),[de,Ee]=(0,v.useState)(!1),[st,He]=(0,v.useState)(!1),[We,Ye]=(0,v.useState)(!0),[x,_]=(0,v.useState)(null),[H,re]=(0,v.useState)(0),Fe=(0,v.useCallback)(()=>re(E=>E+1),[]),Ae=l?.quarantine?.pending??0,ge=l?.quarantine?.deleted??0,ie=(0,v.useRef)(null),[Tt,Ve]=(0,v.useState)(0),[N,w]=(0,v.useState)(!1),q=(0,v.useRef)(null),pe=(0,v.useRef)(null),ye=(0,v.useCallback)(async(E=k,U=d,B=z,ee={})=>{let Z=ee.append===!0;try{Z?w(!0):(h(!0),T(""));let we=new URLSearchParams;E&&E!=="all"&&we.set("kind",E),U.trim()&&we.set("q",U.trim()),we.set("status",B||"all"),we.set("limit",String(Wg)),we.set("offset",String(ee.offset||0)),Se==="title"&&we.set("sort","title");let ut=await(await fetch(`${J}/cards?${we.toString()}`)).json();if(ut.ok){let Ut=ut.cards||[];u(Rt=>Z?[...Rt,...Ut.filter($t=>!Rt.some(Go=>Go.path===$t.path))]:Ut),Ve(Number(ut.total)||Ut.length)}}catch(we){T(String(we&&we.message?we.message:we))}finally{Z?w(!1):h(!1)}},[k,d,z,Se]),ke=(0,v.useCallback)(async()=>{try{let[E,U]=await Promise.all([fetch(`${J}/overview`).then(B=>B.json()),fetch(`${J}/capture-log`).then(B=>B.json()).catch(()=>null)]);E.ok&&a(E),m(U&&U.ok&&U.available&&U.health||null)}catch(E){T(String(E&&E.message?E.message:E))}finally{h(!1)}},[]),f=async E=>{try{let B=await(await fetch(`${J}/delete?path=${encodeURIComponent(E)}`)).json();B.ok?(le({ok:!0,msg:e("deleted")}),await ke(),Fe()):le({ok:!1,msg:B.error||e("deleteFail")})}catch{le({ok:!1,msg:e("deleteFail")})}he.current&&clearTimeout(he.current),he.current=setTimeout(()=>le(null),2e3)};(0,v.useEffect)(()=>(ke(),()=>{ie.current&&clearTimeout(ie.current)}),[ke]);let Sn=E=>{c(E),ie.current&&clearTimeout(ie.current),ie.current=setTimeout(()=>ye(k,E,z),280)},De=E=>{j(E),ye(E,d,z)};(0,v.useEffect)(()=>{P==="cards"&&ye(k,d,z)},[z,P,H,Se]),(0,v.useEffect)(()=>{if(P!=="cards"||p||N||s.length>=Tt)return;let E=q.current,U=pe.current;if(!E)return;let B=new IntersectionObserver(ee=>{ee[0]&&ee[0].isIntersecting&&ye(k,d,z,{append:!0,offset:s.length})},{root:U||null,rootMargin:"400px"});return B.observe(E),()=>B.disconnect()},[P,s,Tt,p,N,k,d,z,ye]);let Kn=async E=>{try{let U=E.status;if(!U&&E.path)try{let Ge=((await fetch(`${J}/cards?status=all&q=${encodeURIComponent(E.title||"")}&limit=50`).then(ut=>ut.json())).cards||[]).find(ut=>ut.path===E.path);Ge&&(U=Ge.status)}catch{}let B=U&&U!=="approved"?`&status=${encodeURIComponent(U)}`:"",ee=await fetch(`${J}/card?path=${encodeURIComponent(E.path||E.id)}${B}`),Z=await ee.json();Z.ok?oe({path:Z.path,title:E.title,text:Z.text}):T(Z.error||`HTTP ${ee.status}`)}catch(U){T(String(U&&U.message?U.message:U))}},nt=async(E,U)=>{X(!0);try{let B=await fetch(`${J}/export`);if(!B.ok)throw new Error("HTTP "+B.status);let ee=await B.json();if(!ee.ok)throw new Error(ee.error||"export failed");let Z,we,Ge;if(E==="json"){we="memory-vault.json",Ge="application/json";let Rt=ee.cards.map($t=>({path:$t.path,title:$t.title,kind:$t.kind,status:$t.status,store:$t.store,text:$t.text}));Z=JSON.stringify({format:ee.format||"memory-eternal-vault",formatVersion:ee.formatVersion||1,exportedAt:ee.exportedAt||new Date().toISOString(),count:Rt.length,cards:Rt},null,2)}else we="memory-vault.md",Ge="text/markdown;charset=utf-8",Z=ee.cards.map(Rt=>Rt.text.trim()).filter(Boolean).join(`
|
|
304
304
|
|
|
305
305
|
---
|
|
306
306
|
|