memory-eternal 0.20.0 → 0.22.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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/README.md +3 -1
- package/README.zh-CN.md +3 -1
- package/cursor-extension/package.json +1 -1
- package/dsh-tui-plugin/dsh-plugin.json +1 -1
- package/dsh-tui-plugin/package.json +1 -1
- package/lib/align.js +16 -2
- package/lib/api.js +99 -5
- package/lib/client.js +36 -20
- package/lib/db.js +12 -0
- package/lib/mcp.js +41 -2
- package/lib/node-bin.js +17 -12
- package/lib/vault.js +780 -4
- package/orca-plugin/orca-plugin.json +1 -1
- package/package.json +2 -2
- package/web/app.js +38 -22
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://anthropic.com/claude-code/plugin.schema.json",
|
|
3
3
|
"name": "memory-eternal",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.22.0",
|
|
5
5
|
"description": "记忆核心(Memory Eternal):对话结束后自动沉淀知识卡到本地记忆库(~/.dsh/memory-vault),支持检索 / 召回 / 审核中心。",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "EternalNight996",
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/EternalNight996/memory-eternal/stargazers)
|
|
4
4
|
[](https://www.npmjs.com/package/memory-eternal)
|
|
5
|
-
[](https://github.com/EternalNight996/memory-eternal/releases)
|
|
6
6
|
[](LICENSE)
|
|
7
7
|
|
|
8
8
|
**English (default)** · [简体中文](README.zh-CN.md)
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
> Local-first, review-gated, measurable — one SQLite vault shared by DSH, Claude Code, Codex, Cursor and any MCP client.
|
|
18
18
|
|
|
19
19
|
- 🔍 **Review before it counts** — new cards wait in a review queue; approve or reject from the Web UI or terminal; every change is written to an immutable audit log.
|
|
20
|
+
- ✏️ **Editable & revisable** — fix a card by hand in the reader, or let an agent submit a **revised copy** (`memory_revise`); the queue labels it a revision, and an approved card is only overwritten when you approve it.
|
|
20
21
|
- 🧾 **Token ledger** — see how much recall injected, how many distillation calls ran, and how often recall was actually useful.
|
|
21
22
|
- 🏠 **Local-first, no cloud** — SQLite + Markdown on your own disk. No third-party memory framework, no account. Memory data never leaves the machine; only the News page fetches public RSS / Hacker News on demand (titles, excerpts and outbound links only).
|
|
22
23
|
- ⭐ **Core memory** — promote approved cards to always-on memory; every session gets them automatically, and the review queue still decides what qualifies.
|
|
@@ -84,6 +85,7 @@ A memory system that silently ingests everything will eventually recall a wrong
|
|
|
84
85
|
- approve, reject or bulk-process from the Web UI, the terminal TUI, or the CLI;
|
|
85
86
|
- every status change is appended to an **immutable audit log** (who / what / when / why) — enforced at the SQLite layer, not by convention;
|
|
86
87
|
- agents can read and propose, but **cannot approve their own cards**;
|
|
88
|
+
- agents can also submit a **revision**: a copy of an approved card lands in the queue marked “✏️ revision of …”; approving it overwrites the original (id, path and status preserved, an entry appended to the card's update log), rejecting it leaves the original untouched;
|
|
87
89
|
- deleted cards go to a recycle bin (30 days by default).
|
|
88
90
|
|
|
89
91
|
## ⭐ Core memory
|
package/README.zh-CN.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/EternalNight996/memory-eternal/stargazers)
|
|
4
4
|
[](https://www.npmjs.com/package/memory-eternal)
|
|
5
|
-
[](https://github.com/EternalNight996/memory-eternal/releases)
|
|
6
6
|
[](LICENSE)
|
|
7
7
|
|
|
8
8
|
[English (default)](README.md) · **简体中文(镜像)**
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
> 本地优先、先审后入、可度量 —— 一个 SQLite 库,DSH / Claude Code / Codex / Cursor 与任意 MCP 客户端共享。
|
|
18
18
|
|
|
19
19
|
- 🔍 **先审核,再入库** —— 新卡先进待审队列,Web 或终端里批准/驳回;每次状态变更写入不可篡改的审计日志。
|
|
20
|
+
- ✏️ **可编辑、可修订** —— 在阅读器里直接改卡片,或让 agent 提交**修改副本**(`memory_revise`);审核中心把它标成「✏️ 修改」,人批准后才覆盖原卡。
|
|
20
21
|
- 🧾 **记忆账本** —— 召回注入了多少、蒸馏调了几次、召回到底有没有用,全部可查。
|
|
21
22
|
- 🏠 **本地优先,零云端** —— SQLite + Markdown 存在你自己的磁盘上。没有第三方记忆框架、不需要账号;记忆数据不外发,只有「动态」资讯会按需请求公开 RSS / Hacker News(只取标题、摘要与外链)。
|
|
22
23
|
- ⭐ **核心记忆** —— 已批准的卡可提升为「每次会话强制注入」的核心记忆;自动沉淀可以建议,但最终仍由审核中心决定。
|
|
@@ -84,6 +85,7 @@ npx -y -p memory-eternal dsh-memory tui # 终端里的实时待审队列
|
|
|
84
85
|
- 在 Web、终端 TUI 或 CLI 里批准 / 驳回 / 批量处理;
|
|
85
86
|
- 每次状态变更追加到**不可篡改的审计日志**(谁 / 什么 / 何时 / 为什么)—— 由 SQLite 层强制,而不是靠约定;
|
|
86
87
|
- Agent 可以读取和提议,但**不能批准自己写的卡**;
|
|
88
|
+
- Agent 也可以提交**修改**:已批准卡的副本进待审队列并标注「✏️ 修改原卡:…」;批准 = 用副本覆盖原卡(id / path / 状态不变,更新记录留痕),驳回 = 原卡一字不动;
|
|
87
89
|
- 删除的卡进回收站(默认保留 30 天)。
|
|
88
90
|
|
|
89
91
|
## ⭐ 核心记忆
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "memory-eternal",
|
|
3
3
|
"displayName": "Memory Atlas",
|
|
4
4
|
"description": "Memory-eternal: open the local Memory Atlas library, review queue and knowledge graph in a Cursor/VS Code webview.",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.22.0",
|
|
6
6
|
"publisher": "EternalNight996",
|
|
7
7
|
"license": "MIT",
|
|
8
8
|
"repository": {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "urn:dsh-std:community-draft:dsh-plugin:0.15",
|
|
3
3
|
"id": "memory-eternal-tui",
|
|
4
4
|
"name": "memory-eternal-tui",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.22.0",
|
|
6
6
|
"manifestVersion": "0.15",
|
|
7
7
|
"description": "Open Memory Atlas from dsh-TUI: native /audit scene plus browser launcher.",
|
|
8
8
|
"facets": {
|
package/lib/align.js
CHANGED
|
@@ -19,7 +19,8 @@ import { probeServedVersion } from './watchdog.js'
|
|
|
19
19
|
import { nodeBinary } from './node-bin.js'
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
|
-
* 纯函数:把「版本现状 +
|
|
22
|
+
* 纯函数:把「版本现状 + 接线现状」翻译成结论清单(便于单测)。唯一 IO 是 sameNodePath 的
|
|
23
|
+
* realpathSync 归一化(issue #56),且它只在路径存在时生效、失败即回退原串,不改变判定语义。
|
|
23
24
|
* @returns {{ok:boolean, findings:Array<{id:string,level:'ok'|'warn'|'fix',title:string,detail:string,command:string}>, counts:{ok:number,warn:number,fix:number}}}
|
|
24
25
|
*/
|
|
25
26
|
export function alignFindings({
|
|
@@ -27,6 +28,19 @@ export function alignFindings({
|
|
|
27
28
|
latest = '', updateCommand = '', wiring = [], hookPaths = [], configMirror = null, knownKeys = null,
|
|
28
29
|
} = {}) {
|
|
29
30
|
const findings = []
|
|
31
|
+
// issue #56:node 路径比较必须**归一化**。配置里写的是稳定 shim(/opt/homebrew/bin/node),
|
|
32
|
+
// 当前解析出的可能是真实路径(…/Cellar/node/26.13.0_2/bin/node)—— 纯字符串比较必然不等,
|
|
33
|
+
// 于是 align 把**正确**的接线判成错误;用户按提示跑 setup 又写回带版本号的路径,形成死循环。
|
|
34
|
+
const sameNodePath = (a, b) => {
|
|
35
|
+
const norm = (p) => {
|
|
36
|
+
const raw = String(p || '')
|
|
37
|
+
if (!raw) return ''
|
|
38
|
+
try { return fs.realpathSync(raw) } catch { return raw }
|
|
39
|
+
}
|
|
40
|
+
const na = norm(a)
|
|
41
|
+
const nb = norm(b)
|
|
42
|
+
return process.platform === 'win32' ? na.toLowerCase() === nb.toLowerCase() : na === nb
|
|
43
|
+
}
|
|
30
44
|
const push = (id, level, title, detail, command) => findings.push({ id, level, title, detail: detail || '', command: command || '' })
|
|
31
45
|
|
|
32
46
|
// ① 宿主进程(最容易被忽略、也是"旧接口"的主因)
|
|
@@ -79,7 +93,7 @@ export function alignFindings({
|
|
|
79
93
|
if (a.mcpMatchesCurrentNode === false) {
|
|
80
94
|
// 措辞必须准确:`mcpMatchesCurrentNode` 比较的是**整条 MCP 条目**(node 路径 + 脚本路径 + 参数),
|
|
81
95
|
// 两条路径相同时也可能因为**脚本路径/参数是旧写法**而不一致(升级后最常见)。
|
|
82
|
-
const sameNode =
|
|
96
|
+
const sameNode = sameNodePath(a.configuredNodePath, a.currentNodePath)
|
|
83
97
|
push('wiring-node-' + a.name, 'fix',
|
|
84
98
|
sameNode ? `${a.name}:MCP 条目与当前版本写出的不一致(脚本路径 / 参数是旧写法)` : `${a.name}:MCP 记的 node 路径与当前不一致`,
|
|
85
99
|
sameNode
|
package/lib/api.js
CHANGED
|
@@ -17,7 +17,7 @@ import fs, { readFileSync } from 'node:fs'
|
|
|
17
17
|
import path from 'node:path'
|
|
18
18
|
import { fileURLToPath } from 'node:url'
|
|
19
19
|
import { spawn } from 'node:child_process'
|
|
20
|
-
import { listCards, countCards, readCard, search, searchAll, graph, graphAll, overview, exportCards, deleteCard, writeCard, parseCard, stats, optimizeCandidates, optimizeApply, addFeedback, dailyCounts, mergeCards, ensureVault, auditQueue, recycleList, purgeExpired, setCardStatus, setCardKind, restoreCard, mainStoreBodies } from './vault.js'
|
|
20
|
+
import { listCards, countCards, readCard, search, searchAll, graph, graphAll, graphLevel, LEVEL_ERR_PATH, LEVEL_ERR_STALE, overview, exportCards, deleteCard, writeCard, parseCard, stats, optimizeCandidates, optimizeApply, addFeedback, dailyCounts, mergeCards, ensureVault, auditQueue, recycleList, purgeExpired, setCardStatus, setCardKind, restoreCard, mainStoreBodies, updateCardContent, reviseCard } from './vault.js'
|
|
21
21
|
import { compressExcerpt, guessRawKind, DEFAULT_CAPTURE_MAX_TOKENS } from './capture.js'
|
|
22
22
|
import { buildDiagnostics } from './feedback.js'
|
|
23
23
|
import { getSetupStatus, runSetup, mcpAgentAction } from './setup.js'
|
|
@@ -199,7 +199,7 @@ export function emptyNumericFields(patch, dict = {}) {
|
|
|
199
199
|
* **真的会写库**的路由(/card、/cards、/search 这些是读,绝不能拦)。
|
|
200
200
|
* 用途:库不可写时短路径失败;撞锁(SQLITE_BUSY)时做应用层异步重试。
|
|
201
201
|
*/
|
|
202
|
-
const DB_WRITE_ROUTES = /^\/(delete|merge|write|import|refine-apply|refine-goals-keep|refine-junk-purge|optimize-execute|feedback|skills\/sync|audit\/(approve|reject)|recycle\/(restore|purge|purge-expired)|backup)$/
|
|
202
|
+
const DB_WRITE_ROUTES = /^\/(delete|merge|write|import|card\/update|card\/revise|refine-apply|refine-goals-keep|refine-junk-purge|optimize-execute|feedback|skills\/sync|audit\/(approve|reject)|recycle\/(restore|purge|purge-expired)|backup)$/
|
|
203
203
|
|
|
204
204
|
export function createApi(deps = {}) {
|
|
205
205
|
const vaultDir = deps.vaultDir ?? (() => '')
|
|
@@ -606,6 +606,25 @@ export function createApi(deps = {}) {
|
|
|
606
606
|
json(res, 200, { ok: true, diagnostics: text, repo: 'EternalNight996/memory-eternal', version: (dshD && dshD.version) || envD.MEMORY_ETERNAL_VERSION || '' })
|
|
607
607
|
return
|
|
608
608
|
}
|
|
609
|
+
case '/graph/level': {
|
|
610
|
+
// 星球分层视图:一次只返回「一层」(根层 kind 折叠 / 卡片层 / 语义簇层)。
|
|
611
|
+
// 10k 规模下客户端绝不再拉全量图 —— 每层图元 ≤1000、payload ≤1MB(见 lib/vault.js 守卫)。
|
|
612
|
+
await ensureVault(vaultRoot)
|
|
613
|
+
const pathStr = query.get('path') || ''
|
|
614
|
+
const allRoots = query.get('all') === '1'
|
|
615
|
+
try {
|
|
616
|
+
const out = await graphLevel(vaultRoot, {
|
|
617
|
+
path: pathStr,
|
|
618
|
+
roots: allRoots ? vaultRoots() : null,
|
|
619
|
+
})
|
|
620
|
+
return json(res, 200, out)
|
|
621
|
+
} catch (e) {
|
|
622
|
+
// 分类失败:路径写错/语义簇过期是「客户端可纠正」的 400;其他未知异常一律 500,
|
|
623
|
+
// 内部细节只进服务端日志(不把 SQL/堆栈原文抛给浏览器)。
|
|
624
|
+
const { status, body } = levelErrorResponse(e, 'path=' + pathStr)
|
|
625
|
+
return json(res, status, body)
|
|
626
|
+
}
|
|
627
|
+
}
|
|
609
628
|
case '/graph': {
|
|
610
629
|
await ensureVault(vaultRoot)
|
|
611
630
|
const all = query.get('all') === '1'
|
|
@@ -753,9 +772,10 @@ export function createApi(deps = {}) {
|
|
|
753
772
|
const rel = query.get('path') || body?.path || ''
|
|
754
773
|
const kind = String(query.get('kind') || body?.kind || '').trim()
|
|
755
774
|
if (!rel) return json(res, 400, { ok: false, error: '缺少 path' })
|
|
756
|
-
await setCardStatus(vaultRoot, rel, 'approved')
|
|
757
|
-
|
|
758
|
-
|
|
775
|
+
const out = await setCardStatus(vaultRoot, rel, 'approved')
|
|
776
|
+
// 修改副本被批准时会覆盖原卡(out.applied),它本身已消失,不能再当独立卡设类型
|
|
777
|
+
if ((kind === 'core' || kind === 'knowledge') && !out.applied) await setCardKind(vaultRoot, rel, kind)
|
|
778
|
+
json(res, 200, { ok: true, path: out.path || rel, status: 'approved', kind: kind || undefined, applied: !!out.applied })
|
|
759
779
|
return
|
|
760
780
|
}
|
|
761
781
|
case '/audit/reject': {
|
|
@@ -770,6 +790,57 @@ export function createApi(deps = {}) {
|
|
|
770
790
|
json(res, 200, { ok: true, path: rel, status: 'rejected', reason })
|
|
771
791
|
return
|
|
772
792
|
}
|
|
793
|
+
case '/card/revise': {
|
|
794
|
+
// POST {path, title?, tags?, body?, reason?, source?}:agent 提交「审核制修改」——
|
|
795
|
+
// 生成一张待审副本(同 kind、指向原卡),**不动原卡**;只有 /audit/approve 才会覆盖。
|
|
796
|
+
// 这是给智能体用的唯一改卡入口:它不能直接改已入库内容,也不能自己批准。
|
|
797
|
+
if (req.method !== 'POST') return json(res, 405, { ok: false, error: '需要 POST' })
|
|
798
|
+
const body = await readBody(req)
|
|
799
|
+
const rel = String(query.get('path') || body.path || '').trim()
|
|
800
|
+
if (!rel) return json(res, 400, { ok: false, error: '缺少 path' })
|
|
801
|
+
const title = body.title === undefined ? undefined : String(body.title).trim()
|
|
802
|
+
if (title !== undefined && !title) return json(res, 400, { ok: false, error: '标题不能为空' })
|
|
803
|
+
const nextBody = body.body === undefined ? undefined : String(body.body)
|
|
804
|
+
if (nextBody !== undefined && !nextBody.trim()) return json(res, 400, { ok: false, error: '正文不能为空' })
|
|
805
|
+
if (nextBody !== undefined && nextBody.length > 200000) return json(res, 413, { ok: false, error: '正文过长(上限 200000 字符)' })
|
|
806
|
+
const nextTags = Array.isArray(body.tags) ? body.tags.map((x) => String(x || '').trim()).filter(Boolean).slice(0, 30) : undefined
|
|
807
|
+
try {
|
|
808
|
+
const out = await reviseCard(vaultRoot, {
|
|
809
|
+
target: rel, title, tags: nextTags, body: nextBody,
|
|
810
|
+
reason: String(body.reason || '').slice(0, 300),
|
|
811
|
+
source: String(body.source || 'agent'),
|
|
812
|
+
submittedBy: String(body.submittedBy || body.source || 'agent'),
|
|
813
|
+
})
|
|
814
|
+
broadcast('audit', { path: out.path })
|
|
815
|
+
json(res, 200, { ok: true, ...out })
|
|
816
|
+
} catch (e) {
|
|
817
|
+
json(res, 400, { ok: false, error: String(e?.message || e) })
|
|
818
|
+
}
|
|
819
|
+
return
|
|
820
|
+
}
|
|
821
|
+
case '/card/update': {
|
|
822
|
+
// POST {path, title, tags, body}:人工编辑卡片内容(标题 / 标签 / 正文)。
|
|
823
|
+
// 只允许改这三样 —— 状态、类型、来源、审核流转仍由 /audit/*、/card-kind 决定,
|
|
824
|
+
// 避免「手动编辑」成为绕过审核的旁路;本路由只在本机回环 + 同源闸门下可达(见 crossOriginError)。
|
|
825
|
+
if (req.method !== 'POST') return json(res, 405, { ok: false, error: '需要 POST' })
|
|
826
|
+
const body = await readBody(req)
|
|
827
|
+
const rel = String(query.get('path') || body.path || '').trim()
|
|
828
|
+
if (!rel) return json(res, 400, { ok: false, error: '缺少 path' })
|
|
829
|
+
const title = body.title === undefined ? undefined : String(body.title).trim()
|
|
830
|
+
if (title !== undefined && !title) return json(res, 400, { ok: false, error: '标题不能为空' })
|
|
831
|
+
const nextBody = body.body === undefined ? undefined : String(body.body)
|
|
832
|
+
if (nextBody !== undefined && !nextBody.trim()) return json(res, 400, { ok: false, error: '正文不能为空' })
|
|
833
|
+
if (nextBody !== undefined && nextBody.length > 200000) return json(res, 413, { ok: false, error: '正文过长(上限 200000 字符)' })
|
|
834
|
+
const nextTags = Array.isArray(body.tags) ? body.tags.map((x) => String(x || '').trim()).filter(Boolean).slice(0, 30) : undefined
|
|
835
|
+
try {
|
|
836
|
+
const out = await updateCardContent(vaultRoot, rel, { title, tags: nextTags, body: nextBody })
|
|
837
|
+
broadcast('cards', { path: rel, reason: 'edited' })
|
|
838
|
+
json(res, 200, { ok: true, path: out.path, store: out.store, updated: out.updated, title, tags: nextTags })
|
|
839
|
+
} catch (e) {
|
|
840
|
+
json(res, 400, { ok: false, error: String(e?.message || e) })
|
|
841
|
+
}
|
|
842
|
+
return
|
|
843
|
+
}
|
|
773
844
|
case '/card-kind': {
|
|
774
845
|
// POST {path, kind:'core'|'knowledge'|...}:人工把已批准卡片设为核心/取消核心
|
|
775
846
|
if (req.method !== 'POST') return json(res, 405, { ok: false, error: '需要 POST' })
|
|
@@ -1315,6 +1386,29 @@ export function createApi(deps = {}) {
|
|
|
1315
1386
|
}
|
|
1316
1387
|
}
|
|
1317
1388
|
|
|
1389
|
+
/**
|
|
1390
|
+
* 分层接口的错误 → HTTP 响应(纯函数,便于单测)。
|
|
1391
|
+
* LEVEL_PATH / LEVEL_STALE:客户端可以自己纠正(路径写错 / 卡片增删让簇 id 过期)→ 400 + 原文案;
|
|
1392
|
+
* 其他未知异常:500 + 通用文案,**内部细节只写服务端日志**,不回给浏览器。
|
|
1393
|
+
* 无论哪种,客户端都会显示「显式报错 + 保留当前层 + 重试」。
|
|
1394
|
+
*/
|
|
1395
|
+
export function levelErrorResponse(err, context = '') {
|
|
1396
|
+
const code = err && err.code
|
|
1397
|
+
if (code === LEVEL_ERR_PATH || code === LEVEL_ERR_STALE) {
|
|
1398
|
+
return {
|
|
1399
|
+
status: 400,
|
|
1400
|
+
body: { ok: false, code, error: String((err && err.message) || err) },
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
try {
|
|
1404
|
+
console.error('[memory-eternal] /graph/level 内部错误' + (context ? '(' + context + ')' : '') + ':', err)
|
|
1405
|
+
} catch { /* console 不可用时彻底静默(日志失败不能反过来影响响应) */ }
|
|
1406
|
+
return {
|
|
1407
|
+
status: 500,
|
|
1408
|
+
body: { ok: false, code: 'LEVEL_FAILED', error: '知识图谱层级暂时不可用,请稍后重试' },
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1318
1412
|
export function json(res, status, body) {
|
|
1319
1413
|
const payload = JSON.stringify(body)
|
|
1320
1414
|
const headers = {
|