memory-eternal 0.8.0 → 0.9.1

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:
@@ -169,8 +177,21 @@ Top of the config page: **🟢 A Light / 💰 B Budget / ⭐ C Premium** — cli
169
177
 
170
178
  ---
171
179
 
180
+ ## 🔔 Capture Log & Failure Alerts
181
+
182
+ Auto-capture is a silent background pipeline: when it broke, the only symptom used to be "no new cards" with no visible cause. Since v0.9.0 it speaks up.
183
+
184
+ - **Capture log**: Settings → Memory → Usage / Today shows the last 100 run entries: `🚀 boot` / `👂 session attached (which event API + event count)` / `✅ card created` / `➕ update appended` / `⏭ skipped (reason)` / `❌ failed (reason)`.
185
+ - **Active alerts**: any failure turns the strip red at the top of the memory library *and* injects "auto-capture failed + reason" into the agent context, so the agent tells you in its first sentence — no need to dig through logs.
186
+ - **Stall watchdog**: if turns start but no turn-stopping event arrives for 15 minutes (e.g. DSH renamed the event), a 10-minute sweep reports it.
187
+ - **Self-adapting event API**: the session event API is detected automatically (`ownEvents()` → `snapshotEvents()` → `events`), so a DSH upgrade that changes the API can no longer silently break capture.
188
+
189
+ ---
190
+
172
191
  ## 🛡️ Audit Center & Recycle Bin
173
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
+
174
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.
175
196
 
176
197
  <p align="center">
@@ -221,6 +242,22 @@ npm run build # builds lib/client.js (DSH embed) + web/app.js (standalone web
221
242
 
222
243
  ---
223
244
 
245
+ ## 📋 Changelog
246
+
247
+ | Version | Date | Highlights |
248
+ |---|---|---|
249
+ | **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) |
250
+ | **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). |
251
+ | **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 |
252
+ | **v0.3.1** | 2026-09-03 | Knowledge graph loads on demand (fetch on open, unregister on close) |
253
+ | **v0.3.0** | 2026-09-03 | **SQLite storage layer** (`node:sqlite`, zero deps) + DB-level audit guard `enforceAudit()` + `audit_log` table + automatic `.md` migration |
254
+ | v0.2.0 | 2026-09-03 | Recall with body text (`search()` returns the `excerpt` field) |
255
+ | v0.1.2 | 2026-09-03 | Audit guard hardening (`writeCard` defaults to `pending`, `mergeCards` goes through audit) |
256
+ | v0.1.1 | 2026-09-03 | Recall body fix (`excerpt` field + body logic) |
257
+ | v0.1.0 | 2026-08-31 | First release: auto capture + auto recall + graphical knowledge base + knowledge graph + audit center + recycle bin |
258
+
259
+ ---
260
+
224
261
  ## 📄 License
225
262
 
226
263
  MIT
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** 后,三样东西立即生效:
@@ -181,6 +188,17 @@ dsh-memory watchdog [--port 7799] # 看门狗保活 web(独立进程)
181
188
 
182
189
  ---
183
190
 
191
+ ## 🔔 自动沉淀日志 & 异常提示
192
+
193
+ 自动沉淀是后台静默管线:一旦坏了,以前只能表现为「卡不再增加」,看不出原因。v0.9.0 起它会自己说话。
194
+
195
+ - **自动沉淀日志**:设置 → 记忆 → 用量/今日,最近 100 条运行轨迹:`🚀 启动` / `👂 会话接入(用的事件接口 + 事件数)` / `✅ 新建卡` / `➕ 追加更新` / `⏭ 跳过(原因)` / `❌ 失败(原因)`。
196
+ - **异常主动提示**:出现失败即亮红——记忆库页面顶部红条,同时把「自动沉淀异常 + 原因」注入 Agent 上下文,Agent 会在回复第一句提醒你,不需要你自己去翻日志。
197
+ - **停滞兜底**:有轮次开始、15 分钟收不到收尾事件(例如 DSH 改了事件名),10 分钟巡检直接报出来。
198
+ - **接口自适应**:自动识别 DSH 会话事件接口(`ownEvents()` → `snapshotEvents()` → `events`),DSH 升级换 API 不会再静默断掉。
199
+
200
+ ---
201
+
184
202
  ## 🛡️ 审核中心 & 回收中心
185
203
 
186
204
  新卡默认进**审核中心**(`pending`),由你确认后才入主库;驳回的进「已驳回」,可恢复或删除进回收站。命中免审条件(审核模式=全部免审 / 免审智能体 / 免审类型)的新卡直接入库;回收站软删卡 30 天内可恢复,超期自动永久删除。
@@ -193,6 +211,7 @@ dsh-memory watchdog [--port 7799] # 看门狗保活 web(独立进程)
193
211
 
194
212
  - **待审核 / 已驳回** 双页签,按类型 / 日期 / 智能体筛选,全选后一键「批准 / 驳回 / 删除进回收站」。
195
213
  - 审核规则在「记忆配置 → 自动审核配置」:`审核模式`(全部要审 / 全部免审)+ `免审智能体` + `免审类型` + `回收保留天数`。
214
+ - **Agent 不能代你审批(v0.9.1 起)**:只要审核生效,插件就往 Agent 的上下文里注入红线——新卡只能停在「待审核」,并明确禁止调用 `approve`/`reject` 接口或任何等价方式绕过;判断权始终在你手里。
196
215
 
197
216
  ### 🥇 为什么审核系统更靠谱(对比其它记忆产品)
198
217
 
@@ -240,6 +259,8 @@ npm run build # 构建 lib/client.js(DSH 内嵌)+ web/app.js(独立 web
240
259
 
241
260
  | 版本 | 日期 | 关键改动 |
242
261
  |---|---|---|
262
+ | **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 进程) |
263
+ | **v0.9.0** | 2026-09-12 | **修复自动沉淀静默失效(一连 6 天不写卡)**:DSH 升级后会话事件接口由 `events` 改为 `ownEvents()/snapshotEvents()`,监听器读不到事件后静默返回、不留任何痕迹 —— 现改为三级自适应取法(`ownEvents()` → `snapshotEvents()` → `events`)+ 每会话接入留痕;日配额改为**只统计真实写卡**(此前「不值得保存」的判定也照样扣配额,40 次即耗尽 24h 配额)、`captureCooldownMs` 冷却真正生效、单次沉淀输入截尾 20000 字;新增**自动沉淀日志**面板与**异常主动提示**(页面红条 + 注入 Agent 上下文 + 轮次收尾停滞巡检);移除「每日回顾」自动简报(定时器 / `/todayBrief` 接口 / UI 按钮) |
243
264
  | **v0.8.0** | 2026-09-06 | **全套 UI i18n**:跟随 DSH 系统语言实时切换(中/英),覆盖知识卡 / 知识图谱 / 审核中心 / 配置面板 / 新建卡模板;Windows 测试收尾 SQLite 连接 EBUSY 修复 |
244
265
  | **v0.3.1** | 2026-09-03 | 知识图谱按需加载(点击时 fetch,关闭时注销) |
245
266
  | **v0.3.0** | 2026-09-03 | **SQLite 化存储层**(`node:sqlite` 零依赖)+ 数据库层审核守卫 `enforceAudit()` + `audit_log` 审计日志 + 自动从 .md 迁移 |
package/index.js CHANGED
@@ -25,10 +25,10 @@ const PACKAGE_ROOT = path.resolve(path.dirname(__filename))
25
25
  const versionRef = (() => { try { const p = JSON.parse(readFileSync(path.join(PACKAGE_ROOT, 'package.json'), 'utf8')); return p.version } catch { return '' } })()
26
26
  import z from '@deepseek-ai/schemastery'
27
27
  import { defineTool } from '@deepseek-ai/dsh-tools'
28
- import { ensureVault, search, generateDailyBrief } from './lib/vault.js'
28
+ import { ensureVault, search } from './lib/vault.js'
29
29
  import { migrateFromMarkdown, setAuditConfig, backupDb } from './lib/db.js'
30
- import { summarizeTurn, extractLastTurn, sliceNewEvents, resolveRoute, captureCard, captureUpdate, pickNeighbors } from './lib/capture.js'
31
- import { createApi, json } from './lib/api.js'
30
+ import { summarizeTurn, extractLastTurn, sliceNewEvents, sessionEvents, sessionEventApi, createCaptureHealth, resolveRoute, captureCard, captureUpdate, pickNeighbors } from './lib/capture.js'
31
+ import { createApi, json, encodeBody } from './lib/api.js'
32
32
 
33
33
  export const name = 'memory-eternal'
34
34
  export const inject = ['systemPrompt', 'settings']
@@ -152,22 +152,65 @@ export function apply(ctx, config) {
152
152
  const pending = new Map() // sessionId -> merged events array
153
153
  let captureQueue = Promise.resolve()
154
154
 
155
- const scheduleCapture = (agent, events, lastSeq) => {
155
+ // -- 自动沉淀日志:最近 200 条「监听/判定/写入」事件(内存环形缓冲,不落盘)。
156
+ // 自动沉淀是后台静默管线,出错或空转时页面上完全看不出来;这里留一条可读的
157
+ // 运行轨迹,供「用量/今日」页的「自动沉淀日志」面板直接排查。
158
+ const captureLog = []
159
+ const CAPTURE_LOG_MAX = 200
160
+ // 健康状态:fail → 亮红(systemPrompt 段 + 页面横幅都会提示);真写卡成功 → 复原。
161
+ const health = createCaptureHealth()
162
+ let refreshPrompt = null // 由 systemPrompt effect 赋值;健康状态翻转时重渲染提示段
163
+ const touchPrompt = () => {
164
+ if (typeof refreshPrompt !== 'function') return
165
+ try { refreshPrompt(settings.get()) } catch { /* 提示段刷新失败不影响沉淀 */ }
166
+ }
167
+ const logCapture = (sessionId, action, reason, extra = {}) => {
168
+ captureLog.push({ time: Date.now(), sessionId: String(sessionId || 'unknown').slice(0, 40), action, reason, ...extra })
169
+ if (captureLog.length > CAPTURE_LOG_MAX) captureLog.splice(0, captureLog.length - CAPTURE_LOG_MAX)
170
+ if (action === 'fail') { health.fail(reason); touchPrompt() }
171
+ else if (action === 'created' || action === 'appended') { health.succeed(); touchPrompt() }
172
+ }
173
+ const preview = (text) => String(text || '').replace(/\s+/g, ' ').trim().slice(0, 60)
174
+ // 单次沉淀送进 LLM 的对话上限(字符):够覆盖一轮长对话,又不会把整段历史塞进去。
175
+ const CAPTURE_TEXT_MAX = 20000
176
+ // 启动留痕:面板里看不到这条 = host 半边没加载(而不是「没东西可沉淀」)。
177
+ {
178
+ const c = settings.get() ?? {}
179
+ logCapture('system', 'boot', `记忆核心 v${versionRef || '?'} 已加载:自动沉淀 ${c.enabled !== false && c.autoCapture !== false ? '开' : '关'} · 库 ${vaultDir()}`)
180
+ }
181
+ const lastCaptureAt = new Map() // sessionId -> 上次实际发起蒸馏的时间戳
182
+
183
+ const scheduleCapture = (agent, events) => {
156
184
  const cfg = settings.get() ?? {}
157
- if (!cfg.enabled || !cfg.autoCapture) return
158
185
  const sessionId = agent?.session?.id ?? agent?.id ?? 'unknown'
159
- const newEvents = sliceNewEvents(events, lastSeq)
160
- if (newEvents.length === 0) return
186
+ if (!cfg.enabled || !cfg.autoCapture) return
187
+ if (!Array.isArray(events) || events.length === 0) {
188
+ logCapture(sessionId, 'listen', '读不到会话事件:agent.session 无 events/snapshotEvents(DSH 版本不匹配)')
189
+ return
190
+ }
161
191
  const existing = pending.get(sessionId)
192
+ // 连续轮次合并:把新事件接到待处理队列尾,一次 LLM 调用处理。
162
193
  if (existing) {
163
- // 连续轮次合并:把新事件接到待处理队列尾,一次 LLM 调用处理。
164
- pending.set(sessionId, [...existing, ...newEvents])
194
+ existing.events.push(...events)
165
195
  return
166
196
  }
167
- pending.set(sessionId, newEvents)
168
- captureQueue = captureQueue.then(() => runCapture(agent, pending.get(sessionId) ?? newEvents))
197
+ const job = { events }
198
+ pending.set(sessionId, job)
199
+ captureQueue = captureQueue.then(() => runCapture(agent, job))
169
200
  }
170
201
 
202
+ // 日配额:滚动 24h 内的「实际写卡」计数。只在真的写卡/追加后记一笔——
203
+ // 之前是「每发起一次判定就记一笔」,模型回 {save:false} 也照样扣配额,
204
+ // 40 次低价值判定就能把配额耗光、之后 24h 全部静默不再沉淀。
205
+ const lastDayStamps = []
206
+ const countWrite = () => { lastDayStamps.push(Date.now()) }
207
+ const quotaUsed = () => {
208
+ const dayStart = Date.now() - 86400000
209
+ while (lastDayStamps.length && lastDayStamps[0] < dayStart) lastDayStamps.shift()
210
+ return lastDayStamps.length
211
+ }
212
+ const underDailyQuota = (max) => quotaUsed() < (max ?? 60)
213
+
171
214
  // 自动审核:根据配置决定新卡 status(免审→approved,否则 pending)
172
215
  const resolveAuditStatus = (cfg, kind, submittedBy) => {
173
216
  if (cfg.auditMode === 'none') return 'approved'
@@ -178,19 +221,39 @@ export function apply(ctx, config) {
178
221
  return 'pending'
179
222
  }
180
223
 
181
- const runCapture = async (agent, events) => {
224
+ const runCapture = async (agent, job) => {
225
+ const sessionId = agent?.session?.id ?? agent?.id ?? 'unknown'
226
+ const events = Array.isArray(job?.events) ? job.events : []
182
227
  try {
183
228
  const cfg = settings.get() ?? {}
184
229
  if (!cfg.enabled || !cfg.autoCapture) return
185
230
  const llm = ctx.get('llm')
186
- const text = extractLastTurn(events)
187
- if (text.length < (cfg.captureMinChars ?? 200)) return
188
- // 日配额:防止一次大扫荡烧光 token。
189
- if (!(await underDailyQuota(cfg.maxCardsPerDay))) return
231
+ // 水位从 0 开始的那一轮(重启后同一会话的第一轮)会把整段历史切进来——
232
+ // 只取尾部,避免一次超大 LLM 调用(超上下文/超时)。
233
+ const raw = extractLastTurn(events)
234
+ const text = raw.length > CAPTURE_TEXT_MAX ? raw.slice(-CAPTURE_TEXT_MAX) : raw
235
+ logCapture(sessionId, 'listen', `会话事件 ${events.length} 条 → 有效对话 ${text.length} 字${raw.length > text.length ? '(超长已截尾)' : ''}`, { preview: preview(text) })
236
+ if (text.length < (cfg.captureMinChars ?? 200)) {
237
+ logCapture(sessionId, 'skip', `内容太短:${text.length} < ${cfg.captureMinChars ?? 200} 字(调小「捕获最小长度」可放宽)`)
238
+ return
239
+ }
240
+ // 会话冷却:同一会话频繁收尾时避免每轮都烧一次 LLM。
241
+ const cooldown = Number(cfg.captureCooldownMs) || 0
242
+ const lastAt = lastCaptureAt.get(sessionId) ?? 0
243
+ if (cooldown > 0 && Date.now() - lastAt < cooldown) {
244
+ logCapture(sessionId, 'skip', `冷却中:距上次沉淀 ${Math.round((Date.now() - lastAt) / 1000)}s < ${Math.round(cooldown / 1000)}s`)
245
+ return
246
+ }
247
+ // 日配额:防止一次大扫荡烧光 token(只统计真正写卡/追加的次数)。
248
+ if (!underDailyQuota(cfg.maxCardsPerDay)) {
249
+ logCapture(sessionId, 'skip', `日配额已满(24h 已写 ${quotaUsed()} 张 / 上限 ${cfg.maxCardsPerDay ?? 60})`)
250
+ return
251
+ }
252
+ lastCaptureAt.set(sessionId, Date.now())
190
253
  // 成本控制:distillEnabled=false 时不调 LLM,直接存原文卡(零蒸馏成本)
191
254
  const source = DSH_AGENT
192
255
  if (cfg.distillEnabled === false || !llm) {
193
- await captureCard(vaultDir(), {
256
+ const out = await captureCard(vaultDir(), {
194
257
  kind: 'content',
195
258
  title: text.replace(/\s+/g, ' ').slice(0, 40) || '未命名记录',
196
259
  tags: ['raw'],
@@ -201,19 +264,25 @@ export function apply(ctx, config) {
201
264
  severity: 'info',
202
265
  reason: 'AI 自动沉淀(原文卡)',
203
266
  }, { threshold: cfg.dedupThreshold })
267
+ if (out.ok) { countWrite(); logCapture(sessionId, 'created', '原文卡(蒸馏已关闭)', { path: out.path ?? out.rel, kind: 'content' }) }
268
+ else if (out.duplicate) { countWrite(); logCapture(sessionId, 'appended', '与已有卡重复 → 追加更新记录', { path: out.duplicate.path }) }
269
+ else logCapture(sessionId, 'fail', `写卡失败:${out.reason || '未知原因'}`)
204
270
  return
205
271
  }
206
272
  const route = await resolveRoute(llm)
207
- if (!route) return
273
+ if (!route) { logCapture(sessionId, 'fail', '取不到模型路由(llm.listProviders 为空)'); return }
208
274
  // 语义去重近邻:把已有卡片索引喂给模型,让模型决定新建 vs 追加。
209
275
  // 成本控制:dedupByLLM=false 时跳过喂 LLM 的近邻采样(纯词法去重兜底)。
210
276
  const draft = { title: '', body: text.slice(0, 400) }
211
277
  const neighbors = cfg.dedupByLLM === false ? [] : await pickNeighbors(vaultDir(), draft, 8)
212
278
  const result = await summarizeTurn(llm, route, text, { signal: AbortSignal.timeout(45000), existing: neighbors, maxTokens: cfg.captureMaxTokens ?? 900 })
213
- if (!result || result.save !== true) return
279
+ if (!result) { logCapture(sessionId, 'skip', '模型无输出或 JSON 解析失败', { model: route.model }); return }
280
+ if (result.save !== true) { logCapture(sessionId, 'skip', '模型判定不值得保存', { model: route.model }); return }
214
281
  if (result.append_to) {
215
282
  // 模型判定属于已有卡 → 追加更新记录,不新建(boujoy 语义)。
216
283
  await captureUpdate(vaultDir(), result.append_to, result.update, { threshold: cfg.dedupThreshold })
284
+ countWrite()
285
+ logCapture(sessionId, 'appended', '模型判定属于已有卡 → 追加更新', { path: result.append_to, model: route.model })
217
286
  return
218
287
  }
219
288
  const card = {
@@ -228,38 +297,70 @@ export function apply(ctx, config) {
228
297
  reason: 'AI 自动沉淀(蒸馏卡)',
229
298
  }
230
299
  const out = await captureCard(vaultDir(), card, { threshold: cfg.dedupThreshold })
231
- if (!out.ok && out.duplicate) {
300
+ if (out.ok) {
301
+ countWrite()
302
+ logCapture(sessionId, 'created', `新卡:${card.title}(${card.status === 'approved' ? '已入库' : '待审核'})`, { path: out.path ?? out.rel, kind: card.kind, model: route.model })
303
+ return
304
+ }
305
+ if (out.duplicate) {
232
306
  // 词法兜底:高度相似 → 追加更新记录而不是再建一张重复卡。
233
307
  await captureUpdate(vaultDir(), out.duplicate.path, `${result.title}:${result.body.slice(0, 400)}`, {
234
308
  threshold: cfg.dedupThreshold,
235
309
  })
310
+ countWrite()
311
+ logCapture(sessionId, 'appended', '词法去重命中 → 追加更新记录', { path: out.duplicate.path, model: route.model })
312
+ return
236
313
  }
314
+ logCapture(sessionId, 'fail', `写卡失败:${out.reason || '未知原因'}`)
237
315
  } catch (error) {
316
+ logCapture(sessionId, 'fail', `异常:${error?.message || error}`)
238
317
  console.error('[memory-eternal] capture failed:', error)
239
318
  } finally {
240
- const sessionId = agent?.session?.id ?? agent?.id ?? 'unknown'
241
- pending.delete(sessionId)
319
+ // 运行期间若又累积了新事件,保留队列给下一轮 run 处理,避免丢事件。
320
+ if (pending.get(sessionId) === job) pending.delete(sessionId)
242
321
  }
243
322
  }
244
323
 
245
- const lastDayStamps = []
246
- const underDailyQuota = async (max) => {
247
- const now = Date.now()
248
- const dayStart = now - 86400000
249
- while (lastDayStamps.length && lastDayStamps[0] < dayStart) lastDayStamps.shift()
250
- if (lastDayStamps.length >= (max ?? 60)) return false
251
- lastDayStamps.push(now)
252
- return true
253
- }
254
-
255
- const lastSeqs = new Map() // sessionId -> last processed seq
324
+ const lastSeqs = new Map() // sessionId -> 已处理到的 seq 水位
325
+ const lastTouched = new Map() // sessionId -> 上次监听时间(用于清理,避免 Map 无界增长)
326
+ // 轮次计数:inbox/claimed 每轮必发(不依赖 turn-stopping),两个计数器对不上
327
+ // 就说明收尾事件没到——这是「监听器整个没被调用」这类静默死亡的兜底探测。
328
+ let turnsStarted = 0
329
+ let turnsStopped = 0
330
+ let lastClaimAt = 0
331
+ ctx.on('agent/inbox/claimed', () => { turnsStarted += 1; lastClaimAt = Date.now() })
256
332
  ctx.on('agent/turn-stopping', ({ agent }) => {
257
- const events = agent?.session?.events
258
- if (!Array.isArray(events)) return
333
+ turnsStopped += 1
259
334
  const sessionId = agent?.session?.id ?? agent?.id ?? 'unknown'
260
- const lastSeq = lastSeqs.get(sessionId) ?? 0
261
- scheduleCapture(agent, events, lastSeq)
262
- lastSeqs.set(sessionId, events.length ? events[events.length - 1].seq : lastSeq)
335
+ try {
336
+ const api = sessionEventApi(agent?.session)
337
+ const first = !lastTouched.has(sessionId)
338
+ const all = sessionEvents(agent?.session)
339
+ // 水位默认 -1:新会话「已处理到」的位置在第一条事件之前,seq 0 不该被跳过。
340
+ const lastSeq = lastSeqs.get(sessionId) ?? -1
341
+ const fresh = sliceNewEvents(all, lastSeq)
342
+ if (all.length) lastSeqs.set(sessionId, Math.max(lastSeq, all[all.length - 1].seq ?? lastSeq))
343
+ // 每个会话第一次收尾留一条接入记录:接口名 + 事件数 + 新增数。
344
+ // 这样「面板一条都没有」只会意味着监听器没跑,而不是看不出所以然。
345
+ if (first) {
346
+ logCapture(sessionId, api ? 'listen' : 'fail', api
347
+ ? `会话接入:事件接口 ${api},事件 ${all.length} 条,新增 ${fresh.length} 条`
348
+ : '会话对象没有事件接口(events/ownEvents/snapshotEvents 都不认识)——DSH 改了接口,需要适配')
349
+ }
350
+ lastTouched.set(sessionId, Date.now())
351
+ if (fresh.length) scheduleCapture(agent, fresh)
352
+ // 1 天没动静的会话水位清理掉(长跑进程里会话数会一直涨)。
353
+ if (lastTouched.size > 200) {
354
+ const cut = Date.now() - 86400000
355
+ for (const [id, t] of lastTouched) {
356
+ if (t < cut) { lastTouched.delete(id); lastSeqs.delete(id); lastCaptureAt.delete(id) }
357
+ }
358
+ }
359
+ } catch (error) {
360
+ // 监听器里抛异常等于管线静默死亡(页面只表现为「一直不写卡」)——记进日志。
361
+ logCapture(sessionId, 'fail', `监听异常:${error?.message || error}`)
362
+ console.error('[memory-eternal] turn-stopping listener failed:', error)
363
+ }
263
364
  })
264
365
 
265
366
  // -- 2. 自动召回:systemPrompt 分段 + memory_recall 工具 -----------------
@@ -279,16 +380,31 @@ export function apply(ctx, config) {
279
380
  '2. 当任务需要项目背景、历史决策、之前讨论过的方案或领域知识时,先调用 memory_recall 检索相关卡片,再作答。',
280
381
  '3. 若检索结果为空,就诚实说明当前记忆库没有相关内容,不要编造。',
281
382
  '4. 知识卡存储在 SQLite 数据库中(memory-eternal.db),**禁止**用文件工具直接读写 vault 目录下的任何文件。沉淀记忆必须通过 memory_recall 工具或 /memory-eternal/api/write API。',
383
+ '5. 若下方出现「自动沉淀异常」,必须在本次回复的第一句用中文转述该异常并提醒用户处理,不要自行猜测或尝试修复。',
384
+ // 审核红线(主上 2026-09-12 强制要求):只在审核真正生效时注入。
385
+ ...((cfg.auditMode ?? 'all') === 'none' ? [] : [
386
+ '6. **审核红线(强制)**:写卡一律停在 pending,由用户在「审核中心」审批。**禁止**调用 /memory-eternal/api/audit/approve 或 /audit/reject 代替用户审批,也不得用任何等价方式绕过审核;用户说「记录一下 / 增加记忆」不等于允许免审入库。需要立刻可用时,写卡后明确告知用户「已进审核中心,待批准」。',
387
+ ]),
388
+ ].join('\n')
389
+ // 异常提示:沉淀管线坏了,靠这一句把消息送到用户面前(不依赖用户去翻页面)。
390
+ const h = health.snapshot()
391
+ const alert = h.ok ? '' : [
392
+ '',
393
+ '⚠ 自动沉淀异常(记忆核心 memory-eternal)——' + h.reason,
394
+ '(发生时间:' + new Date(h.since).toLocaleString() + '。请在回复第一句提醒用户:自动沉淀异常 + 上述原因;'
395
+ + '并可提示用户打开 设置→记忆→用量/今日 查看「自动沉淀日志」;若原因是接口/版本不匹配,需更新 memory-eternal 插件。)',
282
396
  ].join('\n')
283
397
  disposeSection = ctx.systemPrompt.section({
284
398
  name: 'memory-eternal: auto-recall',
285
399
  order: 600,
286
- text,
400
+ text: text + alert,
287
401
  })
288
402
  }
403
+ refreshPrompt = refresh
289
404
  refresh(settings.get())
290
405
  const unwatch = settings.watch(refresh)
291
406
  return () => {
407
+ if (refreshPrompt === refresh) refreshPrompt = null
292
408
  if (typeof unwatch === 'function') unwatch()
293
409
  if (disposeSection) disposeSection()
294
410
  }
@@ -333,16 +449,14 @@ export function apply(ctx, config) {
333
449
  }))
334
450
  }
335
451
 
336
- // 每日回顾:每 30 分钟检查一次,跨天就生成当日简报文件(幂等,凌晨/首日各一次)。
337
- let lastBriefDate = ''
338
- const briefTimer = setInterval(() => {
339
- const d = new Date()
340
- const date = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
341
- if (lastBriefDate === date) return
342
- lastBriefDate = date
343
- generateDailyBrief(vaultDir()).catch(() => {})
344
- }, 30 * 60 * 1000)
345
- ctx.effect(() => () => clearInterval(briefTimer), 'memory-eternal: daily brief timer')
452
+ // 沉淀停滞探测:有轮次开始、却迟迟没有收尾(>15 分钟),说明 turn-stopping 没到。
453
+ const stallTimer = setInterval(() => {
454
+ if (turnsStarted > turnsStopped && lastClaimAt && Date.now() - lastClaimAt > 15 * 60 * 1000) {
455
+ // 经 logCapture 统一走「记日志 + 置异常 + 刷提示段」一条路
456
+ logCapture('system', 'fail', `轮次收尾事件未触发(agent/turn-stopping 没到):已开始 ${turnsStarted} / 已收尾 ${turnsStopped}——DSH 可能改了事件名或作用域`)
457
+ }
458
+ }, 10 * 60 * 1000)
459
+ ctx.effect(() => () => clearInterval(stallTimer), 'memory-eternal: capture stall watch')
346
460
 
347
461
  // 回收站清理:每 30 分钟永久删除超过保留期(默认 30 天)的软删卡。
348
462
  const purgeTimer = setInterval(() => {
@@ -376,6 +490,9 @@ export function apply(ctx, config) {
376
490
  if (webServer !== undefined) {
377
491
  const handleApi = createApi({
378
492
  vaultDir, vaultRoots, getSettings: settings.get,
493
+ // 自动沉淀运行轨迹 + 健康状态:供「用量/今日」页排查「为什么没写卡」,异常时页面顶部亮红。
494
+ getCaptureLog: () => captureLog.slice().reverse(),
495
+ getCaptureHealth: () => health.snapshot(),
379
496
  getDshInfo: () => ({
380
497
  name: 'deepseek-harness',
381
498
  label: 'DeepSeek Harness(当前宿主)',
@@ -487,8 +604,13 @@ export function apply(ctx, config) {
487
604
  const webRoot = path.join(PACKAGE_ROOT, 'web')
488
605
  if (pathname.endsWith('app.js')) {
489
606
  const buf = await readFile(path.join(webRoot, 'app.js'))
490
- res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8', 'Cache-Control': 'no-store' })
491
- return res.end(buf)
607
+ // client bundle 270KB,每次打开配置页都要重下 —— 支持 gzip 的客户端走压缩
608
+ const headers = { 'Content-Type': 'application/javascript; charset=utf-8', 'Cache-Control': 'no-store', 'Vary': 'Accept-Encoding' }
609
+ const { body: out, encoding } = encodeBody(res, buf)
610
+ if (encoding) headers['Content-Encoding'] = encoding
611
+ headers['Content-Length'] = out.length
612
+ res.writeHead(200, headers)
613
+ return res.end(out)
492
614
  }
493
615
  const buf = await readFile(path.join(webRoot, 'index.html'))
494
616
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' })
package/lib/api.js CHANGED
@@ -9,8 +9,10 @@
9
9
  // - vaultDir() → 当前激活 vault 根目录(string)
10
10
  // - vaultRoots() → [{ name, root }] 全部 profile 根(跨库聚合用)
11
11
  // - getSettings() → 插件设置对象(/budget /compress 读取;可选,缺省 {})
12
+ // - getCaptureLog() → 自动沉淀运行轨迹(DSH host 注入;独立 web server 无此数据)
12
13
 
13
- import { listCards, readCard, search, searchAll, graph, graphAll, overview, exportCards, deleteCard, writeCard, parseCard, stats, optimizeCandidates, optimizeApply, addFeedback, dailyBrief, 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'
14
16
  import { compressExcerpt } from './capture.js'
15
17
  import { getSetupStatus, runSetup, mcpAgentAction } from './setup.js'
16
18
  import { backupDb } from './db.js'
@@ -23,6 +25,9 @@ export function createApi(deps = {}) {
23
25
  const getSettings = deps.getSettings ?? (() => ({}))
24
26
  // DSH 宿主信息(独立 web server 无 DSH 环境时为 null)。head DSH 状态行用。
25
27
  const getDshInfo = deps.getDshInfo ?? (() => null)
28
+ // 自动沉淀运行轨迹(最新在前)+ 健康状态(异常时 UI 顶部亮红)。
29
+ const getCaptureLog = deps.getCaptureLog ?? (() => null)
30
+ const getCaptureHealth = deps.getCaptureHealth ?? (() => null)
26
31
 
27
32
  return async function handleApi(req, res) {
28
33
  const vaultRoot = vaultDir()
@@ -40,17 +45,25 @@ export function createApi(deps = {}) {
40
45
  const kind = query.get('kind') || ''
41
46
  const q = query.get('q') || ''
42
47
  const status = (query.get('status') || 'approved').toLowerCase()
43
- 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)
44
53
  // all = 全部(含 pending/rejected,排除回收站);默认/approved = 已审核。
45
54
  const want = status === 'all' ? ['approved', 'pending', 'rejected'] : status === 'pending' || status === 'rejected' ? [status] : ['approved']
46
- let cards = await listCards(vaultRoot, { status: want })
47
- if (kind) cards = cards.filter((c) => c.kind === kind)
48
55
  if (q.trim()) {
49
- const hits = await search(vaultRoot, q, { limit: 200 })
56
+ // 检索是相关性排序:先把命中集合取齐,再在内存里分页(保证 total 与顺序一致)
57
+ const hits = await search(vaultRoot, q, { limit: 500 })
50
58
  const hitPaths = new Set(hits.map((h) => h.path))
51
- 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
52
63
  }
53
- 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 })
54
67
  return
55
68
  }
56
69
  case '/card': {
@@ -80,10 +93,11 @@ export function createApi(deps = {}) {
80
93
  json(res, 200, { ok: true, ...g })
81
94
  return
82
95
  }
83
- case '/todayBrief': {
84
- await ensureVault(vaultRoot)
85
- const s = await stats(vaultRoot)
86
- json(res, 200, { ok: true, today: s.today, brief: dailyBrief(s.todayCards) })
96
+ case '/capture-log': {
97
+ // 最近 100 条自动沉淀运行轨迹(监听 → 判定 → 写入/跳过原因)+ 健康状态。
98
+ const log = getCaptureLog()
99
+ if (!log) return json(res, 200, { ok: true, available: false, entries: [], health: null })
100
+ json(res, 200, { ok: true, available: true, health: getCaptureHealth(), entries: log.slice(0, 100) })
87
101
  return
88
102
  }
89
103
  case '/export': {
@@ -312,12 +326,69 @@ export function createApi(deps = {}) {
312
326
 
313
327
  export function json(res, status, body) {
314
328
  const payload = JSON.stringify(body)
315
- res.writeHead(status, {
329
+ const headers = {
316
330
  'Content-Type': 'application/json; charset=utf-8',
317
- 'Content-Length': Buffer.byteLength(payload),
318
331
  'Cache-Control': 'no-store',
319
- })
320
- res.end(payload)
332
+ 'Vary': 'Accept-Encoding',
333
+ }
334
+ const { body: out, encoding } = encodeBody(res, Buffer.from(payload, 'utf8'))
335
+ if (encoding) headers['Content-Encoding'] = encoding
336
+ headers['Content-Length'] = out.length
337
+ res.writeHead(status, headers)
338
+ res.end(out)
339
+ }
340
+
341
+ /** 请求方的 Accept-Encoding(`res.req` 由 Node 的 http server 挂上;取不到当不支持)。 */
342
+ export function acceptEncoding(res) {
343
+ try {
344
+ const req = res && (res.req || res.__meReq)
345
+ return String((req && req.headers && req.headers['accept-encoding']) || '').toLowerCase()
346
+ } catch { return '' }
347
+ }
348
+
349
+ /** 请求方是否接受 gzip。 */
350
+ export function acceptsGzip(res) {
351
+ return acceptEncoding(res).includes('gzip')
352
+ }
353
+
354
+ /**
355
+ * 协商压缩大响应体:
356
+ * - 支持 brotli 就优先 br(质量 4 = 在线压缩的速度/体积平衡点,比 gzip 再小约 30%)
357
+ * - 否则退 gzip
358
+ * - 都不支持、体积太小、或压完反而更大 → 明文
359
+ */
360
+ export function encodeBody(res, buf) {
361
+ if (buf.length <= 2048) return { body: buf, encoding: '' }
362
+ const ae = acceptEncoding(res)
363
+ const wantBr = /(^|[,\s])br($|[,\s;])/.test(ae)
364
+ const wantGzip = ae.includes('gzip')
365
+ // 大响应体同时压 br + gzip 取更小的那个:br q5 在图谱这种高重复 JSON 上比 gzip 小 35%,
366
+ // 但在高熵的 JS/小 JSON 上偶尔略大 —— 取小者保证永不劣化(成本仅几毫秒)。
367
+ if (buf.length >= 16 * 1024) {
368
+ let br = null
369
+ if (wantBr) {
370
+ try { br = brotliCompressSync(buf, { params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 5, [zlibConstants.BROTLI_PARAM_SIZE_HINT]: buf.length } }) } catch { br = null }
371
+ }
372
+ let gz = null
373
+ if (wantGzip) { try { gz = gzipSync(buf) } catch { gz = null } }
374
+ const best = [br && { body: br, encoding: 'br' }, gz && { body: gz, encoding: 'gzip' }]
375
+ .filter(Boolean)
376
+ .filter((x) => x.body.length < buf.length)
377
+ .sort((a, b) => a.body.length - b.body.length)[0]
378
+ if (best) return best
379
+ return { body: buf, encoding: '' }
380
+ }
381
+ if (wantBr) {
382
+ try {
383
+ const out = brotliCompressSync(buf, { params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 5, [zlibConstants.BROTLI_PARAM_SIZE_HINT]: buf.length } })
384
+ if (out.length < buf.length) return { body: out, encoding: 'br' }
385
+ } catch { /* 落到 gzip */ }
386
+ }
387
+ if (wantGzip) {
388
+ const out = gzipSync(buf)
389
+ if (out.length < buf.length) return { body: out, encoding: 'gzip' }
390
+ }
391
+ return { body: buf, encoding: '' }
321
392
  }
322
393
 
323
394
  /** 读请求体(POST body),返回解析后的 JSON 对象或空。 */