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 +17 -8
- package/README_EN.md +19 -8
- package/lib/client.js +24 -11
- package/lib/engine/commands.js +16 -7
- package/lib/engine/defs.js +60 -0
- package/lib/engine/diag.js +65 -15
- package/lib/engine/mdlib.js +13 -0
- package/lib/engine/parse.js +34 -1
- package/lib/engine/render.js +82 -36
- package/lib/engine/scaffold.js +27 -23
- package/lib/host/prompt.js +4 -3
- package/lib/host/routes.js +7 -5
- package/lib/host/runtime.js +10 -4
- package/lib/host/tools.js +8 -7
- package/package.json +1 -1
- package/skills/project-node-memory-guide/SKILL.md +31 -8
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/
|
|
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/
|
|
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
|
-
[[
|
|
172
|
-
[[
|
|
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
|
-
|
|
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 #
|
|
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/
|
|
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/
|
|
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
|
|
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
|
|
145
|
+
body ← explicit node
|
|
146
146
|
</node>
|
|
147
147
|
|
|
148
|
-
[[
|
|
149
|
-
[[
|
|
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
|
-
|
|
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 #
|
|
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
|
-
/** 把 [[
|
|
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
|
-
|
|
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:
|
|
506
|
-
|
|
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('
|
|
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('
|
|
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
|
}
|
package/lib/engine/commands.js
CHANGED
|
@@ -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
|
+
}
|
package/lib/engine/diag.js
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* diag —— 结构自检。
|
|
3
3
|
*
|
|
4
|
-
* errors = 真损坏(悬空/位置型引用、循环、孤儿、树环、重复 TID
|
|
5
|
-
* warnings = 疑似漂移(跨节点重复块 /
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
if (
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
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'
|
package/lib/engine/mdlib.js
CHANGED
|
@@ -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()
|
package/lib/engine/parse.js
CHANGED
|
@@ -9,8 +9,17 @@
|
|
|
9
9
|
|
|
10
10
|
const CJK = '\\u4e00-\\u9fff'
|
|
11
11
|
|
|
12
|
-
//
|
|
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
|
}
|
package/lib/engine/render.js
CHANGED
|
@@ -1,13 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* render —— 基于内存节点模型的渲染层(md 即库,无持久化状态)。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* -
|
|
6
|
-
* -
|
|
7
|
-
* -
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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(
|
|
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
|
|
package/lib/engine/scaffold.js
CHANGED
|
@@ -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\` | 入口索引 |
|
|
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.
|
|
31
|
-
3. **写之前先 \`kb_read mode=toc\` 看结构**,写完跑 \`kb_diag
|
|
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="
|
|
37
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
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],
|
package/lib/host/prompt.js
CHANGED
|
@@ -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/
|
|
20
|
-
'- 写库:`kb_write`(append/replace/create,或对显式节点用 mode=node
|
|
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
|
-
'-
|
|
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) {
|
package/lib/host/routes.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
}
|
package/lib/host/runtime.js
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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
|
|
271
|
-
if (
|
|
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-guide
|
|
3
|
-
description: 用 .nodekb/ + kb_* 工具沉淀项目长期知识(设计决策、踩坑、事实常量、待办、交接)时的写法规范——什么时候该写、文件怎么分、TID
|
|
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
|
-
|
|
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
|
-
-
|
|
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
|
-
-
|
|
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. 只写「改了什么」不写「为什么」 —— 半年后没人知道能不能改回去。
|