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.
- package/LICENSE +21 -0
- package/README.en.md +204 -0
- package/README.md +265 -0
- package/cordis.patch.yml +6 -0
- package/docs/graph-demo.png +0 -0
- package/lib/capture.js +53 -0
- package/lib/client.js +417 -0
- package/lib/client.js.map +1 -0
- package/lib/concept.js +264 -0
- package/lib/dedupe.js +149 -0
- package/lib/graph.js +95 -0
- package/lib/index.js +381 -0
- package/lib/learning.js +185 -0
- package/lib/memory.js +135 -0
- package/lib/recall.js +47 -0
- package/lib/store.js +217 -0
- package/package.json +89 -0
- package/src/client/index.tsx +232 -0
- package/src/pi/graph-html.ts +300 -0
- package/src/pi/index.ts +383 -0
- package/src/server/capture.ts +52 -0
- package/src/server/concept.ts +302 -0
- package/src/server/dedupe.ts +164 -0
- package/src/server/dsh-tools.d.ts +11 -0
- package/src/server/graph.ts +135 -0
- package/src/server/index.ts +376 -0
- package/src/server/learning.ts +219 -0
- package/src/server/memory.ts +157 -0
- package/src/server/recall.ts +58 -0
- package/src/server/store.ts +255 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* memory.ts — 运行时无关的记忆写入/撤回核心逻辑。
|
|
3
|
+
*
|
|
4
|
+
* 从 index.ts 抽出,供多运行时适配层共用:
|
|
5
|
+
* - dsh 适配层(src/server/index.ts)用它注册 okf_remember / okf_forget
|
|
6
|
+
* - pi 适配层(src/pi/index.ts)用它注册同名工具
|
|
7
|
+
* 本模块零 dsh / 零 pi 依赖,只有 node:fs 与包内核模块。
|
|
8
|
+
*/
|
|
9
|
+
import { promises as fs } from 'node:fs'
|
|
10
|
+
import path from 'node:path'
|
|
11
|
+
import {
|
|
12
|
+
writeConcept, readConcept, refreshIndex, appendLog, filePathOf, withLock, type WriteResult,
|
|
13
|
+
} from './store.js'
|
|
14
|
+
import { findSimilarByTitle } from './dedupe.js'
|
|
15
|
+
import { loadMeta, saveMeta } from './learning.js'
|
|
16
|
+
import { normalizeType, mergeConceptBodies, type ConceptMeta } from './concept.js'
|
|
17
|
+
|
|
18
|
+
export interface RememberArgs {
|
|
19
|
+
title: string
|
|
20
|
+
type: string
|
|
21
|
+
tags?: string[]
|
|
22
|
+
related?: string[]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface RememberOpts {
|
|
26
|
+
force?: boolean
|
|
27
|
+
description?: string
|
|
28
|
+
source?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface RememberResult {
|
|
32
|
+
status: string
|
|
33
|
+
conceptId: string | null
|
|
34
|
+
filePath?: string
|
|
35
|
+
reason?: string
|
|
36
|
+
similarTo?: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* remember 核心(服务与工具共用):类型校验 → 去重 → 小节级合并/新建 → 反馈。
|
|
41
|
+
* 全程持写锁,保证"判断→写入"原子,避免并发下同标题概念被重复创建。
|
|
42
|
+
*/
|
|
43
|
+
export async function rememberCore(root: string, meta: RememberArgs, body: string, opts: RememberOpts = {}): Promise<RememberResult> {
|
|
44
|
+
return withLock(async () => {
|
|
45
|
+
const { title, type, tags, related } = meta
|
|
46
|
+
if (!title || !body) throw new Error('title/content 必填')
|
|
47
|
+
// P0-4:类型归一化 + 词表校验(非法类型不得新建污染目录)
|
|
48
|
+
const normType = normalizeType(type)
|
|
49
|
+
|
|
50
|
+
// 去重
|
|
51
|
+
const similar = await findSimilarByTitle(root, title, normType)
|
|
52
|
+
if (similar.length > 0) {
|
|
53
|
+
const top = similar[0]
|
|
54
|
+
if (top.similarity >= 1) {
|
|
55
|
+
// 标题完全相同 → 更新(小节级合并)或跳过
|
|
56
|
+
const existing = await readConcept(root, top.conceptId)
|
|
57
|
+
const existingLen = String(existing.body || '').trim().length
|
|
58
|
+
const newLen = String(body || '').trim().length
|
|
59
|
+
if (newLen > existingLen * 0.7 && opts.force !== false) {
|
|
60
|
+
// P0-5:按 # 小节合并:同小节覆盖、新小节追加,不再无限 "## 补充(日期)"
|
|
61
|
+
const mergedBody = mergeConceptBodies(existing.body || '', body)
|
|
62
|
+
const res = await writeConcept(root, {
|
|
63
|
+
...existing.meta, title, type: normType, tags: tags || existing.meta?.tags, timestamp: new Date().toISOString(),
|
|
64
|
+
} as ConceptMeta, mergedBody)
|
|
65
|
+
return { status: 'updated', conceptId: res.conceptId, filePath: res.filePath, reason: '标题相同,按小节合并更新' }
|
|
66
|
+
}
|
|
67
|
+
return { status: 'skipped', conceptId: top.conceptId, reason: `标题相同的概念已存在(${existingLen}字),新内容(${newLen}字)未显著增加` }
|
|
68
|
+
}
|
|
69
|
+
// 相近 → 建议互补
|
|
70
|
+
return {
|
|
71
|
+
status: 'linked',
|
|
72
|
+
conceptId: top.conceptId,
|
|
73
|
+
reason: `存在相近概念[${top.title}](${top.conceptId}),已返回其 ID;建议新建后与该概念互建交叉链接,而非复制内容`,
|
|
74
|
+
similarTo: top.conceptId,
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 新建
|
|
79
|
+
const res = await writeConcept(root, {
|
|
80
|
+
type: normType,
|
|
81
|
+
title,
|
|
82
|
+
description: opts.description || String(body).split('\n').find((l) => l.trim().startsWith('>'))?.replace(/^>\s*/, '').trim() || firstLine(body),
|
|
83
|
+
tags: tags || [],
|
|
84
|
+
timestamp: new Date().toISOString(),
|
|
85
|
+
source: opts.source || 'session',
|
|
86
|
+
}, body)
|
|
87
|
+
// 互建交叉链接(related)
|
|
88
|
+
if (Array.isArray(related) && related.length > 0) {
|
|
89
|
+
for (const rid of related) {
|
|
90
|
+
try {
|
|
91
|
+
const r = await readConcept(root, String(rid).replace(/\.md$/, ''))
|
|
92
|
+
const linkLine = `\n\n## 相关\n\n- [${title}](/${res.conceptId}.md)`
|
|
93
|
+
if (!(r.body || '').includes(res.conceptId)) {
|
|
94
|
+
await writeConcept(root, { ...r.meta, timestamp: new Date().toISOString() } as ConceptMeta, `${r.body?.trim() || ''}${linkLine}`)
|
|
95
|
+
}
|
|
96
|
+
} catch { /* 相关概念不存在则忽略 */ }
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return { status: 'created', conceptId: res.conceptId, filePath: res.filePath, reason: '新建' }
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface ForgetResult {
|
|
104
|
+
status: string
|
|
105
|
+
conceptId: string
|
|
106
|
+
reason?: string
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* forget 核心:概念不存在返回 not_found;默认移到 .meta/forgotten/(保留目录结构防同名冲突),
|
|
111
|
+
* delete_file=true 时直接删除文件。全程持写锁。
|
|
112
|
+
*/
|
|
113
|
+
export async function forgetCore(root: string, id: string, deleteFile: boolean): Promise<ForgetResult> {
|
|
114
|
+
return withLock(async () => {
|
|
115
|
+
const filePath = filePathOf(root, id)
|
|
116
|
+
let exists = true
|
|
117
|
+
try {
|
|
118
|
+
await fs.access(filePath)
|
|
119
|
+
} catch {
|
|
120
|
+
exists = false
|
|
121
|
+
}
|
|
122
|
+
if (!exists) {
|
|
123
|
+
return { status: 'not_found', conceptId: id, reason: '概念不存在(可能已撤回)' }
|
|
124
|
+
}
|
|
125
|
+
if (deleteFile) {
|
|
126
|
+
await fs.rm(filePath, { force: true })
|
|
127
|
+
} else {
|
|
128
|
+
// 保留相对目录结构,避免同 slug 概念在 forgotten 里撞文件
|
|
129
|
+
const forgottenDir = path.join(root, '.meta', 'forgotten')
|
|
130
|
+
const dest = path.join(forgottenDir, path.relative(root, filePath))
|
|
131
|
+
await fs.mkdir(path.dirname(dest), { recursive: true })
|
|
132
|
+
await fs.rename(filePath, dest)
|
|
133
|
+
}
|
|
134
|
+
await refreshIndex(root)
|
|
135
|
+
await appendLog(root, {
|
|
136
|
+
action: deleteFile ? 'forgotten(deleted)' : 'forgotten',
|
|
137
|
+
conceptId: id,
|
|
138
|
+
type: '—',
|
|
139
|
+
title: deleteFile ? '已删除文件' : '已移至 .meta/forgotten/',
|
|
140
|
+
})
|
|
141
|
+
// 学习元数据:标记 inactive(可复活)
|
|
142
|
+
const meta = await loadMeta(root)
|
|
143
|
+
if (meta.entries[id]) {
|
|
144
|
+
meta.entries[id].state = 'inactive'
|
|
145
|
+
await saveMeta(root, meta)
|
|
146
|
+
}
|
|
147
|
+
return { status: 'forgotten', conceptId: id, reason: deleteFile ? '已删除文件' : '已移至 .meta/forgotten/' }
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** 取正文首个非标题行,作为缺省 description */
|
|
152
|
+
export function firstLine(s: string): string {
|
|
153
|
+
const line = String(s || '').split('\n').map((l) => l.trim()).find((l) => l && !l.startsWith('#'))
|
|
154
|
+
return line ? line.slice(0, 120) : ''
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export type { WriteResult }
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* recall.ts — 预测性唤起:摘要层粗筛 → 细节层校验 → 反馈回路。
|
|
3
|
+
* 对应"预测加工":先预测需要什么记忆,再检索验证,命中质量写回权重。
|
|
4
|
+
*/
|
|
5
|
+
import { search, type SearchOptions, type SearchHit } from './dedupe.js'
|
|
6
|
+
import { readConcept, type ConceptRef } from './store.js'
|
|
7
|
+
import { rank, recordHit } from './learning.js'
|
|
8
|
+
|
|
9
|
+
export interface PreloadOptions extends SearchOptions {
|
|
10
|
+
limit?: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface PreloadHit {
|
|
14
|
+
conceptId: string
|
|
15
|
+
title: string
|
|
16
|
+
description: string
|
|
17
|
+
type: string
|
|
18
|
+
tags: string[]
|
|
19
|
+
weight: number
|
|
20
|
+
state: string
|
|
21
|
+
score: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 预测性预取:给定查询,返回按唤起评分排序的记忆摘要。
|
|
26
|
+
*/
|
|
27
|
+
export async function preload(root: string, query: string, opts: PreloadOptions = {}): Promise<PreloadHit[]> {
|
|
28
|
+
const raw = await search(root, query, { ...opts, limit: (opts.limit || 8) * 3 })
|
|
29
|
+
const ranked = await rank(root, raw)
|
|
30
|
+
return ranked.slice(0, opts.limit || 8).map(({ conceptId, title, description, type, tags, weight, state, score }) => ({
|
|
31
|
+
conceptId, title, description, type, tags, weight, state, score,
|
|
32
|
+
}))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ConceptLink {
|
|
36
|
+
text: string
|
|
37
|
+
conceptId: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface RecalledConcept extends ConceptRef {
|
|
41
|
+
links: ConceptLink[]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 精读:读全文 + 提取交叉链接,并记录命中反馈。
|
|
46
|
+
*/
|
|
47
|
+
export async function recall(root: string, conceptId: string): Promise<RecalledConcept> {
|
|
48
|
+
const concept = await readConcept(root, conceptId)
|
|
49
|
+
// 提取交叉链接 [text](/path/to/x.md)
|
|
50
|
+
const links: ConceptLink[] = []
|
|
51
|
+
const re = /\[([^\]]+)\]\(\/([^)]+\.md)\)/g
|
|
52
|
+
let m: RegExpExecArray | null
|
|
53
|
+
while ((m = re.exec(concept.body || '')) !== null) {
|
|
54
|
+
links.push({ text: m[1], conceptId: m[2].replace(/\.md$/, '') })
|
|
55
|
+
}
|
|
56
|
+
await recordHit(root, conceptId)
|
|
57
|
+
return { ...concept, links }
|
|
58
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* store.ts — OKF bundle 存储:根目录初始化、概念落盘、index.md 渐进式目录、log.md 变更历史。
|
|
3
|
+
* 路径即概念 ID:<root>/<type小写>/<kebab-id>.md
|
|
4
|
+
*/
|
|
5
|
+
import { promises as fs } from 'node:fs'
|
|
6
|
+
import path from 'node:path'
|
|
7
|
+
import os from 'node:os'
|
|
8
|
+
import { parseFrontmatter, validateConcept, slugify, type ConceptMeta } from './concept.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 进程内写锁(可重入):串行化对 bundle 文件(index.md/log.md/概念/weights.json)的读-改-写,
|
|
12
|
+
* 避免多会话并发写入时丢失更新。Cordis 单进程内生效,跨进程不保证。
|
|
13
|
+
*/
|
|
14
|
+
let writeQueue: Promise<void> = Promise.resolve()
|
|
15
|
+
let lockDepth = 0
|
|
16
|
+
export function withLock<T>(fn: () => Promise<T>): Promise<T> {
|
|
17
|
+
// 已在锁内(可重入):直接执行,避免嵌套自锁死锁
|
|
18
|
+
if (lockDepth > 0) return Promise.resolve().then(fn)
|
|
19
|
+
const run = writeQueue.then(async () => {
|
|
20
|
+
lockDepth = 1
|
|
21
|
+
try {
|
|
22
|
+
return await fn()
|
|
23
|
+
} finally {
|
|
24
|
+
lockDepth = 0
|
|
25
|
+
}
|
|
26
|
+
})
|
|
27
|
+
writeQueue = run.then(() => {}, () => {})
|
|
28
|
+
return run
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 默认记忆库根目录(可被 OKF_MEMORY_ROOT 环境变量覆盖) */
|
|
32
|
+
export function defaultRoot(): string {
|
|
33
|
+
return process.env.OKF_MEMORY_ROOT || path.join(os.homedir(), '.dsh', 'memory')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 概念文件路径 → 概念 ID(相对根,去扩展名,正斜杠归一) */
|
|
37
|
+
export function conceptIdOf(filePath: string, root: string): string {
|
|
38
|
+
const rel = path.relative(root, filePath).replace(/\\/g, '/').replace(/\.md$/, '')
|
|
39
|
+
return rel
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 概念 ID → 文件路径(含安全校验:防路径穿越) */
|
|
43
|
+
export function filePathOf(root: string, conceptId: string): string {
|
|
44
|
+
const norm = String(conceptId || '').replace(/\\/g, '/').replace(/^\/+/, '')
|
|
45
|
+
const resolved = path.resolve(root, ...norm.split('/'))
|
|
46
|
+
const rootResolved = path.resolve(root)
|
|
47
|
+
if (!resolved.startsWith(rootResolved + path.sep) && resolved !== rootResolved) {
|
|
48
|
+
throw new Error(`非法 concept_id(路径穿越):${conceptId}`)
|
|
49
|
+
}
|
|
50
|
+
return resolved.endsWith('.md') ? resolved : `${resolved}.md`
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 确保记忆库骨架存在(根 index.md + log.md) */
|
|
54
|
+
export async function ensureRoot(root: string): Promise<void> {
|
|
55
|
+
await fs.mkdir(root, { recursive: true })
|
|
56
|
+
const indexPath = path.join(root, 'index.md')
|
|
57
|
+
const logPath = path.join(root, 'log.md')
|
|
58
|
+
try {
|
|
59
|
+
await fs.access(indexPath)
|
|
60
|
+
} catch {
|
|
61
|
+
await fs.writeFile(
|
|
62
|
+
indexPath,
|
|
63
|
+
[
|
|
64
|
+
'---',
|
|
65
|
+
'type: Bundle Root',
|
|
66
|
+
'title: OKF 记忆库',
|
|
67
|
+
'description: 会话记忆沉淀库(OKF v0.1)',
|
|
68
|
+
'okf_version: "0.1"',
|
|
69
|
+
'---',
|
|
70
|
+
'',
|
|
71
|
+
'# OKF 记忆库',
|
|
72
|
+
'',
|
|
73
|
+
'由 okf-memory 插件维护。概念按类型分目录,路径即概念 ID。',
|
|
74
|
+
'',
|
|
75
|
+
].join('\n'),
|
|
76
|
+
'utf8',
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
await fs.access(logPath)
|
|
81
|
+
} catch {
|
|
82
|
+
await fs.writeFile(
|
|
83
|
+
logPath,
|
|
84
|
+
'---\ntype: Log\ntitle: 变更历史\n---\n\n# 变更历史\n\n',
|
|
85
|
+
'utf8',
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface ConceptRef {
|
|
91
|
+
filePath: string
|
|
92
|
+
conceptId: string
|
|
93
|
+
meta: ConceptMeta | null
|
|
94
|
+
body: string
|
|
95
|
+
text: string
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** 读取并解析概念文档(校验概念 ID 安全) */
|
|
99
|
+
export async function readConcept(root: string, conceptId: string): Promise<ConceptRef> {
|
|
100
|
+
const filePath = filePathOf(root, conceptId)
|
|
101
|
+
const text = await fs.readFile(filePath, 'utf8')
|
|
102
|
+
const { meta, body } = parseFrontmatter(text)
|
|
103
|
+
return { filePath, conceptId: conceptIdOf(filePath, root), meta, body, text }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface WriteResult {
|
|
107
|
+
action: 'created' | 'updated'
|
|
108
|
+
conceptId: string
|
|
109
|
+
filePath: string
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 写概念文档(先做符合性校验,再落盘,更新 index/log) */
|
|
113
|
+
export async function writeConcept(root: string, meta: ConceptMeta, body: string): Promise<WriteResult> {
|
|
114
|
+
return withLock(async () => {
|
|
115
|
+
const { buildConcept } = await import('./concept.js')
|
|
116
|
+
const md = buildConcept(meta, body)
|
|
117
|
+
const check = validateConcept(md)
|
|
118
|
+
if (!check.ok) throw new Error(`OKF 符合性校验失败:${check.errors.join('; ')}`)
|
|
119
|
+
|
|
120
|
+
// type 目录用 slug 化命名:防路径穿越(如 type="../../x")与非法字符
|
|
121
|
+
const typeDir = slugify(meta.type) || 'other'
|
|
122
|
+
const dir = path.join(root, typeDir)
|
|
123
|
+
await fs.mkdir(dir, { recursive: true })
|
|
124
|
+
|
|
125
|
+
const base = slugify(meta.title || meta.type || 'untitled')
|
|
126
|
+
const filePath = path.join(dir, `${base}.md`)
|
|
127
|
+
|
|
128
|
+
// 同路径已有内容 → 更新(timestamp 刷新),否则新建
|
|
129
|
+
let action: 'created' | 'updated' = 'created'
|
|
130
|
+
try {
|
|
131
|
+
await fs.access(filePath)
|
|
132
|
+
action = 'updated'
|
|
133
|
+
} catch {
|
|
134
|
+
/* new */
|
|
135
|
+
}
|
|
136
|
+
await fs.writeFile(filePath, md, 'utf8')
|
|
137
|
+
const conceptId = conceptIdOf(filePath, root)
|
|
138
|
+
await refreshIndex(root)
|
|
139
|
+
await appendLog(root, { action, conceptId, type: meta.type, title: meta.title })
|
|
140
|
+
return { action, conceptId, filePath }
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface BundleEntry {
|
|
145
|
+
filePath: string
|
|
146
|
+
conceptId: string
|
|
147
|
+
typeDir: string
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** 全库扫描:返回所有概念文档清单(用于 index 重建与检索) */
|
|
151
|
+
export async function scanBundle(root: string): Promise<BundleEntry[]> {
|
|
152
|
+
const out: BundleEntry[] = []
|
|
153
|
+
let entries: import('node:fs').Dirent[]
|
|
154
|
+
try {
|
|
155
|
+
entries = await fs.readdir(root, { withFileTypes: true })
|
|
156
|
+
} catch {
|
|
157
|
+
return out
|
|
158
|
+
}
|
|
159
|
+
for (const e of entries) {
|
|
160
|
+
if (!e.isDirectory()) continue
|
|
161
|
+
if (e.name.startsWith('.')) continue
|
|
162
|
+
const dir = path.join(root, e.name)
|
|
163
|
+
let files: string[]
|
|
164
|
+
try {
|
|
165
|
+
files = await fs.readdir(dir)
|
|
166
|
+
} catch {
|
|
167
|
+
continue
|
|
168
|
+
}
|
|
169
|
+
for (const f of files) {
|
|
170
|
+
if (!f.endsWith('.md')) continue
|
|
171
|
+
const filePath = path.join(dir, f)
|
|
172
|
+
const conceptId = conceptIdOf(filePath, root)
|
|
173
|
+
out.push({ filePath, conceptId, typeDir: e.name })
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return out
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** 重建根 index.md(渐进式目录:按类型分组列概念,每行附 description 供模型感知"库里有啥";写锁内串行) */
|
|
180
|
+
export async function refreshIndex(root: string): Promise<void> {
|
|
181
|
+
return withLock(async () => {
|
|
182
|
+
const concepts = await scanBundle(root)
|
|
183
|
+
// 并行读每个概念 frontmatter,取 title/description 供 index 展示
|
|
184
|
+
// 显式标注返回类型:否则失败分支的 {} 会让 metas[i].description 报类型错
|
|
185
|
+
const metas = await Promise.all(concepts.map(async (c): Promise<Partial<ConceptMeta>> => {
|
|
186
|
+
try {
|
|
187
|
+
const text = await fs.readFile(c.filePath, 'utf8')
|
|
188
|
+
const { meta } = parseFrontmatter(text)
|
|
189
|
+
return meta || {}
|
|
190
|
+
} catch {
|
|
191
|
+
return {}
|
|
192
|
+
}
|
|
193
|
+
}))
|
|
194
|
+
const byType = new Map<string, Array<{ id: string; desc: string }>>()
|
|
195
|
+
for (let i = 0; i < concepts.length; i++) {
|
|
196
|
+
const c = concepts[i]
|
|
197
|
+
if (!byType.has(c.typeDir)) byType.set(c.typeDir, [])
|
|
198
|
+
const desc = String(metas[i].description || '').trim().replace(/\s+/g, ' ').slice(0, 60)
|
|
199
|
+
byType.get(c.typeDir)!.push({ id: c.conceptId, desc })
|
|
200
|
+
}
|
|
201
|
+
const lines = [
|
|
202
|
+
'---',
|
|
203
|
+
'type: Bundle Root',
|
|
204
|
+
'title: OKF 记忆库',
|
|
205
|
+
'description: 会话记忆沉淀库(OKF v0.1)',
|
|
206
|
+
'okf_version: "0.1"',
|
|
207
|
+
'---',
|
|
208
|
+
'',
|
|
209
|
+
'# OKF 记忆库',
|
|
210
|
+
'',
|
|
211
|
+
`共 ${concepts.length} 个概念。路径即概念 ID,交叉链接用包内绝对路径。`,
|
|
212
|
+
'',
|
|
213
|
+
]
|
|
214
|
+
for (const [typeDir, items] of [...byType.entries()].sort()) {
|
|
215
|
+
lines.push(`## ${typeDir}`, '')
|
|
216
|
+
for (const item of [...items].sort((a, b) => a.id.localeCompare(b.id))) {
|
|
217
|
+
lines.push(item.desc ? `- [${item.id}](/${item.id}.md) — ${item.desc}` : `- [${item.id}](/${item.id}.md)`)
|
|
218
|
+
}
|
|
219
|
+
lines.push('')
|
|
220
|
+
}
|
|
221
|
+
if (byType.size === 0) lines.push('(空库 — 在会话中说"记住这个",即可沉淀第一条记忆)', '')
|
|
222
|
+
await fs.writeFile(path.join(root, 'index.md'), lines.join('\n'), 'utf8')
|
|
223
|
+
})
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export interface LogEntry {
|
|
227
|
+
action: string
|
|
228
|
+
conceptId: string
|
|
229
|
+
type: string
|
|
230
|
+
title?: string
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** 追加 log.md 变更记录(## YYYY-MM-DD 分组;写锁内串行) */
|
|
234
|
+
export async function appendLog(root: string, entry: LogEntry): Promise<void> {
|
|
235
|
+
return withLock(async () => {
|
|
236
|
+
const logPath = path.join(root, 'log.md')
|
|
237
|
+
const now = new Date()
|
|
238
|
+
const date = now.toISOString().slice(0, 10)
|
|
239
|
+
const time = now.toISOString().slice(11, 19)
|
|
240
|
+
let text = ''
|
|
241
|
+
try {
|
|
242
|
+
text = await fs.readFile(logPath, 'utf8')
|
|
243
|
+
} catch {
|
|
244
|
+
text = '---\ntype: Log\ntitle: 变更历史\n---\n\n# 变更历史\n\n'
|
|
245
|
+
}
|
|
246
|
+
const marker = `## ${date}`
|
|
247
|
+
const line = `- ${time} — ${entry.action} [${entry.conceptId}](${entry.conceptId}.md) (${entry.type}${entry.title ? ` · ${entry.title}` : ''})`
|
|
248
|
+
if (text.includes(marker)) {
|
|
249
|
+
text = `${text.replace(/\s*$/, '')}\n${line}\n`
|
|
250
|
+
} else {
|
|
251
|
+
text = `${text.replace(/\s*$/, '')}\n${marker}\n\n${line}\n`
|
|
252
|
+
}
|
|
253
|
+
await fs.writeFile(logPath, text, 'utf8')
|
|
254
|
+
})
|
|
255
|
+
}
|