memory-eternal 0.9.0 → 0.9.2

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/README.en.md CHANGED
@@ -37,6 +37,14 @@ dsh plugin --profile web add memory-eternal
37
37
 
38
38
  # or update directly in the profile (pnpm workspace). Use pnpm — `npm install` throws EUNSUPPORTEDPROTOCOL
39
39
  cd ~/.dsh/profiles/web && pnpm add memory-eternal@latest
40
+
41
+ # right after a release, @latest can be held back by pnpm's minimum-release-age gate or a cached
42
+ # packument (it silently stays on the old version): pin the version instead — pnpm adds it to the
43
+ # workspace's minimumReleaseAgeExclude automatically
44
+ cd ~/.dsh/profiles/web && pnpm add memory-eternal@0.9.0
45
+
46
+ # verify the version, then restart dsh web
47
+ node -e "console.log(require('memory-eternal/package.json').version)"
40
48
  ```
41
49
 
42
50
  After **restarting dsh web**, three things are live immediately:
@@ -182,6 +190,8 @@ Auto-capture is a silent background pipeline: when it broke, the only symptom us
182
190
 
183
191
  ## 🛡️ Audit Center & Recycle Bin
184
192
 
193
+ > **Agents cannot approve on your behalf (since v0.9.1).** Whenever audit is active, the plugin injects a red line into the agent context: new cards stay `pending`, and calling the `approve`/`reject` endpoints (or any equivalent bypass) is forbidden. The decision is always yours.
194
+
185
195
  New cards go to the **Audit Center** (`pending`) by default and enter the main vault only after you approve them; rejected ones move to "Rejected", where you can restore or delete to the recycle bin. Cards matching an exemption (audit mode = skip all / exempt agents / exempt kinds) go straight in; recycle-bin cards are recoverable within 30 days, then auto-purged.
186
196
 
187
197
  <p align="center">
@@ -236,6 +246,8 @@ npm run build # builds lib/client.js (DSH embed) + web/app.js (standalone web
236
246
 
237
247
  | Version | Date | Highlights |
238
248
  |---|---|---|
249
+ | **v0.9.2** | 2026-09-12 | **Log visibility + false-alarm fix**: the capture log is now **persisted to disk** (`~/.dsh/memory-eternal-capture.jsonl`, last 500 entries) — the standalone web page no longer says "log only available inside the DSH host" but shows the records, and history survives a host restart (read back on boot); fixed **stall-detector false alarms flooding the panel** (mismatched turn counters are normal: sub-agent turns and cancelled turns emit no `turn-stopping`; it now requires "turns running **and** no capture activity for 15 minutes" to alert once, and the alarm clears as soon as activity resumes — no more one `fail` line every 10 minutes permanently turning health red); when distillation returns nothing / unparseable JSON the content is **saved as a raw card instead of being dropped** |
250
+ | **v0.9.1** | 2026-09-12 | **Performance & UX batch**: memory page loads lazily per view (opening Settings no longer drags in 250KB of cards + 890KB of graph; first paint needs ~0.5KB); graph generation **3232ms → 43ms** (bigram sets computed once + lossless mathematical-bound pruning + tag inverted index) plus a server-side cache with **fingerprint invalidation** (write/audit/delete rebuilds immediately, no stale reads); site-wide **brotli q5** (graph 877KB → **54KB**, 34% smaller than gzip; cards 246KB → 83KB → 20KB with br) and large responses compressed with both br and gzip keeping the smaller, so no response is ever worse than gzip; `/cards` gained **real pagination** (offset/limit/total + server-side title sort, globally ordered across pages) loading 100 cards first and fetching more on scroll; the "Agent cards" panel in Usage/Today filters by agent server-side (30 rows); **audit red line**: the plugin injects "never call approve/reject on the user's behalf" into the agent context; the watchdog now **reaps redundant instances on fallback ports** (a duplicate web process holding 76MB was reaped in testing) |
239
251
  | **v0.9.0** | 2026-09-12 | **Fixed silent auto-capture death (6 days without a single card)**: after a DSH upgrade the session event API moved from `events` to `ownEvents()/snapshotEvents()`; the listener read nothing, returned silently and left no trace — now it tries `ownEvents()` → `snapshotEvents()` → `events` and logs a per-session attach entry. Daily quota now counts **real writes only** (previously every "not worth saving" verdict burned quota, exhausting the 24h budget after 40 attempts); `captureCooldownMs` actually applies; a single capture input is tail-capped at 20k chars. New **capture log** panel and **active failure alerts** (red strip + agent-context injection + turn-stopping stall sweep). Removed the automatic "Daily review" digest (timer / `/todayBrief` route / UI button). |
240
252
  | **v0.8.0** | 2026-09-06 | **Full UI i18n**: follows the DSH system language live (zh/en) across cards, graph, audit center, config panel and new-card templates; SQLite EBUSY fix for the Windows test suite |
241
253
  | **v0.3.1** | 2026-09-03 | Knowledge graph loads on demand (fetch on open, unregister on close) |
package/README.md CHANGED
@@ -37,6 +37,13 @@ dsh plugin --profile web add memory-eternal
37
37
 
38
38
  # 或直接在 profile(pnpm workspace)里更新。注意用 pnpm,npm install 会报 EUNSUPPORTEDPROTOCOL
39
39
  cd ~/.dsh/profiles/web && pnpm add memory-eternal@latest
40
+
41
+ # 刚发新版时 @latest 可能被 pnpm 的最小发布年龄门槛/元数据缓存挡住(静默停在旧版):
42
+ # 直接指定版本号最快,pnpm 会自动把它加进 workspace 的 minimumReleaseAgeExclude
43
+ cd ~/.dsh/profiles/web && pnpm add memory-eternal@0.9.0
44
+
45
+ # 验版本(应等于上面装的号),然后重启 dsh web 生效
46
+ node -e "console.log(require('memory-eternal/package.json').version)"
40
47
  ```
41
48
 
42
49
  **重启 dsh web** 后,三样东西立即生效:
@@ -204,6 +211,7 @@ dsh-memory watchdog [--port 7799] # 看门狗保活 web(独立进程)
204
211
 
205
212
  - **待审核 / 已驳回** 双页签,按类型 / 日期 / 智能体筛选,全选后一键「批准 / 驳回 / 删除进回收站」。
206
213
  - 审核规则在「记忆配置 → 自动审核配置」:`审核模式`(全部要审 / 全部免审)+ `免审智能体` + `免审类型` + `回收保留天数`。
214
+ - **Agent 不能代你审批(v0.9.1 起)**:只要审核生效,插件就往 Agent 的上下文里注入红线——新卡只能停在「待审核」,并明确禁止调用 `approve`/`reject` 接口或任何等价方式绕过;判断权始终在你手里。
207
215
 
208
216
  ### 🥇 为什么审核系统更靠谱(对比其它记忆产品)
209
217
 
@@ -251,6 +259,8 @@ npm run build # 构建 lib/client.js(DSH 内嵌)+ web/app.js(独立 web
251
259
 
252
260
  | 版本 | 日期 | 关键改动 |
253
261
  |---|---|---|
262
+ | **v0.9.2** | 2026-09-12 | **日志可见性 + 误报修复**:自动沉淀日志**落盘持久化**(`~/.dsh/memory-eternal-capture.jsonl`,最多 500 条)——独立 Web 页不再显示「日志仅在 DSH 宿主内可用」而是直接能看到记录,宿主重启后历史也不丢(启动时读回);修复**停滞探测误报刷屏**(轮次计数对不上其实是常态:子代理轮次、被取消的轮次都不发 `turn-stopping`;现在要求「有轮次在跑 **且** 15 分钟无任何沉淀活动」才报一次,活动恢复即自动撤销,不再每 10 分钟刷一条把面板挤满、健康状态永久亮红);蒸馏无输出/JSON 解析失败时**改为原文卡兜底**,内容不再白丢 |
263
+ | **v0.9.1** | 2026-09-12 | **性能与体验批次**:记忆页按视图**懒加载**(打开「设置」不再顺带拉 250KB 卡片 + 890KB 图谱,首屏只需约 0.5KB);知识图谱生成 **3232ms → 43ms**(bigram 集合只算一次 + 数学上界剪枝无损跳过 + 标签倒排),并加**指纹精确失效**的服务端缓存(写卡/审核/删卡立即重建,无脏读);全站 **brotli q5**(图谱 877KB → **54KB**,比 gzip 再小 34%;卡片 246KB → 83KB → br 20KB),大响应体同时压 br + gzip 取更小者,保证任何响应都不比 gzip 差;`/cards` 支持**真分页**(offset/limit/total + 服务端标题排序,跨页全局有序),首屏只拉 100 条、滚到底续拉;「用量/今日」的 Agent 卡清单改为服务端署名过滤(30 条);**审核红线**:插件在 Agent 上下文注入「禁止调用 approve/reject 代用户审批」;watchdog **回收备用端口的多余实例**(实测清掉一个常驻 76MB 的重复 web 进程) |
254
264
  | **v0.9.0** | 2026-09-12 | **修复自动沉淀静默失效(一连 6 天不写卡)**:DSH 升级后会话事件接口由 `events` 改为 `ownEvents()/snapshotEvents()`,监听器读不到事件后静默返回、不留任何痕迹 —— 现改为三级自适应取法(`ownEvents()` → `snapshotEvents()` → `events`)+ 每会话接入留痕;日配额改为**只统计真实写卡**(此前「不值得保存」的判定也照样扣配额,40 次即耗尽 24h 配额)、`captureCooldownMs` 冷却真正生效、单次沉淀输入截尾 20000 字;新增**自动沉淀日志**面板与**异常主动提示**(页面红条 + 注入 Agent 上下文 + 轮次收尾停滞巡检);移除「每日回顾」自动简报(定时器 / `/todayBrief` 接口 / UI 按钮) |
255
265
  | **v0.8.0** | 2026-09-06 | **全套 UI i18n**:跟随 DSH 系统语言实时切换(中/英),覆盖知识卡 / 知识图谱 / 审核中心 / 配置面板 / 新建卡模板;Windows 测试收尾 SQLite 连接 EBUSY 修复 |
256
266
  | **v0.3.1** | 2026-09-03 | 知识图谱按需加载(点击时 fetch,关闭时注销) |
package/index.js CHANGED
@@ -28,7 +28,8 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
28
28
  import { ensureVault, search } from './lib/vault.js'
29
29
  import { migrateFromMarkdown, setAuditConfig, backupDb } from './lib/db.js'
30
30
  import { summarizeTurn, extractLastTurn, sliceNewEvents, sessionEvents, sessionEventApi, createCaptureHealth, resolveRoute, captureCard, captureUpdate, pickNeighbors } from './lib/capture.js'
31
- import { createApi, json } from './lib/api.js'
31
+ import { createApi, json, encodeBody } from './lib/api.js'
32
+ import { appendCaptureLog, readCaptureLog, rotateCaptureLog } from './lib/capture-log.js'
32
33
 
33
34
  export const name = 'memory-eternal'
34
35
  export const inject = ['systemPrompt', 'settings']
@@ -164,11 +165,23 @@ export function apply(ctx, config) {
164
165
  if (typeof refreshPrompt !== 'function') return
165
166
  try { refreshPrompt(settings.get()) } catch { /* 提示段刷新失败不影响沉淀 */ }
166
167
  }
168
+ let logWrites = 0 // 累计写入行数(每 100 行裁剪一次日志文件)
169
+ let lastActivityAt = Date.now() // 最近一次「管线有活口」的时间(见下:listen/读事件/写卡都算)
170
+ let stallAlerted = false // 停滞告警只在状态翻转时报一次,不刷屏
167
171
  const logCapture = (sessionId, action, reason, extra = {}) => {
168
- captureLog.push({ time: Date.now(), sessionId: String(sessionId || 'unknown').slice(0, 40), action, reason, ...extra })
172
+ const entry = { time: Date.now(), sessionId: String(sessionId || 'unknown').slice(0, 40), action, reason, ...extra }
173
+ captureLog.push(entry)
169
174
  if (captureLog.length > CAPTURE_LOG_MAX) captureLog.splice(0, captureLog.length - CAPTURE_LOG_MAX)
175
+ // 落盘:独立 Web 页 + 宿主重启后都还能看到这段历史(失败静默,不影响沉淀)
176
+ appendCaptureLog(entry, process.env).catch(() => {})
177
+ if (++logWrites % 100 === 0) rotateCaptureLog(process.env).catch(() => {})
170
178
  if (action === 'fail') { health.fail(reason); touchPrompt() }
171
179
  else if (action === 'created' || action === 'appended') { health.succeed(); touchPrompt() }
180
+ // 只要监听器还在拿到轮次(listen 行)就说明管线活着 —— 停滞告警自动撤销。
181
+ if (action !== 'fail') {
182
+ lastActivityAt = Date.now()
183
+ if (stallAlerted && health.snapshot().ok === false) { stallAlerted = false; health.succeed(); touchPrompt() }
184
+ }
172
185
  }
173
186
  const preview = (text) => String(text || '').replace(/\s+/g, ' ').trim().slice(0, 60)
174
187
  // 单次沉淀送进 LLM 的对话上限(字符):够覆盖一轮长对话,又不会把整段历史塞进去。
@@ -176,6 +189,11 @@ export function apply(ctx, config) {
176
189
  // 启动留痕:面板里看不到这条 = host 半边没加载(而不是「没东西可沉淀」)。
177
190
  {
178
191
  const c = settings.get() ?? {}
192
+ // 先把上次进程的历史读回面板(重启不丢记录),再写下本次 boot 行
193
+ readCaptureLog(CAPTURE_LOG_MAX, process.env).then((old) => {
194
+ for (const e of old.reverse()) captureLog.unshift(e)
195
+ if (captureLog.length > CAPTURE_LOG_MAX) captureLog.splice(0, captureLog.length - CAPTURE_LOG_MAX)
196
+ }).catch(() => {})
179
197
  logCapture('system', 'boot', `记忆核心 v${versionRef || '?'} 已加载:自动沉淀 ${c.enabled !== false && c.autoCapture !== false ? '开' : '关'} · 库 ${vaultDir()}`)
180
198
  }
181
199
  const lastCaptureAt = new Map() // sessionId -> 上次实际发起蒸馏的时间戳
@@ -276,7 +294,24 @@ export function apply(ctx, config) {
276
294
  const draft = { title: '', body: text.slice(0, 400) }
277
295
  const neighbors = cfg.dedupByLLM === false ? [] : await pickNeighbors(vaultDir(), draft, 8)
278
296
  const result = await summarizeTurn(llm, route, text, { signal: AbortSignal.timeout(45000), existing: neighbors, maxTokens: cfg.captureMaxTokens ?? 900 })
279
- if (!result) { logCapture(sessionId, 'skip', '模型无输出或 JSON 解析失败', { model: route.model }); return }
297
+ if (!result) {
298
+ // 蒸馏失败不能让内容白丢:退成原文卡(与「关闭蒸馏」同一条降级路径)。
299
+ const raw = await captureCard(vaultDir(), {
300
+ kind: 'content',
301
+ title: text.replace(/\s+/g, ' ').slice(0, 40) || '未命名记录',
302
+ tags: ['raw'],
303
+ body: text,
304
+ source,
305
+ status: resolveAuditStatus(cfg, 'content', source),
306
+ submittedBy: source,
307
+ severity: 'info',
308
+ reason: 'AI 自动沉淀(蒸馏无输出 → 原文卡兜底)',
309
+ }, { threshold: cfg.dedupThreshold })
310
+ if (raw.ok) { countWrite(); logCapture(sessionId, 'created', '蒸馏无输出 → 原文卡兜底', { path: raw.path ?? raw.rel, kind: 'content', model: route.model }) }
311
+ else if (raw.duplicate) { countWrite(); logCapture(sessionId, 'appended', '蒸馏无输出 + 与已有卡重复 → 追加更新', { path: raw.duplicate.path, model: route.model }) }
312
+ else logCapture(sessionId, 'fail', `蒸馏无输出,且兜底原文卡也失败:${raw.reason || '未知原因'}`, { model: route.model })
313
+ return
314
+ }
280
315
  if (result.save !== true) { logCapture(sessionId, 'skip', '模型判定不值得保存', { model: route.model }); return }
281
316
  if (result.append_to) {
282
317
  // 模型判定属于已有卡 → 追加更新记录,不新建(boujoy 语义)。
@@ -381,6 +416,10 @@ export function apply(ctx, config) {
381
416
  '3. 若检索结果为空,就诚实说明当前记忆库没有相关内容,不要编造。',
382
417
  '4. 知识卡存储在 SQLite 数据库中(memory-eternal.db),**禁止**用文件工具直接读写 vault 目录下的任何文件。沉淀记忆必须通过 memory_recall 工具或 /memory-eternal/api/write API。',
383
418
  '5. 若下方出现「自动沉淀异常」,必须在本次回复的第一句用中文转述该异常并提醒用户处理,不要自行猜测或尝试修复。',
419
+ // 审核红线(主上 2026-09-12 强制要求):只在审核真正生效时注入。
420
+ ...((cfg.auditMode ?? 'all') === 'none' ? [] : [
421
+ '6. **审核红线(强制)**:写卡一律停在 pending,由用户在「审核中心」审批。**禁止**调用 /memory-eternal/api/audit/approve 或 /audit/reject 代替用户审批,也不得用任何等价方式绕过审核;用户说「记录一下 / 增加记忆」不等于允许免审入库。需要立刻可用时,写卡后明确告知用户「已进审核中心,待批准」。',
422
+ ]),
384
423
  ].join('\n')
385
424
  // 异常提示:沉淀管线坏了,靠这一句把消息送到用户面前(不依赖用户去翻页面)。
386
425
  const h = health.snapshot()
@@ -445,11 +484,15 @@ export function apply(ctx, config) {
445
484
  }))
446
485
  }
447
486
 
448
- // 沉淀停滞探测:有轮次开始、却迟迟没有收尾(>15 分钟),说明 turn-stopping 没到。
487
+ // 沉淀停滞探测:只有「有轮次在跑」且「管线 15 分钟一个活口都没有」才报(真死才报)。
488
+ // 计数器对不上是常态(子代理轮次、被取消的轮次都不发 turn-stopping),单看计数会误报刷屏。
449
489
  const stallTimer = setInterval(() => {
450
- if (turnsStarted > turnsStopped && lastClaimAt && Date.now() - lastClaimAt > 15 * 60 * 1000) {
451
- // 经 logCapture 统一走「记日志 + 置异常 + 刷提示段」一条路
452
- logCapture('system', 'fail', `轮次收尾事件未触发(agent/turn-stopping 没到):已开始 ${turnsStarted} / 已收尾 ${turnsStopped}——DSH 可能改了事件名或作用域`)
490
+ const idleMs = Date.now() - lastActivityAt
491
+ const claimIdleMs = lastClaimAt ? Date.now() - lastClaimAt : 0
492
+ const suspicious = turnsStarted > turnsStopped && claimIdleMs > 15 * 60 * 1000 && idleMs > 15 * 60 * 1000
493
+ if (suspicious && !stallAlerted) {
494
+ stallAlerted = true
495
+ logCapture('system', 'fail', `轮次收尾事件未触发(agent/turn-stopping 没到):已开始 ${turnsStarted} / 已收尾 ${turnsStopped},且 15 分钟无任何沉淀活动——DSH 可能改了事件名或作用域`)
453
496
  }
454
497
  }, 10 * 60 * 1000)
455
498
  ctx.effect(() => () => clearInterval(stallTimer), 'memory-eternal: capture stall watch')
@@ -600,8 +643,13 @@ export function apply(ctx, config) {
600
643
  const webRoot = path.join(PACKAGE_ROOT, 'web')
601
644
  if (pathname.endsWith('app.js')) {
602
645
  const buf = await readFile(path.join(webRoot, 'app.js'))
603
- res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8', 'Cache-Control': 'no-store' })
604
- return res.end(buf)
646
+ // client bundle 270KB,每次打开配置页都要重下 —— 支持 gzip 的客户端走压缩
647
+ const headers = { 'Content-Type': 'application/javascript; charset=utf-8', 'Cache-Control': 'no-store', 'Vary': 'Accept-Encoding' }
648
+ const { body: out, encoding } = encodeBody(res, buf)
649
+ if (encoding) headers['Content-Encoding'] = encoding
650
+ headers['Content-Length'] = out.length
651
+ res.writeHead(200, headers)
652
+ return res.end(out)
605
653
  }
606
654
  const buf = await readFile(path.join(webRoot, 'index.html'))
607
655
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' })
package/lib/api.js CHANGED
@@ -11,7 +11,8 @@
11
11
  // - getSettings() → 插件设置对象(/budget /compress 读取;可选,缺省 {})
12
12
  // - getCaptureLog() → 自动沉淀运行轨迹(DSH host 注入;独立 web server 无此数据)
13
13
 
14
- import { listCards, readCard, search, searchAll, graph, graphAll, overview, exportCards, deleteCard, writeCard, parseCard, stats, optimizeCandidates, optimizeApply, addFeedback, dailyCounts, mergeCards, ensureVault, auditQueue, recycleList, purgeExpired, setCardStatus, restoreCard } from './vault.js'
14
+ import { gzipSync, brotliCompressSync, constants as zlibConstants } from 'node:zlib'
15
+ import { listCards, countCards, readCard, search, searchAll, graph, graphAll, overview, exportCards, deleteCard, writeCard, parseCard, stats, optimizeCandidates, optimizeApply, addFeedback, dailyCounts, mergeCards, ensureVault, auditQueue, recycleList, purgeExpired, setCardStatus, restoreCard } from './vault.js'
15
16
  import { compressExcerpt } from './capture.js'
16
17
  import { getSetupStatus, runSetup, mcpAgentAction } from './setup.js'
17
18
  import { backupDb } from './db.js'
@@ -44,17 +45,25 @@ export function createApi(deps = {}) {
44
45
  const kind = query.get('kind') || ''
45
46
  const q = query.get('q') || ''
46
47
  const status = (query.get('status') || 'approved').toLowerCase()
47
- const limit = Math.min(Number(query.get('limit')) || 200, 500)
48
+ // 真分页:offset/limit + total(客户端据此判断还有没有下一页)
49
+ const limit = Math.min(Math.max(Number(query.get('limit')) || 200, 1), 500)
50
+ const offset = Math.max(Number(query.get('offset')) || 0, 0)
51
+ const sort = query.get('sort') === 'title' ? 'title' : 'recent'
52
+ const agent = query.get('agent') || '' // 服务端署名过滤(如 agent=deepseek-harness)
48
53
  // all = 全部(含 pending/rejected,排除回收站);默认/approved = 已审核。
49
54
  const want = status === 'all' ? ['approved', 'pending', 'rejected'] : status === 'pending' || status === 'rejected' ? [status] : ['approved']
50
- let cards = await listCards(vaultRoot, { status: want })
51
- if (kind) cards = cards.filter((c) => c.kind === kind)
52
55
  if (q.trim()) {
53
- const hits = await search(vaultRoot, q, { limit: 200 })
56
+ // 检索是相关性排序:先把命中集合取齐,再在内存里分页(保证 total 与顺序一致)
57
+ const hits = await search(vaultRoot, q, { limit: 500 })
54
58
  const hitPaths = new Set(hits.map((h) => h.path))
55
- cards = cards.filter((c) => hitPaths.has(c.path))
59
+ let cards = (await listCards(vaultRoot, { status: want, kind, agent })).filter((c) => hitPaths.has(c.path))
60
+ const total = cards.length
61
+ json(res, 200, { ok: true, cards: cards.slice(offset, offset + limit), total, offset, limit })
62
+ return
56
63
  }
57
- json(res, 200, { ok: true, cards: cards.slice(0, limit) })
64
+ const total = await countCards(vaultRoot, { status: want, kind, agent })
65
+ const cards = await listCards(vaultRoot, { status: want, kind, agent, limit, offset, sort })
66
+ json(res, 200, { ok: true, cards, total, offset, limit })
58
67
  return
59
68
  }
60
69
  case '/card': {
@@ -86,9 +95,10 @@ export function createApi(deps = {}) {
86
95
  }
87
96
  case '/capture-log': {
88
97
  // 最近 100 条自动沉淀运行轨迹(监听 → 判定 → 写入/跳过原因)+ 健康状态。
89
- const log = getCaptureLog()
98
+ // 宿主侧是内存缓冲(同步),独立 Web 侧是从 JSONL 读文件(异步)→ 统一 await。
99
+ const log = await getCaptureLog()
90
100
  if (!log) return json(res, 200, { ok: true, available: false, entries: [], health: null })
91
- json(res, 200, { ok: true, available: true, health: getCaptureHealth(), entries: log.slice(0, 100) })
101
+ json(res, 200, { ok: true, available: true, health: await getCaptureHealth(), entries: log.slice(0, 100) })
92
102
  return
93
103
  }
94
104
  case '/export': {
@@ -317,12 +327,69 @@ export function createApi(deps = {}) {
317
327
 
318
328
  export function json(res, status, body) {
319
329
  const payload = JSON.stringify(body)
320
- res.writeHead(status, {
330
+ const headers = {
321
331
  'Content-Type': 'application/json; charset=utf-8',
322
- 'Content-Length': Buffer.byteLength(payload),
323
332
  'Cache-Control': 'no-store',
324
- })
325
- res.end(payload)
333
+ 'Vary': 'Accept-Encoding',
334
+ }
335
+ const { body: out, encoding } = encodeBody(res, Buffer.from(payload, 'utf8'))
336
+ if (encoding) headers['Content-Encoding'] = encoding
337
+ headers['Content-Length'] = out.length
338
+ res.writeHead(status, headers)
339
+ res.end(out)
340
+ }
341
+
342
+ /** 请求方的 Accept-Encoding(`res.req` 由 Node 的 http server 挂上;取不到当不支持)。 */
343
+ export function acceptEncoding(res) {
344
+ try {
345
+ const req = res && (res.req || res.__meReq)
346
+ return String((req && req.headers && req.headers['accept-encoding']) || '').toLowerCase()
347
+ } catch { return '' }
348
+ }
349
+
350
+ /** 请求方是否接受 gzip。 */
351
+ export function acceptsGzip(res) {
352
+ return acceptEncoding(res).includes('gzip')
353
+ }
354
+
355
+ /**
356
+ * 协商压缩大响应体:
357
+ * - 支持 brotli 就优先 br(质量 4 = 在线压缩的速度/体积平衡点,比 gzip 再小约 30%)
358
+ * - 否则退 gzip
359
+ * - 都不支持、体积太小、或压完反而更大 → 明文
360
+ */
361
+ export function encodeBody(res, buf) {
362
+ if (buf.length <= 2048) return { body: buf, encoding: '' }
363
+ const ae = acceptEncoding(res)
364
+ const wantBr = /(^|[,\s])br($|[,\s;])/.test(ae)
365
+ const wantGzip = ae.includes('gzip')
366
+ // 大响应体同时压 br + gzip 取更小的那个:br q5 在图谱这种高重复 JSON 上比 gzip 小 35%,
367
+ // 但在高熵的 JS/小 JSON 上偶尔略大 —— 取小者保证永不劣化(成本仅几毫秒)。
368
+ if (buf.length >= 16 * 1024) {
369
+ let br = null
370
+ if (wantBr) {
371
+ try { br = brotliCompressSync(buf, { params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 5, [zlibConstants.BROTLI_PARAM_SIZE_HINT]: buf.length } }) } catch { br = null }
372
+ }
373
+ let gz = null
374
+ if (wantGzip) { try { gz = gzipSync(buf) } catch { gz = null } }
375
+ const best = [br && { body: br, encoding: 'br' }, gz && { body: gz, encoding: 'gzip' }]
376
+ .filter(Boolean)
377
+ .filter((x) => x.body.length < buf.length)
378
+ .sort((a, b) => a.body.length - b.body.length)[0]
379
+ if (best) return best
380
+ return { body: buf, encoding: '' }
381
+ }
382
+ if (wantBr) {
383
+ try {
384
+ const out = brotliCompressSync(buf, { params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 5, [zlibConstants.BROTLI_PARAM_SIZE_HINT]: buf.length } })
385
+ if (out.length < buf.length) return { body: out, encoding: 'br' }
386
+ } catch { /* 落到 gzip */ }
387
+ }
388
+ if (wantGzip) {
389
+ const out = gzipSync(buf)
390
+ if (out.length < buf.length) return { body: out, encoding: 'gzip' }
391
+ }
392
+ return { body: buf, encoding: '' }
326
393
  }
327
394
 
328
395
  /** 读请求体(POST body),返回解析后的 JSON 对象或空。 */
@@ -0,0 +1,59 @@
1
+ // 记忆核心 · 自动沉淀日志的持久层(JSONL 追加)。
2
+ //
3
+ // 为什么要有它:
4
+ // 1) 独立 Web 页(lib/web.js 起的进程)拿不到宿主内存里的日志,面板只能显示
5
+ // 「日志仅在 DSH 宿主内可用」——用户看到的就是"记录里没内容";
6
+ // 2) 宿主重启后内存环形缓冲清空,历史诊断信息全丢。
7
+ // 落一个 JSONL 到 DSH_HOME 里,两边都能读:宿主写、两边读,最多保留最近 500 条。
8
+
9
+ import { promises as fs } from 'node:fs'
10
+ import path from 'node:path'
11
+ import os from 'node:os'
12
+
13
+ const FILE = 'memory-eternal-capture.jsonl'
14
+ export const CAPTURE_LOG_MAX_LINES = 500
15
+
16
+ /** 日志文件路径(与 vault 同级的 DSH_HOME 下)。 */
17
+ export function captureLogPath(env = process.env) {
18
+ const home = env.DSH_HOME || path.join(os.homedir(), '.dsh')
19
+ return path.join(home, FILE)
20
+ }
21
+
22
+ /** 追加一条(失败静默:日志不能反过来搞坏沉淀管线)。 */
23
+ export async function appendCaptureLog(entry, env = process.env) {
24
+ try { await fs.appendFile(captureLogPath(env), JSON.stringify(entry) + '\n', 'utf8') } catch { /* 忽略 */ }
25
+ }
26
+
27
+ /** 读最近 limit 条,**最新在前**(与 /capture-log 接口语义一致)。 */
28
+ export async function readCaptureLog(limit = 100, env = process.env) {
29
+ try {
30
+ const txt = await fs.readFile(captureLogPath(env), 'utf8')
31
+ const lines = txt.split('\n').filter(Boolean)
32
+ const out = []
33
+ for (const line of lines.slice(-limit)) {
34
+ try { out.push(JSON.parse(line)) } catch { /* 跳过坏行 */ }
35
+ }
36
+ return out.reverse()
37
+ } catch { return [] }
38
+ }
39
+
40
+ /** 从日志推健康状态(独立 Web 页没有宿主内存状态,只能看最近一条结论)。 */
41
+ export function deriveHealth(entries) {
42
+ for (const e of entries || []) {
43
+ if (e.action === 'fail') return { ok: false, reason: e.reason || '', since: e.time || 0, lastOkAt: 0, lastFailAt: e.time || 0 }
44
+ if (e.action === 'created' || e.action === 'appended') return { ok: true, reason: '', since: 0, lastOkAt: e.time || 0, lastFailAt: 0 }
45
+ }
46
+ return null
47
+ }
48
+
49
+ /** 行数超上限时重写为最近 CAPTURE_LOG_MAX_LINES 行(由宿主定期调用)。 */
50
+ export async function rotateCaptureLog(env = process.env) {
51
+ try {
52
+ const p = captureLogPath(env)
53
+ const txt = await fs.readFile(p, 'utf8')
54
+ const lines = txt.split('\n').filter(Boolean)
55
+ if (lines.length <= CAPTURE_LOG_MAX_LINES) return false
56
+ await fs.writeFile(p, lines.slice(-CAPTURE_LOG_MAX_LINES).join('\n') + '\n', 'utf8')
57
+ return true
58
+ } catch { return false }
59
+ }