memory-eternal 0.9.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:
@@ -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,7 @@ 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.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
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). |
240
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 |
241
252
  | **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,7 @@ npm run build # 构建 lib/client.js(DSH 内嵌)+ web/app.js(独立 web
251
259
 
252
260
  | 版本 | 日期 | 关键改动 |
253
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 进程) |
254
263
  | **v0.9.0** | 2026-09-12 | **修复自动沉淀静默失效(一连 6 天不写卡)**:DSH 升级后会话事件接口由 `events` 改为 `ownEvents()/snapshotEvents()`,监听器读不到事件后静默返回、不留任何痕迹 —— 现改为三级自适应取法(`ownEvents()` → `snapshotEvents()` → `events`)+ 每会话接入留痕;日配额改为**只统计真实写卡**(此前「不值得保存」的判定也照样扣配额,40 次即耗尽 24h 配额)、`captureCooldownMs` 冷却真正生效、单次沉淀输入截尾 20000 字;新增**自动沉淀日志**面板与**异常主动提示**(页面红条 + 注入 Agent 上下文 + 轮次收尾停滞巡检);移除「每日回顾」自动简报(定时器 / `/todayBrief` 接口 / UI 按钮) |
255
264
  | **v0.8.0** | 2026-09-06 | **全套 UI i18n**:跟随 DSH 系统语言实时切换(中/英),覆盖知识卡 / 知识图谱 / 审核中心 / 配置面板 / 新建卡模板;Windows 测试收尾 SQLite 连接 EBUSY 修复 |
256
265
  | **v0.3.1** | 2026-09-03 | 知识图谱按需加载(点击时 fetch,关闭时注销) |
package/index.js CHANGED
@@ -28,7 +28,7 @@ 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
32
 
33
33
  export const name = 'memory-eternal'
34
34
  export const inject = ['systemPrompt', 'settings']
@@ -381,6 +381,10 @@ export function apply(ctx, config) {
381
381
  '3. 若检索结果为空,就诚实说明当前记忆库没有相关内容,不要编造。',
382
382
  '4. 知识卡存储在 SQLite 数据库中(memory-eternal.db),**禁止**用文件工具直接读写 vault 目录下的任何文件。沉淀记忆必须通过 memory_recall 工具或 /memory-eternal/api/write API。',
383
383
  '5. 若下方出现「自动沉淀异常」,必须在本次回复的第一句用中文转述该异常并提醒用户处理,不要自行猜测或尝试修复。',
384
+ // 审核红线(主上 2026-09-12 强制要求):只在审核真正生效时注入。
385
+ ...((cfg.auditMode ?? 'all') === 'none' ? [] : [
386
+ '6. **审核红线(强制)**:写卡一律停在 pending,由用户在「审核中心」审批。**禁止**调用 /memory-eternal/api/audit/approve 或 /audit/reject 代替用户审批,也不得用任何等价方式绕过审核;用户说「记录一下 / 增加记忆」不等于允许免审入库。需要立刻可用时,写卡后明确告知用户「已进审核中心,待批准」。',
387
+ ]),
384
388
  ].join('\n')
385
389
  // 异常提示:沉淀管线坏了,靠这一句把消息送到用户面前(不依赖用户去翻页面)。
386
390
  const h = health.snapshot()
@@ -600,8 +604,13 @@ export function apply(ctx, config) {
600
604
  const webRoot = path.join(PACKAGE_ROOT, 'web')
601
605
  if (pathname.endsWith('app.js')) {
602
606
  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)
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)
605
614
  }
606
615
  const buf = await readFile(path.join(webRoot, 'index.html'))
607
616
  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': {
@@ -317,12 +326,69 @@ export function createApi(deps = {}) {
317
326
 
318
327
  export function json(res, status, body) {
319
328
  const payload = JSON.stringify(body)
320
- res.writeHead(status, {
329
+ const headers = {
321
330
  'Content-Type': 'application/json; charset=utf-8',
322
- 'Content-Length': Buffer.byteLength(payload),
323
331
  'Cache-Control': 'no-store',
324
- })
325
- 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: '' }
326
392
  }
327
393
 
328
394
  /** 读请求体(POST body),返回解析后的 JSON 对象或空。 */