memory-eternal 0.9.1 → 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 +1 -0
- package/README.md +1 -0
- package/index.js +45 -6
- package/lib/api.js +3 -2
- package/lib/capture-log.js +59 -0
- package/lib/web.js +4 -0
- package/package.json +1 -1
package/README.en.md
CHANGED
|
@@ -246,6 +246,7 @@ npm run build # builds lib/client.js (DSH embed) + web/app.js (standalone web
|
|
|
246
246
|
|
|
247
247
|
| Version | Date | Highlights |
|
|
248
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** |
|
|
249
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) |
|
|
250
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). |
|
|
251
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 |
|
package/README.md
CHANGED
|
@@ -259,6 +259,7 @@ npm run build # 构建 lib/client.js(DSH 内嵌)+ web/app.js(独立 web
|
|
|
259
259
|
|
|
260
260
|
| 版本 | 日期 | 关键改动 |
|
|
261
261
|
|---|---|---|
|
|
262
|
+
| **v0.9.2** | 2026-09-12 | **日志可见性 + 误报修复**:自动沉淀日志**落盘持久化**(`~/.dsh/memory-eternal-capture.jsonl`,最多 500 条)——独立 Web 页不再显示「日志仅在 DSH 宿主内可用」而是直接能看到记录,宿主重启后历史也不丢(启动时读回);修复**停滞探测误报刷屏**(轮次计数对不上其实是常态:子代理轮次、被取消的轮次都不发 `turn-stopping`;现在要求「有轮次在跑 **且** 15 分钟无任何沉淀活动」才报一次,活动恢复即自动撤销,不再每 10 分钟刷一条把面板挤满、健康状态永久亮红);蒸馏无输出/JSON 解析失败时**改为原文卡兜底**,内容不再白丢 |
|
|
262
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 进程) |
|
|
263
264
|
| **v0.9.0** | 2026-09-12 | **修复自动沉淀静默失效(一连 6 天不写卡)**:DSH 升级后会话事件接口由 `events` 改为 `ownEvents()/snapshotEvents()`,监听器读不到事件后静默返回、不留任何痕迹 —— 现改为三级自适应取法(`ownEvents()` → `snapshotEvents()` → `events`)+ 每会话接入留痕;日配额改为**只统计真实写卡**(此前「不值得保存」的判定也照样扣配额,40 次即耗尽 24h 配额)、`captureCooldownMs` 冷却真正生效、单次沉淀输入截尾 20000 字;新增**自动沉淀日志**面板与**异常主动提示**(页面红条 + 注入 Agent 上下文 + 轮次收尾停滞巡检);移除「每日回顾」自动简报(定时器 / `/todayBrief` 接口 / UI 按钮) |
|
|
264
265
|
| **v0.8.0** | 2026-09-06 | **全套 UI i18n**:跟随 DSH 系统语言实时切换(中/英),覆盖知识卡 / 知识图谱 / 审核中心 / 配置面板 / 新建卡模板;Windows 测试收尾 SQLite 连接 EBUSY 修复 |
|
package/index.js
CHANGED
|
@@ -29,6 +29,7 @@ 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
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
|
-
|
|
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) {
|
|
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 语义)。
|
|
@@ -449,11 +484,15 @@ export function apply(ctx, config) {
|
|
|
449
484
|
}))
|
|
450
485
|
}
|
|
451
486
|
|
|
452
|
-
//
|
|
487
|
+
// 沉淀停滞探测:只有「有轮次在跑」且「管线 15 分钟一个活口都没有」才报(真死才报)。
|
|
488
|
+
// 计数器对不上是常态(子代理轮次、被取消的轮次都不发 turn-stopping),单看计数会误报刷屏。
|
|
453
489
|
const stallTimer = setInterval(() => {
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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 可能改了事件名或作用域`)
|
|
457
496
|
}
|
|
458
497
|
}, 10 * 60 * 1000)
|
|
459
498
|
ctx.effect(() => () => clearInterval(stallTimer), 'memory-eternal: capture stall watch')
|
package/lib/api.js
CHANGED
|
@@ -95,9 +95,10 @@ export function createApi(deps = {}) {
|
|
|
95
95
|
}
|
|
96
96
|
case '/capture-log': {
|
|
97
97
|
// 最近 100 条自动沉淀运行轨迹(监听 → 判定 → 写入/跳过原因)+ 健康状态。
|
|
98
|
-
|
|
98
|
+
// 宿主侧是内存缓冲(同步),独立 Web 侧是从 JSONL 读文件(异步)→ 统一 await。
|
|
99
|
+
const log = await getCaptureLog()
|
|
99
100
|
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) })
|
|
101
|
+
json(res, 200, { ok: true, available: true, health: await getCaptureHealth(), entries: log.slice(0, 100) })
|
|
101
102
|
return
|
|
102
103
|
}
|
|
103
104
|
case '/export': {
|
|
@@ -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
|
+
}
|
package/lib/web.js
CHANGED
|
@@ -16,6 +16,7 @@ import path from 'node:path'
|
|
|
16
16
|
import { fileURLToPath } from 'node:url'
|
|
17
17
|
import { createApi, API_PREFIX, encodeBody } from './api.js'
|
|
18
18
|
import { defaultVaultDir, configFilePath } from './capture-run.js'
|
|
19
|
+
import { readCaptureLog, deriveHealth } from './capture-log.js'
|
|
19
20
|
|
|
20
21
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
21
22
|
const WEB_ROOT = path.join(__dirname, '..', 'web')
|
|
@@ -32,6 +33,9 @@ export function startWebServer({ port = DEFAULT_WEB_PORT, vaultRoot = defaultVau
|
|
|
32
33
|
// 读 DSH 写入的共享配置文件 → web 端与 DSH 设置同步(不同步修复)
|
|
33
34
|
getSettings: () => { try { return JSON.parse(readFileSync(configFilePath(process.env), 'utf8')) } catch { return {} } },
|
|
34
35
|
getDshInfo: () => ({ name: 'deepseek-harness', label: 'DeepSeek Harness(当前宿主)', installed: true, memoryRecallTool: true, autoCapture: true, autoRecall: true, vaultDir: vaultRoot, version: VERSION }),
|
|
36
|
+
// 独立 Web 页也能看「自动沉淀日志」:读宿主写的 JSONL(原来这里只能显示"不可用")
|
|
37
|
+
getCaptureLog: () => readCaptureLog(100, process.env),
|
|
38
|
+
getCaptureHealth: async () => deriveHealth(await readCaptureLog(50, process.env)),
|
|
35
39
|
})
|
|
36
40
|
|
|
37
41
|
const server = http.createServer(async (req, res) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "memory-eternal",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.2",
|
|
4
4
|
"description": "记忆核心(Memory Eternal):自研的 DeepSeek Harness 记忆插件,不移植任何既有记忆框架——对话结束后自动沉淀知识卡到本地 Markdown Vault(自研去重、自研 CJK 检索、可 git 管理),设置页提供图形化知识库(统计 / 搜索 / 知识图谱 + 侧边栏一键弹窗),Agent 通过 memory_recall 工具按需召回历史上下文。零人工干预。",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|