project-node-memory 1.1.0 → 1.1.2
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/README.md +4 -3
- package/README_EN.md +3 -2
- package/lib/engine/migrate.js +171 -0
- package/lib/host/prompt.js +1 -1
- package/lib/host/routes.js +9 -0
- package/lib/host/tools.js +28 -0
- package/package.json +1 -1
- package/skills/project-node-memory-guide/SKILL.md +1 -0
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
> 简体中文 · [English](README_EN.md)
|
|
4
4
|
|
|
5
|
-
把 **Markdown 目录当成知识库** 的 DSH 插件:为 Agent 提供
|
|
5
|
+
把 **Markdown 目录当成知识库** 的 DSH 插件:为 Agent 提供 10 个 `kb_*` 工具,为人类提供知识节点面板(侧栏入口名「项目节点知识库」)。
|
|
6
6
|
|
|
7
7
|
没有索引数据库、没有向量、没有后台进程。完整知识库位于项目下的 `.nodekb/` 目录——一堆 md 文件,删掉插件之后它仍然是普通 md。
|
|
8
8
|
|
|
@@ -92,7 +92,7 @@ config:
|
|
|
92
92
|
面板只读,数据走宿主侧注册的 `/nodekb/api/*`。写库就是写 md——用 `write` / `edit` 工具,或者 `kb_write`。
|
|
93
93
|
右栏正文单次最多渲染 10000 字符(超出显示截断提示);宽表格横向滚动,长文本纵向滚动。
|
|
94
94
|
|
|
95
|
-
##
|
|
95
|
+
## 10 个工具
|
|
96
96
|
|
|
97
97
|
| 工具 | 干什么 |
|
|
98
98
|
| --------------- | ---------------------------------------------------------------------------------------------- |
|
|
@@ -103,7 +103,8 @@ config:
|
|
|
103
103
|
| `kb_backlinks` | 谁引用了它。改 TID / 删节点前先跑 |
|
|
104
104
|
| `kb_init` | 建 `.nodekb` 骨架(幂等),铺出 `doc/ lessons/ todo/ index/` 与落位约定 |
|
|
105
105
|
| `kb_write` | 写库:`append` / `replace` / `create` / `node`(就地改写一个 `<node>` 块);写前校验、写后自检 |
|
|
106
|
-
| `kb_rename` | 文件改名 +
|
|
106
|
+
| `kb_rename` | 文件改名 + 全库引用一起改写;写前重读,别人刚改的不覆盖 |
|
|
107
|
+
| `kb_migrate` | 旧语法裸引用 `[[x]]` → `[[node="x"]]` / `[[tscl="x"]]`;**默认只演练**,`dry_run=false` 才落盘 |
|
|
107
108
|
| `kb_vcs_status` | 库内嵌 git 的快照历史与未提交改动(git 缺失时只说明,不报错) |
|
|
108
109
|
|
|
109
110
|
## 库怎么定位
|
package/README_EN.md
CHANGED
|
@@ -89,7 +89,7 @@ The Markdown renderer is a small home-grown one (headings, lists, code, tables,
|
|
|
89
89
|
The panel is read-only; data flows through host routes under `/nodekb/api/*`. Writing a library means writing Markdown — via `write` / `edit`, or `kb_write`.
|
|
90
90
|
The right column renders at most 10,000 characters per view (with a truncation notice); wide tables scroll horizontally and long text scrolls vertically.
|
|
91
91
|
|
|
92
|
-
## The
|
|
92
|
+
## The ten tools
|
|
93
93
|
|
|
94
94
|
| Tool | What it does |
|
|
95
95
|
|---|---|
|
|
@@ -100,7 +100,8 @@ The right column renders at most 10,000 characters per view (with a truncation n
|
|
|
100
100
|
| `kb_backlinks` | Who references this node. Run before renaming a TID or deleting a node |
|
|
101
101
|
| `kb_init` | Scaffold `.nodekb` (idempotent): `doc/ lessons/ todo/ index/` plus placement conventions |
|
|
102
102
|
| `kb_write` | Write into the library: `append` / `replace` / `create` / `node` (rewrite one `<node>` block in place); validates before writing, self-checks after |
|
|
103
|
-
| `kb_rename` | Rename a file and rewrite every
|
|
103
|
+
| `kb_rename` | Rename a file and rewrite every reference pointing at it; re-reads before writing, so concurrent edits are never clobbered |
|
|
104
|
+
| `kb_migrate` | Rewrites legacy bare refs `[[x]]` into `[[node="x"]]` / `[[tscl="x"]]`; **dry-run by default**, pass `dry_run=false` to write |
|
|
104
105
|
| `kb_vcs_status` | Snapshot history and uncommitted changes of the library's embedded git repo (explains itself when git is missing) |
|
|
105
106
|
|
|
106
107
|
## How a library is located
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* migrate —— 把旧写法裸引用 `[[x]]` 迁成 1.1 的双语法。
|
|
3
|
+
*
|
|
4
|
+
* 规则:
|
|
5
|
+
* - x 是 tscl 定义 id → `[[tscl="x"]]`(值嵌入)
|
|
6
|
+
* - x 是节点 TID → `[[node="x"]]`(链接)
|
|
7
|
+
* - 两者都不是 → 原样保留,记进 unresolved(本来就悬空,迁移不该猜)
|
|
8
|
+
*
|
|
9
|
+
* 三条纪律与 rename 一致:围栏内不动、写前重读复核 mtime/size、默认只演练(dry_run)。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import fs from 'node:fs'
|
|
13
|
+
import path from 'node:path'
|
|
14
|
+
import { ROOT_TID } from './mdlib.js'
|
|
15
|
+
|
|
16
|
+
const FENCE = '```'
|
|
17
|
+
const BARE_REF = /\[\[([^[\]"=\s][^[\]]*)\]\]/g
|
|
18
|
+
|
|
19
|
+
function absOf(lib, node) {
|
|
20
|
+
const raw = String(node.source || '').split('#')[0]
|
|
21
|
+
if (!raw) return ''
|
|
22
|
+
return path.isAbsolute(raw) ? path.normalize(raw) : path.join(lib.nodekb, raw)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function relOf(lib, abs) {
|
|
26
|
+
return path.relative(lib.nodekb, abs).split(path.sep).join('/')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function allMarkdown(nodekb) {
|
|
30
|
+
const out = []
|
|
31
|
+
const walk = (d) => {
|
|
32
|
+
let entries
|
|
33
|
+
try {
|
|
34
|
+
entries = fs.readdirSync(d, { withFileTypes: true })
|
|
35
|
+
} catch {
|
|
36
|
+
return
|
|
37
|
+
}
|
|
38
|
+
for (const e of entries) {
|
|
39
|
+
if (e.name.startsWith('.')) continue
|
|
40
|
+
const p = path.join(d, e.name)
|
|
41
|
+
if (e.isDirectory()) walk(p)
|
|
42
|
+
else if (e.name.endsWith('.md')) out.push(p)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
walk(nodekb)
|
|
46
|
+
return out
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 纯函数:把一段 md 文本里的裸引用改写(可单测) */
|
|
50
|
+
export function migrateText(text, { isTid, isDef }) {
|
|
51
|
+
const out = []
|
|
52
|
+
const hits = { node: 0, tscl: 0, unresolved: [] }
|
|
53
|
+
let fenced = false
|
|
54
|
+
for (const line of String(text).split('\n')) {
|
|
55
|
+
if (line.trim().startsWith(FENCE)) {
|
|
56
|
+
fenced = !fenced
|
|
57
|
+
out.push(line)
|
|
58
|
+
continue
|
|
59
|
+
}
|
|
60
|
+
if (fenced) {
|
|
61
|
+
out.push(line)
|
|
62
|
+
continue
|
|
63
|
+
}
|
|
64
|
+
out.push(
|
|
65
|
+
line.replace(BARE_REF, (whole, inner) => {
|
|
66
|
+
const id = inner.trim()
|
|
67
|
+
if (isDef(id)) {
|
|
68
|
+
hits.tscl += 1
|
|
69
|
+
return `[[tscl="${id}"]]`
|
|
70
|
+
}
|
|
71
|
+
if (isTid(id)) {
|
|
72
|
+
hits.node += 1
|
|
73
|
+
return `[[node="${id}"]]`
|
|
74
|
+
}
|
|
75
|
+
hits.unresolved.push(id)
|
|
76
|
+
return whole
|
|
77
|
+
}),
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
return { text: out.join('\n'), hits }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 迁移整个库。
|
|
85
|
+
* @param {{nodekb:string, nodes:Map, defs?:Map}} lib openLibrary 的结果
|
|
86
|
+
* @param {{dryRun?:boolean}} [opts] 默认演练
|
|
87
|
+
*/
|
|
88
|
+
export function migrateLibrary(lib, opts = {}) {
|
|
89
|
+
const dryRun = opts.dryRun !== false
|
|
90
|
+
const defs = lib.defs || new Map()
|
|
91
|
+
const nodes = lib.nodes
|
|
92
|
+
const isTid = (id) => nodes.has(id)
|
|
93
|
+
const isDef = (id) => defs.has(id)
|
|
94
|
+
|
|
95
|
+
const report = { dry_run: dryRun, files: [], node: 0, tscl: 0, unresolved: [], skipped: [] }
|
|
96
|
+
const edits = []
|
|
97
|
+
const visited = new Set()
|
|
98
|
+
|
|
99
|
+
for (const node of nodes.values()) {
|
|
100
|
+
if (node.kind !== 'file') continue
|
|
101
|
+
const abs = absOf(lib, node)
|
|
102
|
+
if (!abs) continue
|
|
103
|
+
visited.add(path.resolve(abs))
|
|
104
|
+
let text
|
|
105
|
+
let st1
|
|
106
|
+
try {
|
|
107
|
+
st1 = fs.statSync(abs)
|
|
108
|
+
text = fs.readFileSync(abs, 'utf8')
|
|
109
|
+
} catch (e) {
|
|
110
|
+
report.skipped.push({ path: relOf(lib, abs), why: `读失败: ${e.message}` })
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
113
|
+
if (!text.includes('[[')) continue
|
|
114
|
+
const { text: next, hits } = migrateText(text, { isTid, isDef })
|
|
115
|
+
const changed = hits.node + hits.tscl
|
|
116
|
+
for (const u of hits.unresolved) report.unresolved.push({ path: relOf(lib, abs), ref: u })
|
|
117
|
+
if (!changed) continue
|
|
118
|
+
report.node += hits.node
|
|
119
|
+
report.tscl += hits.tscl
|
|
120
|
+
report.files.push({ path: relOf(lib, abs), count: changed, node: hits.node, tscl: hits.tscl })
|
|
121
|
+
edits.push({ abs, st1, next, path: relOf(lib, abs) })
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (!dryRun) {
|
|
125
|
+
for (const e of edits) {
|
|
126
|
+
try {
|
|
127
|
+
const st2 = fs.statSync(e.abs)
|
|
128
|
+
if (st2.mtimeMs !== e.st1.mtimeMs || st2.size !== e.st1.size) {
|
|
129
|
+
report.skipped.push({ path: e.path, why: '读后被改动,未覆盖' })
|
|
130
|
+
continue
|
|
131
|
+
}
|
|
132
|
+
fs.writeFileSync(e.abs, e.next, 'utf8')
|
|
133
|
+
} catch (err) {
|
|
134
|
+
report.skipped.push({ path: e.path, why: err.message })
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// 兜底:只报「存在但没进索引」的 md(解析失败的孤儿文件),不重复报已处理的
|
|
140
|
+
for (const abs of allMarkdown(lib.nodekb)) {
|
|
141
|
+
if (visited.has(path.resolve(abs))) continue
|
|
142
|
+
report.skipped.push({ path: relOf(lib, abs), why: '未被解析为目标节点,未处理' })
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return report
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function formatMigrate(report) {
|
|
149
|
+
const lines = []
|
|
150
|
+
lines.push(report.dry_run ? '(演练,未落盘;确认后传 dry_run=false 才写)' : '已写入')
|
|
151
|
+
const total = report.files.reduce((s, f) => s + f.count, 0)
|
|
152
|
+
if (!total) {
|
|
153
|
+
lines.push('没有需要迁移的裸引用')
|
|
154
|
+
} else {
|
|
155
|
+
lines.push(`共 ${total} 处裸引用 → ${report.tscl} 处 [[tscl="…"]]、${report.node} 处 [[node="…"]]`)
|
|
156
|
+
for (const f of report.files.slice(0, 20)) lines.push(` - ${f.path}: ${f.count}(tscl ${f.tscl} / node ${f.node})`)
|
|
157
|
+
if (report.files.length > 20) lines.push(` …另有 ${report.files.length - 20} 个文件`)
|
|
158
|
+
}
|
|
159
|
+
if (report.unresolved.length) {
|
|
160
|
+
lines.push(`保留未改 ${report.unresolved.length} 处(指向的 id 既不是节点也不是定义,本来悬空):`)
|
|
161
|
+
for (const u of report.unresolved.slice(0, 10)) lines.push(` - ${u.path}: [[${u.ref}]]`)
|
|
162
|
+
if (report.unresolved.length > 10) lines.push(` …另有 ${report.unresolved.length - 10} 处`)
|
|
163
|
+
}
|
|
164
|
+
if (report.skipped.length) {
|
|
165
|
+
lines.push(`跳过 ${report.skipped.length} 个文件:`)
|
|
166
|
+
for (const s of report.skipped.slice(0, 10)) lines.push(` - ${s.path}: ${s.why}`)
|
|
167
|
+
}
|
|
168
|
+
return lines.join('\n')
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export { ROOT_TID }
|
package/lib/host/prompt.js
CHANGED
|
@@ -21,7 +21,7 @@ export const DEFAULT_PROMPT = [
|
|
|
21
21
|
'- 读库:先 `kb_read mode=toc` 看结构(几百 token),再按 TID 读一支;找不到就 `kb_search`。别一次性把整库读进上下文。',
|
|
22
22
|
'- 改名前先 `kb_backlinks`,改名用 `kb_rename`(会重写全库引用并跳过被并发改过的文件)。',
|
|
23
23
|
'- 引用分两种,别混:**结构**用 `[[node="doc:03-build:3.1"]]`(只出一个链接,不复制正文);**值只写一处**——定义 `<tscl id="fact:abi">…</tscl>`,各处写 `[[tscl="fact:abi"]]`,同一 id 全库只能定义一次。',
|
|
24
|
-
'- 旧写法 `[[TID]]` 仍能用但 `kb_diag` 会 warning,2.0
|
|
24
|
+
'- 旧写法 `[[TID]]` 仍能用但 `kb_diag` 会 warning,2.0 移除(老库可先跑 `kb_migrate` 演练,确认后 `dry_run=false` 批量迁)。',
|
|
25
25
|
].join('\n')
|
|
26
26
|
|
|
27
27
|
function textOf(config) {
|
package/lib/host/routes.js
CHANGED
|
@@ -12,6 +12,7 @@ import { search, formatSearch } from '../engine/search.js'
|
|
|
12
12
|
import { diag } from '../engine/diag.js'
|
|
13
13
|
import { extractRefs } from '../engine/parse.js'
|
|
14
14
|
import { renameFileNode, formatRename } from '../engine/rename.js'
|
|
15
|
+
import { migrateLibrary, formatMigrate } from '../engine/migrate.js'
|
|
15
16
|
import { openLibrary, knownLibraries, whereInfo, workspaceOf } from './runtime.js'
|
|
16
17
|
import { promptState } from './prompt.js'
|
|
17
18
|
import { skillState } from './skill.js'
|
|
@@ -279,6 +280,14 @@ export function registerRoutes(ctx, config) {
|
|
|
279
280
|
return
|
|
280
281
|
}
|
|
281
282
|
|
|
283
|
+
if (url.pathname === `${PREFIX}/migrate`) {
|
|
284
|
+
const lib = open()
|
|
285
|
+
const dryRun = !(q.get('dry_run') === '0' || q.get('dry_run') === 'false')
|
|
286
|
+
const report = migrateLibrary(lib, { dryRun })
|
|
287
|
+
send(res, 200, { kb: lib.name, ...report, text: formatMigrate(report) })
|
|
288
|
+
return
|
|
289
|
+
}
|
|
290
|
+
|
|
282
291
|
send(res, 404, { error: `未知端点: ${url.pathname}` })
|
|
283
292
|
} catch (e) {
|
|
284
293
|
send(res, 400, { error: e && e.message ? e.message : String(e) })
|
package/lib/host/tools.js
CHANGED
|
@@ -14,6 +14,7 @@ import { search, formatSearch } from '../engine/search.js'
|
|
|
14
14
|
import { diag } from '../engine/diag.js'
|
|
15
15
|
import { extractRefs } from '../engine/parse.js'
|
|
16
16
|
import { renameFileNode, formatRename } from '../engine/rename.js'
|
|
17
|
+
import { migrateLibrary, formatMigrate } from '../engine/migrate.js'
|
|
17
18
|
import { scaffold } from '../engine/scaffold.js'
|
|
18
19
|
import { writeInto } from '../engine/write.js'
|
|
19
20
|
import { isInside } from '../engine/registry.js'
|
|
@@ -309,6 +310,33 @@ export function buildTools(ctx, config) {
|
|
|
309
310
|
},
|
|
310
311
|
})
|
|
311
312
|
|
|
313
|
+
// ── kb_migrate ───────────────────────────────────────────────────────────
|
|
314
|
+
tools.push({
|
|
315
|
+
name: 'kb_migrate',
|
|
316
|
+
description:
|
|
317
|
+
'把库里的旧写法裸引用 [[x]] 迁成 1.1 的双语法:[[tscl="x"]](x 是定义)或 [[node="x"]](x 是节点 TID)。' +
|
|
318
|
+
'默认只演练(dry_run 缺省为 true),返回每个文件会改几处、哪些引用指向不存在的 id 会原样保留。' +
|
|
319
|
+
'围栏代码块内不动;写前重读校验,别人刚改过的文件不覆盖。',
|
|
320
|
+
parameters: {
|
|
321
|
+
type: 'object',
|
|
322
|
+
properties: {
|
|
323
|
+
dry_run: { type: 'boolean', description: '默认 true(只报告)。确认无误后传 false 才真正写盘。' },
|
|
324
|
+
kb: KB_PARAM,
|
|
325
|
+
lib: LIB_PARAM,
|
|
326
|
+
},
|
|
327
|
+
},
|
|
328
|
+
output: {
|
|
329
|
+
schema: {},
|
|
330
|
+
render: (_args, value) => text(value.text),
|
|
331
|
+
},
|
|
332
|
+
async execute(args, exec) {
|
|
333
|
+
const lib = locate(config, exec, args)
|
|
334
|
+
const dryRun = args.dry_run === undefined ? true : Boolean(args.dry_run)
|
|
335
|
+
const report = migrateLibrary(lib, { dryRun })
|
|
336
|
+
return { ...report, kb: lib.name, text: warnPrefix(lib) + formatMigrate(report) }
|
|
337
|
+
},
|
|
338
|
+
})
|
|
339
|
+
|
|
312
340
|
// ── kb_vcs_status ────────────────────────────────────────────────────────
|
|
313
341
|
tools.push({
|
|
314
342
|
name: 'kb_vcs_status',
|
package/package.json
CHANGED