project-node-memory 0.0.0-stage → 1.0.1
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.md +204 -2
- package/README_EN.md +201 -0
- package/cordis.patch.yml +5 -0
- package/lib/client.js +1621 -0
- package/lib/engine/commands.js +84 -0
- package/lib/engine/diag.js +220 -0
- package/lib/engine/mdlib.js +283 -0
- package/lib/engine/parse.js +67 -0
- package/lib/engine/registry.js +241 -0
- package/lib/engine/rename.js +203 -0
- package/lib/engine/render.js +138 -0
- package/lib/engine/scaffold.js +123 -0
- package/lib/engine/search.js +138 -0
- package/lib/engine/write.js +140 -0
- package/lib/host/prompt.js +111 -0
- package/lib/host/routes.js +304 -0
- package/lib/host/runtime.js +108 -0
- package/lib/host/skill.js +160 -0
- package/lib/host/tools.js +475 -0
- package/lib/index.js +60 -0
- package/package.json +62 -4
- package/skills/project-node-memory-guide/SKILL.md +59 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scaffold —— 给一个目录铺出 .nodekb 骨架。
|
|
3
|
+
*
|
|
4
|
+
* 目的不是「生成一堆空文件」,而是把**约定**写进库里:后来的人(或 Agent)
|
|
5
|
+
* 打开 .nodekb/README.md 就知道该放哪、该多大、什么时候引用而不是复述。
|
|
6
|
+
* 幂等:已存在的文件一律跳过,绝不动用户已写的内容。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import fs from 'node:fs'
|
|
10
|
+
import path from 'node:path'
|
|
11
|
+
|
|
12
|
+
const README = `# 本库约定(.nodekb)
|
|
13
|
+
|
|
14
|
+
这是 **node project memory** 库:md 文件即数据库,没有索引库、没有向量、没有后台进程。
|
|
15
|
+
删掉插件之后它仍然是普通 md。
|
|
16
|
+
|
|
17
|
+
## 落位
|
|
18
|
+
|
|
19
|
+
| 目录 | 放什么 | 判据 |
|
|
20
|
+
|---|---|---|
|
|
21
|
+
| \`doc/\` | 主题文档(一块一块的正文) | 会随版本演进、需要通读的解释性内容 |
|
|
22
|
+
| \`facts/\` | 事实常量(一个节点一条) | 值本身即答案:版本号、公差、系数、路径、阈值 |
|
|
23
|
+
| \`lessons/\` | 踩过的坑 | 换个人还会再踩,且「怎么发现的」比结论更值钱 |
|
|
24
|
+
| \`todo/\` | 待办与计划 | 有明确完成条件 |
|
|
25
|
+
| \`index/entry.md\` | 入口索引 | 只放指针(\`[[TID]]\`),不要复述正文 |
|
|
26
|
+
|
|
27
|
+
## 三条硬规则
|
|
28
|
+
|
|
29
|
+
1. **一个主题一个文件,取 < 4KB**。超过就拆成 \`doc/03-xxx.md\` 的多节,入口表加一行指针。
|
|
30
|
+
2. **不要复述**。同一段数字/结论只写一次,其它地方用 \`[[TID]]\` 引用;改名时 \`kb_rename\` 会一起改写。
|
|
31
|
+
3. **写之前先 \`kb_read mode=toc\` 看结构**,写完跑 \`kb_diag\`:悬空引用、引用位置型段落、循环、重复 TID 都会当场报出来。
|
|
32
|
+
|
|
33
|
+
## 语法(就两条)
|
|
34
|
+
|
|
35
|
+
- 文件即节点:\`doc/03-build.md\` → TID \`doc:03-build\`;\`## 3.1 标题\` → 子节点 \`doc:03-build:3.1\`
|
|
36
|
+
- 显式节点:\`<node name="fact:abi" type="fact">\` … \`</node>\`,标签必须**独占一行**
|
|
37
|
+
- 引用:\`[[doc:03-build:3.1]]\`(引用文件夹/文件展开整支,引用显式节点只展开它自己)
|
|
38
|
+
`
|
|
39
|
+
|
|
40
|
+
const ENTRY = `# 入口索引
|
|
41
|
+
|
|
42
|
+
只放指针,不复述正文。每条一行:\`- [[TID]] 一句话说明\`。
|
|
43
|
+
|
|
44
|
+
## 主题
|
|
45
|
+
|
|
46
|
+
- [[doc:00-overview]] 项目概览与结构
|
|
47
|
+
|
|
48
|
+
## 事实
|
|
49
|
+
|
|
50
|
+
(\`facts/\` 下的节点)
|
|
51
|
+
|
|
52
|
+
## 待办
|
|
53
|
+
|
|
54
|
+
(\`todo/\` 下的条目)
|
|
55
|
+
`
|
|
56
|
+
|
|
57
|
+
const OVERVIEW = `# 项目概览
|
|
58
|
+
|
|
59
|
+
## 这个项目是什么
|
|
60
|
+
|
|
61
|
+
(一段话说清楚)
|
|
62
|
+
|
|
63
|
+
## 目录与关键文件
|
|
64
|
+
|
|
65
|
+
(只列「改动会牵连很多地方」的那几个)
|
|
66
|
+
|
|
67
|
+
## 运行时与依赖
|
|
68
|
+
|
|
69
|
+
(版本、入口、构建方式)
|
|
70
|
+
`
|
|
71
|
+
|
|
72
|
+
const LESSONS = `# 教训
|
|
73
|
+
|
|
74
|
+
一条一个 \`<node name="lesson:xxx" type="lesson">\`,写「现象 → 为什么 → 怎么发现的」。
|
|
75
|
+
比结论更值钱的是发现它的过程。
|
|
76
|
+
`
|
|
77
|
+
|
|
78
|
+
const TODO = `# 待办
|
|
79
|
+
|
|
80
|
+
一条一个 \`<node name="todo:xxx" type="todo">\`,每条要有**明确的完成条件**。
|
|
81
|
+
`
|
|
82
|
+
|
|
83
|
+
const FACTS = `# 事实
|
|
84
|
+
|
|
85
|
+
一个常量一条 \`<node name="fact:xxx" type="fact">\`:值 + 单位 + 出处 + 什么时候会变。
|
|
86
|
+
值本身即答案的东西才放这里,解释性内容放 doc/。
|
|
87
|
+
`
|
|
88
|
+
|
|
89
|
+
const IGNORE = `{
|
|
90
|
+
"blocks": [],
|
|
91
|
+
"lines": []
|
|
92
|
+
}
|
|
93
|
+
`
|
|
94
|
+
|
|
95
|
+
const SEEDS = [
|
|
96
|
+
['README.md', README],
|
|
97
|
+
['index/entry.md', ENTRY],
|
|
98
|
+
['doc/00-overview.md', OVERVIEW],
|
|
99
|
+
['facts/README.md', FACTS],
|
|
100
|
+
['lessons/README.md', LESSONS],
|
|
101
|
+
['todo/README.md', TODO],
|
|
102
|
+
['diag_ignore.json', IGNORE],
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* @param {string} nodekbDir 目标 .nodekb 目录(不存在则创建)
|
|
107
|
+
* @returns {{nodekb:string, created:string[], skipped:string[]}}
|
|
108
|
+
*/
|
|
109
|
+
export function scaffold(nodekbDir) {
|
|
110
|
+
const created = []
|
|
111
|
+
const skipped = []
|
|
112
|
+
for (const [rel, content] of SEEDS) {
|
|
113
|
+
const abs = path.join(nodekbDir, rel)
|
|
114
|
+
if (fs.existsSync(abs)) {
|
|
115
|
+
skipped.push(rel)
|
|
116
|
+
continue
|
|
117
|
+
}
|
|
118
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true })
|
|
119
|
+
fs.writeFileSync(abs, content, 'utf8')
|
|
120
|
+
created.push(rel)
|
|
121
|
+
}
|
|
122
|
+
return { nodekb: nodekbDir, created, skipped }
|
|
123
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* search —— 实时检索(零持久化索引):2-gram 词面倒排 + 多词加权 + LIKE 兜底。
|
|
3
|
+
*
|
|
4
|
+
* 与 Python 版 commands.cmd_search 逐行对齐(同一算法、同一排序、同一片段窗口),
|
|
5
|
+
* 差异只有一个:这里返回结构体,格式化由调用方(工具层/路由层)决定。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { render_node, TransclusionError } from './render.js'
|
|
9
|
+
|
|
10
|
+
// 中文虚词 2-gram(检索分词时过滤)
|
|
11
|
+
const STOP_GRAMS = new Set([
|
|
12
|
+
'怎么', '如何', '是否', '什么', '为什么', '一个', '这种', '那个', '这个',
|
|
13
|
+
'他们', '我们', '你们', '进行', '以及', '还是', '就是', '不是', '没有',
|
|
14
|
+
'可以', '需要', '问题', '情况', '时候', '地方', '东西', '因为', '所以',
|
|
15
|
+
'但是', '然后', '接着', '开始', '最后', '主要', '目前', '已经', '现在',
|
|
16
|
+
'等等', '如果', '那么', '不能', '不会', '应该', '可能',
|
|
17
|
+
])
|
|
18
|
+
|
|
19
|
+
const CJK_RUN = /[\u4e00-\u9fff]+/g
|
|
20
|
+
const QUERY_TOKEN = /[\u4e00-\u9fff]+|[a-zA-Z0-9_.-]+/g
|
|
21
|
+
const INDEX_TOKEN = /[\u4e00-\u9fff]+|[a-z0-9_]+/g
|
|
22
|
+
|
|
23
|
+
/** query → 检索 term:非中文 token 原样;连续中文按 2-gram 拆分并过滤虚词 */
|
|
24
|
+
export function searchTerms(query) {
|
|
25
|
+
const terms = []
|
|
26
|
+
for (const tok of String(query).match(QUERY_TOKEN) || []) {
|
|
27
|
+
if (/^[\u4e00-\u9fff]+$/.test(tok)) {
|
|
28
|
+
if (tok.length <= 2) {
|
|
29
|
+
if (!STOP_GRAMS.has(tok)) terms.push(tok)
|
|
30
|
+
continue
|
|
31
|
+
}
|
|
32
|
+
const seen = new Set()
|
|
33
|
+
for (let i = 0; i < tok.length - 1; i++) {
|
|
34
|
+
const g = tok.slice(i, i + 2)
|
|
35
|
+
if (!seen.has(g) && !STOP_GRAMS.has(g)) {
|
|
36
|
+
seen.add(g)
|
|
37
|
+
terms.push(g)
|
|
38
|
+
}
|
|
39
|
+
if (terms.length >= 10) break
|
|
40
|
+
}
|
|
41
|
+
} else {
|
|
42
|
+
terms.push(tok)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return [...new Set(terms)]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 位置型段落 TID 提升为稳定句柄:沿 parent 上溯到文件/显式节点/文件夹 */
|
|
49
|
+
export function stableTid(nodes, tid) {
|
|
50
|
+
let cur = nodes.get(tid)
|
|
51
|
+
while (cur && cur.positional && cur.parent_tid != null && nodes.has(cur.parent_tid)) {
|
|
52
|
+
cur = nodes.get(cur.parent_tid)
|
|
53
|
+
}
|
|
54
|
+
return cur ? cur.tid : tid
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 检索。
|
|
59
|
+
* @returns {{total:number,count:number,offset:number,hits:Array}}
|
|
60
|
+
*/
|
|
61
|
+
export function search(nodes, query, { type, limit = 10, offset = 0, snippet = 240 } = {}) {
|
|
62
|
+
const terms = searchTerms(query)
|
|
63
|
+
const index = new Map() // gram -> Set(tid)
|
|
64
|
+
for (const node of nodes.values()) {
|
|
65
|
+
const text = `${node.title}\n${node.content}\n${node.tid}`.toLowerCase()
|
|
66
|
+
const grams = new Set()
|
|
67
|
+
for (const tok of text.match(INDEX_TOKEN) || []) {
|
|
68
|
+
if (/^[\x00-\x7f]+$/.test(tok)) {
|
|
69
|
+
grams.add(tok)
|
|
70
|
+
} else {
|
|
71
|
+
for (let i = 0; i < tok.length - 1; i++) grams.add(tok.slice(i, i + 2))
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
for (const g of grams) {
|
|
75
|
+
let set = index.get(g)
|
|
76
|
+
if (!set) index.set(g, (set = new Set()))
|
|
77
|
+
set.add(node.tid)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const scores = new Map()
|
|
82
|
+
for (const g of terms) {
|
|
83
|
+
for (const tid of index.get(g) || []) scores.set(tid, (scores.get(tid) || 0) + 1)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let rows = []
|
|
87
|
+
if (scores.size) {
|
|
88
|
+
const ranked = [...scores.entries()].sort(
|
|
89
|
+
(a, b) => b[1] - a[1] || nodes.get(a[0]).content.length - nodes.get(b[0]).content.length,
|
|
90
|
+
)
|
|
91
|
+
for (const [tid] of ranked) {
|
|
92
|
+
const node = nodes.get(tid)
|
|
93
|
+
if (type && node.type !== type) continue
|
|
94
|
+
rows.push(node)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (!rows.length) {
|
|
98
|
+
// LIKE 兜底:2-gram 未命中(生僻词/整句)时退回子串匹配
|
|
99
|
+
for (const node of nodes.values()) {
|
|
100
|
+
if (type && node.type !== type) continue
|
|
101
|
+
if (!node.title.includes(query) && !node.content.includes(query) && !node.tid.includes(query)) continue
|
|
102
|
+
rows.push(node)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const total = rows.length
|
|
107
|
+
const hits = []
|
|
108
|
+
for (const node of rows.slice(offset, offset + limit)) {
|
|
109
|
+
let text
|
|
110
|
+
try {
|
|
111
|
+
text = render_node(nodes, node.tid)
|
|
112
|
+
} catch (e) {
|
|
113
|
+
if (!(e instanceof TransclusionError)) throw e
|
|
114
|
+
text = node.content
|
|
115
|
+
}
|
|
116
|
+
let idx0 = terms.length ? text.indexOf(terms[0]) : -1
|
|
117
|
+
if (idx0 < 0) idx0 = 0
|
|
118
|
+
const stable = stableTid(nodes, node.tid)
|
|
119
|
+
const half = Math.max(40, Math.floor(snippet / 2))
|
|
120
|
+
hits.push({
|
|
121
|
+
// tid = 稳定句柄(位置型段落上溯到文件/显式节点),ref = 实际命中节点,positional 标记
|
|
122
|
+
tid: stable,
|
|
123
|
+
ref: node.tid,
|
|
124
|
+
positional: !!node.positional,
|
|
125
|
+
type: node.type,
|
|
126
|
+
title: node.title,
|
|
127
|
+
source: node.source,
|
|
128
|
+
snippet: text.slice(Math.max(0, idx0 - half), idx0 + half + 1), // 保留换行/缩进,不再压平
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
return { total, count: hits.length, offset, hits }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** 命中列表 → 紧凑文本(模型侧默认读这个) */
|
|
135
|
+
export function formatSearch(query, result) {
|
|
136
|
+
if (!result.hits.length) return `无匹配: ${query}`
|
|
137
|
+
return result.hits.map((h) => `${h.tid} [${h.type}] ${h.title}\n …${h.snippet}…`).join('\n')
|
|
138
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* write —— 往库里写内容(**带校验**)。
|
|
3
|
+
*
|
|
4
|
+
* 这是本项目对「Agent 直接用 write/edit 手搭知识库」的回答:
|
|
5
|
+
* 同样是写 md,但写之前校验 TID / 标签独占一行 / 路径在库内,写之后由调用方跑 diag。
|
|
6
|
+
* 手写没有任何一层会拦你,所以规则靠人记;这里的规则是代码。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import fs from 'node:fs'
|
|
10
|
+
import path from 'node:path'
|
|
11
|
+
import { is_valid_tid } from './parse.js'
|
|
12
|
+
|
|
13
|
+
const NODE_OPEN = /^<node\b[^>]*>$/m
|
|
14
|
+
const NODE_TAG_LINE = /^\s*<\/?node\b[^>]*>\s*$/m
|
|
15
|
+
|
|
16
|
+
function fail(msg) {
|
|
17
|
+
throw new Error(msg)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** 库内相对路径 → 绝对路径;越界直接拒绝 */
|
|
21
|
+
function absIn(nodekb, rel) {
|
|
22
|
+
const abs = path.resolve(nodekb, rel)
|
|
23
|
+
const root = path.resolve(nodekb)
|
|
24
|
+
const a = process.platform === 'win32' ? abs.toLowerCase() : abs
|
|
25
|
+
const r = process.platform === 'win32' ? root.toLowerCase() : root
|
|
26
|
+
if (a !== r && !a.startsWith(r + path.sep)) fail(`拒绝写库外路径: ${rel}`)
|
|
27
|
+
return abs
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function readText(abs) {
|
|
31
|
+
try {
|
|
32
|
+
return fs.readFileSync(abs, 'utf8')
|
|
33
|
+
} catch {
|
|
34
|
+
return null
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 校验一段将要落盘的内容:node 标签必须独占一行、名字必须是合法 TID */
|
|
39
|
+
export function validateContent(text, where) {
|
|
40
|
+
const lines = text.split(/\r?\n/)
|
|
41
|
+
for (let i = 0; i < lines.length; i++) {
|
|
42
|
+
const line = lines[i]
|
|
43
|
+
// 反引号/围栏里的示例不算真标签
|
|
44
|
+
if (/^\s*(```|~~~)/.test(line)) {
|
|
45
|
+
// 跳过围栏块
|
|
46
|
+
const fence = line.trim().slice(0, 3)
|
|
47
|
+
i++
|
|
48
|
+
while (i < lines.length && !lines[i].trim().startsWith(fence)) i++
|
|
49
|
+
continue
|
|
50
|
+
}
|
|
51
|
+
const m = /^\s*<node\b([^>]*)>\s*$/.exec(line)
|
|
52
|
+
if (m) {
|
|
53
|
+
const nameM = /\bname\s*=\s*"([^"]*)"/.exec(m[1]) || /\bname\s*=\s*'([^']*)'/.exec(m[1])
|
|
54
|
+
if (!nameM) fail(`${where}:${i + 1} <node> 缺少 name=`)
|
|
55
|
+
const tid = nameM[1]
|
|
56
|
+
if (!is_valid_tid(tid)) fail(`${where}:${i + 1} 非法 TID「${tid}」:只能用字母/数字/汉字与 : . _ -`)
|
|
57
|
+
continue
|
|
58
|
+
}
|
|
59
|
+
const inline = /<node\b|<\/node>/.test(line)
|
|
60
|
+
if (inline && !NODE_TAG_LINE.test(line)) {
|
|
61
|
+
fail(`${where}:${i + 1} <node> / </node> 必须独占一行(现在是「${line.trim().slice(0, 60)}」)`)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return true
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @param {{nodekb:string, nodes:Map}} lib 已打开的库
|
|
69
|
+
* @param {{target:string, content:string, mode?:'append'|'replace'|'create'|'node'}} input
|
|
70
|
+
* @returns {{rel:string, abs:string, bytes:number, mode:string, created:boolean, tid?:string}}
|
|
71
|
+
*/
|
|
72
|
+
export function writeInto(lib, input) {
|
|
73
|
+
const target = String(input.target || '').trim()
|
|
74
|
+
const content = String(input.content ?? '')
|
|
75
|
+
let mode = String(input.mode || '').trim()
|
|
76
|
+
if (!target) fail('target 必填:库内相对路径(如 doc/03-build.md)或显式节点 TID(如 fact:abi)')
|
|
77
|
+
if (!content) fail('content 必填:本次要写入的内容')
|
|
78
|
+
|
|
79
|
+
const looksPath = /\.md$/i.test(target) || target.includes('/') || target.includes('\\')
|
|
80
|
+
const looksTid = !looksPath && target.includes(':')
|
|
81
|
+
|
|
82
|
+
if (looksTid) {
|
|
83
|
+
mode = mode || 'node'
|
|
84
|
+
if (mode !== 'node') fail(`target「${target}」是 TID,只能配 mode=node(或改用相对路径 + append/replace/create)`)
|
|
85
|
+
const node = lib.nodes.get(target)
|
|
86
|
+
if (!node) fail(`库里没有显式节点「${target}」。要新建文件请用相对路径(如 lessons/003.md)`)
|
|
87
|
+
if (node.kind !== 'node') {
|
|
88
|
+
fail(`「${target}」是 ${node.kind === 'section' ? '位置型段落(## 标题)' : node.kind} 节点,不是 <node> 块;改写 ## 段落请用相对路径 + mode=replace`)
|
|
89
|
+
}
|
|
90
|
+
const rel = node.source.split('#')[0]
|
|
91
|
+
const abs = path.isAbsolute(rel) ? path.normalize(rel) : absIn(lib.nodekb, rel)
|
|
92
|
+
if (!fs.existsSync(abs)) fail(`源文件不存在: ${abs}`)
|
|
93
|
+
const raw = fs.readFileSync(abs, 'utf8')
|
|
94
|
+
// 用 source 里的行号定位(`path#L12-L30`),在行上找 <node …> 与 </node>,替换两者之间
|
|
95
|
+
const hm = /#L(\d+)(?:-L(\d+))?/.exec(node.source || '')
|
|
96
|
+
if (!hm) fail('这个节点没有行号信息,无法就地改写;请用相对路径 + mode=replace 整篇重写')
|
|
97
|
+
const from = Number(hm[1]) - 1
|
|
98
|
+
const to = hm[2] ? Number(hm[2]) : from + 1
|
|
99
|
+
const lines = raw.split('\n')
|
|
100
|
+
let openAt = -1
|
|
101
|
+
let closeAt = -1
|
|
102
|
+
for (let i = from; i < Math.min(to + 1, lines.length); i++) {
|
|
103
|
+
if (openAt < 0 && /^\s*<node\b/.test(lines[i])) {
|
|
104
|
+
openAt = i
|
|
105
|
+
continue
|
|
106
|
+
}
|
|
107
|
+
if (openAt >= 0 && /^\s*<\/node>\s*$/.test(lines[i])) {
|
|
108
|
+
closeAt = i
|
|
109
|
+
break
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (openAt < 0 || closeAt < 0) fail(`无法在 ${rel} 的第 ${hm[1]}–${to} 行定位 <node> 块`)
|
|
113
|
+
validateContent(content, path.basename(abs))
|
|
114
|
+
const bodyLines = content.replace(/^\n+|\n+$/g, '').split('\n')
|
|
115
|
+
lines.splice(openAt + 1, closeAt - openAt - 1, ...bodyLines)
|
|
116
|
+
fs.writeFileSync(abs, lines.join('\n'), 'utf8')
|
|
117
|
+
return { rel, abs, bytes: Buffer.byteLength(content, 'utf8'), mode, created: false, tid: target }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
mode = mode || 'append'
|
|
121
|
+
if (!['append', 'replace', 'create'].includes(mode)) fail(`未知 mode: ${mode}(可用 append / replace / create / node)`)
|
|
122
|
+
const rel = path.relative(lib.nodekb, absIn(lib.nodekb, target)) || target
|
|
123
|
+
const abs = absIn(lib.nodekb, target)
|
|
124
|
+
const existing = readText(abs)
|
|
125
|
+
if (mode === 'create' && existing !== null) fail(`${rel} 已存在。要追加用 mode=append,要整篇覆盖用 mode=replace`)
|
|
126
|
+
validateContent(content, rel)
|
|
127
|
+
|
|
128
|
+
let next
|
|
129
|
+
if (mode === 'replace' || existing === null) {
|
|
130
|
+
next = content.endsWith('\n') ? content : content + '\n'
|
|
131
|
+
} else {
|
|
132
|
+
const sep = existing.endsWith('\n') ? '\n' : '\n\n'
|
|
133
|
+
next = existing + sep + content.replace(/^\n+/, '') + (content.endsWith('\n') ? '' : '\n')
|
|
134
|
+
}
|
|
135
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true })
|
|
136
|
+
fs.writeFileSync(abs, next, 'utf8')
|
|
137
|
+
return { rel, abs, bytes: Buffer.byteLength(content, 'utf8'), mode, created: existing === null }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export { NODE_OPEN }
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* prompt —— 往系统提示词里加一段「本项目的知识库怎么用」。
|
|
3
|
+
*
|
|
4
|
+
* 这是让 Agent **优先**用 kb_* 而不是自己拿 write 搭 docs/ 的唯一常驻手段:
|
|
5
|
+
* 工具描述只有在模型已经决定「我要写知识」时才会被读到,而提示词每一轮都在。
|
|
6
|
+
* 代价是常驻 token,所以整段压到 ~450 字,只留 schema 表达不了的约定。
|
|
7
|
+
*
|
|
8
|
+
* 插件配置 prompt.enabled=false 可以关掉;prompt.text 可以整段替换。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const PROMPT_SECTION_NAME = 'project-node-memory'
|
|
12
|
+
export const PROMPT_SECTION_ORDER = 100
|
|
13
|
+
|
|
14
|
+
export const DEFAULT_PROMPT = [
|
|
15
|
+
'## 项目知识库(project-node-memory)',
|
|
16
|
+
'',
|
|
17
|
+
'本项目需要沉淀长期知识(设计决策、踩坑、事实常量、待办、交接)时,**用 `.nodekb/` + `kb_*` 工具**,不要用 write/edit 手搭 `docs/*.md` 式的渐进式文档——手写没有校验、没有稳定 TID、没有引用展开。',
|
|
18
|
+
'',
|
|
19
|
+
'- 建库:`kb_init` 一次铺出骨架(`doc/ facts/ lessons/ todo/ index/entry.md` + 约定说明)。已有 `.nodekb/` 就别重建。',
|
|
20
|
+
'- 写库:`kb_write`(append/replace/create,或对显式节点用 mode=node)。它写前校验 TID 与 `<node>` 独占一行,写后立刻跑 diag 把悬空引用/重复块报回来;同样的事用 write 做没有任何一层会拦。',
|
|
21
|
+
'- 读库:先 `kb_read mode=toc` 看结构(几百 token),再按 TID 读一支;找不到就 `kb_search`。别一次性把整库读进上下文。',
|
|
22
|
+
'- 改名前先 `kb_backlinks`,改名用 `kb_rename`(会重写全库引用并跳过被并发改过的文件)。',
|
|
23
|
+
'- 一个主题一个文件、取 < 4KB;结论只写一次,别处用 `[[TID]]` 引用。',
|
|
24
|
+
].join('\n')
|
|
25
|
+
|
|
26
|
+
function textOf(config) {
|
|
27
|
+
const p = (config && config.prompt) || {}
|
|
28
|
+
if (typeof p.text === 'string' && p.text.trim()) return p.text
|
|
29
|
+
return DEFAULT_PROMPT
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 最近一次注册的结果(给 /nodekb/api/prompt 自证用) */
|
|
33
|
+
const STATE = { registered: false, reason: 'not-run', chars: 0, name: PROMPT_SECTION_NAME, order: PROMPT_SECTION_ORDER }
|
|
34
|
+
|
|
35
|
+
export function promptState() {
|
|
36
|
+
return { ...STATE }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @returns {() => void} disposer(未接上 systemPrompt 服务时是空函数)
|
|
41
|
+
*
|
|
42
|
+
* 注意:`systemPrompt` 是**可选服务**,不能写进静态 `inject`(拿不到就整个插件不激活,
|
|
43
|
+
* 连工具一起没了)。宿主给的是动态注入 `ctx.inject(['systemPrompt'], (child) => …)`:
|
|
44
|
+
* 服务就绪时回调,插件先照常起来。
|
|
45
|
+
*/
|
|
46
|
+
export function registerPrompt(ctx, config) {
|
|
47
|
+
const p = (config && config.prompt) || {}
|
|
48
|
+
if (p.enabled === false) {
|
|
49
|
+
Object.assign(STATE, { registered: false, reason: 'disabled-by-config', chars: 0 })
|
|
50
|
+
return () => {}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const attach = (sp) => {
|
|
54
|
+
if (!sp || typeof sp.section !== 'function') {
|
|
55
|
+
Object.assign(STATE, { registered: false, reason: 'no-systemPrompt-service', chars: 0 })
|
|
56
|
+
return null
|
|
57
|
+
}
|
|
58
|
+
const text = textOf(config)
|
|
59
|
+
try {
|
|
60
|
+
const dispose = sp.section({ name: PROMPT_SECTION_NAME, order: PROMPT_SECTION_ORDER, text })
|
|
61
|
+
Object.assign(STATE, { registered: true, reason: 'ok', chars: text.length })
|
|
62
|
+
return () => {
|
|
63
|
+
Object.assign(STATE, { registered: false, reason: 'disposed', chars: 0 })
|
|
64
|
+
if (typeof dispose === 'function') dispose()
|
|
65
|
+
}
|
|
66
|
+
} catch (e) {
|
|
67
|
+
Object.assign(STATE, { registered: false, reason: 'error: ' + (e && e.message), chars: 0 })
|
|
68
|
+
console.warn('[project-node-memory] 提示词段落注册失败:', e && e.message)
|
|
69
|
+
return null
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// 首选:动态注入,服务晚于插件就绪也能接上
|
|
74
|
+
if (typeof ctx.inject === 'function') {
|
|
75
|
+
let off = null
|
|
76
|
+
try {
|
|
77
|
+
const effect = ctx.inject(['systemPrompt'], (child) => {
|
|
78
|
+
off = attach(child && child.systemPrompt)
|
|
79
|
+
return off || undefined
|
|
80
|
+
})
|
|
81
|
+
return () => {
|
|
82
|
+
try {
|
|
83
|
+
if (typeof effect === 'function') effect()
|
|
84
|
+
else if (effect && typeof effect.dispose === 'function') effect.dispose()
|
|
85
|
+
} catch {
|
|
86
|
+
/* 卸载期忽略 */
|
|
87
|
+
}
|
|
88
|
+
if (off) off()
|
|
89
|
+
}
|
|
90
|
+
} catch (e) {
|
|
91
|
+
Object.assign(STATE, { registered: false, reason: 'inject-error: ' + (e && e.message), chars: 0 })
|
|
92
|
+
return () => {}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// 退路:直接取
|
|
97
|
+
let sp = null
|
|
98
|
+
try {
|
|
99
|
+
sp = ctx.systemPrompt || (typeof ctx.get === 'function' ? ctx.get('systemPrompt') : null)
|
|
100
|
+
} catch {
|
|
101
|
+
sp = null
|
|
102
|
+
}
|
|
103
|
+
if (!sp) {
|
|
104
|
+
Object.assign(STATE, { registered: false, reason: 'no-systemPrompt-service', chars: 0 })
|
|
105
|
+
return () => {}
|
|
106
|
+
}
|
|
107
|
+
const off = attach(sp)
|
|
108
|
+
return () => {
|
|
109
|
+
if (off) off()
|
|
110
|
+
}
|
|
111
|
+
}
|