memory-eternal 0.10.23 → 0.10.24

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/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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memory-eternal",
3
- "version": "0.10.23",
3
+ "version": "0.10.24",
4
4
  "description": "记忆核心(Memory Eternal):自研的 DeepSeek Harness 记忆插件,不移植任何既有记忆框架——对话结束后自动沉淀知识卡到本地 Markdown Vault(自研去重、自研 CJK 检索、可 git 管理),设置页提供图形化知识库(统计 / 搜索 / 知识图谱 + 侧边栏一键弹窗),Agent 通过 memory_recall 工具按需召回历史上下文。零人工干预。",
5
5
  "license": "MIT",
6
6
  "repository": {