memory-eternal 0.10.5 → 0.10.6
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 +1 -0
- package/README.md +1 -0
- package/index.js +22 -1
- package/lib/client.js +1 -1
- package/lib/watchdog.js +89 -0
- package/package.json +2 -2
- package/web/app.js +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://anthropic.com/claude-code/plugin.schema.json",
|
|
3
3
|
"name": "memory-eternal",
|
|
4
|
-
"version": "0.10.
|
|
4
|
+
"version": "0.10.6",
|
|
5
5
|
"description": "记忆核心(Memory Eternal):对话结束后自动沉淀知识卡到本地记忆库(~/.dsh/memory-vault),支持检索 / 召回 / 审核中心。",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "EternalNight996",
|
package/README.en.md
CHANGED
|
@@ -327,6 +327,7 @@ Diagnostics:
|
|
|
327
327
|
|
|
328
328
|
| Version | Date | Highlights |
|
|
329
329
|
|---|---|---|
|
|
330
|
+
| **v0.10.6** | 2026-10-07 | **Orphan webs are now reclaimed + a failed shared-config sync is no longer silent.** ① **Orphan webs**: the web child is spawned detached, so when a watchdog is hard-killed (on Windows `process.kill(pid,'SIGTERM')` terminates unconditionally) its exit handler never runs; if that web had **fallen back to port+1…** because the target port was taken, the lock entry naming it disappears together with the watchdog — leaving an unowned web parked on 8001/8002 (two were found on this machine, one of them 20 hours old, and `status` only probes the configured port so it could never see them). Now `startWatchdog` on startup, and `stop`/`restart` (the `checkPort` path), sweep the **fallback window** `[port+1, port+9]` and reap them; three gates must all pass (the port is inside the window / the command line **really is this plugin's web** / the pid is not any watchdog's `pid` or `webPid` in the lock), so **someone else's `node web.js` is never killed**. In addition `spawnWeb` no longer spawns a duplicate when the target port already holds our own web (the main source of fallback instances) — liveness is left to the tick probe. ② **`syncConfigFile()` no longer swallows errors**: it writes the read source shared by the standalone page / hooks / MCP, so a failed write shows up as "config changed but they can't see it" — the old code was `catch { /* silent */ }` (observed once: the shared file's mtime stayed at pre-activation while the host's own value had changed). Failures now reach stderr and the capture log as a `warn` (same reason reported once, no spam); because `logCapture` is defined later inside `apply()`, the log line is deliberately deferred by one tick to avoid the `const` TDZ. New `tests/watchdog-orphans.test.mjs` (7 checks); `npm test` now runs **245 checks** |
|
|
330
331
|
| **v0.10.5** | 2026-10-07 | **Config saves from the standalone web page: no more empty promise, and no DSH install required.** ① **A host-independent write path (#21)**: if nothing consumes the pending file within 800 ms **and** no live DSH host is present (the heartbeat file `memory-eternal-config.host.json` is stale, or its pid is dead), the standalone side now **atomically merges** the change straight into the shared config, clears the pending file and answers `pendingOutcome: applied-direct` — so Codex / Claude Code / Cursor users, and anyone running only `dsh-memory serve`, get their save applied immediately (previously that path just wrote a "pending" file for a host that **does not exist**, while the UI promised "takes effect on next start" forever). When a host *is* alive the standalone side **never** writes directly: the shared file is the host's derived mirror, and a direct write would be overwritten by the next `syncConfigFile()` (a dedicated reverse test guards that boundary). ② **Failures now give directions, not just an error (#21)**: host-guard errors are recognised — `HMR transactions cannot be nested` (Cordis HMR hosts) and `root.events.emit is unavailable from a plugin activation` (dsh-tui and friends) are two phrasings of one defect family — and "this host does not let a plugin write config from its own callback context" plus where to change it instead (**DSH Settings → Memory**, or the profile patch layer) now lands in the give-up error, the diagnostics and the standalone page's hint; `POST /config` also returns `hostGuard` / `hint`. ③ **The UI no longer always says "Saved" (#21)**: the client consumes `pendingOutcome` — `queued` / `failed` use the non-success style and say "not confirmed applied yet", `applied-direct` states "no DSH host here, written straight into the shared config", and only a real apply shows "Saved". ④ Also fixed a cleanup race that could keep a host or test process **alive forever**: the heartbeat timer and the pending-file watcher are created after an async `import()`, so a dispose that happened before it resolved left them uncleaned (the heartbeat interval is additionally `unref()`ed) — this is exactly what hung `tests/settings-compat.test.mjs` when it ran `apply()` against a fake ctx. New `tests/issue-fixes-3.test.mjs` (9 checks); `npm test` now runs **238 checks** |
|
|
331
332
|
| **v0.10.4** | 2026-10-07 | **Four "looks fine, silently wrong" issues fixed at once (#21 / #22 / #23 / #24).** ① **A save misreported by a host guard is no longer treated as failed (#21)**: the capability guard in hosts such as dsh-tui throws `root.events.emit is unavailable from a plugin activation` from the `describe()` call **inside** `settings.update`, even though the write has already been committed — the old code only asked "did it throw", so an **already-applied** change was retried 5 times, marked `dropped` and logged as a failure. New `applyPatchVerified` **reads the config back** after a throw (predicate `patchApplied`, shallow compare) and logs one line while treating it as success; drain and the standalone web share that path, and the standalone web's `POST /config` no longer claims "all good" unconditionally: it waits up to 800 ms for the pending file to be consumed and returns `pendingOutcome: applied / failed / queued` with matching wording (`waitPendingOutcome`). ② **`/cards` status no longer silently misaligns (#22)**: `status=deleted` now really lists the **recycle bin** (the numbers match `/recycle/list`), and an unrecognized value returns `unknownStatus: true` plus `appliedStatus` / `requestedStatus` echoes (the fallback to approved stays for old callers, but it is no longer silent). ③ **After an upgrade, trust the version actually served on the port (#23)**: `/overview` gains the `version` loaded by the **serving process**; `dsh-memory status` reports the **port's actual served version** and says "upgrade not in effect" when it differs (the lock file's version is only the watchdog's self-report; when the port holds a **pre-0.10.4** web nothing can be probed because its `/overview` has no `version` field yet, and the tool then says exactly that — "this plugin's web, but it does not report a version, i.e. an old build" — instead of wrongly claiming no service is responding); `stop` re-checks the port occupant afterwards and warns if it is still taken; `restart` no longer trusts only the webPid recorded in the lock — it falls back to the **port occupant** to reap an old web the lock never registered (only after confirming from the command line that it really is **this plugin's** web — the `memory-eternal` package name, or the absolute path of this plugin's own `lib/web.js`; someone else's `node web.js` is merely warned about, since killing an unrelated process that happens to hold that port is worse than not killing at all), aborts if the port never frees up, and self-checks "port-served version = local version" after startup, exiting non-zero on a mismatch (no more "started" and then silence). ④ **Distillation no longer misreads bare control characters inside strings as unparseable (#24)**: when the model writes a markdown body with real U+000A newlines (braces balanced, `looksTruncatedJson` false), the old code always failed `JSON.parse` and dropped the whole card into a raw-text fallback. New `repairJsonControlChars` escapes bare control characters **only inside strings** (`\n` `\r` `\t` `\b` `\f`, otherwise `\u00XX`; newlines outside strings are legal JSON whitespace and stay untouched), and candidates are tried as "as-is → sliced `{ … }` → each one's repaired form"; parse failures no longer swallow the raw `JSON.parse` error, and the excerpt now keeps **both head and tail** (`describeOutputExcerpt`; the old `slice(0,160)` made output cut mid-`"body":` look like a broken stream); the max-tokens retry went from "double once" to `maxTokenLadder` **doubling all the way to the schema ceiling** (1200 → 2400 → 4000, no longer stopping at 2400). New `tests/issue-fixes-2.test.mjs` (21 checks); `npm test` now runs **229 checks** |
|
|
332
333
|
| **v0.10.3** | 2026-10-06 | **Fixed the counting semantics of `audit list` (#20).** ① The total is now **decoupled from `--limit`**: the reported count is the **matching total** (via `countCards`) while `--limit` caps output only, and truncation is stated explicitly (`显示前 M 张 … 还有 X 张未显示`). ② **No more silent truncation by default** (omit `--limit`, or `--limit 0`, to list everything). ③ `--json` now exposes `total` / `returned` / `count` (a backward-compatible alias of total; in 0.10.2 it wrongly equalled returned) / `limit` (null = all) / `hasMore`. ④ The meaning of `--status all` is documented in the usage text and README: the audit queue (pending + rejected), **excluding** dequeued approved and recycle-bin deleted; `approved` remains queryable explicitly. ⑤ **Same defect fixed in the MCP `memory_audit_list` tool**: it also silently capped at 20 and reported the truncated length as the pending count; it now reports the matching total plus how many are hidden, cap 100 → 500. |
|
package/README.md
CHANGED
|
@@ -366,6 +366,7 @@ curl http://127.0.0.1:7999/memory-eternal/api/web-info # 独立 web 是否活
|
|
|
366
366
|
|
|
367
367
|
| 版本 | 日期 | 关键改动 |
|
|
368
368
|
|---|---|---|
|
|
369
|
+
| **v0.10.6** | 2026-10-07 | **孤儿 web 有了回收机制 + 共享配置同步失败不再静默**。① **孤儿 web**:web 子进程是 detached 启动的,watchdog 被硬终止时(Windows 上 `process.kill(pid,'SIGTERM')` 即无条件终止)它的退出处理器不执行;若这个 web 当初因为目标端口被占而**退让到 port+1…**,登记它的锁条目又随 watchdog 一起消失 —— 于是 8001/8002 这类端口上会常驻没人认领的 web(实测本机就躺着两个,其中一个已跑了 20 小时,而 `status` 只探配置端口,永远发现不了)。现在 `startWatchdog` 启动时、以及 `stop`/`restart`(`checkPort` 路径)都会扫**退让窗口** `[port+1, port+9]` 并收掉它们;三道门缺一不可(端口在窗口内 / 命令行**确实是本插件的 web** / pid 不在锁里任何 watchdog 的 `pid` 或 `webPid` 上),**别人的 `node web.js` 一个都不杀**。另外 `spawnWeb` 发现目标端口上已有自家 web 时**不再重复拉起**(退让实例的主要来源),只交给 tick 探活。② **`syncConfigFile()` 不再静默吞错**:它写的是独立页 / hooks / MCP 共用的读源,写不进去就表现为「配置改了但他们看不到」——旧实现是 `catch { /* 静默 */ }`(实测到过一次:共享文件 mtime 停在激活前,而宿主里的值已经变了)。现在失败会进 stderr 与「自动沉淀日志」的 warn(同一原因只报一次,不刷屏);因为 `logCapture` 定义在 `apply()` 更靠后的位置,日志刻意**延迟一拍**再写,避开 `const` 的 TDZ。新增 `tests/watchdog-orphans.test.mjs`(7 项),`npm test` 共 **245 项** |
|
|
369
370
|
| **v0.10.5** | 2026-10-07 | **独立 Web 端的配置保存:不再空头承诺,也不再要求「必须装了 DSH」**。① **宿主无关的直写路径(#21)**:独立端保存后若 800ms 内没人消费 pending 文件、**且**本机没有活着的 DSH 宿主(心跳文件 `memory-eternal-config.host.json` 不新鲜、或里面的 pid 已死),就直接把改动**原子合并**写进共享配置、清掉 pending,响应回 `pendingOutcome: applied-direct` —— 于是 Codex / Claude Code / Cursor、以及只跑 `dsh-memory serve` 的用户,保存当场生效(此前那条路只会写「待应用」等一个**根本不存在**的宿主,界面上那句「下次启动生效」永远不会兑现)。宿主活着时**绝不**直写:共享配置是宿主的派生镜像,绕过它会被下一次 `syncConfigFile()` 盖回去(有专门的反向测试守着这条边界)。② **失败要指路,不只是重复报错(#21)**:识别「宿主守卫类」报错 —— `HMR transactions cannot be nested`(Cordis HMR 宿主)与 `root.events.emit is unavailable from a plugin activation`(dsh-tui 一类)是同一族缺陷的两种措辞 —— 并把「此宿主不允许插件从自己的回调上下文写配置」与「去哪改」(**DSH 设置 → 记忆**,或 profile 的配置补丁层)写进放弃时的报错、诊断信息与独立页提示;`POST /config` 同时回 `hostGuard` / `hint` 字段。③ **界面不再一律弹「已保存」(#21)**:客户端开始消费 `pendingOutcome` —— `queued` / `failed` 用**非成功样式**并说明「尚未确认生效」,`applied-direct` 明确写「本机没有 DSH 宿主,已直接写入共享配置」,只有真生效才显示「已保存」。④ 顺带修掉一个会让宿主/测试**无法退出**的清理竞态:心跳定时器与 pending 文件监听都是在异步 `import()` 之后才建立,若在它 resolve 之前就 dispose,回调就再没人清理(心跳定时器还额外 `unref()`)—— `tests/settings-compat.test.mjs` 用假 ctx 跑 `apply()` 时正是被它挂住的。新增 `tests/issue-fixes-3.test.mjs`(9 项),`npm test` 共 **238 项** |
|
|
370
371
|
| **v0.10.4** | 2026-10-07 | **四个「看着正常、实际静默错位」的 issue 集中修复(#21 / #22 / #23 / #24)**。① **宿主守卫误报的保存不再被当成失败(#21)**:dsh-tui 一类宿主的能力守卫会在 `settings.update` **内部**的 `describe()` 上抛 `root.events.emit is unavailable from a plugin activation`,而写入其实已经提交 —— 旧实现只认「有没有抛错」,会把**已生效**的改动重试 5 次后标成 `dropped`、日志里还报失败。新增 `applyPatchVerified`(抛错后**回读确认**,判据 `patchApplied` 浅比较,命中则记一行日志按成功处理),drain 与独立 Web 端共用这条路径;独立 Web 的 `POST /config` 也不再无条件回「一切正常」:等最多 800ms 回读 pending 文件,回 `pendingOutcome: applied / failed / queued` 并给对应措辞(`waitPendingOutcome`)。② **`/cards` 的 status 不再静默错位(#22)**:`status=deleted` 现在真的列**回收站**(与 `/recycle/list` 的数字对得上),未知取值显式回 `unknownStatus: true` 并回显 `appliedStatus` / `requestedStatus`(回落 approved 的老行为保留,但不再沉默)。③ **升级后以「端口上真正服务的版本」为准(#23)**:`/overview` 新增**当前服务进程**加载的 `version`;`dsh-memory status` 单独报「端口实际服务版本」,对不上就直说「升级尚未生效」(锁里的版本号只是 watchdog 自述;端口上是 **v0.10.4 之前**的旧 web 时探不到版本 —— 它的 `/overview` 还没有 `version` 字段 —— 这时会明说「是本插件的 web 但不会自报版本,即旧版」,而不是误报「没有服务在响应」);`stop` 停完再问一次端口占用者、仍被占就告警;`restart` 不再只信锁里登记的 webPid —— 按**端口占用者**兜底收掉锁里没登记的旧 web(先用命令行确认**确实是本插件的 web**(包名 `memory-eternal`,或本插件自己的 `lib/web.js` 绝对路径)才动手,别人的 `node web.js` 只告警不动手 —— 收紧这条判据是刻意的:误杀别人占着该端口的进程,比不杀更糟),端口没空出来就中止重启,起完自检「端口服务版本 = 本机版本」,对不上以非 0 退出(不再打印「已启动」就完事)。④ **蒸馏解析不再把「字符串里的裸控制字符」误判成无法解析(#24)**:模型把 markdown 正文的换行写成真实 U+000A(括号闭合、`looksTruncatedJson` 为 false)时,旧实现必然 `JSON.parse` 失败 → 整卡退成原文卡;新增 `repairJsonControlChars` **只在字符串内部**把裸控制字符转成合法转义(`\n` `\r` `\t` `\b` `\f`,其余 `\u00XX`;字符串外的换行是合法 JSON 空白,一律不动),候选串顺序为「原样 → 截 `{ … }` → 各自的已修复版」;解析失败的消息不再吞掉 `JSON.parse` 原始报错,现场摘要改为**首尾各留一段**(`describeOutputExcerpt`,旧的 `slice(0,160)` 恰好把「被切在 `"body":` 半截处」显示得像输出断了);撞输出上限的重试从「只翻倍一次」改为 `maxTokenLadder` **一路翻倍到 schema 上限**(1200 → 2400 → 4000,不再止步 2400)。新增 `tests/issue-fixes-2.test.mjs`(21 项),`npm test` 共 **229 项** |
|
|
371
372
|
| **v0.10.3** | 2026-10-06 | **修复 `audit list` 的计数语义(#20)**。① 总数与 `--limit` **解耦**:`共 N 张` 取**匹配总数**(`countCards`),`--limit` 只限制显示条数,截断时明确写出 `显示前 M 张 … 还有 X 张未显示`。② **默认不再静默截断**(不给 `--limit`、或 `--limit 0` = 全部显示)。③ `--json` 拆分为 `total`(匹配总数)/ `returned`(本次返回)/ `count`(= total 的兼容别名;0.10.2 里它误等于 returned)/ `limit`(null = 全部)/ `hasMore`。④ `--status all` 的语义写进用法与 README:= 审核队列(pending + rejected),**不含**已出队的 approved 与回收中心的 deleted;`approved` 可显式查询。⑤ **同源修复 MCP 的 `memory_audit_list`**:它同样存在「默认 20 静默截断 + 把截断条数报成待处理数」,现改为报匹配总数 + 未显示提示,上限 100 → 500。 |
|
package/index.js
CHANGED
|
@@ -448,6 +448,24 @@ export function apply(ctx, config) {
|
|
|
448
448
|
return roots
|
|
449
449
|
}
|
|
450
450
|
// 把完整配置写入共享文件,使独立 web / MCP hook 捕获与 DSH 设置同步(不同步修复)。
|
|
451
|
+
//
|
|
452
|
+
// 2026-10-07:这里原来是 `catch { /* 静默 */ }` —— 正是本项目一直在消灭的静默吞错。它写不进去
|
|
453
|
+
// 的后果很隐蔽:独立页 / hooks / MCP 读的都是这个文件,于是表现为「配置改了但他们看不到」
|
|
454
|
+
// (实测到过一次:共享文件的 mtime 停在激活前,而宿主里的值已经变了)。现在失败一律可见。
|
|
455
|
+
let lastSyncError = ''
|
|
456
|
+
const reportSyncFailure = (reason) => {
|
|
457
|
+
const why = String(reason || '')
|
|
458
|
+
if (!why || why === lastSyncError) return // 同一原因只报一次,避免 5 秒轮询刷屏
|
|
459
|
+
lastSyncError = why
|
|
460
|
+
// 必须延迟一拍:本函数可能在 apply() 的**同步**阶段被调用,而 logCapture 定义在更后面
|
|
461
|
+
// (const 的 TDZ 会直接抛 ReferenceError)。setImmediate 之后整个 apply() 已执行完毕。
|
|
462
|
+
const emit = () => {
|
|
463
|
+
try { console.error('[memory-eternal] 共享配置文件同步失败(独立页 / hooks / MCP 读到的是旧值):' + why) } catch { /* 无妨 */ }
|
|
464
|
+
try { logCapture('system', 'warn', '共享配置文件同步失败(独立页 / hooks / MCP 会读到旧值):' + why) } catch { /* 无妨 */ }
|
|
465
|
+
}
|
|
466
|
+
if (typeof setImmediate === 'function') setImmediate(emit)
|
|
467
|
+
else setTimeout(emit, 0)
|
|
468
|
+
}
|
|
451
469
|
const syncConfigFile = async () => {
|
|
452
470
|
try {
|
|
453
471
|
const cfg = settings.get() ?? {}
|
|
@@ -456,7 +474,10 @@ export function apply(ctx, config) {
|
|
|
456
474
|
// 原子写(tmp + rename,见 lib/config-sync.js 里 writeFileAtomicSync 的说明):
|
|
457
475
|
// 这个文件是跨进程共享的读源,非原子写会让独立 web / MCP 读到半截内容并静默回落成默认值。
|
|
458
476
|
writeFileAtomicSync(configFilePath(process.env), JSON.stringify(cfg, null, 2))
|
|
459
|
-
|
|
477
|
+
lastSyncError = ''
|
|
478
|
+
} catch (error) {
|
|
479
|
+
reportSyncFailure(error && error.message ? error.message : error)
|
|
480
|
+
}
|
|
460
481
|
}
|
|
461
482
|
syncConfigFile()
|
|
462
483
|
// agent/turn-stopping 是 serial 事件:不在监听器里 await LLM(会拖慢收尾),
|
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 Ca(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:()=>({})},l=>n.default.createElement(Ma,{t:o,locale:t.locale,wide:!(l&&l.wide===!1)}))),"memory-eternal: sidebar footer action")}function Sa(){let[t,o]=(0,n.useState)("http://127.0.0.1:7999/");return(0,n.useEffect)(()=>{fetch("/memory-eternal/api/web-info").then(l=>l.json()).then(l=>{l&&l.ok&&l.url&&o(l.url.endsWith("/")?l.url:l.url+"/")}).catch(()=>{})},[]),t}function Na({t}){return(0,e.jsxs)("div",{style:{display:"flex",flexDirection:"column",height:"100%",minHeight:0},children:[(0,e.jsx)("style",{children:Be}),(0,e.jsx)(ta,{t,onReload:()=>{},version:0})]})}function Ma({t,locale:o,wide:l}){let[g,c]=(0,n.useState)(!1);return(0,e.jsxs)("div",{className:`me-footer${l?"":" rail"}`,children:[(0,e.jsx)("style",{children:Be}),(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")})]}),g&&(0,e.jsx)(za,{t,locale:o,onClose:()=>c(!1)})]})}function za({t,locale:o,onClose:l,tab:g}){let[c,i]=(0,n.useState)(!1),x=(0,n.useRef)(null),S=Sa(),v=(0,n.useCallback)(T=>o&&typeof o.subscribe=="function"?o.subscribe(T):()=>{},[o]),P=(0,n.useCallback)(()=>o&&typeof o.getSnapshot=="function"?o.getSnapshot():null,[o]),w=(0,n.useSyncExternalStore)(v,P),h=w&&(w.active==="zh"||w.active==="en")?w.active:null,u=(0,n.useRef)(null);u.current===null&&h&&(u.current=h);let D=(0,n.useMemo)(()=>{let T=u.current?`lang=${u.current}`:"";return g==="config"?`${U}/ui/config?tab=config${T?`&${T}`:""}`:g?`${S.replace(/\/$/,"")}/?tab=${g}${T?`&${T}`:""}`:T?`${S}${S.endsWith("/")?"":"/"}?${T}`:S},[S,g]);return(0,n.useEffect)(()=>{let T=x.current&&x.current.contentWindow;T&&h&&T.postMessage({source:"memory-eternal",type:"locale",lang:h},"*")},[h]),(0,n.useEffect)(()=>{let T=k=>{k.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:Be}),(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:x,src:D,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 Ca(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:()=>({})},l=>n.default.createElement(Ma,{t:o,locale:t.locale,wide:!(l&&l.wide===!1)}))),"memory-eternal: sidebar footer action")}function Sa(){let[t,o]=(0,n.useState)("http://127.0.0.1:7999/");return(0,n.useEffect)(()=>{fetch("/memory-eternal/api/web-info").then(l=>l.json()).then(l=>{l&&l.ok&&l.url&&o(l.url.endsWith("/")?l.url:l.url+"/")}).catch(()=>{})},[]),t}function Na({t}){return(0,e.jsxs)("div",{style:{display:"flex",flexDirection:"column",height:"100%",minHeight:0},children:[(0,e.jsx)("style",{children:Be}),(0,e.jsx)(ta,{t,onReload:()=>{},version:0})]})}function Ma({t,locale:o,wide:l}){let[g,c]=(0,n.useState)(!1);return(0,e.jsxs)("div",{className:`me-footer${l?"":" rail"}`,children:[(0,e.jsx)("style",{children:Be}),(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")})]}),g&&(0,e.jsx)(za,{t,locale:o,onClose:()=>c(!1)})]})}function za({t,locale:o,onClose:l,tab:g}){let[c,i]=(0,n.useState)(!1),x=(0,n.useRef)(null),S=Sa(),v=(0,n.useCallback)(T=>o&&typeof o.subscribe=="function"?o.subscribe(T):()=>{},[o]),P=(0,n.useCallback)(()=>o&&typeof o.getSnapshot=="function"?o.getSnapshot():null,[o]),w=(0,n.useSyncExternalStore)(v,P),h=w&&(w.active==="zh"||w.active==="en")?w.active:null,u=(0,n.useRef)(null);u.current===null&&h&&(u.current=h);let D=(0,n.useMemo)(()=>{let T=u.current?`lang=${u.current}`:"";return g==="config"?`${U}/ui/config?tab=config${T?`&${T}`:""}`:g?`${S.replace(/\/$/,"")}/?tab=${g}${T?`&${T}`:""}`:T?`${S}${S.endsWith("/")?"":"/"}?${T}`:S},[S,g]);return(0,n.useEffect)(()=>{let T=x.current&&x.current.contentWindow;T&&h&&T.postMessage({source:"memory-eternal",type:"locale",lang:h},"*")},[h]),(0,n.useEffect)(()=>{let T=k=>{k.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:Be}),(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:x,src:D,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.6";function Da({t,inModal:o,onClose:l,onFull:g,full:c}){let[i,x]=(0,n.useState)(null),[S,v]=(0,n.useState)([]),[P,w]=(0,n.useState)(!0),[h,u]=(0,n.useState)(null),[D,T]=(0,n.useState)(""),[k,A]=(0,n.useState)("all"),[z,F]=(0,n.useState)(""),[Z,pe]=(0,n.useState)("all"),[_,De]=(0,n.useState)("all"),[O,ie]=(0,n.useState)(()=>{try{let y=new URLSearchParams(typeof location<"u"?location.search:"").get("tab"),E={cards:"cards",graph:"graph",usage:"stats",stats:"stats",audit:"audit",recycle:"optimize",optimize:"optimize",config:"config"};if(y&&E[y])return E[y]}catch{}return"cards"}),[re,H]=(0,n.useState)(()=>new Set([O])),b=(0,n.useCallback)(y=>{H(E=>E.has(y)?E:new Set(E).add(y)),ie(y)},[]),[f,L]=(0,n.useState)("recent"),[K,$]=(0,n.useState)(null),W=(0,n.useRef)(null),ae=(0,n.useRef)(null),[Q,ge]=(0,n.useState)(!1),[Ue,fe]=(0,n.useState)(null),[Se,ve]=(0,n.useState)(!1),[be,Ie]=(0,n.useState)(!1),[xe,Te]=(0,n.useState)(!0),[Qe,Ke]=(0,n.useState)(null),[Ne,Ge]=(0,n.useState)(0),et=(0,n.useCallback)(()=>Ge(y=>y+1),[]),je=i?.quarantine?.pending??0,Me=i?.quarantine?.deleted??0,ee=(0,n.useRef)(null),[He,ke]=(0,n.useState)(0),[d,r]=(0,n.useState)(!1),B=(0,n.useRef)(null),s=(0,n.useRef)(null),N=(0,n.useCallback)(async(y=k,E=z,J=_,ue={})=>{let q=ue.append===!0;try{q?r(!0):(w(!0),T(""));let ce=new URLSearchParams;y&&y!=="all"&&ce.set("kind",y),E.trim()&&ce.set("q",E.trim()),ce.set("status",J||"all"),ce.set("limit",String(va)),ce.set("offset",String(ue.offset||0)),f==="title"&&ce.set("sort","title");let Ce=await(await fetch(`${U}/cards?${ce.toString()}`)).json();if(Ce.ok){let Oe=Ce.cards||[];v(Re=>q?[...Re,...Oe.filter(We=>!Re.some(ht=>ht.path===We.path))]:Oe),ke(Number(Ce.total)||Oe.length)}}catch(ce){T(String(ce&&ce.message?ce.message:ce))}finally{q?r(!1):w(!1)}},[k,z,_,f]),M=(0,n.useCallback)(async()=>{try{let[y,E]=await Promise.all([fetch(`${U}/overview`).then(J=>J.json()),fetch(`${U}/capture-log`).then(J=>J.json()).catch(()=>null)]);y.ok&&x(y),u(E&&E.ok&&E.available&&E.health||null)}catch(y){T(String(y&&y.message?y.message:y))}finally{w(!1)}},[]),a=async y=>{try{let J=await(await fetch(`${U}/delete?path=${encodeURIComponent(y)}`)).json();J.ok?($({ok:!0,msg:t("deleted")}),await M(),et()):$({ok:!1,msg:J.error||t("deleteFail")})}catch{$({ok:!1,msg:t("deleteFail")})}W.current&&clearTimeout(W.current),W.current=setTimeout(()=>$(null),2e3)};(0,n.useEffect)(()=>(M(),()=>{ee.current&&clearTimeout(ee.current)}),[M]);let de=y=>{F(y),ee.current&&clearTimeout(ee.current),ee.current=setTimeout(()=>N(k,y,_),280)},V=y=>{A(y),N(y,z,_)};(0,n.useEffect)(()=>{O==="cards"&&N(k,z,_)},[_,O,Ne,f]),(0,n.useEffect)(()=>{if(O!=="cards"||P||d||S.length>=He)return;let y=B.current,E=s.current;if(!y)return;let J=new IntersectionObserver(ue=>{ue[0]&&ue[0].isIntersecting&&N(k,z,_,{append:!0,offset:S.length})},{root:E||null,rootMargin:"400px"});return J.observe(y),()=>J.disconnect()},[O,S,He,P,d,k,z,_,N]);let te=async y=>{try{let E=y.status;if(!E&&y.path)try{let we=((await fetch(`${U}/cards?status=all&q=${encodeURIComponent(y.title||"")}&limit=50`).then(Ce=>Ce.json())).cards||[]).find(Ce=>Ce.path===y.path);we&&(E=we.status)}catch{}let J=E&&E!=="approved"?`&status=${encodeURIComponent(E)}`:"",ue=await fetch(`${U}/card?path=${encodeURIComponent(y.path||y.id)}${J}`),q=await ue.json();q.ok?fe({path:q.path,title:y.title,text:q.text}):T(q.error||`HTTP ${ue.status}`)}catch(E){T(String(E&&E.message?E.message:E))}},ye=async(y,E)=>{ge(!0);try{let J=await fetch(`${U}/export`);if(!J.ok)throw new Error("HTTP "+J.status);let ue=await J.json();if(!ue.ok)throw new Error(ue.error||"export failed");let q,ce,we;if(y==="json"){ce="memory-vault.json",we="application/json";let Re=ue.cards.map(We=>({path:We.path,title:We.title,kind:We.kind,status:We.status,store:We.store,text:We.text}));q=JSON.stringify({format:ue.format||"memory-eternal-vault",formatVersion:ue.formatVersion||1,exportedAt:ue.exportedAt||new Date().toISOString(),count:Re.length,cards:Re},null,2)}else ce="memory-vault.md",we="text/markdown;charset=utf-8",q=ue.cards.map(Re=>Re.text.trim()).filter(Boolean).join(`
|
|
302
302
|
|
|
303
303
|
---
|
|
304
304
|
|
package/lib/watchdog.js
CHANGED
|
@@ -263,6 +263,15 @@ export async function stopWatchdogs({ env = process.env, port, timeoutMs = 5000,
|
|
|
263
263
|
if (!found || !found.pid) continue
|
|
264
264
|
warnings.push(`端口 ${p} 仍被 pid ${found.pid} 占用(${found.command || '命令行未知'});status 显示的版本可能不是端口上真正服务的版本`)
|
|
265
265
|
}
|
|
266
|
+
// 顺带回收「上一代退让到 port+1… 的孤儿 web」:它们不在锁里,配置端口的兜底永远够不到它们
|
|
267
|
+
// (8001/8002 上常驻的那两个就是这么来的)。三道门由 sweepOrphanWebs 把握,只收自家的。
|
|
268
|
+
for (const p of ports) {
|
|
269
|
+
try {
|
|
270
|
+
const sweep = await sweepOrphanWebs({ env, port: p, findListener, kill: killFn, isAlive: alive, lock: { watchdogs: readWatchdogLock(env).watchdogs } })
|
|
271
|
+
for (const pid of sweep.killed) portStopped.push(pid)
|
|
272
|
+
for (const w of sweep.warnings) warnings.push(w)
|
|
273
|
+
} catch { /* 回收失败不影响 stop 主流程 */ }
|
|
274
|
+
}
|
|
266
275
|
}
|
|
267
276
|
return {
|
|
268
277
|
stopped: [...new Set(stopped.filter((p) => !failed.includes(p)))],
|
|
@@ -489,6 +498,66 @@ export function looksLikeOurWeb(command, { webJs = WEB_JS } = {}) {
|
|
|
489
498
|
return Boolean(target) && norm(s).includes(target)
|
|
490
499
|
}
|
|
491
500
|
|
|
501
|
+
/**
|
|
502
|
+
* 退让端口窗口:配置端口 + 紧随其后的若干个。
|
|
503
|
+
*
|
|
504
|
+
* web 在目标端口被占时会退到 port+1…(见 startWatchdog 的 findFreePort),这些落点正是
|
|
505
|
+
* 「孤儿 web」的诞生地:退让出来的那个 web 一旦失去托管它的 watchdog,就再没人来认领它。
|
|
506
|
+
*/
|
|
507
|
+
export function fallbackPortWindow(port, span = 9) {
|
|
508
|
+
const base = Number(port) || 0
|
|
509
|
+
const n = Math.max(0, Number(span) || 0)
|
|
510
|
+
const out = []
|
|
511
|
+
for (let i = 1; i <= n; i++) out.push(base + i)
|
|
512
|
+
return out
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* 收掉「本插件拉起、但没有任何托管者认领」的孤儿 web。
|
|
517
|
+
*
|
|
518
|
+
* 为什么必须单独做这件事:web 子进程是 detached 启动的(stdio ignore + unref),watchdog 被
|
|
519
|
+
* 硬终止时(Windows 上 process.kill(pid,'SIGTERM') 就是无条件终止)它的退出处理器不会执行;
|
|
520
|
+
* 若该 web 当初退让到了 port+1,登记它的锁条目也随 watchdog 一起消失 —— 于是它会一直占着
|
|
521
|
+
* 8001/8002 之类的端口,`status` 只探配置端口,永远发现不了它。0.10.4 起 stop/restart 会按
|
|
522
|
+
* 「端口占用者」兜底,但那只覆盖配置端口本身。
|
|
523
|
+
*
|
|
524
|
+
* 三道门缺一不可(宁可漏收,绝不误杀):
|
|
525
|
+
* ① 端口落在 [port+1, port+span] 窗口内 —— 配置端口上的服务不归它管;
|
|
526
|
+
* ② 命令行确实是本插件的 web(复用收紧过的 looksLikeOurWeb,别人的 node web.js 不动);
|
|
527
|
+
* ③ 该 pid 不在锁文件里任何 watchdog 的 pid / webPid 上 —— 登记在案的一律不动。
|
|
528
|
+
*
|
|
529
|
+
* @param {{env?:object, port:number, span?:number, kill?:Function, isAlive?:Function, findListener?:Function, lock?:object}} opts
|
|
530
|
+
* @returns {Promise<{killed:number[], kept:Array<{pid:number,port:number,reason:string}>, warnings:string[]}>}
|
|
531
|
+
*/
|
|
532
|
+
export async function sweepOrphanWebs({ env = process.env, port, span = 9, kill, isAlive, findListener, lock } = {}) {
|
|
533
|
+
const alive = isAlive || isPidAlive
|
|
534
|
+
const killFn = kill || ((pid) => { try { process.kill(pid, 'SIGTERM'); return true } catch { return false } })
|
|
535
|
+
const probeListener = async (p) => {
|
|
536
|
+
try { return findListener ? await findListener(p) : await findPortListener(p) } catch { return null }
|
|
537
|
+
}
|
|
538
|
+
const entries = lock && Array.isArray(lock.watchdogs) ? lock.watchdogs : readWatchdogLock(env).watchdogs
|
|
539
|
+
const registered = new Set()
|
|
540
|
+
for (const w of entries) {
|
|
541
|
+
if (Number(w.pid) > 0) registered.add(Number(w.pid))
|
|
542
|
+
if (Number(w.webPid) > 0) registered.add(Number(w.webPid))
|
|
543
|
+
}
|
|
544
|
+
const killed = []
|
|
545
|
+
const kept = []
|
|
546
|
+
const warnings = []
|
|
547
|
+
for (const p of fallbackPortWindow(port, span)) {
|
|
548
|
+
const found = await probeListener(p)
|
|
549
|
+
if (!found || !found.pid) continue
|
|
550
|
+
const pid = Number(found.pid)
|
|
551
|
+
const command = String(found.command || '')
|
|
552
|
+
if (!looksLikeOurWeb(command)) { kept.push({ pid, port: p, reason: 'not-ours' }); continue }
|
|
553
|
+
if (registered.has(pid)) { kept.push({ pid, port: p, reason: 'registered' }); continue }
|
|
554
|
+
if (!alive(pid)) { kept.push({ pid, port: p, reason: 'already-exited' }); continue }
|
|
555
|
+
if (!killFn(pid)) { warnings.push(`端口 ${p} 上的孤儿 web(pid ${pid})未能终止,请手工处理`); continue }
|
|
556
|
+
killed.push(pid)
|
|
557
|
+
}
|
|
558
|
+
return { killed, kept, warnings }
|
|
559
|
+
}
|
|
560
|
+
|
|
492
561
|
/** 探测端口是否有任何 TCP 监听(即便不是我们的服务也占用)。 */
|
|
493
562
|
export async function probeTcp(port, timeoutMs = 600) {
|
|
494
563
|
return new Promise((resolve) => {
|
|
@@ -530,6 +599,16 @@ export function startWatchdog({ port = 7999, vaultRoot, interval = 5000, maxRest
|
|
|
530
599
|
if (child && !child.killed) {
|
|
531
600
|
try { child.kill('SIGTERM') } catch {}
|
|
532
601
|
}
|
|
602
|
+
// 目标端口上已经有**本插件自己的** web(上一代 watchdog 留下的、或正在启动还没来得及回
|
|
603
|
+
// /overview 的那个)→ 本轮不重复拉起,更不退让到 port+1:退让出来的那个一旦失去托管者
|
|
604
|
+
// 就是孤儿(8001/8002 的来历)。保活交给 tick 的探活,等它真的死了再起。
|
|
605
|
+
try {
|
|
606
|
+
const found = await findPortListener(port)
|
|
607
|
+
if (found && found.pid && looksLikeOurWeb(found.command)) {
|
|
608
|
+
log(`端口 ${port} 上已有本插件的 web(pid ${found.pid})→ 本轮不重复拉起(不制造退让实例)`)
|
|
609
|
+
return port
|
|
610
|
+
}
|
|
611
|
+
} catch { /* 探测失败按原逻辑继续 */ }
|
|
533
612
|
const targetPort = await findFreePort(port)
|
|
534
613
|
const args = [WEB_JS, '--port', String(targetPort)]
|
|
535
614
|
if (vaultRoot) args.push('--vault', vaultRoot)
|
|
@@ -633,6 +712,16 @@ export function startWatchdog({ port = 7999, vaultRoot, interval = 5000, maxRest
|
|
|
633
712
|
process.exit(0)
|
|
634
713
|
}
|
|
635
714
|
|
|
715
|
+
// 启动时顺手回收「上一代退让到 port+1… 的孤儿 web」——不这么做的话,那些 web 会一直占着
|
|
716
|
+
// 8001/8002 之类的端口,而 tick 只探配置端口,永远发现不了它们(它们当初的托管者已退出)。
|
|
717
|
+
;(async () => {
|
|
718
|
+
try {
|
|
719
|
+
const sweep = await sweepOrphanWebs({ port })
|
|
720
|
+
if (sweep.killed.length) log(`已回收上一代的孤儿 web:pid ${sweep.killed.join(', ')}(端口 ${fallbackPortWindow(port).join('/')} 窗口)`)
|
|
721
|
+
for (const w of sweep.warnings) log(`⚠ ${w}`)
|
|
722
|
+
} catch { /* 回收失败不影响保活主流程 */ }
|
|
723
|
+
})()
|
|
724
|
+
|
|
636
725
|
// 首次探测
|
|
637
726
|
;(async () => {
|
|
638
727
|
const alive = await probe(port)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "memory-eternal",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.6",
|
|
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"
|
|
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"
|
|
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 Vl="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 Vl="0.10.6";function ef({t:e,inModal:t,onClose:n,onFull:r,full:l}){let[o,a]=(0,v.useState)(null),[s,u]=(0,v.useState)([]),[f,y]=(0,v.useState)(!0),[h,m]=(0,v.useState)(null),[b,z]=(0,v.useState)(""),[k,I]=(0,v.useState)("all"),[p,c]=(0,v.useState)(""),[g,N]=(0,v.useState)("all"),[L,B]=(0,v.useState)("all"),[P,O]=(0,v.useState)(()=>{try{let _=new URLSearchParams(typeof location<"u"?location.search:"").get("tab"),K={cards:"cards",graph:"graph",usage:"stats",stats:"stats",audit:"audit",recycle:"optimize",optimize:"optimize",config:"config"};if(_&&K[_])return K[_]}catch{}return"cards"}),[Y,R]=(0,v.useState)(()=>new Set([P])),M=(0,v.useCallback)(_=>{R(K=>K.has(_)?K:new Set(K).add(_)),O(_)},[]),[T,$]=(0,v.useState)("recent"),[Z,G]=(0,v.useState)(null),X=(0,v.useRef)(null),ce=(0,v.useRef)(null),[ie,_e]=(0,v.useState)(!1),[Qt,Ie]=(0,v.useState)(null),[rt,$e]=(0,v.useState)(!1),[Fe,pt]=(0,v.useState)(!1),[Ue,ft]=(0,v.useState)(!0),[kn,Yt]=(0,v.useState)(null),[lt,on]=(0,v.useState)(0),bn=(0,v.useCallback)(()=>on(_=>_+1),[]),_t=o?.quarantine?.pending??0,ot=o?.quarantine?.deleted??0,se=(0,v.useRef)(null),[It,qe]=(0,v.useState)(0),[S,x]=(0,v.useState)(!1),U=(0,v.useRef)(null),w=(0,v.useRef)(null),A=(0,v.useCallback)(async(_=k,K=p,oe=L,Me={})=>{let ee=Me.append===!0;try{ee?x(!0):(y(!0),z(""));let be=new URLSearchParams;_&&_!=="all"&&be.set("kind",_),K.trim()&&be.set("q",K.trim()),be.set("status",oe||"all"),be.set("limit",String(Wg)),be.set("offset",String(Me.offset||0)),T==="title"&&be.set("sort","title");let Ze=await(await fetch(`${q}/cards?${be.toString()}`)).json();if(Ze.ok){let Rt=Ze.cards||[];u(bt=>ee?[...bt,...Rt.filter(Ot=>!bt.some(Ql=>Ql.path===Ot.path))]:Rt),qe(Number(Ze.total)||Rt.length)}}catch(be){z(String(be&&be.message?be.message:be))}finally{ee?x(!1):y(!1)}},[k,p,L,T]),j=(0,v.useCallback)(async()=>{try{let[_,K]=await Promise.all([fetch(`${q}/overview`).then(oe=>oe.json()),fetch(`${q}/capture-log`).then(oe=>oe.json()).catch(()=>null)]);_.ok&&a(_),m(K&&K.ok&&K.available&&K.health||null)}catch(_){z(String(_&&_.message?_.message:_))}finally{y(!1)}},[]),d=async _=>{try{let oe=await(await fetch(`${q}/delete?path=${encodeURIComponent(_)}`)).json();oe.ok?(G({ok:!0,msg:e("deleted")}),await j(),bn()):G({ok:!1,msg:oe.error||e("deleteFail")})}catch{G({ok:!1,msg:e("deleteFail")})}X.current&&clearTimeout(X.current),X.current=setTimeout(()=>G(null),2e3)};(0,v.useEffect)(()=>(j(),()=>{se.current&&clearTimeout(se.current)}),[j]);let ke=_=>{c(_),se.current&&clearTimeout(se.current),se.current=setTimeout(()=>A(k,_,L),280)},J=_=>{I(_),A(_,p,L)};(0,v.useEffect)(()=>{P==="cards"&&A(k,p,L)},[L,P,lt,T]),(0,v.useEffect)(()=>{if(P!=="cards"||f||S||s.length>=It)return;let _=U.current,K=w.current;if(!_)return;let oe=new IntersectionObserver(Me=>{Me[0]&&Me[0].isIntersecting&&A(k,p,L,{append:!0,offset:s.length})},{root:K||null,rootMargin:"400px"});return oe.observe(_),()=>oe.disconnect()},[P,s,It,f,S,k,p,L,A]);let ue=async _=>{try{let K=_.status;if(!K&&_.path)try{let Ve=((await fetch(`${q}/cards?status=all&q=${encodeURIComponent(_.title||"")}&limit=50`).then(Ze=>Ze.json())).cards||[]).find(Ze=>Ze.path===_.path);Ve&&(K=Ve.status)}catch{}let oe=K&&K!=="approved"?`&status=${encodeURIComponent(K)}`:"",Me=await fetch(`${q}/card?path=${encodeURIComponent(_.path||_.id)}${oe}`),ee=await Me.json();ee.ok?Ie({path:ee.path,title:_.title,text:ee.text}):z(ee.error||`HTTP ${Me.status}`)}catch(K){z(String(K&&K.message?K.message:K))}},Ae=async(_,K)=>{_e(!0);try{let oe=await fetch(`${q}/export`);if(!oe.ok)throw new Error("HTTP "+oe.status);let Me=await oe.json();if(!Me.ok)throw new Error(Me.error||"export failed");let ee,be,Ve;if(_==="json"){be="memory-vault.json",Ve="application/json";let bt=Me.cards.map(Ot=>({path:Ot.path,title:Ot.title,kind:Ot.kind,status:Ot.status,store:Ot.store,text:Ot.text}));ee=JSON.stringify({format:Me.format||"memory-eternal-vault",formatVersion:Me.formatVersion||1,exportedAt:Me.exportedAt||new Date().toISOString(),count:bt.length,cards:bt},null,2)}else be="memory-vault.md",Ve="text/markdown;charset=utf-8",ee=Me.cards.map(bt=>bt.text.trim()).filter(Boolean).join(`
|
|
304
304
|
|
|
305
305
|
---
|
|
306
306
|
|