pi-okf-memory 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.
@@ -0,0 +1,52 @@
1
+ /**
2
+ * capture.ts — 记忆纪律(系统提示片段)与捕获评估。
3
+ * 神经自我学习驱动:捕获由"预测误差"驱动 —— 用户纠正、首次披露、反直觉结论
4
+ * 都是模型预测失败的信号,内在动机触发写入;取代固定规则表。
5
+ */
6
+
7
+ /** 注入系统提示的"记忆纪律"片段(Agent 自主决定何时调用 okf_remember) */
8
+ export const MEMORY_DISCIPLINE: string = `
9
+ # 记忆纪律(okf-memory)
10
+
11
+ 你有一个 OKF 长期记忆库。记忆不靠自动记录,而靠你判断"本轮是否产生了值得沉淀的新知识"。
12
+
13
+ ## 值得记忆(高价值信号,写!)
14
+ - 用户给出新背景事实 / 偏好 / 习惯(首次披露)
15
+ - 达成的决策及理由(尤其用户明确拍板的事项)
16
+ - 可复用方法论 / 流程 / 经验教训
17
+ - 用户纠正了你的理解(这是最强的"预测误差"信号)
18
+ - 反直觉但被用户确认的结论
19
+ - 技术选型:用户提到前端/后端/语言/方案/配置的选择、切换、配置细节
20
+
21
+ ## 不值得记忆(不写)
22
+ - 寒暄、过程性问答、单轮临时任务
23
+ - 已记忆内容的重复表述(先搜再写,命中则跳过)
24
+ - 未验证的猜测、尚未落地的设想(可归入 Idea 类型等待成熟)
25
+
26
+ ## 写入纪律
27
+ 1. 写入前必先 okf_search:命中同标题 → 不新建,更新或跳过;命中相近 → 互补合并或互建交叉链接
28
+ 2. 每篇必须:type 非空、title 简洁、description 一句话摘要(检索全靠它)、正文结构化(标题/列表/表格)
29
+ 3. type 词表:Fact 背景事实 / Preference 偏好 / Decision 决策 / Method 方法论 / Insight 洞察 / Idea 灵感 / Lesson 教训 / TechChoice 技术选型
30
+ 4. 正文模板:Decision/Insight 用 # 数据 → # 分析 → # 结论 三段式;TechChoice 用 # Options 候选表 + # Active 当前使用;其余自由结构化
31
+ 5. **交叉链接必须显式传 related 参数**:新建概念时,把 okf_search 命中的相关概念 ID 填进
32
+ related(如 related: ["fact/门店布局"])。工具会自动双向建立「## 相关」链接。
33
+ - 正文里手写路径 **不会** 生成图谱边 —— 图谱的边只来自 related 写入的链接。
34
+ - 一次会话里新建多条互相相关的概念时,后建的必须把先建的 ID 放进 related。
35
+ - 若 okf_remember 返回 status="linked",说明命中相近概念,用返回的 similarTo 作 related 重新提交(不要重复写正文)。
36
+
37
+ ## 技术选型三档规则(用户既定协议,硬性执行)
38
+ 1. 命中 2+ 候选 → 全部展示给用户选择(带说明/配置/状态),绝不擅自决定
39
+ 2. 命中 1 个候选 → 直接使用
40
+ 3. 用户未指定技术,但消息命中维度关键词(如"前端")→ 按该维度记忆处理
41
+ 4. 用户说出新技术/切换/配置 → okf_remember 追加式更新,不覆盖旧候选
42
+ 5. 用户拍板后,记录选择反馈(权重自动更新,下次排序靠前)
43
+
44
+ ## 唤起纪律
45
+ - 会话开场先 okf_search 预取相关记忆,再开始干活
46
+ - 检索不到 → 明确告知"记忆库没有",不要编造
47
+ `.trim()
48
+
49
+ /** 预取提示:会话启动时建议先搜什么(低优先级,供摘要注入用) */
50
+ export const RECALL_GUIDE: string = `
51
+ 记忆库概念清单见本片段顶部(okf-memory index)。需要细节时用 okf_read <concept_id>。
52
+ `.trim()
@@ -0,0 +1,302 @@
1
+ /**
2
+ * concept.ts — OKF v0.1 概念化:frontmatter 组装、正文模板、概念 ID 规范化、frontmatter 解析。
3
+ * 依据 OKF v0.1:frontmatter 唯一硬要求是 type;title/description 强烈建议;tags/timestamp 可选;扩展字段允许。
4
+ */
5
+
6
+ /** 类型词表(起步版) */
7
+ export const TYPE_VOCAB: readonly string[] = [
8
+ 'Fact', 'Preference', 'Decision', 'Method',
9
+ 'Insight', 'Idea', 'Lesson', 'TechChoice',
10
+ ]
11
+
12
+ /** 概念 frontmatter 元数据(OKF v0.1) */
13
+ export interface ConceptMeta {
14
+ type: string
15
+ title?: string
16
+ description?: string
17
+ resource?: string
18
+ tags?: string[]
19
+ timestamp?: string
20
+ source?: string
21
+ [key: string]: unknown
22
+ }
23
+
24
+ /** 解析 frontmatter 的结果 */
25
+ export interface ParsedFrontmatter {
26
+ meta: ConceptMeta | null
27
+ body: string
28
+ }
29
+
30
+ /** 归一化并校验概念类型(大小写不敏感,必须属于 TYPE_VOCAB);非法时抛错 */
31
+ export function normalizeType(type: unknown): string {
32
+ const t = String(type || '').trim()
33
+ if (!t) throw new Error('OKF 概念 type 必填')
34
+ const hit = TYPE_VOCAB.find((v) => v.toLowerCase() === t.toLowerCase())
35
+ if (!hit) throw new Error(`非法 type:「${type}」;可选:${TYPE_VOCAB.join('/')}`)
36
+ return hit
37
+ }
38
+
39
+ /** 规范化概念 ID:保留中文/字母数字,其余转连字符(Windows 安全字符集) */
40
+ export function slugify(input: unknown): string {
41
+ return String(input ?? '')
42
+ .trim()
43
+ .toLowerCase()
44
+ .replace(/[\s_]+/g, '-')
45
+ .replace(/[^\p{L}\p{N}\-]/gu, '')
46
+ .replace(/-+/g, '-')
47
+ .replace(/^-|-$/g, '')
48
+ }
49
+
50
+ /** YAML 标量转义 */
51
+ function yamlScalar(v: unknown): string {
52
+ if (typeof v === 'string') {
53
+ if (/^[\p{L}\p{N}\s.,\-_/:()()%¥¥+*#@!?'"=<>\[\]{}|&^~`\\;]*$/u.test(v) && !/^[\s\-?:]/.test(v) && !v.includes(': ')) {
54
+ return v
55
+ }
56
+ return JSON.stringify(v)
57
+ }
58
+ if (typeof v === 'number' || typeof v === 'boolean') return String(v)
59
+ return JSON.stringify(v)
60
+ }
61
+
62
+ function yamlTags(tags: unknown): string {
63
+ if (!Array.isArray(tags) || tags.length === 0) return 'tags: []'
64
+ const items = tags.map((t) => {
65
+ const s = String(t)
66
+ // 含逗号/引号/方括号的 tag 必须 JSON 转义,否则 flow 数组解析会被拆坏
67
+ return /[,()[\]{}"'#:]/.test(s) ? JSON.stringify(s) : yamlScalar(s)
68
+ }).join(', ')
69
+ return `tags: [${items}]`
70
+ }
71
+
72
+ /** 生成 frontmatter(固定顺序,稳定可比较) */
73
+ export function buildFrontmatter(meta: ConceptMeta): string {
74
+ const lines = ['---']
75
+ const order = ['type', 'title', 'description', 'resource', 'tags', 'timestamp', 'source']
76
+ for (const key of order) {
77
+ const v = meta[key]
78
+ if (v === undefined || v === null || v === '') continue
79
+ if (key === 'tags') {
80
+ lines.push(yamlTags(v))
81
+ } else {
82
+ lines.push(`${key}: ${yamlScalar(v)}`)
83
+ }
84
+ }
85
+ // 生产者扩展字段(OKF 允许,消费者须保留)
86
+ for (const [k, v] of Object.entries(meta)) {
87
+ if (order.includes(k)) continue
88
+ lines.push(`${k}: ${yamlScalar(v)}`)
89
+ }
90
+ lines.push('---')
91
+ return lines.join('\n')
92
+ }
93
+
94
+ /**
95
+ * 生成概念文档。
96
+ */
97
+ export function buildConcept(meta: ConceptMeta, body: string): string {
98
+ const type = String(meta.type || '').trim()
99
+ if (!type) throw new Error('OKF 概念必须包含非空 type')
100
+ const fm = buildFrontmatter(meta)
101
+ const b = String(body || '').trim()
102
+ return b ? `${fm}\n\n${b}\n` : `${fm}\n`
103
+ }
104
+
105
+ /**
106
+ * 决策/结论三段式模板(继承用户约定:数据/分析/结论)。
107
+ */
108
+ export function buildDecisionBody(parts: { data?: string; analysis?: string; conclusion: string }): string {
109
+ const { data, analysis, conclusion } = parts || {}
110
+ const out: string[] = []
111
+ if (data) out.push(`# 数据\n\n${data.trim()}`)
112
+ if (analysis) out.push(`# 分析\n\n${analysis.trim()}`)
113
+ if (conclusion) out.push(`# 结论\n\n${conclusion.trim()}`)
114
+ if (out.length === 0) throw new Error('Decision/Insight 正文需至少包含 conclusion')
115
+ return out.join('\n\n')
116
+ }
117
+
118
+ /**
119
+ * 技术选型正文模板(TechChoice):Options 候选表 + Active 当前使用。
120
+ */
121
+ export function buildTechChoiceBody(spec: { title?: string; options: Array<{ name: string; desc?: string; config?: string; status?: string }>; active?: string; notes?: string }): string {
122
+ const opts = spec.options || []
123
+ if (opts.length === 0) throw new Error('TechChoice 至少需要一个候选')
124
+ const rows = opts
125
+ .map((o) => `| ${yamlScalar(o.name)} | ${yamlScalar(o.desc || '')} | ${yamlScalar(o.config || '')} | ${yamlScalar(o.status || 'candidate')} |`)
126
+ .join('\n')
127
+ const out: string[] = []
128
+ out.push(`## Options\n\n| 候选 | 说明 | 配置要点 | 状态 |\n|---|---|---|---|\n${rows}`)
129
+ if (spec.active) out.push(`## Active\n\n- 当前使用:${spec.active}`)
130
+ if (spec.notes) out.push(`## 相关\n\n${spec.notes.trim()}`)
131
+ return out.join('\n\n')
132
+ }
133
+
134
+ /** 解析 frontmatter(容错:解析失败返回 {meta:null, body:原文};支持引号/多行块/flow 数组) */
135
+ export function parseFrontmatter(md: unknown): ParsedFrontmatter {
136
+ const text = String(md || '')
137
+ const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(text)
138
+ if (!m) return { meta: null, body: text }
139
+ const [, yaml, body] = m
140
+ const meta: Record<string, unknown> = {}
141
+ const lines = yaml.split(/\r?\n/)
142
+ let i = 0
143
+ while (i < lines.length) {
144
+ const line = lines[i]
145
+ const trimmed = line.trim()
146
+ if (!trimmed || trimmed.startsWith('#')) { i++; continue }
147
+ const idx = line.indexOf(':')
148
+ if (idx <= 0) { i++; continue }
149
+ const key = line.slice(0, idx).trim().replace(/^['"]|['"]$/g, '')
150
+ let val = line.slice(idx + 1).trim()
151
+ // 多行块值(| 字面 / > 折叠)
152
+ if (val === '|' || val === '>' || val === '|-' || val === '>-') {
153
+ const block: string[] = []
154
+ i++
155
+ while (i < lines.length && /^\s+/.test(lines[i])) {
156
+ block.push(lines[i].replace(/^[ \t]+/, ''))
157
+ i++
158
+ }
159
+ meta[key] = block.join('\n')
160
+ continue
161
+ }
162
+ // flow 数组:尊重引号内逗号
163
+ if (val.startsWith('[') && val.endsWith(']')) {
164
+ meta[key] = splitFlowArray(val.slice(1, -1))
165
+ i++
166
+ continue
167
+ }
168
+ meta[key] = unquoteScalar(val)
169
+ i++
170
+ }
171
+ return { meta: meta as ConceptMeta, body: body || '' }
172
+ }
173
+
174
+ /** flow 数组拆分:引号内的逗号不拆,去引号/转义(闭合引号消费但不进内容;反斜杠转义下一个字符) */
175
+ function splitFlowArray(s: string): string[] {
176
+ const out: string[] = []
177
+ let cur = ''
178
+ let q: string | null = null
179
+ let esc = false
180
+ for (const ch of s) {
181
+ if (esc) {
182
+ // 转义:只保留下一字符本身(丢反斜杠),\"→"、\\→\
183
+ cur += ch
184
+ esc = false
185
+ } else if (ch === '\\') {
186
+ esc = true
187
+ } else if (q) {
188
+ if (ch === q) {
189
+ q = null // 闭合引号:消费,不进内容
190
+ } else {
191
+ cur += ch
192
+ }
193
+ } else if (ch === '"' || ch === "'") {
194
+ q = ch
195
+ } else if (ch === ',') {
196
+ if (cur.trim()) out.push(unquoteScalar(cur))
197
+ cur = ''
198
+ } else {
199
+ cur += ch
200
+ }
201
+ }
202
+ if (cur.trim()) out.push(unquoteScalar(cur))
203
+ return out.filter(Boolean)
204
+ }
205
+
206
+ /** 去掉标量两端匹配的引号并还原常见转义 */
207
+ function unquoteScalar(v: string): string {
208
+ const s = String(v || '').trim()
209
+ if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
210
+ return s.slice(1, -1).replace(/\\"/g, '"').replace(/\\'/g, "'").replace(/\\n/g, '\n')
211
+ }
212
+ return s
213
+ }
214
+
215
+ export interface ValidationResult {
216
+ ok: boolean
217
+ errors: string[]
218
+ warnings: string[]
219
+ }
220
+
221
+ /**
222
+ * OKF v0.1 符合性校验(三条硬要求 + 建议字段)。
223
+ */
224
+ export function validateConcept(md: string): ValidationResult {
225
+ const errors: string[] = []
226
+ const warnings: string[] = []
227
+ const { meta } = parseFrontmatter(md)
228
+ // 硬要求 1:可解析 YAML 头信息
229
+ if (!meta) {
230
+ errors.push('缺少可解析的 YAML frontmatter')
231
+ return { ok: false, errors, warnings }
232
+ }
233
+ // 硬要求 2:type 非空
234
+ const type = String(meta.type || '').trim()
235
+ if (!type) errors.push('type 字段为空')
236
+ // 建议字段
237
+ if (!meta.title) warnings.push('缺 title(将用文件名推导)')
238
+ if (!meta.description) warnings.push('缺 description(索引/搜索靠它)')
239
+ if (!meta.timestamp) warnings.push('缺 timestamp')
240
+ return { ok: errors.length === 0, errors, warnings }
241
+ }
242
+
243
+ interface Section {
244
+ key: string | null
245
+ content: string
246
+ }
247
+
248
+ /**
249
+ * 按顶层小节合并两份概念正文(防止更新时无限追加 "## 补充(日期)"):
250
+ * - 相同小节标题(如 # 数据 / ## Options)→ 新内容覆盖旧小节,保留原位置
251
+ * - 新小节 → 追加到末尾
252
+ * - 无标题引言 → 仅当旧文没有引言时才补入
253
+ */
254
+ export function mergeConceptBodies(existing: string, incoming: string): string {
255
+ const ex = splitSections(existing)
256
+ const inc = splitSections(incoming)
257
+ const byKey = new Map<string | null, Section>(ex.map((s) => [s.key, s]))
258
+ for (const s of inc) {
259
+ if (s.key === null) {
260
+ if (!byKey.has(null)) byKey.set(null, s)
261
+ } else {
262
+ byKey.set(s.key, s)
263
+ }
264
+ }
265
+ const out: Section[] = []
266
+ const used = new Set<string | null>()
267
+ for (const s of ex) {
268
+ const hit = byKey.get(s.key)
269
+ if (hit) { out.push(hit); used.add(s.key) }
270
+ }
271
+ for (const s of inc) {
272
+ if (!used.has(s.key)) { out.push(s); used.add(s.key) }
273
+ }
274
+ return out.map(renderSection).filter(Boolean).join('\n\n')
275
+ }
276
+
277
+ /** 按 #/##/### 顶层标题切分成小节(含无标题引言小节 key=null) */
278
+ function splitSections(body: unknown): Section[] {
279
+ const sections: Section[] = []
280
+ let cur: Section | null = null
281
+ for (const line of String(body || '').split('\n')) {
282
+ const m = /^(#{1,3})\s+(.*)$/.exec(line)
283
+ if (m) {
284
+ cur = { key: `${m[1]} ${m[2]}`.replace(/\s+/g, ' ').trim(), content: '' }
285
+ sections.push(cur)
286
+ } else {
287
+ if (!cur) {
288
+ // 无标题引言小节:必须把首行一并收进 content,否则引言内容丢失
289
+ cur = { key: null, content: '' }
290
+ sections.push(cur)
291
+ }
292
+ cur.content += line + '\n'
293
+ }
294
+ }
295
+ return sections
296
+ }
297
+
298
+ function renderSection(s: Section): string {
299
+ if (s.key === null) return s.content.trim()
300
+ const body = s.content.trim()
301
+ return body ? `${s.key}\n\n${body}` : s.key
302
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * dedupe.ts — 去重与互补决策(对应"互补而非复制"原则)。
3
+ * 写前必查:命中且相同 → skip;命中但互补 → merge 建议 + 交叉链接;未命中 → create。
4
+ */
5
+ import { promises as fs } from 'node:fs'
6
+ import { parseFrontmatter, type ConceptMeta } from './concept.js'
7
+ import { scanBundle, readConcept, type BundleEntry } from './store.js'
8
+
9
+ /**
10
+ * 词项化:空白分词 + CJK 二元组。
11
+ * 纯子串匹配对中文短语(如「查询数据库」)几乎必然落空,而库中概念标题/描述往往写成
12
+ * 「查询鼎赞…数据…」。补重叠二元组后,「查询」「数据」等子片段也能命中,提升中文召回。
13
+ * 保留原始整串词项以维持精确短语加分。
14
+ */
15
+ function tokenizeQuery(q: string): string[] {
16
+ const terms = new Set<string>()
17
+ const raw = String(q || '').split(/\s+/).filter(Boolean)
18
+ for (const token of raw) {
19
+ terms.add(token)
20
+ // 含 CJK 且长度>=3 时,补相邻 2 字符二元组
21
+ if (/[\u4e00-\u9fff]/.test(token) && token.length >= 3) {
22
+ for (let i = 0; i < token.length - 1; i++) terms.add(token.slice(i, i + 2))
23
+ }
24
+ }
25
+ return [...terms]
26
+ }
27
+
28
+ export interface SearchOptions {
29
+ type?: string
30
+ tags?: string[]
31
+ limit?: number
32
+ }
33
+
34
+ export interface SearchHit {
35
+ conceptId: string
36
+ title: string
37
+ description: string
38
+ type: string
39
+ tags: string[]
40
+ score: number
41
+ }
42
+
43
+ /**
44
+ * 全库检索:按关键词匹配 title/description/tags/type(正文做二级加分)。
45
+ */
46
+ export async function search(root: string, query: string, opts: SearchOptions = {}): Promise<SearchHit[]> {
47
+ const { type, tags, limit = 20 } = opts
48
+ const q = String(query || '').trim().toLowerCase()
49
+ const qTerms = tokenizeQuery(q)
50
+ const concepts = await scanBundle(root)
51
+ const hits: SearchHit[] = []
52
+ for (const c of concepts) {
53
+ let text: string
54
+ try {
55
+ text = await fs.readFile(c.filePath, 'utf8')
56
+ } catch {
57
+ continue
58
+ }
59
+ const { meta } = parseFrontmatter(text)
60
+ if (!meta || !meta.type) continue
61
+ if (type && String(meta.type).toLowerCase() !== String(type).toLowerCase()) continue
62
+ if (tags && tags.length > 0) {
63
+ const mt = Array.isArray(meta.tags) ? (meta.tags as string[]).map(String) : []
64
+ if (!tags.every((t) => mt.some((x) => x.toLowerCase().includes(String(t).toLowerCase())))) continue
65
+ }
66
+ let score = 0
67
+ if (qTerms.length > 0) {
68
+ const hay = [meta.title, meta.description, Array.isArray(meta.tags) ? (meta.tags as string[]).join(' ') : '', meta.type]
69
+ .filter(Boolean)
70
+ .join(' ').toLowerCase()
71
+ let matched = 0
72
+ for (const t of qTerms) {
73
+ if (hay.includes(t)) matched++
74
+ else if (text.toLowerCase().includes(t)) { score += 0.3; matched++ }
75
+ }
76
+ if (matched === 0) continue
77
+ score += (matched / qTerms.length) * 2
78
+ if (hay.includes(q)) score += 3 // 完整短语命中加分
79
+ }
80
+ score += (Array.isArray(meta.tags) ? (meta.tags as string[]).length : 0) * 0.1
81
+ hits.push({
82
+ conceptId: c.conceptId,
83
+ title: meta.title || c.conceptId,
84
+ description: meta.description || '',
85
+ type: meta.type,
86
+ tags: Array.isArray(meta.tags) ? (meta.tags as string[]) : [],
87
+ score,
88
+ })
89
+ }
90
+ hits.sort((a, b) => b.score - a.score)
91
+ return hits.slice(0, limit)
92
+ }
93
+
94
+ export interface SimilarHit {
95
+ conceptId: string
96
+ title: string
97
+ type: string
98
+ similarity: number
99
+ }
100
+
101
+ /**
102
+ * 按标题找相似概念(去重主查:精确相等或互相包含)。
103
+ * 包含判定要求较短一方 ≥ MIN_CONTAIN_LEN(3 字符),否则「前端」这种短词
104
+ * 会误伤「前端方案」,挡住正常新建。
105
+ */
106
+ const MIN_CONTAIN_LEN = 3
107
+ export async function findSimilarByTitle(root: string, title: string, type?: string): Promise<SimilarHit[]> {
108
+ const t = String(title || '').trim().toLowerCase()
109
+ if (!t) return []
110
+ const concepts = await scanBundle(root)
111
+ const out: SimilarHit[] = []
112
+ for (const c of concepts) {
113
+ let text: string
114
+ try {
115
+ text = await fs.readFile(c.filePath, 'utf8')
116
+ } catch {
117
+ continue
118
+ }
119
+ const { meta } = parseFrontmatter(text)
120
+ if (!meta || !meta.title) continue
121
+ if (type && String(meta.type).toLowerCase() !== String(type).toLowerCase()) continue
122
+ const ct = String(meta.title).trim().toLowerCase()
123
+ if (ct === t) {
124
+ out.push({ conceptId: c.conceptId, title: meta.title, type: meta.type, similarity: 1 })
125
+ } else if ((ct.includes(t) || t.includes(ct)) && Math.min(ct.length, t.length) >= MIN_CONTAIN_LEN) {
126
+ out.push({ conceptId: c.conceptId, title: meta.title, type: meta.type, similarity: 0.6 })
127
+ }
128
+ }
129
+ return out.sort((a, b) => b.similarity - a.similarity)
130
+ }
131
+
132
+ export interface DecideInput {
133
+ title: string
134
+ type?: string
135
+ body: string
136
+ }
137
+
138
+ export interface DecideResult {
139
+ action: 'skip' | 'update' | 'create'
140
+ conceptId?: string
141
+ reason: string
142
+ }
143
+
144
+ /**
145
+ * 去重决策。
146
+ */
147
+ export async function decide(root: string, { title, type, body }: DecideInput): Promise<DecideResult> {
148
+ const similar = await findSimilarByTitle(root, title, type)
149
+ if (similar.length > 0) {
150
+ const top = similar[0]
151
+ if (top.similarity >= 1) {
152
+ // 标题完全相同:比较正文长度,内容被覆盖 → update,否则 skip 建议
153
+ const existing = await readConcept(root, top.conceptId)
154
+ const bodyLen = String(body || '').trim().length
155
+ const existingLen = String(existing.body || '').trim().length
156
+ if (bodyLen > existingLen * 0.7) {
157
+ return { action: 'update', conceptId: top.conceptId, reason: `标题相同且新正文更完整(${bodyLen}字 vs 已有${existingLen}字),更新已有概念` }
158
+ }
159
+ return { action: 'skip', conceptId: top.conceptId, reason: '标题相同的概念已存在,内容未明显增加,跳过写入' }
160
+ }
161
+ return { action: 'update', conceptId: top.conceptId, reason: `找到相近概念[${top.title}](similarity ${top.similarity}),建议互补合并或互建交叉链接` }
162
+ }
163
+ return { action: 'create', reason: '未命中已有概念,新建' }
164
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * 可选宿主依赖的环境声明。
3
+ *
4
+ * `@deepseek-ai/dsh-tools` 只在 dsh 运行时提供,本包不将其列为依赖
5
+ * (pi 侧根本用不到它)。src/server/index.ts 用动态 import + try/catch
6
+ * 做降级,因此这里声明一个宽松的模块形状即可,避免 tsc 报「找不到模块」。
7
+ */
8
+ declare module '@deepseek-ai/dsh-tools' {
9
+ /** 官方工具定义包装器(缺失时调用方会降级为透传原对象) */
10
+ export const defineTool: ((def: unknown) => unknown) | undefined
11
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * graph.ts — 记忆图谱数据提取:把 OKF 记忆库转成图谱 JSON(nodes/edges/timeline)。
3
+ * 供 okf_graph 工具/服务消费,契约与可视化前端一致,可被 dshfind 等复用。
4
+ * 纯业务逻辑,不依赖 dsh ctx,便于单测。
5
+ */
6
+ import { promises as fs } from 'node:fs'
7
+ import { scanBundle, type BundleEntry } from './store.js'
8
+ import { parseFrontmatter } from './concept.js'
9
+ import { loadMeta } from './learning.js'
10
+
11
+ export interface GraphMeta {
12
+ generatedAt: string
13
+ root: string
14
+ totalConcepts: number
15
+ }
16
+
17
+ export interface GraphNode {
18
+ id: string
19
+ title: string
20
+ type: string
21
+ tags: string[]
22
+ description: string
23
+ weight: number
24
+ state: string
25
+ lastAccessed: string | null
26
+ }
27
+
28
+ export interface GraphEdge {
29
+ source: string
30
+ target: string
31
+ text: string
32
+ }
33
+
34
+ export interface GraphTimelineEntry {
35
+ id: string
36
+ weight: number
37
+ state: string
38
+ lastAccessed: string | null
39
+ accessCount: number
40
+ }
41
+
42
+ export interface GraphData {
43
+ meta: GraphMeta
44
+ nodes: GraphNode[]
45
+ edges: GraphEdge[]
46
+ timeline: GraphTimelineEntry[]
47
+ }
48
+
49
+ export interface BuildGraphOptions {
50
+ /** 预留:后续可扩展筛选/上限 */
51
+ limit?: number
52
+ }
53
+
54
+ /**
55
+ * 提取记忆图谱数据。
56
+ */
57
+ export async function buildGraph(root: string, _opts: BuildGraphOptions = {}): Promise<GraphData> {
58
+ const concepts: BundleEntry[] = await scanBundle(root)
59
+ const weights = await loadMeta(root)
60
+ const meta: GraphMeta = {
61
+ generatedAt: new Date().toISOString(),
62
+ root,
63
+ totalConcepts: concepts.length,
64
+ }
65
+
66
+ const nodes: GraphNode[] = []
67
+ const byId = new Map<string, string>()
68
+ for (const c of concepts) {
69
+ let text: string
70
+ try {
71
+ text = await readText(c.filePath)
72
+ } catch {
73
+ continue
74
+ }
75
+ const { meta: fm } = parseFrontmatter(text)
76
+ if (!fm) continue
77
+ const w = weights.entries[c.conceptId]
78
+ nodes.push({
79
+ id: c.conceptId,
80
+ title: fm.title || c.conceptId.replace(/^[^/]+\//, ''),
81
+ type: fm.type || 'Other',
82
+ tags: Array.isArray(fm.tags) ? (fm.tags as string[]) : [],
83
+ description: fm.description || '',
84
+ weight: w ? +w.weight.toFixed(2) : 1.0,
85
+ state: w?.state || 'active',
86
+ lastAccessed: w?.lastAccessed || null,
87
+ })
88
+ byId.set(c.conceptId, c.conceptId)
89
+ }
90
+
91
+ // 边:交叉链接(用 recall 同款正则,不触发权重反馈)
92
+ const edges: GraphEdge[] = []
93
+ const seen = new Set<string>()
94
+ for (const c of concepts) {
95
+ let text: string
96
+ try {
97
+ text = await readText(c.filePath)
98
+ } catch {
99
+ continue
100
+ }
101
+ const { body } = parseFrontmatter(text)
102
+ const re = /\[([^\]]+)\]\(\/([^)]+\.md)\)/g
103
+ let m: RegExpExecArray | null
104
+ while ((m = re.exec(body || '')) !== null) {
105
+ let targetId = m[2].replace(/^\/+/, '').replace(/\.md$/, '')
106
+ if (!byId.has(targetId)) {
107
+ const k = Object.keys(byId).find((x) => x.toLowerCase() === targetId.toLowerCase())
108
+ if (k) targetId = k
109
+ }
110
+ if (targetId && targetId !== c.conceptId) {
111
+ const k = [c.conceptId, targetId].sort().join('||')
112
+ if (!seen.has(k)) {
113
+ seen.add(k)
114
+ edges.push({ source: c.conceptId, target: targetId, text: m[1] })
115
+ }
116
+ }
117
+ }
118
+ }
119
+
120
+ // 时间线(权重历史,供学习热力)
121
+ const timeline: GraphTimelineEntry[] = Object.entries(weights.entries || {}).map(([id, e]) => ({
122
+ id,
123
+ weight: +e.weight.toFixed(2),
124
+ state: e.state || 'active',
125
+ lastAccessed: e.lastAccessed || null,
126
+ accessCount: e.accessCount || 0,
127
+ }))
128
+
129
+ return { meta, nodes, edges, timeline }
130
+ }
131
+
132
+ /** 读文件 */
133
+ async function readText(filePath: string): Promise<string> {
134
+ return fs.readFile(filePath, 'utf8')
135
+ }