project-node-memory 0.0.0-stage → 1.0.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.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,475 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tools —— 注册 6 个 kb_* 工具(纯 JS,不 import 任何 @deepseek-ai/* 包)。
|
|
3
|
+
*
|
|
4
|
+
* 工具契约(与宿主 registry 结构兼容):
|
|
5
|
+
* { name, description, parameters: raw JSON Schema, output: { schema, render(args,value) }, execute(args,exec) }
|
|
6
|
+
*
|
|
7
|
+
* 设计取向:description 本身就是 prompt,所以它要写清楚「什么时候用我、什么时候别用我」。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import path from 'node:path'
|
|
11
|
+
import { execFileSync } from 'node:child_process'
|
|
12
|
+
import { render_node, render_subtree, toc_lines, node_info, resolve_path } from '../engine/render.js'
|
|
13
|
+
import { search, formatSearch } from '../engine/search.js'
|
|
14
|
+
import { diag } from '../engine/diag.js'
|
|
15
|
+
import { extract_refs } from '../engine/parse.js'
|
|
16
|
+
import { renameFileNode, formatRename } from '../engine/rename.js'
|
|
17
|
+
import { scaffold } from '../engine/scaffold.js'
|
|
18
|
+
import { writeInto } from '../engine/write.js'
|
|
19
|
+
import { isInside } from '../engine/registry.js'
|
|
20
|
+
import { openLibrary, workspaceOf, libraryHint, knownLibraries } from './runtime.js'
|
|
21
|
+
|
|
22
|
+
const DEFAULT_MAXLEN = 50000
|
|
23
|
+
|
|
24
|
+
/** 最近一次批量注册的结果(给 /nodekb/api/tools 自证用) */
|
|
25
|
+
const TOOL_STATE = { registered: 0, failed: [] }
|
|
26
|
+
|
|
27
|
+
export function toolState() {
|
|
28
|
+
return { registered: TOOL_STATE.registered, failed: [...TOOL_STATE.failed] }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const KB_PARAM = {
|
|
32
|
+
type: 'string',
|
|
33
|
+
description: '库名(注册表里的名字,或工作区自带的 workspace)。省略则用工作区 .nodekb;都不存在时用唯一已注册的库。',
|
|
34
|
+
}
|
|
35
|
+
const LIB_PARAM = {
|
|
36
|
+
type: 'string',
|
|
37
|
+
description: '库根路径的备用写法:技能目录或其下的 .nodekb 绝对路径(也可相对工作区)。仅当库没有注册名时使用。',
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function text(t) {
|
|
41
|
+
return [{ type: 'text', text: t }]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function warnPrefix(lib) {
|
|
45
|
+
if (!lib.problems || !lib.problems.length) return ''
|
|
46
|
+
return `⚠ 库有 ${lib.problems.length} 处 TID 冲突,已跳过冲突项:\n - ${lib.problems.slice(0, 5).join('\n - ')}\n\n`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 允许建库/写库的根:工作区 + 所有已注册库(技能目录也在其中) */
|
|
50
|
+
function allowedRoots(config, exec) {
|
|
51
|
+
try {
|
|
52
|
+
return knownLibraries(config, exec)
|
|
53
|
+
.map((r) => r.path || r.nodekb)
|
|
54
|
+
.filter((p) => typeof p === 'string' && p)
|
|
55
|
+
} catch {
|
|
56
|
+
return []
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 库定位失败时补一句「有哪些库可用」,别让模型瞎猜 */
|
|
61
|
+
function locate(config, exec, args) {
|
|
62
|
+
try {
|
|
63
|
+
return openLibrary(config, exec, { kb: args.kb, lib: args.lib })
|
|
64
|
+
} catch (e) {
|
|
65
|
+
const hint = libraryHint(config, exec)
|
|
66
|
+
const err = new Error(`${e.message}${hint ? `\n${hint}` : ''}`)
|
|
67
|
+
err.cause = e
|
|
68
|
+
throw err
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function buildTools(ctx, config) {
|
|
73
|
+
const tools = []
|
|
74
|
+
|
|
75
|
+
// ── kb_list ──────────────────────────────────────────────────────────────
|
|
76
|
+
tools.push({
|
|
77
|
+
name: 'kb_list',
|
|
78
|
+
description:
|
|
79
|
+
'列出当前可用的 nodekb 知识库(名字 + 路径 + 是否存在),以及本次调用实际会命中的默认库。' +
|
|
80
|
+
'不确定该传什么 kb 参数时先调它。只读、极轻量。',
|
|
81
|
+
parameters: { type: 'object', properties: {} },
|
|
82
|
+
output: {
|
|
83
|
+
schema: {},
|
|
84
|
+
render: (_args, value) => text(value.text),
|
|
85
|
+
},
|
|
86
|
+
async execute() {
|
|
87
|
+
const rows = knownLibraries(config, null)
|
|
88
|
+
const lines = ['可用于 kb_* 工具的 nodekb 库:']
|
|
89
|
+
if (!rows.length) {
|
|
90
|
+
lines.push(' (无)在工作区建 .nodekb/ 并放入 md 文件,或在插件配置 config.libraries / config.registries 里登记。')
|
|
91
|
+
}
|
|
92
|
+
for (const r of rows) {
|
|
93
|
+
lines.push(`- ${r.name} ${r.path}${r.exists ? '' : ' [路径不存在]'}${r.origin === 'workspace' ? ' (工作区默认)' : ''}`)
|
|
94
|
+
}
|
|
95
|
+
return { text: lines.join('\n'), libs: rows }
|
|
96
|
+
},
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
// ── kb_read ──────────────────────────────────────────────────────────────
|
|
100
|
+
tools.push({
|
|
101
|
+
name: 'kb_read',
|
|
102
|
+
description:
|
|
103
|
+
'读取 nodekb 知识库的一个节点。target 是 TID(如 doc:03-工程经验与坑:3.1)或唯一前缀(如 3.1 不行、doc:03 可以)。' +
|
|
104
|
+
'mode 默认 subtree = 整支渲染(本节点的 [[TID]] 引用就地展开成内容,再递归子节点);' +
|
|
105
|
+
'toc = 只看结构不看正文(每个节点一行:tid · 标题 · 字符数,适合先定位再读);' +
|
|
106
|
+
'node = 只读本节点自身(不含子节点,也不展开子节);raw = 元信息 + 原始内容(含未展开的 [[TID]])。' +
|
|
107
|
+
'比 read 工具好在:一次拿到装配好的上下文,且 TID 不受文件行号漂移影响。超过 maxlen 会报错并提示改用 toc/node。',
|
|
108
|
+
parameters: {
|
|
109
|
+
type: 'object',
|
|
110
|
+
properties: {
|
|
111
|
+
target: { type: 'string', description: '节点 TID 或其唯一前缀。空字符串或 "." 表示库根。' },
|
|
112
|
+
kb: KB_PARAM,
|
|
113
|
+
lib: LIB_PARAM,
|
|
114
|
+
mode: {
|
|
115
|
+
type: 'string',
|
|
116
|
+
enum: ['subtree', 'node', 'toc', 'raw'],
|
|
117
|
+
description: 'subtree(默认,整支投影)| node(仅本节点)| toc(目录视图)| raw(元信息+原文)',
|
|
118
|
+
},
|
|
119
|
+
maxlen: { type: 'integer', description: '整支渲染的字符上限,默认 50000;传 0 表示不限。只对 mode=subtree 生效。' },
|
|
120
|
+
},
|
|
121
|
+
required: ['target'],
|
|
122
|
+
},
|
|
123
|
+
output: {
|
|
124
|
+
schema: {},
|
|
125
|
+
render: (_args, value) => text(value.text),
|
|
126
|
+
},
|
|
127
|
+
async execute(args, exec) {
|
|
128
|
+
const lib = locate(config, exec, args)
|
|
129
|
+
const target = String(args.target ?? '').trim() || 'root'
|
|
130
|
+
const mode = args.mode || 'subtree'
|
|
131
|
+
const maxlen = args.maxlen === undefined ? DEFAULT_MAXLEN : Number(args.maxlen) || 0
|
|
132
|
+
|
|
133
|
+
const tid = resolve_path(lib.nodes, target)
|
|
134
|
+
if (tid === null) {
|
|
135
|
+
const cands = [...lib.nodes.keys()].filter((t) => t.startsWith(target) || t.startsWith(target + ':')).sort().slice(0, 10)
|
|
136
|
+
throw new Error(`未找到节点: ${target}${cands.length ? `;候选: ${cands.join('、')}` : ''}`)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
let out
|
|
140
|
+
if (mode === 'toc') {
|
|
141
|
+
out = toc_lines(lib.nodes, tid).join('\n')
|
|
142
|
+
} else if (mode === 'raw') {
|
|
143
|
+
out = JSON.stringify(node_info(lib.nodes, tid), null, 2)
|
|
144
|
+
} else if (mode === 'node') {
|
|
145
|
+
out = render_node(lib.nodes, tid)
|
|
146
|
+
const kids = [...lib.nodes.values()].filter((c) => c.parent_tid === tid).length
|
|
147
|
+
if (kids) out += `\n\n(本节点含 ${kids} 个子节:正文在子节里;看结构用 mode=toc,看整支不传 mode)`
|
|
148
|
+
} else {
|
|
149
|
+
out = render_subtree(lib.nodes, tid)
|
|
150
|
+
if (maxlen && out.length > maxlen) {
|
|
151
|
+
throw new Error(
|
|
152
|
+
`渲染超上限: ${out.length} 字符 > ${maxlen}。建议 mode=node 单节点、先 mode=toc 看结构、或指定小节 tid;` +
|
|
153
|
+
`确实要整支就传 maxlen=0。`,
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const node = lib.nodes.get(tid)
|
|
158
|
+
return {
|
|
159
|
+
tid,
|
|
160
|
+
title: node ? node.title : '',
|
|
161
|
+
type: node ? node.type : '',
|
|
162
|
+
source: node ? node.source : '',
|
|
163
|
+
kb: lib.name,
|
|
164
|
+
mode,
|
|
165
|
+
chars: out.length,
|
|
166
|
+
text: warnPrefix(lib) + out,
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
// ── kb_search ────────────────────────────────────────────────────────────
|
|
172
|
+
tools.push({
|
|
173
|
+
name: 'kb_search',
|
|
174
|
+
description:
|
|
175
|
+
'在 nodekb 知识库里检索(中文自然语言可用:连续中文按 2-gram 拆分并过滤虚词,多词命中加权,无命中时退化为整句子串匹配)。' +
|
|
176
|
+
'返回每条命中的稳定 TID(positional=true 表示命中的是位置型段落,tid 已上溯到文件/显式节点,ref 是实际命中点)、类型、标题、源文件行号与命中片段。' +
|
|
177
|
+
'用 kb_search 找到 tid,再用 kb_read 读,比 grep 后逐个 read 省往返。',
|
|
178
|
+
parameters: {
|
|
179
|
+
type: 'object',
|
|
180
|
+
properties: {
|
|
181
|
+
query: { type: 'string', description: '检索词或自然语言问句。' },
|
|
182
|
+
kb: KB_PARAM,
|
|
183
|
+
lib: LIB_PARAM,
|
|
184
|
+
type: { type: 'string', description: '只保留该类型的节点(如 doc / todo / handover)。' },
|
|
185
|
+
limit: { type: 'integer', description: '返回条数上限,默认 10。' },
|
|
186
|
+
offset: { type: 'integer', description: '跳过前 N 条,用于翻页。' },
|
|
187
|
+
snippet: { type: 'integer', description: '命中片段窗口字符数,默认 240。' },
|
|
188
|
+
},
|
|
189
|
+
required: ['query'],
|
|
190
|
+
},
|
|
191
|
+
output: {
|
|
192
|
+
schema: {},
|
|
193
|
+
render: (_args, value) => text(value.text),
|
|
194
|
+
},
|
|
195
|
+
async execute(args, exec) {
|
|
196
|
+
const lib = locate(config, exec, args)
|
|
197
|
+
const res = search(lib.nodes, String(args.query ?? ''), {
|
|
198
|
+
type: args.type,
|
|
199
|
+
limit: args.limit === undefined ? 10 : Number(args.limit),
|
|
200
|
+
offset: args.offset === undefined ? 0 : Number(args.offset),
|
|
201
|
+
snippet: args.snippet === undefined ? 240 : Number(args.snippet),
|
|
202
|
+
})
|
|
203
|
+
const body = formatSearch(args.query, res)
|
|
204
|
+
const footer =
|
|
205
|
+
res.total > res.count
|
|
206
|
+
? `\n\n(共 ${res.total} 条,已返回 ${res.count} 条;还有 ${res.total - res.count} 条,用 offset=${res.offset + res.count} 继续翻)`
|
|
207
|
+
: ''
|
|
208
|
+
return { ...res, kb: lib.name, text: warnPrefix(lib) + body + footer }
|
|
209
|
+
},
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
// ── kb_diag ──────────────────────────────────────────────────────────────
|
|
213
|
+
tools.push({
|
|
214
|
+
name: 'kb_diag',
|
|
215
|
+
description:
|
|
216
|
+
'知识库结构自检。errors = 真损坏(悬空引用 [[TID]]、引用位置型段落 TID、循环引用、孤儿节点、树环、重复 TID);' +
|
|
217
|
+
'warnings = 疑似内容漂移(同一段文字出现在多个节点 / 节点内重复行),默认不影响结论。' +
|
|
218
|
+
'改完 md 之后跑一次,比人工核对可靠。库根可放 diag_ignore.json 豁免样板块。',
|
|
219
|
+
parameters: {
|
|
220
|
+
type: 'object',
|
|
221
|
+
properties: {
|
|
222
|
+
kb: KB_PARAM,
|
|
223
|
+
lib: LIB_PARAM,
|
|
224
|
+
strict: { type: 'boolean', description: '严格模式:把 warnings 也算失败。' },
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
output: {
|
|
228
|
+
schema: {},
|
|
229
|
+
render: (_args, value) => text(value.text),
|
|
230
|
+
},
|
|
231
|
+
async execute(args, exec) {
|
|
232
|
+
const lib = locate(config, exec, args)
|
|
233
|
+
const { report, code } = diag(lib.nodes, { problems: lib.problems, strict: !!args.strict, libRoot: lib.nodekb })
|
|
234
|
+
const text_ = JSON.stringify(report, null, 2)
|
|
235
|
+
if (code !== 0) {
|
|
236
|
+
const e = new Error(text_)
|
|
237
|
+
e.code = 'KB_DIAG_FAILED'
|
|
238
|
+
throw e
|
|
239
|
+
}
|
|
240
|
+
return { ...report, kb: lib.name, text: text_ }
|
|
241
|
+
},
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
// ── kb_backlinks ─────────────────────────────────────────────────────────
|
|
245
|
+
tools.push({
|
|
246
|
+
name: 'kb_backlinks',
|
|
247
|
+
description:
|
|
248
|
+
'反查:谁引用了这个节点([[TID]] 指向它)。改 TID / 删节点之前先跑,能一次列出所有会断的地方——' +
|
|
249
|
+
'比全库 grep 更准(只认真实引用语法,不受正文里偶然出现的同名文字干扰)。',
|
|
250
|
+
parameters: {
|
|
251
|
+
type: 'object',
|
|
252
|
+
properties: {
|
|
253
|
+
target: { type: 'string', description: '节点 TID 或其唯一前缀。' },
|
|
254
|
+
kb: KB_PARAM,
|
|
255
|
+
lib: LIB_PARAM,
|
|
256
|
+
},
|
|
257
|
+
required: ['target'],
|
|
258
|
+
},
|
|
259
|
+
output: {
|
|
260
|
+
schema: {},
|
|
261
|
+
render: (_args, value) => text(value.text),
|
|
262
|
+
},
|
|
263
|
+
async execute(args, exec) {
|
|
264
|
+
const lib = locate(config, exec, args)
|
|
265
|
+
const tid = resolve_path(lib.nodes, String(args.target ?? ''))
|
|
266
|
+
if (tid === null) throw new Error(`未找到节点: ${args.target}`)
|
|
267
|
+
const rows = []
|
|
268
|
+
for (const node of lib.nodes.values()) {
|
|
269
|
+
if (node.tid === tid) continue
|
|
270
|
+
const refs = extract_refs(node.content)
|
|
271
|
+
if (refs.includes(tid)) rows.push({ tid: node.tid, title: node.title, type: node.type, source: node.source })
|
|
272
|
+
}
|
|
273
|
+
const lines = [`引用 ${tid} 的节点:${rows.length} 个`]
|
|
274
|
+
for (const r of rows) lines.push(`- ${r.tid} [${r.type}] ${r.title} ${r.source}`)
|
|
275
|
+
if (!rows.length) lines.push('(没有任何节点引用它)')
|
|
276
|
+
return { target: tid, count: rows.length, backlinks: rows, kb: lib.name, text: warnPrefix(lib) + lines.join('\n') }
|
|
277
|
+
},
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
// ── kb_rename ────────────────────────────────────────────────────────────
|
|
281
|
+
tools.push({
|
|
282
|
+
name: 'kb_rename',
|
|
283
|
+
description:
|
|
284
|
+
'重命名库里的一个 md 文件,并把全库指向它的 [[TID]] 引用一起改写(含 [[tid:1.2]] 这种子节点引用)。' +
|
|
285
|
+
'nodekb 的 TID 由文件名派生,所以「用 mv / 编辑器改名」必然留下一堆悬空引用;要么用这个工具,' +
|
|
286
|
+
'要么改名后自己跑 kb_diag 逐个修。写前会重读校验,别人刚改过的文件不覆盖、只报出来。',
|
|
287
|
+
parameters: {
|
|
288
|
+
type: 'object',
|
|
289
|
+
properties: {
|
|
290
|
+
target: { type: 'string', description: '文件节点的 TID(kb_read --toc 里的 doc:03-x),或相对路径 doc/03-x.md。' },
|
|
291
|
+
new_name: { type: 'string', description: '新文件名(.md 可省略,不能带目录)。' },
|
|
292
|
+
dry_run: { type: 'boolean', description: 'true = 只报「会改哪些引用」,不动文件。' },
|
|
293
|
+
kb: KB_PARAM,
|
|
294
|
+
lib: LIB_PARAM,
|
|
295
|
+
},
|
|
296
|
+
required: ['target', 'new_name'],
|
|
297
|
+
},
|
|
298
|
+
output: {
|
|
299
|
+
schema: {},
|
|
300
|
+
render: (_args, value) => text(value.text),
|
|
301
|
+
},
|
|
302
|
+
async execute(args, exec) {
|
|
303
|
+
const lib = locate(config, exec, args)
|
|
304
|
+
const report = renameFileNode(lib, String(args.target ?? ''), String(args.new_name ?? ''), {
|
|
305
|
+
dryRun: Boolean(args.dry_run),
|
|
306
|
+
})
|
|
307
|
+
return { ...report, kb: lib.name, text: warnPrefix(lib) + formatRename(report) }
|
|
308
|
+
},
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
// ── kb_vcs_status ────────────────────────────────────────────────────────
|
|
312
|
+
tools.push({
|
|
313
|
+
name: 'kb_vcs_status',
|
|
314
|
+
description:
|
|
315
|
+
'查看知识库内嵌 git 仓库的状态(最近提交 + 未提交改动)。nodekb 每次解析后会自动快照,' +
|
|
316
|
+
'所以这里能看到「库的改动历史」;文件系统上有改动但没进快照时也会列出来。git 不可用时返回说明而不报错。',
|
|
317
|
+
parameters: {
|
|
318
|
+
type: 'object',
|
|
319
|
+
properties: {
|
|
320
|
+
kb: KB_PARAM,
|
|
321
|
+
lib: LIB_PARAM,
|
|
322
|
+
},
|
|
323
|
+
},
|
|
324
|
+
output: {
|
|
325
|
+
schema: {},
|
|
326
|
+
render: (_args, value) => text(value.text),
|
|
327
|
+
},
|
|
328
|
+
async execute(args, exec) {
|
|
329
|
+
const lib = locate(config, exec, args)
|
|
330
|
+
const git = (argv) =>
|
|
331
|
+
execFileSync('git', ['-C', lib.nodekb, ...argv], { encoding: 'utf8', timeout: 10000, windowsHide: true }).trim()
|
|
332
|
+
const out = { kb: lib.name, nodekb: lib.nodekb, initialized: false, head: null, log: [], dirty: false, files: 0 }
|
|
333
|
+
try {
|
|
334
|
+
out.initialized = git(['rev-parse', '--is-inside-work-tree']) === 'true'
|
|
335
|
+
} catch {
|
|
336
|
+
out.text = `库 ${lib.name} 未初始化 git(或系统没有 git)。读取与检索不受影响;要历史快照请手动 git init ${lib.nodekb}。`
|
|
337
|
+
return out
|
|
338
|
+
}
|
|
339
|
+
try {
|
|
340
|
+
out.head = git(['rev-parse', '--short', 'HEAD'])
|
|
341
|
+
out.log = git(['log', '--pretty=%h %ad %s', '--date=short', '-n', '8']).split('\n').filter(Boolean)
|
|
342
|
+
} catch {
|
|
343
|
+
/* 空仓库:还没有提交 */
|
|
344
|
+
}
|
|
345
|
+
try {
|
|
346
|
+
const porcelain = git(['status', '--porcelain'])
|
|
347
|
+
const lines = porcelain ? porcelain.split('\n').filter(Boolean) : []
|
|
348
|
+
out.dirty = lines.length > 0
|
|
349
|
+
out.files = lines.length
|
|
350
|
+
out.pending = lines.slice(0, 10)
|
|
351
|
+
} catch {
|
|
352
|
+
/* ignore */
|
|
353
|
+
}
|
|
354
|
+
const lines = [`库 ${lib.name}(${lib.nodekb})`, `HEAD: ${out.head || '(无提交)'}`]
|
|
355
|
+
if (out.log.length) lines.push('最近提交:', ...out.log.map((l) => ' ' + l))
|
|
356
|
+
if (out.dirty) lines.push(`未提交改动: ${out.files} 个文件`, ...(out.pending || []).map((l) => ' ' + l))
|
|
357
|
+
else lines.push('未提交改动: 无')
|
|
358
|
+
out.text = lines.join('\n')
|
|
359
|
+
return out
|
|
360
|
+
},
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
// ── kb_init ──────────────────────────────────────────────────────────────
|
|
364
|
+
tools.push({
|
|
365
|
+
name: 'kb_init',
|
|
366
|
+
description:
|
|
367
|
+
'为一个项目建 .nodekb 知识库骨架(幂等:已存在的文件不覆盖)。' +
|
|
368
|
+
'当用户要在项目里沉淀长期知识、而你准备开始写第一份文档之前,先调这个——' +
|
|
369
|
+
'它一次铺出 doc/ facts/ lessons/ todo/ index/entry.md 与 .nodekb/README.md 里的落位约定,' +
|
|
370
|
+
'比你自己拿 write 从零设计目录结构更省事,也让后续 Agent 知道该往哪写。' +
|
|
371
|
+
'已经有 .nodekb/ 就不要调(先用 kb_list 看)。',
|
|
372
|
+
parameters: {
|
|
373
|
+
type: 'object',
|
|
374
|
+
properties: {
|
|
375
|
+
dir: {
|
|
376
|
+
type: 'string',
|
|
377
|
+
description: '库目录(相对工作区,默认 .nodekb)。要建在技能目录下就传 .dsh/skills/<技能>/.nodekb。',
|
|
378
|
+
},
|
|
379
|
+
},
|
|
380
|
+
},
|
|
381
|
+
output: {
|
|
382
|
+
schema: {},
|
|
383
|
+
render: (_args, value) => text(value.text),
|
|
384
|
+
},
|
|
385
|
+
async execute(args, exec) {
|
|
386
|
+
const ws = workspaceOf(config, exec)
|
|
387
|
+
const rel = typeof args.dir === 'string' && args.dir.trim() ? args.dir.trim() : '.nodekb'
|
|
388
|
+
const target = path.resolve(ws, rel)
|
|
389
|
+
const okRoot = isInside(target, ws) || allowedRoots(config, exec).some((r) => isInside(target, r))
|
|
390
|
+
if (!okRoot) throw new Error(`拒绝在工作区外建库: ${target}`)
|
|
391
|
+
const res = scaffold(target)
|
|
392
|
+
const lines = [`库目录: ${res.nodekb}`]
|
|
393
|
+
lines.push(res.created.length ? `新建 ${res.created.length} 个文件:\n` + res.created.map((f) => ' + ' + f).join('\n') : '没有新建文件(骨架已存在)')
|
|
394
|
+
if (res.skipped.length) lines.push(`跳过 ${res.skipped.length} 个已存在文件: ${res.skipped.join('、')}`)
|
|
395
|
+
lines.push('')
|
|
396
|
+
lines.push('下一步:把这次要记的东西用 kb_write 写进对应目录(先读 .nodekb/README.md 的落位表),写完 kb_diag 一遍。')
|
|
397
|
+
return { text: lines.join('\n'), nodekb: res.nodekb, created: res.created, skipped: res.skipped }
|
|
398
|
+
},
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
// ── kb_write ─────────────────────────────────────────────────────────────
|
|
402
|
+
tools.push({
|
|
403
|
+
name: 'kb_write',
|
|
404
|
+
description:
|
|
405
|
+
'往 nodekb 库里写内容,并在写后立刻自检(悬空引用/位置型引用/重复块)。' +
|
|
406
|
+
'这是「用知识库管知识」的正路:同样写 md,但写前校验 TID 与 <node> 独占一行,写后把结构错误当场报回来;' +
|
|
407
|
+
'直接用 write/edit 没有这一层。' +
|
|
408
|
+
'mode=append 追加到文件(不存在则创建)、replace 整篇覆盖、create 只新建、node 就地改写一个 <node> 块。',
|
|
409
|
+
parameters: {
|
|
410
|
+
type: 'object',
|
|
411
|
+
properties: {
|
|
412
|
+
target: {
|
|
413
|
+
type: 'string',
|
|
414
|
+
description: '库内相对路径(如 lessons/003-cp936.md,建议带 .md)或显式节点 TID(如 fact:abi,配 mode=node)。',
|
|
415
|
+
},
|
|
416
|
+
content: { type: 'string', description: '要写入的 Markdown。node 模式下是 <node> 与 </node> 之间的正文。' },
|
|
417
|
+
mode: {
|
|
418
|
+
type: 'string',
|
|
419
|
+
enum: ['append', 'replace', 'create', 'node'],
|
|
420
|
+
description: '默认:路径→append,TID→node。create 遇到已存在会报错,避免覆盖。',
|
|
421
|
+
},
|
|
422
|
+
kb: KB_PARAM,
|
|
423
|
+
lib: LIB_PARAM,
|
|
424
|
+
},
|
|
425
|
+
required: ['target', 'content'],
|
|
426
|
+
},
|
|
427
|
+
output: {
|
|
428
|
+
schema: {},
|
|
429
|
+
render: (_args, value) => text(value.text),
|
|
430
|
+
},
|
|
431
|
+
async execute(args, exec) {
|
|
432
|
+
const lib = locate(config, exec, args)
|
|
433
|
+
const res = writeInto(lib, args)
|
|
434
|
+
// 写后重开(签名变了会重新解析),把结构问题当场报回来
|
|
435
|
+
let report = null
|
|
436
|
+
try {
|
|
437
|
+
const fresh = openLibrary(config, exec, { kb: args.kb, lib: args.lib })
|
|
438
|
+
report = diag(fresh.nodes, { problems: fresh.problems, libRoot: fresh.nodekb })
|
|
439
|
+
} catch (e) {
|
|
440
|
+
report = { report: { errors: [`写后解析失败: ${e.message}`], warnings: [], node_count: 0 }, code: 2 }
|
|
441
|
+
}
|
|
442
|
+
const r = report.report || report
|
|
443
|
+
const errs = r.errors || []
|
|
444
|
+
const warns = r.warnings || []
|
|
445
|
+
const lines = [`已写入 ${res.rel}(${res.bytes} 字节,mode=${res.mode}${res.created ? ',新建文件' : ''})`]
|
|
446
|
+
if (res.tid) lines.push(`节点: ${res.tid}`)
|
|
447
|
+
lines.push(`库状态: ${r.node_count ?? '?'} 节点,${errs.length} error / ${warns.length} warning`)
|
|
448
|
+
if (errs.length) lines.push('需要修:\n' + errs.slice(0, 6).map((e) => ' ✗ ' + e).join('\n'))
|
|
449
|
+
if (warns.length) lines.push('提示:\n' + warns.slice(0, 4).map((w) => ' ! ' + w).join('\n'))
|
|
450
|
+
return { text: lines.join('\n'), path: res.rel, mode: res.mode, errors: errs.length, warnings: warns.length }
|
|
451
|
+
},
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
// 逐个注册、逐个兜底:宿主若因为某一条定义(schema/字段改名)拒收,
|
|
455
|
+
// 只有那一个工具消失,其余照旧可用(整批 map 会让一处失败带走全部 9 个)。
|
|
456
|
+
const disposers = []
|
|
457
|
+
const failed = []
|
|
458
|
+
for (const t of tools) {
|
|
459
|
+
try {
|
|
460
|
+
const d = ctx.tools.register(t)
|
|
461
|
+
if (typeof d === 'function') disposers.push(d)
|
|
462
|
+
} catch (e) {
|
|
463
|
+
failed.push(t.name)
|
|
464
|
+
console.warn(`[project-node-memory] 工具 ${t.name} 注册失败,其余工具继续:`, e && e.message)
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
if (failed.length) {
|
|
468
|
+
Object.assign(TOOL_STATE, { registered: tools.length - failed.length, failed })
|
|
469
|
+
} else {
|
|
470
|
+
Object.assign(TOOL_STATE, { registered: tools.length, failed: [] })
|
|
471
|
+
}
|
|
472
|
+
return disposers
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* project-node-memory —— 宿主侧入口(Node,ESM)。
|
|
3
|
+
*
|
|
4
|
+
* 一个包两面:
|
|
5
|
+
* - 这一面跑在 DSH 宿主进程:注册 6 个 kb_* 工具 + 给面板用的只读 HTTP 路由
|
|
6
|
+
* - 另一面 lib/client.js 跑在浏览器:侧边栏图标 + 中栏面板
|
|
7
|
+
*
|
|
8
|
+
* 刻意不 import 任何 `@deepseek-ai/*`:那条路会从 profile 的 node_modules 里解析到
|
|
9
|
+
* 另一份运行时,把整个基础层一起打崩(tiddlywiki 版踩过)。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { buildTools } from './host/tools.js'
|
|
13
|
+
import { registerRoutes } from './host/routes.js'
|
|
14
|
+
import { registerPrompt } from './host/prompt.js'
|
|
15
|
+
import { registerSkill } from './host/skill.js'
|
|
16
|
+
|
|
17
|
+
export const name = 'project-node-memory'
|
|
18
|
+
export const inject = ['tools', 'webServer']
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 配置(全部可选):
|
|
22
|
+
* - libraries: { 名字: 技能目录绝对路径 } —— 内联登记库
|
|
23
|
+
* - registries: [注册表 JSON 路径] —— 与 Python 版 kb_registry.json 同格式
|
|
24
|
+
* - workspace: 工作区路径(默认取会话工作区,取不到用进程 cwd)
|
|
25
|
+
* - prompt: { enabled?: false, text?: string } —— 系统提示词里的那段约定(默认开)
|
|
26
|
+
*/
|
|
27
|
+
export function apply(ctx, config) {
|
|
28
|
+
const cfg = config && typeof config === 'object' ? config : {}
|
|
29
|
+
const disposers = []
|
|
30
|
+
// 启动期绝不向外抛:插件装坏了顶多是它自己不可用,不能连累宿主起不来。
|
|
31
|
+
try {
|
|
32
|
+
disposers.push(...buildTools(ctx, cfg))
|
|
33
|
+
} catch (error) {
|
|
34
|
+
console.warn('[project-node-memory] 工具注册失败,插件继续以只读路由方式运行:', error)
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
disposers.push(registerPrompt(ctx, cfg))
|
|
38
|
+
} catch (error) {
|
|
39
|
+
console.warn('[project-node-memory] 提示词段落注册失败,工具与面板不受影响:', error)
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
disposers.push(registerSkill(ctx, cfg))
|
|
43
|
+
} catch (error) {
|
|
44
|
+
console.warn('[project-node-memory] 技能注册失败,工具与面板不受影响:', error)
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
disposers.push(registerRoutes(ctx, cfg))
|
|
48
|
+
} catch (error) {
|
|
49
|
+
console.warn('[project-node-memory] 路由注册失败,面板与工具不影响:', error)
|
|
50
|
+
}
|
|
51
|
+
return () => {
|
|
52
|
+
for (const dispose of disposers) {
|
|
53
|
+
try {
|
|
54
|
+
dispose()
|
|
55
|
+
} catch {
|
|
56
|
+
/* 卸载期忽略 */
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,64 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "project-node-memory",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Project node knowledge base for DSH agents: markdown-as-database, stable TIDs, transclusion projection, structure self-check.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "levodoubt",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/levodoubt/project-node-memory.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/levodoubt/project-node-memory#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/levodoubt/project-node-memory/issues"
|
|
15
|
+
},
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"dsh-plugin",
|
|
21
|
+
"knowledge-base",
|
|
22
|
+
"markdown",
|
|
23
|
+
"context",
|
|
24
|
+
"project-memory"
|
|
25
|
+
],
|
|
26
|
+
"exports": {
|
|
27
|
+
".": {
|
|
28
|
+
"default": "./lib/index.js"
|
|
29
|
+
},
|
|
30
|
+
"./client": {
|
|
31
|
+
"default": "./lib/client.js"
|
|
32
|
+
},
|
|
33
|
+
"./package.json": "./package.json"
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"lib",
|
|
37
|
+
"skills",
|
|
38
|
+
"cordis.patch.yml",
|
|
39
|
+
"README.md",
|
|
40
|
+
"LICENSE",
|
|
41
|
+
"README_EN.md"
|
|
42
|
+
],
|
|
43
|
+
"engines": {
|
|
44
|
+
"node": ">=18"
|
|
45
|
+
},
|
|
46
|
+
"scripts": {
|
|
47
|
+
"check": "node scripts/check.mjs",
|
|
48
|
+
"test": "node --test test/"
|
|
49
|
+
},
|
|
50
|
+
"dsh": {
|
|
51
|
+
"bundle": {
|
|
52
|
+
"patch": "./cordis.patch.yml"
|
|
53
|
+
},
|
|
54
|
+
"client": {
|
|
55
|
+
"platform": "web",
|
|
56
|
+
"inject": [
|
|
57
|
+
"slots"
|
|
58
|
+
]
|
|
59
|
+
},
|
|
60
|
+
"compatibility": {
|
|
61
|
+
"dsh": ">=0.1.5-rc.1"
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: project-node-memory-guide
|
|
3
|
+
description: 用 .nodekb/ + kb_* 工具沉淀项目长期知识(设计决策、踩坑、事实常量、待办、交接)时的写法规范——什么时候该写、文件怎么分、TID 怎么起、先读什么再写什么。准备手写 docs/*.md 之前先读这一条。
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# 写项目节点知识库(`.nodekb/`)
|
|
7
|
+
|
|
8
|
+
## 什么时候该写
|
|
9
|
+
|
|
10
|
+
值得沉淀的:**设计决策与理由**、**踩过的坑(症状 + 根因 + 对策)**、**事实常量**(版本号、路径、阈值、接口字段)、**待办与交接**、**环境和操作手册**。
|
|
11
|
+
|
|
12
|
+
不该写的:一次性的调试输出、能从代码直接读出来的内容(函数签名、目录列表)、临时状态。判据是「下次换个人/换个会话,不知道这条会不会重做一遍错事」。
|
|
13
|
+
|
|
14
|
+
## 先读,再写
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
kb_read mode=toc # 结构 + 每节字符数,几百 token
|
|
18
|
+
kb_read target=<TID> # 按需读一支(引用会就地展开)
|
|
19
|
+
kb_search query=... # toc 里找不到再搜
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
**不要**一次把整库读进上下文。写之前先确认:这个结论是不是已经写在别处了?是的话改那一处,别新开一条(同一结论写两遍,两份迟早打架)。
|
|
23
|
+
|
|
24
|
+
## 目录怎么分
|
|
25
|
+
|
|
26
|
+
| 目录 | 放什么 | 判据 |
|
|
27
|
+
|---|---|---|
|
|
28
|
+
| `doc/` | 主题文档:架构、模块、流程、配置 | 「是什么、怎么运转」 |
|
|
29
|
+
| `facts/` | 常量与事实:版本、路径、阈值、接口 | 一句话能说完、会被反复引用 |
|
|
30
|
+
| `lessons/` | 踩坑记录 | 「症状 → 根因 → 对策」三行齐全 |
|
|
31
|
+
| `todo/` | 未完成事项、阻塞、交接 | 有明确的下一步动作 |
|
|
32
|
+
| `index/entry.md` | 总入口与落位约定 | 只放导航,不放正文 |
|
|
33
|
+
|
|
34
|
+
一个主题一个文件,**单文件取 < 4KB**;变大就按子主题拆成 `## 3.x` 段或独立文件。结论只写一次,别处用 `[[TID]]` 引用。
|
|
35
|
+
|
|
36
|
+
## TID 规则
|
|
37
|
+
|
|
38
|
+
- 文件节点:`<目录>:<文件名 slug>`,例如 `doc/03-工程经验与坑.md` → `doc:03-工程经验与坑`
|
|
39
|
+
- 显式节点:`<node name="abi">` 里的 `name` 原样就是 TID,可跨文件引用
|
|
40
|
+
- 段节点:`## 3.1 xxx` → `<文件TID>:3.1`,但它是**位置型**的,不受引用校验保护——别拿它当引用目标,要引用就用显式节点
|
|
41
|
+
- 引用写成 `[[TID]]` 或 `[[显示文字|TID]]`;改名走 `kb_rename`,它会重写全库引用
|
|
42
|
+
|
|
43
|
+
## 写
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
kb_write target=lessons/003-cp936.md mode=create content=...
|
|
47
|
+
kb_write target=<显式节点TID> mode=node content=... # 只换 <node> 之间的正文
|
|
48
|
+
kb_write target=doc/03-...md mode=append content=...
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
- 写完**看返回值里的校验结果**:悬空引用、重复块、`<node>` 没独占一行,都会当场报回来。看到就修,别留着。
|
|
52
|
+
- 改已有内容用 `append` / `node`,不要 `replace` 整篇(会丢掉别人刚写的段落)。
|
|
53
|
+
- 新建文件前先 `kb_read mode=toc` 确认没有同主题的旧文件。
|
|
54
|
+
|
|
55
|
+
## 三个反例
|
|
56
|
+
|
|
57
|
+
1. 把整段对话贴进 `doc/` —— 记的是过程,不是结论。
|
|
58
|
+
2. 同一件事在 `doc/` 和 `lessons/` 各写一份 —— 引用它,别复制它。
|
|
59
|
+
3. 只写「改了什么」不写「为什么」 —— 半年后没人知道能不能改回去。
|