project-node-memory 1.0.6 → 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
 
@@ -35,7 +35,7 @@ Agent 默认靠 `read` / `grep` / `glob` 找东西,代价是**试探**:grep
35
35
  1. **系统提示词段落**(默认开,`prompt.enabled=false` 可关)——每轮都在,规则只有五行:要沉淀就用 `.nodekb/` + `kb_*`,别拿 `write` 手搭 `docs/*.md`。
36
36
  段落常驻约 450 字,实测 `GET /nodekb/api/prompt` 可自证是否注册成功。
37
37
 
38
- 2. **把「建库」变成一次调用**——`kb_init` 直接铺出 `doc/ facts/ lessons/ todo/ index/entry.md` 与 `.nodekb/README.md` 里的落位约定(幂等,已存在不覆盖)。没有这一步,Agent 会自己发明目录结构。
38
+ 2. **把「建库」变成一次调用**——`kb_init` 直接铺出 `doc/ lessons/ todo/ index/entry.md` 与 `.nodekb/README.md` 里的落位约定(幂等,已存在不覆盖)。没有这一步,Agent 会自己发明目录结构。
39
39
 
40
40
  3. **写也走工具**——`kb_write` 写前校验 TID / `<node>` 独占一行 / 路径不越界,写后立刻跑 diag 把悬空引用与重复块报回来。`write` 没有任何一层会拦,所以规则只能靠记。
41
41
 
@@ -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
  | --------------- | ---------------------------------------------------------------------------------------------- |
@@ -101,9 +101,10 @@ config:
101
101
  | `kb_search` | 中文友好检索(2-gram + 多词加权 + 少见时退化子串),返回稳定 TID、类型、源行、片段 |
102
102
  | `kb_diag` | 结构自检;有 error 时直接失败,别读完才发现引用断了 |
103
103
  | `kb_backlinks` | 谁引用了它。改 TID / 删节点前先跑 |
104
- | `kb_init` | 建 `.nodekb` 骨架(幂等),铺出 `doc/ facts/ lessons/ todo/ index/` 与落位约定 |
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
  ## 库怎么定位
@@ -163,21 +164,30 @@ config:
163
164
  ## 语法
164
165
 
165
166
  ```md
166
- ## 3.1 编译契约 → 节点 tid = doc:03-build:3.1(位置型,不能当引用目标)
167
+ ## 3.1 编译契约 → 节点 tid = doc:03-build:3.1(位置型)
167
168
  <node name="fact:abi" type="fact" parent="doc:03-build">
168
- 正文 ← 显式节点,引用只展开它自己
169
+ 正文 ← 显式节点
169
170
  </node>
170
171
 
171
- [[fact:abi]] → 引用;渲染时就地展开
172
- [[doc:03-build]] → 引文件/文件夹 = 展开整支子树
172
+ [[node="doc:03-build:3.1"]] → 结构引用:渲染成一个链接,不复制正文
173
+ [[tscl="fact:abi"]] → 值嵌入:把 <tscl id="fact:abi"> 的定义体替换进来
174
+
175
+ <tscl id="fact:abi">
176
+ 三条硬规则:…(这个 id 全库只能定义一次)
177
+ </tscl>
173
178
  ```
174
179
 
175
- 硬规则只有两条:`<node>` / `</node>` 必须各占整行;显式节点名不能和文件派生 TID 撞。
180
+ 一条原则:**结构用节点(链接),值用 tscl(嵌入)**。同一个数字/结论写两遍就是漂移,
181
+ `kb_diag` 会报;写成 `<tscl>` 定义后各处只留一个引用,改一处全库同步。
182
+
183
+ 硬规则:`<node>` / `</node>` 必须各占整行;`<tscl>` 内部不做结构解析(里面的 `##` 不算标题);
184
+ 同一 `tscl` id 全库只能定义一次,且不能与节点 TID 同名。
185
+ 旧写法 `[[TID]]` 仍兼容(有定义时当宏、否则当链接),`kb_diag` 会 warning,2.0 移除。
176
186
 
177
187
  ## 自检(不装 DSH 也能跑)
178
188
 
179
189
  ```bash
180
- node scripts/check.mjs # 235 项:清单契约 / 工具 / 路由 / 客户端槽位 / 改名改写 / 建库写库 / 提示词段落 / 随包技能
190
+ node scripts/check.mjs # 275 项:清单契约 / 工具 / 路由 / 客户端槽位 / 改名改写 / 建库写库 / 提示词段落 / 随包技能
181
191
  node test/parity.mjs # 与 Python 原实现逐条对拍(需要 python 与 nodekb 源码)
182
192
  ```
183
193
 
package/README_EN.md CHANGED
@@ -35,7 +35,7 @@ There is a hard size gate too: a `mode=subtree` render above `maxlen` (default 5
35
35
  A tool description is only read *after* the agent has already decided "I should record knowledge". Getting it to prefer this path takes four layers:
36
36
 
37
37
  1. **A system-prompt section** (on by default, `prompt.enabled=false` to disable). It is present every turn, and the rule is five lines: to record something use `.nodekb/` + `kb_*`, don't hand-roll `docs/*.md` with `write`. The section is ~450 characters; `GET /nodekb/api/prompt` proves whether it registered.
38
- 2. **Make "create a library" one call.** `kb_init` lays out `doc/ facts/ lessons/ todo/ index/entry.md` plus the placement conventions in `.nodekb/README.md` (idempotent, never overwrites). Without it, the agent invents its own directory structure.
38
+ 2. **Make "create a library" one call.** `kb_init` lays out `doc/ lessons/ todo/ index/entry.md` plus the placement conventions in `.nodekb/README.md` (idempotent, never overwrites). Without it, the agent invents its own directory structure.
39
39
  3. **Writes go through a tool too.** `kb_write` validates TID syntax, that `<node>` / `</node>` sit on their own lines, and that the path stays inside the library — then runs `diag` immediately and reports dangling refs and duplicate blocks. Plain `write` has no such layer, so the rules survive only by memory.
40
40
  4. **A bundled skill, `project-node-memory-guide`** (loaded on demand). The prompt section is five lines; the writing conventions (how to split files, how to name TIDs, what to read before writing, three anti-patterns) live in `skills/project-node-memory-guide/SKILL.md`. It works out of the box with **no manual step**: when the host exposes a `skills` service, the plugin registers the body in memory via `ctx.skills.register()` (no prompt cost until used, withdrawn when the plugin is disabled). `skill.enabled=false` disables it; `GET /nodekb/api/skill` proves whether it registered.
41
41
 
@@ -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
  |---|---|
@@ -98,9 +98,10 @@ The right column renders at most 10,000 characters per view (with a truncation n
98
98
  | `kb_search` | CJK-friendly retrieval (2-gram index + multi-term weighting + substring fallback), returns stable TID, type, source line, snippet |
99
99
  | `kb_diag` | Structure self-check; fails outright on errors so you don't finish a read before noticing a broken ref |
100
100
  | `kb_backlinks` | Who references this node. Run before renaming a TID or deleting a node |
101
- | `kb_init` | Scaffold `.nodekb` (idempotent): `doc/ facts/ lessons/ todo/ index/` plus placement conventions |
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
@@ -140,21 +141,32 @@ The panel has no session context, so it follows **the workspace of the most rece
140
141
  ## Syntax (the whole of it)
141
142
 
142
143
  ```md
143
- ## 3.1 Compile contract → node tid = doc:03-build:3.1 (positional, cannot be a reference target)
144
+ ## 3.1 Compile contract → node tid = doc:03-build:3.1 (positional)
144
145
  <node name="fact:abi" type="fact" parent="doc:03-build">
145
- body ← explicit node; a reference expands only this
146
+ body ← explicit node
146
147
  </node>
147
148
 
148
- [[fact:abi]] → reference; expanded in place at render time
149
- [[doc:03-build]] → referencing a file/folder = expand its whole subtree
149
+ [[node="doc:03-build:3.1"]] → structural reference: becomes a link, body is NOT copied
150
+ [[tscl="fact:abi"]] → value embed: the body of <tscl id="fact:abi"> is substituted
151
+
152
+ <tscl id="fact:abi">
153
+ three hard rules: … (this id may be defined exactly once per library)
154
+ </tscl>
150
155
  ```
151
156
 
152
- Only two hard rules: `<node>` / `</node>` must each occupy an entire line, and an explicit node name must not collide with a file-derived TID.
157
+ One rule of thumb: **structure is a node (link), a single value is tscl (embed)**. Writing the
158
+ same number or conclusion twice is drift, and `kb_diag` reports it; define it once as `<tscl>` and
159
+ leave references at every use site, so a single edit updates the whole library.
160
+
161
+ Hard rules: `<node>` / `</node>` must each occupy an entire line; nothing inside `<tscl>` is parsed
162
+ as structure (a `##` there is not a heading); a `tscl` id may be defined exactly once and must not
163
+ collide with a node TID. The old `[[TID]]` form still works (macro when a definition exists, link
164
+ otherwise) with a `kb_diag` warning, and goes away in 2.0.
153
165
 
154
166
  ## Self-check (no DSH required)
155
167
 
156
168
  ```bash
157
- node scripts/check.mjs # 235 checks: manifest contract / tools / routes / client slots / rename rewriting / scaffold + write / prompt section / bundled skill
169
+ node scripts/check.mjs # 275 checks: manifest contract / tools / routes / client slots / rename rewriting / scaffold + write / prompt section / bundled skill
158
170
  node test/parity.mjs # case-by-case parity against the Python implementation (needs python and nodekb sources)
159
171
  ```
160
172
 
package/lib/client.js CHANGED
@@ -186,6 +186,7 @@
186
186
  '.nkb-muted{opacity:.55}',
187
187
  '.nkb-err{color:#d9534f;white-space:pre-wrap}',
188
188
  '.nkb-link{color:#3b82f6;cursor:pointer;text-decoration:underline dotted}',
189
+ '.nkb-macro{color:#b45309;cursor:help;text-decoration:underline dashed}',
189
190
  '.nkb-kv{display:flex;gap:6px}',
190
191
  '.nkb-kv .k{opacity:.5;flex:0 0 58px}',
191
192
  '.nkb-kv .v{word-break:break-all}',
@@ -199,17 +200,19 @@
199
200
  }
200
201
 
201
202
  // ── 小组件 ────────────────────────────────────────────────────────────
202
- /** 把 [[TID]] 变成可点的链接 */
203
+ /** 把 [[node="x"]] / [[tscl="x"]] / [[x]] 变成可点条目(点击跳到节点;宏点击跳到定义所在处) */
203
204
  function Rich(props) {
204
205
  var text = props.text || ''
205
206
  var parts = useMemo(function () {
206
207
  var out = []
207
- var re = /\[\[([a-zA-Z0-9\u4e00-\u9fff:._-]+)\]\]/g
208
+ var re = /\[\[node\s*=\s*"([^"]+)"\]\]|\[\[tscl\s*=\s*"([^"]+)"\]\]|\[\[([a-zA-Z0-9\u4e00-\u9fff:._-]+)\]\]/g
208
209
  var last = 0
209
210
  var m
210
211
  while ((m = re.exec(text))) {
211
212
  if (m.index > last) out.push({ t: text.slice(last, m.index) })
212
- out.push({ t: m[0], tid: m[1] })
213
+ var kind = m[1] !== undefined ? 'node' : m[2] !== undefined ? 'tscl' : 'bare'
214
+ var id = m[1] !== undefined ? m[1] : m[2] !== undefined ? m[2] : m[3]
215
+ out.push({ t: m[0], tid: kind === 'tscl' ? '' : id, macro: kind === 'tscl' ? id : '' })
213
216
  last = m.index + m[0].length
214
217
  }
215
218
  if (last < text.length) out.push({ t: text.slice(last) })
@@ -219,6 +222,13 @@
219
222
  'pre',
220
223
  { className: 'nkb-text' },
221
224
  parts.map(function (p, i) {
225
+ if (p.macro) {
226
+ return h(
227
+ 'span',
228
+ { key: i, className: 'nkb-macro', title: '宏:' + p.macro, onClick: function () { props.onMacro && props.onMacro(p.macro) } },
229
+ p.t,
230
+ )
231
+ }
222
232
  if (!p.tid) return p.t
223
233
  return h(
224
234
  'span',
@@ -482,7 +492,7 @@
482
492
  }
483
493
 
484
494
  // ── 极简 markdown 渲染(不引第三方库)─────────────────────────────────
485
- var MD_INLINE = /`([^`\n]+)`|\$\$([\s\S]+?)\$\$|\$([^$\n]+?)\$|\[\[([a-zA-Z0-9\u4e00-\u9fff:._-]+)\]\]|\*\*([^*\n]+)\*\*|\*([^*\n]+)\*|\[([^\]\n]+)\]\((https?:[^)\s]+)\)/g
495
+ var MD_INLINE = /`([^`\n]+)`|\$\$([\s\S]+?)\$\$|\$([^$\n]+?)\$|\[\[node\s*=\s*"([^"]+)"\]\]|\[\[tscl\s*=\s*"([^"]+)"\]\]|\[\[([a-zA-Z0-9\u4e00-\u9fff:._-]+)\]\]|\*\*([^*\n]+)\*\*|\*([^*\n]+)\*|\[([^\]\n]+)\]\((https?:[^)\s]+)\)/g
486
496
 
487
497
  function mdInline(text, onOpen, kbKey) {
488
498
  var out = []
@@ -498,20 +508,23 @@
498
508
  } else if (m[2] !== undefined || m[3] !== undefined) {
499
509
  var texSrc = m[2] !== undefined ? m[2] : m[3]
500
510
  out.push(h('span', { key: key, className: 'nkb-math', dangerouslySetInnerHTML: { __html: safeTex(texSrc, m[2] !== undefined) } }))
501
- } else if (m[4] !== undefined) {
511
+ } else if (m[4] !== undefined || m[6] !== undefined) {
512
+ var nid = m[4] !== undefined ? m[4] : m[6]
502
513
  out.push(
503
514
  h(
504
515
  'span',
505
- { key: key, className: 'nkb-link', title: m[4], onClick: (function (tid) { return function () { onOpen && onOpen(tid) } })(m[4]) },
506
- m[4],
516
+ { key: key, className: 'nkb-link', title: nid, onClick: (function (tid) { return function () { onOpen && onOpen(tid) } })(nid) },
517
+ nid,
507
518
  ),
508
519
  )
509
520
  } else if (m[5] !== undefined) {
510
- out.push(h('strong', { key: key }, m[5]))
511
- } else if (m[6] !== undefined) {
512
- out.push(h('em', { key: key }, m[6]))
521
+ out.push(h('span', { key: key, className: 'nkb-macro', title: '宏:' + m[5] }, m[5]))
513
522
  } else if (m[7] !== undefined) {
514
- out.push(h('a', { key: key, href: m[8], target: '_blank', rel: 'noreferrer' }, m[7]))
523
+ out.push(h('strong', { key: key }, m[7]))
524
+ } else if (m[8] !== undefined) {
525
+ out.push(h('em', { key: key }, m[8]))
526
+ } else if (m[9] !== undefined) {
527
+ out.push(h('a', { key: key, href: m[10], target: '_blank', rel: 'noreferrer' }, m[9]))
515
528
  }
516
529
  last = m.index + m[0].length
517
530
  }
@@ -9,13 +9,20 @@
9
9
  import { render_node, render_subtree, toc_lines, node_info, resolve_path, TransclusionError } from './render.js'
10
10
  import { search, formatSearch } from './search.js'
11
11
  import { diag } from './diag.js'
12
+ import { collectDefs } from './defs.js'
12
13
  import { lib_root_tids } from './mdlib.js'
13
14
 
14
15
  // 整支渲染字符上限(0 = 不限)。防止一次调用打爆上下文(root ≈ 23.5 万字符)
15
16
  export const DEFAULT_MAXLEN = 50000
16
17
 
18
+ /** 渲染上下文:有传入就用传入的(宿主侧会缓存),否则现扫一遍定义表 */
19
+ export function renderCtx(nodes, defs, libRoot) {
20
+ if (defs) return { defs, libRoot }
21
+ return { defs: collectDefs(nodes).defs, libRoot }
22
+ }
23
+
17
24
  /** 读取:默认整支;node=仅该节点;toc=目录;raw=元信息 */
18
- export function cmdRead(nodes, target, { node = false, raw = false, toc = false, maxlen = 0 } = {}) {
25
+ export function cmdRead(nodes, target, { node = false, raw = false, toc = false, maxlen = 0, defs, libRoot } = {}) {
19
26
  let tid
20
27
  try {
21
28
  tid = resolve_path(nodes, target)
@@ -34,8 +41,9 @@ export function cmdRead(nodes, target, { node = false, raw = false, toc = false,
34
41
  try {
35
42
  if (toc) return { text: toc_lines(nodes, tid).join('\n'), code: 0 }
36
43
  if (raw) return { text: JSON.stringify(node_info(nodes, tid), null, 2), code: 0 }
44
+ const ctx = renderCtx(nodes, defs, libRoot)
37
45
  if (node) {
38
- let out = render_node(nodes, tid)
46
+ let out = render_node(nodes, tid, ctx)
39
47
  const kids = [...nodes.values()].filter((c) => c.parent_tid === tid)
40
48
  if (kids.length) {
41
49
  // 反直觉提示:带 ## 子节的页面只读 node 只回前言
@@ -43,7 +51,7 @@ export function cmdRead(nodes, target, { node = false, raw = false, toc = false,
43
51
  }
44
52
  return { text: out, code: 0 }
45
53
  }
46
- const out = render_subtree(nodes, tid)
54
+ const out = render_subtree(nodes, tid, 1, ctx)
47
55
  if (maxlen && out.length > maxlen) {
48
56
  return {
49
57
  text:
@@ -66,15 +74,16 @@ export function cmdSearch(nodes, query, opts = {}) {
66
74
  }
67
75
 
68
76
  /** 自检 */
69
- export function cmdDiag(nodes, { problems = [], strict = false, libRoot } = {}) {
70
- return diag(nodes, { problems, strict, libRoot })
77
+ export function cmdDiag(nodes, { problems = [], strict = false, libRoot, defs } = {}) {
78
+ return diag(nodes, { problems, strict, libRoot, defs })
71
79
  }
72
80
 
73
81
  /** 导出整库为渲染 md 树(一个一级节点一个文件) */
74
- export function cmdDump(nodes, outDir, fs, path) {
82
+ export function cmdDump(nodes, outDir, fs, path, defs) {
75
83
  const written = []
84
+ const ctx = renderCtx(nodes, defs)
76
85
  for (const tid of lib_root_tids(nodes)) {
77
- const text = render_subtree(nodes, tid)
86
+ const text = render_subtree(nodes, tid, 1, ctx)
78
87
  const p = path.join(outDir, `${tid}.md`)
79
88
  fs.mkdirSync(outDir, { recursive: true })
80
89
  fs.writeFileSync(p, text, 'utf8')
@@ -0,0 +1,60 @@
1
+ /**
2
+ * defs —— 全库 `<tscl id="x">…</tscl>` 定义表。
3
+ *
4
+ * 定义可以写在任何节点正文里(通常写在 facts/ 的自然语言段落里,也可以就近写在
5
+ * 引用它的文档旁)。这里把全书扫一遍,产出 id → { id, body, ownerTid, source }。
6
+ *
7
+ * 不做的事:不判断「该不该只有一处」以外的语义——重复、与节点 TID 撞名、嵌套开标签、
8
+ * 无人引用,全部以 problems 形式交回给 diag 定级。
9
+ */
10
+
11
+ import { extract_defs } from './parse.js'
12
+
13
+ const NESTED_OPEN = /<tscl\b[^>]*>(?:(?!<\/tscl>)[\s\S])*?<tscl\b/
14
+
15
+ /**
16
+ * @param {Map<string, {tid:string, content:string, source:string, title:string}>} nodes
17
+ * @returns {{ defs: Map<string, object>, problems: object[] }}
18
+ */
19
+ export function collectDefs(nodes) {
20
+ const defs = new Map()
21
+ const problems = []
22
+
23
+ for (const node of nodes.values()) {
24
+ const content = node.content || ''
25
+ if (!content.includes('<tscl')) continue
26
+ if (NESTED_OPEN.test(content)) {
27
+ problems.push({ code: 'nested-tscl', tid: node.tid, where: node.source })
28
+ }
29
+ for (const [id, body] of extract_defs(content)) {
30
+ const prev = defs.get(id)
31
+ if (prev) {
32
+ prev.count += 1
33
+ prev.owners.push(node.tid)
34
+ problems.push({
35
+ code: 'dup-def',
36
+ id,
37
+ owners: prev.owners.slice(),
38
+ where: node.source,
39
+ })
40
+ continue
41
+ }
42
+ defs.set(id, {
43
+ id,
44
+ body,
45
+ ownerTid: node.tid,
46
+ ownerTitle: node.title || '',
47
+ source: node.source || '',
48
+ count: 1,
49
+ owners: [node.tid],
50
+ })
51
+ }
52
+ }
53
+
54
+ return { defs, problems }
55
+ }
56
+
57
+ /** 定义体是否有跨行内容(跨行定义不能内联进表格单元格) */
58
+ export function isMultiline(body) {
59
+ return String(body || '').replace(/\s+$/, '').includes('\n')
60
+ }
@@ -1,16 +1,17 @@
1
1
  /**
2
2
  * diag —— 结构自检。
3
3
  *
4
- * errors = 真损坏(悬空/位置型引用、循环、孤儿、树环、重复 TID),退出码 2。
5
- * warnings = 疑似漂移(跨节点重复块 / 节点内重复行),默认只报;strict 时算失败。
4
+ * errors = 真损坏(悬空/位置型引用、循环、孤儿、树环、重复 TID、重复/冲突/悬空的 tscl 定义),退出码 2。
5
+ * warnings = 疑似漂移(跨节点重复块 / 节点内重复行 / 旧语法 / 无人引用的定义),默认只报;strict 时算失败。
6
6
  *
7
7
  * 逐行对齐 Python 版 commands.cmd_diag(含 _is_boilerplate 的全部豁免规则)。
8
8
  */
9
9
 
10
10
  import fs from 'node:fs'
11
11
  import path from 'node:path'
12
- import { extract_refs } from './parse.js'
13
- import { render_node, TransclusionError } from './render.js'
12
+ import { extractRefs, extract_refs } from './parse.js'
13
+ import { render_node, resolve_path, TransclusionError } from './render.js'
14
+ import { collectDefs, isMultiline } from './defs.js'
14
15
 
15
16
  const POSITIONAL_SEG = /^\d+(\.\d+)*$/
16
17
  const DUP_MIN_LEN = 40 // 视为漂移候选的文本块最短长度
@@ -172,25 +173,73 @@ function typeCounts(nodes) {
172
173
  * 自检。
173
174
  * @returns {{report:object, code:number}}
174
175
  */
175
- export function diag(nodes, { problems = [], strict = false, libRoot } = {}) {
176
+ export function diag(nodes, { problems = [], strict = false, libRoot, defs = null } = {}) {
176
177
  const errors = [...problems]
178
+ const warnings = []
179
+
180
+ // 定义表:全库 <tscl id="x">…</tscl>
181
+ let defsMap = defs
182
+ if (!defsMap) {
183
+ const collected = collectDefs(nodes)
184
+ defsMap = collected.defs
185
+ for (const p of collected.problems) {
186
+ if (p.code === 'dup-def') errors.push(`重复定义(必须恰好一处): <tscl id="${p.id}"> 出现在 ${p.owners.join('、')}`)
187
+ else if (p.code === 'nested-tscl') errors.push(`嵌套 <tscl> 开标签: ${p.tid}`)
188
+ }
189
+ }
190
+ for (const id of defsMap.keys()) {
191
+ if (nodes.has(id)) errors.push(`命名空间冲突: tscl 定义 "${id}" 与节点 TID 同名(一个名字只能有一种含义)`)
192
+ }
193
+ const referenced = new Set()
177
194
 
178
195
  for (const node of nodes.values()) {
179
- for (const refTid of extract_refs(node.content)) {
180
- if (!nodes.has(refTid)) {
181
- errors.push(`悬空引用: ${node.tid} → [[${refTid}]]`)
182
- } else {
183
- const segs = refTid.split(':')
184
- if (segs.length >= 3 && POSITIONAL_SEG.test(segs[segs.length - 1])) {
185
- // 位置型段落 TID(文件:序号):插章节就漂移,禁止作为引用目标
186
- errors.push(`位置型 TID 引用(插章节会漂移): ${node.tid} → [[${refTid}]]`)
196
+ const content = String(node.content || '')
197
+ for (const ref of extractRefs(content)) {
198
+ const lineOfRef = content.slice(0, ref.index).split('\n').pop() || ''
199
+ if (ref.kind === 'tscl' || (ref.kind === 'legacy' && defsMap.has(ref.id))) {
200
+ if (ref.kind === 'tscl') referenced.add(ref.id)
201
+ if (!defsMap.has(ref.id)) {
202
+ errors.push(`悬空 tscl 引用: ${node.tid} → [[tscl="${ref.id}"]]`)
203
+ continue
204
+ }
205
+ if (ref.kind === 'legacy') {
206
+ warnings.push(`旧语法(实为宏): ${node.tid} → [[${ref.id}]],请写成 [[tscl="${ref.id}"]]`)
187
207
  }
208
+ const body = defsMap.get(ref.id).body
209
+ if (/^\s*\|/.test(lineOfRef) && isMultiline(body)) {
210
+ warnings.push(`跨行宏被内联进表格单元格: ${node.tid} → [[tscl="${ref.id}"]](表格里只放单行宏)`)
211
+ }
212
+ continue
213
+ }
214
+
215
+ if (ref.kind === 'legacy') {
216
+ warnings.push(`旧语法: ${node.tid} → [[${ref.id}]],请写成 [[node="${ref.id}"]](2.0 将移除裸引用)`)
217
+ }
218
+
219
+ // 节点引用(node= 或旧语法):允许三级前缀唯一解析,与 kb_read 一致
220
+ const hit = nodes.has(ref.id) ? ref.id : resolve_path(nodes, ref.id)
221
+ if (!hit) {
222
+ errors.push(`悬空引用: ${node.tid} → ${ref.raw}`)
223
+ continue
224
+ }
225
+ const segs = hit.split(':')
226
+ if (segs.length >= 3 && POSITIONAL_SEG.test(segs[segs.length - 1])) {
227
+ // 位置型段落 TID(文件:序号):插章节就漂移。1.1 起 node 引用只是链接、
228
+ // 不再复制正文,误指也就跳错一节 → 降为 warning。
229
+ warnings.push(`位置型 TID 链接(插章节会漂移): ${node.tid} → ${ref.raw}`)
188
230
  }
189
231
  }
190
232
  }
233
+
234
+ for (const def of defsMap.values()) {
235
+ if (!referenced.has(def.id)) {
236
+ warnings.push(`未被引用的定义: <tscl id="${def.id}">(在 ${def.ownerTid})`)
237
+ }
238
+ }
239
+
191
240
  for (const tid of nodes.keys()) {
192
241
  try {
193
- render_node(nodes, tid)
242
+ render_node(nodes, tid, { defs: defsMap })
194
243
  } catch (e) {
195
244
  if (e instanceof TransclusionError) errors.push(`循环引用 @${tid}: ${e.message}`)
196
245
  else throw e
@@ -205,7 +254,7 @@ export function diag(nodes, { problems = [], strict = false, libRoot } = {}) {
205
254
 
206
255
  const root = libRoot || libRootOf(nodes)
207
256
  const ignore = loadIgnore(root)
208
- const warnings = [...dupBlocks(nodes, ignore.blocks), ...intraDupLines(nodes, ignore.lines)]
257
+ warnings.push(...dupBlocks(nodes, ignore.blocks), ...intraDupLines(nodes, ignore.lines))
209
258
 
210
259
  const code = errors.length || (strict && warnings.length) ? 2 : 0
211
260
  const report = {
@@ -213,6 +262,7 @@ export function diag(nodes, { problems = [], strict = false, libRoot } = {}) {
213
262
  errors,
214
263
  warnings,
215
264
  node_count: nodes.size,
265
+ def_count: defsMap.size,
216
266
  types: typeCounts(nodes),
217
267
  }
218
268
  if (!errors.length && !warnings.length) report.status = 'ok'
@@ -100,6 +100,7 @@ export function parse_file(absPath, file_tid, folder_type, parent_tid) {
100
100
  let sec_index = 0
101
101
  const stack = [] // 显式 <node> 嵌套栈
102
102
  fenced = false // ``` 围栏内一律视为原文,不做任何标记解析
103
+ let tscl_open = false // <tscl> 定义块内不做结构解析(宏体可含任意 md)
103
104
 
104
105
  const default_parent = () => (first_node_tid ? first_node_tid : file_tid)
105
106
  // 内容落到哪:最内层 <node> 栈 → 当前 H2 段 → 文件节点
@@ -118,6 +119,18 @@ export function parse_file(absPath, file_tid, folder_type, parent_tid) {
118
119
  content_target().content.push(line)
119
120
  continue
120
121
  }
122
+ // <tscl> 定义块内部一律按原文处理:不切 H2、不建节点、不弹栈
123
+ if (tscl_open) {
124
+ content_target().content.push(line)
125
+ if (/^\s*<\/tscl>\s*$/.test(line)) tscl_open = false
126
+ continue
127
+ }
128
+ if (/^\s*<tscl\b/.test(line)) {
129
+ content_target().content.push(line)
130
+ const afterOpen = line.slice(line.indexOf('>') + 1)
131
+ if (!/<\/tscl>/.test(afterOpen)) tscl_open = true
132
+ continue
133
+ }
121
134
  if (stack.length) {
122
135
  if (line.trim() === '</node>') {
123
136
  stack.pop()
@@ -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 }
@@ -9,8 +9,17 @@
9
9
 
10
10
  const CJK = '\\u4e00-\\u9fff'
11
11
 
12
- // 引用:[[TID]](允许 CJK,便于中文文件名/标题直接进入 TID)
12
+ // 引用(1.1 起双语法):
13
+ // [[node="tid 或相对路径"]] → 链接(不展开)
14
+ // [[tscl="id"]] → 值嵌入(替换成 <tscl> 定义体)
15
+ // [[tid]] → 旧语法,1.x 兼容并按目标类型推断,2.0 移除
13
16
  export const REF_SOURCE = '\\[\\[([a-zA-Z0-9\\u4e00-\\u9fff:._-]+)\\]\\]'
17
+ export const NODE_REF_SOURCE = '\\[\\[node\\s*=\\s*"([^"]+)"\\]\\]'
18
+ export const TSCL_REF_SOURCE = '\\[\\[tscl\\s*=\\s*"([^"]+)"\\]\\]'
19
+ const LEGACY_REF_SOURCE = '\\[\\[(?!node\\s*=|tscl\\s*=)([a-zA-Z0-9\\u4e00-\\u9fff:._-]+)\\]\\]'
20
+ const ANY_REF_SOURCE =
21
+ `\\[\\[node\\s*=\\s*"([^"]+)"\\]\\]|\\[\\[tscl\\s*=\\s*"([^"]+)"\\]\\]|` +
22
+ `\\[\\[(?!node\\s*=|tscl\\s*=)([a-zA-Z0-9\\u4e00-\\u9fff:._-]+)\\]\\]`
14
23
 
15
24
  const TSCL_SOURCE = '<tscl id="([^"]+)">([\\s\\S]*?)<\\/tscl>'
16
25
  const TID_RE = new RegExp(`^[a-z0-9${CJK}][a-z0-9${CJK}:._-]*$`)
@@ -21,6 +30,30 @@ export function refRegex() {
21
30
  return new RegExp(REF_SOURCE, 'g')
22
31
  }
23
32
 
33
+ export function nodeRefRegex() {
34
+ return new RegExp(NODE_REF_SOURCE, 'g')
35
+ }
36
+
37
+ export function tsclRefRegex() {
38
+ return new RegExp(TSCL_REF_SOURCE, 'g')
39
+ }
40
+
41
+ /** 三种引用合一的 global 正则(组 1=node 组 2=tscl 组 3=legacy),渲染用 */
42
+ export function anyRefRegex() {
43
+ return new RegExp(ANY_REF_SOURCE, 'g')
44
+ }
45
+
46
+ /** 一次扫过三种引用(保序),返回 [{kind:'node'|'tscl'|'legacy', id, raw, index}] */
47
+ export function extractRefs(text) {
48
+ const out = []
49
+ for (const m of String(text).matchAll(new RegExp(ANY_REF_SOURCE, 'g'))) {
50
+ const kind = m[1] !== undefined ? 'node' : m[2] !== undefined ? 'tscl' : 'legacy'
51
+ const id = m[1] !== undefined ? m[1] : m[2] !== undefined ? m[2] : m[3]
52
+ out.push({ kind, id, raw: m[0], index: m.index })
53
+ }
54
+ return out
55
+ }
56
+
24
57
  export function is_valid_tid(tid) {
25
58
  return TID_RE.test(String(tid))
26
59
  }
@@ -1,13 +1,21 @@
1
1
  /**
2
2
  * render —— 基于内存节点模型的渲染层(md 即库,无持久化状态)。
3
3
  *
4
- * - render_node / render_subtree:[[TID]] 就地填充(transclusion 展开)
5
- * - toc_lines:目录视图(tid · 标题 · 字符数,不渲染正文)
6
- * - node_info:节点元信息(含 source 行号)
7
- * - 环检测:展开路径重复出现同一 TID 抛 TransclusionError;悬空引用保留 [[TID]] 原文
4
+ * 1.1 起引用分两种,写法与结果都不同:
5
+ * - `[[node="x"]]` → 链接,**不展开**。结构由节点承担,引用只留一行指针。
6
+ * - `[[tscl="x"]]` → 值嵌入:替换成 `<tscl id="x">…</tscl>` 的定义体。
7
+ * - `[[x]]` → 1.x 旧语法,先查定义、再查节点;2.0 移除(diag 报 warning)。
8
+ *
9
+ * 为什么不展开节点:整支复制会让同一句话在库里出现两次、改一处忘另一处——这正是
10
+ * nodekb 想消灭的重复。需要「完整内容」时走 kb_read target=…(整支投影),而不是复制文本。
11
+ *
12
+ * 其它导出:toc_lines / toc_tree / node_info / resolve_path;环检测抛 TransclusionError。
8
13
  */
9
14
 
10
- import { refRegex } from './parse.js'
15
+ import path from 'node:path'
16
+ import { anyRefRegex, strip_tags } from './parse.js'
17
+
18
+ const MAX_EMBED = 64 // 单次渲染最多展开的宏个数(防病态文件自引用爆炸)
11
19
 
12
20
  export class TransclusionError extends Error {
13
21
  constructor(message) {
@@ -24,62 +32,100 @@ function children(nodes, tid) {
24
32
  }
25
33
 
26
34
  function cycle_msg(seen, target, nodes) {
27
- let msg = `循环引用: ${[...seen, target].join(' -> ')}`
35
+ const chain = [...seen, target].map((s) => String(s).replace(/^tscl:/, 'tscl:'))
36
+ let msg = `循环引用: ${chain.join(' -> ')}`
28
37
  const node = nodes.get(target)
29
38
  if (node && node.source) msg += `(重复节点源: ${node.source})`
30
39
  return msg
31
40
  }
32
41
 
33
- /** 容器节点(文件夹/文件)转译:整支子树投影(`## 标题` + 内容 + 子节递归) */
34
- function project_container(nodes, tid, seen) {
35
- const node = nodes.get(tid)
36
- const parts = []
37
- const title = node.title || tid
38
- if (title !== tid) parts.push('## ' + title) // 显式节点(title===tid)内联时不输出标题,避免列表内噪音
39
- parts.push(expand(node.content, nodes, seen))
40
- for (const child of children(nodes, tid)) {
41
- parts.push(project_container(nodes, child.tid, [...seen, child.tid]))
42
+ const fileOf = (source) => String(source || '').replace(/#L\d+(-L\d+)?$/, '')
43
+
44
+ function lineOf(source) {
45
+ const m = /#L(\d+)/.exec(String(source || ''))
46
+ return m ? Number(m[1]) : 0
47
+ }
48
+
49
+ /** 节点 → 一行 md 链接。文件夹没有可点的文件,就给粗体标题。 */
50
+ function linkTo(node, ctx) {
51
+ const label = String(node.title || node.tid).replace(/[[\]]/g, (c) => '\\' + c)
52
+ const file = fileOf(node.source)
53
+ if (!file) return `**${label}**`
54
+ let target = file
55
+ if (ctx && ctx.libRoot) {
56
+ const rel = path.relative(ctx.libRoot, file)
57
+ if (rel && !rel.startsWith('..')) target = rel.split(path.sep).join('/')
42
58
  }
43
- return parts.join('\n\n')
59
+ const line = node.kind === 'section' || node.positional ? lineOf(node.source) : 0
60
+ return `[${label}](${target}${line ? `#L${line}` : ''})`
44
61
  }
45
62
 
46
- function expand(text, nodes, seen) {
63
+ function resolveNode(nodes, id) {
64
+ if (nodes.has(id)) return nodes.get(id)
65
+ const hit = resolve_path(nodes, id)
66
+ return hit ? nodes.get(hit) : null
67
+ }
68
+
69
+ /**
70
+ * 把正文里的三种引用渲染掉。
71
+ * ctx = { defs: Map<id,{body,ownerTid,source}>, libRoot?: string }
72
+ */
73
+ export function expand(text, nodes, seen = [], ctx = {}) {
74
+ const defs = (ctx && ctx.defs) || new Map()
75
+ // 定义块在正文里就地显示为它的内容(标签本身不出现)
76
+ const source = strip_tags(text)
47
77
  const out = []
48
78
  let pos = 0
49
- for (const m of String(text).matchAll(refRegex())) {
50
- out.push(text.slice(pos, m.index))
51
- const target = m[1]
52
- if (seen.includes(target)) throw new TransclusionError(cycle_msg(seen, target, nodes))
53
- const nxt = [...seen, target]
54
- const node = nodes.get(target)
55
- if (node) {
56
- // 仅 文件夹/文件 容器展开整支子树;显式 <node> 原子引用只展开自身(避免同文件兄弟被连带展开)
57
- if (node.kind === 'folder' || node.kind === 'file') out.push(project_container(nodes, target, nxt))
58
- else out.push(expand(node.content, nodes, nxt))
59
- } else {
60
- out.push(m[0]) // 悬空:保留原文,diag 报告
79
+ let embeds = 0
80
+ for (const m of String(source).matchAll(anyRefRegex())) {
81
+ out.push(String(source).slice(pos, m.index))
82
+ const kind = m[1] !== undefined ? 'node' : m[2] !== undefined ? 'tscl' : 'legacy'
83
+ const id = m[1] !== undefined ? m[1] : m[2] !== undefined ? m[2] : m[3]
84
+ let handled = false
85
+
86
+ // 值嵌入:tscl 显式请求,或旧语法且确有同名定义
87
+ if ((kind === 'tscl' || kind === 'legacy') && defs.has(id)) {
88
+ const key = 'tscl:' + id
89
+ if (seen.includes(key)) throw new TransclusionError(cycle_msg(seen, key, nodes))
90
+ if (++embeds > MAX_EMBED) {
91
+ throw new TransclusionError(`宏展开超过 ${MAX_EMBED} 次(疑似病态引用): ${id}`)
92
+ }
93
+ const body = String(defs.get(id).body || '').replace(/\s+$/, '')
94
+ out.push(expand(body, nodes, [...seen, key], ctx))
95
+ handled = true
96
+ }
97
+
98
+ // 结构引用:只出链接,不再复制子树
99
+ if (!handled && (kind === 'node' || kind === 'legacy')) {
100
+ const node = resolveNode(nodes, id)
101
+ if (node) {
102
+ out.push(linkTo(node, ctx))
103
+ handled = true
104
+ }
61
105
  }
106
+
107
+ if (!handled) out.push(m[0]) // 悬空:保留原文,diag 报告
62
108
  pos = m.index + m[0].length
63
109
  }
64
- out.push(text.slice(pos))
110
+ out.push(String(source).slice(pos))
65
111
  return out.join('')
66
112
  }
67
113
 
68
114
  /** 渲染单节点内容(不含标题行) */
69
- export function render_node(nodes, tid) {
115
+ export function render_node(nodes, tid, ctx = {}) {
70
116
  const node = nodes.get(tid)
71
117
  if (!node) throw new Error(`节点不存在: ${tid}`)
72
- return expand(node.content, nodes, [tid])
118
+ return expand(node.content, nodes, [tid], ctx)
73
119
  }
74
120
 
75
- /** 渲染整支:递归子树为带标题层级的 md */
76
- export function render_subtree(nodes, tid, level = 1) {
121
+ /** 渲染整支:递归子树为带标题层级的 md(节点引用只在原文位置出现一次) */
122
+ export function render_subtree(nodes, tid, level = 1, ctx = {}) {
77
123
  const node = nodes.get(tid)
78
124
  if (!node) throw new Error(`节点不存在: ${tid}`)
79
125
  const title = node.title || tid
80
126
  const head = '#'.repeat(level) + ' ' + title
81
- const parts = [head, expand(node.content, nodes, [tid])]
82
- for (const child of children(nodes, tid)) parts.push(render_subtree(nodes, child.tid, level + 1))
127
+ const parts = [head, expand(node.content, nodes, [tid], ctx)]
128
+ for (const child of children(nodes, tid)) parts.push(render_subtree(nodes, child.tid, level + 1, ctx))
83
129
  return parts.join('\n\n')
84
130
  }
85
131
 
@@ -4,6 +4,9 @@
4
4
  * 目的不是「生成一堆空文件」,而是把**约定**写进库里:后来的人(或 Agent)
5
5
  * 打开 .nodekb/README.md 就知道该放哪、该多大、什么时候引用而不是复述。
6
6
  * 幂等:已存在的文件一律跳过,绝不动用户已写的内容。
7
+ *
8
+ * 1.1 起不再预建 facts/:需要「唯一事实」时用 <tscl> 定义 + 引用,而不是单独开目录。
9
+ * 目录与节点类型都是约定,不是约束——项目需要什么就加减什么。
7
10
  */
8
11
 
9
12
  import fs from 'node:fs'
@@ -19,39 +22,47 @@ const README = `# 本库约定(.nodekb)
19
22
  | 目录 | 放什么 | 判据 |
20
23
  |---|---|---|
21
24
  | \`doc/\` | 主题文档(一块一块的正文) | 会随版本演进、需要通读的解释性内容 |
22
- | \`facts/\` | 事实常量(一个节点一条) | 值本身即答案:版本号、公差、系数、路径、阈值 |
23
25
  | \`lessons/\` | 踩过的坑 | 换个人还会再踩,且「怎么发现的」比结论更值钱 |
24
26
  | \`todo/\` | 待办与计划 | 有明确完成条件 |
25
- | \`index/entry.md\` | 入口索引 | 只放指针(\`[[TID]]\`),不要复述正文 |
27
+ | \`index/entry.md\` | 入口索引 | 只放指针,不要复述正文 |
28
+
29
+ **目录与节点类型都是约定,不是约束**:项目没有待办就别建 \`todo/\`,需要 \`adr/\`、\`api/\`、\`runbook/\` 就自己加,
30
+ 节点类型也一样(\`type="fact"\`、\`type="decision"\`、\`type="api"\` 都是自由的)。
31
+ 上面这张表只是「不知道放哪时」的默认答案,不是白名单。
26
32
 
27
- ## 三条硬规则
33
+ ## 四条硬规则
28
34
 
29
35
  1. **一个主题一个文件,取 < 4KB**。超过就拆成 \`doc/03-xxx.md\` 的多节,入口表加一行指针。
30
- 2. **不要复述**。同一段数字/结论只写一次,其它地方用 \`[[TID]]\` 引用;改名时 \`kb_rename\` 会一起改写。
31
- 3. **写之前先 \`kb_read mode=toc\` 看结构**,写完跑 \`kb_diag\`:悬空引用、引用位置型段落、循环、重复 TID 都会当场报出来。
36
+ 2. **不要复述**。同一句话只写一次:结构用 \`[[node="…"]]\` 指过去,真正的「值」写成 \`<tscl>\` 定义再引用。
37
+ 3. **写之前先 \`kb_read mode=toc\` 看结构**,写完跑 \`kb_diag\`:悬空引用、重复定义、循环、重复 TID 都会当场报出来。
38
+ 4. **事实只写一处**。同一个数字/结论出现两次就算漂移,\`kb_diag\` 会报。
32
39
 
33
- ## 语法(就两条)
40
+ ## 语法(三种标记)
34
41
 
35
42
  - 文件即节点:\`doc/03-build.md\` → TID \`doc:03-build\`;\`## 3.1 标题\` → 子节点 \`doc:03-build:3.1\`
36
- - 显式节点:\`<node name="fact:abi" type="fact">\` … \`</node>\`,标签必须**独占一行**
37
- - 引用:\`[[doc:03-build:3.1]]\`(引用文件夹/文件展开整支,引用显式节点只展开它自己)
43
+ - 显式节点:\`<node name="lesson:abi" type="lesson">\` … \`</node>\`,标签必须**独占一行**
44
+ - 结构引用:\`[[node="doc:03-build:3.1"]]\` —— 渲染成一个链接,**不展开**(内容不复制)
45
+ - 值嵌入:先定义
46
+ \`\`\`md
47
+ <tscl id="fact:abi">
48
+ 三条硬规则:1) 只用 C ABI;2) 名字加前缀 \`fm_\`;3) 导出结构体必须定长。
49
+ </tscl>
50
+ \`\`\`
51
+ 再写 \`[[tscl="fact:abi"]]\` —— 渲染时整段替换进来。**同一个 id 全库只能定义一次**,
52
+ 改一处,所有引用处同时变;这就是用引用代替复述的意义。
38
53
  `
39
54
 
40
55
  const ENTRY = `# 入口索引
41
56
 
42
- 只放指针,不复述正文。每条一行:\`- [[TID]] 一句话说明\`。
57
+ 只放指针,不复述正文。结构用 \`[[node="TID"]]\`,值用 \`[[tscl="id"]]\`。
43
58
 
44
59
  ## 主题
45
60
 
46
- - [[doc:00-overview]] 项目概览与结构
47
-
48
- ## 事实
49
-
50
- (\`facts/\` 下的节点)
61
+ - [[node="doc:00-overview"]] 项目概览与结构
51
62
 
52
- ## 待办
63
+ ## 其它
53
64
 
54
- (\`todo/\` 下的条目)
65
+ 按项目实际增删:待办(\`todo/\`)、决策(\`adr/\`)、接口(\`api/\`)……没有的目录直接删掉。
55
66
  `
56
67
 
57
68
  const OVERVIEW = `# 项目概览
@@ -80,12 +91,6 @@ const TODO = `# 待办
80
91
  一条一个 \`<node name="todo:xxx" type="todo">\`,每条要有**明确的完成条件**。
81
92
  `
82
93
 
83
- const FACTS = `# 事实
84
-
85
- 一个常量一条 \`<node name="fact:xxx" type="fact">\`:值 + 单位 + 出处 + 什么时候会变。
86
- 值本身即答案的东西才放这里,解释性内容放 doc/。
87
- `
88
-
89
94
  const IGNORE = `{
90
95
  "blocks": [],
91
96
  "lines": []
@@ -96,7 +101,6 @@ const SEEDS = [
96
101
  ['README.md', README],
97
102
  ['index/entry.md', ENTRY],
98
103
  ['doc/00-overview.md', OVERVIEW],
99
- ['facts/README.md', FACTS],
100
104
  ['lessons/README.md', LESSONS],
101
105
  ['todo/README.md', TODO],
102
106
  ['diag_ignore.json', IGNORE],
@@ -16,11 +16,12 @@ export const DEFAULT_PROMPT = [
16
16
  '',
17
17
  '本项目需要沉淀长期知识(设计决策、踩坑、事实常量、待办、交接)时,**用 `.nodekb/` + `kb_*` 工具**,不要用 write/edit 手搭 `docs/*.md` 式的渐进式文档——手写没有校验、没有稳定 TID、没有引用展开。',
18
18
  '',
19
- '- 建库:`kb_init` 一次铺出骨架(`doc/ facts/ lessons/ todo/ index/entry.md` + 约定说明)。已有 `.nodekb/` 就别重建。',
20
- '- 写库:`kb_write`(append/replace/create,或对显式节点用 mode=node)。它写前校验 TID 与 `<node>` 独占一行,写后立刻跑 diag 把悬空引用/重复块报回来;同样的事用 write 做没有任何一层会拦。',
19
+ '- 建库:`kb_init` 一次铺出骨架(`doc/ lessons/ todo/ index/entry.md` + 约定说明)。已有 `.nodekb/` 就别重建;目录与节点类型都可按项目实际增删。',
20
+ '- 写库:`kb_write`(append/replace/create,或对显式节点用 mode=node)。写前校验 TID 与标签独占一行,写后立刻 `kb_diag` 报悬空引用/重复定义;用 write 做这些没有任何一层会拦。',
21
21
  '- 读库:先 `kb_read mode=toc` 看结构(几百 token),再按 TID 读一支;找不到就 `kb_search`。别一次性把整库读进上下文。',
22
22
  '- 改名前先 `kb_backlinks`,改名用 `kb_rename`(会重写全库引用并跳过被并发改过的文件)。',
23
- '- 一个主题一个文件、取 < 4KB;结论只写一次,别处用 `[[TID]]` 引用。',
23
+ '- 引用分两种,别混:**结构**用 `[[node="doc:03-build:3.1"]]`(只出一个链接,不复制正文);**值只写一处**——定义 `<tscl id="fact:abi">…</tscl>`,各处写 `[[tscl="fact:abi"]]`,同一 id 全库只能定义一次。',
24
+ '- 旧写法 `[[TID]]` 仍能用但 `kb_diag` 会 warning,2.0 移除(老库可先跑 `kb_migrate` 演练,确认后 `dry_run=false` 批量迁)。',
24
25
  ].join('\n')
25
26
 
26
27
  function textOf(config) {
@@ -10,8 +10,9 @@ import path from 'node:path'
10
10
  import { render_node, render_subtree, toc_tree, node_info, resolve_path } from '../engine/render.js'
11
11
  import { search, formatSearch } from '../engine/search.js'
12
12
  import { diag } from '../engine/diag.js'
13
- import { extract_refs } from '../engine/parse.js'
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'
@@ -171,10 +172,11 @@ export function registerRoutes(ctx, config) {
171
172
  return
172
173
  }
173
174
  const node = lib.nodes.get(tid)
175
+ const ctx = { defs: lib.defs, libRoot: lib.nodekb }
174
176
  let text
175
- if (mode === 'node') text = render_node(lib.nodes, tid)
177
+ if (mode === 'node') text = render_node(lib.nodes, tid, ctx)
176
178
  else if (mode === 'raw') text = node.content
177
- else text = render_subtree(lib.nodes, tid)
179
+ else text = render_subtree(lib.nodes, tid, 1, ctx)
178
180
  send(res, 200, {
179
181
  kb: lib.name,
180
182
  ...node_info(lib.nodes, tid),
@@ -227,7 +229,7 @@ export function registerRoutes(ctx, config) {
227
229
 
228
230
  if (url.pathname === `${PREFIX}/diag`) {
229
231
  const lib = open()
230
- const { report } = diag(lib.nodes, { problems: lib.problems, libRoot: lib.nodekb })
232
+ const { report } = diag(lib.nodes, { problems: lib.problems, libRoot: lib.nodekb, defs: lib.defs })
231
233
  send(res, 200, { kb: lib.name, ...report })
232
234
  return
233
235
  }
@@ -242,7 +244,8 @@ export function registerRoutes(ctx, config) {
242
244
  }
243
245
  const rows = []
244
246
  for (const node of lib.nodes.values()) {
245
- if (node.tid !== tid && extract_refs(node.content).includes(tid)) {
247
+ const hit = extractRefs(node.content).some((r) => r.kind !== 'tscl' && r.id === tid)
248
+ if (node.tid !== tid && hit) {
246
249
  rows.push({ tid: node.tid, title: node.title, type: node.type, source: node.source })
247
250
  }
248
251
  }
@@ -277,6 +280,14 @@ export function registerRoutes(ctx, config) {
277
280
  return
278
281
  }
279
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
+
280
291
  send(res, 404, { error: `未知端点: ${url.pathname}` })
281
292
  } catch (e) {
282
293
  send(res, 400, { error: e && e.message ? e.message : String(e) })
@@ -8,9 +8,10 @@
8
8
  import fs from 'node:fs'
9
9
  import path from 'node:path'
10
10
  import { parse_library } from '../engine/mdlib.js'
11
+ import { collectDefs } from '../engine/defs.js'
11
12
  import { resolveLibrary, listLibraries } from '../engine/registry.js'
12
13
 
13
- const CACHE = new Map() // nodekb -> { sig, nodes, problems }
14
+ const CACHE = new Map() // nodekb -> { sig, nodes, problems, defs }
14
15
  const MAX_CACHE = 8
15
16
 
16
17
  // 面板没有会话上下文,只能靠人指定工作区(ws 参数)。
@@ -99,13 +100,18 @@ export function openLibrary(config, exec, { kb, lib, remember = true } = {}) {
99
100
  const sig = signature(resolved.nodekb)
100
101
  const hit = CACHE.get(resolved.nodekb)
101
102
  if (hit && hit.sig === sig) {
102
- return { ...resolved, nodes: hit.nodes, problems: hit.problems, cached: true }
103
+ return { ...resolved, nodes: hit.nodes, defs: hit.defs, problems: hit.problems, cached: true }
103
104
  }
104
105
  const problems = []
105
106
  const nodes = parse_library(resolved.nodekb, problems)
106
- CACHE.set(resolved.nodekb, { sig, nodes, problems })
107
+ const found = collectDefs(nodes)
108
+ for (const p of found.problems) {
109
+ if (p.code === 'dup-def') problems.push(`重复定义: <tscl id="${p.id}"> 出现在 ${p.owners.join('、')}`)
110
+ else if (p.code === 'nested-tscl') problems.push(`嵌套 <tscl> 开标签: ${p.tid}`)
111
+ }
112
+ CACHE.set(resolved.nodekb, { sig, nodes, defs: found.defs, problems })
107
113
  if (CACHE.size > MAX_CACHE) CACHE.delete(CACHE.keys().next().value)
108
- return { ...resolved, nodes, problems, cached: false }
114
+ return { ...resolved, nodes, defs: found.defs, problems, cached: false }
109
115
  }
110
116
 
111
117
  export function knownLibraries(config, exec, opts = {}) {
package/lib/host/tools.js CHANGED
@@ -12,8 +12,9 @@ import { execFileSync } from 'node:child_process'
12
12
  import { render_node, render_subtree, toc_lines, node_info, resolve_path } from '../engine/render.js'
13
13
  import { search, formatSearch } from '../engine/search.js'
14
14
  import { diag } from '../engine/diag.js'
15
- import { extract_refs } from '../engine/parse.js'
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'
@@ -137,16 +138,17 @@ export function buildTools(ctx, config) {
137
138
  }
138
139
 
139
140
  let out
141
+ const ctx = { defs: lib.defs, libRoot: lib.nodekb }
140
142
  if (mode === 'toc') {
141
143
  out = toc_lines(lib.nodes, tid).join('\n')
142
144
  } else if (mode === 'raw') {
143
145
  out = JSON.stringify(node_info(lib.nodes, tid), null, 2)
144
146
  } else if (mode === 'node') {
145
- out = render_node(lib.nodes, tid)
147
+ out = render_node(lib.nodes, tid, ctx)
146
148
  const kids = [...lib.nodes.values()].filter((c) => c.parent_tid === tid).length
147
149
  if (kids) out += `\n\n(本节点含 ${kids} 个子节:正文在子节里;看结构用 mode=toc,看整支不传 mode)`
148
150
  } else {
149
- out = render_subtree(lib.nodes, tid)
151
+ out = render_subtree(lib.nodes, tid, 1, ctx)
150
152
  if (maxlen && out.length > maxlen) {
151
153
  throw new Error(
152
154
  `渲染超上限: ${out.length} 字符 > ${maxlen}。建议 mode=node 单节点、先 mode=toc 看结构、或指定小节 tid;` +
@@ -230,7 +232,7 @@ export function buildTools(ctx, config) {
230
232
  },
231
233
  async execute(args, exec) {
232
234
  const lib = locate(config, exec, args)
233
- const { report, code } = diag(lib.nodes, { problems: lib.problems, strict: !!args.strict, libRoot: lib.nodekb })
235
+ const { report, code } = diag(lib.nodes, { problems: lib.problems, strict: !!args.strict, libRoot: lib.nodekb, defs: lib.defs })
234
236
  const text_ = JSON.stringify(report, null, 2)
235
237
  if (code !== 0) {
236
238
  const e = new Error(text_)
@@ -267,8 +269,8 @@ export function buildTools(ctx, config) {
267
269
  const rows = []
268
270
  for (const node of lib.nodes.values()) {
269
271
  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
+ const hit = extractRefs(node.content).some((r) => r.kind !== 'tscl' && r.id === tid)
273
+ if (hit) rows.push({ tid: node.tid, title: node.title, type: node.type, source: node.source })
272
274
  }
273
275
  const lines = [`引用 ${tid} 的节点:${rows.length} 个`]
274
276
  for (const r of rows) lines.push(`- ${r.tid} [${r.type}] ${r.title} ${r.source}`)
@@ -308,6 +310,33 @@ export function buildTools(ctx, config) {
308
310
  },
309
311
  })
310
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
+
311
340
  // ── kb_vcs_status ────────────────────────────────────────────────────────
312
341
  tools.push({
313
342
  name: 'kb_vcs_status',
@@ -435,7 +464,7 @@ export function buildTools(ctx, config) {
435
464
  let report = null
436
465
  try {
437
466
  const fresh = openLibrary(config, exec, { kb: args.kb, lib: args.lib })
438
- report = diag(fresh.nodes, { problems: fresh.problems, libRoot: fresh.nodekb })
467
+ report = diag(fresh.nodes, { problems: fresh.problems, libRoot: fresh.nodekb, defs: fresh.defs })
439
468
  } catch (e) {
440
469
  report = { report: { errors: [`写后解析失败: ${e.message}`], warnings: [], node_count: 0 }, code: 2 }
441
470
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "project-node-memory",
3
- "version": "1.0.6",
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",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: project-node-memory-guide
3
- description: 用 .nodekb/ + kb_* 工具沉淀项目长期知识(设计决策、踩坑、事实常量、待办、交接)时的写法规范——什么时候该写、文件怎么分、TID 怎么起、先读什么再写什么。准备手写 docs/*.md 之前先读这一条。
3
+ description: 用 .nodekb/ + kb_* 工具沉淀项目长期知识(设计决策、踩坑、事实常量、待办、交接)时的写法规范——什么时候该写、文件怎么分、TID 怎么起、唯一事实用 tscl 怎么共享、先读什么再写什么。准备手写 docs/*.md 之前先读这一条。
4
4
  ---
5
5
 
6
6
  # 写项目节点知识库(`.nodekb/`)
@@ -15,7 +15,7 @@ description: 用 .nodekb/ + kb_* 工具沉淀项目长期知识(设计决策
15
15
 
16
16
  ```
17
17
  kb_read mode=toc # 结构 + 每节字符数,几百 token
18
- kb_read target=<TID> # 按需读一支(引用会就地展开)
18
+ kb_read target=<TID> # 按需读一支
19
19
  kb_search query=... # toc 里找不到再搜
20
20
  ```
21
21
 
@@ -26,19 +26,43 @@ kb_search query=... # toc 里找不到再搜
26
26
  | 目录 | 放什么 | 判据 |
27
27
  |---|---|---|
28
28
  | `doc/` | 主题文档:架构、模块、流程、配置 | 「是什么、怎么运转」 |
29
- | `facts/` | 常量与事实:版本、路径、阈值、接口 | 一句话能说完、会被反复引用 |
30
29
  | `lessons/` | 踩坑记录 | 「症状 → 根因 → 对策」三行齐全 |
31
30
  | `todo/` | 未完成事项、阻塞、交接 | 有明确的下一步动作 |
32
31
  | `index/entry.md` | 总入口与落位约定 | 只放导航,不放正文 |
33
32
 
34
- 一个主题一个文件,**单文件取 < 4KB**;变大就按子主题拆成 `## 3.x` 段或独立文件。结论只写一次,别处用 `[[TID]]` 引用。
33
+ 这张表是「不知道放哪时」的默认答案,**不是白名单**:项目没有待办就别建 `todo/`,需要 `adr/`、`api/`、`runbook/`、`metrics/` 就自己加;节点 `type` 同样自由(`type="decision"`、`type="api"` 都行)。
34
+
35
+ 一个主题一个文件,**单文件取 < 4KB**;变大就按子主题拆成 `## 3.x` 段或独立文件。
36
+
37
+ ## 两种引用,别混
38
+
39
+ | 写法 | 渲染成 | 用在 |
40
+ |---|---|---|
41
+ | `[[node="doc:03-build:3.1"]]` | 一个链接,**不复制正文** | 指向另一段结构:「细节见 …」 |
42
+ | `[[tscl="fact:abi"]]` | 把 `<tscl id="fact:abi">` 的定义体**替换进来** | 同一句话/同一组数字必须在多处出现 |
43
+
44
+ ```md
45
+ <tscl id="fact:abi">
46
+ 三条硬规则:1) 只用 C ABI;2) 名字加前缀 `fm_`;3) 导出结构体必须定长。
47
+ </tscl>
48
+ ```
49
+
50
+ 规则:
51
+
52
+ - **同一个 id 全库只能定义一次**,否则 `kb_diag` 报 error(重复定义)。
53
+ - id 不能和节点 TID 同名(命名空间冲突 = error)。惯例:值用 `fact:` 前缀,结构用目录前缀。
54
+ - 宏体**跨行**时不要内联进表格单元格(diag 会 warning,渲染也会断行)。
55
+ - 定义块内部的 `##` **不会**变成节点——它只是文本。
56
+
57
+ 旧写法 `[[TID]]` 仍能解析(有定义时当宏、否则当链接),但 `kb_diag` 会 warning,2.0 移除。
58
+ 接手老库时先跑 `kb_migrate`(默认只演练),看清每处会改成 `[[node=…]]` 还是 `[[tscl=…]]` 再 `dry_run=false`。
35
59
 
36
60
  ## TID 规则
37
61
 
38
62
  - 文件节点:`<目录>:<文件名 slug>`,例如 `doc/03-工程经验与坑.md` → `doc:03-工程经验与坑`
39
63
  - 显式节点:`<node name="abi">` 里的 `name` 原样就是 TID,可跨文件引用
40
- - 段节点:`## 3.1 xxx` → `<文件TID>:3.1`,但它是**位置型**的,不受引用校验保护——别拿它当引用目标,要引用就用显式节点
41
- - 引用写成 `[[TID]]` 或 `[[显示文字|TID]]`;改名走 `kb_rename`,它会重写全库引用
64
+ - 段节点:`## 3.1 xxx` → `<文件TID>:3.1`,是**位置型**的:插一节就漂。链接它只是 warning(指错了跳错节),但**别拿它当需要长期稳定的锚点**——要稳定就写显式节点
65
+ - 改名走 `kb_rename`,它会重写全库引用
42
66
 
43
67
  ## 写
44
68
 
@@ -48,12 +72,12 @@ kb_write target=<显式节点TID> mode=node content=... # 只换 <
48
72
  kb_write target=doc/03-...md mode=append content=...
49
73
  ```
50
74
 
51
- - 写完**看返回值里的校验结果**:悬空引用、重复块、`<node>` 没独占一行,都会当场报回来。看到就修,别留着。
75
+ - 写完**看返回值里的校验结果**:悬空引用、重复定义、`<node>` 没独占一行,都会当场报回来。看到就修,别留着。
52
76
  - 改已有内容用 `append` / `node`,不要 `replace` 整篇(会丢掉别人刚写的段落)。
53
77
  - 新建文件前先 `kb_read mode=toc` 确认没有同主题的旧文件。
54
78
 
55
79
  ## 三个反例
56
80
 
57
81
  1. 把整段对话贴进 `doc/` —— 记的是过程,不是结论。
58
- 2. 同一件事在 `doc/` 和 `lessons/` 各写一份 —— 引用它,别复制它。
82
+ 2. 同一件事在 `doc/` 和 `lessons/` 各写一份 —— 抽成 `<tscl>` 定义,两处都引用它。
59
83
  3. 只写「改了什么」不写「为什么」 —— 半年后没人知道能不能改回去。