memory-eternal 0.21.0 → 0.23.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 +30 -1
- package/README.zh-CN.md +30 -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/index.js +78 -0
- package/lib/align.js +16 -2
- package/lib/api.js +211 -20
- package/lib/client.js +93 -23
- package/lib/db.js +12 -0
- package/lib/mcp.js +41 -2
- package/lib/news-digest.js +109 -0
- package/lib/news-follows.js +523 -0
- package/lib/node-bin.js +17 -12
- package/lib/vault.js +159 -7
- package/orca-plugin/orca-plugin.json +1 -1
- package/package.json +2 -2
- package/web/app.js +98 -28
package/lib/db.js
CHANGED
|
@@ -213,6 +213,18 @@ function initTables(db) {
|
|
|
213
213
|
|
|
214
214
|
CREATE INDEX IF NOT EXISTS idx_card_updates_card ON card_updates(card_id);
|
|
215
215
|
|
|
216
|
+
-- 审核制修改:待审副本 → 原卡。agent 只能提交副本,通过后由 setCardStatus 覆盖原卡。
|
|
217
|
+
-- 单独建表(而不是给两张卡表加列):旧库打开时 CREATE TABLE IF NOT EXISTS 即可补上,无需迁移。
|
|
218
|
+
CREATE TABLE IF NOT EXISTS card_revisions (
|
|
219
|
+
revision_path TEXT PRIMARY KEY,
|
|
220
|
+
target_path TEXT NOT NULL,
|
|
221
|
+
target_id INTEGER,
|
|
222
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
223
|
+
updated_at TEXT DEFAULT (datetime('now'))
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
CREATE INDEX IF NOT EXISTS idx_card_revisions_target ON card_revisions(target_path);
|
|
227
|
+
|
|
216
228
|
CREATE TABLE IF NOT EXISTS feedback (
|
|
217
229
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
218
230
|
query TEXT NOT NULL,
|
package/lib/mcp.js
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
// (stdout 是协议通道,被 console.log 污染会破坏握手)。
|
|
6
6
|
//
|
|
7
7
|
// 支持的协议版本:2025-06-18 / 2025-03-26 / 2024-11-05(协商取请求版本)。
|
|
8
|
-
// 工具:memory_recall / memory_capture / memory_stats / memory_audit_list。
|
|
8
|
+
// 工具:memory_recall / memory_capture / memory_stats / memory_audit_list / memory_revise。
|
|
9
|
+
// memory_revise 只**提交**修改副本进审核(不会直接改库、不能批准),与 issue #17 的审核边界一致。
|
|
9
10
|
//
|
|
10
11
|
// 审核边界(issue #17):MCP 侧**只读**。新增的 memory_audit_list 只列待审/驳回,
|
|
11
12
|
// 不提供任何 approve/reject —— 否则任何接了 MCP 的智能体都能批准自己刚写进去的卡,
|
|
@@ -14,7 +15,7 @@
|
|
|
14
15
|
// 启动:node bin/dsh-memory.mjs mcp (或 node lib/mcp.js)
|
|
15
16
|
|
|
16
17
|
import readline from 'node:readline'
|
|
17
|
-
import { search, overview, stats, ensureVault, listCards, countCards } from './vault.js'
|
|
18
|
+
import { search, overview, stats, ensureVault, listCards, countCards, reviseCard } from './vault.js'
|
|
18
19
|
import { runStandaloneCapture, defaultVaultDir } from './capture-run.js'
|
|
19
20
|
import { recordLedger } from './ledger.js'
|
|
20
21
|
|
|
@@ -72,6 +73,25 @@ export const MCP_TOOLS = [
|
|
|
72
73
|
},
|
|
73
74
|
},
|
|
74
75
|
},
|
|
76
|
+
{
|
|
77
|
+
name: 'memory_revise',
|
|
78
|
+
description:
|
|
79
|
+
'提交「审核制修改」:把一张已批准的知识卡复制成待审副本(可改标题/标签/正文),进入审核中心。' +
|
|
80
|
+
'人工批准后才会覆盖原卡;agent 不能直接改已入库内容,也不能自己批准。' +
|
|
81
|
+
'用于修正过期/错误的知识 —— 比再写一张新卡更干净(审核界面会标成「修改」,通过即覆盖)。',
|
|
82
|
+
inputSchema: {
|
|
83
|
+
type: 'object',
|
|
84
|
+
properties: {
|
|
85
|
+
path: { type: 'string', description: '要修改的原卡 path(memory_recall 结果里的「路径:…」)' },
|
|
86
|
+
title: { type: 'string', description: '新标题(不改就不传)' },
|
|
87
|
+
tags: { type: 'array', items: { type: 'string' }, description: '新标签(不改就不传)' },
|
|
88
|
+
body: { type: 'string', description: '新正文(不改就不传)' },
|
|
89
|
+
reason: { type: 'string', description: '修改原因,会显示在审核中心' },
|
|
90
|
+
source: { type: 'string', description: '来源标记,如 claude-code / codex' },
|
|
91
|
+
},
|
|
92
|
+
required: ['path'],
|
|
93
|
+
},
|
|
94
|
+
},
|
|
75
95
|
]
|
|
76
96
|
|
|
77
97
|
function toolResult(text, isError = false) {
|
|
@@ -108,6 +128,25 @@ export async function callTool(name, args, vaultRoot, meta = {}) {
|
|
|
108
128
|
if (!out.ok && out.action === 'failed') return toolResult(`沉淀失败:${out.reason}`, true)
|
|
109
129
|
return toolResult(JSON.stringify(out))
|
|
110
130
|
}
|
|
131
|
+
case 'memory_revise': {
|
|
132
|
+
const rel = String(args?.path || '').trim()
|
|
133
|
+
if (!rel) return toolResult('(未提供要修改的卡片 path)')
|
|
134
|
+
try {
|
|
135
|
+
const src = String(args?.source || meta.submittedBy || 'mcp')
|
|
136
|
+
const out = await reviseCard(vaultRoot, {
|
|
137
|
+
target: rel,
|
|
138
|
+
title: args?.title === undefined ? undefined : String(args.title),
|
|
139
|
+
tags: Array.isArray(args?.tags) ? args.tags : undefined,
|
|
140
|
+
body: args?.body === undefined ? undefined : String(args.body),
|
|
141
|
+
reason: String(args?.reason || ''),
|
|
142
|
+
source: src,
|
|
143
|
+
submittedBy: src,
|
|
144
|
+
})
|
|
145
|
+
return toolResult('已提交修改,待人工审核通过后覆盖原卡。\n修改副本:' + out.path + '\n原卡:' + out.target + (out.reused ? '\n(已存在待审修改,本次就地更新,未新增条目)' : ''))
|
|
146
|
+
} catch (e) {
|
|
147
|
+
return toolResult('提交修改失败:' + ((e && e.message) || e), true)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
111
150
|
case 'memory_audit_list': {
|
|
112
151
|
await ensureVault(vaultRoot)
|
|
113
152
|
const wanted = String(args?.status || 'pending').toLowerCase()
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// 记忆核心 · 关注摘要(News Digest)
|
|
2
|
+
//
|
|
3
|
+
// 定位:「关注」负责持续抓取,摘要负责**把结果压成一句话送给 Agent**。
|
|
4
|
+
// 送达通道复用插件的 systemPrompt 分段(与核心记忆同一机制),因此不需要新的宿主能力。
|
|
5
|
+
//
|
|
6
|
+
// 三条纪律:
|
|
7
|
+
// 1. **默认关闭**:这是会打扰人的功能,必须由用户显式开启(newsDigestEnabled)。
|
|
8
|
+
// 2. 只送「新东西」:generatedAt > consumedAt 才注入;一轮会话消费后立刻静默,不刷屏。
|
|
9
|
+
// 3. 本地可预览:配置页有「立即生成」,用户能在开启前先看到会推送什么。
|
|
10
|
+
import { promises as fs } from 'node:fs'
|
|
11
|
+
import path from 'node:path'
|
|
12
|
+
import { refreshFollows, followItems } from './news-follows.js'
|
|
13
|
+
|
|
14
|
+
const DIGEST_FILE = 'digest.json'
|
|
15
|
+
|
|
16
|
+
function digestPath(root) { return path.join(String(root || ''), 'news', DIGEST_FILE) }
|
|
17
|
+
|
|
18
|
+
export async function readDigest(root) {
|
|
19
|
+
try { return JSON.parse(await fs.readFile(digestPath(root), 'utf8')) } catch { return null }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function writeDigest(root, digest) {
|
|
23
|
+
const file = digestPath(root)
|
|
24
|
+
await fs.mkdir(path.dirname(file), { recursive: true })
|
|
25
|
+
const tmp = file + '.tmp'
|
|
26
|
+
await fs.writeFile(tmp, JSON.stringify(digest, null, 2), 'utf8')
|
|
27
|
+
await fs.rename(tmp, file)
|
|
28
|
+
return digest
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 从关注结果里挑出摘要条目(纯函数,便于单测)。
|
|
33
|
+
* 排序:热度优先(GitHub star / HN points),其次时间。
|
|
34
|
+
*/
|
|
35
|
+
export function pickDigestItems(items, { sinceMs = 0, maxItems = 5, seenUrls = [] } = {}) {
|
|
36
|
+
const seen = new Set(Array.isArray(seenUrls) ? seenUrls : [])
|
|
37
|
+
return (Array.isArray(items) ? items : [])
|
|
38
|
+
.filter((it) => it && it.url && !seen.has(it.url) && (!sinceMs || (Number(it.publishedAt) || 0) >= sinceMs))
|
|
39
|
+
.slice()
|
|
40
|
+
.sort((a, b) => (Number(b.points) || 0) - (Number(a.points) || 0) || (Number(b.publishedAt) || 0) - (Number(a.publishedAt) || 0))
|
|
41
|
+
.filter((it) => { if (seen.has(it.url)) return false; seen.add(it.url); return true })
|
|
42
|
+
.slice(0, Math.max(1, Number(maxItems) || 5))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 生成一份摘要快照并落盘。
|
|
47
|
+
* @param {string} root vault
|
|
48
|
+
* @param {{windowHours?:number,maxItems?:number,force?:boolean,now?:number}} opts
|
|
49
|
+
*/
|
|
50
|
+
export async function buildDigest(root, { windowHours = 24, maxItems = 5, force = false, now = Date.now() } = {}) {
|
|
51
|
+
const follows = await refreshFollows(root, { force, now })
|
|
52
|
+
const all = await followItems(root, follows)
|
|
53
|
+
// 「新」的判定 = **没推送过**,而不是「刚发布」。
|
|
54
|
+
// 为什么:GitHub 仓库的时间戳是创建时间,按 12/24 小时窗口筛会把它们全筛掉,
|
|
55
|
+
// 于是「GitHub 热门」的摘要永远是空的。用 sentUrls 记账才符合"关注"的语义。
|
|
56
|
+
const prev = await readDigest(root)
|
|
57
|
+
const prevSent = Array.isArray(prev && prev.sentUrls) ? prev.sentUrls : []
|
|
58
|
+
const picked = pickDigestItems(all, { maxItems, seenUrls: prevSent })
|
|
59
|
+
// 关键:把**这一次扫到的全部 URL**记为已推送,而不是只记被挑中的那几条。
|
|
60
|
+
// 否则每次摘要都会从积压里再掏 5 条 —— 那不叫"新内容",叫"清库存",用户会被反复骚扰。
|
|
61
|
+
const sentUrls = [...new Set([...prevSent, ...all.map((it) => it.url)])].slice(-2000)
|
|
62
|
+
const digest = {
|
|
63
|
+
ok: true,
|
|
64
|
+
generatedAt: now,
|
|
65
|
+
consumedAt: 0,
|
|
66
|
+
windowHours,
|
|
67
|
+
maxItems,
|
|
68
|
+
followCount: follows.length,
|
|
69
|
+
scanned: all.length,
|
|
70
|
+
sentUrls,
|
|
71
|
+
items: picked.map((it) => ({
|
|
72
|
+
title: it.title, url: it.url, source: it.source, followLabel: it.followLabel || '',
|
|
73
|
+
points: Number(it.points) || 0, publishedAt: Number(it.publishedAt) || 0,
|
|
74
|
+
})),
|
|
75
|
+
}
|
|
76
|
+
await writeDigest(root, digest)
|
|
77
|
+
return digest
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 标记「这一份已经进过一轮会话」,避免同一个摘要反复注入。 */
|
|
81
|
+
export async function consumeDigest(root, generatedAt) {
|
|
82
|
+
const d = await readDigest(root)
|
|
83
|
+
if (!d || Number(d.generatedAt) !== Number(generatedAt)) return null
|
|
84
|
+
d.consumedAt = d.generatedAt
|
|
85
|
+
await writeDigest(root, d)
|
|
86
|
+
return d
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 生成注入文本。返回空串 = 本轮不注入。
|
|
91
|
+
* 刻意要求 Agent「一两句话带过」:摘要是提醒,不是让模型把标题念一遍。
|
|
92
|
+
*/
|
|
93
|
+
export function digestPrompt(digest, { maxItems = 5 } = {}) {
|
|
94
|
+
const items = Array.isArray(digest && digest.items) ? digest.items : []
|
|
95
|
+
if (!items.length) return ''
|
|
96
|
+
const top = items.slice(0, Math.max(1, Number(maxItems) || 5))
|
|
97
|
+
const lines = top.map((it, i) => {
|
|
98
|
+
const src = it.followLabel || it.source || '关注'
|
|
99
|
+
const heat = it.points ? ' · ' + it.points : ''
|
|
100
|
+
return (i + 1) + '. ' + it.title + '(' + src + heat + ')'
|
|
101
|
+
})
|
|
102
|
+
return [
|
|
103
|
+
'【关注动态(记忆核心 · 新闻关注)】',
|
|
104
|
+
'后台按你开启的「关注摘要」抓到了 ' + items.length + ' 条新内容:',
|
|
105
|
+
...lines,
|
|
106
|
+
'请在本次回复开头用一两句话告诉用户「有哪些关注更新」,不要整段罗列标题;',
|
|
107
|
+
'用户要看全部时再展开(新闻页有「关注动态」专区)。',
|
|
108
|
+
].join('\n')
|
|
109
|
+
}
|