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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  > 简体中文 · [English](README_EN.md)
4
4
 
5
- 把 **Markdown 目录当成知识库** 的 DSH 插件:为 Agent 提供 9 个 `kb_*` 工具,为人类提供知识节点面板(侧栏入口名「项目节点知识库」)。
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
- ## 9 个工具
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` | 文件改名 + 全库 `[[TID]]` 引用一起改写(含 `[[tid:1.2]]`);写前重读,别人刚改的不覆盖 |
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 nine tools
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 `[[TID]]` pointing at it (including `[[tid:1.2]]`); re-reads before writing, so concurrent edits are never clobbered |
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 }
@@ -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) {
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "project-node-memory",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "description": "Project node knowledge base for DSH agents: markdown-as-database, stable TIDs, transclusion projection, structure self-check.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -55,6 +55,7 @@ kb_search query=... # toc 里找不到再搜
55
55
  - 定义块内部的 `##` **不会**变成节点——它只是文本。
56
56
 
57
57
  旧写法 `[[TID]]` 仍能解析(有定义时当宏、否则当链接),但 `kb_diag` 会 warning,2.0 移除。
58
+ 接手老库时先跑 `kb_migrate`(默认只演练),看清每处会改成 `[[node=…]]` 还是 `[[tscl=…]]` 再 `dry_run=false`。
58
59
 
59
60
  ## TID 规则
60
61