memory-eternal 0.10.3 → 0.10.4
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 +7 -1
- package/README.md +10 -1
- package/bin/dsh-memory.mjs +53 -5
- package/index.js +32 -7
- package/lib/api.js +37 -7
- package/lib/capture.js +128 -20
- package/lib/client.js +1 -1
- package/lib/config-sync.js +76 -0
- package/lib/watchdog.js +165 -3
- package/package.json +2 -2
- package/scripts/release.mjs +14 -0
- 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.4",
|
|
5
5
|
"description": "记忆核心(Memory Eternal):对话结束后自动沉淀知识卡到本地记忆库(~/.dsh/memory-vault),支持检索 / 召回 / 审核中心。",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "EternalNight996",
|
package/README.en.md
CHANGED
|
@@ -111,8 +111,11 @@ dsh-memory serve [--port 7999] # run web in foreground
|
|
|
111
111
|
dsh-memory open # ensure web alive + open browser
|
|
112
112
|
dsh-memory watchdog [--port 7799] # watchdog keep-alive (standalone process)
|
|
113
113
|
dsh-memory status [--json] # resident watchdog: pid / port / version / alive
|
|
114
|
+
# plus the version **actually served on the port**
|
|
114
115
|
dsh-memory stop [--port 7799] # stop the resident watchdog (+ the web it spawned)
|
|
116
|
+
# then re-checks who still holds the port and warns
|
|
115
117
|
dsh-memory restart [--port 7799] # stop + respawn (apply a new version)
|
|
118
|
+
# also reaps port owners the lock never registered, then verifies the served version
|
|
116
119
|
dsh-memory audit list [--status pending|rejected|approved|deleted|all] [--limit N] [--json]
|
|
117
120
|
# list cards; the total is never rewritten by --limit (which caps output only)
|
|
118
121
|
# all = audit queue (pending + rejected), excluding approved / deleted
|
|
@@ -135,7 +138,9 @@ Three concepts, don't mix them:
|
|
|
135
138
|
**Change these**: Vault left rail "Memory Config" (or DSH Settings → Memory) → edit the table and save; `autoWebMode` / `watchdogAutoSpawn` need a DSH restart.
|
|
136
139
|
|
|
137
140
|
> ⚠️ **Same port is never auto-replaced (#19)**: the watchdog is a **standalone process we deliberately never kill** (several sessions share one). So turning `watchdogAutoSpawn` off does **not** stop the running one, and changing `webCheckIntervalMs` / `webMaxRestart` has no effect on it.
|
|
138
|
-
> Stop or upgrade it explicitly with `dsh-memory stop [--port N]` / `dsh-memory restart [--port N]` (`stop` also stops the **web it spawned**, so no orphan keeps holding the port); `dsh-memory status` shows the resident pid, port, start time
|
|
141
|
+
> Stop or upgrade it explicitly with `dsh-memory stop [--port N]` / `dsh-memory restart [--port N]` (`stop` also stops the **web it spawned**, so no orphan keeps holding the port); `dsh-memory status` shows the resident pid, port, start time, whether its **version lags the installed package**, and separately the version **actually served on the port** (the lock's version is only the watchdog's self-report).
|
|
142
|
+
>
|
|
143
|
+
> ⚠️ **After upgrading, trust the served version (#23)**: `restart` no longer trusts the lock's `webPid` alone — it reaps whoever holds the port (including web processes the lock never registered; non-memory-eternal processes are only reported, never killed), and after spawning it verifies that the **served version equals the installed version**, exiting non-zero instead of printing "started" and walking away.
|
|
139
144
|
|
|
140
145
|
**MCP is a protocol, not a resident service**: the agent spawns it per session and exits when done — no "auto-start on boot" concept.
|
|
141
146
|
|
|
@@ -322,6 +327,7 @@ Diagnostics:
|
|
|
322
327
|
|
|
323
328
|
| Version | Date | Highlights |
|
|
324
329
|
|---|---|---|
|
|
330
|
+
| **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** |
|
|
325
331
|
| **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. |
|
|
326
332
|
| **v0.10.2** | 2026-10-06 | **Five long-standing issues fixed at once (#15 / #16 / #17 / #18 / #19).** ① **Per-project vaults finally work in the host (#15-1)**: the host matched `vaultProfiles[].match.workspace` against the process cwd (the directory dsh was launched from) — it now uses the **session's own workspace** from `session.header.cwd` (both capture and `memory_recall`), while CLI/MCP/hooks keep their existing cwd behaviour. ② **Distillation stops burning doomed candidates (#15-2)**: providers declaring `supportsReasoningEffort:false` no longer receive a `reasoningEffort` field (unknown = supported), and they move behind supporting candidates instead of being dropped. ③ **Truncated output is no longer misreported as a parse failure (#18)**: a dedicated `MAX_TOKENS` code (with the actual cap in the message), one automatic retry with a doubled cap, failure reporting that **always keeps the first candidate's error as the primary cause** plus a per-code summary of the rest, default `captureMaxTokens` 900 → **2000**, and a `distill-failed` tag on fallback raw cards. ④ **Standalone-web config saves are no longer lost silently (#16)**: drain errors are no longer swallowed (stderr + capture log, throttled), and the shared `memory-eternal-config.json` is now written **atomically (tmp + rename)** — the old non-atomic write could be read mid-truncation by the standalone web / MCP, fail JSON.parse and silently fall back to defaults, the pending file is **kept** with `dropped:true` + `lastError` after retries are exhausted, and the state shows up in `/diagnostics` and `/config`. ⑤ **Audit CLI (#17)**: `dsh-memory audit list/approve/reject` (multiple paths, `--json` for jq) reuses the same `setCardStatus` guard and `audit_log`; MCP gains only a **read-only** `memory_audit_list`. ⑥ **Watchdog lifecycle (#19)**: new `dsh-memory status/stop/restart`, `pkgVersion` recorded in the lock (status warns when the resident instance is outdated), truthful `spawned` vs `delegated to existing pid` host logs, a clear notice when `watchdogAutoSpawn` is off but an instance is still running, `dsh-memory stop` also stops the **web server the watchdog spawned** (on Windows `process.kill(pid,'SIGTERM')` terminates unconditionally, so the watchdog's own exit handler never runs — the old behaviour left an orphan web holding the port; the web pid is recorded in the lock and confirmed against the process command line before killing, avoiding pid-reuse mistakes), plus README fixes for the contradictory default and the "restart turns it off" promise. |
|
|
327
333
|
| **v0.10.1** | 2026-10-02 | **Fixes "export works, import brings in 0 cards".** Root cause: "Export JSON" wrote a **bare array**, while `/import` only read `payload.cards` — a bare array was silently treated as an empty backup (`ok:true` / `imported:0` / `skipped:0`), so the UI only ever said "Import complete: 0". ① `/import` now accepts both a **bare array** (backups from v0.10.0 and earlier) and an **envelope object** `{format, formatVersion, exportedAt, count, cards}`; an unrecognized shape returns **400 with a readable reason**, an empty file returns a `warning`. ② Export now writes the envelope (older builds only read `payload.cards`, so the envelope stays compatible with them) and keeps each card's `status` / `store`. ③ The import dedup baseline is now a **pre-import snapshot** (new `dedupAgainst` option on `writeCard`) — otherwise near-identical cards inside one backup dedup against each other and swallow part of the batch (a backup is the source of truth). ④ The import response adds `total` / `skipped` / `quarantined` / `failed[]` (with a per-card reason), and the toast reads "Import complete: N / of M · pending review K · skipped D"; a run that imports nothing is shown as a failure. ⑤ New `tests/import-roundtrip.test.mjs` (6 checks). ⑥ **A broken install now speaks plainly (issue #14)**: a copy installed from the plugin marketplace can be missing its `web/` assets, and the sidebar then showed a raw `{"ok":false,"error":"ENOENT …"}` page. Now a missing `index.html` with `app.js` still present is covered by a **built-in shell** that boots the UI anyway (self-healing), while a missing `app.js` returns a JS snippet that paints the diagnosis into `#root` plus a page stating which file is gone, its absolute path, the package version and how to reinstall. Both cases land in the auto-capture log (`app.js` → `fail`, health turns red; `index.html` only → `warn`), and the host self-checks the same way at startup (shared predicates and wording in the new `lib/web-assets.js`, with 6 checks in `tests/web-assets.test.mjs`). ⑦ `appendCaptureLog` no longer fails silently when the `DSH_HOME` directory does not exist. ⑧ **DSH compatibility widened to `>=0.1.5-alpha.2 <0.3.0-0`**: the old `<0.2.0` upper bound excludes DSH `0.2.0-rc.x` under plain semver (DSH's guard uses `includePrerelease:true`, so installs still worked, but the declaration did not match reality) — the range now covers the whole 0.1/0.2 line while `<0.3.0-0` still blocks 0.3.0 and its prereleases; `dsh.compatibility` gains `"0.2.0-rc.2": "compatible"` (verified on the official desktop build 0.2.0-rc.2). ⑨ **Fixed three third-party plugin manifests that were invalid JSON**: the closing quote of `description` in `.claude-plugin` / `.codex-plugin` / `.cursor-plugin` had been swallowed by a double-encoding accident (last touched in v0.9.23); DSH never reads those files, so nothing surfaced, but installing through Claude Code / Codex / Cursor **always failed to parse**. They are now valid UTF-8 JSON with `version` bumped to 0.10.1, plus a new **shipped-files health guard** `tests/manifests.test.mjs` (3 checks: every shipped JSON parses / the three manifests' `name`+`version` match package.json / no encoding-accident mojibake in shipped text). Measured on a real **661-card bare-array backup (3.9 MB): 0 cards before the fix → 661 after** (654 into the main store + 7 into quarantine). `npm test` now runs **187 checks** |
|
package/README.md
CHANGED
|
@@ -122,8 +122,11 @@ dsh-memory serve [--port 7999] # 前台跑 web
|
|
|
122
122
|
dsh-memory open # ensure web 存活 + 开浏览器
|
|
123
123
|
dsh-memory watchdog [--port 7799] # 看门狗保活 web(独立进程)
|
|
124
124
|
dsh-memory status [--json] # 看常驻 watchdog:pid / 端口 / 版本 / 是否存活
|
|
125
|
+
# 以及**端口上真正在服务的版本**(锁里的版本号只是 watchdog 自述)
|
|
125
126
|
dsh-memory stop [--port 7799] # 停止常驻 watchdog(配置改动不会自动停;连它拉起的 web 一起停)
|
|
127
|
+
# 停完会再问一次端口占用者,仍被占着就明确告警
|
|
126
128
|
dsh-memory restart [--port 7799] # 停止并重起 watchdog(升级后换新版)
|
|
129
|
+
# 按端口占用者兜底收掉锁里没登记的旧 web,起完自检端口上服务的版本
|
|
127
130
|
dsh-memory audit list [--status pending|rejected|approved|deleted|all] [--limit N] [--json]
|
|
128
131
|
# 列出卡片;总数不受 --limit 影响,--limit 只限制显示条数(默认全部)
|
|
129
132
|
# all = 审核队列(pending + rejected),不含 approved / deleted
|
|
@@ -148,7 +151,12 @@ dsh-memory audit reject <卡片路径...> --reason "..." # 人工批量驳回
|
|
|
148
151
|
> ⚠️ **同端口不会自动替换(#19)**:看门狗是**独立进程且故意不杀**(多会话共用一个),所以
|
|
149
152
|
> 关闭 `watchdogAutoSpawn` **不会**停掉已经在跑的那个,改 `webCheckIntervalMs`/`webMaxRestart` 也不会生效。
|
|
150
153
|
> 要停止 / 换新版请显式执行 `dsh-memory stop [--port N]` 与 `dsh-memory restart [--port N]`(`stop` 会**连它拉起的 web 一起停**,不留占端口的孤儿);
|
|
151
|
-
> `dsh-memory status` 会显示常驻实例的 pid
|
|
154
|
+
> `dsh-memory status` 会显示常驻实例的 pid、端口、启动时间与**版本是否落后于本机安装的包**,并单独报出**端口上真正服务的版本**
|
|
155
|
+
> (锁文件里的版本号只是 watchdog 自述,端口上跑旧代码时它不会变)。
|
|
156
|
+
>
|
|
157
|
+
> ⚠️ **升级后请以「端口实际服务版本」为准(#23)**:`restart` 现在不只信锁里登记的 web 子进程,
|
|
158
|
+
> 还会按**端口占用者**兜底清理(锁里没登记的旧 web 也会被识别并收掉,非本插件的进程只告警不动手),
|
|
159
|
+
> 并在起完之后自检「端口上服务的版本 = 本机版本」——对不上就以非 0 退出码报失败,不再打印「已启动」就完事。
|
|
152
160
|
|
|
153
161
|
**MCP 是协议不是常驻服务**:agent 开会话才 spawn,用完即退,没有「开机自启」一说。
|
|
154
162
|
|
|
@@ -358,6 +366,7 @@ curl http://127.0.0.1:7999/memory-eternal/api/web-info # 独立 web 是否活
|
|
|
358
366
|
|
|
359
367
|
| 版本 | 日期 | 关键改动 |
|
|
360
368
|
|---|---|---|
|
|
369
|
+
| **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 项** |
|
|
361
370
|
| **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。 |
|
|
362
371
|
| **v0.10.2** | 2026-10-06 | **五个存量 issue 集中修复(#15 / #16 / #17 / #18 / #19)**。① **按项目选库在宿主侧终于生效(#15-1)**:宿主过去拿进程 cwd 去匹配 `vaultProfiles[].match.workspace`,而那是「启动 dsh 的目录」——现在从 `session.header.cwd` 取**会话自己的工作区**(capture 与 memory_recall 两条路径都传),CLI/MCP/hooks 行为不变。② **蒸馏不再选错 provider(#15-2)**:声明 `supportsReasoningEffort:false` 的候选不再被传 `reasoningEffort`(能力未知一律按支持处理),并把它们排到支持者之后但**不删除**,避免兜底全无。③ **输出截断不再伪装成解析失败(#18)**:新增独立错误码 `MAX_TOKENS`(含实际上限值),撞上限自动以双倍上限重试同一候选;兜底上报改为**始终以第一候选为主因** + 其余错误按码汇总(不再被最后一个候选覆盖);默认 `captureMaxTokens` 900 → **2000**;失败兜底的原文卡加 `distill-failed` 标签便于过滤。④ **独立 Web 端保存配置不再静默丢失(#16)**:drain 报错不再被 `catch {}` 吞掉(进 stderr + 自动沉淀日志,5 分钟去重节流),共享配置 `memory-eternal-config.json` 改为 **tmp + rename 原子写**(原先非原子写会在截断窗口内被独立 web / MCP 读到半截内容 → JSON.parse 失败 → 设置静默回落成默认值),重试耗尽后**保留 pending 文件**并标注 `dropped:true` + `lastError`(不再删除用户改动),失败状态进 `/diagnostics` 与 `/config` 响应。⑤ **审核中心有了 CLI(#17)**:`dsh-memory audit list/approve/reject`(多路径批量、`--json` 便于 jq 筛选、复用同一套 `setCardStatus` 守卫与 `audit_log`);MCP 侧只新增**只读**的 `memory_audit_list`,不暴露 approve/reject。⑥ **看门狗有了生命周期(#19)**:新增 `dsh-memory status/stop/restart`,锁文件写入 `pkgVersion`(status 会提示「常驻实例是旧版」),宿主日志按抢锁结果如实打印 `spawned` / `delegated to existing pid`(不再每次都谎报 spawned),关闭 `watchdogAutoSpawn` 时明确提示既有实例仍在跑;并且 `dsh-memory stop` 会**连同该 watchdog 拉起的 web 一起停**(Windows 上 `process.kill(pid,'SIGTERM')` 是无条件终止,watchdog 的退出处理器根本不会执行 —— 旧写法会在 stop 后留下一个占着端口的孤儿 web;web 子进程 pid 现在记在锁里,杀之前还会用进程命令行确认确实是 `web.js`,防 pid 复用误杀);README 修正「默认值自相矛盾」与「重启即可关闭」的错误承诺。 |
|
|
363
372
|
| **v0.10.1** | 2026-10-02 | **修复「导出 OK、导入 0 张」**。根因:客户端「导出JSON」写出的是**裸数组**,而 `/import` 只读 `payload.cards` —— 裸数组被静默当成空备份(`ok:true` / `imported:0` / `skipped:0`),UI 只显示「导入完成:0」,看不出是格式不匹配。① `/import` 现在同时接受**裸数组**(v0.10.0 及以前的备份)与**信封对象** `{format, formatVersion, exportedAt, count, cards}`;形状不认识 → **400 + 人话原因**,空文件 → `warning`,坏 JSON → 原有的解析失败提示;② 导出改为**带信封**(老版本只认 `payload.cards`,信封对老版本也互通)并保留每张卡的 `status` / `store`;③ 导入去重基线改为**导入前快照**(`writeCard` 新增 `dedupAgainst`)—— 否则同一份备份里互相近似的卡会在写入过程中互相判重、被自己人吞掉一批(备份是真相源);④ 导入应答补 `total` / `skipped` / `quarantined` / `failed[]`(含每条未导入原因),提示语改为「导入完成:N 张 / 文件共 M 张 · 待审核 K · 跳过 D」,一张都没进来时按**失败样式**提示;⑤ 新增 `tests/import-roundtrip.test.mjs`(6 项:裸数组 / 信封 / 导出→导入全量往返 / 重复导入如实报重复 / 坏形状 / 审核守卫不可绕过)。⑥ **装坏了也要说人话(issue #14)**:从插件市场装出来的副本可能缺 `web/` 静态资源,那时侧边栏「记忆」只会弹一页 `{"ok":false,"error":"ENOENT …"}` —— 现在缺 `index.html` 但 `app.js` 还在就用**内置外壳**把界面拉起来(自救),缺 `app.js` 则回一段把诊断画进 `#root` 的 JS + 一页含「缺哪个文件 / 绝对路径 / 包版本 / 怎么重装」的说明;两种缺失都写进自动沉淀日志(`app.js` → `fail` 健康态亮红、只缺 `index.html` → `warn`),宿主启动时也自检一次(判据与措辞由新增的 `lib/web-assets.js` 统一,附 `tests/web-assets.test.mjs` 6 项)。⑦ 顺带修 `appendCaptureLog` 在 `DSH_HOME` 目录不存在时静默失败(诊断信息连一条都留不下)。⑧ **DSH 兼容范围放宽到 `>=0.1.5-alpha.2 <0.3.0-0`**:旧上界 `<0.2.0` 按 semver 会把 DSH `0.2.0-rc.x` 排除在外(DSH 守卫用 `includePrerelease:true`,实际装得上,但声明与事实不符)——现在覆盖整个 0.1/0.2 线,`<0.3.0-0` 仍挡住 0.3.0 及其预发布;`dsh.compatibility` 同步补 `"0.2.0-rc.2": "compatible"`(本机在官方桌面版 0.2.0-rc.2 上实测运行)。⑨ **修三个第三方插件清单长期非法 JSON**:`.claude-plugin` / `.codex-plugin` / `.cursor-plugin` 的 `description` 收尾引号被双重编码吃掉(最后改动停在 v0.9.23),DSH 不走这三个文件所以一直没暴露,但 Claude Code / Codex / Cursor 侧安装**必然解析失败**;现已重写为合法 UTF-8 JSON、`version` 跟到 0.10.1,并新增**随包体检守卫** `tests/manifests.test.mjs`(3 项:随包 JSON 全部可解析 / 三清单 `name`+`version` 与 package.json 一致 / 随包文本无编码事故乱码)。实测用户 **661 张真实备份(裸数组、3.9MB):修复前导入 0 张 → 修复后 661 张**(654 进主库 + 7 进隔离区)。`npm test` 共 **187 项** |
|
package/bin/dsh-memory.mjs
CHANGED
|
@@ -122,34 +122,52 @@ async function main() {
|
|
|
122
122
|
return
|
|
123
123
|
}
|
|
124
124
|
case 'status': {
|
|
125
|
-
const { watchdogStatus } = await import('../lib/watchdog.js')
|
|
125
|
+
const { watchdogStatus, probeServedVersion, findPortListener, looksLikeOurWeb } = await import('../lib/watchdog.js')
|
|
126
126
|
const port = argOf('--port')
|
|
127
127
|
const st = watchdogStatus({ port: port || undefined })
|
|
128
|
-
|
|
128
|
+
// issue #23:锁里的 pkgVersion 是「看门狗自述」,与端口上真正跑着的代码无关 ——
|
|
129
|
+
// 升级后旧 web 仍占端口时它会骗人地报新版本。这里直接问端口上服务的那个进程。
|
|
130
|
+
const probePort = Number(port) || (st.watchdogs[0] && Number(st.watchdogs[0].port)) || 0
|
|
131
|
+
const served = probePort ? await probeServedVersion(probePort) : ''
|
|
132
|
+
const occupant = probePort && !served ? await findPortListener(probePort) : null
|
|
133
|
+
// 旧版(< v0.10.4)的 /overview 里**没有 version 字段**,所以探不到版本不等于「不是我们的服务」。
|
|
134
|
+
// 这正是 v0.10.3 → v0.10.4 升级时的常见现场,必须说清楚,否则用户会以为端口空着。
|
|
135
|
+
const oursNoVersion = Boolean(occupant && occupant.pid && looksLikeOurWeb(occupant.command))
|
|
136
|
+
if (has('--json')) { console.log(JSON.stringify({ ok: true, ...st, servedPort: probePort, servedVersion: served, portOccupant: occupant, portOccupantIsOurs: oursNoVersion }, null, 2)); return }
|
|
129
137
|
console.log(`锁文件:${st.lockPath}`)
|
|
130
138
|
console.log(`本机包版本:${st.pkgVersion || '?'}`)
|
|
139
|
+
if (probePort) {
|
|
140
|
+
if (served) console.log(`端口 ${probePort} 实际服务版本:${served}`)
|
|
141
|
+
else if (oursNoVersion) console.log(`端口 ${probePort} 上跑的是本插件的 web(pid ${occupant.pid}),但它不会自报版本 —— 说明是 v0.10.4 之前的旧版,升级尚未生效 → 执行 dsh-memory restart --port ${probePort}`)
|
|
142
|
+
else console.log(`端口 ${probePort} 上没有本插件的服务在响应${occupant && occupant.pid ? `(被 pid ${occupant.pid} 占用:${occupant.command || '命令行未知'})` : ''}`)
|
|
143
|
+
}
|
|
131
144
|
if (!st.watchdogs.length) { console.log('锁文件里没有登记的看门狗(没有常驻实例)。'); return }
|
|
132
145
|
for (const w of st.watchdogs) {
|
|
133
146
|
console.log(`- pid ${w.pid} · 端口 ${w.port} · ${w.alive ? '运行中' : '已退出(陈旧记录)'}${w.pkgVersion ? ' · v' + w.pkgVersion : ''}${w.startedAt ? ' · 启动于 ' + w.startedAt : ''}${w.vault ? ' · vault ' + w.vault : ''}`)
|
|
134
147
|
if (w.webPid) console.log(` web 子进程:pid ${w.webPid}(端口 ${w.webPort || w.port},${w.webAlive ? '运行中' : '已退出'})`)
|
|
135
148
|
if (w.versionMismatch) console.log(` ⚠ 常驻实例是旧版本(v${w.pkgVersion} ≠ 本机 v${st.pkgVersion})→ 执行 dsh-memory restart --port ${w.port} 替换`)
|
|
149
|
+
if (probePort && Number(w.port) === probePort && served && st.pkgVersion && served !== st.pkgVersion) {
|
|
150
|
+
console.log(` ⚠ 端口 ${probePort} 上真正服务的是 v${served}(本机 v${st.pkgVersion})—— 升级尚未生效,执行 dsh-memory restart --port ${probePort}`)
|
|
151
|
+
}
|
|
136
152
|
}
|
|
137
153
|
return
|
|
138
154
|
}
|
|
139
155
|
case 'stop': {
|
|
140
156
|
const { stopWatchdogs } = await import('../lib/watchdog.js')
|
|
141
157
|
const port = argOf('--port')
|
|
142
|
-
|
|
158
|
+
// checkPort:停完再问一次「端口上还有谁在服务」,把「停了却还占着端口」显式说出来(#23)
|
|
159
|
+
const out = await stopWatchdogs({ port: port || undefined, checkPort: true })
|
|
143
160
|
if (has('--json')) { console.log(JSON.stringify({ ok: out.failed.length === 0, ...out }, null, 2)); return }
|
|
144
161
|
if (!out.stopped.length && !out.failed.length && !out.webStopped.length) console.log('没有需要停止的常驻看门狗(锁里没有活着的实例)。')
|
|
145
162
|
if (out.stopped.length) console.log(`已停止看门狗:pid ${out.stopped.join(', ')}`)
|
|
146
163
|
if (out.webStopped.length) console.log(`已停止它拉起的 web:pid ${out.webStopped.join(', ')}`)
|
|
164
|
+
for (const w of out.warnings || []) console.error('⚠ ' + w)
|
|
147
165
|
if (out.failed.length) { console.error(`未能停止:pid ${out.failed.join(', ')}(可能无权限,请手动任务管理器 / kill 处理)`); process.exitCode = 1 }
|
|
148
166
|
return
|
|
149
167
|
}
|
|
150
168
|
case 'restart': {
|
|
151
169
|
// 显式替换常驻实例(#19):同端口已有实例时新进程只会让位,所以必须先停再起。
|
|
152
|
-
const { stopWatchdogs } = await import('../lib/watchdog.js')
|
|
170
|
+
const { stopWatchdogs, findPortListener, waitForServedVersion, probeServedVersion, looksLikeOurWeb, currentPkgVersion } = await import('../lib/watchdog.js')
|
|
153
171
|
const { nodeBinary, childEnv } = await import('../lib/node-bin.js')
|
|
154
172
|
const { defaultVaultDir } = await import('../lib/capture-run.js')
|
|
155
173
|
const { spawn } = await import('node:child_process')
|
|
@@ -158,10 +176,28 @@ async function main() {
|
|
|
158
176
|
const interval = Number(argOf('--interval')) || 5000
|
|
159
177
|
const maxRestart = Number(argOf('--max-restart')) || 10
|
|
160
178
|
const vaultRoot = argOf('--vault') ? path.resolve(argOf('--vault')) : defaultVaultDir()
|
|
161
|
-
|
|
179
|
+
// 停的时候不只信锁里登记的 webPid —— 锁可能没写 / 写了却枚举不到,正是
|
|
180
|
+
// 「restart 报成功、端口上还是旧代码」的漏网处(issue #23):直接按端口占用者兜底收掉。
|
|
181
|
+
const stopped = await stopWatchdogs({ port, checkPort: true, forcePortOccupant: true })
|
|
162
182
|
if (stopped.failed.length) { console.error(`旧实例未能停止:pid ${stopped.failed.join(', ')},放弃重启`); process.exitCode = 1; return }
|
|
163
183
|
if (stopped.stopped.length) console.log(`已停止旧实例:pid ${stopped.stopped.join(', ')}`)
|
|
164
184
|
if (stopped.webStopped && stopped.webStopped.length) console.log(`已停止旧实例拉起的 web:pid ${stopped.webStopped.join(', ')}`)
|
|
185
|
+
if (stopped.portStopped && stopped.portStopped.length) console.log(`已停止占着端口 ${port} 的旧 web(锁里没登记):pid ${stopped.portStopped.join(', ')}`)
|
|
186
|
+
for (const w of stopped.warnings || []) console.error('⚠ ' + w)
|
|
187
|
+
// 端口必须真的空出来:否则新 web 只能退到 port+1,旧代码继续在目标端口上服务
|
|
188
|
+
let left = await findPortListener(port)
|
|
189
|
+
if (left && left.pid && looksLikeOurWeb(left.command)) {
|
|
190
|
+
try { process.kill(left.pid, 'SIGTERM') } catch { /* 无权限只能报错 */ }
|
|
191
|
+
const releaseDeadline = Date.now() + 5000
|
|
192
|
+
while (Date.now() < releaseDeadline && (left = await findPortListener(port))) await new Promise((r) => setTimeout(r, 250))
|
|
193
|
+
}
|
|
194
|
+
left = await findPortListener(port)
|
|
195
|
+
if (left && left.pid) {
|
|
196
|
+
console.error(`端口 ${port} 仍被 pid ${left.pid} 占用(${left.command || '命令行未知'}),新实例无法接管该端口,已中止重启。`)
|
|
197
|
+
console.error(`请先结束该进程,或用 --port 换一个端口:dsh-memory restart --port <其它端口>`)
|
|
198
|
+
process.exitCode = 1
|
|
199
|
+
return
|
|
200
|
+
}
|
|
165
201
|
const bin = nodeBinary()
|
|
166
202
|
const script = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'lib', 'watchdog.js')
|
|
167
203
|
const wd = spawn(bin, [script, '--port', String(port), '--interval', String(interval), '--max-restart', String(maxRestart), '--vault', vaultRoot], {
|
|
@@ -169,6 +205,18 @@ async function main() {
|
|
|
169
205
|
})
|
|
170
206
|
wd.unref()
|
|
171
207
|
console.log(`已在后台启动新看门狗 pid=${wd.pid} port=${port} vault=${vaultRoot}`)
|
|
208
|
+
// 起完必须自检「端口上真正服务的版本」(issue #23 建议 2):只看 spawn 成功就报
|
|
209
|
+
// 「已启动」,正是升级静默不生效时骗人的那一步 —— 端口上服务的版本必须等于本机版本。
|
|
210
|
+
const expect = currentPkgVersion()
|
|
211
|
+
const check = await waitForServedVersion(port, expect, { timeoutMs: 20000 })
|
|
212
|
+
if (check.ok) {
|
|
213
|
+
console.log(`自检通过:端口 ${port} 正在服务 v${check.version}(等待 ${Math.round(check.waitedMs / 1000)}s)`)
|
|
214
|
+
} else {
|
|
215
|
+
const served = check.version || (await probeServedVersion(port)) || ''
|
|
216
|
+
console.error(`自检失败:端口 ${port} 上服务的版本是 ${served || '(无响应)'},本机是 v${expect || '?'} —— 升级可能未生效。`)
|
|
217
|
+
console.error(`排查:dsh-memory status --port ${port};若端口被别的进程占着,先结束它再 restart。`)
|
|
218
|
+
process.exitCode = 1
|
|
219
|
+
}
|
|
172
220
|
return
|
|
173
221
|
}
|
|
174
222
|
case 'audit': {
|
package/index.js
CHANGED
|
@@ -30,7 +30,7 @@ import { migrateFromMarkdown, setAuditConfig, backupDb } from './lib/db.js'
|
|
|
30
30
|
import { evaluateStall, MAX_STAMPS } from './lib/stall.js'
|
|
31
31
|
import { resolveVaultDir, currentWorkspace } from './lib/vault-resolve.js'
|
|
32
32
|
import { createHub } from './lib/sse.js'
|
|
33
|
-
import { summarizeTurn, summarizeTurnDetailed, routeCandidates, extractLastTurn, sliceNewEvents, sessionEvents, sessionEventApi, createCaptureHealth, resolveRoute, captureCard, captureUpdate, pickNeighbors, hasUsableContent, deriveTitle, looksTruncated, DEFAULT_CAPTURE_MAX_TOKENS, MAX_CAPTURE_MAX_TOKENS } from './lib/capture.js'
|
|
33
|
+
import { summarizeTurn, summarizeTurnDetailed, routeCandidates, extractLastTurn, sliceNewEvents, sessionEvents, sessionEventApi, createCaptureHealth, resolveRoute, captureCard, captureUpdate, pickNeighbors, hasUsableContent, deriveTitle, looksTruncated, maxTokenLadder, DEFAULT_CAPTURE_MAX_TOKENS, MAX_CAPTURE_MAX_TOKENS } from './lib/capture.js'
|
|
34
34
|
import { createApi, json, encodeBody } from './lib/api.js'
|
|
35
35
|
import { appendCaptureLog, readCaptureLog, rotateCaptureLog } from './lib/capture-log.js'
|
|
36
36
|
import { missingWebAssets, missingAssetsReason } from './lib/web-assets.js'
|
|
@@ -362,10 +362,32 @@ export function apply(ctx, config) {
|
|
|
362
362
|
try { console.error('[memory-eternal] ' + why) } catch { /* 日志失败无妨 */ }
|
|
363
363
|
try { logCapture('system', dropped ? 'fail' : 'warn', why) } catch { /* 日志失败无妨 */ }
|
|
364
364
|
}
|
|
365
|
+
// issue #21:dsh-tui 一类宿主的能力守卫会在 settings.update **内部**的 describe() 上抛
|
|
366
|
+
// 「root.events.emit is unavailable from a plugin activation」(守卫只区分「第三方插件 vs
|
|
367
|
+
// host 内部代码」,识别不出「官方服务在插件调用链里执行的合法动作」,上游已开 issue)。
|
|
368
|
+
// 关键点:抛错 ≠ 没写进去 —— 只认有没有抛错,会把其实已经生效的改动重试 5 次后标成
|
|
369
|
+
// dropped,日志里还报失败。applyPatchVerified 在抛错后回读确认(带守卫的宿主上这是
|
|
370
|
+
// 唯一能自证的信号),判据与实现都在 lib/config-sync.js 里(有单测)。
|
|
371
|
+
//
|
|
372
|
+
// 注意:把 drain 推迟到 activation 之后**解决不了**这个问题 —— 上游 dsh-tui 用
|
|
373
|
+
// AsyncLocalStorage.run(token) 包住每次插件回调,并 patch 了 Fiber._execute,定时器 /
|
|
374
|
+
// fs.watch 的每一次回调都带着 activation token(见 ccch1mneyyy/dsh-TUI#1348 的定位)。
|
|
375
|
+
const applyPendingPatch = async (patch) => {
|
|
376
|
+
const { applyPatchVerified } = await import('./lib/config-sync.js')
|
|
377
|
+
return applyPatchVerified(patch, {
|
|
378
|
+
apply: (p) => settings.update(p, undefined),
|
|
379
|
+
read: () => settings.get() ?? {},
|
|
380
|
+
onRepaired: (error) => {
|
|
381
|
+
try {
|
|
382
|
+
console.error('[memory-eternal] settings.update 抛错但回读确认改动已生效(宿主守卫误报),按成功处理:' + String((error && error.message) || error))
|
|
383
|
+
} catch { /* 日志失败无妨 */ }
|
|
384
|
+
},
|
|
385
|
+
})
|
|
386
|
+
}
|
|
365
387
|
const drainPending = async () => {
|
|
366
388
|
try {
|
|
367
389
|
const { drainPendingConfig } = await import('./lib/config-sync.js')
|
|
368
|
-
const applied = await drainPendingConfig(process.env,
|
|
390
|
+
const applied = await drainPendingConfig(process.env, applyPendingPatch)
|
|
369
391
|
if (applied) {
|
|
370
392
|
lastDrainError = ''
|
|
371
393
|
syncConfigFile()
|
|
@@ -591,12 +613,15 @@ export function apply(ctx, config) {
|
|
|
591
613
|
for (let i = 0; i < routes.length; i++) {
|
|
592
614
|
route = routes[i]
|
|
593
615
|
let detailed = await summarizeTurnDetailed(llm, route, text, { signal: AbortSignal.timeout(45000), existing: neighbors, maxTokens })
|
|
594
|
-
// #18 建议 3
|
|
595
|
-
//
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
616
|
+
// #18 建议 3 + #24:撞输出上限不是「模型坏了」,把上限一路翻倍到 schema 上限。
|
|
617
|
+
// 旧实现只翻倍**一次**:1200 → 2400 仍不够就照样退成原文卡,尽管 4000 本来装得下
|
|
618
|
+
// (issue #24 里 9 次失败中有一半正是被这一限制吃掉的)。
|
|
619
|
+
let prevBudget = maxTokens
|
|
620
|
+
for (const bigger of maxTokenLadder(maxTokens)) {
|
|
621
|
+
logCapture(sessionId, 'listen', `输出撞到 maxTokens=${prevBudget} 上限 → 以 ${bigger} 重试同一候选(${route.provider}/${route.model})`)
|
|
599
622
|
detailed = await summarizeTurnDetailed(llm, route, text, { signal: AbortSignal.timeout(45000), existing: neighbors, maxTokens: bigger })
|
|
623
|
+
prevBudget = bigger
|
|
624
|
+
if (!(detailed.failure && detailed.failure.code === 'MAX_TOKENS')) break
|
|
600
625
|
}
|
|
601
626
|
if (detailed.card !== undefined) { result = detailed.card; failure = null; break }
|
|
602
627
|
if (detailed.failure) {
|
package/lib/api.js
CHANGED
|
@@ -50,7 +50,15 @@ export function createApi(deps = {}) {
|
|
|
50
50
|
switch (route) {
|
|
51
51
|
case '/overview': {
|
|
52
52
|
await ensureVault(vaultRoot)
|
|
53
|
-
|
|
53
|
+
// version = **当前正在服务这个端口的进程**加载的包版本(issue #23):
|
|
54
|
+
// 磁盘上升级了、端口上跑的还是旧进程时,只有这个字段说得清真相(锁文件里的
|
|
55
|
+
// pkgVersion 是 watchdog 自己写的,与端口上真正服务的代码无关)。
|
|
56
|
+
json(res, 200, {
|
|
57
|
+
ok: true,
|
|
58
|
+
vaultDir: vaultRoot,
|
|
59
|
+
version: (getDshInfo() || {}).version || process.env.MEMORY_ETERNAL_VERSION || '',
|
|
60
|
+
...(await overview(vaultRoot)),
|
|
61
|
+
})
|
|
54
62
|
return
|
|
55
63
|
}
|
|
56
64
|
case '/cards': {
|
|
@@ -62,24 +70,36 @@ export function createApi(deps = {}) {
|
|
|
62
70
|
const offset = Math.max(Number(query.get('offset')) || 0, 0)
|
|
63
71
|
const sort = query.get('sort') === 'title' ? 'title' : 'recent'
|
|
64
72
|
const agent = query.get('agent') || '' // 服务端署名过滤(如 agent=deepseek-harness)
|
|
65
|
-
// all = 全部(含 pending/rejected,排除回收站);默认/approved =
|
|
66
|
-
//
|
|
73
|
+
// all = 全部(含 pending/rejected,排除回收站);默认/approved = 已审核;
|
|
74
|
+
// deleted = 回收站(隔离区里 status=deleted,与 /card、/recycle/list 保持一致,见 issue #22)。
|
|
75
|
+
// 白名单(P0 修复):除下面四种取值外一律按 approved 处理,未知 status 不再可能越权列未审核卡。
|
|
76
|
+
const KNOWN_STATUS = ['all', 'approved', 'pending', 'rejected', 'deleted']
|
|
67
77
|
const want = status === 'all' ? ['approved', 'pending', 'rejected']
|
|
68
78
|
: status === 'pending' ? ['pending']
|
|
69
79
|
: status === 'rejected' ? ['rejected']
|
|
80
|
+
: status === 'deleted' ? ['deleted']
|
|
70
81
|
: ['approved']
|
|
82
|
+
// issue #22:unknown status 以前被静默当成 approved,调用方拿到「看起来正常」的主库内容
|
|
83
|
+
// 却无从察觉(total 与 /recycle/list 打架就是这么来的)。这里回显**实际生效**的 status,
|
|
84
|
+
// 并对无法识别的取值显式置 unknownStatus:true —— 回落行为保留(不破坏老调用方),但不再沉默。
|
|
85
|
+
const appliedStatus = status === 'all' ? 'all'
|
|
86
|
+
: status === 'pending' ? 'pending'
|
|
87
|
+
: status === 'rejected' ? 'rejected'
|
|
88
|
+
: status === 'deleted' ? 'deleted'
|
|
89
|
+
: 'approved'
|
|
90
|
+
const unknownStatus = !KNOWN_STATUS.includes(status)
|
|
71
91
|
if (q.trim()) {
|
|
72
92
|
// 检索是相关性排序:先把命中集合取齐,再在内存里分页(保证 total 与顺序一致)
|
|
73
93
|
const hits = await search(vaultRoot, q, { limit: 500 })
|
|
74
94
|
const hitPaths = new Set(hits.map((h) => h.path))
|
|
75
95
|
let cards = (await listCards(vaultRoot, { status: want, kind, agent })).filter((c) => hitPaths.has(c.path))
|
|
76
96
|
const total = cards.length
|
|
77
|
-
json(res, 200, { ok: true, cards: cards.slice(offset, offset + limit), total, offset, limit })
|
|
97
|
+
json(res, 200, { ok: true, cards: cards.slice(offset, offset + limit), total, offset, limit, appliedStatus, requestedStatus: status, unknownStatus })
|
|
78
98
|
return
|
|
79
99
|
}
|
|
80
100
|
const total = await countCards(vaultRoot, { status: want, kind, agent })
|
|
81
101
|
const cards = await listCards(vaultRoot, { status: want, kind, agent, limit, offset, sort })
|
|
82
|
-
json(res, 200, { ok: true, cards, total, offset, limit })
|
|
102
|
+
json(res, 200, { ok: true, cards, total, offset, limit, appliedStatus, requestedStatus: status, unknownStatus })
|
|
83
103
|
return
|
|
84
104
|
}
|
|
85
105
|
case '/card': {
|
|
@@ -468,19 +488,29 @@ export function createApi(deps = {}) {
|
|
|
468
488
|
}
|
|
469
489
|
if (bad.length) return json(res, 400, { ok: false, error: '字段值不合法:' + bad.join('、'), fields: bad })
|
|
470
490
|
if (!Object.keys(clean).length) return json(res, 400, { ok: false, error: '没有可写入的字段' })
|
|
471
|
-
const { writePendingConfig, readPendingConfig } = await import('./config-sync.js')
|
|
491
|
+
const { writePendingConfig, readPendingConfig, waitPendingOutcome } = await import('./config-sync.js')
|
|
472
492
|
// #16 建议 4:上一份改动是否已被判定失败?是的话这次不能再报「一切正常」——
|
|
473
493
|
// 否则用户第 N 次点击保存,看到的仍是成功,而问题一直在(其实是 settings.update 在报错)。
|
|
474
494
|
const before = readPendingConfig(process.env)
|
|
475
495
|
const out = writePendingConfig(process.env, clean)
|
|
476
496
|
hub.broadcast('config', { at: out.at, source: 'standalone', applied: Object.keys(clean) })
|
|
497
|
+
// issue #21 建议 3:别急着报「一切正常」。宿主在跑时对 pending 文件有 fs.watch
|
|
498
|
+
// (毫秒级),稍等一下回读,就能分清「真的应用了」还是「只是排上队了」——
|
|
499
|
+
// 以前无论哪种都回 ok:true + 一句泛泛的 note,用户只会在几秒后从日志里发现失败。
|
|
500
|
+
const outcome = await waitPendingOutcome(process.env, { timeoutMs: 800 })
|
|
477
501
|
const stale = before && (before.dropped || before.lastError)
|
|
502
|
+
const note = outcome === 'applied'
|
|
503
|
+
? '宿主已应用该改动。'
|
|
504
|
+
: outcome === 'failed'
|
|
505
|
+
? '改动已写入,但宿主侧应用失败(详见诊断信息)。'
|
|
506
|
+
: '已写入待应用:DSH 端会自动同步(DSH 未运行时下次启动生效)'
|
|
478
507
|
return json(res, 200, {
|
|
479
508
|
ok: true, applied: Object.keys(clean), pending: Object.keys(clean), retried: false, at: out.at,
|
|
509
|
+
pendingOutcome: outcome,
|
|
480
510
|
warning: stale
|
|
481
511
|
? '上一次写入待应用时连续失败(' + (before.lastError || '原因未记录') + ');本次已重新写入并重试,如仍不生效请查看「反馈异常」里的诊断信息'
|
|
482
512
|
: undefined,
|
|
483
|
-
note
|
|
513
|
+
note,
|
|
484
514
|
})
|
|
485
515
|
}
|
|
486
516
|
let pendingD = null
|
package/lib/capture.js
CHANGED
|
@@ -268,14 +268,18 @@ export async function summarizeTurnDetailed(llm, route, conversation, opts = {})
|
|
|
268
268
|
}
|
|
269
269
|
}
|
|
270
270
|
const out = assembler.blocks().filter((b) => b.type === 'text').map((b) => b.text).join('').trim()
|
|
271
|
-
const parsed =
|
|
272
|
-
if (!parsed) {
|
|
271
|
+
const parsed = parseCaptureJsonDetailed(out)
|
|
272
|
+
if (!parsed.card) {
|
|
273
273
|
const why = out ? '模型输出无法解析为 JSON' : '模型返回空文本'
|
|
274
274
|
// 括号没闭合 = 截断的强特征:即便宿主没给 max-tokens finish,也把方向指对(#18 建议 1)
|
|
275
275
|
const hint = out && looksTruncatedJson(out) ? '(JSON 括号未闭合,疑似输出被截断)' : ''
|
|
276
|
-
|
|
276
|
+
// #24:JSON.parse 的原始报错(如 Bad control character in string literal at position N)
|
|
277
|
+
// 以前被两个裸 catch{} 完全吞掉,排查只能靠猜;现场摘要也从「只留前 160 字」改成首尾各留一段。
|
|
278
|
+
const raw = parsed.error ? '(JSON.parse 原始报错:' + parsed.error + ')' : ''
|
|
279
|
+
return { failure: { code: 'UNPARSEABLE_OUTPUT', message: why + hint + raw + (out ? ':' + describeOutputExcerpt(out) : '') } }
|
|
277
280
|
}
|
|
278
|
-
|
|
281
|
+
// repaired=true = 走了「字符串内裸控制字符转义」这条容错路径(#24),排障时能看到它真的生效了。
|
|
282
|
+
return { card: parsed.card, repaired: parsed.repaired }
|
|
279
283
|
}
|
|
280
284
|
|
|
281
285
|
/**
|
|
@@ -369,24 +373,65 @@ export function looksTruncatedJson(text) {
|
|
|
369
373
|
return depth > 0 || inStr
|
|
370
374
|
}
|
|
371
375
|
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
376
|
+
/**
|
|
377
|
+
* 把 JSON **字符串值内部**未转义的裸控制字符改成合法转义(issue #24)。
|
|
378
|
+
*
|
|
379
|
+
* 模型写 markdown 正文时经常把换行写成真实的 U+000A(而不是 `\n` 两字符),
|
|
380
|
+
* 这种输出括号是闭合的(looksTruncatedJson 得 false),却因为「字符串内不得出现
|
|
381
|
+
* 未转义控制字符」被 JSON.parse 必然拒绝 —— 旧实现只做「截 { … }」一层容错,
|
|
382
|
+
* 于是稳定地误报成 UNPARSEABLE_OUTPUT,真正的知识卡全丢进原文卡兜底。
|
|
383
|
+
*
|
|
384
|
+
* 只在字符串内部动手:字符串外的换行/制表符是合法 JSON 空白,改了反而是破坏。
|
|
385
|
+
* 标准转义(\n \r \t \b \f)用短写,其余控制字符转 \u00XX。
|
|
386
|
+
*
|
|
387
|
+
* @returns {{ text: string, repaired: boolean }} repaired=true 表示确实改过字符
|
|
388
|
+
*/
|
|
389
|
+
export function repairJsonControlChars(text) {
|
|
390
|
+
const s = String(text || '')
|
|
391
|
+
let out = ''
|
|
392
|
+
let inStr = false
|
|
393
|
+
let esc = false
|
|
394
|
+
let repaired = false
|
|
395
|
+
for (let i = 0; i < s.length; i++) {
|
|
396
|
+
const ch = s[i]
|
|
397
|
+
if (!inStr) {
|
|
398
|
+
if (ch === '"') inStr = true
|
|
399
|
+
out += ch
|
|
400
|
+
continue
|
|
401
|
+
}
|
|
402
|
+
if (esc) { esc = false; out += ch; continue }
|
|
403
|
+
if (ch === '\\') { esc = true; out += ch; continue }
|
|
404
|
+
if (ch === '"') { inStr = false; out += ch; continue }
|
|
405
|
+
const code = ch.charCodeAt(0)
|
|
406
|
+
if (code < 0x20) {
|
|
407
|
+
repaired = true
|
|
408
|
+
out += code === 0x0a ? '\\n'
|
|
409
|
+
: code === 0x0d ? '\\r'
|
|
410
|
+
: code === 0x09 ? '\\t'
|
|
411
|
+
: code === 0x08 ? '\\b'
|
|
412
|
+
: code === 0x0c ? '\\f'
|
|
413
|
+
: '\\u' + code.toString(16).padStart(4, '0')
|
|
414
|
+
continue
|
|
388
415
|
}
|
|
416
|
+
out += ch
|
|
389
417
|
}
|
|
418
|
+
return { text: out, repaired }
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* 失败现场摘要:首尾各留一段,中间省略多少字写清楚。
|
|
423
|
+
*
|
|
424
|
+
* 旧实现 `out.slice(0, 160)` 把正文后半段全砍了,而模型输出恰好经常被切在
|
|
425
|
+
* `"body": "# ...` 的半截处 —— 看上去更像「输出断了」,把排查引向错误方向(#24)。
|
|
426
|
+
*/
|
|
427
|
+
export function describeOutputExcerpt(text, { head = 120, tail = 120 } = {}) {
|
|
428
|
+
const s = String(text || '')
|
|
429
|
+
if (s.length <= head + tail + 20) return s
|
|
430
|
+
return s.slice(0, head) + '…(中间省略 ' + (s.length - head - tail) + ' 字)…' + s.slice(-tail)
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** 解析出来的对象 → 知识卡(返回 null = 解析成功但内容不合格 / 不值得保存)。 */
|
|
434
|
+
function cardFromParsedJson(parsed) {
|
|
390
435
|
if (!parsed || parsed.save === false) return { save: false }
|
|
391
436
|
// 追加已有卡片:append_to 命中的优先级最高
|
|
392
437
|
if (parsed.append_to) {
|
|
@@ -409,6 +454,69 @@ export function parseCaptureJson(text) {
|
|
|
409
454
|
return finishCard(parsed, title, body)
|
|
410
455
|
}
|
|
411
456
|
|
|
457
|
+
/**
|
|
458
|
+
* 解析模型输出:返回「卡片 + 解析诊断」。
|
|
459
|
+
*
|
|
460
|
+
* 候选串顺序(#24):原样 → 截 \`{ … }\` → 各自的「字符串内裸控制字符已转义」版本。
|
|
461
|
+
* 只要有一个 JSON.parse 成功就停在那一个上(解析成功但语义不合格时不再换候选 ——
|
|
462
|
+
* 换候选只会把同一份内容再判一遍,与旧实现行为一致)。
|
|
463
|
+
*
|
|
464
|
+
* @returns {{ card: object|null, parsed: boolean, error: string, repaired: boolean }}
|
|
465
|
+
* parsed=false → 所有候选都没能 JSON.parse(error 里是**原始**报错,不再吞掉)
|
|
466
|
+
* parsed=true → JSON 解析成功;card=null 表示内容不合格 / 不值得保存
|
|
467
|
+
*/
|
|
468
|
+
export function parseCaptureJsonDetailed(text) {
|
|
469
|
+
if (!text) return { card: null, parsed: false, error: '空输出', repaired: false }
|
|
470
|
+
// 去掉可能的 ```json 围栏
|
|
471
|
+
const cleaned = String(text).replace(/^```(?:json)?\s*/i, '').replace(/```\s*$/, '').trim()
|
|
472
|
+
const bases = [cleaned]
|
|
473
|
+
const start = cleaned.indexOf('{')
|
|
474
|
+
const end = cleaned.lastIndexOf('}')
|
|
475
|
+
if (start >= 0 && end > start) bases.push(cleaned.slice(start, end + 1))
|
|
476
|
+
const candidates = []
|
|
477
|
+
for (const base of bases) candidates.push({ text: base, repaired: false })
|
|
478
|
+
for (const base of bases) {
|
|
479
|
+
const fixed = repairJsonControlChars(base)
|
|
480
|
+
if (fixed.repaired) candidates.push({ text: fixed.text, repaired: true })
|
|
481
|
+
}
|
|
482
|
+
let lastError = ''
|
|
483
|
+
for (const candidate of candidates) {
|
|
484
|
+
let parsed
|
|
485
|
+
try {
|
|
486
|
+
parsed = JSON.parse(candidate.text)
|
|
487
|
+
} catch (error) {
|
|
488
|
+
lastError = String((error && error.message) || error)
|
|
489
|
+
continue
|
|
490
|
+
}
|
|
491
|
+
return { card: cardFromParsedJson(parsed), parsed: true, error: '', repaired: candidate.repaired }
|
|
492
|
+
}
|
|
493
|
+
return { card: null, parsed: false, error: lastError || '输出不是合法 JSON', repaired: false }
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** 兼容旧签名:解析成卡片;解析失败 / 内容不合格返回 null。 */
|
|
497
|
+
export function parseCaptureJson(text) {
|
|
498
|
+
return parseCaptureJsonDetailed(text).card
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* 撞到输出上限后的重试上限序列(#24):一路翻倍到 schema 上限,不再只翻一次。
|
|
503
|
+
*
|
|
504
|
+
* 旧实现 index.js 里只做一次 倍=2 的重试:1200 → 2400 仍不够就退成原文卡,
|
|
505
|
+
* 尽管 4000 本来装得下。抽成纯函数,便于单测这条序列。
|
|
506
|
+
*
|
|
507
|
+
* @returns {number[]} 例如 start=1200, cap=4000 → [2400, 4000];start>=cap → []
|
|
508
|
+
*/
|
|
509
|
+
export function maxTokenLadder(start, cap = MAX_CAPTURE_MAX_TOKENS) {
|
|
510
|
+
const out = []
|
|
511
|
+
const ceiling = Number(cap) || MAX_CAPTURE_MAX_TOKENS
|
|
512
|
+
let cur = Number(start) || DEFAULT_CAPTURE_MAX_TOKENS
|
|
513
|
+
while (cur < ceiling) {
|
|
514
|
+
cur = Math.min(cur * 2, ceiling)
|
|
515
|
+
out.push(cur)
|
|
516
|
+
}
|
|
517
|
+
return out
|
|
518
|
+
}
|
|
519
|
+
|
|
412
520
|
/** 标题是否「像标题」:≥4 个字母/数字字符,且不是碎片(错位开头 / 未完结结尾)。 */
|
|
413
521
|
export function isUsableTitle(title) {
|
|
414
522
|
const t = String(title || '').trim()
|
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(rt,{zh:Gt,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 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:Ae}),(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:Ae}),(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:Ae}),(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 gt="0.10.
|
|
301
|
+
`;function Ca(t){t.effect(()=>t.locale.register(rt,{zh:Gt,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 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:Ae}),(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:Ae}),(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:Ae}),(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 gt="0.10.4";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"),[_,ze]=(0,n.useState)("all"),[H,re]=(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"}),[ie,O]=(0,n.useState)(()=>new Set([H])),b=(0,n.useCallback)(y=>{O(E=>E.has(y)?E:new Set(E).add(y)),re(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),[Ce,he]=(0,n.useState)(!1),[be,De]=(0,n.useState)(!1),[ve,Ie]=(0,n.useState)(!0),[Ze,Ke]=(0,n.useState)(null),[Se,Xe]=(0,n.useState)(0),Qe=(0,n.useCallback)(()=>Xe(y=>y+1),[]),Fe=i?.quarantine?.pending??0,Ne=i?.quarantine?.deleted??0,ee=(0,n.useRef)(null),[je,we]=(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 V=ue.append===!0;try{V?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 ke=await(await fetch(`${U}/cards?${ce.toString()}`)).json();if(ke.ok){let He=ke.cards||[];v(Be=>V?[...Be,...He.filter(We=>!Be.some(yt=>yt.path===We.path))]:He),we(Number(ke.total)||He.length)}}catch(ce){T(String(ce&&ce.message?ce.message:ce))}finally{V?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(),Qe()):$({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)},G=y=>{A(y),N(y,z,_)};(0,n.useEffect)(()=>{H==="cards"&&N(k,z,_)},[_,H,Se,f]),(0,n.useEffect)(()=>{if(H!=="cards"||P||d||S.length>=je)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()},[H,S,je,P,d,k,z,_,N]);let te=async y=>{try{let E=y.status;if(!E&&y.path)try{let xe=((await fetch(`${U}/cards?status=all&q=${encodeURIComponent(y.title||"")}&limit=50`).then(ke=>ke.json())).cards||[]).find(ke=>ke.path===y.path);xe&&(E=xe.status)}catch{}let J=E&&E!=="approved"?`&status=${encodeURIComponent(E)}`:"",ue=await fetch(`${U}/card?path=${encodeURIComponent(y.path||y.id)}${J}`),V=await ue.json();V.ok?fe({path:V.path,title:y.title,text:V.text}):T(V.error||`HTTP ${ue.status}`)}catch(E){T(String(E&&E.message?E.message:E))}},Oe=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 V,ce,xe;if(y==="json"){ce="memory-vault.json",xe="application/json";let Be=ue.cards.map(We=>({path:We.path,title:We.title,kind:We.kind,status:We.status,store:We.store,text:We.text}));V=JSON.stringify({format:ue.format||"memory-eternal-vault",formatVersion:ue.formatVersion||1,exportedAt:ue.exportedAt||new Date().toISOString(),count:Be.length,cards:Be},null,2)}else ce="memory-vault.md",xe="text/markdown;charset=utf-8",V=ue.cards.map(Be=>Be.text.trim()).filter(Boolean).join(`
|
|
302
302
|
|
|
303
303
|
---
|
|
304
304
|
|
package/lib/config-sync.js
CHANGED
|
@@ -125,6 +125,82 @@ export function watchPendingConfig(env, onChange, { debounceMs = 60 } = {}) {
|
|
|
125
125
|
}
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
/**
|
|
129
|
+
* patch 里的每个键是否都已在配置快照里生效(浅比较,数组/对象按 JSON 串等价判断)。
|
|
130
|
+
*
|
|
131
|
+
* 用途(issue #21):dsh-tui 一类宿主的能力守卫会在 settings.update **内部**的
|
|
132
|
+
* describe() 上抛「root.events.emit is unavailable from a plugin activation」,
|
|
133
|
+
* 但写入本身可能已经提交。只认「有没有抛错」,就会把其实已经生效的改动重试 5 次
|
|
134
|
+
* 后标成 dropped,日志里还报成失败 —— 回读是唯一能自证的信号。
|
|
135
|
+
*
|
|
136
|
+
* @returns {boolean} 三个条件全部满足才为 true:patch 非空、config 是对象、每个键取值一致
|
|
137
|
+
*/
|
|
138
|
+
export function patchApplied(config, patch) {
|
|
139
|
+
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) return false
|
|
140
|
+
if (!config || typeof config !== 'object') return false
|
|
141
|
+
const keys = Object.keys(patch)
|
|
142
|
+
if (!keys.length) return false
|
|
143
|
+
for (const key of keys) {
|
|
144
|
+
const want = JSON.stringify(patch[key])
|
|
145
|
+
const got = config[key] === undefined ? undefined : JSON.stringify(config[key])
|
|
146
|
+
if (want !== got) return false
|
|
147
|
+
}
|
|
148
|
+
return true
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* 应用一次 patch,并在抛错后**回读确认**(issue #21 的核心修复)。
|
|
153
|
+
*
|
|
154
|
+
* 背景:dsh-tui 一类宿主的能力守卫会在 settings.update **内部**的 describe() 上抛
|
|
155
|
+
* 「root.events.emit is unavailable from a plugin activation」,但写入可能已经提交。
|
|
156
|
+
* 只看有没有抛错,就会把其实已经生效的改动当成失败重试 5 次、最后标成 dropped。
|
|
157
|
+
*
|
|
158
|
+
* @param {object} patch
|
|
159
|
+
* @param {object} opts
|
|
160
|
+
* @param {(patch:object)=>any} opts.apply 真正的写入(可能抛)
|
|
161
|
+
* @param {()=>object} opts.read 回读配置快照
|
|
162
|
+
* @param {(config:object, patch:object)=>boolean} [opts.verify] 判定是否已生效(默认浅比较)
|
|
163
|
+
* @param {(error:any)=>void} [opts.onRepaired] 命中「抛错但其实已生效」时的回调(记日志用)
|
|
164
|
+
* @returns {Promise<{applied:boolean, repaired:boolean}>} repaired=由回读判定成功
|
|
165
|
+
*/
|
|
166
|
+
export async function applyPatchVerified(patch, { apply, read, verify = patchApplied, tries = 3, delayMs = 120, onRepaired } = {}) {
|
|
167
|
+
try {
|
|
168
|
+
await apply(patch)
|
|
169
|
+
return { applied: true, repaired: false }
|
|
170
|
+
} catch (error) {
|
|
171
|
+
const rounds = Math.max(1, Number(tries) || 1)
|
|
172
|
+
for (let i = 0; i < rounds; i++) {
|
|
173
|
+
let snapshot = null
|
|
174
|
+
try { snapshot = read() } catch { snapshot = null }
|
|
175
|
+
if (verify(snapshot, patch)) {
|
|
176
|
+
if (typeof onRepaired === 'function') { try { onRepaired(error) } catch { /* 回调异常不影响结果 */ } }
|
|
177
|
+
return { applied: true, repaired: true }
|
|
178
|
+
}
|
|
179
|
+
if (i < rounds - 1 && Number(delayMs) > 0) await new Promise((r) => setTimeout(r, Number(delayMs)))
|
|
180
|
+
}
|
|
181
|
+
throw error
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* 等一下看这次写入的最终去向(issue #21 建议 3):宿主在跑时对 pending 文件有
|
|
187
|
+
* fs.watch(毫秒级),稍微等一会儿就能分辨「真的应用了」和「只是排上队了」。
|
|
188
|
+
*
|
|
189
|
+
* @returns {Promise<'applied'|'failed'|'queued'>}
|
|
190
|
+
* applied = pending 文件已被宿主消费;failed = 已被标记 dropped;
|
|
191
|
+
* queued = 到超时还在(DSH 没运行 / 还没轮到它)
|
|
192
|
+
*/
|
|
193
|
+
export async function waitPendingOutcome(env = process.env, { timeoutMs = 800, intervalMs = 100 } = {}) {
|
|
194
|
+
const deadline = Date.now() + Math.max(0, Number(timeoutMs) || 0)
|
|
195
|
+
for (;;) {
|
|
196
|
+
const pending = readPendingConfig(env)
|
|
197
|
+
if (!pending) return 'applied'
|
|
198
|
+
if (pending.dropped) return 'failed'
|
|
199
|
+
if (Date.now() >= deadline) return 'queued'
|
|
200
|
+
await new Promise((r) => setTimeout(r, Math.max(20, Number(intervalMs) || 100)))
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
128
204
|
/**
|
|
129
205
|
* 取出并应用一次待应用改动:apply(patch) 成功后删除文件;失败保留(下轮重试)。
|
|
130
206
|
*
|
package/lib/watchdog.js
CHANGED
|
@@ -168,9 +168,13 @@ export function watchdogStatus({ env = process.env, port } = {}) {
|
|
|
168
168
|
* @param {(pid:number)=>boolean} [opts.kill] 注入用(单测)
|
|
169
169
|
* @param {(pid:number)=>boolean} [opts.isAlive] 注入用(单测)
|
|
170
170
|
* @param {()=>Promise<Array<{pid:number,port:number,command:string}>>} [opts.listProcesses] 注入用(单测)
|
|
171
|
-
* @
|
|
171
|
+
* @param {boolean} [opts.checkPort] 停完是否再问一次「端口上还有谁在服务」(issue #23)
|
|
172
|
+
* @param {boolean} [opts.forcePortOccupant] 是否连「按端口找到、且确认是本插件的 web」也一起收掉
|
|
173
|
+
* @param {(port:number)=>Promise<{pid:number,command:string}|null>} [opts.findListener] 注入用(单测)
|
|
174
|
+
* @returns {Promise<{stopped:number[], webStopped:number[], failed:number[], skipped:number[],
|
|
175
|
+
* portOwners:Array<{port:number,pid:number,command:string}>, portStopped:number[], warnings:string[]}>}
|
|
172
176
|
*/
|
|
173
|
-
export async function stopWatchdogs({ env = process.env, port, timeoutMs = 5000, kill, isAlive, sleep, listProcesses } = {}) {
|
|
177
|
+
export async function stopWatchdogs({ env = process.env, port, timeoutMs = 5000, kill, isAlive, sleep, listProcesses, checkPort = false, forcePortOccupant = false, findListener } = {}) {
|
|
174
178
|
const want = port === undefined || port === null || port === '' ? null : Number(port)
|
|
175
179
|
const alive = isAlive || isPidAlive
|
|
176
180
|
const killFn = kill || ((pid) => { try { process.kill(pid, 'SIGTERM'); return true } catch { return false } })
|
|
@@ -204,9 +208,14 @@ export async function stopWatchdogs({ env = process.env, port, timeoutMs = 5000,
|
|
|
204
208
|
// watchdog 的 stop() 处理器不会执行 —— 不在这里收,就会留下一个占着端口的孤儿 web,
|
|
205
209
|
// 而「stop 了却还占端口」正是 issue #19 抱怨的那类不可控状态。
|
|
206
210
|
const webStopped = []
|
|
211
|
+
const webSkipped = [] // issue #23 建议 4:要收但没敢收的,必须留一行可查的话,不能静默
|
|
207
212
|
for (const target of wantedWeb) {
|
|
208
213
|
if (!alive(target.pid)) continue
|
|
209
|
-
if (confirmedWeb && !confirmedWeb.has(target.pid)) {
|
|
214
|
+
if (confirmedWeb && !confirmedWeb.has(target.pid)) {
|
|
215
|
+
skipped.push(target.pid)
|
|
216
|
+
webSkipped.push(target.pid)
|
|
217
|
+
continue
|
|
218
|
+
}
|
|
210
219
|
if (!killFn(target.pid)) { failed.push(target.pid); continue }
|
|
211
220
|
webStopped.push(target.pid)
|
|
212
221
|
}
|
|
@@ -215,11 +224,54 @@ export async function stopWatchdogs({ env = process.env, port, timeoutMs = 5000,
|
|
|
215
224
|
if (alive(pid)) { if (pid !== 0) failed.push(pid); continue }
|
|
216
225
|
releaseWatchdogLock({ env, pid })
|
|
217
226
|
}
|
|
227
|
+
|
|
228
|
+
// 端口兜底(issue #23):锁里登记的 webPid 可能压根没写、或者写了却枚举不到,
|
|
229
|
+
// 于是「已停止旧实例」打印了、端口上跑的还是旧代码(升级静默不生效)。
|
|
230
|
+
// 收紧到「直接问操作系统谁占着端口」,不再只信锁;可选地把它一起收掉。
|
|
231
|
+
const warnings = []
|
|
232
|
+
const portOwners = []
|
|
233
|
+
const portStopped = []
|
|
234
|
+
if (webSkipped.length) {
|
|
235
|
+
warnings.push(`锁里登记的 web 子进程 pid ${webSkipped.join(', ')} 未被收掉:在进程列表里没确认到它是 web.js(防 pid 复用误杀,宁可不杀)——若端口仍被占着,请手工结束它`)
|
|
236
|
+
}
|
|
237
|
+
if (checkPort) {
|
|
238
|
+
const ports = want === null
|
|
239
|
+
? [...new Set(slots.map((w) => Number(w.port) || 0))].filter((p) => p > 0)
|
|
240
|
+
: [want]
|
|
241
|
+
const probeListener = async (p) => {
|
|
242
|
+
try { return findListener ? await findListener(p) : await findPortListener(p) } catch { return null }
|
|
243
|
+
}
|
|
244
|
+
for (const p of ports) {
|
|
245
|
+
const found = await probeListener(p)
|
|
246
|
+
if (found && found.pid) portOwners.push({ port: p, pid: Number(found.pid), command: String(found.command || '') })
|
|
247
|
+
}
|
|
248
|
+
if (portOwners.length && forcePortOccupant) {
|
|
249
|
+
for (const occ of portOwners) {
|
|
250
|
+
if (!looksLikeOurWeb(occ.command)) {
|
|
251
|
+
warnings.push(`端口 ${occ.port} 被 pid ${occ.pid} 占用,但它看起来不是本插件拉起的 web(${occ.command || '命令行未知'})→ 不自动终止,请人工确认`)
|
|
252
|
+
continue
|
|
253
|
+
}
|
|
254
|
+
if (!killFn(occ.pid)) { warnings.push(`端口 ${occ.port} 上的旧 web(pid ${occ.pid})未能终止,请手工 kill`); continue }
|
|
255
|
+
const webDeadline = Date.now() + Math.max(0, Number(timeoutMs) || 0)
|
|
256
|
+
while (Date.now() < webDeadline && alive(occ.pid)) await wait(100)
|
|
257
|
+
portStopped.push(occ.pid)
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
// 收尾复检:这时还占着端口的,就是需要人介入的
|
|
261
|
+
for (const p of ports) {
|
|
262
|
+
const found = await probeListener(p)
|
|
263
|
+
if (!found || !found.pid) continue
|
|
264
|
+
warnings.push(`端口 ${p} 仍被 pid ${found.pid} 占用(${found.command || '命令行未知'});status 显示的版本可能不是端口上真正服务的版本`)
|
|
265
|
+
}
|
|
266
|
+
}
|
|
218
267
|
return {
|
|
219
268
|
stopped: [...new Set(stopped.filter((p) => !failed.includes(p)))],
|
|
220
269
|
webStopped: [...new Set(webStopped.filter((p) => !failed.includes(p)))],
|
|
221
270
|
failed: [...new Set(failed)],
|
|
222
271
|
skipped: [...new Set(skipped)],
|
|
272
|
+
portOwners,
|
|
273
|
+
portStopped: [...new Set(portStopped)],
|
|
274
|
+
warnings,
|
|
223
275
|
}
|
|
224
276
|
}
|
|
225
277
|
|
|
@@ -327,6 +379,116 @@ export async function probe(port, timeoutMs = 1000) {
|
|
|
327
379
|
})
|
|
328
380
|
}
|
|
329
381
|
|
|
382
|
+
/**
|
|
383
|
+
* 探测端口上**实际服务**的那个进程加载的包版本(issue #23)。
|
|
384
|
+
*
|
|
385
|
+
* 为什么必须问端口而不是读锁文件:锁里的 pkgVersion 是 watchdog 自己写的,
|
|
386
|
+
* 与端口上真正跑着的代码无关 —— 升级后旧 web 仍占着端口时,status 会骗人地报新版本。
|
|
387
|
+
* /overview 的 version 字段由被探测进程自己(lib/web.js 模块加载时读 package.json)给出。
|
|
388
|
+
*
|
|
389
|
+
* @returns {Promise<string>} 取不到返回空串
|
|
390
|
+
*/
|
|
391
|
+
export async function probeServedVersion(port, timeoutMs = 1200) {
|
|
392
|
+
try {
|
|
393
|
+
const info = await probe(port, timeoutMs)
|
|
394
|
+
return info && typeof info.version === 'string' ? info.version : ''
|
|
395
|
+
} catch { return '' }
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* 轮询等到端口上的服务版本等于期望值(restart 后的自检,issue #23 建议 2)。
|
|
400
|
+
*
|
|
401
|
+
* @returns {Promise<{ok:boolean, version:string, waitedMs:number}>}
|
|
402
|
+
*/
|
|
403
|
+
export async function waitForServedVersion(port, expect, { timeoutMs = 15000, intervalMs = 500, probeFn } = {}) {
|
|
404
|
+
const probeVersion = probeFn || probeServedVersion
|
|
405
|
+
const start = Date.now()
|
|
406
|
+
let version = ''
|
|
407
|
+
for (;;) {
|
|
408
|
+
version = String((await probeVersion(port)) || '')
|
|
409
|
+
if (version && version === String(expect || '')) return { ok: true, version, waitedMs: Date.now() - start }
|
|
410
|
+
if (Date.now() - start >= Math.max(0, Number(timeoutMs) || 0)) return { ok: false, version, waitedMs: Date.now() - start }
|
|
411
|
+
await new Promise((r) => setTimeout(r, Math.max(50, Number(intervalMs) || 500)))
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* 解析 `lsof -Fpcn` 的输出(每个进程一段:p<pid> / c<command> / n<name>)。
|
|
417
|
+
* 纯函数,便于单测(POSIX 侧探测端口占用者用它)。
|
|
418
|
+
*/
|
|
419
|
+
export function parseLsofFields(text) {
|
|
420
|
+
const out = []
|
|
421
|
+
let cur = null
|
|
422
|
+
for (const raw of String(text || '').split(/\r?\n/)) {
|
|
423
|
+
const line = raw.trim()
|
|
424
|
+
if (!line) continue
|
|
425
|
+
const tag = line[0]
|
|
426
|
+
const val = line.slice(1)
|
|
427
|
+
if (tag === 'p') {
|
|
428
|
+
if (cur) out.push(cur)
|
|
429
|
+
cur = { pid: Number(val) || 0, command: '', name: '' }
|
|
430
|
+
} else if (!cur) {
|
|
431
|
+
continue
|
|
432
|
+
} else if (tag === 'c') cur.command = val
|
|
433
|
+
else if (tag === 'n') cur.name = val
|
|
434
|
+
}
|
|
435
|
+
if (cur) out.push(cur)
|
|
436
|
+
return out.filter((p) => p.pid > 0)
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* 查「谁正占着这个端口」(不依赖锁文件,issue #23 建议 3)。
|
|
441
|
+
*
|
|
442
|
+
* 锁里没登记 webPid 的旧实例、或 pid 复用导致的登记失效,只有直接问操作系统
|
|
443
|
+
* 才能发现 —— 这正是「restart 打印成功、端口上还是旧代码」的漏网处。
|
|
444
|
+
*
|
|
445
|
+
* @param {number} port
|
|
446
|
+
* @param {{exec?: (cmd:string,args:string[])=>Promise<string>}} [opts]
|
|
447
|
+
* @returns {Promise<{pid:number, command:string}|null>}
|
|
448
|
+
*/
|
|
449
|
+
export async function findPortListener(port, { exec } = {}) {
|
|
450
|
+
const p = Number(port)
|
|
451
|
+
if (!Number.isFinite(p) || p <= 0) return null
|
|
452
|
+
const runExec = exec || defaultExec()
|
|
453
|
+
try {
|
|
454
|
+
if (process.platform === 'win32') {
|
|
455
|
+
const out = await runExec('powershell', ['-NoProfile', '-Command', `Get-NetTCPConnection -LocalPort ${p} -State Listen -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess`])
|
|
456
|
+
const pid = Number(String(out || '').split(/\r?\n/).map((s) => s.trim()).find((s) => /^\d+$/.test(s))) || 0
|
|
457
|
+
if (!pid) return null
|
|
458
|
+
const cmd = await runExec('powershell', ['-NoProfile', '-Command', `(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}" | Select-Object -ExpandProperty CommandLine)`])
|
|
459
|
+
return { pid, command: String(cmd || '').trim() }
|
|
460
|
+
}
|
|
461
|
+
const fields = await runExec('lsof', ['-nP', `-iTCP:${p}`, '-sTCP:LISTEN', '-Fpcn'])
|
|
462
|
+
const list = parseLsofFields(fields)
|
|
463
|
+
if (!list.length) return null
|
|
464
|
+
return { pid: list[0].pid, command: (list[0].command + (list[0].name ? ' ' + list[0].name : '')).trim() }
|
|
465
|
+
} catch { return null }
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* 这个占用者看起来是不是本插件拉起的 web(只按命令行判定,用于「收不收」的门禁)。
|
|
470
|
+
*
|
|
471
|
+
* 判据必须收紧到「本插件自己的 web.js」:早先只要命令行里出现 `web.js` 就算命中,
|
|
472
|
+
* 而 `node web.js` 是别的项目里极常见的写法 —— `dsh-memory restart --port N` 时,
|
|
473
|
+
* 恰好占着 N 端口的外来进程会被**误杀**(issue #23 的修复不能引入新的误杀)。
|
|
474
|
+
* 现在只在两种情况下放行:
|
|
475
|
+
* ① 命令行里带 `memory-eternal`(包名 / 安装路径 / pnpm store 路径);
|
|
476
|
+
* ② 命令行里出现**本进程加载的** lib/web.js 绝对路径 —— 同一次安装拉起的 web 必然命中
|
|
477
|
+
* (watchdog spawn 用的就是同一个 WEB_JS,见 spawnWeb)。
|
|
478
|
+
* 认不出来的一律只告警、不动手:宁可不杀,也不误杀。
|
|
479
|
+
*
|
|
480
|
+
* @param {string} command 占用者的命令行
|
|
481
|
+
* @param {{webJs?: string}} [opts] webJs 默认本文件同级的 web.js(单测注入用)
|
|
482
|
+
*/
|
|
483
|
+
export function looksLikeOurWeb(command, { webJs = WEB_JS } = {}) {
|
|
484
|
+
const s = String(command || '')
|
|
485
|
+
if (!s) return false
|
|
486
|
+
if (/memory-eternal/i.test(s)) return true
|
|
487
|
+
const norm = (p) => String(p || '').replace(/\\/g, '/').toLowerCase()
|
|
488
|
+
const target = norm(webJs)
|
|
489
|
+
return Boolean(target) && norm(s).includes(target)
|
|
490
|
+
}
|
|
491
|
+
|
|
330
492
|
/** 探测端口是否有任何 TCP 监听(即便不是我们的服务也占用)。 */
|
|
331
493
|
export async function probeTcp(port, timeoutMs = 600) {
|
|
332
494
|
return new Promise((resolve) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "memory-eternal",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.4",
|
|
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"
|
|
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"
|
|
68
68
|
},
|
|
69
69
|
"dependencies": {
|
|
70
70
|
"@deepseek-ai/schemastery": "^3.18.1"
|
package/scripts/release.mjs
CHANGED
|
@@ -88,6 +88,20 @@ if (!SKIP_TESTS) {
|
|
|
88
88
|
say('(--skip-tests:跳过测试)')
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
// 构建产物一致性闸门(v0.10.3 踩过的坑):
|
|
92
|
+
// npm test 会跑 build.mjs 重新生成 lib/client.js 与 web/app.js,而这两个 bundle 内嵌了
|
|
93
|
+
// package.json 的版本号(__ME_CLIENT_VERSION__)。如果改完版本号忘了先 npm run build 就提交,
|
|
94
|
+
// 这一步会把工作区弄脏 —— 结果是 tag/仓库里的 bundle 嵌的是旧版本号,而 npm 上的却是新的。
|
|
95
|
+
// 一旦 tag 与 npm 都发出去就很难收回,所以在打 tag、发布**之前**直接拦住。
|
|
96
|
+
const dirtyAfter = run('git', ['status', '--porcelain']).out
|
|
97
|
+
if (dirtyAfter && !flag('allow-dirty')) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
'测试/构建后工作区变脏 —— 多半是改了版本号却没先 npm run build:\n' + dirtyAfter +
|
|
100
|
+
'\n处理:npm run build 后把产物一起提交,再重新跑发布。\n(确属有意请加 --allow-dirty)',
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
say('构建产物与提交一致 ✓')
|
|
104
|
+
|
|
91
105
|
// ---- 2. 发布说明 -----------------------------------------------------------
|
|
92
106
|
step(2, '准备发布说明')
|
|
93
107
|
const notesFile = opt('notes-file')
|
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 $l="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 $l="0.10.4";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),[nt,We]=(0,v.useState)(!1),[Fe,dt]=(0,v.useState)(!1),[$e,pt]=(0,v.useState)(!0),[wn,Yt]=(0,v.useState)(null),[rt,on]=(0,v.useState)(0),kn=(0,v.useCallback)(()=>on(_=>_+1),[]),Dt=o?.quarantine?.pending??0,lt=o?.quarantine?.deleted??0,se=(0,v.useRef)(null),[_t,Ge]=(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 J=Me.append===!0;try{J?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 qe=await(await fetch(`${q}/cards?${be.toString()}`)).json();if(qe.ok){let Rt=qe.cards||[];u(kt=>J?[...kt,...Rt.filter(Ot=>!kt.some(Kl=>Kl.path===Ot.path))]:Rt),Ge(Number(qe.total)||Rt.length)}}catch(be){z(String(be&&be.message?be.message:be))}finally{J?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(),kn()):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)},le=_=>{I(_),A(_,p,L)};(0,v.useEffect)(()=>{P==="cards"&&A(k,p,L)},[L,P,rt,T]),(0,v.useEffect)(()=>{if(P!=="cards"||f||S||s.length>=_t)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,_t,f,S,k,p,L,A]);let ue=async _=>{try{let K=_.status;if(!K&&_.path)try{let Ue=((await fetch(`${q}/cards?status=all&q=${encodeURIComponent(_.title||"")}&limit=50`).then(qe=>qe.json())).cards||[]).find(qe=>qe.path===_.path);Ue&&(K=Ue.status)}catch{}let oe=K&&K!=="approved"?`&status=${encodeURIComponent(K)}`:"",Me=await fetch(`${q}/card?path=${encodeURIComponent(_.path||_.id)}${oe}`),J=await Me.json();J.ok?Ie({path:J.path,title:_.title,text:J.text}):z(J.error||`HTTP ${Me.status}`)}catch(K){z(String(K&&K.message?K.message:K))}},It=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 J,be,Ue;if(_==="json"){be="memory-vault.json",Ue="application/json";let kt=Me.cards.map(Ot=>({path:Ot.path,title:Ot.title,kind:Ot.kind,status:Ot.status,store:Ot.store,text:Ot.text}));J=JSON.stringify({format:Me.format||"memory-eternal-vault",formatVersion:Me.formatVersion||1,exportedAt:Me.exportedAt||new Date().toISOString(),count:kt.length,cards:kt},null,2)}else be="memory-vault.md",Ue="text/markdown;charset=utf-8",J=Me.cards.map(kt=>kt.text.trim()).filter(Boolean).join(`
|
|
304
304
|
|
|
305
305
|
---
|
|
306
306
|
|