project-node-memory 1.0.6 → 1.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -11
- package/README_EN.md +22 -10
- 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/migrate.js +171 -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 +16 -5
- package/lib/host/runtime.js +10 -4
- package/lib/host/tools.js +36 -7
- package/package.json +1 -1
- package/skills/project-node-memory-guide/SKILL.md +32 -8
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
> 简体中文 · [English](README_EN.md)
|
|
4
4
|
|
|
5
|
-
把 **Markdown 目录当成知识库** 的 DSH 插件:为 Agent 提供
|
|
5
|
+
把 **Markdown 目录当成知识库** 的 DSH 插件:为 Agent 提供 10 个 `kb_*` 工具,为人类提供知识节点面板(侧栏入口名「项目节点知识库」)。
|
|
6
6
|
|
|
7
7
|
没有索引数据库、没有向量、没有后台进程。完整知识库位于项目下的 `.nodekb/` 目录——一堆 md 文件,删掉插件之后它仍然是普通 md。
|
|
8
8
|
|
|
@@ -35,7 +35,7 @@ Agent 默认靠 `read` / `grep` / `glob` 找东西,代价是**试探**:grep
|
|
|
35
35
|
1. **系统提示词段落**(默认开,`prompt.enabled=false` 可关)——每轮都在,规则只有五行:要沉淀就用 `.nodekb/` + `kb_*`,别拿 `write` 手搭 `docs/*.md`。
|
|
36
36
|
段落常驻约 450 字,实测 `GET /nodekb/api/prompt` 可自证是否注册成功。
|
|
37
37
|
|
|
38
|
-
2. **把「建库」变成一次调用**——`kb_init` 直接铺出 `doc/
|
|
38
|
+
2. **把「建库」变成一次调用**——`kb_init` 直接铺出 `doc/ lessons/ todo/ index/entry.md` 与 `.nodekb/README.md` 里的落位约定(幂等,已存在不覆盖)。没有这一步,Agent 会自己发明目录结构。
|
|
39
39
|
|
|
40
40
|
3. **写也走工具**——`kb_write` 写前校验 TID / `<node>` 独占一行 / 路径不越界,写后立刻跑 diag 把悬空引用与重复块报回来。`write` 没有任何一层会拦,所以规则只能靠记。
|
|
41
41
|
|
|
@@ -92,7 +92,7 @@ config:
|
|
|
92
92
|
面板只读,数据走宿主侧注册的 `/nodekb/api/*`。写库就是写 md——用 `write` / `edit` 工具,或者 `kb_write`。
|
|
93
93
|
右栏正文单次最多渲染 10000 字符(超出显示截断提示);宽表格横向滚动,长文本纵向滚动。
|
|
94
94
|
|
|
95
|
-
##
|
|
95
|
+
## 10 个工具
|
|
96
96
|
|
|
97
97
|
| 工具 | 干什么 |
|
|
98
98
|
| --------------- | ---------------------------------------------------------------------------------------------- |
|
|
@@ -101,9 +101,10 @@ config:
|
|
|
101
101
|
| `kb_search` | 中文友好检索(2-gram + 多词加权 + 少见时退化子串),返回稳定 TID、类型、源行、片段 |
|
|
102
102
|
| `kb_diag` | 结构自检;有 error 时直接失败,别读完才发现引用断了 |
|
|
103
103
|
| `kb_backlinks` | 谁引用了它。改 TID / 删节点前先跑 |
|
|
104
|
-
| `kb_init` | 建 `.nodekb` 骨架(幂等),铺出 `doc/
|
|
104
|
+
| `kb_init` | 建 `.nodekb` 骨架(幂等),铺出 `doc/ lessons/ todo/ index/` 与落位约定 |
|
|
105
105
|
| `kb_write` | 写库:`append` / `replace` / `create` / `node`(就地改写一个 `<node>` 块);写前校验、写后自检 |
|
|
106
|
-
| `kb_rename` | 文件改名 +
|
|
106
|
+
| `kb_rename` | 文件改名 + 全库引用一起改写;写前重读,别人刚改的不覆盖 |
|
|
107
|
+
| `kb_migrate` | 旧语法裸引用 `[[x]]` → `[[node="x"]]` / `[[tscl="x"]]`;**默认只演练**,`dry_run=false` 才落盘 |
|
|
107
108
|
| `kb_vcs_status` | 库内嵌 git 的快照历史与未提交改动(git 缺失时只说明,不报错) |
|
|
108
109
|
|
|
109
110
|
## 库怎么定位
|
|
@@ -163,21 +164,30 @@ config:
|
|
|
163
164
|
## 语法
|
|
164
165
|
|
|
165
166
|
```md
|
|
166
|
-
## 3.1 编译契约 → 节点 tid = doc:03-build:3.1
|
|
167
|
+
## 3.1 编译契约 → 节点 tid = doc:03-build:3.1(位置型)
|
|
167
168
|
<node name="fact:abi" type="fact" parent="doc:03-build">
|
|
168
|
-
正文 ←
|
|
169
|
+
正文 ← 显式节点
|
|
169
170
|
</node>
|
|
170
171
|
|
|
171
|
-
[[
|
|
172
|
-
[[
|
|
172
|
+
[[node="doc:03-build:3.1"]] → 结构引用:渲染成一个链接,不复制正文
|
|
173
|
+
[[tscl="fact:abi"]] → 值嵌入:把 <tscl id="fact:abi"> 的定义体替换进来
|
|
174
|
+
|
|
175
|
+
<tscl id="fact:abi">
|
|
176
|
+
三条硬规则:…(这个 id 全库只能定义一次)
|
|
177
|
+
</tscl>
|
|
173
178
|
```
|
|
174
179
|
|
|
175
|
-
|
|
180
|
+
一条原则:**结构用节点(链接),值用 tscl(嵌入)**。同一个数字/结论写两遍就是漂移,
|
|
181
|
+
`kb_diag` 会报;写成 `<tscl>` 定义后各处只留一个引用,改一处全库同步。
|
|
182
|
+
|
|
183
|
+
硬规则:`<node>` / `</node>` 必须各占整行;`<tscl>` 内部不做结构解析(里面的 `##` 不算标题);
|
|
184
|
+
同一 `tscl` id 全库只能定义一次,且不能与节点 TID 同名。
|
|
185
|
+
旧写法 `[[TID]]` 仍兼容(有定义时当宏、否则当链接),`kb_diag` 会 warning,2.0 移除。
|
|
176
186
|
|
|
177
187
|
## 自检(不装 DSH 也能跑)
|
|
178
188
|
|
|
179
189
|
```bash
|
|
180
|
-
node scripts/check.mjs #
|
|
190
|
+
node scripts/check.mjs # 275 项:清单契约 / 工具 / 路由 / 客户端槽位 / 改名改写 / 建库写库 / 提示词段落 / 随包技能
|
|
181
191
|
node test/parity.mjs # 与 Python 原实现逐条对拍(需要 python 与 nodekb 源码)
|
|
182
192
|
```
|
|
183
193
|
|
package/README_EN.md
CHANGED
|
@@ -35,7 +35,7 @@ There is a hard size gate too: a `mode=subtree` render above `maxlen` (default 5
|
|
|
35
35
|
A tool description is only read *after* the agent has already decided "I should record knowledge". Getting it to prefer this path takes four layers:
|
|
36
36
|
|
|
37
37
|
1. **A system-prompt section** (on by default, `prompt.enabled=false` to disable). It is present every turn, and the rule is five lines: to record something use `.nodekb/` + `kb_*`, don't hand-roll `docs/*.md` with `write`. The section is ~450 characters; `GET /nodekb/api/prompt` proves whether it registered.
|
|
38
|
-
2. **Make "create a library" one call.** `kb_init` lays out `doc/
|
|
38
|
+
2. **Make "create a library" one call.** `kb_init` lays out `doc/ lessons/ todo/ index/entry.md` plus the placement conventions in `.nodekb/README.md` (idempotent, never overwrites). Without it, the agent invents its own directory structure.
|
|
39
39
|
3. **Writes go through a tool too.** `kb_write` validates TID syntax, that `<node>` / `</node>` sit on their own lines, and that the path stays inside the library — then runs `diag` immediately and reports dangling refs and duplicate blocks. Plain `write` has no such layer, so the rules survive only by memory.
|
|
40
40
|
4. **A bundled skill, `project-node-memory-guide`** (loaded on demand). The prompt section is five lines; the writing conventions (how to split files, how to name TIDs, what to read before writing, three anti-patterns) live in `skills/project-node-memory-guide/SKILL.md`. It works out of the box with **no manual step**: when the host exposes a `skills` service, the plugin registers the body in memory via `ctx.skills.register()` (no prompt cost until used, withdrawn when the plugin is disabled). `skill.enabled=false` disables it; `GET /nodekb/api/skill` proves whether it registered.
|
|
41
41
|
|
|
@@ -89,7 +89,7 @@ The Markdown renderer is a small home-grown one (headings, lists, code, tables,
|
|
|
89
89
|
The panel is read-only; data flows through host routes under `/nodekb/api/*`. Writing a library means writing Markdown — via `write` / `edit`, or `kb_write`.
|
|
90
90
|
The right column renders at most 10,000 characters per view (with a truncation notice); wide tables scroll horizontally and long text scrolls vertically.
|
|
91
91
|
|
|
92
|
-
## The
|
|
92
|
+
## The ten tools
|
|
93
93
|
|
|
94
94
|
| Tool | What it does |
|
|
95
95
|
|---|---|
|
|
@@ -98,9 +98,10 @@ The right column renders at most 10,000 characters per view (with a truncation n
|
|
|
98
98
|
| `kb_search` | CJK-friendly retrieval (2-gram index + multi-term weighting + substring fallback), returns stable TID, type, source line, snippet |
|
|
99
99
|
| `kb_diag` | Structure self-check; fails outright on errors so you don't finish a read before noticing a broken ref |
|
|
100
100
|
| `kb_backlinks` | Who references this node. Run before renaming a TID or deleting a node |
|
|
101
|
-
| `kb_init` | Scaffold `.nodekb` (idempotent): `doc/
|
|
101
|
+
| `kb_init` | Scaffold `.nodekb` (idempotent): `doc/ lessons/ todo/ index/` plus placement conventions |
|
|
102
102
|
| `kb_write` | Write into the library: `append` / `replace` / `create` / `node` (rewrite one `<node>` block in place); validates before writing, self-checks after |
|
|
103
|
-
| `kb_rename` | Rename a file and rewrite every
|
|
103
|
+
| `kb_rename` | Rename a file and rewrite every reference pointing at it; re-reads before writing, so concurrent edits are never clobbered |
|
|
104
|
+
| `kb_migrate` | Rewrites legacy bare refs `[[x]]` into `[[node="x"]]` / `[[tscl="x"]]`; **dry-run by default**, pass `dry_run=false` to write |
|
|
104
105
|
| `kb_vcs_status` | Snapshot history and uncommitted changes of the library's embedded git repo (explains itself when git is missing) |
|
|
105
106
|
|
|
106
107
|
## How a library is located
|
|
@@ -140,21 +141,32 @@ The panel has no session context, so it follows **the workspace of the most rece
|
|
|
140
141
|
## Syntax (the whole of it)
|
|
141
142
|
|
|
142
143
|
```md
|
|
143
|
-
## 3.1 Compile contract → node tid = doc:03-build:3.1 (positional
|
|
144
|
+
## 3.1 Compile contract → node tid = doc:03-build:3.1 (positional)
|
|
144
145
|
<node name="fact:abi" type="fact" parent="doc:03-build">
|
|
145
|
-
body ← explicit node
|
|
146
|
+
body ← explicit node
|
|
146
147
|
</node>
|
|
147
148
|
|
|
148
|
-
[[
|
|
149
|
-
[[
|
|
149
|
+
[[node="doc:03-build:3.1"]] → structural reference: becomes a link, body is NOT copied
|
|
150
|
+
[[tscl="fact:abi"]] → value embed: the body of <tscl id="fact:abi"> is substituted
|
|
151
|
+
|
|
152
|
+
<tscl id="fact:abi">
|
|
153
|
+
three hard rules: … (this id may be defined exactly once per library)
|
|
154
|
+
</tscl>
|
|
150
155
|
```
|
|
151
156
|
|
|
152
|
-
|
|
157
|
+
One rule of thumb: **structure is a node (link), a single value is tscl (embed)**. Writing the
|
|
158
|
+
same number or conclusion twice is drift, and `kb_diag` reports it; define it once as `<tscl>` and
|
|
159
|
+
leave references at every use site, so a single edit updates the whole library.
|
|
160
|
+
|
|
161
|
+
Hard rules: `<node>` / `</node>` must each occupy an entire line; nothing inside `<tscl>` is parsed
|
|
162
|
+
as structure (a `##` there is not a heading); a `tscl` id may be defined exactly once and must not
|
|
163
|
+
collide with a node TID. The old `[[TID]]` form still works (macro when a definition exists, link
|
|
164
|
+
otherwise) with a `kb_diag` warning, and goes away in 2.0.
|
|
153
165
|
|
|
154
166
|
## Self-check (no DSH required)
|
|
155
167
|
|
|
156
168
|
```bash
|
|
157
|
-
node scripts/check.mjs #
|
|
169
|
+
node scripts/check.mjs # 275 checks: manifest contract / tools / routes / client slots / rename rewriting / scaffold + write / prompt section / bundled skill
|
|
158
170
|
node test/parity.mjs # case-by-case parity against the Python implementation (needs python and nodekb sources)
|
|
159
171
|
```
|
|
160
172
|
|
package/lib/client.js
CHANGED
|
@@ -186,6 +186,7 @@
|
|
|
186
186
|
'.nkb-muted{opacity:.55}',
|
|
187
187
|
'.nkb-err{color:#d9534f;white-space:pre-wrap}',
|
|
188
188
|
'.nkb-link{color:#3b82f6;cursor:pointer;text-decoration:underline dotted}',
|
|
189
|
+
'.nkb-macro{color:#b45309;cursor:help;text-decoration:underline dashed}',
|
|
189
190
|
'.nkb-kv{display:flex;gap:6px}',
|
|
190
191
|
'.nkb-kv .k{opacity:.5;flex:0 0 58px}',
|
|
191
192
|
'.nkb-kv .v{word-break:break-all}',
|
|
@@ -199,17 +200,19 @@
|
|
|
199
200
|
}
|
|
200
201
|
|
|
201
202
|
// ── 小组件 ────────────────────────────────────────────────────────────
|
|
202
|
-
/** 把 [[
|
|
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()
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* migrate —— 把旧写法裸引用 `[[x]]` 迁成 1.1 的双语法。
|
|
3
|
+
*
|
|
4
|
+
* 规则:
|
|
5
|
+
* - x 是 tscl 定义 id → `[[tscl="x"]]`(值嵌入)
|
|
6
|
+
* - x 是节点 TID → `[[node="x"]]`(链接)
|
|
7
|
+
* - 两者都不是 → 原样保留,记进 unresolved(本来就悬空,迁移不该猜)
|
|
8
|
+
*
|
|
9
|
+
* 三条纪律与 rename 一致:围栏内不动、写前重读复核 mtime/size、默认只演练(dry_run)。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import fs from 'node:fs'
|
|
13
|
+
import path from 'node:path'
|
|
14
|
+
import { ROOT_TID } from './mdlib.js'
|
|
15
|
+
|
|
16
|
+
const FENCE = '```'
|
|
17
|
+
const BARE_REF = /\[\[([^[\]"=\s][^[\]]*)\]\]/g
|
|
18
|
+
|
|
19
|
+
function absOf(lib, node) {
|
|
20
|
+
const raw = String(node.source || '').split('#')[0]
|
|
21
|
+
if (!raw) return ''
|
|
22
|
+
return path.isAbsolute(raw) ? path.normalize(raw) : path.join(lib.nodekb, raw)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function relOf(lib, abs) {
|
|
26
|
+
return path.relative(lib.nodekb, abs).split(path.sep).join('/')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function allMarkdown(nodekb) {
|
|
30
|
+
const out = []
|
|
31
|
+
const walk = (d) => {
|
|
32
|
+
let entries
|
|
33
|
+
try {
|
|
34
|
+
entries = fs.readdirSync(d, { withFileTypes: true })
|
|
35
|
+
} catch {
|
|
36
|
+
return
|
|
37
|
+
}
|
|
38
|
+
for (const e of entries) {
|
|
39
|
+
if (e.name.startsWith('.')) continue
|
|
40
|
+
const p = path.join(d, e.name)
|
|
41
|
+
if (e.isDirectory()) walk(p)
|
|
42
|
+
else if (e.name.endsWith('.md')) out.push(p)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
walk(nodekb)
|
|
46
|
+
return out
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 纯函数:把一段 md 文本里的裸引用改写(可单测) */
|
|
50
|
+
export function migrateText(text, { isTid, isDef }) {
|
|
51
|
+
const out = []
|
|
52
|
+
const hits = { node: 0, tscl: 0, unresolved: [] }
|
|
53
|
+
let fenced = false
|
|
54
|
+
for (const line of String(text).split('\n')) {
|
|
55
|
+
if (line.trim().startsWith(FENCE)) {
|
|
56
|
+
fenced = !fenced
|
|
57
|
+
out.push(line)
|
|
58
|
+
continue
|
|
59
|
+
}
|
|
60
|
+
if (fenced) {
|
|
61
|
+
out.push(line)
|
|
62
|
+
continue
|
|
63
|
+
}
|
|
64
|
+
out.push(
|
|
65
|
+
line.replace(BARE_REF, (whole, inner) => {
|
|
66
|
+
const id = inner.trim()
|
|
67
|
+
if (isDef(id)) {
|
|
68
|
+
hits.tscl += 1
|
|
69
|
+
return `[[tscl="${id}"]]`
|
|
70
|
+
}
|
|
71
|
+
if (isTid(id)) {
|
|
72
|
+
hits.node += 1
|
|
73
|
+
return `[[node="${id}"]]`
|
|
74
|
+
}
|
|
75
|
+
hits.unresolved.push(id)
|
|
76
|
+
return whole
|
|
77
|
+
}),
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
return { text: out.join('\n'), hits }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 迁移整个库。
|
|
85
|
+
* @param {{nodekb:string, nodes:Map, defs?:Map}} lib openLibrary 的结果
|
|
86
|
+
* @param {{dryRun?:boolean}} [opts] 默认演练
|
|
87
|
+
*/
|
|
88
|
+
export function migrateLibrary(lib, opts = {}) {
|
|
89
|
+
const dryRun = opts.dryRun !== false
|
|
90
|
+
const defs = lib.defs || new Map()
|
|
91
|
+
const nodes = lib.nodes
|
|
92
|
+
const isTid = (id) => nodes.has(id)
|
|
93
|
+
const isDef = (id) => defs.has(id)
|
|
94
|
+
|
|
95
|
+
const report = { dry_run: dryRun, files: [], node: 0, tscl: 0, unresolved: [], skipped: [] }
|
|
96
|
+
const edits = []
|
|
97
|
+
const visited = new Set()
|
|
98
|
+
|
|
99
|
+
for (const node of nodes.values()) {
|
|
100
|
+
if (node.kind !== 'file') continue
|
|
101
|
+
const abs = absOf(lib, node)
|
|
102
|
+
if (!abs) continue
|
|
103
|
+
visited.add(path.resolve(abs))
|
|
104
|
+
let text
|
|
105
|
+
let st1
|
|
106
|
+
try {
|
|
107
|
+
st1 = fs.statSync(abs)
|
|
108
|
+
text = fs.readFileSync(abs, 'utf8')
|
|
109
|
+
} catch (e) {
|
|
110
|
+
report.skipped.push({ path: relOf(lib, abs), why: `读失败: ${e.message}` })
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
113
|
+
if (!text.includes('[[')) continue
|
|
114
|
+
const { text: next, hits } = migrateText(text, { isTid, isDef })
|
|
115
|
+
const changed = hits.node + hits.tscl
|
|
116
|
+
for (const u of hits.unresolved) report.unresolved.push({ path: relOf(lib, abs), ref: u })
|
|
117
|
+
if (!changed) continue
|
|
118
|
+
report.node += hits.node
|
|
119
|
+
report.tscl += hits.tscl
|
|
120
|
+
report.files.push({ path: relOf(lib, abs), count: changed, node: hits.node, tscl: hits.tscl })
|
|
121
|
+
edits.push({ abs, st1, next, path: relOf(lib, abs) })
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (!dryRun) {
|
|
125
|
+
for (const e of edits) {
|
|
126
|
+
try {
|
|
127
|
+
const st2 = fs.statSync(e.abs)
|
|
128
|
+
if (st2.mtimeMs !== e.st1.mtimeMs || st2.size !== e.st1.size) {
|
|
129
|
+
report.skipped.push({ path: e.path, why: '读后被改动,未覆盖' })
|
|
130
|
+
continue
|
|
131
|
+
}
|
|
132
|
+
fs.writeFileSync(e.abs, e.next, 'utf8')
|
|
133
|
+
} catch (err) {
|
|
134
|
+
report.skipped.push({ path: e.path, why: err.message })
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// 兜底:只报「存在但没进索引」的 md(解析失败的孤儿文件),不重复报已处理的
|
|
140
|
+
for (const abs of allMarkdown(lib.nodekb)) {
|
|
141
|
+
if (visited.has(path.resolve(abs))) continue
|
|
142
|
+
report.skipped.push({ path: relOf(lib, abs), why: '未被解析为目标节点,未处理' })
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return report
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function formatMigrate(report) {
|
|
149
|
+
const lines = []
|
|
150
|
+
lines.push(report.dry_run ? '(演练,未落盘;确认后传 dry_run=false 才写)' : '已写入')
|
|
151
|
+
const total = report.files.reduce((s, f) => s + f.count, 0)
|
|
152
|
+
if (!total) {
|
|
153
|
+
lines.push('没有需要迁移的裸引用')
|
|
154
|
+
} else {
|
|
155
|
+
lines.push(`共 ${total} 处裸引用 → ${report.tscl} 处 [[tscl="…"]]、${report.node} 处 [[node="…"]]`)
|
|
156
|
+
for (const f of report.files.slice(0, 20)) lines.push(` - ${f.path}: ${f.count}(tscl ${f.tscl} / node ${f.node})`)
|
|
157
|
+
if (report.files.length > 20) lines.push(` …另有 ${report.files.length - 20} 个文件`)
|
|
158
|
+
}
|
|
159
|
+
if (report.unresolved.length) {
|
|
160
|
+
lines.push(`保留未改 ${report.unresolved.length} 处(指向的 id 既不是节点也不是定义,本来悬空):`)
|
|
161
|
+
for (const u of report.unresolved.slice(0, 10)) lines.push(` - ${u.path}: [[${u.ref}]]`)
|
|
162
|
+
if (report.unresolved.length > 10) lines.push(` …另有 ${report.unresolved.length - 10} 处`)
|
|
163
|
+
}
|
|
164
|
+
if (report.skipped.length) {
|
|
165
|
+
lines.push(`跳过 ${report.skipped.length} 个文件:`)
|
|
166
|
+
for (const s of report.skipped.slice(0, 10)) lines.push(` - ${s.path}: ${s.why}`)
|
|
167
|
+
}
|
|
168
|
+
return lines.join('\n')
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export { ROOT_TID }
|
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 移除(老库可先跑 `kb_migrate` 演练,确认后 `dry_run=false` 批量迁)。',
|
|
24
25
|
].join('\n')
|
|
25
26
|
|
|
26
27
|
function textOf(config) {
|
package/lib/host/routes.js
CHANGED
|
@@ -10,8 +10,9 @@ import path from 'node:path'
|
|
|
10
10
|
import { render_node, render_subtree, toc_tree, node_info, resolve_path } from '../engine/render.js'
|
|
11
11
|
import { search, formatSearch } from '../engine/search.js'
|
|
12
12
|
import { diag } from '../engine/diag.js'
|
|
13
|
-
import {
|
|
13
|
+
import { extractRefs } from '../engine/parse.js'
|
|
14
14
|
import { renameFileNode, formatRename } from '../engine/rename.js'
|
|
15
|
+
import { migrateLibrary, formatMigrate } from '../engine/migrate.js'
|
|
15
16
|
import { openLibrary, knownLibraries, whereInfo, workspaceOf } from './runtime.js'
|
|
16
17
|
import { promptState } from './prompt.js'
|
|
17
18
|
import { skillState } from './skill.js'
|
|
@@ -171,10 +172,11 @@ export function registerRoutes(ctx, config) {
|
|
|
171
172
|
return
|
|
172
173
|
}
|
|
173
174
|
const node = lib.nodes.get(tid)
|
|
175
|
+
const ctx = { defs: lib.defs, libRoot: lib.nodekb }
|
|
174
176
|
let text
|
|
175
|
-
if (mode === 'node') text = render_node(lib.nodes, tid)
|
|
177
|
+
if (mode === 'node') text = render_node(lib.nodes, tid, ctx)
|
|
176
178
|
else if (mode === 'raw') text = node.content
|
|
177
|
-
else text = render_subtree(lib.nodes, tid)
|
|
179
|
+
else text = render_subtree(lib.nodes, tid, 1, ctx)
|
|
178
180
|
send(res, 200, {
|
|
179
181
|
kb: lib.name,
|
|
180
182
|
...node_info(lib.nodes, tid),
|
|
@@ -227,7 +229,7 @@ export function registerRoutes(ctx, config) {
|
|
|
227
229
|
|
|
228
230
|
if (url.pathname === `${PREFIX}/diag`) {
|
|
229
231
|
const lib = open()
|
|
230
|
-
const { report } = diag(lib.nodes, { problems: lib.problems, libRoot: lib.nodekb })
|
|
232
|
+
const { report } = diag(lib.nodes, { problems: lib.problems, libRoot: lib.nodekb, defs: lib.defs })
|
|
231
233
|
send(res, 200, { kb: lib.name, ...report })
|
|
232
234
|
return
|
|
233
235
|
}
|
|
@@ -242,7 +244,8 @@ export function registerRoutes(ctx, config) {
|
|
|
242
244
|
}
|
|
243
245
|
const rows = []
|
|
244
246
|
for (const node of lib.nodes.values()) {
|
|
245
|
-
|
|
247
|
+
const hit = extractRefs(node.content).some((r) => r.kind !== 'tscl' && r.id === tid)
|
|
248
|
+
if (node.tid !== tid && hit) {
|
|
246
249
|
rows.push({ tid: node.tid, title: node.title, type: node.type, source: node.source })
|
|
247
250
|
}
|
|
248
251
|
}
|
|
@@ -277,6 +280,14 @@ export function registerRoutes(ctx, config) {
|
|
|
277
280
|
return
|
|
278
281
|
}
|
|
279
282
|
|
|
283
|
+
if (url.pathname === `${PREFIX}/migrate`) {
|
|
284
|
+
const lib = open()
|
|
285
|
+
const dryRun = !(q.get('dry_run') === '0' || q.get('dry_run') === 'false')
|
|
286
|
+
const report = migrateLibrary(lib, { dryRun })
|
|
287
|
+
send(res, 200, { kb: lib.name, ...report, text: formatMigrate(report) })
|
|
288
|
+
return
|
|
289
|
+
}
|
|
290
|
+
|
|
280
291
|
send(res, 404, { error: `未知端点: ${url.pathname}` })
|
|
281
292
|
} catch (e) {
|
|
282
293
|
send(res, 400, { error: e && e.message ? e.message : String(e) })
|
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,8 +12,9 @@ import { execFileSync } from 'node:child_process'
|
|
|
12
12
|
import { render_node, render_subtree, toc_lines, node_info, resolve_path } from '../engine/render.js'
|
|
13
13
|
import { search, formatSearch } from '../engine/search.js'
|
|
14
14
|
import { diag } from '../engine/diag.js'
|
|
15
|
-
import {
|
|
15
|
+
import { extractRefs } from '../engine/parse.js'
|
|
16
16
|
import { renameFileNode, formatRename } from '../engine/rename.js'
|
|
17
|
+
import { migrateLibrary, formatMigrate } from '../engine/migrate.js'
|
|
17
18
|
import { scaffold } from '../engine/scaffold.js'
|
|
18
19
|
import { writeInto } from '../engine/write.js'
|
|
19
20
|
import { isInside } from '../engine/registry.js'
|
|
@@ -137,16 +138,17 @@ export function buildTools(ctx, config) {
|
|
|
137
138
|
}
|
|
138
139
|
|
|
139
140
|
let out
|
|
141
|
+
const ctx = { defs: lib.defs, libRoot: lib.nodekb }
|
|
140
142
|
if (mode === 'toc') {
|
|
141
143
|
out = toc_lines(lib.nodes, tid).join('\n')
|
|
142
144
|
} else if (mode === 'raw') {
|
|
143
145
|
out = JSON.stringify(node_info(lib.nodes, tid), null, 2)
|
|
144
146
|
} else if (mode === 'node') {
|
|
145
|
-
out = render_node(lib.nodes, tid)
|
|
147
|
+
out = render_node(lib.nodes, tid, ctx)
|
|
146
148
|
const kids = [...lib.nodes.values()].filter((c) => c.parent_tid === tid).length
|
|
147
149
|
if (kids) out += `\n\n(本节点含 ${kids} 个子节:正文在子节里;看结构用 mode=toc,看整支不传 mode)`
|
|
148
150
|
} else {
|
|
149
|
-
out = render_subtree(lib.nodes, tid)
|
|
151
|
+
out = render_subtree(lib.nodes, tid, 1, ctx)
|
|
150
152
|
if (maxlen && out.length > maxlen) {
|
|
151
153
|
throw new Error(
|
|
152
154
|
`渲染超上限: ${out.length} 字符 > ${maxlen}。建议 mode=node 单节点、先 mode=toc 看结构、或指定小节 tid;` +
|
|
@@ -230,7 +232,7 @@ export function buildTools(ctx, config) {
|
|
|
230
232
|
},
|
|
231
233
|
async execute(args, exec) {
|
|
232
234
|
const lib = locate(config, exec, args)
|
|
233
|
-
const { report, code } = diag(lib.nodes, { problems: lib.problems, strict: !!args.strict, libRoot: lib.nodekb })
|
|
235
|
+
const { report, code } = diag(lib.nodes, { problems: lib.problems, strict: !!args.strict, libRoot: lib.nodekb, defs: lib.defs })
|
|
234
236
|
const text_ = JSON.stringify(report, null, 2)
|
|
235
237
|
if (code !== 0) {
|
|
236
238
|
const e = new Error(text_)
|
|
@@ -267,8 +269,8 @@ export function buildTools(ctx, config) {
|
|
|
267
269
|
const rows = []
|
|
268
270
|
for (const node of lib.nodes.values()) {
|
|
269
271
|
if (node.tid === tid) continue
|
|
270
|
-
const
|
|
271
|
-
if (
|
|
272
|
+
const hit = extractRefs(node.content).some((r) => r.kind !== 'tscl' && r.id === tid)
|
|
273
|
+
if (hit) rows.push({ tid: node.tid, title: node.title, type: node.type, source: node.source })
|
|
272
274
|
}
|
|
273
275
|
const lines = [`引用 ${tid} 的节点:${rows.length} 个`]
|
|
274
276
|
for (const r of rows) lines.push(`- ${r.tid} [${r.type}] ${r.title} ${r.source}`)
|
|
@@ -308,6 +310,33 @@ export function buildTools(ctx, config) {
|
|
|
308
310
|
},
|
|
309
311
|
})
|
|
310
312
|
|
|
313
|
+
// ── kb_migrate ───────────────────────────────────────────────────────────
|
|
314
|
+
tools.push({
|
|
315
|
+
name: 'kb_migrate',
|
|
316
|
+
description:
|
|
317
|
+
'把库里的旧写法裸引用 [[x]] 迁成 1.1 的双语法:[[tscl="x"]](x 是定义)或 [[node="x"]](x 是节点 TID)。' +
|
|
318
|
+
'默认只演练(dry_run 缺省为 true),返回每个文件会改几处、哪些引用指向不存在的 id 会原样保留。' +
|
|
319
|
+
'围栏代码块内不动;写前重读校验,别人刚改过的文件不覆盖。',
|
|
320
|
+
parameters: {
|
|
321
|
+
type: 'object',
|
|
322
|
+
properties: {
|
|
323
|
+
dry_run: { type: 'boolean', description: '默认 true(只报告)。确认无误后传 false 才真正写盘。' },
|
|
324
|
+
kb: KB_PARAM,
|
|
325
|
+
lib: LIB_PARAM,
|
|
326
|
+
},
|
|
327
|
+
},
|
|
328
|
+
output: {
|
|
329
|
+
schema: {},
|
|
330
|
+
render: (_args, value) => text(value.text),
|
|
331
|
+
},
|
|
332
|
+
async execute(args, exec) {
|
|
333
|
+
const lib = locate(config, exec, args)
|
|
334
|
+
const dryRun = args.dry_run === undefined ? true : Boolean(args.dry_run)
|
|
335
|
+
const report = migrateLibrary(lib, { dryRun })
|
|
336
|
+
return { ...report, kb: lib.name, text: warnPrefix(lib) + formatMigrate(report) }
|
|
337
|
+
},
|
|
338
|
+
})
|
|
339
|
+
|
|
311
340
|
// ── kb_vcs_status ────────────────────────────────────────────────────────
|
|
312
341
|
tools.push({
|
|
313
342
|
name: 'kb_vcs_status',
|
|
@@ -435,7 +464,7 @@ export function buildTools(ctx, config) {
|
|
|
435
464
|
let report = null
|
|
436
465
|
try {
|
|
437
466
|
const fresh = openLibrary(config, exec, { kb: args.kb, lib: args.lib })
|
|
438
|
-
report = diag(fresh.nodes, { problems: fresh.problems, libRoot: fresh.nodekb })
|
|
467
|
+
report = diag(fresh.nodes, { problems: fresh.problems, libRoot: fresh.nodekb, defs: fresh.defs })
|
|
439
468
|
} catch (e) {
|
|
440
469
|
report = { report: { errors: [`写后解析失败: ${e.message}`], warnings: [], node_count: 0 }, code: 2 }
|
|
441
470
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: project-node-memory-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,43 @@ kb_search query=... # toc 里找不到再搜
|
|
|
26
26
|
| 目录 | 放什么 | 判据 |
|
|
27
27
|
|---|---|---|
|
|
28
28
|
| `doc/` | 主题文档:架构、模块、流程、配置 | 「是什么、怎么运转」 |
|
|
29
|
-
| `facts/` | 常量与事实:版本、路径、阈值、接口 | 一句话能说完、会被反复引用 |
|
|
30
29
|
| `lessons/` | 踩坑记录 | 「症状 → 根因 → 对策」三行齐全 |
|
|
31
30
|
| `todo/` | 未完成事项、阻塞、交接 | 有明确的下一步动作 |
|
|
32
31
|
| `index/entry.md` | 总入口与落位约定 | 只放导航,不放正文 |
|
|
33
32
|
|
|
34
|
-
|
|
33
|
+
这张表是「不知道放哪时」的默认答案,**不是白名单**:项目没有待办就别建 `todo/`,需要 `adr/`、`api/`、`runbook/`、`metrics/` 就自己加;节点 `type` 同样自由(`type="decision"`、`type="api"` 都行)。
|
|
34
|
+
|
|
35
|
+
一个主题一个文件,**单文件取 < 4KB**;变大就按子主题拆成 `## 3.x` 段或独立文件。
|
|
36
|
+
|
|
37
|
+
## 两种引用,别混
|
|
38
|
+
|
|
39
|
+
| 写法 | 渲染成 | 用在 |
|
|
40
|
+
|---|---|---|
|
|
41
|
+
| `[[node="doc:03-build:3.1"]]` | 一个链接,**不复制正文** | 指向另一段结构:「细节见 …」 |
|
|
42
|
+
| `[[tscl="fact:abi"]]` | 把 `<tscl id="fact:abi">` 的定义体**替换进来** | 同一句话/同一组数字必须在多处出现 |
|
|
43
|
+
|
|
44
|
+
```md
|
|
45
|
+
<tscl id="fact:abi">
|
|
46
|
+
三条硬规则:1) 只用 C ABI;2) 名字加前缀 `fm_`;3) 导出结构体必须定长。
|
|
47
|
+
</tscl>
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
规则:
|
|
51
|
+
|
|
52
|
+
- **同一个 id 全库只能定义一次**,否则 `kb_diag` 报 error(重复定义)。
|
|
53
|
+
- id 不能和节点 TID 同名(命名空间冲突 = error)。惯例:值用 `fact:` 前缀,结构用目录前缀。
|
|
54
|
+
- 宏体**跨行**时不要内联进表格单元格(diag 会 warning,渲染也会断行)。
|
|
55
|
+
- 定义块内部的 `##` **不会**变成节点——它只是文本。
|
|
56
|
+
|
|
57
|
+
旧写法 `[[TID]]` 仍能解析(有定义时当宏、否则当链接),但 `kb_diag` 会 warning,2.0 移除。
|
|
58
|
+
接手老库时先跑 `kb_migrate`(默认只演练),看清每处会改成 `[[node=…]]` 还是 `[[tscl=…]]` 再 `dry_run=false`。
|
|
35
59
|
|
|
36
60
|
## TID 规则
|
|
37
61
|
|
|
38
62
|
- 文件节点:`<目录>:<文件名 slug>`,例如 `doc/03-工程经验与坑.md` → `doc:03-工程经验与坑`
|
|
39
63
|
- 显式节点:`<node name="abi">` 里的 `name` 原样就是 TID,可跨文件引用
|
|
40
|
-
- 段节点:`## 3.1 xxx` → `<文件TID>:3.1
|
|
41
|
-
-
|
|
64
|
+
- 段节点:`## 3.1 xxx` → `<文件TID>:3.1`,是**位置型**的:插一节就漂。链接它只是 warning(指错了跳错节),但**别拿它当需要长期稳定的锚点**——要稳定就写显式节点
|
|
65
|
+
- 改名走 `kb_rename`,它会重写全库引用
|
|
42
66
|
|
|
43
67
|
## 写
|
|
44
68
|
|
|
@@ -48,12 +72,12 @@ kb_write target=<显式节点TID> mode=node content=... # 只换 <
|
|
|
48
72
|
kb_write target=doc/03-...md mode=append content=...
|
|
49
73
|
```
|
|
50
74
|
|
|
51
|
-
-
|
|
75
|
+
- 写完**看返回值里的校验结果**:悬空引用、重复定义、`<node>` 没独占一行,都会当场报回来。看到就修,别留着。
|
|
52
76
|
- 改已有内容用 `append` / `node`,不要 `replace` 整篇(会丢掉别人刚写的段落)。
|
|
53
77
|
- 新建文件前先 `kb_read mode=toc` 确认没有同主题的旧文件。
|
|
54
78
|
|
|
55
79
|
## 三个反例
|
|
56
80
|
|
|
57
81
|
1. 把整段对话贴进 `doc/` —— 记的是过程,不是结论。
|
|
58
|
-
2. 同一件事在 `doc/` 和 `lessons/` 各写一份 ——
|
|
82
|
+
2. 同一件事在 `doc/` 和 `lessons/` 各写一份 —— 抽成 `<tscl>` 定义,两处都引用它。
|
|
59
83
|
3. 只写「改了什么」不写「为什么」 —— 半年后没人知道能不能改回去。
|