project-node-memory 1.0.6 → 1.1.0

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
@@ -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
 
@@ -101,7 +101,7 @@ 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
106
  | `kb_rename` | 文件改名 + 全库 `[[TID]]` 引用一起改写(含 `[[tid:1.2]]`);写前重读,别人刚改的不覆盖 |
107
107
  | `kb_vcs_status` | 库内嵌 git 的快照历史与未提交改动(git 缺失时只说明,不报错) |
@@ -163,21 +163,30 @@ config:
163
163
  ## 语法
164
164
 
165
165
  ```md
166
- ## 3.1 编译契约 → 节点 tid = doc:03-build:3.1(位置型,不能当引用目标)
166
+ ## 3.1 编译契约 → 节点 tid = doc:03-build:3.1(位置型)
167
167
  <node name="fact:abi" type="fact" parent="doc:03-build">
168
- 正文 ← 显式节点,引用只展开它自己
168
+ 正文 ← 显式节点
169
169
  </node>
170
170
 
171
- [[fact:abi]] → 引用;渲染时就地展开
172
- [[doc:03-build]] → 引文件/文件夹 = 展开整支子树
171
+ [[node="doc:03-build:3.1"]] → 结构引用:渲染成一个链接,不复制正文
172
+ [[tscl="fact:abi"]] → 值嵌入:把 <tscl id="fact:abi"> 的定义体替换进来
173
+
174
+ <tscl id="fact:abi">
175
+ 三条硬规则:…(这个 id 全库只能定义一次)
176
+ </tscl>
173
177
  ```
174
178
 
175
- 硬规则只有两条:`<node>` / `</node>` 必须各占整行;显式节点名不能和文件派生 TID 撞。
179
+ 一条原则:**结构用节点(链接),值用 tscl(嵌入)**。同一个数字/结论写两遍就是漂移,
180
+ `kb_diag` 会报;写成 `<tscl>` 定义后各处只留一个引用,改一处全库同步。
181
+
182
+ 硬规则:`<node>` / `</node>` 必须各占整行;`<tscl>` 内部不做结构解析(里面的 `##` 不算标题);
183
+ 同一 `tscl` id 全库只能定义一次,且不能与节点 TID 同名。
184
+ 旧写法 `[[TID]]` 仍兼容(有定义时当宏、否则当链接),`kb_diag` 会 warning,2.0 移除。
176
185
 
177
186
  ## 自检(不装 DSH 也能跑)
178
187
 
179
188
  ```bash
180
- node scripts/check.mjs # 235 项:清单契约 / 工具 / 路由 / 客户端槽位 / 改名改写 / 建库写库 / 提示词段落 / 随包技能
189
+ node scripts/check.mjs # 275 项:清单契约 / 工具 / 路由 / 客户端槽位 / 改名改写 / 建库写库 / 提示词段落 / 随包技能
181
190
  node test/parity.mjs # 与 Python 原实现逐条对拍(需要 python 与 nodekb 源码)
182
191
  ```
183
192
 
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
 
@@ -98,7 +98,7 @@ 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
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 |
104
104
  | `kb_vcs_status` | Snapshot history and uncommitted changes of the library's embedded git repo (explains itself when git is missing) |
@@ -140,21 +140,32 @@ The panel has no session context, so it follows **the workspace of the most rece
140
140
  ## Syntax (the whole of it)
141
141
 
142
142
  ```md
143
- ## 3.1 Compile contract → node tid = doc:03-build:3.1 (positional, cannot be a reference target)
143
+ ## 3.1 Compile contract → node tid = doc:03-build:3.1 (positional)
144
144
  <node name="fact:abi" type="fact" parent="doc:03-build">
145
- body ← explicit node; a reference expands only this
145
+ body ← explicit node
146
146
  </node>
147
147
 
148
- [[fact:abi]] → reference; expanded in place at render time
149
- [[doc:03-build]] → referencing a file/folder = expand its whole subtree
148
+ [[node="doc:03-build:3.1"]] → structural reference: becomes a link, body is NOT copied
149
+ [[tscl="fact:abi"]] → value embed: the body of <tscl id="fact:abi"> is substituted
150
+
151
+ <tscl id="fact:abi">
152
+ three hard rules: … (this id may be defined exactly once per library)
153
+ </tscl>
150
154
  ```
151
155
 
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.
156
+ One rule of thumb: **structure is a node (link), a single value is tscl (embed)**. Writing the
157
+ same number or conclusion twice is drift, and `kb_diag` reports it; define it once as `<tscl>` and
158
+ leave references at every use site, so a single edit updates the whole library.
159
+
160
+ Hard rules: `<node>` / `</node>` must each occupy an entire line; nothing inside `<tscl>` is parsed
161
+ as structure (a `##` there is not a heading); a `tscl` id may be defined exactly once and must not
162
+ collide with a node TID. The old `[[TID]]` form still works (macro when a definition exists, link
163
+ otherwise) with a `kb_diag` warning, and goes away in 2.0.
153
164
 
154
165
  ## Self-check (no DSH required)
155
166
 
156
167
  ```bash
157
- node scripts/check.mjs # 235 checks: manifest contract / tools / routes / client slots / rename rewriting / scaffold + write / prompt section / bundled skill
168
+ node scripts/check.mjs # 275 checks: manifest contract / tools / routes / client slots / rename rewriting / scaffold + write / prompt section / bundled skill
158
169
  node test/parity.mjs # case-by-case parity against the Python implementation (needs python and nodekb sources)
159
170
  ```
160
171
 
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()
@@ -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 移除。',
24
25
  ].join('\n')
25
26
 
26
27
  function textOf(config) {
@@ -10,7 +10,7 @@ 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
15
  import { openLibrary, knownLibraries, whereInfo, workspaceOf } from './runtime.js'
16
16
  import { promptState } from './prompt.js'
@@ -171,10 +171,11 @@ export function registerRoutes(ctx, config) {
171
171
  return
172
172
  }
173
173
  const node = lib.nodes.get(tid)
174
+ const ctx = { defs: lib.defs, libRoot: lib.nodekb }
174
175
  let text
175
- if (mode === 'node') text = render_node(lib.nodes, tid)
176
+ if (mode === 'node') text = render_node(lib.nodes, tid, ctx)
176
177
  else if (mode === 'raw') text = node.content
177
- else text = render_subtree(lib.nodes, tid)
178
+ else text = render_subtree(lib.nodes, tid, 1, ctx)
178
179
  send(res, 200, {
179
180
  kb: lib.name,
180
181
  ...node_info(lib.nodes, tid),
@@ -227,7 +228,7 @@ export function registerRoutes(ctx, config) {
227
228
 
228
229
  if (url.pathname === `${PREFIX}/diag`) {
229
230
  const lib = open()
230
- const { report } = diag(lib.nodes, { problems: lib.problems, libRoot: lib.nodekb })
231
+ const { report } = diag(lib.nodes, { problems: lib.problems, libRoot: lib.nodekb, defs: lib.defs })
231
232
  send(res, 200, { kb: lib.name, ...report })
232
233
  return
233
234
  }
@@ -242,7 +243,8 @@ export function registerRoutes(ctx, config) {
242
243
  }
243
244
  const rows = []
244
245
  for (const node of lib.nodes.values()) {
245
- if (node.tid !== tid && extract_refs(node.content).includes(tid)) {
246
+ const hit = extractRefs(node.content).some((r) => r.kind !== 'tscl' && r.id === tid)
247
+ if (node.tid !== tid && hit) {
246
248
  rows.push({ tid: node.tid, title: node.title, type: node.type, source: node.source })
247
249
  }
248
250
  }
@@ -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,7 +12,7 @@ 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
17
  import { scaffold } from '../engine/scaffold.js'
18
18
  import { writeInto } from '../engine/write.js'
@@ -137,16 +137,17 @@ export function buildTools(ctx, config) {
137
137
  }
138
138
 
139
139
  let out
140
+ const ctx = { defs: lib.defs, libRoot: lib.nodekb }
140
141
  if (mode === 'toc') {
141
142
  out = toc_lines(lib.nodes, tid).join('\n')
142
143
  } else if (mode === 'raw') {
143
144
  out = JSON.stringify(node_info(lib.nodes, tid), null, 2)
144
145
  } else if (mode === 'node') {
145
- out = render_node(lib.nodes, tid)
146
+ out = render_node(lib.nodes, tid, ctx)
146
147
  const kids = [...lib.nodes.values()].filter((c) => c.parent_tid === tid).length
147
148
  if (kids) out += `\n\n(本节点含 ${kids} 个子节:正文在子节里;看结构用 mode=toc,看整支不传 mode)`
148
149
  } else {
149
- out = render_subtree(lib.nodes, tid)
150
+ out = render_subtree(lib.nodes, tid, 1, ctx)
150
151
  if (maxlen && out.length > maxlen) {
151
152
  throw new Error(
152
153
  `渲染超上限: ${out.length} 字符 > ${maxlen}。建议 mode=node 单节点、先 mode=toc 看结构、或指定小节 tid;` +
@@ -230,7 +231,7 @@ export function buildTools(ctx, config) {
230
231
  },
231
232
  async execute(args, exec) {
232
233
  const lib = locate(config, exec, args)
233
- const { report, code } = diag(lib.nodes, { problems: lib.problems, strict: !!args.strict, libRoot: lib.nodekb })
234
+ const { report, code } = diag(lib.nodes, { problems: lib.problems, strict: !!args.strict, libRoot: lib.nodekb, defs: lib.defs })
234
235
  const text_ = JSON.stringify(report, null, 2)
235
236
  if (code !== 0) {
236
237
  const e = new Error(text_)
@@ -267,8 +268,8 @@ export function buildTools(ctx, config) {
267
268
  const rows = []
268
269
  for (const node of lib.nodes.values()) {
269
270
  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 })
271
+ const hit = extractRefs(node.content).some((r) => r.kind !== 'tscl' && r.id === tid)
272
+ if (hit) rows.push({ tid: node.tid, title: node.title, type: node.type, source: node.source })
272
273
  }
273
274
  const lines = [`引用 ${tid} 的节点:${rows.length} 个`]
274
275
  for (const r of rows) lines.push(`- ${r.tid} [${r.type}] ${r.title} ${r.source}`)
@@ -435,7 +436,7 @@ export function buildTools(ctx, config) {
435
436
  let report = null
436
437
  try {
437
438
  const fresh = openLibrary(config, exec, { kb: args.kb, lib: args.lib })
438
- report = diag(fresh.nodes, { problems: fresh.problems, libRoot: fresh.nodekb })
439
+ report = diag(fresh.nodes, { problems: fresh.problems, libRoot: fresh.nodekb, defs: fresh.defs })
439
440
  } catch (e) {
440
441
  report = { report: { errors: [`写后解析失败: ${e.message}`], warnings: [], node_count: 0 }, code: 2 }
441
442
  }
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.0",
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,42 @@ 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 移除。
35
58
 
36
59
  ## TID 规则
37
60
 
38
61
  - 文件节点:`<目录>:<文件名 slug>`,例如 `doc/03-工程经验与坑.md` → `doc:03-工程经验与坑`
39
62
  - 显式节点:`<node name="abi">` 里的 `name` 原样就是 TID,可跨文件引用
40
- - 段节点:`## 3.1 xxx` → `<文件TID>:3.1`,但它是**位置型**的,不受引用校验保护——别拿它当引用目标,要引用就用显式节点
41
- - 引用写成 `[[TID]]` 或 `[[显示文字|TID]]`;改名走 `kb_rename`,它会重写全库引用
63
+ - 段节点:`## 3.1 xxx` → `<文件TID>:3.1`,是**位置型**的:插一节就漂。链接它只是 warning(指错了跳错节),但**别拿它当需要长期稳定的锚点**——要稳定就写显式节点
64
+ - 改名走 `kb_rename`,它会重写全库引用
42
65
 
43
66
  ## 写
44
67
 
@@ -48,12 +71,12 @@ kb_write target=<显式节点TID> mode=node content=... # 只换 <
48
71
  kb_write target=doc/03-...md mode=append content=...
49
72
  ```
50
73
 
51
- - 写完**看返回值里的校验结果**:悬空引用、重复块、`<node>` 没独占一行,都会当场报回来。看到就修,别留着。
74
+ - 写完**看返回值里的校验结果**:悬空引用、重复定义、`<node>` 没独占一行,都会当场报回来。看到就修,别留着。
52
75
  - 改已有内容用 `append` / `node`,不要 `replace` 整篇(会丢掉别人刚写的段落)。
53
76
  - 新建文件前先 `kb_read mode=toc` 确认没有同主题的旧文件。
54
77
 
55
78
  ## 三个反例
56
79
 
57
80
  1. 把整段对话贴进 `doc/` —— 记的是过程,不是结论。
58
- 2. 同一件事在 `doc/` 和 `lessons/` 各写一份 —— 引用它,别复制它。
81
+ 2. 同一件事在 `doc/` 和 `lessons/` 各写一份 —— 抽成 `<tscl>` 定义,两处都引用它。
59
82
  3. 只写「改了什么」不写「为什么」 —— 半年后没人知道能不能改回去。