memory-eternal 0.10.26 → 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.
@@ -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.26",
4
+ "version": "0.10.27",
5
5
  "description": "记忆核心(Memory Eternal):对话结束后自动沉淀知识卡到本地记忆库(~/.dsh/memory-vault),支持检索 / 召回 / 审核中心。",
6
6
  "author": {
7
7
  "name": "EternalNight996",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memory-eternal",
3
- "version": "0.10.26",
3
+ "version": "0.10.27",
4
4
  "description": "记忆核心(Memory Eternal):对话结束后自动沉淀知识卡到本地记忆库(~/.dsh/memory-vault),支持检索 / 召回 / 审核中心。",
5
5
  "author": {
6
6
  "name": "EternalNight996",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memory-eternal",
3
- "version": "0.10.26",
3
+ "version": "0.10.27",
4
4
  "description": "记忆核心(Memory Eternal):对话结束后自动沉淀知识卡到本地记忆库(~/.dsh/memory-vault),支持检索 / 召回 / 审核中心。",
5
5
  "author": {
6
6
  "name": "EternalNight996",
package/README.en.md CHANGED
@@ -361,14 +361,15 @@ curl http://127.0.0.1:7999/memory-eternal/api/web-info # is the standalone we
361
361
 
362
362
  | Version | Date | Highlights |
363
363
  |---|---|---|
364
+ | **v0.10.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** |
364
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** |
365
- | **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** |
366
366
 
367
367
  <details>
368
- <summary>Older release notes (v0.10.24 and earlier · 51 releases, click to expand)</summary>
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** |
372
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** |
373
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** |
374
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** |
package/README.md CHANGED
@@ -393,14 +393,15 @@ curl http://127.0.0.1:7999/memory-eternal/api/web-info # 独立 web 是否活
393
393
 
394
394
  | 版本 | 日期 | 关键改动 |
395
395
  |---|---|---|
396
+ | **v0.10.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 项** |
396
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 项** |
397
- | **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 项** |
398
398
 
399
399
  <details>
400
- <summary>更早的更新日志(v0.10.24 及以前 · 共 51 版,点开查看)</summary>
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 项** |
404
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 项** |
405
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 项** |
406
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 项** |
@@ -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/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.26";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(`
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
 
@@ -441,6 +441,112 @@ export function closeAllDb() {
441
441
  * SQLite 备份:用 VACUUM INTO 创建紧凑副本。
442
442
  * 保留最近 maxKeep 个备份,自动清理旧的。
443
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
+
444
550
  export async function backupDb(root, { maxKeep = 7 } = {}) {
445
551
  const resolved = path.resolve(root)
446
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.26",
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"
@@ -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
- throw new Error(`npm 上查不到 ${pkg.version}(version 端点超时):发布可能未生效,请手动 \`npm publish --access public\` 后复核 https://registry.npmjs.org/memory-eternal/${pkg.version}`)
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.26";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(`
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