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,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* routes —— 给浏览器面板用的只读 HTTP API(前缀 /nodekb/api)。
|
|
3
|
+
*
|
|
4
|
+
* 宿主侧与客户端唯一允许的通信方式:插件自己注册的路由。
|
|
5
|
+
* 全部只读;写操作走 write/edit 工具改 md,不需要这里开写接口。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from 'node:fs'
|
|
9
|
+
import path from 'node:path'
|
|
10
|
+
import { render_node, render_subtree, toc_tree, node_info, resolve_path } from '../engine/render.js'
|
|
11
|
+
import { search, formatSearch } from '../engine/search.js'
|
|
12
|
+
import { diag } from '../engine/diag.js'
|
|
13
|
+
import { extract_refs } from '../engine/parse.js'
|
|
14
|
+
import { renameFileNode, formatRename } from '../engine/rename.js'
|
|
15
|
+
import { openLibrary, knownLibraries } from './runtime.js'
|
|
16
|
+
import { promptState } from './prompt.js'
|
|
17
|
+
import { skillState } from './skill.js'
|
|
18
|
+
import { toolState } from './tools.js'
|
|
19
|
+
import { discoverLibraries } from '../engine/registry.js'
|
|
20
|
+
|
|
21
|
+
const PREFIX = '/nodekb/api'
|
|
22
|
+
|
|
23
|
+
function send(res, code, body, type = 'application/json; charset=utf-8') {
|
|
24
|
+
const payload = typeof body === 'string' ? body : JSON.stringify(body)
|
|
25
|
+
res.writeHead(code, {
|
|
26
|
+
'content-type': type,
|
|
27
|
+
'cache-control': 'no-store',
|
|
28
|
+
'content-length': Buffer.byteLength(payload),
|
|
29
|
+
})
|
|
30
|
+
res.end(payload)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 从 source(`path#L12` / `path#L1-L30`)里取出源文件与行范围 */
|
|
34
|
+
export function parseSource(source) {
|
|
35
|
+
const s = String(source || '')
|
|
36
|
+
const hash = s.lastIndexOf('#')
|
|
37
|
+
if (hash < 0) return null
|
|
38
|
+
const file = s.slice(0, hash)
|
|
39
|
+
const m = /^L(\d+)(?:-L(\d+))?$/.exec(s.slice(hash + 1))
|
|
40
|
+
if (!file || !m) return null
|
|
41
|
+
const start = Number(m[1])
|
|
42
|
+
const end = m[2] ? Number(m[2]) : start
|
|
43
|
+
return { file, start, end }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// 路由只能注册一次(同类前缀重名会被 webServer 拒掉),但代码会被反复重载。
|
|
47
|
+
// 中间这一跳让"第一个注册成功的路由"始终把请求转给"最新装载的处理器",
|
|
48
|
+
// 这样升级插件不需要重启 DSH 也能换上新逻辑。
|
|
49
|
+
const LIVE_HANDLER = Symbol.for('project-node-memory.live-handler.v1')
|
|
50
|
+
// 改名前装过旧版(行 id dsh-nodekb)时,旧 route 的闭包还在用的旧符号。
|
|
51
|
+
// 两边同时写,旧 route 才能把请求转给新版处理器 —— 改名的升级不需要重启。
|
|
52
|
+
const LIVE_HANDLER_OLD = Symbol.for('dsh-nodekb.live-handler.v1')
|
|
53
|
+
|
|
54
|
+
// 最近一次浏览器半边的自证(GET /nodekb/api/client-report)
|
|
55
|
+
let CLIENT_REPORT = null
|
|
56
|
+
|
|
57
|
+
export function registerRoutes(ctx, config) {
|
|
58
|
+
const handler = async (req, res) => {
|
|
59
|
+
let url
|
|
60
|
+
try {
|
|
61
|
+
url = new URL(req.url || '/', 'http://127.0.0.1')
|
|
62
|
+
} catch {
|
|
63
|
+
send(res, 400, { error: 'bad url' })
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
const q = url.searchParams
|
|
67
|
+
const kb = q.get('kb') || undefined
|
|
68
|
+
const libPath = q.get('lib') || undefined
|
|
69
|
+
// 面板没有会话上下文:允许前端显式给一个工作区(人指定一次,存在 localStorage)
|
|
70
|
+
const wsParam = q.get('ws') || undefined
|
|
71
|
+
const exec = wsParam ? { cwd: wsParam } : null
|
|
72
|
+
// 面板给 ws 是显式选择,别拿它覆盖 Agent 侧记住的默认工作区
|
|
73
|
+
const open = () => openLibrary(config, exec, { kb, lib: libPath, remember: !wsParam })
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
if (url.pathname === `${PREFIX}/version`) {
|
|
77
|
+
// 探针用:确认活着的处理器是哪一版(插件热载后老路由可能还在)
|
|
78
|
+
let pkg = {}
|
|
79
|
+
try {
|
|
80
|
+
pkg = JSON.parse(fs.readFileSync(new URL('../../package.json', import.meta.url), 'utf8'))
|
|
81
|
+
} catch {
|
|
82
|
+
/* 读不到就只报进程信息 */
|
|
83
|
+
}
|
|
84
|
+
send(res, 200, { name: pkg.name, version: pkg.version, node: process.version, pid: process.pid })
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (url.pathname === `${PREFIX}/libs`) {
|
|
89
|
+
send(res, 200, { libs: knownLibraries(config, exec) })
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (url.pathname === `${PREFIX}/prompt`) {
|
|
94
|
+
// 自证:提示词段落到底有没有被宿主接上(不接上,Agent 就永远优先用 write)
|
|
95
|
+
send(res, 200, promptState())
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (url.pathname === `${PREFIX}/skill`) {
|
|
100
|
+
// 自证:随包技能(写法规范)有没有进宿主 skill 注册表
|
|
101
|
+
send(res, 200, skillState())
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (url.pathname === `${PREFIX}/tools`) {
|
|
106
|
+
// 自证:宿主收下了几个 kb_* 工具(被拒收的那条会出现在 failed 里)
|
|
107
|
+
send(res, 200, toolState())
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (url.pathname === `${PREFIX}/workspaces`) {
|
|
112
|
+
// 面板没有会话上下文,但宿主有 DSH 自己的 workspace 注册表:
|
|
113
|
+
// 把「这个 DSH 用过哪些项目目录」直接给面板,省掉让人手打路径。
|
|
114
|
+
const out = []
|
|
115
|
+
try {
|
|
116
|
+
const reg = typeof ctx.get === 'function' ? ctx.get('workspaceRegistry') : ctx.workspaceRegistry
|
|
117
|
+
const list = reg && typeof reg.list === 'function' ? reg.list() : []
|
|
118
|
+
for (const w of list || []) {
|
|
119
|
+
const dir = String(w.path || '')
|
|
120
|
+
if (!dir) continue
|
|
121
|
+
let found = 0
|
|
122
|
+
try {
|
|
123
|
+
found = discoverLibraries(dir).length
|
|
124
|
+
} catch {
|
|
125
|
+
found = 0
|
|
126
|
+
}
|
|
127
|
+
out.push({
|
|
128
|
+
id: String(w.id || ''),
|
|
129
|
+
title: String(w.title || ''),
|
|
130
|
+
path: dir,
|
|
131
|
+
nodekb: found > 0,
|
|
132
|
+
libs: found,
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
} catch (e) {
|
|
136
|
+
send(res, 500, { error: String((e && e.message) || e), workspaces: [] })
|
|
137
|
+
return
|
|
138
|
+
}
|
|
139
|
+
send(res, 200, { workspaces: out })
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (url.pathname === `${PREFIX}/tree`) {
|
|
144
|
+
const lib = open()
|
|
145
|
+
send(res, 200, {
|
|
146
|
+
kb: lib.name,
|
|
147
|
+
base: lib.base,
|
|
148
|
+
nodekb: lib.nodekb,
|
|
149
|
+
problems: lib.problems,
|
|
150
|
+
nodeCount: lib.nodes.size,
|
|
151
|
+
tree: toc_tree(lib.nodes, 'root'),
|
|
152
|
+
})
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (url.pathname === `${PREFIX}/node`) {
|
|
157
|
+
const lib = open()
|
|
158
|
+
const target = q.get('tid') || 'root'
|
|
159
|
+
const mode = q.get('mode') || 'subtree'
|
|
160
|
+
const tid = resolve_path(lib.nodes, target)
|
|
161
|
+
if (tid === null) {
|
|
162
|
+
send(res, 404, { error: `未找到节点: ${target}`, kb: lib.name })
|
|
163
|
+
return
|
|
164
|
+
}
|
|
165
|
+
const node = lib.nodes.get(tid)
|
|
166
|
+
let text
|
|
167
|
+
if (mode === 'node') text = render_node(lib.nodes, tid)
|
|
168
|
+
else if (mode === 'raw') text = node.content
|
|
169
|
+
else text = render_subtree(lib.nodes, tid)
|
|
170
|
+
send(res, 200, {
|
|
171
|
+
kb: lib.name,
|
|
172
|
+
...node_info(lib.nodes, tid),
|
|
173
|
+
kind: node.kind,
|
|
174
|
+
mode,
|
|
175
|
+
text,
|
|
176
|
+
children: [...lib.nodes.values()].filter((c) => c.parent_tid === tid).map((c) => c.tid),
|
|
177
|
+
})
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (url.pathname === `${PREFIX}/source`) {
|
|
182
|
+
const lib = open()
|
|
183
|
+
const target = q.get('tid') || 'root'
|
|
184
|
+
const tid = resolve_path(lib.nodes, target)
|
|
185
|
+
if (tid === null) {
|
|
186
|
+
send(res, 404, { error: `未找到节点: ${target}` })
|
|
187
|
+
return
|
|
188
|
+
}
|
|
189
|
+
const src = parseSource(lib.nodes.get(tid).source)
|
|
190
|
+
if (!src) {
|
|
191
|
+
send(res, 404, { error: '该节点没有源文件(文件夹节点)' })
|
|
192
|
+
return
|
|
193
|
+
}
|
|
194
|
+
const all = fs.readFileSync(src.file, 'utf8').replace(/^+/, '').split(/\r\n|\n|\r/)
|
|
195
|
+
// 文件节点给整篇(源里 L1-Ln),段落节点给该行起的一段窗口
|
|
196
|
+
const from = src.start - 1
|
|
197
|
+
const to = Math.min(all.length, src.end > src.start ? src.end : src.start + 60)
|
|
198
|
+
send(res, 200, {
|
|
199
|
+
kb: lib.name,
|
|
200
|
+
path: src.file,
|
|
201
|
+
offset: src.start,
|
|
202
|
+
totalLines: all.length,
|
|
203
|
+
lines: all.slice(from, to).map((textLine, i) => ({ number: from + i + 1, text: textLine })),
|
|
204
|
+
})
|
|
205
|
+
return
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (url.pathname === `${PREFIX}/search`) {
|
|
209
|
+
const lib = open()
|
|
210
|
+
const query = q.get('q') || ''
|
|
211
|
+
const res0 = search(lib.nodes, query, {
|
|
212
|
+
type: q.get('type') || undefined,
|
|
213
|
+
limit: Number(q.get('limit') || 20),
|
|
214
|
+
offset: Number(q.get('offset') || 0),
|
|
215
|
+
})
|
|
216
|
+
send(res, 200, { kb: lib.name, ...res0, text: formatSearch(query, res0) })
|
|
217
|
+
return
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (url.pathname === `${PREFIX}/diag`) {
|
|
221
|
+
const lib = open()
|
|
222
|
+
const { report } = diag(lib.nodes, { problems: lib.problems, libRoot: lib.nodekb })
|
|
223
|
+
send(res, 200, { kb: lib.name, ...report })
|
|
224
|
+
return
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (url.pathname === `${PREFIX}/backlinks`) {
|
|
228
|
+
const lib = open()
|
|
229
|
+
const target = q.get('tid') || ''
|
|
230
|
+
const tid = resolve_path(lib.nodes, target)
|
|
231
|
+
if (tid === null) {
|
|
232
|
+
send(res, 404, { error: `未找到节点: ${target}` })
|
|
233
|
+
return
|
|
234
|
+
}
|
|
235
|
+
const rows = []
|
|
236
|
+
for (const node of lib.nodes.values()) {
|
|
237
|
+
if (node.tid !== tid && extract_refs(node.content).includes(tid)) {
|
|
238
|
+
rows.push({ tid: node.tid, title: node.title, type: node.type, source: node.source })
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
send(res, 200, { kb: lib.name, target: tid, backlinks: rows })
|
|
242
|
+
return
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// 客户端自证通道:浏览器半边 POST 自己的加载状态,人/Agent 用 GET 读。
|
|
246
|
+
if (url.pathname === `${PREFIX}/client-report`) {
|
|
247
|
+
if (req.method === 'POST') {
|
|
248
|
+
let body = ''
|
|
249
|
+
for await (const chunk of req) body += chunk
|
|
250
|
+
try {
|
|
251
|
+
CLIENT_REPORT = JSON.parse(body || '{}')
|
|
252
|
+
} catch {
|
|
253
|
+
CLIENT_REPORT = { unparsable: String(body).slice(0, 2000) }
|
|
254
|
+
}
|
|
255
|
+
CLIENT_REPORT = { ...CLIENT_REPORT, at: new Date().toISOString() }
|
|
256
|
+
send(res, 200, { ok: true })
|
|
257
|
+
return
|
|
258
|
+
}
|
|
259
|
+
send(res, 200, { report: CLIENT_REPORT })
|
|
260
|
+
return
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (url.pathname === `${PREFIX}/rename`) {
|
|
264
|
+
const lib = open()
|
|
265
|
+
const report = renameFileNode(lib, q.get('target') || '', q.get('new_name') || '', {
|
|
266
|
+
dryRun: q.get('dry_run') === '1' || q.get('dry_run') === 'true',
|
|
267
|
+
})
|
|
268
|
+
send(res, 200, { kb: lib.name, ...report, text: formatRename(report) })
|
|
269
|
+
return
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
send(res, 404, { error: `未知端点: ${url.pathname}` })
|
|
273
|
+
} catch (e) {
|
|
274
|
+
send(res, 400, { error: e && e.message ? e.message : String(e) })
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// 让已注册的那条 route 转发到本次装载的处理器
|
|
279
|
+
globalThis[LIVE_HANDLER] = handler
|
|
280
|
+
globalThis[LIVE_HANDLER_OLD] = handler
|
|
281
|
+
const spec = {
|
|
282
|
+
kind: 'prefix',
|
|
283
|
+
path: PREFIX,
|
|
284
|
+
handler: (req, res) => {
|
|
285
|
+
const live = globalThis[LIVE_HANDLER] || globalThis[LIVE_HANDLER_OLD]
|
|
286
|
+
return live ? live(req, res) : send(res, 503, { error: 'project-node-memory 未就绪' })
|
|
287
|
+
},
|
|
288
|
+
}
|
|
289
|
+
try {
|
|
290
|
+
return ctx.webServer.register(spec)
|
|
291
|
+
} catch (e) {
|
|
292
|
+
// 升级插件时旧进程里的 route 还没释放,会撞 "duplicate prefix route"。
|
|
293
|
+
// 这不该让整行激活失败(工具仍然可用);上面的 LIVE_HANDLER 已保证旧 route 也会走到新逻辑。
|
|
294
|
+
if (/duplicate|already registered|conflict/i.test(String((e && e.message) || e))) {
|
|
295
|
+
console.warn(`[project-node-memory] 路由 ${PREFIX} 已由更早的实例注册,本次沿用(请求已转发到最新处理器)。`)
|
|
296
|
+
return () => {
|
|
297
|
+
if (globalThis[LIVE_HANDLER] === handler) delete globalThis[LIVE_HANDLER]
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
throw e
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export { PREFIX }
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* runtime —— 库的打开与缓存(工具的共用底层)。
|
|
3
|
+
*
|
|
4
|
+
* 缓存键是 .nodekb 的绝对路径,有效性靠「全库 md 的 mtime/size 签名」判断:
|
|
5
|
+
* 编辑任何一个 md 都会让签名变化,所以不需要 fs.watch(也就没有 watcher 丢事件的问题)。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from 'node:fs'
|
|
9
|
+
import path from 'node:path'
|
|
10
|
+
import { parse_library } from '../engine/mdlib.js'
|
|
11
|
+
import { resolveLibrary, listLibraries } from '../engine/registry.js'
|
|
12
|
+
|
|
13
|
+
const CACHE = new Map() // nodekb -> { sig, nodes, problems }
|
|
14
|
+
const MAX_CACHE = 8
|
|
15
|
+
|
|
16
|
+
// 面板没有会话上下文,只能靠人指定工作区(ws 参数)。
|
|
17
|
+
// 但 Agent 调 kb_* 工具时是带着工作区的——把那一次记下来,
|
|
18
|
+
// 面板就能「跟着 Agent 走」,不必每次手填目录。
|
|
19
|
+
let LAST_WORKSPACE = ''
|
|
20
|
+
|
|
21
|
+
export function rememberWorkspace(ws) {
|
|
22
|
+
if (typeof ws === 'string' && ws) LAST_WORKSPACE = path.resolve(ws)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function lastWorkspace() {
|
|
26
|
+
return LAST_WORKSPACE
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function signature(nodekb) {
|
|
30
|
+
let count = 0
|
|
31
|
+
let maxM = 0
|
|
32
|
+
let size = 0
|
|
33
|
+
const walk = (d) => {
|
|
34
|
+
let entries
|
|
35
|
+
try {
|
|
36
|
+
entries = fs.readdirSync(d, { withFileTypes: true })
|
|
37
|
+
} catch {
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
for (const e of entries) {
|
|
41
|
+
if (e.name.startsWith('.')) continue // 与 parse_library 一致:跳过点目录/点文件
|
|
42
|
+
const p = path.join(d, e.name)
|
|
43
|
+
if (e.isDirectory()) {
|
|
44
|
+
walk(p)
|
|
45
|
+
} else if (e.name.endsWith('.md')) {
|
|
46
|
+
try {
|
|
47
|
+
const st = fs.statSync(p)
|
|
48
|
+
count += 1
|
|
49
|
+
size += st.size
|
|
50
|
+
if (st.mtimeMs > maxM) maxM = st.mtimeMs
|
|
51
|
+
} catch {
|
|
52
|
+
/* 竞态删除:忽略 */
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
walk(nodekb)
|
|
58
|
+
return `${count}:${size}:${maxM}`
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** 取会话工作区:配置优先 → exec 上下文 → 进程 cwd */
|
|
62
|
+
export function workspaceOf(config, exec) {
|
|
63
|
+
const configured = config && typeof config.workspace === 'string' ? config.workspace : ''
|
|
64
|
+
if (configured) return path.resolve(configured)
|
|
65
|
+
const env = process.env.DSH_NODEKB_WORKSPACE
|
|
66
|
+
if (env) return path.resolve(env)
|
|
67
|
+
const e = exec && typeof exec === 'object' ? exec : {}
|
|
68
|
+
const candidates = [e.workspace, e.cwd, e.session && e.session.workspace, e.session && e.session.cwd]
|
|
69
|
+
for (const c of candidates) if (typeof c === 'string' && c) return path.resolve(c)
|
|
70
|
+
if (LAST_WORKSPACE) return LAST_WORKSPACE
|
|
71
|
+
return process.cwd()
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 打开一个库(带缓存)。
|
|
76
|
+
* @returns {{name:string, base:string, nodekb:string, nodes:Map, problems:string[], cached:boolean}}
|
|
77
|
+
*/
|
|
78
|
+
export function openLibrary(config, exec, { kb, lib, remember = true } = {}) {
|
|
79
|
+
const workspace = workspaceOf(config, exec)
|
|
80
|
+
// 只有「Agent 带着会话工作区」的调用才值得记下来;
|
|
81
|
+
// 面板用 ws 参数指定目录是显式选择,别拿它污染工具侧的默认工作区。
|
|
82
|
+
if (remember) rememberWorkspace(workspace)
|
|
83
|
+
const resolved = resolveLibrary({ kb, lib, workspace, config })
|
|
84
|
+
const sig = signature(resolved.nodekb)
|
|
85
|
+
const hit = CACHE.get(resolved.nodekb)
|
|
86
|
+
if (hit && hit.sig === sig) {
|
|
87
|
+
return { ...resolved, nodes: hit.nodes, problems: hit.problems, cached: true }
|
|
88
|
+
}
|
|
89
|
+
const problems = []
|
|
90
|
+
const nodes = parse_library(resolved.nodekb, problems)
|
|
91
|
+
CACHE.set(resolved.nodekb, { sig, nodes, problems })
|
|
92
|
+
if (CACHE.size > MAX_CACHE) CACHE.delete(CACHE.keys().next().value)
|
|
93
|
+
return { ...resolved, nodes, problems, cached: false }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function knownLibraries(config, exec) {
|
|
97
|
+
return listLibraries({ workspace: workspaceOf(config, exec), config })
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function libraryHint(config, exec) {
|
|
101
|
+
try {
|
|
102
|
+
const rows = knownLibraries(config, exec)
|
|
103
|
+
if (!rows.length) return '当前没有可用库。在工作区(或已注册的技能目录)下建 .nodekb/ 并放入 md 文件即可。'
|
|
104
|
+
return `可用库: ${rows.map((r) => `${r.name}${r.exists ? '' : '(路径不存在)'}`).join('、')}`
|
|
105
|
+
} catch {
|
|
106
|
+
return ''
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* skill —— 随包分发一条「怎么写这个知识库」的技能。
|
|
3
|
+
*
|
|
4
|
+
* 为什么要做:系统提示词那段只能点到为止(常驻 token 有预算),而「文件怎么分、
|
|
5
|
+
* TID 怎么起、先读什么再写什么」这种规范篇幅不小、又不是每一轮都需要。skill 正好
|
|
6
|
+
* 是「按需加载、不用时不占提示词预算」的容器。
|
|
7
|
+
*
|
|
8
|
+
* 为什么不是把 SKILL.md 拷进用户的 $DSH_HOME/skills:那样能生效,但会在用户家目录
|
|
9
|
+
* 留下插件无权回收的文件(升级残留、卸载残留),而且需要该目录可写。宿主的 skills
|
|
10
|
+
* 服务提供 `register()`,把技能注册在内存里——随包版本走、禁用即撤回、零写入。
|
|
11
|
+
*
|
|
12
|
+
* 技能注册是**可选服务**,所以走动态注入:服务不在时插件照常起来(工具、面板、
|
|
13
|
+
* 提示词段落都不受影响)。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import fs from 'node:fs'
|
|
17
|
+
import { fileURLToPath } from 'node:url'
|
|
18
|
+
|
|
19
|
+
/** 必须是 kebab-case(宿主 skill 注册表按这个名字做唯一键) */
|
|
20
|
+
export const SKILL_NAME = 'project-node-memory-guide'
|
|
21
|
+
export const SKILL_SOURCE = 'project-node-memory'
|
|
22
|
+
|
|
23
|
+
export const DEFAULT_SKILL_DESCRIPTION =
|
|
24
|
+
'用 .nodekb/ + kb_* 工具沉淀项目长期知识时的写法规范:什么时候该写、文件怎么分、TID 怎么起、先读什么再写什么。准备手写 docs/*.md 之前先读这一条。'
|
|
25
|
+
|
|
26
|
+
/** 随包 SKILL.md 的位置:lib/host/ 上两级就是包根,skills/ 是它的兄弟目录。 */
|
|
27
|
+
function skillFileCandidates() {
|
|
28
|
+
return [new URL('../../skills/project-node-memory-guide/SKILL.md', import.meta.url)]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 读随包 SKILL.md;读不到返回 undefined(不抛)。 */
|
|
32
|
+
export function readBundledSkillText() {
|
|
33
|
+
for (const url of skillFileCandidates()) {
|
|
34
|
+
try {
|
|
35
|
+
return fs.readFileSync(fileURLToPath(url), 'utf8')
|
|
36
|
+
} catch {
|
|
37
|
+
/* 试下一个候选 */
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return undefined
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 拆出 YAML front matter。
|
|
45
|
+
* 只认简单 `key: value`(够用即可,不引依赖);值两端的引号会剥掉。
|
|
46
|
+
*/
|
|
47
|
+
export function splitFrontMatter(text) {
|
|
48
|
+
const src = String(text ?? '').replace(/^\uFEFF/, '')
|
|
49
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(src)
|
|
50
|
+
if (!m) return { meta: {}, body: src }
|
|
51
|
+
const meta = {}
|
|
52
|
+
for (const line of m[1].split(/\r?\n/)) {
|
|
53
|
+
const kv = /^([A-Za-z_][\w-]*)\s*:\s*(.*)$/.exec(line.trim())
|
|
54
|
+
if (!kv) continue
|
|
55
|
+
let v = kv[2].trim()
|
|
56
|
+
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1)
|
|
57
|
+
meta[kv[1]] = v
|
|
58
|
+
}
|
|
59
|
+
return { meta, body: src.slice(m[0].length) }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 最近一次注册结果(给 /nodekb/api/skill 自证用) */
|
|
63
|
+
const STATE = { registered: false, reason: 'not-run', name: SKILL_NAME, chars: 0 }
|
|
64
|
+
|
|
65
|
+
export function skillState() {
|
|
66
|
+
return { ...STATE }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 把随包 SKILL.md 注册成宿主内存里的 skill。
|
|
71
|
+
* @returns {() => void} disposer(未接上 skills 服务时是空函数)
|
|
72
|
+
*/
|
|
73
|
+
export function registerSkill(ctx, config) {
|
|
74
|
+
const c = (config && config.skill) || {}
|
|
75
|
+
if (c.enabled === false) {
|
|
76
|
+
Object.assign(STATE, { registered: false, reason: 'disabled-by-config', chars: 0 })
|
|
77
|
+
return () => {}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const raw = typeof c.text === 'string' && c.text.trim() ? c.text : readBundledSkillText()
|
|
81
|
+
if (typeof raw !== 'string' || !raw.trim()) {
|
|
82
|
+
Object.assign(STATE, { registered: false, reason: 'missing-skill-file', chars: 0 })
|
|
83
|
+
console.warn('[project-node-memory] 随包 SKILL.md 缺失,技能不注册(工具与面板不受影响)')
|
|
84
|
+
return () => {}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const { meta, body } = splitFrontMatter(raw)
|
|
88
|
+
const name = typeof meta.name === 'string' && meta.name ? meta.name : SKILL_NAME
|
|
89
|
+
const description =
|
|
90
|
+
(typeof c.description === 'string' && c.description.trim()) ||
|
|
91
|
+
(typeof meta.description === 'string' && meta.description.trim()) ||
|
|
92
|
+
DEFAULT_SKILL_DESCRIPTION
|
|
93
|
+
const content = body.trim()
|
|
94
|
+
const skill = {
|
|
95
|
+
name,
|
|
96
|
+
description,
|
|
97
|
+
content,
|
|
98
|
+
source: SKILL_SOURCE,
|
|
99
|
+
// 模型和用户都能调:/skill 命令与目录都要看到它
|
|
100
|
+
invocation: { modelInvocable: true, userInvocable: true },
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const attach = (svc) => {
|
|
104
|
+
if (!svc || typeof svc.register !== 'function') {
|
|
105
|
+
Object.assign(STATE, { registered: false, reason: 'no-skills-service', chars: 0 })
|
|
106
|
+
return null
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
const dispose = svc.register(skill)
|
|
110
|
+
Object.assign(STATE, { registered: true, reason: 'ok', name, chars: content.length })
|
|
111
|
+
return () => {
|
|
112
|
+
Object.assign(STATE, { registered: false, reason: 'disposed', chars: 0 })
|
|
113
|
+
if (typeof dispose === 'function') dispose()
|
|
114
|
+
}
|
|
115
|
+
} catch (e) {
|
|
116
|
+
Object.assign(STATE, { registered: false, reason: 'error: ' + (e && e.message), chars: 0 })
|
|
117
|
+
console.warn('[project-node-memory] 技能注册失败:', e && e.message)
|
|
118
|
+
return null
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// 首选:动态注入(skills 服务可能晚于本插件就绪)
|
|
123
|
+
if (typeof ctx.inject === 'function') {
|
|
124
|
+
let off = null
|
|
125
|
+
try {
|
|
126
|
+
const effect = ctx.inject(['skills'], (child) => {
|
|
127
|
+
off = attach(child && child.skills)
|
|
128
|
+
return off || undefined
|
|
129
|
+
})
|
|
130
|
+
return () => {
|
|
131
|
+
try {
|
|
132
|
+
if (typeof effect === 'function') effect()
|
|
133
|
+
else if (effect && typeof effect.dispose === 'function') effect.dispose()
|
|
134
|
+
} catch {
|
|
135
|
+
/* 卸载期忽略 */
|
|
136
|
+
}
|
|
137
|
+
if (off) off()
|
|
138
|
+
}
|
|
139
|
+
} catch (e) {
|
|
140
|
+
Object.assign(STATE, { registered: false, reason: 'inject-error: ' + (e && e.message), chars: 0 })
|
|
141
|
+
return () => {}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// 退路:直接取
|
|
146
|
+
let svc = null
|
|
147
|
+
try {
|
|
148
|
+
svc = ctx.skills || (typeof ctx.get === 'function' ? ctx.get('skills') : null)
|
|
149
|
+
} catch {
|
|
150
|
+
svc = null
|
|
151
|
+
}
|
|
152
|
+
if (!svc) {
|
|
153
|
+
Object.assign(STATE, { registered: false, reason: 'no-skills-service', chars: 0 })
|
|
154
|
+
return () => {}
|
|
155
|
+
}
|
|
156
|
+
const off = attach(svc)
|
|
157
|
+
return () => {
|
|
158
|
+
if (off) off()
|
|
159
|
+
}
|
|
160
|
+
}
|