project-node-memory 1.1.0 → 1.1.3

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
  ## 库怎么定位
@@ -183,10 +184,29 @@ config:
183
184
  同一 `tscl` id 全库只能定义一次,且不能与节点 TID 同名。
184
185
  旧写法 `[[TID]]` 仍兼容(有定义时当宏、否则当链接),`kb_diag` 会 warning,2.0 移除。
185
186
 
187
+ ## 从 1.0 升到 1.1
188
+
189
+ 数据格式没变——md 还是 md,TID 还是那些 TID,插件卸了照样能读。变化只有两条:
190
+
191
+ 1. **`[[x]]` 现在只出链接**,不再把被引用节点的正文复制进来(要复制原文请显式写 `<tscl>`)。
192
+ 好处是引用不再撑大渲染结果,位置型小节 `## 3.1` 也能点了。
193
+ 2. **`kb_init` 不再建 `facts/`**。已有的 `facts/` 不会被动,`kb_read`/`kb_search` 照常;只是新库默认只有
194
+ `doc/ lessons/ todo/ index/`,事实改写成 `<tscl>` 定义放进它所属的 `doc/` 里。
195
+
196
+ 老库要不要迁?**不迁也能用**,只是 `kb_diag` 会给每条裸引用一条 warning。要迁就跑:
197
+
198
+ ```text
199
+ kb_migrate # 演练:报每个文件会改几处、哪些引用本来就悬空
200
+ kb_migrate dry_run=false # 确认后落盘
201
+ ```
202
+
203
+ 迁移只改写法(`[[doc:x]]` → `[[node="doc:x"]]`,命中定义则 `[[tscl="…"]]`),不动任何 TID、不搬文件,
204
+ 所以引用关系与渲染结果都不变;围栏代码块内一律跳过。
205
+
186
206
  ## 自检(不装 DSH 也能跑)
187
207
 
188
208
  ```bash
189
- node scripts/check.mjs # 275 项:清单契约 / 工具 / 路由 / 客户端槽位 / 改名改写 / 建库写库 / 提示词段落 / 随包技能
209
+ node scripts/check.mjs # 290 项:清单契约 / 工具 / 路由 / 客户端槽位 / 改名改写 / 建库写库 / tscl / 迁移 / 提示词段落 / 随包技能
190
210
  node test/parity.mjs # 与 Python 原实现逐条对拍(需要 python 与 nodekb 源码)
191
211
  ```
192
212
 
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
@@ -162,10 +163,34 @@ as structure (a `##` there is not a heading); a `tscl` id may be defined exactly
162
163
  collide with a node TID. The old `[[TID]]` form still works (macro when a definition exists, link
163
164
  otherwise) with a `kb_diag` warning, and goes away in 2.0.
164
165
 
166
+ ## Upgrading from 1.0
167
+
168
+ The data format is unchanged — Markdown stays Markdown, TIDs stay the same, and the library remains
169
+ readable after the plugin is removed. Only two things changed:
170
+
171
+ 1. **`[[x]]` now renders a link only**; it no longer copies the referenced node's body into the
172
+ output (use an explicit `<tscl>` for that). References stop inflating rendered size, and
173
+ positional sections (`## 3.1`) become clickable.
174
+ 2. **`kb_init` no longer creates `facts/`.** Existing `facts/` folders are untouched and still
175
+ readable/searchable; new libraries get `doc/ lessons/ todo/ index/` only, and facts become
176
+ `<tscl>` definitions inside the `doc/` file they belong to.
177
+
178
+ Do old libraries need migrating? **No** — they keep working, `kb_diag` just emits one warning per
179
+ bare reference. To migrate:
180
+
181
+ ```text
182
+ kb_migrate # dry run: how many refs per file, which ones are already dangling
183
+ kb_migrate dry_run=false # write
184
+ ```
185
+
186
+ Migration only changes syntax (`[[doc:x]]` → `[[node="doc:x"]]`, or `[[tscl="…"]]` when the id is a
187
+ definition). No TID changes, no file moves, so references and rendered output stay identical; fenced
188
+ code blocks are skipped.
189
+
165
190
  ## Self-check (no DSH required)
166
191
 
167
192
  ```bash
168
- node scripts/check.mjs # 275 checks: manifest contract / tools / routes / client slots / rename rewriting / scaffold + write / prompt section / bundled skill
193
+ node scripts/check.mjs # 290 checks: manifest contract / tools / routes / client slots / rename rewriting / scaffold + write / tscl / migration / prompt section / bundled skill
169
194
  node test/parity.mjs # case-by-case parity against the Python implementation (needs python and nodekb sources)
170
195
  ```
171
196
 
@@ -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.3",
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