dsh-memory-eternal 0.1.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/LICENSE +21 -0
- package/PUBLISH.md +100 -0
- package/README.md +148 -0
- package/assets/preview.html +139 -0
- package/assets/screen/memory-preview.html +139 -0
- package/assets/screen/memory-preview.png +0 -0
- package/cordis.patch.yml +15 -0
- package/docs/ARCHITECTURE.md +87 -0
- package/index.js +283 -0
- package/lib/capture.js +221 -0
- package/lib/client.js +47 -0
- package/lib/vault.js +386 -0
- package/package.json +82 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# 架构说明(Architecture)
|
|
2
|
+
|
|
3
|
+
记忆核心(dsh-memory-eternal)是一个独立 DSH 插件,采用「host 半边负责数据与逻辑、client 半边负责图形化界面」的双进程结构,与 [dsh-ui-three-body](https://github.com/EternalNight996/dsh-ui-three-body) 同一套插件装载机制。
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
┌───────────────────────── DSH Web (browser) ─────────────────────────┐
|
|
7
|
+
│ src/client/index.tsx → lib/client.js (esbuild 打包) │
|
|
8
|
+
│ · 设置 → 记忆 页面(slots settings.section 挂载) │
|
|
9
|
+
│ · 统计 / 检索 / 知识卡网格 / 知识图谱(fetch /memory-eternal/api/*) │
|
|
10
|
+
└───────────────┬─────────────────────────────────────▲───────────────┘
|
|
11
|
+
│ fetch(同源 webServer 路由) │ JSON
|
|
12
|
+
┌───────────────▼─────────────────────────────────────┴───────────────┐
|
|
13
|
+
│ index.js(host 半边,DSH Node 进程) │
|
|
14
|
+
│ · settings 命名空间 memory-eternal │
|
|
15
|
+
│ · agent/turn-stopping 钩子 → 自动沉淀管线 │
|
|
16
|
+
│ · systemPrompt 召回段 + memory_recall 工具 │
|
|
17
|
+
│ · webServer /memory-eternal/api/* JSON 路由 │
|
|
18
|
+
│ ├── lib/vault.js Markdown Vault 存储层(纯 Node,可单测) │
|
|
19
|
+
│ └── lib/capture.js 自动沉淀管线(LLM 压缩 + 语义去重) │
|
|
20
|
+
└───────────────────────────┬─────────────────────────────────────────┘
|
|
21
|
+
▼
|
|
22
|
+
~/.dsh/memory-vault/(本地 Markdown 知识库)
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## 数据流
|
|
26
|
+
|
|
27
|
+
### 自动沉淀(写入路径)
|
|
28
|
+
|
|
29
|
+
1. `agent/turn-stopping`(serial 事件)触发:同步抓取 `agent.session.events` 增量快照(按 seq 过滤,`sliceNewEvents`),不阻塞收尾。
|
|
30
|
+
2. 后台队列执行 `runCapture`:
|
|
31
|
+
- `extractLastTurn` 提取 user/assistant 文本(工具结果不进入);
|
|
32
|
+
- 短于 `captureMinChars` 直接跳过;日配额 `maxCardsPerDay` 防烧 token;
|
|
33
|
+
- `resolveRoute` 取当前 provider 旗舰模型(与驯兽师一致);
|
|
34
|
+
- `pickNeighbors` 按关键词重叠选出最相关已有卡(≤8 张,控制 token);
|
|
35
|
+
- `summarizeTurn` 把对话 + 已有卡索引交给模型,输出严格 JSON:
|
|
36
|
+
- `{"save":false}` → 丢弃;
|
|
37
|
+
- `{"append_to","update"}` → 追加更新记录到已有卡(语义去重,免疫 LLM 改写);
|
|
38
|
+
- `{"save":true,title,kind,tags,body}` → 写新卡。
|
|
39
|
+
3. `writeCard` 写入前再做**词法去重**(Jaccard 字符 bigram,阈值 0.62,移植 boujoy):命中则拒绝新建、改为 `appendUpdate` 追加「更新记录」段。
|
|
40
|
+
|
|
41
|
+
### 自动召回(读取路径)
|
|
42
|
+
|
|
43
|
+
- `systemPrompt.section`:注入「你拥有记忆核心 + 规则」段(可配置关闭);
|
|
44
|
+
- `memory_recall` 工具:CJK 感知检索(整词 + 中文字符 bigram),返回命中卡摘要。
|
|
45
|
+
|
|
46
|
+
### 图形化知识库(client 路径)
|
|
47
|
+
|
|
48
|
+
- `settings.section` id=`memory-eternal`:统计概览、搜索框(280ms 防抖)、kind 筛选、知识卡网格、SVG 知识图谱;
|
|
49
|
+
- 数据源:host `webServer.register({kind:'prefix', path:'/memory-eternal/api'})`,端点:
|
|
50
|
+
- `GET /overview` — 统计(总数/分 kind/近 7 天/标签数/vault 路径)
|
|
51
|
+
- `GET /cards?kind=&q=&limit=` — 卡片列表
|
|
52
|
+
- `GET /card?path=` — 卡片全文(阅读弹层)
|
|
53
|
+
- `GET /search?q=` — 检索命中
|
|
54
|
+
- `GET /graph` — 图谱节点与边(wikilink + 共享标签)
|
|
55
|
+
|
|
56
|
+
## 关键设计决策
|
|
57
|
+
|
|
58
|
+
| 决策 | 理由 |
|
|
59
|
+
| --- | --- |
|
|
60
|
+
| Vault = 普通 Markdown | 用户拥有数据;可手动编辑、可 git、可被 Obsidian 等工具读取;不锁进私有数据库 |
|
|
61
|
+
| 模型决定「建/并/弃」 | 对齐 boujoy「模型判定价值与压缩」哲学;语义去重比纯词法更抗 LLM 改写 |
|
|
62
|
+
| 词法去重兜底 | 低成本防重复卡堆积;阈值与 boujoy 一致(0.62) |
|
|
63
|
+
| 增量 seq 捕获 | 多轮会话不重复处理历史;避免把整段历史重复喂给模型 |
|
|
64
|
+
| 后台队列 + 日配额 | 不阻塞 turn 收尾;防止大扫荡烧光 token |
|
|
65
|
+
| 同源 fetch API | 不引入 typert/Remote 复杂度;client 只需 `fetch('/memory-eternal/api/…')` |
|
|
66
|
+
|
|
67
|
+
## 目录结构
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
dsh-memory-eternal/
|
|
71
|
+
├── index.js # host 半边
|
|
72
|
+
├── lib/
|
|
73
|
+
│ ├── vault.js # Vault 存储层(parseCard/safeSlug/textSimilarity/dedupCheck/queryTerms/listCards/search/graph/overview/writeCard/appendUpdate/ensureVault/readCard)
|
|
74
|
+
│ └── capture.js # 沉淀管线(summarizeTurn/parseCaptureJson/extractLastTurn/sliceNewEvents/pickNeighbors/captureCard/captureUpdate/makeDedupChecker)
|
|
75
|
+
├── src/client/index.tsx # client 半边(设置页 UI)
|
|
76
|
+
├── build.mjs # esbuild → lib/client.js
|
|
77
|
+
├── cordis.patch.yml # bundle 补丁层
|
|
78
|
+
├── tests/ # node:test 单测(vault 12 + capture 12 + api 5 = 29)
|
|
79
|
+
├── README.md / PUBLISH.md / LICENSE
|
|
80
|
+
└── assets/ # README 截图(memory-preview.png)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## 依赖
|
|
84
|
+
|
|
85
|
+
- 运行时(host):`@deepseek-ai/schemastery`(配置 schema)、`@deepseek-ai/dsh-tools`(工具)、`@deepseek-ai/dsh-llm`(流式模型调用)——均为 peerDependency,由 DSH 提供。
|
|
86
|
+
- 构建(dev):esbuild。
|
|
87
|
+
- client:仅 React(DSH `__ModuleLoader__` 注入),零第三方 UI 依赖。
|
package/index.js
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
// 记忆核心(host 侧):自动沉淀 + 自动召回 + 知识库 JSON API。
|
|
2
|
+
//
|
|
3
|
+
// 职责:
|
|
4
|
+
// 1. 注册 `memory-eternal` 设置命名空间(enabled / autoCapture / autoRecall /
|
|
5
|
+
// vaultDir / dedupThreshold / captureMinChars / captureCooldownMs)。
|
|
6
|
+
// 2. 监听 `agent/turn-stopping`:每轮对话结束自动把「值得长期复用的内容」
|
|
7
|
+
// 压缩成知识卡写入本地 Markdown Vault(去重守卫:相似卡拒绝新建、改为
|
|
8
|
+
// 追加更新记录)。零人工干预。
|
|
9
|
+
// 3. 注入 systemPrompt 分段:告知 Agent 它有一块记忆核心、可随时
|
|
10
|
+
// memory_recall 召回历史上下文;并注册 `memory_recall` 工具。
|
|
11
|
+
// 4. 注册 `/memory-eternal/api/*` JSON 路由:供客户端设置页渲染统计 / 卡片 /
|
|
12
|
+
// 知识图谱 / 检索。
|
|
13
|
+
//
|
|
14
|
+
// 存储全部落在本地 Markdown Vault(默认 $DSH_HOME/memory-vault),不依赖
|
|
15
|
+
// 外部数据库;卡是普通 .md 文件,可手动编辑、可 git 管理。
|
|
16
|
+
|
|
17
|
+
import { promises as fs } from 'node:fs'
|
|
18
|
+
import path from 'node:path'
|
|
19
|
+
import os from 'node:os'
|
|
20
|
+
import z from '@deepseek-ai/schemastery'
|
|
21
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
22
|
+
import { ensureVault, listCards, readCard, search, graph, overview } from './lib/vault.js'
|
|
23
|
+
import { summarizeTurn, extractLastTurn, sliceNewEvents, resolveRoute, captureCard, captureUpdate, pickNeighbors } from './lib/capture.js'
|
|
24
|
+
|
|
25
|
+
export const name = 'memory-eternal'
|
|
26
|
+
export const inject = ['systemPrompt', 'settings']
|
|
27
|
+
|
|
28
|
+
export const Config = z.object({
|
|
29
|
+
enabled: z.boolean().default(true),
|
|
30
|
+
autoCapture: z.boolean().default(true),
|
|
31
|
+
autoRecall: z.boolean().default(true),
|
|
32
|
+
vaultDir: z.string().default(''),
|
|
33
|
+
dedupThreshold: z.number().min(0).max(1).default(0.62),
|
|
34
|
+
captureMinChars: z.number().default(200),
|
|
35
|
+
captureCooldownMs: z.number().default(5 * 60 * 1000),
|
|
36
|
+
maxCardsPerDay: z.number().default(60),
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
const API_PREFIX = '/memory-eternal/api'
|
|
40
|
+
|
|
41
|
+
export function apply(ctx, config) {
|
|
42
|
+
const settings = ctx.settings.register('memory-eternal', Config, { base: config ?? {} })
|
|
43
|
+
|
|
44
|
+
const vaultDir = () => {
|
|
45
|
+
const cfg = settings.get() ?? {}
|
|
46
|
+
if (cfg.vaultDir && cfg.vaultDir.trim()) return path.resolve(cfg.vaultDir.trim())
|
|
47
|
+
const home = process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
|
|
48
|
+
return path.join(home, 'memory-vault')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// -- 1. 自动沉淀 ---------------------------------------------------------
|
|
52
|
+
// agent/turn-stopping 是 serial 事件:不在监听器里 await LLM(会拖慢收尾),
|
|
53
|
+
// 同步抓取增量事件快照后,把真正的捕获调度到后台队列执行。
|
|
54
|
+
const pending = new Map() // sessionId -> merged events array
|
|
55
|
+
let captureQueue = Promise.resolve()
|
|
56
|
+
|
|
57
|
+
const scheduleCapture = (agent, events, lastSeq) => {
|
|
58
|
+
const cfg = settings.get() ?? {}
|
|
59
|
+
if (!cfg.enabled || !cfg.autoCapture) return
|
|
60
|
+
const sessionId = agent?.session?.id ?? agent?.id ?? 'unknown'
|
|
61
|
+
const newEvents = sliceNewEvents(events, lastSeq)
|
|
62
|
+
if (newEvents.length === 0) return
|
|
63
|
+
const existing = pending.get(sessionId)
|
|
64
|
+
if (existing) {
|
|
65
|
+
// 连续轮次合并:把新事件接到待处理队列尾,一次 LLM 调用处理。
|
|
66
|
+
pending.set(sessionId, [...existing, ...newEvents])
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
pending.set(sessionId, newEvents)
|
|
70
|
+
captureQueue = captureQueue.then(() => runCapture(agent, pending.get(sessionId) ?? newEvents))
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const runCapture = async (agent, events) => {
|
|
74
|
+
try {
|
|
75
|
+
const cfg = settings.get() ?? {}
|
|
76
|
+
if (!cfg.enabled || !cfg.autoCapture) return
|
|
77
|
+
const llm = ctx.get('llm')
|
|
78
|
+
if (!llm) return
|
|
79
|
+
const text = extractLastTurn(events)
|
|
80
|
+
if (text.length < (cfg.captureMinChars ?? 200)) return
|
|
81
|
+
// 日配额:防止一次大扫荡烧光 token。
|
|
82
|
+
if (!(await underDailyQuota(cfg.maxCardsPerDay))) return
|
|
83
|
+
const route = await resolveRoute(llm)
|
|
84
|
+
if (!route) return
|
|
85
|
+
// 语义去重近邻:把已有卡片索引喂给模型,让模型决定新建 vs 追加。
|
|
86
|
+
const draft = { title: '', body: text.slice(0, 400) }
|
|
87
|
+
const neighbors = await pickNeighbors(vaultDir(), draft, 8)
|
|
88
|
+
const result = await summarizeTurn(llm, route, text, { signal: AbortSignal.timeout(45000), existing: neighbors })
|
|
89
|
+
if (!result || result.save !== true) return
|
|
90
|
+
if (result.append_to) {
|
|
91
|
+
// 模型判定属于已有卡 → 追加更新记录,不新建(boujoy 语义)。
|
|
92
|
+
await captureUpdate(vaultDir(), result.append_to, result.update, { threshold: cfg.dedupThreshold })
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
const card = {
|
|
96
|
+
kind: result.kind,
|
|
97
|
+
title: result.title,
|
|
98
|
+
tags: result.tags,
|
|
99
|
+
body: result.body,
|
|
100
|
+
source: agent?.session?.id ? `session:${agent.session.id}` : '',
|
|
101
|
+
}
|
|
102
|
+
const out = await captureCard(vaultDir(), card, { threshold: cfg.dedupThreshold })
|
|
103
|
+
if (!out.ok && out.duplicate) {
|
|
104
|
+
// 词法兜底:高度相似 → 追加更新记录而不是再建一张重复卡。
|
|
105
|
+
await captureUpdate(vaultDir(), out.duplicate.path, `${result.title}:${result.body.slice(0, 400)}`, {
|
|
106
|
+
threshold: cfg.dedupThreshold,
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
} catch (error) {
|
|
110
|
+
console.error('[memory-eternal] capture failed:', error)
|
|
111
|
+
} finally {
|
|
112
|
+
const sessionId = agent?.session?.id ?? agent?.id ?? 'unknown'
|
|
113
|
+
pending.delete(sessionId)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const lastDayStamps = []
|
|
118
|
+
const underDailyQuota = async (max) => {
|
|
119
|
+
const now = Date.now()
|
|
120
|
+
const dayStart = now - 86400000
|
|
121
|
+
while (lastDayStamps.length && lastDayStamps[0] < dayStart) lastDayStamps.shift()
|
|
122
|
+
if (lastDayStamps.length >= (max ?? 60)) return false
|
|
123
|
+
lastDayStamps.push(now)
|
|
124
|
+
return true
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const lastSeqs = new Map() // sessionId -> last processed seq
|
|
128
|
+
ctx.on('agent/turn-stopping', ({ agent }) => {
|
|
129
|
+
const events = agent?.session?.events
|
|
130
|
+
if (!Array.isArray(events)) return
|
|
131
|
+
const sessionId = agent?.session?.id ?? agent?.id ?? 'unknown'
|
|
132
|
+
const lastSeq = lastSeqs.get(sessionId) ?? 0
|
|
133
|
+
scheduleCapture(agent, events, lastSeq)
|
|
134
|
+
lastSeqs.set(sessionId, events.length ? events[events.length - 1].seq : lastSeq)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
// -- 2. 自动召回:systemPrompt 分段 + memory_recall 工具 -----------------
|
|
138
|
+
ctx.effect(() => {
|
|
139
|
+
let disposeSection = null
|
|
140
|
+
const refresh = (cfg) => {
|
|
141
|
+
if (disposeSection) {
|
|
142
|
+
const dispose = disposeSection
|
|
143
|
+
disposeSection = null
|
|
144
|
+
dispose()
|
|
145
|
+
}
|
|
146
|
+
if (!cfg || cfg.enabled === false || cfg.autoRecall !== true) return
|
|
147
|
+
const text = [
|
|
148
|
+
'你拥有一个本地「记忆核心」(Markdown 知识库,位于 ' + vaultDir() + ')。',
|
|
149
|
+
'规则:',
|
|
150
|
+
'1. 每轮对话结束后,值得长期复用的内容会被自动沉淀成知识卡,你无需询问用户、也无需手动保存。',
|
|
151
|
+
'2. 当任务需要项目背景、历史决策、之前讨论过的方案或领域知识时,先调用 memory_recall 检索相关卡片,再作答。',
|
|
152
|
+
'3. 若检索结果为空,就诚实说明当前记忆库没有相关内容,不要编造。',
|
|
153
|
+
'4. 知识卡是普通 Markdown 文件,你可以用文件工具直接读写它。',
|
|
154
|
+
].join('\n')
|
|
155
|
+
disposeSection = ctx.systemPrompt.section({
|
|
156
|
+
name: 'memory-eternal: auto-recall',
|
|
157
|
+
order: 600,
|
|
158
|
+
text,
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
refresh(settings.get())
|
|
162
|
+
const unwatch = settings.watch(refresh)
|
|
163
|
+
return () => {
|
|
164
|
+
if (typeof unwatch === 'function') unwatch()
|
|
165
|
+
if (disposeSection) disposeSection()
|
|
166
|
+
}
|
|
167
|
+
}, 'memory-eternal: recall section')
|
|
168
|
+
|
|
169
|
+
const tools = ctx.get('tools')
|
|
170
|
+
if (tools !== undefined) {
|
|
171
|
+
tools.register(defineTool({
|
|
172
|
+
name: 'memory_recall',
|
|
173
|
+
description:
|
|
174
|
+
'从本地记忆核心(Markdown 知识库)检索相关知识卡。需要项目背景、历史决策、之前讨论过的方案、' +
|
|
175
|
+
'或领域知识时调用;返回最相关的卡片摘要。用 query 描述要找的内容,支持中文整词与字符片段检索。',
|
|
176
|
+
parameters: {
|
|
177
|
+
query: { type: 'string', required: true, description: '检索关键词或自然语言描述,如「数据库选型」「用户偏好」' },
|
|
178
|
+
limit: { type: 'number', description: '返回卡片数上限,默认 5' },
|
|
179
|
+
},
|
|
180
|
+
output: {
|
|
181
|
+
schema: { type: 'string' },
|
|
182
|
+
render(_a, v) { return [{ type: 'text', text: v }] },
|
|
183
|
+
},
|
|
184
|
+
timeoutMs: 20000,
|
|
185
|
+
async execute(args) {
|
|
186
|
+
const cfg = settings.get() ?? {}
|
|
187
|
+
if (!cfg.enabled) return '(记忆核心已禁用)'
|
|
188
|
+
const query = String(args.query || '').trim()
|
|
189
|
+
if (!query) return '(未提供检索词)'
|
|
190
|
+
const limit = Math.min(Math.max(Number(args.limit) || 5, 1), 20)
|
|
191
|
+
const hits = await search(vaultDir(), query, { limit })
|
|
192
|
+
if (hits.length === 0) return `记忆库中没有与「${query}」相关的内容。`
|
|
193
|
+
const lines = hits.map((h, i) => {
|
|
194
|
+
const tags = h.tags.length ? ` [${h.tags.join(', ')}]` : ''
|
|
195
|
+
return `### ${i + 1}. ${h.title}${tags}\n路径:${h.path}\n${h.summary || ''}`
|
|
196
|
+
})
|
|
197
|
+
return `从记忆核心检索到 ${hits.length} 条相关卡片:\n\n${lines.join('\n\n')}`
|
|
198
|
+
},
|
|
199
|
+
}))
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// -- 3. 知识库 JSON API(客户端设置页数据源) ----------------------------
|
|
203
|
+
const webServer = ctx.get('webServer')
|
|
204
|
+
if (webServer !== undefined) {
|
|
205
|
+
webServer.register({
|
|
206
|
+
kind: 'prefix',
|
|
207
|
+
path: API_PREFIX,
|
|
208
|
+
handler: async (req, res) => {
|
|
209
|
+
try {
|
|
210
|
+
await handleApi(req, res, vaultDir())
|
|
211
|
+
} catch (error) {
|
|
212
|
+
json(res, 500, { ok: false, error: String(error?.message || error) })
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
})
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// 首次激活时确保 vault 目录存在。
|
|
219
|
+
ctx.effect(() => {
|
|
220
|
+
const root = vaultDir()
|
|
221
|
+
ensureVault(root).catch((error) => console.error('[memory-eternal] ensureVault failed:', error))
|
|
222
|
+
}, 'memory-eternal: ensure vault')
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// -- API -------------------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
async function handleApi(req, res, vaultRoot) {
|
|
228
|
+
const url = new URL(req.url, 'http://localhost')
|
|
229
|
+
const route = url.pathname.slice(API_PREFIX.length).replace(/\/+$/, '') || '/overview'
|
|
230
|
+
const query = url.searchParams
|
|
231
|
+
|
|
232
|
+
switch (route) {
|
|
233
|
+
case '/overview': {
|
|
234
|
+
await ensureVault(vaultRoot)
|
|
235
|
+
json(res, 200, { ok: true, vaultDir: vaultRoot, ...(await overview(vaultRoot)) })
|
|
236
|
+
return
|
|
237
|
+
}
|
|
238
|
+
case '/cards': {
|
|
239
|
+
const kind = query.get('kind') || ''
|
|
240
|
+
const q = query.get('q') || ''
|
|
241
|
+
const limit = Math.min(Number(query.get('limit')) || 200, 500)
|
|
242
|
+
let cards = await listCards(vaultRoot)
|
|
243
|
+
if (kind) cards = cards.filter((c) => c.kind === kind)
|
|
244
|
+
if (q.trim()) {
|
|
245
|
+
const hits = await search(vaultRoot, q, { limit: 200 })
|
|
246
|
+
const hitPaths = new Set(hits.map((h) => h.path))
|
|
247
|
+
cards = cards.filter((c) => hitPaths.has(c.path))
|
|
248
|
+
}
|
|
249
|
+
json(res, 200, { ok: true, cards: cards.slice(0, limit) })
|
|
250
|
+
return
|
|
251
|
+
}
|
|
252
|
+
case '/card': {
|
|
253
|
+
const rel = query.get('path') || ''
|
|
254
|
+
if (!rel) return json(res, 400, { ok: false, error: '缺少 path' })
|
|
255
|
+
const text = await readCard(vaultRoot, rel)
|
|
256
|
+
json(res, 200, { ok: true, path: rel, text })
|
|
257
|
+
return
|
|
258
|
+
}
|
|
259
|
+
case '/search': {
|
|
260
|
+
const q = query.get('q') || ''
|
|
261
|
+
if (!q.trim()) return json(res, 200, { ok: true, hits: [] })
|
|
262
|
+
json(res, 200, { ok: true, hits: await search(vaultRoot, q, { limit: 30 }) })
|
|
263
|
+
return
|
|
264
|
+
}
|
|
265
|
+
case '/graph': {
|
|
266
|
+
await ensureVault(vaultRoot)
|
|
267
|
+
json(res, 200, { ok: true, ...(await graph(vaultRoot)) })
|
|
268
|
+
return
|
|
269
|
+
}
|
|
270
|
+
default:
|
|
271
|
+
json(res, 404, { ok: false, error: '未知接口' })
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function json(res, status, body) {
|
|
276
|
+
const payload = JSON.stringify(body)
|
|
277
|
+
res.writeHead(status, {
|
|
278
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
279
|
+
'Content-Length': Buffer.byteLength(payload),
|
|
280
|
+
'Cache-Control': 'no-store',
|
|
281
|
+
})
|
|
282
|
+
res.end(payload)
|
|
283
|
+
}
|
package/lib/capture.js
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// 记忆核心 · 自动沉淀管线
|
|
2
|
+
//
|
|
3
|
+
// 移植 boujoy-harness 的「知识捕获」理念:模型负责判定价值(score)与压缩
|
|
4
|
+
// (卡片正文),本模块只负责安全(写哪、多短拒绝、去重守卫)。
|
|
5
|
+
//
|
|
6
|
+
// 与 boujoy 的区别:不需要用户点「沉淀」——会话每轮结束后由 index.js 监听
|
|
7
|
+
// agent/turn-stopping 自动触发 summarizeTurn → writeCard/appendUpdate。
|
|
8
|
+
//
|
|
9
|
+
// 去重:两级。第一级是词法(Jaccard bigram,阈值 0.62,捕获纯重复);第二级
|
|
10
|
+
// 是语义——把「现有卡片索引」(标题+摘要,最多近邻 8 张)喂给模型,让模型
|
|
11
|
+
// 判断新知识是「新建卡片」还是「追加到已有卡片」(append_to),这正是
|
|
12
|
+
// boujoy 的「追加更新记录,不要堆重复卡」语义,且对 LLM 改写免疫。
|
|
13
|
+
|
|
14
|
+
import { writeCard, appendUpdate, textSimilarity, dedupCheck, listCards, queryTerms } from './vault.js'
|
|
15
|
+
|
|
16
|
+
/** 默认去重阈值(与 boujoy 一致)。 */
|
|
17
|
+
export const DEDUP_THRESHOLD = 0.62
|
|
18
|
+
|
|
19
|
+
/** 参与语义去重的近邻卡片数上限(控制 token)。 */
|
|
20
|
+
export const DEDUP_NEIGHBORS = 8
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 用当前模型把一段对话压缩成知识卡(或判定不值得保存 / 应追加到已有卡)。
|
|
24
|
+
* @param llm - dsh 的 llm 服务(ctx.get('llm'))
|
|
25
|
+
* @param route - { provider, model } 解析结果
|
|
26
|
+
* @param conversation - 本轮对话文本(用户+助手)
|
|
27
|
+
* @param opts - { maxTokens, signal, existing }:existing 为 [{path,title,summary}]
|
|
28
|
+
* @returns Promise<null | { save:boolean } | { save:true, title, kind, tags, body } | { append_to, update }>
|
|
29
|
+
*/
|
|
30
|
+
export async function summarizeTurn(llm, route, conversation, opts = {}) {
|
|
31
|
+
const text = String(conversation || '').trim()
|
|
32
|
+
if (text.length < 120) return null // 太短,没有沉淀价值
|
|
33
|
+
|
|
34
|
+
const existing = Array.isArray(opts.existing) ? opts.existing.slice(0, DEDUP_NEIGHBORS) : []
|
|
35
|
+
const existingBlock = existing.length
|
|
36
|
+
? '\n\n## 记忆库已有卡片索引(若新知识属于其中某张,必须用 append_to 追加而不是新建)\n' +
|
|
37
|
+
existing.map((e, i) => `${i + 1}. [${e.path}] ${e.title}${e.summary ? `:${e.summary.slice(0, 120)}` : ''}`).join('\n')
|
|
38
|
+
: ''
|
|
39
|
+
|
|
40
|
+
const system = [
|
|
41
|
+
'你是本地知识库的「记忆沉淀引擎」。你的唯一任务:判断一段对话中是否有值得长期复用的知识,若有则压缩成一张知识卡。',
|
|
42
|
+
'判断标准(全部满足才保存):',
|
|
43
|
+
'1. 内容对未来有复用价值(技术方案、项目背景、设计决策、领域知识、偏好约定等);',
|
|
44
|
+
'2. 不是纯闲聊、寒暄或一次性指令;',
|
|
45
|
+
'3. 与已有知识不重复(见下)。',
|
|
46
|
+
'输出严格 JSON(不要 markdown 代码块、不要多余文字):',
|
|
47
|
+
'{"save": false} 表示不值得保存;',
|
|
48
|
+
'否则输出:',
|
|
49
|
+
'{"save": true, "title": "简短标题", "kind": "knowledge|project|content|prompt|business", "tags": ["标签"], "body": "300-800字的压缩正文,markdown,含要点列表,首行是# 标题"}',
|
|
50
|
+
'kind 含义:project=项目背景/进度;knowledge=通用知识/技术方案/设计决策;content=内容素材/资料;prompt=提示词/工作流;business=业务/商业。',
|
|
51
|
+
'正文要求:去掉对话口水,只留可复用的结论、参数、步骤、原因。语言与对话一致。',
|
|
52
|
+
'已有卡片规则:若新知识与某张已有卡片是同一主题(同一技术、同一项目、同一决策),不要新建——输出',
|
|
53
|
+
'{"append_to": "已有卡片的 path 字段原样", "update": "追加/修正的短文本(一段话)"}',
|
|
54
|
+
'只有当已有卡片索引为空或不相关时,才新建卡片。',
|
|
55
|
+
].join('\n')
|
|
56
|
+
|
|
57
|
+
const { BlockAssembler, createUserMessage } = await importLlmHelpers()
|
|
58
|
+
const assembler = new BlockAssembler()
|
|
59
|
+
const messages = [createUserMessage({
|
|
60
|
+
content: [{ type: 'text', text: text + existingBlock }],
|
|
61
|
+
source: { kind: 'plugin', plugin: 'memory-eternal' },
|
|
62
|
+
})]
|
|
63
|
+
|
|
64
|
+
const options = {
|
|
65
|
+
provider: route.provider,
|
|
66
|
+
model: route.model,
|
|
67
|
+
messages,
|
|
68
|
+
system,
|
|
69
|
+
maxTokens: opts.maxTokens ?? 900,
|
|
70
|
+
reasoningEffort: 'off',
|
|
71
|
+
purpose: 'memory-capture',
|
|
72
|
+
signal: opts.signal ?? AbortSignal.timeout(45000),
|
|
73
|
+
}
|
|
74
|
+
for await (const chunk of llm.stream(options)) assembler.push(chunk)
|
|
75
|
+
const out = assembler.blocks().filter((b) => b.type === 'text').map((b) => b.text).join('').trim()
|
|
76
|
+
return parseCaptureJson(out)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 语义去重近邻:按关键词重叠为候选卡排序,返回最相似的若干张。
|
|
81
|
+
* 关键词 = 新卡标题/正文的 CJK 整词 + bigram 与已有卡标题/摘要的命中数。
|
|
82
|
+
*/
|
|
83
|
+
export async function pickNeighbors(root, card, limit = DEDUP_NEIGHBORS) {
|
|
84
|
+
const haystack = `${card.title}\n${card.body}`.toLowerCase()
|
|
85
|
+
const wanted = queryTerms(haystack)
|
|
86
|
+
const cards = await listCards(root)
|
|
87
|
+
const scored = []
|
|
88
|
+
for (const existing of cards) {
|
|
89
|
+
let score = 0
|
|
90
|
+
const target = `${existing.title}\n${existing.summary}`.toLowerCase()
|
|
91
|
+
for (const term of wanted) if (target.includes(term.toLowerCase())) score += 1
|
|
92
|
+
if (score > 0) scored.push({ ...existing, score })
|
|
93
|
+
}
|
|
94
|
+
scored.sort((a, b) => b.score - a.score)
|
|
95
|
+
return scored.slice(0, limit)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* 去重守卫:目标目录里已有高度相似卡片时返回它,否则返回 null。
|
|
100
|
+
* @returns Promise<null | { path, score }>
|
|
101
|
+
*/
|
|
102
|
+
export function makeDedupChecker(dir) {
|
|
103
|
+
return (text, threshold = DEDUP_THRESHOLD) => dedupCheck(dir, text, threshold)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** 写卡(带目录级去重守卫),命中返回 duplicate 而非写卡。 */
|
|
107
|
+
export async function captureCard(root, card, opts = {}) {
|
|
108
|
+
const threshold = opts.threshold ?? DEDUP_THRESHOLD
|
|
109
|
+
return writeCard(root, card, { threshold, dedup: true })
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 追加更新记录(去重命中时)。 */
|
|
113
|
+
export async function captureUpdate(root, rel, text, opts = {}) {
|
|
114
|
+
return appendUpdate(root, rel, text, { threshold: opts.threshold ?? DEDUP_THRESHOLD })
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// -- helpers ---------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
async function importLlmHelpers() {
|
|
120
|
+
// 延迟加载,避免在无 llm 服务时拉高启动成本;dsh-llm 是 peerDependency。
|
|
121
|
+
const { BlockAssembler, createUserMessage } = await import('@deepseek-ai/dsh-llm')
|
|
122
|
+
return { BlockAssembler, createUserMessage }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function parseCaptureJson(text) {
|
|
126
|
+
if (!text) return null
|
|
127
|
+
// 去掉可能的 ```json 围栏
|
|
128
|
+
const cleaned = text.replace(/^```(?:json)?\s*/i, '').replace(/```\s*$/, '').trim()
|
|
129
|
+
let parsed
|
|
130
|
+
try {
|
|
131
|
+
parsed = JSON.parse(cleaned)
|
|
132
|
+
} catch {
|
|
133
|
+
// 容错:截取第一个 { 到最后一个 }
|
|
134
|
+
const start = cleaned.indexOf('{')
|
|
135
|
+
const end = cleaned.lastIndexOf('}')
|
|
136
|
+
if (start < 0 || end <= start) return null
|
|
137
|
+
try {
|
|
138
|
+
parsed = JSON.parse(cleaned.slice(start, end + 1))
|
|
139
|
+
} catch {
|
|
140
|
+
return null
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (!parsed || parsed.save === false) return { save: false }
|
|
144
|
+
// 追加已有卡片:append_to 命中的优先级最高
|
|
145
|
+
if (parsed.append_to) {
|
|
146
|
+
const target = String(parsed.append_to).trim()
|
|
147
|
+
const update = String(parsed.update || '').trim()
|
|
148
|
+
if (target && update.length >= 10) return { append_to: target, update }
|
|
149
|
+
return null
|
|
150
|
+
}
|
|
151
|
+
const title = String(parsed.title || '').trim().slice(0, 80)
|
|
152
|
+
const body = String(parsed.body || '').trim()
|
|
153
|
+
if (!title || body.length < 20) return null
|
|
154
|
+
const kind = ['project', 'knowledge', 'content', 'prompt', 'business'].includes(parsed.kind) ? parsed.kind : 'knowledge'
|
|
155
|
+
const tags = Array.isArray(parsed.tags)
|
|
156
|
+
? parsed.tags.map((t) => String(t).trim().slice(0, 30)).filter(Boolean).slice(0, 8)
|
|
157
|
+
: []
|
|
158
|
+
return { save: true, title, kind, tags, body }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** 从会话事件里提取「增量片段」的可读对话文本(用户 + 助手)。 */
|
|
162
|
+
export function extractLastTurn(events, _turn) {
|
|
163
|
+
const lines = []
|
|
164
|
+
const pushMessage = (event) => {
|
|
165
|
+
const data = event.data || event
|
|
166
|
+
const message = data.message || data
|
|
167
|
+
const role = message.role
|
|
168
|
+
const content = message.content
|
|
169
|
+
if (Array.isArray(content)) {
|
|
170
|
+
for (const block of content) {
|
|
171
|
+
if (block.type === 'text' && block.text) lines.push(`${role === 'user' ? '用户' : '助手'}:${block.text}`)
|
|
172
|
+
}
|
|
173
|
+
} else if (typeof content === 'string' && content) {
|
|
174
|
+
lines.push(`${role === 'user' ? '用户' : '助手'}:${content}`)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
for (const event of events) {
|
|
178
|
+
const type = event.type
|
|
179
|
+
const data = event.data || event
|
|
180
|
+
if (type === 'user/message' || (type === 'message' && data.role === 'user')) {
|
|
181
|
+
pushMessage(data)
|
|
182
|
+
} else if (type === 'assistant/message' || type === 'assistant/chunk') {
|
|
183
|
+
if (type === 'assistant/message') pushMessage(data)
|
|
184
|
+
else if (data.chunk?.content) {
|
|
185
|
+
const content = data.chunk.content
|
|
186
|
+
if (typeof content === 'string') lines.push(`助手:${content}`)
|
|
187
|
+
else if (Array.isArray(content)) {
|
|
188
|
+
for (const block of content) {
|
|
189
|
+
if (block.type === 'text' && block.text) lines.push(`助手:${block.text}`)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return lines.join('\n').trim()
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** 取增量事件:seq 大于 lastSeq 的 user/assistant 消息事件。 */
|
|
199
|
+
export function sliceNewEvents(events, lastSeq) {
|
|
200
|
+
const out = []
|
|
201
|
+
for (const event of events) {
|
|
202
|
+
const seq = event.seq
|
|
203
|
+
if (seq !== undefined && lastSeq !== undefined && seq <= lastSeq) continue
|
|
204
|
+
const type = event.type
|
|
205
|
+
if (type === 'user/message' || type === 'assistant/message') out.push(event)
|
|
206
|
+
}
|
|
207
|
+
return out
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** 解析当前模型路由:取第一个 provider 的旗舰模型(与驯兽师一致)。 */
|
|
211
|
+
export async function resolveRoute(llm) {
|
|
212
|
+
try {
|
|
213
|
+
const providers = llm.listProviders()
|
|
214
|
+
if (!providers || providers.length === 0) return null
|
|
215
|
+
const models = await llm.listModels(providers[0].id)
|
|
216
|
+
if (!models || models.length === 0) return null
|
|
217
|
+
return { provider: providers[0].id, model: models[0].id }
|
|
218
|
+
} catch {
|
|
219
|
+
return null
|
|
220
|
+
}
|
|
221
|
+
}
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: "dsh-memory-eternal",
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
var module = { exports: {} };
|
|
5
|
+
var exports = module.exports;
|
|
6
|
+
var R=Object.create;var S=Object.defineProperty;var O=Object.getOwnPropertyDescriptor;var A=Object.getOwnPropertyNames;var L=Object.getPrototypeOf,B=Object.prototype.hasOwnProperty;var T=(e,r)=>{for(var l in r)S(e,l,{get:r[l],enumerable:!0})},I=(e,r,l,n)=>{if(r&&typeof r=="object"||typeof r=="function")for(let d of A(r))!B.call(e,d)&&d!==l&&S(e,d,{get:()=>r[d],enumerable:!(n=O(r,d))||n.enumerable});return e};var H=(e,r,l)=>(l=e!=null?R(L(e)):{},I(r||!e||!e.__esModule?S(l,"default",{value:e,enumerable:!0}):l,e)),G=e=>I(S({},"__esModule",{value:!0}),e);var re={};T(re,{apply:()=>F,inject:()=>q});module.exports=G(re);var t=H(require("react"),1),w="memory-eternal",E="/memory-eternal/api",q=["settingsScope","slots","locale","connection","remote"],U={nav:"\u8BB0\u5FC6",loading:"\u52A0\u8F7D\u4E2D\u2026",refresh:"\u5237\u65B0",overview:"\u8BB0\u5FC6\u5E93\u6982\u89C8",total:"\u77E5\u8BC6\u5361",recent:"\u8FD1 7 \u5929\u65B0\u589E",tags:"\u6807\u7B7E",searchPlaceholder:"\u641C\u7D22\u8BB0\u5FC6\uFF08\u652F\u6301\u4E2D\u6587\u7247\u6BB5\uFF09\u2026",search:"\u641C\u7D22",all:"\u5168\u90E8",kindProject:"\u9879\u76EE",kindKnowledge:"\u77E5\u8BC6",kindContent:"\u5185\u5BB9",kindPrompt:"\u63D0\u793A\u8BCD",kindBusiness:"\u4E1A\u52A1",graph:"\u77E5\u8BC6\u56FE\u8C31",graphHint:"\u8282\u70B9=\u77E5\u8BC6\u5361\uFF1B\u8FDE\u7EBF=[[\u94FE\u63A5]] \u6216\u5171\u4EAB\u6807\u7B7E\u3002\u70B9\u51FB\u8282\u70B9\u9605\u8BFB\u5361\u7247\u3002",empty:"\u8BB0\u5FC6\u5E93\u8FD8\u662F\u7A7A\u7684\u3002\u591A\u804A\u51E0\u8F6E\u540E\uFF0C\u503C\u5F97\u4FDD\u5B58\u7684\u5185\u5BB9\u4F1A\u81EA\u52A8\u6C89\u6DC0\u6210\u77E5\u8BC6\u5361\u3002",open:"\u9605\u8BFB",updated:"\u66F4\u65B0\u4E8E",created:"\u521B\u5EFA\u4E8E",source:"\u6765\u6E90",close:"\u5173\u95ED",vaultDir:"\u8BB0\u5FC6\u5E93\u76EE\u5F55",capture:"\u81EA\u52A8\u6C89\u6DC0",recall:"\u81EA\u52A8\u53EC\u56DE",enabled:"\u5DF2\u542F\u7528",disabled:"\u5DF2\u7981\u7528",error:"\u52A0\u8F7D\u5931\u8D25",back:"\u8FD4\u56DE\u5217\u8868",cardCount:"\u5F20\u5361",nodes:"\u8282\u70B9",edges:"\u8FDE\u7EBF"},_={nav:"Memory",loading:"Loading\u2026",refresh:"Refresh",overview:"Memory Overview",total:"Cards",recent:"Added (7d)",tags:"Tags",searchPlaceholder:"Search memory\u2026",search:"Search",all:"All",kindProject:"Projects",kindKnowledge:"Knowledge",kindContent:"Content",kindPrompt:"Prompts",kindBusiness:"Business",graph:"Knowledge Graph",graphHint:"Nodes = cards; edges = [[links]] or shared tags. Click a node to read.",empty:"Memory is empty. After a few conversations, valuable content is auto-captured.",open:"Read",updated:"Updated",created:"Created",source:"Source",close:"Close",vaultDir:"Vault directory",capture:"Auto capture",recall:"Auto recall",enabled:"on",disabled:"off",error:"Load failed",back:"Back to list",cardCount:"cards",nodes:"nodes",edges:"edges"},V=["all","project","knowledge","content","prompt","business"],x={project:"#3b82f6",knowledge:"#22c55e",content:"#f43f5e",prompt:"#06b6d4",business:"#eab308",other:"#8b5cf6"},Z=`
|
|
7
|
+
.memory-eternal-root { font-family: inherit; color: var(--dsh-color-text, #1f2937); }
|
|
8
|
+
.memory-eternal-root .mc-card { background: var(--dsh-color-surface, #ffffff); border: 1px solid var(--dsh-color-border, #e5e7eb); border-radius: 12px; padding: 14px 16px; }
|
|
9
|
+
.memory-eternal-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); gap: 10px; margin-bottom: 14px; }
|
|
10
|
+
.memory-eternal-stat { text-align: center; }
|
|
11
|
+
.memory-eternal-stat b { display: block; font-size: 22px; line-height: 1.2; }
|
|
12
|
+
.memory-eternal-stat span { font-size: 12px; opacity: 0.65; }
|
|
13
|
+
.memory-eternal-toolbar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 12px; }
|
|
14
|
+
.memory-eternal-toolbar input[type=text] { flex: 1; min-width: 180px; padding: 7px 10px; border-radius: 8px; border: 1px solid var(--dsh-color-border, #d1d5db); background: var(--dsh-color-input, #f9fafb); color: inherit; font-size: 13px; }
|
|
15
|
+
.memory-eternal-chips { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 12px; }
|
|
16
|
+
.memory-eternal-chip { border: 1px solid var(--dsh-color-border, #d1d5db); background: transparent; color: inherit; border-radius: 999px; padding: 4px 12px; font-size: 12px; cursor: pointer; opacity: 0.7; }
|
|
17
|
+
.memory-eternal-chip.active { opacity: 1; font-weight: 600; border-color: currentColor; }
|
|
18
|
+
.memory-eternal-btn { border: 1px solid var(--dsh-color-border, #d1d5db); background: transparent; color: inherit; border-radius: 8px; padding: 6px 12px; font-size: 12px; cursor: pointer; }
|
|
19
|
+
.memory-eternal-btn:hover { opacity: 0.85; }
|
|
20
|
+
.memory-eternal-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 10px; }
|
|
21
|
+
.memory-eternal-card { cursor: pointer; transition: border-color 0.15s; display: flex; flex-direction: column; gap: 6px; min-height: 110px; }
|
|
22
|
+
.memory-eternal-card:hover { border-color: var(--dsh-color-accent, #6366f1); }
|
|
23
|
+
.memory-eternal-card h4 { margin: 0; font-size: 14px; line-height: 1.35; }
|
|
24
|
+
.memory-eternal-card p { margin: 0; font-size: 12px; opacity: 0.7; line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
|
|
25
|
+
.memory-eternal-card footer { margin-top: auto; display: flex; justify-content: space-between; align-items: center; font-size: 11px; opacity: 0.55; }
|
|
26
|
+
.memory-eternal-tags { display: flex; gap: 4px; flex-wrap: wrap; }
|
|
27
|
+
.memory-eternal-tag { font-size: 10px; padding: 1px 7px; border-radius: 999px; background: var(--dsh-color-border, #e5e7eb); opacity: 0.85; }
|
|
28
|
+
.memory-eternal-kind { width: 8px; height: 8px; border-radius: 50%; display: inline-block; margin-right: 6px; }
|
|
29
|
+
.memory-eternal-empty { text-align: center; padding: 40px 10px; opacity: 0.6; font-size: 13px; }
|
|
30
|
+
.memory-eternal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.45); z-index: 999; display: flex; align-items: center; justify-content: center; padding: 24px; }
|
|
31
|
+
.memory-eternal-dialog { background: var(--dsh-color-surface, #fff); color: var(--dsh-color-text, #111); border-radius: 14px; max-width: 720px; width: 100%; max-height: 84vh; display: flex; flex-direction: column; box-shadow: 0 24px 60px rgba(0,0,0,0.3); }
|
|
32
|
+
.memory-eternal-dialog-head { display: flex; justify-content: space-between; align-items: center; padding: 12px 18px; border-bottom: 1px solid var(--dsh-color-border, #e5e7eb); }
|
|
33
|
+
.memory-eternal-dialog-head h3 { margin: 0; font-size: 15px; }
|
|
34
|
+
.memory-eternal-dialog-body { padding: 14px 18px; overflow: auto; font-size: 13px; line-height: 1.7; white-space: pre-wrap; word-break: break-word; }
|
|
35
|
+
.memory-eternal-graph-wrap { width: 100%; height: 420px; position: relative; }
|
|
36
|
+
.memory-eternal-graph-wrap svg { width: 100%; height: 100%; }
|
|
37
|
+
.memory-eternal-graph-line { stroke: var(--dsh-color-border, #d1d5db); stroke-width: 1; opacity: 0.6; }
|
|
38
|
+
.memory-eternal-graph-node { cursor: pointer; }
|
|
39
|
+
.memory-eternal-graph-node:hover circle { stroke: var(--dsh-color-accent, #6366f1); stroke-width: 2; }
|
|
40
|
+
.memory-eternal-graph-label { font-size: 11px; fill: var(--dsh-color-text, #333); pointer-events: none; }
|
|
41
|
+
.memory-eternal-graph-legend { display: flex; gap: 10px; flex-wrap: wrap; padding: 8px 2px 0; font-size: 11px; opacity: 0.8; }
|
|
42
|
+
.memory-eternal-flag { font-size: 11px; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--dsh-color-border, #d1d5db); margin-left: 6px; }
|
|
43
|
+
`;function F(e){e.effect(()=>e.locale.register(w,{zh:U,en:_}),"memory-eternal: locale");let r=e.locale.bind(w);e.effect(()=>e.slots.inject("settings.section",()=>e.slots.register({name:"settings.section",id:w,order:25,label:()=>r("nav"),locale:w,inject:()=>({})},()=>t.default.createElement(J,{t:r,scope:e.settingsScope.bind({namespace:w})}))),"memory-eternal: settings section")}function J({t:e}){let[r,l]=(0,t.useState)(null),[n,d]=(0,t.useState)([]),[h,f]=(0,t.useState)(!0),[y,i]=(0,t.useState)(""),[u,z]=(0,t.useState)("all"),[p,N]=(0,t.useState)(""),[b,C]=(0,t.useState)(null),[o,c]=(0,t.useState)(!1),s=(0,t.useRef)(null),k=(0,t.useCallback)(async(a=u,g=p)=>{try{f(!0),i("");let m=new URLSearchParams;a&&a!=="all"&&m.set("kind",a),g.trim()&&m.set("q",g.trim());let M=await(await fetch(`${E}/cards?${m.toString()}`)).json();M.ok&&d(M.cards||[])}catch(m){i(String(m&&m.message?m.message:m))}finally{f(!1)}},[u,p]),v=(0,t.useCallback)(async()=>{try{let[a,g]=await Promise.all([fetch(`${E}/overview`).then(m=>m.json()),fetch(`${E}/cards`).then(m=>m.json())]);a.ok&&l(a),g.ok&&d(g.cards||[])}catch(a){i(String(a&&a.message?a.message:a))}finally{f(!1)}},[]);(0,t.useEffect)(()=>(v(),()=>{s.current&&clearTimeout(s.current)}),[v]);let D=a=>{N(a),s.current&&clearTimeout(s.current),s.current=setTimeout(()=>k(u,a),280)},$=a=>{z(a),k(a,p)},K=async a=>{try{let m=await(await fetch(`${E}/card?path=${encodeURIComponent(a.path)}`)).json();m.ok&&C({path:a.path,title:a.title,text:m.text})}catch(g){i(String(g&&g.message?g.message:g))}},ae=a=>e(P(a));return t.default.createElement("div",{className:"memory-eternal-root mc-card"},t.default.createElement("style",{key:"mc-css"},Z),t.default.createElement("div",{className:"memory-eternal-stats"},j(e("total"),r?r.total:"\u2014"),j(e("recent"),r?r.recent:"\u2014"),j(e("tags"),r?r.tags:"\u2014"),j(e("cardCount"),r?r.byKind?.knowledge??0:"\u2014")),t.default.createElement("div",{className:"memory-eternal-toolbar"},t.default.createElement("input",{type:"text",placeholder:e("searchPlaceholder"),value:p,onChange:a=>D(a.target.value)}),t.default.createElement("button",{className:"memory-eternal-btn",onClick:()=>v()},e("refresh")),t.default.createElement("button",{className:"memory-eternal-btn",onClick:()=>c(!0)},e("graph"))),t.default.createElement("div",{className:"memory-eternal-chips"},V.map(a=>t.default.createElement("button",{key:a,className:`memory-eternal-chip${u===a?" active":""}`,onClick:()=>$(a)},e(a==="all"?"all":P(a))))),y&&t.default.createElement("div",{className:"memory-eternal-empty"},`${e("error")}\uFF1A${y}`),h&&!n.length?t.default.createElement("div",{className:"memory-eternal-empty"},e("loading")):n.length===0?t.default.createElement("div",{className:"memory-eternal-empty"},e("empty")):t.default.createElement("div",{className:"memory-eternal-grid"},n.map(a=>Q(a,e,K))),b&&t.default.createElement(W,{t:e,card:b,onClose:()=>C(null)}),o&&t.default.createElement(X,{t:e,onClose:()=>c(!1),onOpen:K}))}var j=(e,r)=>t.default.createElement("div",{className:"memory-eternal-stat mc-card"},t.default.createElement("b",null,r),t.default.createElement("span",null,e)),Q=(e,r,l)=>t.default.createElement("article",{key:e.path,className:"memory-eternal-card mc-card",onClick:()=>l(e)},t.default.createElement("h4",null,t.default.createElement("span",{className:"memory-eternal-kind",style:{background:x[e.kind]||x.other}}),e.title),t.default.createElement("p",null,e.summary||""),e.tags.length>0&&t.default.createElement("div",{className:"memory-eternal-tags"},e.tags.slice(0,4).map(n=>t.default.createElement("span",{key:n,className:"memory-eternal-tag"},n))),t.default.createElement("footer",null,t.default.createElement("span",null,r(P(e.kind))),t.default.createElement("span",null,te(e.updated))));function W({t:e,card:r,onClose:l}){return t.default.createElement("div",{className:"memory-eternal-overlay",onClick:l},t.default.createElement("div",{className:"memory-eternal-dialog",onClick:n=>n.stopPropagation()},t.default.createElement("div",{className:"memory-eternal-dialog-head"},t.default.createElement("h3",null,r.title),t.default.createElement("button",{className:"memory-eternal-btn",onClick:l},e("close"))),t.default.createElement("div",{className:"memory-eternal-dialog-body"},r.text)))}function X({t:e,onClose:r,onOpen:l}){let[n,d]=(0,t.useState)(null),[h,f]=(0,t.useState)("");return(0,t.useEffect)(()=>{let y=!0;return fetch(`${E}/graph`).then(i=>i.json()).then(i=>{y&&d(i.ok?i:null)}).catch(i=>{y&&f(String(i&&i.message?i.message:i))}),()=>{y=!1}},[]),t.default.createElement("div",{className:"memory-eternal-overlay",onClick:r},t.default.createElement("div",{className:"memory-eternal-dialog",onClick:y=>y.stopPropagation()},t.default.createElement("div",{className:"memory-eternal-dialog-head"},t.default.createElement("h3",null,`${e("graph")}\uFF08${n?n.nodes.length:0} ${e("nodes")} \xB7 ${n?Y(n.edges):0} ${e("edges")}\uFF09`),t.default.createElement("button",{className:"memory-eternal-btn",onClick:r},e("close"))),t.default.createElement("div",{className:"memory-eternal-dialog-body"},h&&t.default.createElement("div",{className:"memory-eternal-empty"},`${e("error")}\uFF1A${h}`),!n&&!h&&t.default.createElement("div",{className:"memory-eternal-empty"},e("loading")),n&&n.nodes.length===0&&t.default.createElement("div",{className:"memory-eternal-empty"},e("empty")),n&&n.nodes.length>0&&t.default.createElement(ee,{nodes:n.nodes,edges:n.edges,onOpen:l,t:e}))))}function Y(e){let r=new Set;for(let l of e||[])r.add([l.source,l.target].sort().join("|"));return r.size}function ee({nodes:e,edges:r,onOpen:l,t:n}){let i=Math.min(660,380)/2-46,u={};e.forEach((o,c)=>{(u[o.kind]=u[o.kind]||[]).push(c)});let z=Object.keys(u),p=new Array(e.length),N=-Math.PI/2;for(let o of z){let c=u[o],s=c.length/e.length*Math.PI*2,k=N;c.forEach((v,D)=>{let $=c.length===1?k+s/2:k+s*(D+.5)/c.length;p[v]={x:330+i*Math.cos($),y:190+i*Math.sin($)}}),N+=s}let b=new Set,C=(r||[]).filter(o=>{let c=[o.source,o.target].sort().join("|");return b.has(c)?!1:(b.add(c),!0)});return t.default.createElement("div",null,t.default.createElement("div",{className:"memory-eternal-graph-wrap"},t.default.createElement("svg",{viewBox:"0 0 660 380",role:"img"},C.map((o,c)=>t.default.createElement("line",{key:`e${c}`,className:"memory-eternal-graph-line",x1:p[e.findIndex(s=>s.id===o.source)]?.x,y1:p[e.findIndex(s=>s.id===o.source)]?.y,x2:p[e.findIndex(s=>s.id===o.target)]?.x,y2:p[e.findIndex(s=>s.id===o.target)]?.y})),e.map((o,c)=>{let s=p[c];return s?t.default.createElement("g",{key:o.id,className:"memory-eternal-graph-node",transform:`translate(${s.x} ${s.y})`,onClick:()=>l({path:o.id,title:o.title})},t.default.createElement("circle",{r:o.kind==="project"?9:7,fill:x[o.kind]||x.other}),t.default.createElement("text",{className:"memory-eternal-graph-label",x:12,y:3},String(o.title||"").slice(0,14))):null}))),t.default.createElement("div",{className:"memory-eternal-graph-legend"},Object.keys(x).map(o=>t.default.createElement("span",{key:o},t.default.createElement("span",{className:"memory-eternal-kind",style:{background:x[o]}}),n(P(o)))),t.default.createElement("span",{style:{opacity:.6,marginLeft:6}},n("graphHint"))))}var P=e=>({project:"kindProject",knowledge:"kindKnowledge",content:"kindContent",prompt:"kindPrompt",business:"kindBusiness",other:"kindKnowledge"})[e]||"kindKnowledge";function te(e){if(!e)return"";let r=new Date(e);if(Number.isNaN(r.getTime()))return"";let l=new Date,n=r.toDateString()===l.toDateString(),d=f=>String(f).padStart(2,"0"),h=`${d(r.getHours())}:${d(r.getMinutes())}`;return n?h:`${r.getMonth()+1}-${r.getDate()} ${h}`}
|
|
44
|
+
|
|
45
|
+
return module.exports;
|
|
46
|
+
},
|
|
47
|
+
});
|