memory-eternal 0.10.29 → 0.10.32

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.
@@ -0,0 +1,61 @@
1
+ // 「常驻实例不可替换」这个**长期状态**的记忆(issue #30 第五轮复测:A: 盘那台的稳态)。
2
+ //
3
+ // 现场(报告者实测 5 天 44 次):
4
+ // 升级 → 端口上那个常驻实例仍是上一代 → 宿主探测到"漂移" → 拉起 --replace 助手
5
+ // → 助手前置探到 lock.writable = false → **正确地拒绝**(宁可拒绝也不留孤儿)
6
+ // → 旧实例继续存活 → 下次开机再报一次…… 于是「每一步都按设计工作,合起来永远换不掉」。
7
+ //
8
+ // 所以:**环境限制要当成状态记下来**,而不是每次都当"漂移"重试一遍。记下之后:
9
+ // · 面板 / align / version-check 能显示「常驻实例不可替换(环境限制)」并给出路;
10
+ // · 宿主不再每次开机都重试注定失败的替换(首次 warn,之后降级为 boot 级别的一条已知状态)。
11
+ import fs from 'node:fs'
12
+ import path from 'node:path'
13
+ import { watchdogLockPath } from './watchdog.js'
14
+
15
+ /** 状态文件与看门狗锁同目录(同一个 DSH_HOME)。 */
16
+ export function residentStatePath(env = process.env) {
17
+ return path.join(path.dirname(watchdogLockPath(env)), 'memory-eternal-state.json')
18
+ }
19
+
20
+ /** 读状态:不存在/损坏都返回 {}(绝不影响主流程)。 */
21
+ export function readResidentState(env = process.env) {
22
+ try {
23
+ const data = JSON.parse(fs.readFileSync(residentStatePath(env), 'utf8'))
24
+ return data && typeof data === 'object' ? data : {}
25
+ } catch { return {} }
26
+ }
27
+
28
+ function writeResidentState(env, data) {
29
+ const file = residentStatePath(env)
30
+ const tmp = file + '.tmp'
31
+ fs.mkdirSync(path.dirname(file), { recursive: true })
32
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf8')
33
+ fs.renameSync(tmp, file)
34
+ }
35
+
36
+ /**
37
+ * 记下「常驻实例不可替换(环境限制)」。
38
+ * @returns {{ok:boolean, recordedAt:number, firstTime:boolean}}
39
+ */
40
+ export function recordReplaceBlocked(env = process.env, { error = '', lockPath = '', port = 0, version = '', served = '' } = {}) {
41
+ const prev = readResidentState(env).replaceBlocked
42
+ const firstTime = !prev
43
+ const at = Date.now()
44
+ const data = { ...readResidentState(env), replaceBlocked: { at, error: String(error || '').slice(0, 300), lockPath: String(lockPath || ''), port: Number(port) || 0, version: String(version || ''), served: String(served || ''), count: (prev && Number(prev.count) || 0) + 1 } }
45
+ try { writeResidentState(env, data); return { ok: true, recordedAt: at, firstTime } } catch { return { ok: false, recordedAt: at, firstTime } }
46
+ }
47
+
48
+ /** 环境修好(锁可写了)→ 清掉这条状态。 */
49
+ export function clearReplaceBlocked(env = process.env) {
50
+ const cur = readResidentState(env)
51
+ if (!cur.replaceBlocked) return false
52
+ delete cur.replaceBlocked
53
+ try { writeResidentState(env, cur); return true } catch { return false }
54
+ }
55
+
56
+ /** 供界面/报告用:这条状态是否仍然成立(默认 7 天内算"当前有效")。 */
57
+ export function replaceBlockedInfo(env = process.env, { freshMs = 7 * 24 * 3600 * 1000 } = {}) {
58
+ const b = readResidentState(env).replaceBlocked
59
+ if (!b) return null
60
+ return { ...b, fresh: Date.now() - Number(b.at || 0) < freshMs }
61
+ }
package/lib/vault.js CHANGED
@@ -184,7 +184,7 @@ function rowToCard(row) {
184
184
  path: row.path,
185
185
  kind: row.kind,
186
186
  title: row.title,
187
- tags: JSON.parse(row.tags || '[]'),
187
+ tags: normalizeTags(row.tags),
188
188
  summary: row.summary || '',
189
189
  created: row.created_at,
190
190
  updated: row.updated_at,
@@ -199,7 +199,7 @@ function rowToCard(row) {
199
199
 
200
200
  /** 将 SQL 行还原为完整 Markdown 文本(frontmatter + body)。 */
201
201
  function rowToMarkdown(row) {
202
- const tags = JSON.parse(row.tags || '[]')
202
+ const tags = normalizeTags(row.tags)
203
203
  const lines = [
204
204
  '---',
205
205
  `formatVersion: 1`,
@@ -558,9 +558,43 @@ export async function setCardStatus(root, rel, status, { changedBy = 'system', r
558
558
  * `dedupAgainst` 传入候选集合时,去重只跟**这些**卡比(导入备份用:基线取导入前的快照,
559
559
  * 让同一批里互相近似的卡不会互吞)。
560
560
  */
561
+ /**
562
+ * 把各种形态的 tags 归一化成字符串数组(issue #41 的根因)。
563
+ *
564
+ * 现场:调用方通过 /write 传了**字符串**(`"[\"a\",\"b\"]"`),而 writeCard 直接 `JSON.stringify(tags)`
565
+ * → 落库变成「一个字符串的 JSON 表示」(双重编码)→ 读出来仍是字符串 → 前端 `tags.slice(...).map` 抛错
566
+ * → **整页黑屏**(React 根组件挂载失败)。自动沉淀路径本来就有 Array.isArray 检查,只有 /write 等直连入口漏了。
567
+ * 这里做成唯一入口:数组 / JSON 字符串 / 逗号串 / 双重编码的旧数据,全都收敛成数组。
568
+ */
569
+ export function normalizeTags(tags) {
570
+ let v = tags
571
+ if (typeof v === 'string') {
572
+ const s = v.trim()
573
+ if (!s) return []
574
+ // 最多剥两层:正常 JSON(["a","b"])+ 双重编码("[\"a\",\"b\"]" —— #41 的现场,
575
+ // 它以引号开头,所以只判断 startsWith('[') 会漏掉)。剥不动就按逗号串处理。
576
+ let cur = s
577
+ for (let i = 0; i < 2; i++) {
578
+ if (typeof cur !== 'string') break
579
+ const t = cur.trim()
580
+ if (!(t.startsWith('[') || t.startsWith('"'))) break
581
+ try { cur = JSON.parse(t) } catch { break }
582
+ }
583
+ v = typeof cur === 'string' ? cur.split(',') : cur
584
+ }
585
+ if (!Array.isArray(v)) return []
586
+ return v
587
+ .map((t) => String(t == null ? '' : t).trim())
588
+ .filter(Boolean)
589
+ .map((t) => t.slice(0, 30))
590
+ .slice(0, 20)
591
+ }
592
+
561
593
  export async function writeCard(root, { kind, title, tags = [], body, source = '', status = 'pending', submittedBy = '', severity = 'info', reason = '', deletedAt = '' }, { threshold = 0.62, dedup = true, dedupAgainst = null } = {}) {
562
594
  const db = getDb(root)
563
595
  const kindRoot = KIND_ROOTS[kind] || KIND_ROOTS.knowledge
596
+ // 唯一归一化入口:任何调用方(/write、MCP、脚本、自动沉淀)传字符串都不会再双重编码
597
+ tags = normalizeTags(tags)
564
598
 
565
599
  // 审核守卫:数据库层强制,调用方传的 status 仅作 fallback
566
600
  const enforcedStatus = enforceAudit(root, kind, submittedBy, status)
@@ -634,10 +668,10 @@ export async function mergeCards(root, paths) {
634
668
  if (rows.length < 2) return { ok: false, error: '读取失败' }
635
669
 
636
670
  const first = rows[0]
637
- const tags = new Set(JSON.parse(first.tags || '[]'))
671
+ const tags = new Set(normalizeTags(first.tags))
638
672
  let combined = ''
639
673
  rows.forEach((row, i) => {
640
- const rowTags = JSON.parse(row.tags || '[]')
674
+ const rowTags = normalizeTags(row.tags)
641
675
  rowTags.forEach((t) => tags.add(t))
642
676
  combined += (i ? '\n\n---\n\n' : '') + row.body.trim()
643
677
  })
@@ -784,7 +818,7 @@ export async function graph(root) {
784
818
  const bodyRows = db.prepare('SELECT path, body FROM cards').all()
785
819
  const bodyMap = new Map(bodyRows.map((r) => [r.path, r.body]))
786
820
  const cards = rows.map((r) => ({
787
- path: r.path, kind: r.kind, title: r.title, tags: JSON.parse(r.tags || '[]'),
821
+ path: r.path, kind: r.kind, title: r.title, tags: normalizeTags(r.tags),
788
822
  summary: r.summary || '', updated: r.updated_at, mtime: new Date(r.updated_at).getTime(), status: r.status,
789
823
  }))
790
824
 
@@ -983,7 +1017,7 @@ export async function stats(root) {
983
1017
  const weekRow = db.prepare('SELECT COUNT(*) as cnt FROM cards WHERE updated_at >= ?').get(weekAgo)
984
1018
  const tagRows = db.prepare('SELECT tags FROM cards').all()
985
1019
  const tagSet = new Set()
986
- for (const r of tagRows) { for (const t of JSON.parse(r.tags || '[]')) tagSet.add(t) }
1020
+ for (const r of tagRows) { for (const t of normalizeTags(r.tags)) tagSet.add(t) }
987
1021
 
988
1022
  const todayCardsRows = db.prepare('SELECT * FROM cards WHERE updated_at >= ? ORDER BY updated_at DESC LIMIT 30').all(dayAgo)
989
1023
  const todayCards = todayCardsRows.map((row) => ({ path: row.path, title: row.title, kind: row.kind, updated: row.updated_at, summary: (row.summary || '').slice(0, 80) }))
package/lib/watchdog.js CHANGED
@@ -108,14 +108,17 @@ export function probeLockWritable(env = process.env) {
108
108
  * 尝试取得看门狗锁(按端口分槽)。
109
109
  * @returns {{acquired:boolean, file:string, previous:object|null, reaped:number[]}} acquired=false 表示同端口已有活着的看门狗,调用方应直接退出。
110
110
  */
111
- export function acquireWatchdogLock({ env = process.env, port, vault = '', pid = process.pid, pkgVersion = currentPkgVersion() } = {}) {
111
+ export function acquireWatchdogLock({ env = process.env, port, vault = '', pid = process.pid, pkgVersion = currentPkgVersion(), force = false } = {}) {
112
112
  const file = watchdogLockPath(env)
113
113
  const cur = readWatchdogLock(env)
114
114
  const samePort = cur.watchdogs.filter((w) => Number(w.port) === Number(port) && w.pid !== pid)
115
115
  // endedAt 的槽是「已结束」的记录(releaseWatchdogLock 留下的痕迹,见 #30):**绝不能挡住新实例**
116
116
  // —— pid 可能已被复用、那个进程还活着,但那条记录明确表示它已经交出这把锁。
117
117
  const alive = samePort.find((w) => !w.endedAt && isPidAlive(w.pid))
118
- if (alive) return { acquired: false, file, previous: alive, reaped: [] }
118
+ // force:--replace 助手的语义就是「替换同端口那个」。不允许让位 —— 否则旧实例还活着时
119
+ // 新助手直接退出,而清场那一步可能根本没跑完(issue #30 实测:清场被十几秒的枚举拖住 →
120
+ // 助手退在写锁之前 → 锁永远为空 → verify 必然超时、restart 100% 失败)。
121
+ if (alive && !force) return { acquired: false, file, previous: alive, reaped: [] }
119
122
  // 清掉死掉的槽(含其它端口的陈旧记录),保留仍然存活的其它端口
120
123
  const reaped = []
121
124
  const kept = []
@@ -156,7 +159,9 @@ export function restoreWatchdogLock({ env = process.env, port, watchdogs = [] }
156
159
  const want = (Array.isArray(watchdogs) ? watchdogs : []).filter((w) => Number(w.port) === p)
157
160
  if (!want.length) return false
158
161
  const cur = readWatchdogLock(env)
159
- const liveSame = cur.watchdogs.filter((w) => Number(w.port) === p && isPidAlive(w.pid))
162
+ // 必须排除 endedAt 槽(与 acquireWatchdogLock 同一口径)——报告者实测:PID 复用/正在退出时,
163
+ // 一条本端口的 endedAt 槽会让这里误判「已有活着的登记」而放弃写回。
164
+ const liveSame = cur.watchdogs.filter((w) => Number(w.port) === p && !w.endedAt && isPidAlive(w.pid))
160
165
  if (liveSame.length) return false
161
166
  const others = cur.watchdogs.filter((w) => Number(w.port) !== p && isPidAlive(w.pid))
162
167
  writeWatchdogLock(env, [...others, ...want.map((w) => ({ ...w, restoredAt: new Date().toISOString() }))])
@@ -386,9 +391,10 @@ async function listWatchdogProcesses(exec, platform = process.platform, marker =
386
391
  * 两条对策:① `try/catch` 包住 spawn,失败就当「枚举不到」;② **用临时文件接 stdout**,完全不碰管道。
387
392
  */
388
393
  function defaultExec() {
389
- return async (cmd, args) => {
394
+ return async (cmd, args, opts = {}) => {
390
395
  const { spawn } = await import('node:child_process')
391
396
  let outFile = ''
397
+ let timer = null
392
398
  try {
393
399
  const os = await import('node:os')
394
400
  const path = await import('node:path')
@@ -401,6 +407,10 @@ function defaultExec() {
401
407
  } finally {
402
408
  try { fs.closeSync(fd) } catch { /* 子进程已继承则关父进程副本 */ }
403
409
  }
410
+ // 超时必须能掐断子进程:issue #30 实测 Windows 上 Get-NetTCPConnection 无条件要 4–7.5 秒
411
+ // (一次 13.6 秒),而调用方(接管清场)的 8000ms 预算根本管不住它 —— 于是决策基于过期数据。
412
+ const tm = Number(opts && opts.timeoutMs) || 0
413
+ if (tm > 0) timer = setTimeout(() => { try { child.kill() } catch { /* 已经在退出 */ } }, tm)
404
414
  await new Promise((resolve) => {
405
415
  child.on('error', () => resolve(''))
406
416
  child.on('close', () => resolve(''))
@@ -409,6 +419,7 @@ function defaultExec() {
409
419
  } catch {
410
420
  return '' // 受限环境:宁可「枚举不到」,也不能把调用方(如 --reap)崩掉
411
421
  } finally {
422
+ if (timer) clearTimeout(timer)
412
423
  if (outFile) { try { (await import('node:fs')).unlinkSync(outFile) } catch { /* 清理失败无妨 */ } }
413
424
  }
414
425
  }
@@ -820,6 +831,8 @@ export async function restartResident({
820
831
  export async function clearResidentForTakeover({ port = 7999, env = process.env, timeoutMs = 8000, deps = {} } = {}) {
821
832
  const p = Number(port) || 7999
822
833
  const findListener = deps.findListener || findPortListener
834
+ // 枚举本身要能被时间预算约束(否则 8s 预算被一次 13.6s 的 PowerShell 查询吃光,决策基于过期数据)
835
+ const listenerOpts = { timeoutMs: Math.max(500, Math.min(2500, Number(timeoutMs) || 8000)) }
823
836
  const out = { stopped: [], webStopped: [], portStopped: [], warnings: [], failed: [], portFree: false }
824
837
  const rawKill = deps.kill || ((pid) => { try { process.kill(pid, 'SIGTERM'); return true } catch { return false } })
825
838
  // 记下「已经判定收不掉」的 pid:立刻再来一次只会同样失败,白折腾还可能误导用户以为是权限抖动
@@ -841,14 +854,14 @@ export async function clearResidentForTakeover({ port = 7999, env = process.env,
841
854
  }
842
855
  const wait = deps.sleep || ((ms) => new Promise((r) => setTimeout(r, ms)))
843
856
  let left = null
844
- try { left = await findListener(p) } catch { left = null }
857
+ try { left = await findListener(p, listenerOpts) } catch { left = null }
845
858
  const deadline = Date.now() + Math.max(0, Number(timeoutMs) || 0)
846
859
  // 只对「确认是本插件的 web」下手:别人的进程占着端口时只如实报告,不误杀(同 issue #23 的口径)。
847
860
  // 已经判定收不掉的 pid 不再重试 —— 立刻再来一次只会同样失败。
848
861
  while (left && left.pid && looksLikeOurWeb(left.command) && !failedKills.has(Number(left.pid)) && Date.now() < deadline) {
849
862
  if (!killFn(left.pid)) break
850
863
  await wait(200)
851
- try { left = await findListener(p) } catch { left = null }
864
+ try { left = await findListener(p, listenerOpts) } catch { left = null }
852
865
  }
853
866
  if (left && left.pid) {
854
867
  out.portFree = false
@@ -904,19 +917,20 @@ export function parseLsofFields(text) {
904
917
  * @param {{exec?: (cmd:string,args:string[])=>Promise<string>}} [opts]
905
918
  * @returns {Promise<{pid:number, command:string}|null>}
906
919
  */
907
- export async function findPortListener(port, { exec, platform = process.platform } = {}) {
920
+ export async function findPortListener(port, { exec, platform = process.platform, timeoutMs = 4000 } = {}) {
908
921
  const p = Number(port)
909
922
  if (!Number.isFinite(p) || p <= 0) return null
910
923
  const runExec = exec || defaultExec()
924
+ const execOpts = { timeoutMs: Math.max(200, Number(timeoutMs) || 4000) }
911
925
  try {
912
926
  if (platform === 'win32') {
913
- const out = await runExec('powershell', ['-NoProfile', '-Command', `Get-NetTCPConnection -LocalPort ${p} -State Listen -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess`])
927
+ const out = await runExec('powershell', ['-NoProfile', '-Command', `Get-NetTCPConnection -LocalPort ${p} -State Listen -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess`], execOpts)
914
928
  const pid = Number(String(out || '').split(/\r?\n/).map((s) => s.trim()).find((s) => /^\d+$/.test(s))) || 0
915
929
  if (!pid) return null
916
- const cmd = await runExec('powershell', ['-NoProfile', '-Command', `(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}" | Select-Object -ExpandProperty CommandLine)`])
930
+ const cmd = await runExec('powershell', ['-NoProfile', '-Command', `(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}" | Select-Object -ExpandProperty CommandLine)`], execOpts)
917
931
  return { pid, command: String(cmd || '').trim() }
918
932
  }
919
- const fields = await runExec('lsof', ['-nP', `-iTCP:${p}`, '-sTCP:LISTEN', '-Fpcn'])
933
+ const fields = await runExec('lsof', ['-nP', `-iTCP:${p}`, '-sTCP:LISTEN', '-Fpcn'], execOpts)
920
934
  const list = parseLsofFields(fields)
921
935
  if (!list.length) return null
922
936
  const pid = list[0].pid
@@ -1287,6 +1301,12 @@ if (process.argv[1] && /[\\/]watchdog\.js$/.test(process.argv[1])) {
1287
1301
  log(summary)
1288
1302
  if (cleared.failed.length) log('⚠ 未能停止:pid ' + cleared.failed.join(', '))
1289
1303
  if (!cleared.portFree) {
1304
+ // issue #30:**清场失败也必须留下登记**。老实现直接 exit(1),于是锁里什么都没有 ——
1305
+ // 而 restartResident 的自检要求「锁里出现新看门狗」,必然超时,用户看到的永远是「(无响应)」。
1306
+ // 登记一条(pid 已退出时会自动落成 endedAt 记录,不挡后续),现场从此可复盘。
1307
+ const claim = acquireWatchdogLock({ port, vault: vaultRoot || '', pkgVersion: currentPkgVersion(), force: true })
1308
+ if (claim.acquired) log('已登记本次替换尝试(端口仍被占用,看门狗未启动):pid ' + process.pid)
1309
+ else log('⚠ 未能登记本次替换(锁不可写?):' + (claim.file || ''))
1290
1310
  const why = `${summary} —— 端口没空出来,已中止接管(不退让到 port+1,避免又留一个孤儿 web)`
1291
1311
  await noteReplace('fail', why)
1292
1312
  process.exit(1)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memory-eternal",
3
- "version": "0.10.29",
3
+ "version": "0.10.32",
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 && node tests/db-restore.test.mjs && node tests/align.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 && node tests/align.test.mjs && node tests/tags-normalize.test.mjs && node tests/resident-state.test.mjs"
68
68
  },
69
69
  "dependencies": {
70
70
  "@deepseek-ai/schemastery": "^3.18.1"