memory-eternal 0.10.23 → 0.10.25
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.en.md +5 -3
- package/README.md +5 -3
- package/bin/dsh-memory.mjs +26 -1
- package/lib/api.js +85 -3
- package/lib/client.js +15 -15
- package/lib/refine.js +126 -0
- package/lib/watchdog.js +30 -1
- package/package.json +1 -1
- package/web/app.js +16 -16
package/lib/refine.js
CHANGED
|
@@ -130,6 +130,132 @@ export async function refineScan(root, { llm, limit = 12, provider, model, minSi
|
|
|
130
130
|
return { ok: true, candidates: cands.length, useModel: route.provider + '/' + route.model, findings, skipped }
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
// -- 「同目标、不同做法」聚类(issue #36 第三块)----------------------------------
|
|
134
|
+
//
|
|
135
|
+
// 为什么不能用正文相似度:同一件事的不同做法(例如「用 7z 压缩」「用 tar.gz 分发」「npm 包瘦身」)
|
|
136
|
+
// 字面差异大、相似度低,按正文召回根本抓不到;而它们**目标**是一致的。
|
|
137
|
+
// 所以先让模型给每张卡抽一句「这张卡解决什么问题」,再按**目标**聚类。抽取结果缓存在
|
|
138
|
+
// refine-goals.json(同一张卡只抽一次),避免每次扫描都烧一遍 token。
|
|
139
|
+
|
|
140
|
+
const GOALS_FILE = 'refine-goals.json'
|
|
141
|
+
|
|
142
|
+
/** 已抽取的「卡片目标」缓存:{ [path]: { goal, at } }。 */
|
|
143
|
+
export function readGoals(root) {
|
|
144
|
+
try { return JSON.parse(fs.readFileSync(path.join(root, GOALS_FILE), 'utf8')) || {} } catch { return {} }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function writeGoals(root, data) {
|
|
148
|
+
const file = path.join(root, GOALS_FILE)
|
|
149
|
+
const tmp = file + '.tmp'
|
|
150
|
+
fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf8')
|
|
151
|
+
fs.renameSync(tmp, file)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** 让模型用一句话说清「这张卡解决什么问题」。 */
|
|
155
|
+
export async function extractGoal(llm, route, card) {
|
|
156
|
+
const system = [
|
|
157
|
+
'你在给本地知识库的卡片做「目标索引」。只回答一个问题:**这张卡解决什么问题 / 给什么场景用**。',
|
|
158
|
+
'只输出严格 JSON(不要 markdown 代码块、不要多余文字):',
|
|
159
|
+
'{"goal":"一句话(不超过 40 字,动词开头,写清要解决的问题)","topic":"一个主题词"}',
|
|
160
|
+
'不要复述卡里的做法与细节,只写目标。同一件事的不同做法,goal 应当几乎一样。',
|
|
161
|
+
].join('\n')
|
|
162
|
+
const user = '标题:' + card.title + '\n\n正文:\n' + String(card.body || '').slice(0, 1800)
|
|
163
|
+
const res = await callLlmJson(llm, route, { system, user, maxTokens: 300, purpose: 'memory-refine-goal' })
|
|
164
|
+
if (res.failure) return { failure: res.failure }
|
|
165
|
+
let s = String(res.text || '').trim().replace(/^\x60\x60\x60(?:json)?/i, '').replace(/\x60\x60\x60$/, '').trim()
|
|
166
|
+
const a = s.indexOf('{'); const b = s.lastIndexOf('}')
|
|
167
|
+
if (a >= 0 && b > a) s = s.slice(a, b + 1)
|
|
168
|
+
try {
|
|
169
|
+
const obj = JSON.parse(s)
|
|
170
|
+
const goal = String(obj.goal || '').trim().slice(0, 120)
|
|
171
|
+
if (!goal) return { failure: { code: 'UNPARSEABLE_OUTPUT', message: '模型没给出 goal' } }
|
|
172
|
+
return { goal, topic: String(obj.topic || '').trim().slice(0, 40) }
|
|
173
|
+
} catch { return { failure: { code: 'UNPARSEABLE_OUTPUT', message: '目标抽取输出不是合法 JSON:' + String(res.text || '').slice(0, 120) } } }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* 扫描「同目标、不同做法」的卡片群:抽目标(带缓存)→ 按目标相似度聚类 → 过滤已裁决的对。
|
|
178
|
+
* **只读**:不写卡片,只更新目标缓存(refine-goals.json)。
|
|
179
|
+
*/
|
|
180
|
+
export async function goalScan(root, { llm, limit = 30, threshold = 0.55, maxCards = 400, provider, model } = {}) {
|
|
181
|
+
if (!llm) return { ok: false, error: '当前进程拿不到 llm 服务(请在 DSH 面板里跑,或由宿主触发)' }
|
|
182
|
+
const rows = rowsOf(root).slice(0, Math.max(2, Number(maxCards) || 400))
|
|
183
|
+
let routes = []
|
|
184
|
+
try { routes = await routeCandidates(llm, { provider, model }) } catch { routes = [] }
|
|
185
|
+
if (!routes.length) return { ok: false, error: '取不到模型路由(llm.listProviders 为空)' }
|
|
186
|
+
const route = routes[0]
|
|
187
|
+
const cache = readGoals(root)
|
|
188
|
+
const cards = rows.map((r) => ({ path: r.path, title: r.title, kind: r.kind, body: String(r.body || '') }))
|
|
189
|
+
const pending = cards.filter((c) => !(cache[c.path] && cache[c.path].goal))
|
|
190
|
+
let calls = 0
|
|
191
|
+
const skipped = []
|
|
192
|
+
for (const c of pending.slice(0, Math.max(1, Number(limit) || 30))) {
|
|
193
|
+
let r
|
|
194
|
+
try { r = await extractGoal(llm, route, c) } catch (e) { r = { failure: { code: 'EXCEPTION', message: String((e && e.message) || e) } } }
|
|
195
|
+
calls += 1
|
|
196
|
+
if (r.failure) { skipped.push({ path: c.path, error: r.failure.code + ': ' + r.failure.message }); continue }
|
|
197
|
+
cache[c.path] = { goal: r.goal, topic: r.topic || '', at: Date.now() }
|
|
198
|
+
}
|
|
199
|
+
try { writeGoals(root, cache) } catch (e) { skipped.push({ path: '(cache)', error: '目标缓存写入失败:' + String((e && e.message) || e) }) }
|
|
200
|
+
|
|
201
|
+
// 并查集聚类:目标相似度 >= threshold 视为同一目标
|
|
202
|
+
const withGoal = cards.filter((c) => cache[c.path] && cache[c.path].goal)
|
|
203
|
+
const parent = new Map(withGoal.map((c) => [c.path, c.path]))
|
|
204
|
+
const find = (x) => { while (parent.get(x) !== x) { parent.set(x, parent.get(parent.get(x))); x = parent.get(x) } return x }
|
|
205
|
+
const union = (x, y) => { const a = find(x), b = find(y); if (a !== b) parent.set(a, b) }
|
|
206
|
+
const sim = (x, y) => textSimilarity(String(cache[x.path].goal), String(cache[y.path].goal))
|
|
207
|
+
for (let i = 0; i < withGoal.length; i++) {
|
|
208
|
+
for (let j = i + 1; j < withGoal.length; j++) {
|
|
209
|
+
if (sim(withGoal[i], withGoal[j]) >= Number(threshold) || cache[withGoal[i].path].goal === cache[withGoal[j].path].goal) union(withGoal[i].path, withGoal[j].path)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
const groups = new Map()
|
|
213
|
+
for (const c of withGoal) {
|
|
214
|
+
const k = find(c.path)
|
|
215
|
+
if (!groups.has(k)) groups.set(k, [])
|
|
216
|
+
groups.get(k).push(c)
|
|
217
|
+
}
|
|
218
|
+
const decided = readDecisions(root)
|
|
219
|
+
const clusters = []
|
|
220
|
+
for (const members of groups.values()) {
|
|
221
|
+
if (members.length < 2) continue
|
|
222
|
+
// 组内任意一对已被裁决(都保留 / 保留其一)→ 整组不再提示:人已经看过了
|
|
223
|
+
const already = members.some((a, i) => members.slice(i + 1).some((b) => decided[pairKey(a.path, b.path)]))
|
|
224
|
+
clusters.push({
|
|
225
|
+
goal: cache[members[0].path].goal,
|
|
226
|
+
members: members.map((m) => ({ path: m.path, title: m.title, kind: m.kind, goal: cache[m.path].goal })),
|
|
227
|
+
decided: already,
|
|
228
|
+
})
|
|
229
|
+
}
|
|
230
|
+
clusters.sort((a, b) => b.members.length - a.members.length)
|
|
231
|
+
return {
|
|
232
|
+
ok: true,
|
|
233
|
+
useModel: route.provider + '/' + route.model,
|
|
234
|
+
cached: withGoal.length,
|
|
235
|
+
calls,
|
|
236
|
+
candidates: pending.length,
|
|
237
|
+
clusters: clusters.filter((c) => !c.decided),
|
|
238
|
+
decidedClusters: clusters.filter((c) => c.decided).length,
|
|
239
|
+
skipped,
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** 把一组「同目标」的卡标成「都保留」(记两两决策,避免下次再提示)。 */
|
|
244
|
+
export async function markClusterKept(root, { paths = [], note = '同目标不同做法,都保留' } = {}) {
|
|
245
|
+
const list = (Array.isArray(paths) ? paths : []).map((x) => String(x || '').trim()).filter(Boolean)
|
|
246
|
+
if (list.length < 2) return { ok: false, error: '至少给两张卡的 path' }
|
|
247
|
+
const data = readDecisions(root)
|
|
248
|
+
let n = 0
|
|
249
|
+
for (let i = 0; i < list.length; i++) {
|
|
250
|
+
for (let j = i + 1; j < list.length; j++) {
|
|
251
|
+
data[pairKey(list[i], list[j])] = { action: 'keepBoth', relation: 'complement', note: String(note).slice(0, 500), at: Date.now() }
|
|
252
|
+
n += 1
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
try { writeDecisions(root, data) } catch (e) { return { ok: false, error: '决策写入失败:' + String((e && e.message) || e) } }
|
|
256
|
+
return { ok: true, recorded: n }
|
|
257
|
+
}
|
|
258
|
+
|
|
133
259
|
/** 技能目录条目行的形态(与 capture.js 的噪声闸门同一口径)。 */
|
|
134
260
|
const CATALOG_LINE_RE = /^\s*[-*]\s*\x60[a-z0-9][a-z0-9-]{2,40}\x60:\s/
|
|
135
261
|
|
package/lib/watchdog.js
CHANGED
|
@@ -67,12 +67,41 @@ export function readWatchdogLock(env = process.env) {
|
|
|
67
67
|
return { watchdogs: [] }
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
let lockWarned = false
|
|
71
|
+
|
|
70
72
|
function writeWatchdogLock(env, list) {
|
|
71
73
|
const file = watchdogLockPath(env)
|
|
72
74
|
try {
|
|
73
75
|
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
74
76
|
fs.writeFileSync(file, JSON.stringify({ watchdogs: list }))
|
|
75
|
-
} catch {
|
|
77
|
+
} catch (e) {
|
|
78
|
+
// 写不了锁**不阻塞保活**,但不能再全静默:这是「托管状态判不出来」的根因(issue #33 第③层,
|
|
79
|
+
// 实测受限环境写不进锁 → 面板显示「未托管」而端口上其实有健康实例)。留一条可查的痕。
|
|
80
|
+
if (!lockWarned) {
|
|
81
|
+
lockWarned = true
|
|
82
|
+
console.error('[memory-eternal] 看门狗锁文件写不进去(' + file + '):' + String((e && e.message) || e) + ' —— 保活不受影响,但「是否被托管」将无法判断')
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 探测「锁文件所在目录现在写不写得了」—— **只探测,不碰锁文件本身**(避免把真实状态写坏)。
|
|
89
|
+
*
|
|
90
|
+
* 用途(issue #33 第③层):面板要区分「**确实没被托管**」与「**判不出来**(受限环境写不进锁)」
|
|
91
|
+
* —— 后者不该被显示成「未托管 / 未运行」,那会让用户以为实例坏了,跑去反复重启。
|
|
92
|
+
*/
|
|
93
|
+
export function probeLockWritable(env = process.env) {
|
|
94
|
+
const file = watchdogLockPath(env)
|
|
95
|
+
const probe = path.join(path.dirname(file), '.me-lock-probe-' + process.pid + '-' + Date.now())
|
|
96
|
+
try {
|
|
97
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
98
|
+
fs.writeFileSync(probe, 'ok')
|
|
99
|
+
fs.unlinkSync(probe)
|
|
100
|
+
return { writable: true, path: file, error: '' }
|
|
101
|
+
} catch (e) {
|
|
102
|
+
try { fs.unlinkSync(probe) } catch { /* 清不掉也无妨 */ }
|
|
103
|
+
return { writable: false, path: file, error: String((e && e.message) || e) }
|
|
104
|
+
}
|
|
76
105
|
}
|
|
77
106
|
|
|
78
107
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "memory-eternal",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.25",
|
|
4
4
|
"description": "记忆核心(Memory Eternal):自研的 DeepSeek Harness 记忆插件,不移植任何既有记忆框架——对话结束后自动沉淀知识卡到本地 Markdown Vault(自研去重、自研 CJK 检索、可 git 管理),设置页提供图形化知识库(统计 / 搜索 / 知识图谱 + 侧边栏一键弹窗),Agent 通过 memory_recall 工具按需召回历史上下文。零人工干预。",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|