memory-eternal 0.10.25 → 0.10.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/README.en.md +5 -3
- package/README.md +5 -3
- package/bin/dsh-memory.mjs +35 -0
- package/lib/api.js +21 -3
- package/lib/client.js +1 -1
- package/lib/db.js +139 -1
- package/package.json +2 -2
- package/scripts/release.mjs +14 -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.27",
|
|
5
5
|
"description": "记忆核心(Memory Eternal):对话结束后自动沉淀知识卡到本地记忆库(~/.dsh/memory-vault),支持检索 / 召回 / 审核中心。",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "EternalNight996",
|
package/README.en.md
CHANGED
|
@@ -361,14 +361,16 @@ curl http://127.0.0.1:7999/memory-eternal/api/web-info # is the standalone we
|
|
|
361
361
|
|
|
362
362
|
| Version | Date | Highlights |
|
|
363
363
|
|---|---|---|
|
|
364
|
-
| **v0.10.
|
|
365
|
-
| **v0.10.
|
|
364
|
+
| **v0.10.27** | 2026-10-13 | **"Rebuild from backup" goes from guidance to something you can run (the practical way out for issue #31)**. Once a WAL header is corrupt, code cannot repair the **data** — the previous version could only tell you which backups exist and in what order to swap files; now `dsh-memory doctor --restore latest\|<name>` does it: **without `--yes` it is a dry run** (not a single file touched, and it tells you how many cards the backup holds and whether it would overwrite a writable database). Execution passes four gates: ① **validate the backup** (open read-only + `PRAGMA quick_check` + count cards); ② require an explicit `--force` when the current database is healthy **right now** (decided by a live probe, not a cached health verdict); ③ **abort** if a resident watchdog is still alive (no swapping while writers are active, unless `--force`); ④ **re-verify** afterwards (reopen + write probe + card count). The original scene is **always renamed to `*.corrupt-<timestamp>` and never deleted** (the DB plus `-wal` and `-shm`). Adds `tests/db-restore.test.mjs` (4 checks: rebuild from a broken DB / healthy DB needs force and a dry run touches nothing / dangerous sources refused / health verdict refreshed). `npm test` now runs **339 checks** |
|
|
365
|
+
| **v0.10.26** | 2026-10-13 | **Lock contention now retries asynchronously at the application level instead of using `busy_timeout` (the measured remedy for issues #31/#33)**. Local controlled experiment: while an external process held a write transaction we immediately got `database is locked`, and it **stayed locked even after that process was killed** — the old code had no waiting at all, so a single collision failed permanently (on restricted or externally-occupied drives like A: this shows up as "the whole memory database is broken"). The review rejected `PRAGMA busy_timeout` (`DatabaseSync` from `node:sqlite` is a **synchronous API**, so busy-waiting blocks the host event loop and freezes all of DSH), so we added `withDbRetry()` instead: on a lock, **yield the event loop, sleep briefly and retry** (linear backoff, up to 3 attempts), only on **write routes**; once headers have been sent retrying is explicitly forbidden (`e.noRetry`, preventing double writes), and non-lock errors (data/syntax) are tried once and rethrown unchanged. Tests cover: retry until success / one attempt for non-busy errors / `noRetry` honoured / exhaustion / a source guard against sneaking `busy_timeout` back in. `npm test` now runs **335 checks** |
|
|
366
366
|
|
|
367
367
|
<details>
|
|
368
|
-
<summary>Older release notes (v0.10.
|
|
368
|
+
<summary>Older release notes (v0.10.25 and earlier · 52 releases, click to expand)</summary>
|
|
369
369
|
|
|
370
370
|
| Version | Date | Highlights |
|
|
371
371
|
|---|---|---|
|
|
372
|
+
| **v0.10.25** | 2026-10-13 | **"Cannot be determined" no longer masquerades as "unmanaged", plus a unified UX for an unwritable DB (closing layer ③ of issue #33)**. ① **Managed state becomes three-valued**: `/version-check` now also reports `resident.managedKnown`, `lock:{path,writable,error}` and `pid` — in restricted environments (sandbox write bans, ACLs, non-system drives) where the watchdog lock file cannot be written, the panel no longer says "unmanaged" (which made users think the instance was broken and restart it repeatedly) but "**management state unknown**: the lock file cannot be written, so it cannot be determined; keep-alive usually still works", together with the real cause and path; `writeWatchdogLock` failures are no longer fully silent either (one traceable line per process). ② **Unified UX when the DB is unwritable**: write routes (delete / merge / card / refine-apply / optimize-execute / …) now **fail fast at the entrance** (503 + `DB_UNAVAILABLE` + recovery guidance), and any write path not enumerated is caught by the outermost wrapper translating raw SQLite text into the same reply; the panel shows a **cross-tab banner** at the top: "the memory database is currently not writable — card reads/writes will fail, but **settings can still be saved**". ③ `dsh-memory doctor` now reports watchdog lock writability (visible at a glance in restricted environments). `npm test` now runs **334 checks** |
|
|
373
|
+
| **v0.10.24** | 2026-10-13 | **"Same goal, different approach" now clusters too (third and final slice of issue #36)**. The two previous versions covered "merge duplicates" and "judge conflicts", but the last piece — one purpose, several workflows/codebases — is **undetectable by body similarity** ("compress with 7z", "distribute as tar.gz", "slim the npm package" read very differently yet share a goal). Approach: **the model first writes one line per card stating what problem it solves, then we cluster by goal** — goals are cached in `refine-goals.json` (each card extracted once; a second scan makes zero calls) and clustered with union-find; hits are grouped as "one goal, N cards" with two actions: **keep all** (records pairwise rulings so it stops asking) or **merge this group** (via the existing /merge; the new card enters the audit queue). *Read-only throughout — the only write is the goal cache.* Entry points: the panel (organize → 🎯 same goal, different approach) and APIs `/refine-goals`, `/refine-goals-keep`; CLI `dsh-memory refine --goals --keep --path A --path B` (the clustering itself needs a model, run it in the panel). `npm test` now runs **332 checks** |
|
|
372
374
|
| **v0.10.23** | 2026-10-13 | **Existing "injected junk cards" can be cleaned up in one click (second slice of issue #36)**. The previous version stopped such cards from being **created** (skill catalogs / `<system-reminder>` arriving as user messages were captured as knowledge after their tags were stripped), but the **backlog remained**: a scan of a real vault immediately found **32** cards unrelated to any conversation (catalog lines like `- \`code-review\`: Review the changes…`, fragments like `用户system-reminder-you-are-teammate-c…`). Adds `findInjectedCards()` (a **proposal only**, it changes nothing; criteria: injection tags present / catalog lines ≥30% of the body / under 200 characters left after stripping / a fragmentary title) and `purgeInjected()` (**goes to the recycle bin, recoverable**, reporting failures per card). Entry points: the panel (organize → 🧹 injected junk cards: scan, recycle one, recycle all) and the CLI `dsh-memory refine --junk [--purge --path P] [--json]`; API `/refine-junk`, `/refine-junk-purge`. `npm test` now runs **331 checks** |
|
|
373
375
|
| **v0.10.22** | 2026-10-13 | **"Conflict scan": similar cards may be merged, conflicts must be judged by a human (first slice of issue #36)**. Until now `/optimize` merged **blindly** on similarity (≥0.55) — yet two semantically close cards with mutually exclusive conclusions score just as high (0.93 measured), so a blind merge quietly "reconciles" the conflict, and `mergeCards` **permanently deletes** the sources. New `lib/refine.js` plus a "organize → 🧭 Conflict scan" panel section: ① **wide recall** (≥0.30, upper bound lifted to 1.0, deliberately including the near-duplicate band — the most dangerous one for blind merging); ② the model only classifies each pair as "duplicate / complement / conflict / unrelated" and states the conflict in one line plus who to keep; ③ **the human decides**: keep A / keep B / keep both / merge; the **scan never writes**, and `keepA|keepB` only moves the yielding card to the **recycle bin** (recoverable); ④ rulings are recorded in `refine-decisions.json` so the same pair is not raised again; ⑤ **safety interlock**: pairs ruled conflicting are **excluded from one-click optimize** entirely. Also fixes one source of redundancy: skill-catalog injections (`- \`skill-name\`: …`) are no longer captured as junk cards (real examples already existed in the vault). API: `/refine`, `/refine-scan`, `/refine-apply`; CLI: `dsh-memory refine` (lists candidates without calling a model; `--apply` records a ruling). `npm test` now runs **329 checks** |
|
|
374
376
|
| **v0.10.21** | 2026-10-13 | **"DB unavailable" goes from silent to visible (issue #33, scoped by review)**. ① **Health check at open**: the old code ran `PRAGMA journal_mode=WAL` and assumed success (that pragma does not throw on failure — it just returns the actual mode), so a read-only file or unwritable directory let the plugin run on silently; now the real journal mode is **read back** and a **write probe** runs (create then drop a table, in the main DB rather than temp), the verdict lands in `dbStatus()`, and an unwritable DB immediately logs the reason plus where to look. ② **`/overview` degrades instead of 500**: when the DB or directory is unavailable it no longer returns 500 (probes, the panel and scripts read 500 as "this port is broken / not our service", which historically started the restart storm and port drift); it now returns 200 with `degraded:true`, `dbError` and `db:{journalMode,writable,error}` while keeping the `vaultDir` / `version` identity fields — so "this is us, but the DB is broken" can finally be expressed separately. ③ **New `dsh-memory doctor`**: DB health self-check (real journal mode / write probe / backup list / recovery guidance) with `--json` for scripting; when the DB is unwritable or suspected corrupt it prints **guidance that never touches data** (stop writers → copy the scene aside → list backups → swap → re-verify). **Deliberately not done** (review conclusion, now guarded by tests so nobody adds it back casually): `busy_timeout` (`DatabaseSync` is a synchronous API, so waiting on a lock blocks the host event loop) and automatic WAL→DELETE downgrade (it **permanently** changes the DB mode, and a rollback journal has worse concurrency, which could worsen lock contention). `npm test` now runs **322 checks** |
|
package/README.md
CHANGED
|
@@ -393,14 +393,16 @@ curl http://127.0.0.1:7999/memory-eternal/api/web-info # 独立 web 是否活
|
|
|
393
393
|
|
|
394
394
|
| 版本 | 日期 | 关键改动 |
|
|
395
395
|
|---|---|---|
|
|
396
|
-
| **v0.10.
|
|
397
|
-
| **v0.10.
|
|
396
|
+
| **v0.10.27** | 2026-10-13 | **「损坏时从备份重建」从引导升级成可执行(issue #31 的现实出路)**。WAL 头一旦损坏,代码修不了**数据** —— 上一版只能告诉你「有哪些备份、按什么顺序替换」,现在多了 `dsh-memory doctor --restore latest\|备份名`:**不带 `--yes` 只演练**(一个文件都不动,先告诉你备份里有几张卡、会不会覆盖可写库);执行时四道闸门:① **校验备份**(只读打开 + `PRAGMA quick_check` + 数卡片);② 当前库**此刻**完好时要求显式 `--force`(用**当场探测**判,不信缓存的体检结论);③ 检测到常驻看门狗还活着就**中止**(避免边写边换,除非 `--force`);④ 恢复后**复核**(重新打开 + 写探针 + 卡片数)。原现场**一律改名 `*.corrupt-<时间戳>` 留证,绝不删除**(库 + `-wal` + `-shm` 一起)。新增 `tests/db-restore.test.mjs`(4 项:坏库重建 / 完好库需 force 且演练不动任何文件 / 拒绝危险来源 / 体检结论刷新)。`npm test` 共 **339 项** |
|
|
397
|
+
| **v0.10.26** | 2026-10-13 | **撞锁改用「应用层异步重试」,而不是 `busy_timeout`(issue #31/#33 实测对症)**。本机对照实验:外部进程持写事务时我方立刻拿到 `database is locked`,而且**对方被强杀之后仍然锁着** —— 老实现没有忙等,一撞锁就永久失败(在 A: 盘那类受限 / 被外部占用的现场,表现出来就是「整个记忆库坏了」)。评审已否决 `PRAGMA busy_timeout`(`node:sqlite` 的 `DatabaseSync` 是**同步 API**,忙等会阻塞宿主事件循环、把整个 DSH 卡住),所以改成新增 `withDbRetry()`:撞锁时**让出事件循环睡一小会儿再试**(线性退避,最多 3 次),只用**写路由**;「响应已发出」时显式禁止重试(`e.noRetry`,避免重复写),非锁类错误(数据 / 语法)只试一次并原样抛。测试覆盖:重试到成功 / 非忙错误只试一次 / `noRetry` 生效 / 试满仍失败 / 源码守卫(不许偷偷加 `busy_timeout`)。`npm test` 共 **335 项** |
|
|
398
398
|
|
|
399
399
|
<details>
|
|
400
|
-
<summary>更早的更新日志(v0.10.
|
|
400
|
+
<summary>更早的更新日志(v0.10.25 及以前 · 共 52 版,点开查看)</summary>
|
|
401
401
|
|
|
402
402
|
| 版本 | 日期 | 关键改动 |
|
|
403
403
|
|---|---|---|
|
|
404
|
+
| **v0.10.25** | 2026-10-13 | **「判不出来」不再冒充「未托管」+ 库不可写时的统一 UX(issue #33 第③层收口)**。① **托管状态变三态**:`/version-check` 增报 `resident.managedKnown`、`lock:{path,writable,error}` 与 `pid` —— 受限环境(沙箱禁写 / ACL / 非系统盘)写不进看门狗锁文件时,面板不再显示「未托管」(那会让用户以为实例坏了、反复重启),改为「**托管状态未知**:锁文件写不进去 → 无法判断;保活通常仍然有效」,并同时显示真实原因与路径;`writeWatchdogLock` 的失败也不再全静默(每个进程留一条可查的痕)。② **库不可写时统一 UX**:写路由(delete / merge / card / refine-apply / optimize-execute / …)在入口**短路径失败**(503 + `DB_UNAVAILABLE` + 恢复指引),没枚举到的写路径由最外层兜底把 SQLite 原文翻译成同一套答复;面板**跨标签顶部**明示「记忆库当前不可写 —— 卡片读写会失败,但**配置仍可保存**」。③ `dsh-memory doctor` 增报看门狗锁可写性(受限环境一眼可见)。`npm test` 共 **334 项** |
|
|
405
|
+
| **v0.10.24** | 2026-10-13 | **「同目标、不同做法」也能归到一起了(issue #36 第三块,闭环)**。前两版解决了「冗余合并」与「矛盾判别」,但最后一块 —— 同一目的、不同工作流/代码 —— 按正文相似度**根本抓不到**(「用 7z 压缩」「用 tar.gz 分发」「npm 包瘦身」字面差很远、目标却一样)。做法:**先让模型给每张卡抽一句「这张卡解决什么问题」,再按目标聚类** —— 目标抽取结果缓存在 `refine-goals.json`(同一张卡只抽一次,第二次扫描 0 次调用),聚类用并查集;命中后按「同一目标 N 张卡」成组给出,动作二选一:**都保留**(记两两决策,下次不再提示)或**合并这一组**(走现有 /merge,新卡进审核队列)。*全程只读,唯一写入是目标缓存*。入口:面板「整理 → 🎯 同目标不同做法」+ API `/refine-goals`、`/refine-goals-keep`;CLI `dsh-memory refine --goals --keep --path A --path B`(聚类本身需要模型,在面板跑)。`npm test` 共 **332 项** |
|
|
404
406
|
| **v0.10.23** | 2026-10-13 | **存量「注入垃圾卡」可一键清理(issue #36 第二刀)**。上一版修掉了**新生成**的注入垃圾卡(技能目录 / `<system-reminder>` 被当 user 消息 → 剥标签后仍被当知识沉淀),但**存量还在**:在真实库里一跑就找出 **32 张**「和对话毫无关系」的卡(`- \`code-review\`: Review the changes…` 这种目录行、`用户system-reminder-you-are-teammate-c…` 这种碎片)。新增 `findInjectedCards()`(只**提议**、不动数据;判据:含注入标签 / 技能目录条目占比 ≥30% / 剥离注入后只剩 <200 字 / 标题是碎片)与 `purgeInjected()`(**走回收站、可还原**,逐个报告失败)。入口:面板「整理 → 🧹 注入垃圾卡」(扫描存量 / 单张回收 / 全部回收)+ CLI `dsh-memory refine --junk [--purge --path P] [--json]`;API `/refine-junk`、`/refine-junk-purge`。`npm test` 共 **331 项** |
|
|
405
407
|
| **v0.10.22** | 2026-10-13 | **「矛盾扫描」:相似可以合并,矛盾必须人判断(issue #36 第一刀)**。现状是 `/optimize` 只按相似度(≥0.55)**盲合并** —— 而两张语义相近、结论互斥的卡相似度同样很高(实测 0.93),盲合并会把冲突悄悄「和解」掉,且 `mergeCards` 会**永久删除**源卡。新增 `lib/refine.js` + 客户端「整理 → 🧭 矛盾扫描」:① **宽召回**(≥0.30,上限放到 1.0,特意把近重复那一段纳入 —— 那正是盲合并最危险的区间);② 模型只判「重复 / 补充 / 矛盾 / 无关」并给一句冲突点 + 建议保留谁;③ **人拍板**:保留 A / 保留 B / 都保留 / 合并;**扫描绝不写库**,`keepA|keepB` 只把让位那张送**回收站**(可还原);④ 决策记进 `refine-decisions.json`,同一对不再重复打扰;⑤ **安全联锁**:被判为矛盾的对**绝不参与一键优化**的盲合并。另修一处冗余来源:技能目录(`- \`skill-name\`: …`)这类注入文本不再被抓成垃圾卡(实测库里已有这种卡)。API:`/refine`、`/refine-scan`、`/refine-apply`;CLI:`dsh-memory refine`(列候选不调模型,`--apply` 落裁决)。`npm test` 共 **329 项** |
|
|
406
408
|
| **v0.10.21** | 2026-10-13 | **「库不可用」从「静默运行」变成「看得清」(issue #33,按评审裁剪范围)**。① **打开即体检**:老实现执行完 `PRAGMA journal_mode=WAL` 就当成功(这条 PRAGMA 失败时**不抛错**,只把真实模式当一行返回),文件只读 / 目录不可写时插件会一路静默跑下去;现在打开后**读回真实 journal 模式 + 真写一次探针**(建表→删表,写主库而非 temp 库),结论记进 `dbStatus()`,不可写时立即把原因与排查方向打到日志。② **`/overview` 降级返回**:库/目录不可用时不再吐 500(500 会被探针、面板、脚本读成「这个端口坏了 / 不是我们的服务」,历史上正是重启风暴与端口漂移的入口),改成 200 + `degraded:true` + `dbError` + `db:{journalMode,writable,error}`,并保住 `vaultDir` / `version` 身份字段 —— 「这是我们、但库有问题」从此可以被分开表达。③ **新增 `dsh-memory doctor`**:库健康自检(真实 journal 模式 / 写探针 / 备份清单 / 恢复引导),带 `--json` 可脚本化;不可写或疑似损坏时给出**只引导、不动数据**的步骤(先停写者 → 复制现场留证 → 查备份 → 替换 → 复核)。**刻意不做**(评审结论,已写成测试守卫防止顺手加回):`busy_timeout`(`DatabaseSync` 是同步 API,撞锁会阻塞宿主事件循环)与 WAL→DELETE 自动降级(会**永久**改变库模式,且 rollback journal 并发更差、可能反过来加剧锁竞争)。`npm test` 共 **322 项** |
|
package/bin/dsh-memory.mjs
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
// 自动挂载 MCP 到已装的 agent
|
|
12
12
|
// dsh-memory sweep <dir> 挖掘 Claude Code 会话 JSONL
|
|
13
13
|
// dsh-memory doctor [--vault DIR] [--json] 库健康自检(journal 模式 / 写探针 / 备份清单 / 恢复引导)
|
|
14
|
+
// dsh-memory doctor --restore latest|备份名 [--yes] [--force] 从备份重建(不带 --yes 只演练;当前库可写时需 --force)
|
|
14
15
|
// dsh-memory refine [--vault DIR] [--json] 矛盾扫描候选(不调模型);--apply --a A --b B --action keepA|keepB|merge|keepBoth 落裁决
|
|
15
16
|
// dsh-memory refine --junk [--purge --path P] [--json] 注入垃圾卡(技能目录等注入文本沉淀成的卡):列清单 / 送回收站
|
|
16
17
|
// dsh-memory refine --goals [--keep --path A --path B] 同目标不同做法:聚类需模型(面板里跑);--keep 落"都保留"
|
|
@@ -236,6 +237,40 @@ async function main() {
|
|
|
236
237
|
}
|
|
237
238
|
case 'doctor': {
|
|
238
239
|
// ⑤ 损坏/不可写时的「引导」(issue #33):只体检、只列备份、只打印下一步 —— 绝不自动替换/删除数据。
|
|
240
|
+
// --restore:把「损坏时从备份重建」从**引导**升级成**可执行**(#31 里 WAL 头损坏的唯一现实出路)。
|
|
241
|
+
// 安全设计:不带 --yes 只做演练(dryRun,一个文件都不动);带 --yes 才真正执行,
|
|
242
|
+
// 且执行前检查「有没有常驻实例正开着这个库」(有就先停下来,除非 --force)。
|
|
243
|
+
if (argOf('--restore')) {
|
|
244
|
+
const { defaultVaultDir: dvd } = await import('../lib/capture-run.js')
|
|
245
|
+
const { restoreFromBackup } = await import('../lib/db.js')
|
|
246
|
+
const { readWatchdogLock, isPidAlive } = await import('../lib/watchdog.js')
|
|
247
|
+
const root = argOf('--vault') ? path.resolve(argOf('--vault')) : dvd()
|
|
248
|
+
const from = argOf('--restore')
|
|
249
|
+
const live = readWatchdogLock(process.env).watchdogs.filter((w) => isPidAlive(w.pid))
|
|
250
|
+
const plan = await restoreFromBackup(root, { from, dryRun: true, force: has('--force') })
|
|
251
|
+
if (has('--json')) { console.log(JSON.stringify({ planned: plan, liveWatchdogs: live.map((w) => w.pid) }, null, 2)); if (!plan.ok) process.exitCode = 1; return }
|
|
252
|
+
if (!plan.ok) { console.error('不能恢复:' + plan.error); if (plan.code === 'DB_HEALTHY') console.error(' (确认要覆盖可写库:加 --force)'); process.exitCode = 1; return }
|
|
253
|
+
console.log('备份:' + plan.from)
|
|
254
|
+
console.log('备份里的卡片数:' + (plan.cards >= 0 ? plan.cards : '(读不出,可能不是本插件的库)'))
|
|
255
|
+
console.log('当前库:' + root + '\\memory-eternal.db')
|
|
256
|
+
if (live.length) {
|
|
257
|
+
console.log('⚠ 检测到常驻看门狗还活着(pid ' + live.map((w) => w.pid).join(', ') + ')—— 恢复前应先:dsh-memory stop --port 7999(或完全退出 DSH)')
|
|
258
|
+
if (!has('--force')) { console.error('为避免边写边换,已中止。确认可强做:加 --force'); process.exitCode = 1; return }
|
|
259
|
+
}
|
|
260
|
+
if (!has('--yes')) {
|
|
261
|
+
console.log('')
|
|
262
|
+
console.log('这是**演练**(没有改动任何文件)。确认执行请加 --yes:')
|
|
263
|
+
console.log(' dsh-memory doctor --restore ' + from + ' --yes' + (argOf('--vault') ? ' --vault "' + root + '"' : ''))
|
|
264
|
+
process.exitCode = 1
|
|
265
|
+
return
|
|
266
|
+
}
|
|
267
|
+
const out = await restoreFromBackup(root, { from, force: has('--force') })
|
|
268
|
+
if (!out.ok) { console.error('恢复失败:' + out.error); process.exitCode = 1; return }
|
|
269
|
+
console.log('✓ 已用备份重建:卡片 ' + out.cards + ' 张,写探针 ' + (out.health.writable ? 'OK' : '仍失败'))
|
|
270
|
+
for (const m of out.movedAside || []) console.log(' 原现场留证:' + m)
|
|
271
|
+
console.log(' ' + out.note)
|
|
272
|
+
return
|
|
273
|
+
}
|
|
239
274
|
const { defaultVaultDir } = await import('../lib/capture-run.js')
|
|
240
275
|
const { getDb, dbStatus, checkDbHealth, rebuildGuidance } = await import('../lib/db.js')
|
|
241
276
|
const fsPromises = await import('node:fs/promises')
|
package/lib/api.js
CHANGED
|
@@ -18,7 +18,7 @@ import { listCards, countCards, readCard, search, searchAll, graph, graphAll, ov
|
|
|
18
18
|
import { compressExcerpt, DEFAULT_CAPTURE_MAX_TOKENS } from './capture.js'
|
|
19
19
|
import { buildDiagnostics } from './feedback.js'
|
|
20
20
|
import { getSetupStatus, runSetup, mcpAgentAction } from './setup.js'
|
|
21
|
-
import { backupDb, dbStatus, rebuildGuidance } from './db.js'
|
|
21
|
+
import { backupDb, dbStatus, rebuildGuidance, withDbRetry } from './db.js'
|
|
22
22
|
import { createHub } from './sse.js'
|
|
23
23
|
import { probeServedVersion, restartResident, currentPkgVersion, readWatchdogLock, isPidAlive, probeLockWritable } from './watchdog.js'
|
|
24
24
|
|
|
@@ -127,6 +127,12 @@ export function emptyNumericFields(patch, dict = {}) {
|
|
|
127
127
|
.map(([k]) => k)
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
+
/**
|
|
131
|
+
* **真的会写库**的路由(/card、/cards、/search 这些是读,绝不能拦)。
|
|
132
|
+
* 用途:库不可写时短路径失败;撞锁(SQLITE_BUSY)时做应用层异步重试。
|
|
133
|
+
*/
|
|
134
|
+
const DB_WRITE_ROUTES = /^\/(delete|merge|write|import|refine-apply|refine-goals-keep|refine-junk-purge|optimize-execute|feedback|audit\/(approve|reject)|recycle\/(restore|purge|purge-expired)|backup)$/
|
|
135
|
+
|
|
130
136
|
export function createApi(deps = {}) {
|
|
131
137
|
const vaultDir = deps.vaultDir ?? (() => '')
|
|
132
138
|
const vaultRoots = deps.vaultRoots ?? (() => [{ name: '', root: vaultDir() }])
|
|
@@ -158,8 +164,6 @@ export function createApi(deps = {}) {
|
|
|
158
164
|
// 库不可写时**短路径失败**(issue #33):这些路由一定会写库,早失败早说清,避免做一半、
|
|
159
165
|
// 也避免每个接口各自把 `attempt to write a readonly database` 原文丢给用户。
|
|
160
166
|
// **只读路由与配置路由不受影响**(配置不经过数据库 —— 用户至少还能改设置)。
|
|
161
|
-
// 只列**真的会写库**的路由(/card、/cards、/search 这些是读,绝不能拦)
|
|
162
|
-
const DB_WRITE_ROUTES = /^\/(delete|merge|write|import|write-card|refine-apply|refine-goals-keep|refine-junk-purge|optimize-execute|feedback|audit\/(approve|reject)|recycle\/(restore|purge|purge-expired)|backup)$/
|
|
163
167
|
if (DB_WRITE_ROUTES.test(route)) {
|
|
164
168
|
const st = dbStatus(vaultRoot)
|
|
165
169
|
if (st && st.writable === false) {
|
|
@@ -903,7 +907,21 @@ export function createApi(deps = {}) {
|
|
|
903
907
|
* `attempt to write a readonly database` 原文对用户毫无意义),并带上恢复指引。
|
|
904
908
|
*/
|
|
905
909
|
return async function handleApi(req, res) {
|
|
910
|
+
const routePath = (() => { try { return new URL(req.url, 'http://localhost').pathname.slice(API_PREFIX.length).replace(/\/+$/, '') || '/overview' } catch { return '' } })()
|
|
911
|
+
const isWriteRoute = DB_WRITE_ROUTES.test(routePath)
|
|
906
912
|
try {
|
|
913
|
+
// 写路由:撞锁(SQLITE_BUSY / database is locked)做**应用层异步重试** ——
|
|
914
|
+
// 不阻塞事件循环(`busy_timeout` 是同步忙等,评审已否),且只在**还没发出响应**时重试,
|
|
915
|
+
// 避免重复写;SQLite 在 BUSY 时事务已回滚,重试是安全的。
|
|
916
|
+
if (isWriteRoute) {
|
|
917
|
+
return await withDbRetry(async () => {
|
|
918
|
+
try { return await dispatch(req, res) } catch (e) {
|
|
919
|
+
// 响应已经发出(writeHead 过了)→ 不能再重试,否则可能写两遍
|
|
920
|
+
if (res && res.headersSent) { e.noRetry = true; throw e }
|
|
921
|
+
throw e
|
|
922
|
+
}
|
|
923
|
+
}, { tries: 3, delayMs: 120 })
|
|
924
|
+
}
|
|
907
925
|
return await dispatch(req, res)
|
|
908
926
|
} catch (e) {
|
|
909
927
|
const msg = String((e && e.message) || e)
|
package/lib/client.js
CHANGED
|
@@ -298,7 +298,7 @@ Option A, because:
|
|
|
298
298
|
height: calc(100vh - var(--dsh-windows-titlebar-height, 0px));
|
|
299
299
|
max-height: calc(100vh - var(--dsh-windows-titlebar-height, 0px));
|
|
300
300
|
}
|
|
301
|
-
`;function Sa(t){t.effect(()=>t.locale.register(lt,{zh:Xt,en:Jt}),"memory-eternal: locale");let o=t.locale.bind(lt);t.effect(()=>t.slots.inject("settings.section",()=>t.slots.register({name:"settings.section",id:lt,order:25,label:()=>o("nav"),locale:lt,inject:()=>({})},()=>n.default.createElement(Na,{t:o}))),"memory-eternal: settings section"),t.effect(()=>t.slots.inject("sidebar.footer.action",()=>t.slots.register({name:"sidebar.footer.action",id:`${lt}:footer`,order:100,label:()=>o("nav"),locale:lt,inject:()=>({})},r=>n.default.createElement(Ma,{t:o,locale:t.locale,wide:!(r&&r.wide===!1)}))),"memory-eternal: sidebar footer action")}function Ca(){let[t,o]=(0,n.useState)($+"/ui/app");return(0,n.useEffect)(()=>{let r=!0,f=()=>fetch("/memory-eternal/api/web-info").then(c=>c.json()).then(c=>{r&&c&&c.ok&&c.alive!==!1&&c.url&&o(c.url.endsWith("/")?c.url:c.url+"/")}).catch(()=>{});return fetch($+"/ui/app",{method:"HEAD"}).then(c=>{if(r&&!c.ok)return f()}).catch(()=>r?f():void 0),()=>{r=!1}},[]),t}function Na({t}){return(0,e.jsxs)("div",{style:{display:"flex",flexDirection:"column",height:"100%",minHeight:0},children:[(0,e.jsx)("style",{children:Fe}),(0,e.jsx)(ta,{t,onReload:()=>{},version:0})]})}function Ma({t,locale:o,wide:r}){let[f,c]=(0,n.useState)(!1);return(0,e.jsxs)("div",{className:`me-footer${r?"":" rail"}`,children:[(0,e.jsx)("style",{children:Fe}),(0,e.jsxs)("button",{type:"button",className:"me-footer-btn",onClick:()=>c(!0),"aria-label":t("nav"),title:t("nav"),children:[(0,e.jsx)("span",{className:"me-footer-ico","aria-hidden":"true",children:(0,e.jsx)(Qt,{})}),(0,e.jsx)("span",{className:"me-footer-label",children:t("nav")})]}),f&&(0,e.jsx)(Ta,{t,locale:o,onClose:()=>c(!1)})]})}function Ta({t,locale:o,onClose:r,tab:f}){let[c,i]=(0,n.useState)(!1),w=(0,n.useRef)(null),z=Ca(),k=(0,n.useCallback)(B=>o&&typeof o.subscribe=="function"?o.subscribe(B):()=>{},[o]),F=(0,n.useCallback)(()=>o&&typeof o.getSnapshot=="function"?o.getSnapshot():null,[o]),N=(0,n.useSyncExternalStore)(k,F),h=N&&(N.active==="zh"||N.active==="en")?N.active:null,g=(0,n.useRef)(null);g.current===null&&h&&(g.current=h);let L=(0,n.useMemo)(()=>{let B=g.current?`lang=${g.current}`:"";if(f==="config")return`${$}/ui/config?tab=config${B?`&${B}`:""}`;let x=z.replace(/\/$/,""),S=x.endsWith("/ui/app")?"?":"/?";return f?`${x}${S}tab=${f}${B?`&${B}`:""}`:B?`${x}${S}${B}`:z},[z,f]);return(0,n.useEffect)(()=>{let B=w.current&&w.current.contentWindow;B&&h&&B.postMessage({source:"memory-eternal",type:"locale",lang:h},"*")},[h]),(0,n.useEffect)(()=>{let B=x=>{x.key==="Escape"&&r()};return window.addEventListener("keydown",B),()=>window.removeEventListener("keydown",B)},[r]),(0,e.jsxs)("div",{className:"me-overlay-top",onClick:r,children:[(0,e.jsx)("style",{children:Fe}),(0,e.jsx)("div",{className:"me-modal"+(c?" me-modal-full":""),onClick:B=>B.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(B=>!B),"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:r,"aria-label":t("close"),title:t("close"),style:{width:26,height:26,borderRadius:6,border:"1px solid rgba(255,255,255,0.18)",background:"rgba(255,255,255,0.08)",color:"#fff",cursor:"pointer",fontSize:16,lineHeight:1,display:"flex",alignItems:"center",justifyContent:"center"},children:"\xD7"})]}),(0,e.jsx)("iframe",{ref:w,src:L,title:"memory-eternal",allow:"clipboard-write; clipboard-read",style:{width:"100%",flex:1,border:0,borderRadius:"inherit",background:"var(--dsw-alias-bg-base, #fff)"}})]})})]})}function Qt(){return(0,e.jsxs)("svg",{viewBox:"0 0 24 24","aria-hidden":"true",children:[(0,e.jsx)("defs",{children:(0,e.jsxs)("linearGradient",{id:"mg-brain",x1:"0",y1:"0",x2:"1",y2:"1",children:[(0,e.jsx)("stop",{offset:"0%",stopColor:"#FFC46B"}),(0,e.jsx)("stop",{offset:"50%",stopColor:"#F97316"}),(0,e.jsx)("stop",{offset:"100%",stopColor:"#EA580C"})]})}),(0,e.jsx)("path",{fill:"url(#mg-brain)",d:"M12 4.5a3.2 3.2 0 0 0-6.4.1 4.1 4.1 0 0 0-2.7 5.9 4.1 4.1 0 0 0 .6 6.7A4.2 4.2 0 0 0 12 18.2Z"}),(0,e.jsx)("path",{fill:"url(#mg-brain)",d:"M12 4.5a3.2 3.2 0 0 1 6.4.1 4.1 4.1 0 0 1 2.7 5.9 4.1 4.1 0 0 1-.6 6.7A4.2 4.2 0 0 1 12 18.2Z"}),(0,e.jsx)("path",{d:"M12 9.2c-.8-1.4-1.7-2-3.1-2.2M12 9.2c.8-1.4 1.7-2 3.1-2.2M12 12.4c-1.2 1-3.2 1.6-5 1.4M12 12.4c1.2 1 3.2 1.6 5 1.4M12 13.2v4",fill:"none",stroke:"rgba(255,255,255,0.62)",strokeWidth:"1",strokeLinecap:"round"}),(0,e.jsx)("circle",{cx:"12",cy:"8.6",r:"1.1",fill:"rgba(255,255,255,0.85)"})]})}var bt="0.10.
|
|
301
|
+
`;function Sa(t){t.effect(()=>t.locale.register(lt,{zh:Xt,en:Jt}),"memory-eternal: locale");let o=t.locale.bind(lt);t.effect(()=>t.slots.inject("settings.section",()=>t.slots.register({name:"settings.section",id:lt,order:25,label:()=>o("nav"),locale:lt,inject:()=>({})},()=>n.default.createElement(Na,{t:o}))),"memory-eternal: settings section"),t.effect(()=>t.slots.inject("sidebar.footer.action",()=>t.slots.register({name:"sidebar.footer.action",id:`${lt}:footer`,order:100,label:()=>o("nav"),locale:lt,inject:()=>({})},r=>n.default.createElement(Ma,{t:o,locale:t.locale,wide:!(r&&r.wide===!1)}))),"memory-eternal: sidebar footer action")}function Ca(){let[t,o]=(0,n.useState)($+"/ui/app");return(0,n.useEffect)(()=>{let r=!0,f=()=>fetch("/memory-eternal/api/web-info").then(c=>c.json()).then(c=>{r&&c&&c.ok&&c.alive!==!1&&c.url&&o(c.url.endsWith("/")?c.url:c.url+"/")}).catch(()=>{});return fetch($+"/ui/app",{method:"HEAD"}).then(c=>{if(r&&!c.ok)return f()}).catch(()=>r?f():void 0),()=>{r=!1}},[]),t}function Na({t}){return(0,e.jsxs)("div",{style:{display:"flex",flexDirection:"column",height:"100%",minHeight:0},children:[(0,e.jsx)("style",{children:Fe}),(0,e.jsx)(ta,{t,onReload:()=>{},version:0})]})}function Ma({t,locale:o,wide:r}){let[f,c]=(0,n.useState)(!1);return(0,e.jsxs)("div",{className:`me-footer${r?"":" rail"}`,children:[(0,e.jsx)("style",{children:Fe}),(0,e.jsxs)("button",{type:"button",className:"me-footer-btn",onClick:()=>c(!0),"aria-label":t("nav"),title:t("nav"),children:[(0,e.jsx)("span",{className:"me-footer-ico","aria-hidden":"true",children:(0,e.jsx)(Qt,{})}),(0,e.jsx)("span",{className:"me-footer-label",children:t("nav")})]}),f&&(0,e.jsx)(Ta,{t,locale:o,onClose:()=>c(!1)})]})}function Ta({t,locale:o,onClose:r,tab:f}){let[c,i]=(0,n.useState)(!1),w=(0,n.useRef)(null),z=Ca(),k=(0,n.useCallback)(B=>o&&typeof o.subscribe=="function"?o.subscribe(B):()=>{},[o]),F=(0,n.useCallback)(()=>o&&typeof o.getSnapshot=="function"?o.getSnapshot():null,[o]),N=(0,n.useSyncExternalStore)(k,F),h=N&&(N.active==="zh"||N.active==="en")?N.active:null,g=(0,n.useRef)(null);g.current===null&&h&&(g.current=h);let L=(0,n.useMemo)(()=>{let B=g.current?`lang=${g.current}`:"";if(f==="config")return`${$}/ui/config?tab=config${B?`&${B}`:""}`;let x=z.replace(/\/$/,""),S=x.endsWith("/ui/app")?"?":"/?";return f?`${x}${S}tab=${f}${B?`&${B}`:""}`:B?`${x}${S}${B}`:z},[z,f]);return(0,n.useEffect)(()=>{let B=w.current&&w.current.contentWindow;B&&h&&B.postMessage({source:"memory-eternal",type:"locale",lang:h},"*")},[h]),(0,n.useEffect)(()=>{let B=x=>{x.key==="Escape"&&r()};return window.addEventListener("keydown",B),()=>window.removeEventListener("keydown",B)},[r]),(0,e.jsxs)("div",{className:"me-overlay-top",onClick:r,children:[(0,e.jsx)("style",{children:Fe}),(0,e.jsx)("div",{className:"me-modal"+(c?" me-modal-full":""),onClick:B=>B.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(B=>!B),"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:r,"aria-label":t("close"),title:t("close"),style:{width:26,height:26,borderRadius:6,border:"1px solid rgba(255,255,255,0.18)",background:"rgba(255,255,255,0.08)",color:"#fff",cursor:"pointer",fontSize:16,lineHeight:1,display:"flex",alignItems:"center",justifyContent:"center"},children:"\xD7"})]}),(0,e.jsx)("iframe",{ref:w,src:L,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.27";function Ra({t,inModal:o,onClose:r,onFull:f,full:c}){let[i,w]=(0,n.useState)(null),[z,k]=(0,n.useState)([]),[F,N]=(0,n.useState)(!0),[h,g]=(0,n.useState)(null),[L,B]=(0,n.useState)(""),[x,S]=(0,n.useState)("all"),[v,_]=(0,n.useState)(""),[Q,Ce]=(0,n.useState)("all"),[G,Be]=(0,n.useState)("all"),[Y,me]=(0,n.useState)(()=>{try{let u=new URLSearchParams(typeof location<"u"?location.search:"").get("tab"),I={cards:"cards",graph:"graph",usage:"stats",stats:"stats",audit:"audit",recycle:"optimize",optimize:"optimize",config:"config"};if(u&&I[u])return I[u]}catch{}return"cards"}),[de,H]=(0,n.useState)(()=>new Set([Y])),Z=(0,n.useCallback)(u=>{H(I=>I.has(u)?I:new Set(I).add(u)),me(u)},[]),[ve,M]=(0,n.useState)("recent"),[Ne,J]=(0,n.useState)(null),se=(0,n.useRef)(null),Le=(0,n.useRef)(null),[y,q]=(0,n.useState)(!1),[re,X]=(0,n.useState)(null),[ne,ye]=(0,n.useState)(!1),[l,C]=(0,n.useState)(!1),[T,V]=(0,n.useState)(!0),[we,ge]=(0,n.useState)(null),[oe,Se]=(0,n.useState)(0),Ye=(0,n.useCallback)(()=>Se(u=>u+1),[]),Ge=i?.quarantine?.pending??0,Ue=i?.quarantine?.deleted??0,pe=(0,n.useRef)(null),[Xe,Te]=(0,n.useState)(0),[p,s]=(0,n.useState)(!1),O=(0,n.useRef)(null),ie=(0,n.useRef)(null),le=(0,n.useCallback)(async(u=x,I=v,D=G,U={})=>{let W=U.append===!0;try{W?s(!0):(N(!0),B(""));let ue=new URLSearchParams;u&&u!=="all"&&ue.set("kind",u),I.trim()&&ue.set("q",I.trim()),ue.set("status",D||"all"),ue.set("limit",String(va)),ue.set("offset",String(U.offset||0)),ve==="title"&&ue.set("sort","title");let Ie=await(await fetch(`${$}/cards?${ue.toString()}`)).json();if(Ie.ok){let Ke=Ie.cards||[];k(Ee=>W?[...Ee,...Ke.filter(qe=>!Ee.some(vt=>vt.path===qe.path))]:Ke),Te(Number(Ie.total)||Ke.length)}}catch(ue){B(String(ue&&ue.message?ue.message:ue))}finally{W?s(!1):N(!1)}},[x,v,G,ve]),fe=(0,n.useCallback)(async()=>{try{let[u,I]=await Promise.all([fetch(`${$}/overview`).then(D=>D.json()),fetch(`${$}/capture-log`).then(D=>D.json()).catch(()=>null)]);u.ok&&w(u),g(I&&I.ok&&I.available&&I.health||null)}catch(u){B(String(u&&u.message?u.message:u))}finally{N(!1)}},[]),a=async u=>{try{let D=await(await fetch(`${$}/delete?path=${encodeURIComponent(u)}`)).json();D.ok?(J({ok:!0,msg:t("deleted")}),await fe(),Ye()):J({ok:!1,msg:D.error||t("deleteFail")})}catch{J({ok:!1,msg:t("deleteFail")})}se.current&&clearTimeout(se.current),se.current=setTimeout(()=>J(null),2e3)};(0,n.useEffect)(()=>(fe(),()=>{pe.current&&clearTimeout(pe.current)}),[fe]);let tt=u=>{_(u),pe.current&&clearTimeout(pe.current),pe.current=setTimeout(()=>le(x,u,G),280)},ke=u=>{S(u),le(u,v,G)};(0,n.useEffect)(()=>{Y==="cards"&&le(x,v,G)},[G,Y,oe,ve]),(0,n.useEffect)(()=>{if(Y!=="cards"||F||p||z.length>=Xe)return;let u=O.current,I=ie.current;if(!u)return;let D=new IntersectionObserver(U=>{U[0]&&U[0].isIntersecting&&le(x,v,G,{append:!0,offset:z.length})},{root:I||null,rootMargin:"400px"});return D.observe(u),()=>D.disconnect()},[Y,z,Xe,F,p,x,v,G,le]);let nt=async u=>{try{let I=u.status;if(!I&&u.path)try{let Re=((await fetch(`${$}/cards?status=all&q=${encodeURIComponent(u.title||"")}&limit=50`).then(Ie=>Ie.json())).cards||[]).find(Ie=>Ie.path===u.path);Re&&(I=Re.status)}catch{}let D=I&&I!=="approved"?`&status=${encodeURIComponent(I)}`:"",U=await fetch(`${$}/card?path=${encodeURIComponent(u.path||u.id)}${D}`),W=await U.json();W.ok?X({path:W.path,title:u.title,text:W.text}):B(W.error||`HTTP ${U.status}`)}catch(I){B(String(I&&I.message?I.message:I))}},ze=async(u,I)=>{q(!0);try{let D=await fetch(`${$}/export`);if(!D.ok)throw new Error("HTTP "+D.status);let U=await D.json();if(!U.ok)throw new Error(U.error||"export failed");let W,ue,Re;if(u==="json"){ue="memory-vault.json",Re="application/json";let Ee=U.cards.map(qe=>({path:qe.path,title:qe.title,kind:qe.kind,status:qe.status,store:qe.store,text:qe.text}));W=JSON.stringify({format:U.format||"memory-eternal-vault",formatVersion:U.formatVersion||1,exportedAt:U.exportedAt||new Date().toISOString(),count:Ee.length,cards:Ee},null,2)}else ue="memory-vault.md",Re="text/markdown;charset=utf-8",W=U.cards.map(Ee=>Ee.text.trim()).filter(Boolean).join(`
|
|
302
302
|
|
|
303
303
|
---
|
|
304
304
|
|
package/lib/db.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// 首次启动自动从 .md 文件迁移(幂等)
|
|
5
5
|
|
|
6
6
|
import { DatabaseSync } from 'node:sqlite'
|
|
7
|
-
import { promises as fs, mkdirSync } from 'node:fs'
|
|
7
|
+
import { promises as fs, mkdirSync, existsSync, renameSync, copyFileSync } from 'node:fs'
|
|
8
8
|
import path from 'node:path'
|
|
9
9
|
import { parseCard, KIND_ROOTS } from './vault.js'
|
|
10
10
|
|
|
@@ -53,6 +53,38 @@ export function checkDbHealth(db) {
|
|
|
53
53
|
return out
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/** 这个错误是不是「库被别人锁着」(SQLite 的 BUSY / locked),而不是数据或语法问题。 */
|
|
57
|
+
export function isDbBusyError(e) {
|
|
58
|
+
const m = String((e && e.message) || e)
|
|
59
|
+
return /database is locked|database table is locked|SQLITE_BUSY/i.test(m)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 撞锁时**在应用层异步重试**(issue #31/#33 的实测补充)。
|
|
64
|
+
*
|
|
65
|
+
* 为什么不用 `PRAGMA busy_timeout`:`node:sqlite` 的 `DatabaseSync` 是**同步 API**,
|
|
66
|
+
* 忙等会阻塞宿主的事件循环(整个 DSH 卡住)—— 评审后**故意没做**。这里改成:失败 → 让出事件循环
|
|
67
|
+
* 睡一小会儿 → 再试,最多几次。SQLite 在 BUSY 时不会留下半成品(事务回滚),所以重试是安全的;
|
|
68
|
+
* 实测现场:外部进程持写事务时我方立刻拿到 `database is locked`,且对方被强杀后**仍然锁着** ——
|
|
69
|
+
* 这种瞬时锁正好由短退避重试化解。
|
|
70
|
+
*
|
|
71
|
+
* @param {() => Promise<any>} fn
|
|
72
|
+
* @param {{tries?:number, delayMs?:number}} opts
|
|
73
|
+
*/
|
|
74
|
+
export async function withDbRetry(fn, { tries = 3, delayMs = 120 } = {}) {
|
|
75
|
+
const n = Math.max(1, Number(tries) || 3)
|
|
76
|
+
let last
|
|
77
|
+
for (let i = 0; i < n; i++) {
|
|
78
|
+
try { return await fn() } catch (e) {
|
|
79
|
+
last = e
|
|
80
|
+
if (e && e.noRetry) throw e // 调用方显式禁止重试(例如响应已发出)
|
|
81
|
+
if (!isDbBusyError(e) || i === n - 1) throw e
|
|
82
|
+
await new Promise((r) => setTimeout(r, (Number(delayMs) || 120) * (i + 1))) // 线性退避,异步让出
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
throw last
|
|
86
|
+
}
|
|
87
|
+
|
|
56
88
|
/** 某个库最近一次打开时的健康结论(没打开过返回 null)。 */
|
|
57
89
|
export function dbStatus(root) {
|
|
58
90
|
return healthByRoot.get(path.resolve(root)) || null
|
|
@@ -409,6 +441,112 @@ export function closeAllDb() {
|
|
|
409
441
|
* SQLite 备份:用 VACUUM INTO 创建紧凑副本。
|
|
410
442
|
* 保留最近 maxKeep 个备份,自动清理旧的。
|
|
411
443
|
*/
|
|
444
|
+
/**
|
|
445
|
+
* 拿只读连接**当场**检查一个 .db 文件是否可用(quick_check),并尽可能数出卡片数。
|
|
446
|
+
* 为什么不复用 dbStatus():那是**缓存**的打开时结论,文件可能在那之后被写坏/替换 ——
|
|
447
|
+
* 恢复流程必须看**此刻**的真相。
|
|
448
|
+
* @returns {{ok:boolean, cards:number, error:string}}
|
|
449
|
+
*/
|
|
450
|
+
export function probeDbFile(file) {
|
|
451
|
+
let probe = null
|
|
452
|
+
try {
|
|
453
|
+
probe = new DatabaseSync(file, { readOnly: true })
|
|
454
|
+
const row = probe.prepare('PRAGMA quick_check').get()
|
|
455
|
+
const verdict = String((row && (row.quick_check || Object.values(row)[0])) || '')
|
|
456
|
+
if (verdict.toLowerCase() !== 'ok') return { ok: false, cards: -1, error: 'quick_check 未通过:' + verdict }
|
|
457
|
+
let cards = -1
|
|
458
|
+
try { cards = Number(probe.prepare('SELECT count(*) AS n FROM cards').get().n) } catch { cards = -1 }
|
|
459
|
+
return { ok: true, cards, error: '' }
|
|
460
|
+
} catch (e) {
|
|
461
|
+
return { ok: false, cards: -1, error: String((e && e.message) || e) }
|
|
462
|
+
} finally {
|
|
463
|
+
try { probe && probe.close() } catch { /* 忽略 */ }
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* 从备份恢复数据库(issue #31 的现实出路:WAL 头一旦损坏,代码修不了**数据**,只能用备份重建)。
|
|
469
|
+
*
|
|
470
|
+
* 硬约束 —— **宁可拒绝,也不破坏现场**:
|
|
471
|
+
* 1. **不删任何东西**:当前库连同 -wal/-shm 一律改名成 `*.corrupt-<时间戳>` 留证;
|
|
472
|
+
* 2. 替换前先**校验备份真的可用**(能打开 + `PRAGMA quick_check` 通过 + 能数出卡片);
|
|
473
|
+
* 3. 拒绝「拿正在使用的库当备份」(自己替换自己);
|
|
474
|
+
* 4. 当前库**明明可写**时需要显式 `force`(防止手滑覆盖好数据);
|
|
475
|
+
* 5. 恢复后**复核**(重新打开 + 写探针 + 卡片数),结果如实返回。
|
|
476
|
+
*
|
|
477
|
+
* @param {string} root vault 目录
|
|
478
|
+
* @param {{from?:string, force?:boolean, dryRun?:boolean}} opts from:'latest' | 备份文件名 | 绝对路径
|
|
479
|
+
*/
|
|
480
|
+
export async function restoreFromBackup(root, { from = '', force = false, dryRun = false } = {}) {
|
|
481
|
+
const dir = path.resolve(root)
|
|
482
|
+
const dbPath = path.join(dir, DB_FILE)
|
|
483
|
+
const backupsDir = path.join(dir, 'backups')
|
|
484
|
+
let src = String(from || '').trim()
|
|
485
|
+
if (!src) return { ok: false, error: '缺少备份来源(--restore latest 或 --restore <备份文件名>' + ')' }
|
|
486
|
+
if (src === 'latest') {
|
|
487
|
+
const files = (await fs.readdir(backupsDir).catch(() => []))
|
|
488
|
+
.filter((f) => f.startsWith('memory-eternal-') && f.endsWith('.db')).sort().reverse()
|
|
489
|
+
if (!files.length) return { ok: false, error: '没有可用备份:' + backupsDir }
|
|
490
|
+
src = path.join(backupsDir, files[0])
|
|
491
|
+
} else if (!src.includes('/') && !src.includes('\\')) {
|
|
492
|
+
src = path.join(backupsDir, src)
|
|
493
|
+
}
|
|
494
|
+
src = path.resolve(src)
|
|
495
|
+
if (src === path.resolve(dbPath)) return { ok: false, error: '备份文件不能是正在使用的库本身' }
|
|
496
|
+
let st
|
|
497
|
+
try { st = await fs.stat(src) } catch { return { ok: false, error: '备份文件不存在:' + src } }
|
|
498
|
+
if (!st.isFile()) return { ok: false, error: '不是文件:' + src }
|
|
499
|
+
|
|
500
|
+
// ② 校验备份可用(只读连接 + quick_check + 卡片数)
|
|
501
|
+
const backup = probeDbFile(src)
|
|
502
|
+
if (!backup.ok) return { ok: false, error: '备份不可用(' + src + '):' + backup.error }
|
|
503
|
+
const cards = backup.cards
|
|
504
|
+
|
|
505
|
+
// ④ 当前库**此刻**仍然健康时要求显式 force(手滑保护)—— 用当场探测,别信缓存
|
|
506
|
+
const curNow = existsSync(dbPath) ? probeDbFile(dbPath) : { ok: false }
|
|
507
|
+
if (curNow.ok && !force) {
|
|
508
|
+
return { ok: false, code: 'DB_HEALTHY', error: '当前库此刻是完好的(quick_check 通过),恢复会覆盖它 —— 确认要恢复请加 --force', cards }
|
|
509
|
+
}
|
|
510
|
+
if (dryRun) return { ok: true, dryRun: true, from: src, cards, note: '仅演练:未改任何文件' }
|
|
511
|
+
|
|
512
|
+
// ① 先关掉本进程里这个库的连接(Windows 上不关会改名失败)
|
|
513
|
+
try { closeDb(dir) } catch { /* 忽略 */ }
|
|
514
|
+
// ② 现场留证:库 + -wal + -shm 全部改名(**不删**)
|
|
515
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
|
|
516
|
+
const movedAside = []
|
|
517
|
+
for (const suffix of ['', '-wal', '-shm']) {
|
|
518
|
+
const f = dbPath + suffix
|
|
519
|
+
if (!existsSync(f)) continue
|
|
520
|
+
const to = f + '.corrupt-' + stamp
|
|
521
|
+
try { renameSync(f, to); movedAside.push(to) } catch (e) {
|
|
522
|
+
return { ok: false, error: '现场留证失败(' + f + '):' + String((e && e.message) || e) + '(未做任何替换)', movedAside }
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
// ③ 备份 → 库
|
|
526
|
+
try { copyFileSync(src, dbPath) } catch (e) {
|
|
527
|
+
return { ok: false, error: '写入新库失败:' + String((e && e.message) || e), movedAside }
|
|
528
|
+
}
|
|
529
|
+
// ⑤ 复核
|
|
530
|
+
try {
|
|
531
|
+
const db = getDb(dir)
|
|
532
|
+
const health = checkDbHealth(db)
|
|
533
|
+
const n = Number(db.prepare('SELECT count(*) AS n FROM cards').get().n)
|
|
534
|
+
return {
|
|
535
|
+
ok: Boolean(health.writable),
|
|
536
|
+
from: src,
|
|
537
|
+
cards: n,
|
|
538
|
+
backupCards: cards,
|
|
539
|
+
health,
|
|
540
|
+
movedAside,
|
|
541
|
+
note: health.writable
|
|
542
|
+
? '已用备份重建;原现场已改名留证(可人工检查后再删)'
|
|
543
|
+
: '已替换文件,但写探针仍失败:请贴 doctor 输出',
|
|
544
|
+
}
|
|
545
|
+
} catch (e) {
|
|
546
|
+
return { ok: false, error: '复核失败:' + String((e && e.message) || e), movedAside, from: src }
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
412
550
|
export async function backupDb(root, { maxKeep = 7 } = {}) {
|
|
413
551
|
const resolved = path.resolve(root)
|
|
414
552
|
const dbPath = path.join(resolved, DB_FILE)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "memory-eternal",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.27",
|
|
4
4
|
"description": "记忆核心(Memory Eternal):自研的 DeepSeek Harness 记忆插件,不移植任何既有记忆框架——对话结束后自动沉淀知识卡到本地 Markdown Vault(自研去重、自研 CJK 检索、可 git 管理),设置页提供图形化知识库(统计 / 搜索 / 知识图谱 + 侧边栏一键弹窗),Agent 通过 memory_recall 工具按需召回历史上下文。零人工干预。",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
"prepublishOnly": "node build.mjs",
|
|
65
65
|
"release": "node scripts/release.mjs",
|
|
66
66
|
"release:dry": "node scripts/release.mjs --dry-run",
|
|
67
|
-
"test": "node build.mjs && node tests/vault.test.mjs && node tests/capture.test.mjs && node tests/capture-route.test.mjs && node tests/stall.test.mjs && node tests/vault-path.test.mjs && node tests/vault-resolve.test.mjs && node tests/minhash.test.mjs && node tests/settings-save.test.mjs && node tests/config-coverage.test.mjs && node tests/config-sync.test.mjs && node tests/config-merge.test.mjs && node tests/sse.test.mjs && node tests/api.test.mjs && node tests/import-roundtrip.test.mjs && node tests/graph-perf.test.mjs && node tests/settings-compat.test.mjs && node tests/node-bin.test.mjs && node tests/watchdog-lock.test.mjs && node tests/markdown.test.mjs && node tests/feedback.test.mjs && node tests/graph-lod.test.mjs && node tests/ssr.test.mjs && node tests/web-assets.test.mjs && node tests/manifests.test.mjs && node tests/two-store.test.mjs && node tests/noise-gate.test.mjs && node tests/e2e-recall.test.mjs && node tests/issue-fixes.test.mjs && node tests/issue-fixes-2.test.mjs && node tests/issue-fixes-3.test.mjs && node tests/watchdog-orphans.test.mjs && node tests/watchdog-restart.test.mjs && node tests/readme-i18n.test.mjs && node tests/issue-fixes-4.test.mjs && node tests/host-drain.test.mjs && node tests/e2e-host-drain.mjs && node tests/issue-fixes-5.test.mjs && node tests/issue-fixes-6.test.mjs && node tests/capture-json-repair.test.mjs && node tests/csrf-origin.test.mjs && node tests/watchdog-orphan-web.test.mjs && node tests/config-validate.test.mjs && node tests/issue-fixes-8.test.mjs && node tests/issue-fixes-9.test.mjs && node tests/release-verify.test.mjs && node tests/refine.test.mjs"
|
|
67
|
+
"test": "node build.mjs && node tests/vault.test.mjs && node tests/capture.test.mjs && node tests/capture-route.test.mjs && node tests/stall.test.mjs && node tests/vault-path.test.mjs && node tests/vault-resolve.test.mjs && node tests/minhash.test.mjs && node tests/settings-save.test.mjs && node tests/config-coverage.test.mjs && node tests/config-sync.test.mjs && node tests/config-merge.test.mjs && node tests/sse.test.mjs && node tests/api.test.mjs && node tests/import-roundtrip.test.mjs && node tests/graph-perf.test.mjs && node tests/settings-compat.test.mjs && node tests/node-bin.test.mjs && node tests/watchdog-lock.test.mjs && node tests/markdown.test.mjs && node tests/feedback.test.mjs && node tests/graph-lod.test.mjs && node tests/ssr.test.mjs && node tests/web-assets.test.mjs && node tests/manifests.test.mjs && node tests/two-store.test.mjs && node tests/noise-gate.test.mjs && node tests/e2e-recall.test.mjs && node tests/issue-fixes.test.mjs && node tests/issue-fixes-2.test.mjs && node tests/issue-fixes-3.test.mjs && node tests/watchdog-orphans.test.mjs && node tests/watchdog-restart.test.mjs && node tests/readme-i18n.test.mjs && node tests/issue-fixes-4.test.mjs && node tests/host-drain.test.mjs && node tests/e2e-host-drain.mjs && node tests/issue-fixes-5.test.mjs && node tests/issue-fixes-6.test.mjs && node tests/capture-json-repair.test.mjs && node tests/csrf-origin.test.mjs && node tests/watchdog-orphan-web.test.mjs && node tests/config-validate.test.mjs && node tests/issue-fixes-8.test.mjs && node tests/issue-fixes-9.test.mjs && node tests/release-verify.test.mjs && node tests/refine.test.mjs && node tests/db-restore.test.mjs"
|
|
68
68
|
},
|
|
69
69
|
"dependencies": {
|
|
70
70
|
"@deepseek-ai/schemastery": "^3.18.1"
|
package/scripts/release.mjs
CHANGED
|
@@ -166,10 +166,22 @@ if (SKIP_NPM) {
|
|
|
166
166
|
}
|
|
167
167
|
// **不信 stdout,只信 registry**:publish 返回 0 不代表包真的上去了(0.10.21 实测:
|
|
168
168
|
// 本地 packument/npm view 缓存会假装没有,边缘缓存又会假装有)—— 用 version 端点实证。
|
|
169
|
+
// 端点滞后(实测可达数分钟)不该中断后面的 tag/push/Release —— 但也**绝不能**当成已发布:
|
|
170
|
+
// 记进问题清单,继续跑完,最后汇总 + 非零退出(0.10.22 / 0.10.26 都因这里超时中断过一次发布)。
|
|
169
171
|
if (!(await waitNpmVersion(pkg.version))) {
|
|
170
|
-
|
|
172
|
+
// 端点滞后可能有几分钟(实测 0.10.26:publish 先"暂存"、稍后才落地)。再打一次 publish 探针:
|
|
173
|
+
// npm 若回「previously staged / previously published / 409」——那**恰好证明版本确实进去了**。
|
|
174
|
+
const probe = run('npm', ['publish', '--access', 'public'], { allowFail: true })
|
|
175
|
+
const txt = String(probe.out || '')
|
|
176
|
+
if (/previously staged version|previously published version|EPUBLISHCONFLICT|409/i.test(txt)) {
|
|
177
|
+
say('npm version 端点滞后,但 publish 探针回报「版本已存在 / 已暂存」→ 视为**已发布**')
|
|
178
|
+
} else {
|
|
179
|
+
say(`⚠ npm version 端点仍未可见:https://registry.npmjs.org/memory-eternal/${pkg.version}(可能只是 CDN 滞后)`)
|
|
180
|
+
problems.push(`npm 上暂时查不到 ${pkg.version} → 稍后复核(200 才算真的发布成功):https://registry.npmjs.org/memory-eternal/${pkg.version}`)
|
|
181
|
+
}
|
|
182
|
+
} else {
|
|
183
|
+
say(`npm 校验通过:https://registry.npmjs.org/memory-eternal/${pkg.version} → 200`)
|
|
171
184
|
}
|
|
172
|
-
say(`npm 校验通过:https://registry.npmjs.org/memory-eternal/${pkg.version} → 200`)
|
|
173
185
|
}
|
|
174
186
|
}
|
|
175
187
|
|
package/web/app.js
CHANGED
|
@@ -300,7 +300,7 @@ Option A, because:
|
|
|
300
300
|
height: calc(100vh - var(--dsh-windows-titlebar-height, 0px));
|
|
301
301
|
max-height: calc(100vh - var(--dsh-windows-titlebar-height, 0px));
|
|
302
302
|
}
|
|
303
|
-
`;function Vg(){return(0,i.jsxs)("svg",{viewBox:"0 0 24 24","aria-hidden":"true",children:[(0,i.jsx)("defs",{children:(0,i.jsxs)("linearGradient",{id:"mg-brain",x1:"0",y1:"0",x2:"1",y2:"1",children:[(0,i.jsx)("stop",{offset:"0%",stopColor:"#FFC46B"}),(0,i.jsx)("stop",{offset:"50%",stopColor:"#F97316"}),(0,i.jsx)("stop",{offset:"100%",stopColor:"#EA580C"})]})}),(0,i.jsx)("path",{fill:"url(#mg-brain)",d:"M12 4.5a3.2 3.2 0 0 0-6.4.1 4.1 4.1 0 0 0-2.7 5.9 4.1 4.1 0 0 0 .6 6.7A4.2 4.2 0 0 0 12 18.2Z"}),(0,i.jsx)("path",{fill:"url(#mg-brain)",d:"M12 4.5a3.2 3.2 0 0 1 6.4.1 4.1 4.1 0 0 1 2.7 5.9 4.1 4.1 0 0 1-.6 6.7A4.2 4.2 0 0 1 12 18.2Z"}),(0,i.jsx)("path",{d:"M12 9.2c-.8-1.4-1.7-2-3.1-2.2M12 9.2c.8-1.4 1.7-2 3.1-2.2M12 12.4c-1.2 1-3.2 1.6-5 1.4M12 12.4c1.2 1 3.2 1.6 5 1.4M12 13.2v4",fill:"none",stroke:"rgba(255,255,255,0.62)",strokeWidth:"1",strokeLinecap:"round"}),(0,i.jsx)("circle",{cx:"12",cy:"8.6",r:"1.1",fill:"rgba(255,255,255,0.85)"})]})}var Ko="0.10.
|
|
303
|
+
`;function Vg(){return(0,i.jsxs)("svg",{viewBox:"0 0 24 24","aria-hidden":"true",children:[(0,i.jsx)("defs",{children:(0,i.jsxs)("linearGradient",{id:"mg-brain",x1:"0",y1:"0",x2:"1",y2:"1",children:[(0,i.jsx)("stop",{offset:"0%",stopColor:"#FFC46B"}),(0,i.jsx)("stop",{offset:"50%",stopColor:"#F97316"}),(0,i.jsx)("stop",{offset:"100%",stopColor:"#EA580C"})]})}),(0,i.jsx)("path",{fill:"url(#mg-brain)",d:"M12 4.5a3.2 3.2 0 0 0-6.4.1 4.1 4.1 0 0 0-2.7 5.9 4.1 4.1 0 0 0 .6 6.7A4.2 4.2 0 0 0 12 18.2Z"}),(0,i.jsx)("path",{fill:"url(#mg-brain)",d:"M12 4.5a3.2 3.2 0 0 1 6.4.1 4.1 4.1 0 0 1 2.7 5.9 4.1 4.1 0 0 1-.6 6.7A4.2 4.2 0 0 1 12 18.2Z"}),(0,i.jsx)("path",{d:"M12 9.2c-.8-1.4-1.7-2-3.1-2.2M12 9.2c.8-1.4 1.7-2 3.1-2.2M12 12.4c-1.2 1-3.2 1.6-5 1.4M12 12.4c1.2 1 3.2 1.6 5 1.4M12 13.2v4",fill:"none",stroke:"rgba(255,255,255,0.62)",strokeWidth:"1",strokeLinecap:"round"}),(0,i.jsx)("circle",{cx:"12",cy:"8.6",r:"1.1",fill:"rgba(255,255,255,0.85)"})]})}var Ko="0.10.27";function ef({t:e,inModal:t,onClose:n,onFull:r,full:o}){let[l,a]=(0,v.useState)(null),[s,u]=(0,v.useState)([]),[p,h]=(0,v.useState)(!0),[y,m]=(0,v.useState)(null),[b,T]=(0,v.useState)(""),[w,D]=(0,v.useState)("all"),[c,d]=(0,v.useState)(""),[g,z]=(0,v.useState)("all"),[R,U]=(0,v.useState)("all"),[P,F]=(0,v.useState)(()=>{try{let N=new URLSearchParams(typeof location<"u"?location.search:"").get("tab"),W={cards:"cards",graph:"graph",usage:"stats",stats:"stats",audit:"audit",recycle:"optimize",optimize:"optimize",config:"config"};if(N&&W[N])return W[N]}catch{}return"cards"}),[q,_]=(0,v.useState)(()=>new Set([P])),te=(0,v.useCallback)(N=>{_(W=>W.has(N)?W:new Set(W).add(N)),F(N)},[]),[Ce,O]=(0,v.useState)("recent"),[Be,le]=(0,v.useState)(null),ge=(0,v.useRef)(null),dt=(0,v.useRef)(null),[L,ne]=(0,v.useState)(!1),[me,oe]=(0,v.useState)(null),[ce,Ne]=(0,v.useState)(!1),[k,j]=(0,v.useState)(!1),[A,re]=(0,v.useState)(!0),[Fe,ke]=(0,v.useState)(null),[de,Oe]=(0,v.useState)(0),qt=(0,v.useCallback)(()=>Oe(N=>N+1),[]),Zt=l?.quarantine?.pending??0,At=l?.quarantine?.deleted??0,xe=(0,v.useRef)(null),[Jt,$e]=(0,v.useState)(0),[C,x]=(0,v.useState)(!1),G=(0,v.useRef)(null),pe=(0,v.useRef)(null),ye=(0,v.useCallback)(async(N=w,W=c,H=R,J={})=>{let X=J.append===!0;try{X?x(!0):(h(!0),T(""));let we=new URLSearchParams;N&&N!=="all"&&we.set("kind",N),W.trim()&&we.set("q",W.trim()),we.set("status",H||"all"),we.set("limit",String(Wg)),we.set("offset",String(J.offset||0)),Ce==="title"&&we.set("sort","title");let it=await(await fetch(`${Z}/cards?${we.toString()}`)).json();if(it.ok){let Bt=it.cards||[];u(Et=>X?[...Et,...Bt.filter(Ht=>!Et.some(Go=>Go.path===Ht.path))]:Bt),$e(Number(it.total)||Bt.length)}}catch(we){T(String(we&&we.message?we.message:we))}finally{X?x(!1):h(!1)}},[w,c,R,Ce]),be=(0,v.useCallback)(async()=>{try{let[N,W]=await Promise.all([fetch(`${Z}/overview`).then(H=>H.json()),fetch(`${Z}/capture-log`).then(H=>H.json()).catch(()=>null)]);N.ok&&a(N),m(W&&W.ok&&W.available&&W.health||null)}catch(N){T(String(N&&N.message?N.message:N))}finally{h(!1)}},[]),f=async N=>{try{let H=await(await fetch(`${Z}/delete?path=${encodeURIComponent(N)}`)).json();H.ok?(le({ok:!0,msg:e("deleted")}),await be(),qt()):le({ok:!1,msg:H.error||e("deleteFail")})}catch{le({ok:!1,msg:e("deleteFail")})}ge.current&&clearTimeout(ge.current),ge.current=setTimeout(()=>le(null),2e3)};(0,v.useEffect)(()=>(be(),()=>{xe.current&&clearTimeout(xe.current)}),[be]);let Sn=N=>{d(N),xe.current&&clearTimeout(xe.current),xe.current=setTimeout(()=>ye(w,N,R),280)},De=N=>{D(N),ye(N,c,R)};(0,v.useEffect)(()=>{P==="cards"&&ye(w,c,R)},[R,P,de,Ce]),(0,v.useEffect)(()=>{if(P!=="cards"||p||C||s.length>=Jt)return;let N=G.current,W=pe.current;if(!N)return;let H=new IntersectionObserver(J=>{J[0]&&J[0].isIntersecting&&ye(w,c,R,{append:!0,offset:s.length})},{root:W||null,rootMargin:"400px"});return H.observe(N),()=>H.disconnect()},[P,s,Jt,p,C,w,c,R,ye]);let Kn=async N=>{try{let W=N.status;if(!W&&N.path)try{let Qe=((await fetch(`${Z}/cards?status=all&q=${encodeURIComponent(N.title||"")}&limit=50`).then(it=>it.json())).cards||[]).find(it=>it.path===N.path);Qe&&(W=Qe.status)}catch{}let H=W&&W!=="approved"?`&status=${encodeURIComponent(W)}`:"",J=await fetch(`${Z}/card?path=${encodeURIComponent(N.path||N.id)}${H}`),X=await J.json();X.ok?oe({path:X.path,title:N.title,text:X.text}):T(X.error||`HTTP ${J.status}`)}catch(W){T(String(W&&W.message?W.message:W))}},et=async(N,W)=>{ne(!0);try{let H=await fetch(`${Z}/export`);if(!H.ok)throw new Error("HTTP "+H.status);let J=await H.json();if(!J.ok)throw new Error(J.error||"export failed");let X,we,Qe;if(N==="json"){we="memory-vault.json",Qe="application/json";let Et=J.cards.map(Ht=>({path:Ht.path,title:Ht.title,kind:Ht.kind,status:Ht.status,store:Ht.store,text:Ht.text}));X=JSON.stringify({format:J.format||"memory-eternal-vault",formatVersion:J.formatVersion||1,exportedAt:J.exportedAt||new Date().toISOString(),count:Et.length,cards:Et},null,2)}else we="memory-vault.md",Qe="text/markdown;charset=utf-8",X=J.cards.map(Et=>Et.text.trim()).filter(Boolean).join(`
|
|
304
304
|
|
|
305
305
|
---
|
|
306
306
|
|