memory-eternal 0.3.9 → 0.9.0
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 +28 -0
- package/README.md +15 -0
- package/index.js +161 -48
- package/lib/api.js +10 -5
- package/lib/capture.js +69 -1
- package/lib/client.js +94 -49
- package/lib/db.js +6 -0
- package/lib/vault.js +0 -40
- package/package.json +1 -1
- package/web/app.js +98 -53
package/README.en.md
CHANGED
|
@@ -49,6 +49,8 @@ After **restarting dsh web**, three things are live immediately:
|
|
|
49
49
|
|
|
50
50
|
**Entry**: The sidebar-footer `Memory` button opens the vault (its left rail includes Cards / Graph / Usage / **Audit Center** / Recycle Bin / **Memory Config**); "DSH Settings → Memory" is the pure config page.
|
|
51
51
|
|
|
52
|
+
**UI language**: The whole UI is bilingual (English / 中文) and follows **DSH Settings → Language** in real time — Cards, Graph, Audit Center, card templates, config panel all included; the standalone web page follows the browser language.
|
|
53
|
+
|
|
52
54
|
**Edit config**: Vault left rail "Memory Config" (or DSH Settings → Memory) → DSH memory config / cost control / auto-audit config / self-hosting — just hit "Save Config". `autoWebMode` / `watchdogAutoSpawn` changes require a DSH restart.
|
|
53
55
|
|
|
54
56
|
### 🟨 Claude Code
|
|
@@ -167,6 +169,17 @@ Top of the config page: **🟢 A Light / 💰 B Budget / ⭐ C Premium** — cli
|
|
|
167
169
|
|
|
168
170
|
---
|
|
169
171
|
|
|
172
|
+
## 🔔 Capture Log & Failure Alerts
|
|
173
|
+
|
|
174
|
+
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.
|
|
175
|
+
|
|
176
|
+
- **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)`.
|
|
177
|
+
- **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.
|
|
178
|
+
- **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.
|
|
179
|
+
- **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.
|
|
180
|
+
|
|
181
|
+
---
|
|
182
|
+
|
|
170
183
|
## 🛡️ Audit Center & Recycle Bin
|
|
171
184
|
|
|
172
185
|
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.
|
|
@@ -219,6 +232,21 @@ npm run build # builds lib/client.js (DSH embed) + web/app.js (standalone web
|
|
|
219
232
|
|
|
220
233
|
---
|
|
221
234
|
|
|
235
|
+
## 📋 Changelog
|
|
236
|
+
|
|
237
|
+
| Version | Date | Highlights |
|
|
238
|
+
|---|---|---|
|
|
239
|
+
| **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
|
+
| **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
|
+
| **v0.3.1** | 2026-09-03 | Knowledge graph loads on demand (fetch on open, unregister on close) |
|
|
242
|
+
| **v0.3.0** | 2026-09-03 | **SQLite storage layer** (`node:sqlite`, zero deps) + DB-level audit guard `enforceAudit()` + `audit_log` table + automatic `.md` migration |
|
|
243
|
+
| v0.2.0 | 2026-09-03 | Recall with body text (`search()` returns the `excerpt` field) |
|
|
244
|
+
| v0.1.2 | 2026-09-03 | Audit guard hardening (`writeCard` defaults to `pending`, `mergeCards` goes through audit) |
|
|
245
|
+
| v0.1.1 | 2026-09-03 | Recall body fix (`excerpt` field + body logic) |
|
|
246
|
+
| v0.1.0 | 2026-08-31 | First release: auto capture + auto recall + graphical knowledge base + knowledge graph + audit center + recycle bin |
|
|
247
|
+
|
|
248
|
+
---
|
|
249
|
+
|
|
222
250
|
## 📄 License
|
|
223
251
|
|
|
224
252
|
MIT
|
package/README.md
CHANGED
|
@@ -49,6 +49,8 @@ cd ~/.dsh/profiles/web && pnpm add memory-eternal@latest
|
|
|
49
49
|
|
|
50
50
|
**入口**:侧边栏底部「记忆」按钮 → 记忆库(左栏含 知识卡 / 知识图谱 / 用量 / **审核中心** / 回收中心 / **记忆配置**);「DSH 设置 → 记忆」= 纯配置页。
|
|
51
51
|
|
|
52
|
+
**界面语言**:全套 UI 中英双语,跟随 **DSH 设置 → 语言** 实时切换(含知识卡 / 知识图谱 / 审核中心 / 新建卡模板);浏览器直接访问 web 时跟随浏览器语言。
|
|
53
|
+
|
|
52
54
|
**改配置**:记忆库左栏「记忆配置」(或 DSH 设置 → 记忆)→ DSH 记忆配置 / 成本控制 / 自动审核配置 / 服务自管理,点「保存配置」即写入。`autoWebMode`/`watchdogAutoSpawn` 的改动需重启 DSH 生效。
|
|
53
55
|
|
|
54
56
|
### 🟨 Claude Code
|
|
@@ -179,6 +181,17 @@ dsh-memory watchdog [--port 7799] # 看门狗保活 web(独立进程)
|
|
|
179
181
|
|
|
180
182
|
---
|
|
181
183
|
|
|
184
|
+
## 🔔 自动沉淀日志 & 异常提示
|
|
185
|
+
|
|
186
|
+
自动沉淀是后台静默管线:一旦坏了,以前只能表现为「卡不再增加」,看不出原因。v0.9.0 起它会自己说话。
|
|
187
|
+
|
|
188
|
+
- **自动沉淀日志**:设置 → 记忆 → 用量/今日,最近 100 条运行轨迹:`🚀 启动` / `👂 会话接入(用的事件接口 + 事件数)` / `✅ 新建卡` / `➕ 追加更新` / `⏭ 跳过(原因)` / `❌ 失败(原因)`。
|
|
189
|
+
- **异常主动提示**:出现失败即亮红——记忆库页面顶部红条,同时把「自动沉淀异常 + 原因」注入 Agent 上下文,Agent 会在回复第一句提醒你,不需要你自己去翻日志。
|
|
190
|
+
- **停滞兜底**:有轮次开始、15 分钟收不到收尾事件(例如 DSH 改了事件名),10 分钟巡检直接报出来。
|
|
191
|
+
- **接口自适应**:自动识别 DSH 会话事件接口(`ownEvents()` → `snapshotEvents()` → `events`),DSH 升级换 API 不会再静默断掉。
|
|
192
|
+
|
|
193
|
+
---
|
|
194
|
+
|
|
182
195
|
## 🛡️ 审核中心 & 回收中心
|
|
183
196
|
|
|
184
197
|
新卡默认进**审核中心**(`pending`),由你确认后才入主库;驳回的进「已驳回」,可恢复或删除进回收站。命中免审条件(审核模式=全部免审 / 免审智能体 / 免审类型)的新卡直接入库;回收站软删卡 30 天内可恢复,超期自动永久删除。
|
|
@@ -238,6 +251,8 @@ npm run build # 构建 lib/client.js(DSH 内嵌)+ web/app.js(独立 web
|
|
|
238
251
|
|
|
239
252
|
| 版本 | 日期 | 关键改动 |
|
|
240
253
|
|---|---|---|
|
|
254
|
+
| **v0.9.0** | 2026-09-12 | **修复自动沉淀静默失效(一连 6 天不写卡)**:DSH 升级后会话事件接口由 `events` 改为 `ownEvents()/snapshotEvents()`,监听器读不到事件后静默返回、不留任何痕迹 —— 现改为三级自适应取法(`ownEvents()` → `snapshotEvents()` → `events`)+ 每会话接入留痕;日配额改为**只统计真实写卡**(此前「不值得保存」的判定也照样扣配额,40 次即耗尽 24h 配额)、`captureCooldownMs` 冷却真正生效、单次沉淀输入截尾 20000 字;新增**自动沉淀日志**面板与**异常主动提示**(页面红条 + 注入 Agent 上下文 + 轮次收尾停滞巡检);移除「每日回顾」自动简报(定时器 / `/todayBrief` 接口 / UI 按钮) |
|
|
255
|
+
| **v0.8.0** | 2026-09-06 | **全套 UI i18n**:跟随 DSH 系统语言实时切换(中/英),覆盖知识卡 / 知识图谱 / 审核中心 / 配置面板 / 新建卡模板;Windows 测试收尾 SQLite 连接 EBUSY 修复 |
|
|
241
256
|
| **v0.3.1** | 2026-09-03 | 知识图谱按需加载(点击时 fetch,关闭时注销) |
|
|
242
257
|
| **v0.3.0** | 2026-09-03 | **SQLite 化存储层**(`node:sqlite` 零依赖)+ 数据库层审核守卫 `enforceAudit()` + `audit_log` 审计日志 + 自动从 .md 迁移 |
|
|
243
258
|
| v0.2.0 | 2026-09-03 | recall 含正文修复(`search()` 返回 excerpt 字段) |
|
package/index.js
CHANGED
|
@@ -25,9 +25,9 @@ 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
|
|
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'
|
|
30
|
+
import { summarizeTurn, extractLastTurn, sliceNewEvents, sessionEvents, sessionEventApi, createCaptureHealth, resolveRoute, captureCard, captureUpdate, pickNeighbors } from './lib/capture.js'
|
|
31
31
|
import { createApi, json } from './lib/api.js'
|
|
32
32
|
|
|
33
33
|
export const name = 'memory-eternal'
|
|
@@ -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
|
-
|
|
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
|
-
|
|
160
|
-
if (
|
|
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
|
-
|
|
164
|
-
pending.set(sessionId, [...existing, ...newEvents])
|
|
194
|
+
existing.events.push(...events)
|
|
165
195
|
return
|
|
166
196
|
}
|
|
167
|
-
|
|
168
|
-
|
|
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,
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
241
|
-
pending.delete(sessionId)
|
|
319
|
+
// 运行期间若又累积了新事件,保留队列给下一轮 run 处理,避免丢事件。
|
|
320
|
+
if (pending.get(sessionId) === job) pending.delete(sessionId)
|
|
242
321
|
}
|
|
243
322
|
}
|
|
244
323
|
|
|
245
|
-
const
|
|
246
|
-
const
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
258
|
-
if (!Array.isArray(events)) return
|
|
333
|
+
turnsStopped += 1
|
|
259
334
|
const sessionId = agent?.session?.id ?? agent?.id ?? 'unknown'
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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,27 @@ 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
|
+
].join('\n')
|
|
385
|
+
// 异常提示:沉淀管线坏了,靠这一句把消息送到用户面前(不依赖用户去翻页面)。
|
|
386
|
+
const h = health.snapshot()
|
|
387
|
+
const alert = h.ok ? '' : [
|
|
388
|
+
'',
|
|
389
|
+
'⚠ 自动沉淀异常(记忆核心 memory-eternal)——' + h.reason,
|
|
390
|
+
'(发生时间:' + new Date(h.since).toLocaleString() + '。请在回复第一句提醒用户:自动沉淀异常 + 上述原因;'
|
|
391
|
+
+ '并可提示用户打开 设置→记忆→用量/今日 查看「自动沉淀日志」;若原因是接口/版本不匹配,需更新 memory-eternal 插件。)',
|
|
282
392
|
].join('\n')
|
|
283
393
|
disposeSection = ctx.systemPrompt.section({
|
|
284
394
|
name: 'memory-eternal: auto-recall',
|
|
285
395
|
order: 600,
|
|
286
|
-
text,
|
|
396
|
+
text: text + alert,
|
|
287
397
|
})
|
|
288
398
|
}
|
|
399
|
+
refreshPrompt = refresh
|
|
289
400
|
refresh(settings.get())
|
|
290
401
|
const unwatch = settings.watch(refresh)
|
|
291
402
|
return () => {
|
|
403
|
+
if (refreshPrompt === refresh) refreshPrompt = null
|
|
292
404
|
if (typeof unwatch === 'function') unwatch()
|
|
293
405
|
if (disposeSection) disposeSection()
|
|
294
406
|
}
|
|
@@ -333,16 +445,14 @@ export function apply(ctx, config) {
|
|
|
333
445
|
}))
|
|
334
446
|
}
|
|
335
447
|
|
|
336
|
-
//
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
}, 30 * 60 * 1000)
|
|
345
|
-
ctx.effect(() => () => clearInterval(briefTimer), 'memory-eternal: daily brief timer')
|
|
448
|
+
// 沉淀停滞探测:有轮次开始、却迟迟没有收尾(>15 分钟),说明 turn-stopping 没到。
|
|
449
|
+
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 可能改了事件名或作用域`)
|
|
453
|
+
}
|
|
454
|
+
}, 10 * 60 * 1000)
|
|
455
|
+
ctx.effect(() => () => clearInterval(stallTimer), 'memory-eternal: capture stall watch')
|
|
346
456
|
|
|
347
457
|
// 回收站清理:每 30 分钟永久删除超过保留期(默认 30 天)的软删卡。
|
|
348
458
|
const purgeTimer = setInterval(() => {
|
|
@@ -376,6 +486,9 @@ export function apply(ctx, config) {
|
|
|
376
486
|
if (webServer !== undefined) {
|
|
377
487
|
const handleApi = createApi({
|
|
378
488
|
vaultDir, vaultRoots, getSettings: settings.get,
|
|
489
|
+
// 自动沉淀运行轨迹 + 健康状态:供「用量/今日」页排查「为什么没写卡」,异常时页面顶部亮红。
|
|
490
|
+
getCaptureLog: () => captureLog.slice().reverse(),
|
|
491
|
+
getCaptureHealth: () => health.snapshot(),
|
|
379
492
|
getDshInfo: () => ({
|
|
380
493
|
name: 'deepseek-harness',
|
|
381
494
|
label: 'DeepSeek Harness(当前宿主)',
|
package/lib/api.js
CHANGED
|
@@ -9,8 +9,9 @@
|
|
|
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,
|
|
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
15
|
import { compressExcerpt } from './capture.js'
|
|
15
16
|
import { getSetupStatus, runSetup, mcpAgentAction } from './setup.js'
|
|
16
17
|
import { backupDb } from './db.js'
|
|
@@ -23,6 +24,9 @@ export function createApi(deps = {}) {
|
|
|
23
24
|
const getSettings = deps.getSettings ?? (() => ({}))
|
|
24
25
|
// DSH 宿主信息(独立 web server 无 DSH 环境时为 null)。head DSH 状态行用。
|
|
25
26
|
const getDshInfo = deps.getDshInfo ?? (() => null)
|
|
27
|
+
// 自动沉淀运行轨迹(最新在前)+ 健康状态(异常时 UI 顶部亮红)。
|
|
28
|
+
const getCaptureLog = deps.getCaptureLog ?? (() => null)
|
|
29
|
+
const getCaptureHealth = deps.getCaptureHealth ?? (() => null)
|
|
26
30
|
|
|
27
31
|
return async function handleApi(req, res) {
|
|
28
32
|
const vaultRoot = vaultDir()
|
|
@@ -80,10 +84,11 @@ export function createApi(deps = {}) {
|
|
|
80
84
|
json(res, 200, { ok: true, ...g })
|
|
81
85
|
return
|
|
82
86
|
}
|
|
83
|
-
case '/
|
|
84
|
-
|
|
85
|
-
const
|
|
86
|
-
json(res, 200, { ok: true,
|
|
87
|
+
case '/capture-log': {
|
|
88
|
+
// 最近 100 条自动沉淀运行轨迹(监听 → 判定 → 写入/跳过原因)+ 健康状态。
|
|
89
|
+
const log = getCaptureLog()
|
|
90
|
+
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) })
|
|
87
92
|
return
|
|
88
93
|
}
|
|
89
94
|
case '/export': {
|
package/lib/capture.js
CHANGED
|
@@ -230,12 +230,80 @@ export function extractLastTurn(events, _turn) {
|
|
|
230
230
|
return lines.join('\n').trim()
|
|
231
231
|
}
|
|
232
232
|
|
|
233
|
+
/**
|
|
234
|
+
* 读会话事件快照。
|
|
235
|
+
*
|
|
236
|
+
* 优先 `ownEvents()`(DSH 公开 API):只取本会话自身事件,恢复/分叉会话不会把
|
|
237
|
+
* 继承来的历史前缀重复沉淀;`snapshotEvents()` 次之,老结构里的 `events` 兜底。
|
|
238
|
+
* 三条路都取不到时返回空数组,而不是让监听器抛异常(抛异常 = 管线静默死亡,
|
|
239
|
+
* 页面只表现为「一直不写卡」)。
|
|
240
|
+
*/
|
|
241
|
+
export function sessionEvents(session) {
|
|
242
|
+
if (!session) return []
|
|
243
|
+
// 逐个试:某个取法报了错就换下一个,不要让监听器跟着挂掉。
|
|
244
|
+
for (const pick of ['ownEvents', 'snapshotEvents']) {
|
|
245
|
+
if (typeof session[pick] === 'function') {
|
|
246
|
+
try {
|
|
247
|
+
const out = session[pick]()
|
|
248
|
+
if (Array.isArray(out)) return out
|
|
249
|
+
} catch { /* 换下一个取法 */ }
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return Array.isArray(session.events) ? session.events : []
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* 自动沉淀健康状态(进程内)。
|
|
257
|
+
*
|
|
258
|
+
* 目的:任何一条失败路径都必须留下「坏消息」,供 systemPrompt 段和 UI 横幅提示用户——
|
|
259
|
+
* 沉淀是后台静默管线,坏了不能只写日志等人来看。`fail()` 记录当前故障与起始时间,
|
|
260
|
+
* `succeed()` 表示一次真实写卡成功(故障自动清除)。
|
|
261
|
+
*/
|
|
262
|
+
export function createCaptureHealth() {
|
|
263
|
+
let ok = true
|
|
264
|
+
let reason = ''
|
|
265
|
+
let since = 0
|
|
266
|
+
let lastOkAt = 0
|
|
267
|
+
let lastFailAt = 0
|
|
268
|
+
return {
|
|
269
|
+
fail(why) {
|
|
270
|
+
ok = false
|
|
271
|
+
reason = String(why || '未知原因').replace(/\s+/g, ' ').slice(0, 160)
|
|
272
|
+
since = Date.now()
|
|
273
|
+
lastFailAt = since
|
|
274
|
+
},
|
|
275
|
+
succeed() {
|
|
276
|
+
ok = true
|
|
277
|
+
reason = ''
|
|
278
|
+
since = 0
|
|
279
|
+
lastOkAt = Date.now()
|
|
280
|
+
},
|
|
281
|
+
snapshot() { return { ok, reason, since, lastOkAt, lastFailAt } },
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* 探测会话对象认识哪种「取事件」方式,返回用到的成员名('' = 一个都不认识)。
|
|
287
|
+
*
|
|
288
|
+
* 这个返回值会写进自动沉淀日志:一旦 DSH 再改接口,日志第一行就会显示
|
|
289
|
+
* 「没有事件接口」,而不是继续静默无输出。
|
|
290
|
+
*/
|
|
291
|
+
export function sessionEventApi(session) {
|
|
292
|
+
if (!session) return ''
|
|
293
|
+
// 顺序即优先级:ownEvents() 只含本会话自身事件(恢复/分叉不会重复沉淀历史)。
|
|
294
|
+
for (const pick of ['ownEvents', 'snapshotEvents']) {
|
|
295
|
+
if (typeof session[pick] === 'function') return pick
|
|
296
|
+
}
|
|
297
|
+
return Array.isArray(session.events) ? 'events' : ''
|
|
298
|
+
}
|
|
299
|
+
|
|
233
300
|
/** 取增量事件:seq 大于 lastSeq 的 user/assistant 消息事件。 */
|
|
234
301
|
export function sliceNewEvents(events, lastSeq) {
|
|
235
302
|
const out = []
|
|
236
303
|
for (const event of events) {
|
|
237
304
|
const seq = event.seq
|
|
238
|
-
|
|
305
|
+
// 无 seq 的事件(非常规日志)也收,避免漏掉消息;有 seq 则按水位过滤。
|
|
306
|
+
if (typeof seq === 'number' && typeof lastSeq === 'number' && seq <= lastSeq) continue
|
|
239
307
|
const type = event.type
|
|
240
308
|
if (type === 'user/message' || type === 'assistant/message') out.push(event)
|
|
241
309
|
}
|