project-node-memory 1.4.0 → 1.4.4
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 +9 -1
- package/lib/engine/commands.js +1 -1
- package/lib/engine/defs.js +22 -8
- package/lib/engine/diag.js +5 -2
- package/lib/engine/mdlib.js +35 -7
- package/lib/engine/parse.js +59 -6
- package/lib/engine/registry.js +10 -8
- package/lib/engine/rename.js +38 -19
- package/lib/engine/render.js +61 -8
- package/lib/engine/write.js +40 -9
- package/lib/host/routes.js +1 -1
- package/lib/host/runtime.js +16 -2
- package/lib/host/tools.js +24 -5
- package/package.json +1 -1
- package/skills/project-node-memory-guide/SKILL.md +4 -1
package/README.md
CHANGED
|
@@ -197,7 +197,15 @@ config:
|
|
|
197
197
|
`kb_diag` 会报;写成 `<tscl>` 定义后各处只留一个引用,改一处全库同步。
|
|
198
198
|
|
|
199
199
|
硬规则:`<node>` / `</node>` 必须各占整行;`<tscl>` 内部不做结构解析(里面的 `##` 不算标题);
|
|
200
|
-
同一 `tscl` id 全库只能定义一次,且不能与节点 TID
|
|
200
|
+
同一 `tscl` id 全库只能定义一次,且不能与节点 TID 同名。`<node tid="…">` 与
|
|
201
|
+
`<node name="…">` 等价(`title="…"` 可另给显示名);缺 `name`/`tid` 的 `<node>` 会整段
|
|
202
|
+
按正文处理并在 `kb_diag` 里报 warning —— 不会静默消失,`</node>` 也不会漏进正文。
|
|
203
|
+
|
|
204
|
+
代码区(``` 围栏与 `行内代码`)里的 `[[…]]` 和 `<tscl>` 都当**示例**:不解析、不计引用、
|
|
205
|
+
不算定义。写文档时不必再改全角括号。
|
|
206
|
+
|
|
207
|
+
TID 支持前缀兜底:`kb_read target=doc:02` 能命中 `doc:02-运行环境`(多个候选中取唯一的
|
|
208
|
+
文件/文件夹节点;纯前缀歧义仍报错并给候选)。
|
|
201
209
|
|
|
202
210
|
定义也可以**就地写在句子中间**,渲染时标签被剥掉、值留在原处:
|
|
203
211
|
|
package/lib/engine/commands.js
CHANGED
|
@@ -33,7 +33,7 @@ export function cmdRead(nodes, target, { node = false, raw = false, toc = false,
|
|
|
33
33
|
// 不是节点:再看是不是一个 tscl 定义 id(值的老家可能只是一句话)
|
|
34
34
|
const defsMap = defs || collectDefs(nodes).defs
|
|
35
35
|
const def = defsMap.get(target)
|
|
36
|
-
if (def) return { text: formatDefRead(def, refsToDef(nodes, target)), code: 0, def: target }
|
|
36
|
+
if (def) return { text: formatDefRead(def, refsToDef(nodes, target, def.ownerTid)), code: 0, def: target }
|
|
37
37
|
const cands = [...nodes.keys()]
|
|
38
38
|
.filter((t) => t.startsWith(target) || t.startsWith(target + ':'))
|
|
39
39
|
.sort()
|
package/lib/engine/defs.js
CHANGED
|
@@ -59,12 +59,16 @@ export function isMultiline(body) {
|
|
|
59
59
|
return String(body || '').replace(/\s+$/, '').includes('\n')
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
/** 谁在正文里引用了这个定义([[tscl="id"]]
|
|
63
|
-
|
|
62
|
+
/** 谁在正文里引用了这个定义([[tscl="id"]],或指向它的旧语法裸引用)
|
|
63
|
+
* → [{tid,title,source,line,legacy,self}]
|
|
64
|
+
* ownerTid 只用来标记「定义所在节点自己也引用了一次」(self:true);计数时排除,
|
|
65
|
+
* 但列表里保留,免得用户以为那处引用凭空消失。line 是文件内的绝对行号。 */
|
|
66
|
+
export function refsToDef(nodes, id, ownerTid = '') {
|
|
64
67
|
const out = []
|
|
65
68
|
for (const node of nodes.values()) {
|
|
66
69
|
const content = String(node.content || '')
|
|
67
70
|
if (!content.includes(id)) continue
|
|
71
|
+
const base = Number(node.content_line || 1)
|
|
68
72
|
for (const ref of extractRefs(content)) {
|
|
69
73
|
if (ref.id !== id) continue
|
|
70
74
|
if (ref.kind !== 'tscl' && ref.kind !== 'legacy') continue
|
|
@@ -72,8 +76,9 @@ export function refsToDef(nodes, id) {
|
|
|
72
76
|
tid: node.tid,
|
|
73
77
|
title: node.title || '',
|
|
74
78
|
source: node.source || '',
|
|
75
|
-
line: content.slice(0, ref.index).split('\n').length,
|
|
79
|
+
line: base + content.slice(0, ref.index).split('\n').length - 1,
|
|
76
80
|
legacy: ref.kind === 'legacy',
|
|
81
|
+
self: Boolean(ownerTid) && node.tid === ownerTid,
|
|
77
82
|
})
|
|
78
83
|
}
|
|
79
84
|
}
|
|
@@ -82,17 +87,26 @@ export function refsToDef(nodes, id) {
|
|
|
82
87
|
|
|
83
88
|
/** 定义 → 人读文本(kb_read 按 tscl id 读、面板点宏时跳转用) */
|
|
84
89
|
export function formatDefRead(def, refs = []) {
|
|
85
|
-
const
|
|
90
|
+
const fileOf = (source) => String(source || '').split('#')[0].split(/[\\/]/).filter(Boolean).slice(-2).join('/')
|
|
91
|
+
const src = fileOf(def.source)
|
|
86
92
|
const lines = [`定义体 <tscl id="${def.id}"> · 定义在 ${def.ownerTid}${src ? `(${src})` : ''}`, '', String(def.body || '').replace(/\s+$/, '')]
|
|
87
93
|
if (def.count > 1) lines.push('', `重复定义 ${def.count} 处(必须只剩一处): ${def.owners.join('、')}`)
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
94
|
+
const outside = refs.filter((r) => !r.self)
|
|
95
|
+
const selfRefs = refs.filter((r) => r.self)
|
|
96
|
+
if (outside.length) {
|
|
97
|
+
lines.push('', `被引用 ${outside.length} 处:`)
|
|
98
|
+
for (const r of outside) {
|
|
99
|
+
const at = fileOf(r.source)
|
|
92
100
|
lines.push(`- ${r.tid}${r.title ? ` · ${r.title}` : ''}${at ? `(${at}#L${r.line})` : ''}${r.legacy ? ` ← 旧语法,建议 [[tscl="${def.id}"]]` : ''}`)
|
|
93
101
|
}
|
|
94
102
|
} else {
|
|
95
103
|
lines.push('', '尚未被任何 [[tscl="…"]] 引用。')
|
|
96
104
|
}
|
|
105
|
+
if (selfRefs.length) {
|
|
106
|
+
const where = selfRefs
|
|
107
|
+
.map((r) => `${fileOf(r.source) || r.tid}#L${r.line}`)
|
|
108
|
+
.join('、')
|
|
109
|
+
lines.push('', `定义所在节点自己也引用了 ${selfRefs.length} 次(不计入上面的引用数): ${where}`)
|
|
110
|
+
}
|
|
97
111
|
return lines.join('\n')
|
|
98
112
|
}
|
package/lib/engine/diag.js
CHANGED
|
@@ -174,8 +174,11 @@ function typeCounts(nodes) {
|
|
|
174
174
|
* @returns {{report:object, code:number}}
|
|
175
175
|
*/
|
|
176
176
|
export function diag(nodes, { problems = [], strict = false, libRoot, defs = null } = {}) {
|
|
177
|
-
|
|
178
|
-
|
|
177
|
+
// 解析期的「形状问题」分两级:真损坏(重复 TID、树环)算 error;
|
|
178
|
+
// 手写 <node> 出错(缺 name、孤立 </node>)算 warning —— 那行按正文处理,库仍可用。
|
|
179
|
+
const SOFT_PARSE = /^(显式节点缺|孤立的 <\/node>)/
|
|
180
|
+
const errors = problems.filter((p) => !SOFT_PARSE.test(String(p)))
|
|
181
|
+
const warnings = problems.filter((p) => SOFT_PARSE.test(String(p)))
|
|
179
182
|
|
|
180
183
|
// 定义表:全库 <tscl id="x">…</tscl>
|
|
181
184
|
let defsMap = defs
|
package/lib/engine/mdlib.js
CHANGED
|
@@ -58,10 +58,13 @@ function toPosix(p) {
|
|
|
58
58
|
/**
|
|
59
59
|
* 解析单个 md 文件为节点列表(文件节点 + 隐式 H2 段 + 显式 <node>,按物理行序)。
|
|
60
60
|
*/
|
|
61
|
-
export function parse_file(absPath, file_tid, folder_type, parent_tid) {
|
|
61
|
+
export function parse_file(absPath, file_tid, folder_type, parent_tid, problems) {
|
|
62
62
|
const text = fs.readFileSync(absPath, 'utf8').replace(/^\uFEFF+/, '')
|
|
63
63
|
const lines = splitLines(text)
|
|
64
64
|
const rel = toPosix(absPath)
|
|
65
|
+
const warn = (msg) => {
|
|
66
|
+
if (Array.isArray(problems)) problems.push(`${msg}(${rel})`)
|
|
67
|
+
}
|
|
65
68
|
|
|
66
69
|
// 1) 先找 H1(围栏内的 # 不算标题):它决定文件节点的 title,且整行不进任何节点正文
|
|
67
70
|
let h1 = null
|
|
@@ -94,6 +97,7 @@ export function parse_file(absPath, file_tid, folder_type, parent_tid) {
|
|
|
94
97
|
'file',
|
|
95
98
|
)
|
|
96
99
|
nodes.push(file_node)
|
|
100
|
+
file_node.content_line = h1_line ? h1_line + 1 : 1
|
|
97
101
|
|
|
98
102
|
let first_node_tid = null // 文件根(第一个 <node> 的 name)
|
|
99
103
|
let sec_node = null
|
|
@@ -139,16 +143,17 @@ export function parse_file(absPath, file_tid, folder_type, parent_tid) {
|
|
|
139
143
|
// 内层 <node>(须单行闭合;只认行首,正文里提到 <node> 不算)
|
|
140
144
|
if (line.replace(/^\s+/, '').startsWith('<node')) {
|
|
141
145
|
const attrs = Object.fromEntries([...line.matchAll(ATTR_RE)].map((m) => [m[1], m[2]]))
|
|
142
|
-
const name = (attrs.name || '').trim()
|
|
146
|
+
const name = (attrs.name || attrs.tid || '').trim()
|
|
143
147
|
if (name) {
|
|
144
148
|
const n = newNode(
|
|
145
149
|
name,
|
|
146
|
-
name,
|
|
150
|
+
(attrs.title || '').trim() || name,
|
|
147
151
|
(attrs.type || '').trim() || folder_type,
|
|
148
152
|
(attrs.parent || '').trim() || default_parent(),
|
|
149
153
|
lineno,
|
|
150
154
|
`${rel}#L${lineno}`,
|
|
151
155
|
)
|
|
156
|
+
n.content_line = lineno + 1
|
|
152
157
|
nodes.push(n)
|
|
153
158
|
const gt = line.indexOf('>')
|
|
154
159
|
const rest = gt >= 0 ? line.slice(gt + 1) : ''
|
|
@@ -156,20 +161,32 @@ export function parse_file(absPath, file_tid, folder_type, parent_tid) {
|
|
|
156
161
|
n.content.push(rest.split('</node>')[0])
|
|
157
162
|
const after = rest.split('</node>').slice(1).join('</node>')
|
|
158
163
|
if (after) stack[stack.length - 1].content.push(after)
|
|
164
|
+
} else {
|
|
165
|
+
stack.push(n)
|
|
159
166
|
}
|
|
167
|
+
} else {
|
|
168
|
+
// 缺 name/tid:不能静默吞掉(否则开标签消失、闭标签漏进正文)
|
|
169
|
+
warn(`显式节点缺 name(或 tid)属性: <node> 在第 ${lineno} 行,整段按正文处理`)
|
|
170
|
+
content_target().content.push(line)
|
|
160
171
|
}
|
|
161
172
|
continue
|
|
162
173
|
}
|
|
163
174
|
stack[stack.length - 1].content.push(line)
|
|
164
175
|
continue
|
|
165
176
|
}
|
|
177
|
+
if (line.trim() === '</node>') {
|
|
178
|
+
warn(`孤立的 </node>: 第 ${lineno} 行没有对应的 <node>(已忽略,不会漏进正文)`)
|
|
179
|
+
continue
|
|
180
|
+
}
|
|
166
181
|
if (line.replace(/^\s+/, '').startsWith('<node')) {
|
|
167
182
|
const attrs = Object.fromEntries([...line.matchAll(ATTR_RE)].map((m) => [m[1], m[2]]))
|
|
168
|
-
const name = (attrs.name || '').trim()
|
|
183
|
+
const name = (attrs.name || attrs.tid || '').trim()
|
|
169
184
|
if (name) {
|
|
170
185
|
const parent = (attrs.parent || '').trim() || default_parent()
|
|
171
186
|
const ptype = (attrs.type || '').trim() || folder_type
|
|
172
|
-
const
|
|
187
|
+
const title = (attrs.title || '').trim() || name
|
|
188
|
+
const n = newNode(name, title, ptype, parent, lineno, `${rel}#L${lineno}`)
|
|
189
|
+
n.content_line = lineno + 1
|
|
173
190
|
nodes.push(n)
|
|
174
191
|
if (first_node_tid === null) first_node_tid = name
|
|
175
192
|
const gt = line.indexOf('>')
|
|
@@ -180,6 +197,9 @@ export function parse_file(absPath, file_tid, folder_type, parent_tid) {
|
|
|
180
197
|
} else {
|
|
181
198
|
stack.push(n)
|
|
182
199
|
}
|
|
200
|
+
} else {
|
|
201
|
+
warn(`显式节点缺 name(或 tid)属性: <node> 在第 ${lineno} 行,整段按正文处理`)
|
|
202
|
+
;(sec_node || file_node).content.push(line)
|
|
183
203
|
}
|
|
184
204
|
continue
|
|
185
205
|
}
|
|
@@ -190,13 +210,21 @@ export function parse_file(absPath, file_tid, folder_type, parent_tid) {
|
|
|
190
210
|
const tid = section_tid(file_tid, title, sec_index)
|
|
191
211
|
sec_node = newNode(tid, title, folder_type, default_parent(), lineno, `${rel}#L${lineno}`, [], 'section')
|
|
192
212
|
sec_node.positional = true // 位置型段落 TID:插章节就漂移,不可作引用/检索句柄
|
|
213
|
+
sec_node.content_line = lineno + 1
|
|
193
214
|
nodes.push(sec_node)
|
|
194
215
|
continue
|
|
195
216
|
}
|
|
196
217
|
;(sec_node || file_node).content.push(line)
|
|
197
218
|
}
|
|
198
219
|
|
|
199
|
-
for (const n of nodes)
|
|
220
|
+
for (const n of nodes) {
|
|
221
|
+
const joined = n.content.join('\n')
|
|
222
|
+
// trim 会吃掉节点正文开头的空行;行号基准要跟着往下挪,否则引用定位偏前。
|
|
223
|
+
const leadChars = joined.length - joined.replace(/^\s+/, '').length
|
|
224
|
+
const leadLines = (joined.slice(0, leadChars).match(/\n/g) || []).length
|
|
225
|
+
n.content_line = Number(n.content_line || 1) + leadLines
|
|
226
|
+
n.content = joined.trim()
|
|
227
|
+
}
|
|
200
228
|
return nodes
|
|
201
229
|
}
|
|
202
230
|
|
|
@@ -271,7 +299,7 @@ export function parse_library(lib_dir, problems) {
|
|
|
271
299
|
const md = path.join(dir, name)
|
|
272
300
|
const stem = slugify(path.basename(name, path.extname(name)))
|
|
273
301
|
const fileTid = ftid !== ROOT_TID ? `${ftid}:${stem}` : stem
|
|
274
|
-
for (const node of parse_file(md, fileTid, ftype, ftid)) {
|
|
302
|
+
for (const node of parse_file(md, fileTid, ftype, ftid, problems)) {
|
|
275
303
|
if (nodes.has(node.tid)) {
|
|
276
304
|
duplicate(node.tid, name)
|
|
277
305
|
continue
|
package/lib/engine/parse.js
CHANGED
|
@@ -43,10 +43,14 @@ export function anyRefRegex() {
|
|
|
43
43
|
return new RegExp(ANY_REF_SOURCE, 'g')
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
/** 一次扫过三种引用(保序),返回 [{kind:'node'|'tscl'|'legacy', id, raw, index}]
|
|
46
|
+
/** 一次扫过三种引用(保序),返回 [{kind:'node'|'tscl'|'legacy', id, raw, index}]
|
|
47
|
+
* 围栏与行内代码里的 `[[…]]` 是「示例」,不是引用:跳过(否则写文档会自我投毒)。 */
|
|
47
48
|
export function extractRefs(text) {
|
|
49
|
+
const src = String(text)
|
|
50
|
+
const regions = codeRegions(src)
|
|
48
51
|
const out = []
|
|
49
|
-
for (const m of
|
|
52
|
+
for (const m of src.matchAll(new RegExp(ANY_REF_SOURCE, 'g'))) {
|
|
53
|
+
if (inCode(regions, m.index)) continue
|
|
50
54
|
const kind = m[1] !== undefined ? 'node' : m[2] !== undefined ? 'tscl' : 'legacy'
|
|
51
55
|
const id = m[1] !== undefined ? m[1] : m[2] !== undefined ? m[2] : m[3]
|
|
52
56
|
out.push({ kind, id, raw: m[0], index: m.index })
|
|
@@ -54,6 +58,38 @@ export function extractRefs(text) {
|
|
|
54
58
|
return out
|
|
55
59
|
}
|
|
56
60
|
|
|
61
|
+
/** 代码区段(围栏块与行内代码),[start, end) 半开区间,按出现顺序 */
|
|
62
|
+
export function codeRegions(text) {
|
|
63
|
+
const s = String(text)
|
|
64
|
+
const regions = []
|
|
65
|
+
let offset = 0
|
|
66
|
+
let open = -1
|
|
67
|
+
let openMark = ''
|
|
68
|
+
for (const line of s.split('\n')) {
|
|
69
|
+
const m = /^\s{0,3}(`{3,}|~{3,})/.exec(line)
|
|
70
|
+
if (m) {
|
|
71
|
+
if (open < 0) {
|
|
72
|
+
open = offset
|
|
73
|
+
openMark = m[1]
|
|
74
|
+
} else if (m[1][0] === openMark[0] && m[1].length >= openMark.length) {
|
|
75
|
+
regions.push([open, offset + line.length])
|
|
76
|
+
open = -1
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
offset += line.length + 1
|
|
80
|
+
}
|
|
81
|
+
if (open >= 0) regions.push([open, s.length])
|
|
82
|
+
for (const m of s.matchAll(/(`+)([^`\n]*?)\1/g)) {
|
|
83
|
+
const start = m.index
|
|
84
|
+
if (!regions.some(([a, b]) => start >= a && start < b)) regions.push([start, start + m[0].length])
|
|
85
|
+
}
|
|
86
|
+
return regions
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function inCode(regions, index) {
|
|
90
|
+
return regions.some(([a, b]) => index >= a && index < b)
|
|
91
|
+
}
|
|
92
|
+
|
|
57
93
|
export function is_valid_tid(tid) {
|
|
58
94
|
return TID_RE.test(String(tid))
|
|
59
95
|
}
|
|
@@ -71,10 +107,13 @@ export function slugify(name) {
|
|
|
71
107
|
return s.replace(/^[-_]+/, '').replace(/[-_]+$/, '')
|
|
72
108
|
}
|
|
73
109
|
|
|
74
|
-
/** 提取全部 <tscl> 定义 → [[tid, text]]
|
|
110
|
+
/** 提取全部 <tscl> 定义 → [[tid, text]](保序)。代码块里的定义是示例,不算定义。 */
|
|
75
111
|
export function extract_defs(content) {
|
|
112
|
+
const src = String(content)
|
|
113
|
+
const regions = codeRegions(src)
|
|
76
114
|
const out = []
|
|
77
|
-
for (const m of
|
|
115
|
+
for (const m of src.matchAll(new RegExp(TSCL_SOURCE, 'gs'))) {
|
|
116
|
+
if (inCode(regions, m.index)) continue
|
|
78
117
|
out.push([m[1], m[2]])
|
|
79
118
|
}
|
|
80
119
|
return out
|
|
@@ -87,9 +126,23 @@ export function extract_refs(content) {
|
|
|
87
126
|
return out
|
|
88
127
|
}
|
|
89
128
|
|
|
90
|
-
/**
|
|
129
|
+
/** 抹掉指定 id 的定义块(整块删除,不只是标签)。代码块里的不算定义,保持原样。 */
|
|
130
|
+
export function strip_defs(content, ids) {
|
|
131
|
+
const src = String(content)
|
|
132
|
+
if (!ids || !ids.size) return src
|
|
133
|
+
const regions = codeRegions(src)
|
|
134
|
+
return src.replace(new RegExp(TSCL_SOURCE, 'gs'), (m, id, body, offset) =>
|
|
135
|
+
!inCode(regions, offset) && ids.has(id) ? '' : m,
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** 剥离 <tscl> 标签本身,保留内部文本(渲染投影的基础变换)。代码块里的标签是示例,保留原样。 */
|
|
91
140
|
export function strip_tags(content) {
|
|
92
|
-
|
|
141
|
+
const src = String(content)
|
|
142
|
+
const regions = codeRegions(src)
|
|
143
|
+
return src.replace(new RegExp(TSCL_SOURCE, 'gs'), (m, id, body, offset) =>
|
|
144
|
+
inCode(regions, offset) ? m : body,
|
|
145
|
+
)
|
|
93
146
|
}
|
|
94
147
|
|
|
95
148
|
/** 同一节点内重复定义的 tid */
|
package/lib/engine/registry.js
CHANGED
|
@@ -201,10 +201,11 @@ export function resolveLibrary({ kb, lib, workspace, config, extraRoots = [] } =
|
|
|
201
201
|
const kbName = looksLikePath ? undefined : raw
|
|
202
202
|
const exclude = config && config.exclude
|
|
203
203
|
const discover = () => discoverLibraries(ws, { exclude })
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
)
|
|
204
|
+
// config.registries 是「额外」路径,不是「替代」:否则项目里的 kb_registry.json
|
|
205
|
+
// (kb_register 写的那份)永远读不到,登记完立刻就不认。
|
|
206
|
+
const cfgRegs = (config && config.registries) || []
|
|
207
|
+
const regPaths = [...cfgRegs, ...defaultRegistryPaths(ws).filter((p) => !cfgRegs.includes(p))]
|
|
208
|
+
const registries = loadRegistries(regPaths, config && config.libraries)
|
|
208
209
|
|
|
209
210
|
// 1) 显式路径
|
|
210
211
|
if (explicit) {
|
|
@@ -284,10 +285,11 @@ export function resolveLibrary({ kb, lib, workspace, config, extraRoots = [] } =
|
|
|
284
285
|
*/
|
|
285
286
|
export function listLibraries({ workspace, config, scope, includeHidden = false } = {}) {
|
|
286
287
|
const ws = path.resolve(workspace || process.cwd())
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
)
|
|
288
|
+
// config.registries 是「额外」路径,不是「替代」:否则项目里的 kb_registry.json
|
|
289
|
+
// (kb_register 写的那份)永远读不到,登记完立刻就不认。
|
|
290
|
+
const cfgRegs = (config && config.registries) || []
|
|
291
|
+
const regPaths = [...cfgRegs, ...defaultRegistryPaths(ws).filter((p) => !cfgRegs.includes(p))]
|
|
292
|
+
const registries = loadRegistries(regPaths, config && config.libraries)
|
|
291
293
|
const rows = []
|
|
292
294
|
const seen = new Set()
|
|
293
295
|
for (const r of discoverLibraries(ws, { exclude: config && config.exclude })) {
|
package/lib/engine/rename.js
CHANGED
|
@@ -15,10 +15,27 @@ import fs from 'node:fs'
|
|
|
15
15
|
import path from 'node:path'
|
|
16
16
|
import { slugify } from './parse.js'
|
|
17
17
|
import { ROOT_TID } from './mdlib.js'
|
|
18
|
+
import { resolve_path } from './render.js'
|
|
18
19
|
|
|
19
20
|
const FENCE = '```'
|
|
20
21
|
|
|
21
|
-
/**
|
|
22
|
+
/** 把引用体(`[[…]]` 中间那段)换成新 TID;不是这个 TID 的返回 null。 */
|
|
23
|
+
function rewriteInner(inner, oldTid, newTid) {
|
|
24
|
+
// 新语法:[[node="TID"]] / [[tscl="TID"]]
|
|
25
|
+
const quoted = /^(node|tscl)\s*=\s*"([^"]*)"$/.exec(inner)
|
|
26
|
+
if (quoted) {
|
|
27
|
+
const [, kind, id] = quoted
|
|
28
|
+
if (id === oldTid) return `[[${kind}="${newTid}"]]`
|
|
29
|
+
if (id.startsWith(oldTid + ':')) return `[[${kind}="${newTid}${id.slice(oldTid.length)}"]]`
|
|
30
|
+
return null
|
|
31
|
+
}
|
|
32
|
+
// 旧语法:[[TID]] / [[TID:子节点]]
|
|
33
|
+
if (inner === oldTid) return `[[${newTid}]]`
|
|
34
|
+
if (inner.startsWith(oldTid + ':')) return `[[${newTid}${inner.slice(oldTid.length)}]]`
|
|
35
|
+
return null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 把一段文本里的 oldTid 引用改写成 newTid(三种写法都覆盖,含 `oldTid:…` 子节点引用)。 */
|
|
22
39
|
export function rewriteRefsInText(text, oldTid, newTid) {
|
|
23
40
|
const lines = String(text).split('\n')
|
|
24
41
|
const out = []
|
|
@@ -36,15 +53,10 @@ export function rewriteRefsInText(text, oldTid, newTid) {
|
|
|
36
53
|
}
|
|
37
54
|
out.push(
|
|
38
55
|
line.replace(/\[\[([^[\]]+)\]\]/g, (whole, inner) => {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
if (inner.startsWith(oldTid + ':')) {
|
|
44
|
-
count += 1
|
|
45
|
-
return `[[${newTid}${inner.slice(oldTid.length)}]]`
|
|
46
|
-
}
|
|
47
|
-
return whole
|
|
56
|
+
const next = rewriteInner(inner, oldTid, newTid)
|
|
57
|
+
if (next === null) return whole
|
|
58
|
+
count += 1
|
|
59
|
+
return next
|
|
48
60
|
}),
|
|
49
61
|
)
|
|
50
62
|
}
|
|
@@ -97,11 +109,22 @@ export function renameFileNode(lib, target, newName, opts = {}) {
|
|
|
97
109
|
|
|
98
110
|
let node = nodes.get(wanted)
|
|
99
111
|
if (!node) {
|
|
100
|
-
//
|
|
101
|
-
|
|
102
|
-
|
|
112
|
+
// 允许直接给相对路径(doc/03-x.md、lessons/004-…):走与 kb_read 相同的三级路径解析
|
|
113
|
+
let tid = null
|
|
114
|
+
try {
|
|
115
|
+
tid = resolve_path(nodes, wanted)
|
|
116
|
+
} catch {
|
|
117
|
+
tid = null
|
|
118
|
+
}
|
|
119
|
+
if (tid) node = nodes.get(tid)
|
|
120
|
+
}
|
|
121
|
+
if (!node) {
|
|
122
|
+
// source 存的是绝对路径,按「库根 + 相对路径」比较(大小写与分隔符都归一)
|
|
123
|
+
const rel = wanted.split(path.sep).join('/').replace(/^\.?\/+/, '')
|
|
124
|
+
const want = path.normalize(path.join(lib.nodekb, rel))
|
|
125
|
+
node = [...nodes.values()].find((n) => n.kind === 'file' && absOf(lib, n) === want)
|
|
103
126
|
}
|
|
104
|
-
if (!node) throw new Error(`未找到节点: ${wanted}(可先 kb_read --toc 看 TID
|
|
127
|
+
if (!node) throw new Error(`未找到节点: ${wanted}(可先 kb_read --toc 看 TID 清单;也可以给相对路径 doc/03-x.md)`)
|
|
105
128
|
if (node.kind !== 'file') {
|
|
106
129
|
throw new Error(`只能重命名文件节点;${node.tid} 是 ${node.kind} 节点(目录改名请直接改文件夹)`)
|
|
107
130
|
}
|
|
@@ -145,7 +168,7 @@ export function renameFileNode(lib, target, newName, opts = {}) {
|
|
|
145
168
|
report.skipped.push({ path: relOf(lib, file), why: `读失败: ${e.message}` })
|
|
146
169
|
continue
|
|
147
170
|
}
|
|
148
|
-
if (!text.includes(
|
|
171
|
+
if (!text.includes(node.tid)) continue
|
|
149
172
|
const { text: next, count } = rewriteRefsInText(text, node.tid, newTid)
|
|
150
173
|
if (!count) continue
|
|
151
174
|
edits.push({ file, st1, next, count })
|
|
@@ -177,10 +200,6 @@ export function renameFileNode(lib, target, newName, opts = {}) {
|
|
|
177
200
|
return report
|
|
178
201
|
}
|
|
179
202
|
|
|
180
|
-
function oldRefPrefix(tid) {
|
|
181
|
-
return `[[${tid}`
|
|
182
|
-
}
|
|
183
|
-
|
|
184
203
|
/** 人读的回执 */
|
|
185
204
|
export function formatRename(report) {
|
|
186
205
|
const lines = []
|
package/lib/engine/render.js
CHANGED
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import path from 'node:path'
|
|
16
|
-
import { anyRefRegex, strip_tags } from './parse.js'
|
|
16
|
+
import { anyRefRegex, strip_tags, strip_defs, codeRegions, inCode } from './parse.js'
|
|
17
|
+
import { ROOT_TID } from './mdlib.js'
|
|
17
18
|
|
|
18
19
|
const MAX_EMBED = 64 // 单次渲染最多展开的宏个数(防病态文件自引用爆炸)
|
|
19
20
|
|
|
@@ -72,12 +73,16 @@ function resolveNode(nodes, id) {
|
|
|
72
73
|
*/
|
|
73
74
|
export function expand(text, nodes, seen = [], ctx = {}) {
|
|
74
75
|
const defs = (ctx && ctx.defs) || new Map()
|
|
76
|
+
// 同一支里既打印定义体、又在引用处展开,会让一个值出现两遍:第二遍渲染时抹掉定义块
|
|
77
|
+
const rawText = ctx && ctx.suppress && ctx.suppress.size ? strip_defs(text, ctx.suppress) : text
|
|
75
78
|
// 定义块在正文里就地显示为它的内容(标签本身不出现)
|
|
76
|
-
const source = strip_tags(
|
|
79
|
+
const source = strip_tags(rawText)
|
|
80
|
+
const regions = codeRegions(source)
|
|
77
81
|
const out = []
|
|
78
82
|
let pos = 0
|
|
79
83
|
let embeds = 0
|
|
80
84
|
for (const m of String(source).matchAll(anyRefRegex())) {
|
|
85
|
+
if (inCode(regions, m.index)) continue // 代码里的 [[…]] 是示例,原样保留
|
|
81
86
|
out.push(String(source).slice(pos, m.index))
|
|
82
87
|
const kind = m[1] !== undefined ? 'node' : m[2] !== undefined ? 'tscl' : 'legacy'
|
|
83
88
|
const id = m[1] !== undefined ? m[1] : m[2] !== undefined ? m[2] : m[3]
|
|
@@ -85,6 +90,7 @@ export function expand(text, nodes, seen = [], ctx = {}) {
|
|
|
85
90
|
|
|
86
91
|
// 值嵌入:tscl 显式请求,或旧语法且确有同名定义
|
|
87
92
|
if ((kind === 'tscl' || kind === 'legacy') && defs.has(id)) {
|
|
93
|
+
if (ctx && ctx.expanded) ctx.expanded.add(id)
|
|
88
94
|
const key = 'tscl:' + id
|
|
89
95
|
if (seen.includes(key)) throw new TransclusionError(cycle_msg(seen, key, nodes))
|
|
90
96
|
if (++embeds > MAX_EMBED) {
|
|
@@ -118,14 +124,46 @@ export function render_node(nodes, tid, ctx = {}) {
|
|
|
118
124
|
return expand(node.content, nodes, [tid], ctx)
|
|
119
125
|
}
|
|
120
126
|
|
|
121
|
-
/** 渲染整支:递归子树为带标题层级的 md(节点引用只在原文位置出现一次)
|
|
127
|
+
/** 渲染整支:递归子树为带标题层级的 md(节点引用只在原文位置出现一次)
|
|
128
|
+
* 两遍:第一遍收集「在这支里被展开过的定义」,第二遍把这些定义块从正文里抹掉——
|
|
129
|
+
* 否则同一支里既打印定义体、又在引用处展开一次,同一个值出现两遍。 */
|
|
122
130
|
export function render_subtree(nodes, tid, level = 1, ctx = {}) {
|
|
131
|
+
const node = nodes.get(tid)
|
|
132
|
+
if (!node) throw new Error(`节点不存在: ${tid}`)
|
|
133
|
+
if (!ctx._pass2) {
|
|
134
|
+
const expanded = new Set()
|
|
135
|
+
renderSubtreeRaw(nodes, tid, level, { ...ctx, expanded })
|
|
136
|
+
const inside = subtreeTids(nodes, tid)
|
|
137
|
+
const suppress = new Set(
|
|
138
|
+
[...expanded].filter((id) => {
|
|
139
|
+
const def = ctx && ctx.defs ? ctx.defs.get(id) : null
|
|
140
|
+
return def && inside.has(def.ownerTid)
|
|
141
|
+
}),
|
|
142
|
+
)
|
|
143
|
+
return renderSubtreeRaw(nodes, tid, level, { ...ctx, _pass2: true, suppress })
|
|
144
|
+
}
|
|
145
|
+
return renderSubtreeRaw(nodes, tid, level, ctx)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** 子树内的全部 TID(含自身) */
|
|
149
|
+
export function subtreeTids(nodes, tid) {
|
|
150
|
+
const out = new Set()
|
|
151
|
+
const walk = (t) => {
|
|
152
|
+
if (out.has(t) || !nodes.has(t)) return
|
|
153
|
+
out.add(t)
|
|
154
|
+
for (const c of children(nodes, t)) walk(c.tid)
|
|
155
|
+
}
|
|
156
|
+
walk(tid)
|
|
157
|
+
return out
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function renderSubtreeRaw(nodes, tid, level, ctx) {
|
|
123
161
|
const node = nodes.get(tid)
|
|
124
162
|
if (!node) throw new Error(`节点不存在: ${tid}`)
|
|
125
163
|
const title = node.title || tid
|
|
126
164
|
const head = '#'.repeat(level) + ' ' + title
|
|
127
165
|
const parts = [head, expand(node.content, nodes, [tid], ctx)]
|
|
128
|
-
for (const child of children(nodes, tid)) parts.push(
|
|
166
|
+
for (const child of children(nodes, tid)) parts.push(renderSubtreeRaw(nodes, child.tid, level + 1, ctx))
|
|
129
167
|
return parts.join('\n\n')
|
|
130
168
|
}
|
|
131
169
|
|
|
@@ -169,16 +207,31 @@ export function node_info(nodes, tid) {
|
|
|
169
207
|
}
|
|
170
208
|
}
|
|
171
209
|
|
|
172
|
-
/** 路径解析(三级):精确 TID → 段边界前缀唯一 →
|
|
210
|
+
/** 路径解析(三级):精确 TID → 段边界前缀唯一 → 裸前缀(模糊)唯一。
|
|
211
|
+
* 空串与 "." 都当库根(README 一直这么写,代码以前只认空串)。 */
|
|
173
212
|
export function resolve_path(nodes, p) {
|
|
174
|
-
|
|
213
|
+
const raw = String(p ?? '').trim()
|
|
214
|
+
const target = raw === '' || raw === '.' || raw === './' ? ROOT_TID : raw
|
|
215
|
+
if (nodes.has(target)) return target
|
|
175
216
|
const keys = [...nodes.keys()]
|
|
176
|
-
let hits = keys.filter((t) => t ===
|
|
217
|
+
let hits = keys.filter((t) => t === target || t.startsWith(target + ':')).sort()
|
|
177
218
|
if (hits.length === 1) return hits[0]
|
|
178
219
|
if (!hits.length) {
|
|
179
220
|
// 段边界无命中 → 裸前缀模糊(fact:mod → fact:modid / fact:modid-places / fact:modid-trap)
|
|
180
|
-
hits = keys.filter((t) => t.startsWith(
|
|
221
|
+
hits = keys.filter((t) => t.startsWith(target)).sort()
|
|
181
222
|
if (hits.length === 1) return hits[0]
|
|
182
223
|
}
|
|
224
|
+
// 多个命中:只要其中有唯一的「文件/文件夹」节点,它就是用户想要的
|
|
225
|
+
// (doc:02 命中 doc:02-运行环境 与 doc:02-运行环境:2.1 —— 后者是前者的子节)
|
|
226
|
+
if (hits.length > 1) {
|
|
227
|
+
const containers = hits.filter((t) => {
|
|
228
|
+
const n = nodes.get(t)
|
|
229
|
+
return n && (n.kind === 'file' || n.kind === 'folder')
|
|
230
|
+
})
|
|
231
|
+
if (containers.length === 1) return containers[0]
|
|
232
|
+
// 都在同一文件里:取最短的那个(父节点)
|
|
233
|
+
const shortest = hits.reduce((a, b) => (b.length < a.length ? b : a))
|
|
234
|
+
if (hits.every((t) => t.startsWith(shortest))) return shortest
|
|
235
|
+
}
|
|
183
236
|
return null
|
|
184
237
|
}
|
package/lib/engine/write.js
CHANGED
|
@@ -91,30 +91,61 @@ export function writeInto(lib, input) {
|
|
|
91
91
|
const abs = path.isAbsolute(rel) ? path.normalize(rel) : absIn(lib.nodekb, rel)
|
|
92
92
|
if (!fs.existsSync(abs)) fail(`源文件不存在: ${abs}`)
|
|
93
93
|
const raw = fs.readFileSync(abs, 'utf8')
|
|
94
|
-
// 用 source
|
|
94
|
+
// 用 source 里的行号定位开标签,再**向后扫**找最近的 </node>。
|
|
95
|
+
// (旧实现只扫 from..from+1,于是「标签独占一行」的合法块永远定位失败——死锁。)
|
|
95
96
|
const hm = /#L(\d+)(?:-L(\d+))?/.exec(node.source || '')
|
|
96
97
|
if (!hm) fail('这个节点没有行号信息,无法就地改写;请用相对路径 + mode=replace 整篇重写')
|
|
97
98
|
const from = Number(hm[1]) - 1
|
|
98
|
-
const to = hm[2] ? Number(hm[2]) : from + 1
|
|
99
99
|
const lines = raw.split('\n')
|
|
100
100
|
let openAt = -1
|
|
101
101
|
let closeAt = -1
|
|
102
|
-
for (let i = from; i <
|
|
103
|
-
if (openAt < 0
|
|
104
|
-
|
|
102
|
+
for (let i = from; i < lines.length; i++) {
|
|
103
|
+
if (openAt < 0) {
|
|
104
|
+
if (/^\s*<node\b/.test(lines[i])) {
|
|
105
|
+
openAt = i
|
|
106
|
+
// 单行形式 <node …>正文</node>
|
|
107
|
+
if (/<\/node>/.test(lines[i])) {
|
|
108
|
+
closeAt = i
|
|
109
|
+
break
|
|
110
|
+
}
|
|
111
|
+
}
|
|
105
112
|
continue
|
|
106
113
|
}
|
|
107
|
-
if (
|
|
114
|
+
if (/^\s*<\/node>\s*$/.test(lines[i])) {
|
|
108
115
|
closeAt = i
|
|
109
116
|
break
|
|
110
117
|
}
|
|
111
118
|
}
|
|
112
|
-
if (openAt < 0
|
|
119
|
+
if (openAt < 0) fail(`无法在 ${rel} 第 ${from + 1} 行起找到 <node> 开标签;内容可能已被改动,先 kb_read 重看`)
|
|
120
|
+
if (closeAt < 0) {
|
|
121
|
+
fail(
|
|
122
|
+
`无法在 ${rel} 定位 </node>:开标签在第 ${openAt + 1} 行,` +
|
|
123
|
+
`向后扫到文件末(第 ${lines.length} 行)都没有独占一行的 </node>。` +
|
|
124
|
+
`请补上闭合标签,或用相对路径 + mode=replace 整篇重写`,
|
|
125
|
+
)
|
|
126
|
+
}
|
|
113
127
|
validateContent(content, path.basename(abs))
|
|
114
128
|
const bodyLines = content.replace(/^\n+|\n+$/g, '').split('\n')
|
|
115
|
-
|
|
129
|
+
if (openAt === closeAt) {
|
|
130
|
+
// 单行 <node …>旧正文</node> → 就地换掉中间那段
|
|
131
|
+
const line = lines[openAt]
|
|
132
|
+
const gt = line.indexOf('>')
|
|
133
|
+
const head = line.slice(0, gt + 1)
|
|
134
|
+
lines[openAt] = `${head}${bodyLines.join(' ')}</node>`
|
|
135
|
+
} else {
|
|
136
|
+
lines.splice(openAt + 1, closeAt - openAt - 1, ...bodyLines)
|
|
137
|
+
}
|
|
116
138
|
fs.writeFileSync(abs, lines.join('\n'), 'utf8')
|
|
117
|
-
return {
|
|
139
|
+
return {
|
|
140
|
+
rel,
|
|
141
|
+
abs,
|
|
142
|
+
bytes: Buffer.byteLength(content, 'utf8'),
|
|
143
|
+
mode,
|
|
144
|
+
created: false,
|
|
145
|
+
tid: target,
|
|
146
|
+
// 回执里给真实行区间,便于核对
|
|
147
|
+
lines: `${openAt + 1}–${closeAt + 1}`,
|
|
148
|
+
}
|
|
118
149
|
}
|
|
119
150
|
|
|
120
151
|
mode = mode || 'append'
|
package/lib/host/routes.js
CHANGED
|
@@ -197,7 +197,7 @@ export function registerRoutes(ctx, config) {
|
|
|
197
197
|
send(res, 404, { error: `未找到定义: ${id}`, kb: lib.name })
|
|
198
198
|
return
|
|
199
199
|
}
|
|
200
|
-
const refs = refsToDef(lib.nodes, id)
|
|
200
|
+
const refs = refsToDef(lib.nodes, id, def.ownerTid)
|
|
201
201
|
send(res, 200, {
|
|
202
202
|
kb: lib.name,
|
|
203
203
|
id,
|
package/lib/host/runtime.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import fs from 'node:fs'
|
|
14
14
|
import path from 'node:path'
|
|
15
|
+
import crypto from 'node:crypto'
|
|
15
16
|
import { parse_library } from '../engine/mdlib.js'
|
|
16
17
|
import { collectDefs } from '../engine/defs.js'
|
|
17
18
|
import { resolveLibrary, listLibraries } from '../engine/registry.js'
|
|
@@ -90,6 +91,7 @@ function signature(nodekb) {
|
|
|
90
91
|
let count = 0
|
|
91
92
|
let maxM = 0
|
|
92
93
|
let size = 0
|
|
94
|
+
const names = []
|
|
93
95
|
const walk = (d) => {
|
|
94
96
|
let entries
|
|
95
97
|
try {
|
|
@@ -108,6 +110,9 @@ function signature(nodekb) {
|
|
|
108
110
|
count += 1
|
|
109
111
|
size += st.size
|
|
110
112
|
if (st.mtimeMs > maxM) maxM = st.mtimeMs
|
|
113
|
+
// 文件名也算签名:改名/移动既不改 mtime 也不改大小,
|
|
114
|
+
// 只按内容比签名会让「改名后的旧索引」一直命中缓存(库看着正常、实际裂开)。
|
|
115
|
+
names.push(path.relative(nodekb, p).split(path.sep).join('/'))
|
|
111
116
|
} catch {
|
|
112
117
|
/* 竞态删除:忽略 */
|
|
113
118
|
}
|
|
@@ -115,7 +120,14 @@ function signature(nodekb) {
|
|
|
115
120
|
}
|
|
116
121
|
}
|
|
117
122
|
walk(nodekb)
|
|
118
|
-
|
|
123
|
+
names.sort()
|
|
124
|
+
const digest = crypto.createHash('sha1').update(names.join('\n')).digest('hex').slice(0, 12)
|
|
125
|
+
return `${count}:${size}:${maxM}:${digest}`
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** 让某个库的缓存立刻失效(改名/移动之后必须调,别指望下一次签名比对) */
|
|
129
|
+
export function forgetLibrary(nodekb) {
|
|
130
|
+
if (nodekb) CACHE.delete(path.resolve(nodekb))
|
|
119
131
|
}
|
|
120
132
|
|
|
121
133
|
/** 判定工作区,并说明它是怎么定下来的。
|
|
@@ -128,11 +140,13 @@ export function workspaceSource(config, exec) {
|
|
|
128
140
|
if (cwd) return { path: cwd, from: 'exec.cwd' }
|
|
129
141
|
const scwd = sessionCwd(e)
|
|
130
142
|
if (scwd) return { path: scwd, from: 'session.header.cwd' }
|
|
143
|
+
// 面板没有会话:优先「最近一次工具用过的目录」,配置只当最后的默认值。
|
|
144
|
+
// 这样配置里留着的旧路径不会把面板钉在某个老项目上。
|
|
145
|
+
if (!e && LAST_WORKSPACE) return { path: LAST_WORKSPACE, from: 'last-workspace(面板)' }
|
|
131
146
|
const configured = config && typeof config.workspace === 'string' ? config.workspace : ''
|
|
132
147
|
if (configured) return { path: path.resolve(configured), from: 'config.workspace' }
|
|
133
148
|
const env = process.env.DSH_NODEKB_WORKSPACE
|
|
134
149
|
if (env) return { path: path.resolve(env), from: 'env.DSH_NODEKB_WORKSPACE' }
|
|
135
|
-
if (!e && LAST_WORKSPACE) return { path: LAST_WORKSPACE, from: 'last-workspace(面板)' }
|
|
136
150
|
return { path: process.cwd(), from: 'process.cwd' }
|
|
137
151
|
}
|
|
138
152
|
|
package/lib/host/tools.js
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
workspaceRoots,
|
|
29
29
|
sessionIdOf,
|
|
30
30
|
bindServices,
|
|
31
|
+
forgetLibrary,
|
|
31
32
|
} from './runtime.js'
|
|
32
33
|
|
|
33
34
|
const DEFAULT_MAXLEN = 50000
|
|
@@ -167,7 +168,7 @@ export function buildTools(ctx, config) {
|
|
|
167
168
|
kb: lib.name,
|
|
168
169
|
mode,
|
|
169
170
|
chars: String(def.body || '').length,
|
|
170
|
-
text: warnPrefix(lib) + formatDefRead(def, refsToDef(lib.nodes, target)),
|
|
171
|
+
text: warnPrefix(lib) + formatDefRead(def, refsToDef(lib.nodes, target, def.ownerTid)),
|
|
171
172
|
}
|
|
172
173
|
}
|
|
173
174
|
const cands = [...lib.nodes.keys()].filter((t) => t.startsWith(target) || t.startsWith(target + ':')).sort().slice(0, 10)
|
|
@@ -281,11 +282,11 @@ export function buildTools(ctx, config) {
|
|
|
281
282
|
tools.push({
|
|
282
283
|
name: 'kb_backlinks',
|
|
283
284
|
description:
|
|
284
|
-
'反查:谁引用了这个 id
|
|
285
|
+
'反查:谁引用了这个 id(节点 TID 或 <tscl> 定义 id 都认)。建议改 TID 或删节点前先跑。',
|
|
285
286
|
parameters: {
|
|
286
287
|
type: 'object',
|
|
287
288
|
properties: {
|
|
288
|
-
target: { type: 'string', description: '节点 TID
|
|
289
|
+
target: { type: 'string', description: '节点 TID 或其唯一前缀;也可以是 <tscl id> 定义的名字。' },
|
|
289
290
|
kb: KB_PARAM,
|
|
290
291
|
},
|
|
291
292
|
required: ['target'],
|
|
@@ -296,8 +297,24 @@ export function buildTools(ctx, config) {
|
|
|
296
297
|
},
|
|
297
298
|
async execute(args, exec) {
|
|
298
299
|
const lib = locate(config, exec, args)
|
|
299
|
-
const
|
|
300
|
-
|
|
300
|
+
const raw = String(args.target ?? '').trim()
|
|
301
|
+
const tid = resolve_path(lib.nodes, raw)
|
|
302
|
+
if (tid === null) {
|
|
303
|
+
// 与 kb_read 同一口径:节点找不到时再看是不是 <tscl> 定义
|
|
304
|
+
const def = lib.defs && (lib.defs.get(raw) || lib.defs.get(raw.replace(/^tscl:/, '')))
|
|
305
|
+
if (def) {
|
|
306
|
+
const refs = refsToDef(lib.nodes, def.id, def.ownerTid)
|
|
307
|
+
const outside = refs.filter((r) => !r.self)
|
|
308
|
+
const lines = [`引用定义 <tscl id="${def.id}"> 的位置:${outside.length} 处(定义在 ${def.ownerTid})`]
|
|
309
|
+
for (const r of outside) {
|
|
310
|
+
lines.push(`- ${r.tid} · ${r.title}${r.legacy ? '(旧语法)' : ''} ${r.source}`)
|
|
311
|
+
}
|
|
312
|
+
if (!outside.length) lines.push('(没有任何节点展开它)')
|
|
313
|
+
if (refs.length > outside.length) lines.push(`另有 ${refs.length - outside.length} 处在定义所在节点内部(不计入)`)
|
|
314
|
+
return { target: def.id, kind: 'def', count: outside.length, backlinks: outside, kb: lib.name, text: warnPrefix(lib) + lines.join('\n') }
|
|
315
|
+
}
|
|
316
|
+
throw new Error(`未找到节点或定义: ${args.target}`)
|
|
317
|
+
}
|
|
301
318
|
const rows = []
|
|
302
319
|
for (const node of lib.nodes.values()) {
|
|
303
320
|
if (node.tid === tid) continue
|
|
@@ -335,6 +352,8 @@ export function buildTools(ctx, config) {
|
|
|
335
352
|
const report = renameFileNode(lib, String(args.target ?? ''), String(args.new_name ?? ''), {
|
|
336
353
|
dryRun: Boolean(args.dry_run),
|
|
337
354
|
})
|
|
355
|
+
// 改名只动文件名:缓存签名里的 mtime/size 都不变,不主动失效就会继续用旧索引
|
|
356
|
+
if (!report.dry_run && report.renamed) forgetLibrary(lib.nodekb)
|
|
338
357
|
return { ...report, kb: lib.name, text: warnPrefix(lib) + formatRename(report) }
|
|
339
358
|
},
|
|
340
359
|
})
|
package/package.json
CHANGED
|
@@ -61,7 +61,10 @@ tscl 机制支持段落标记与行内标记
|
|
|
61
61
|
## TID 规则
|
|
62
62
|
|
|
63
63
|
- 文件节点:`<目录>:<文件名 slug>`,例如 `doc/03-工程经验与坑.md` → `doc:03-工程经验与坑`
|
|
64
|
-
- 显式节点:`<node name="abi">` 里的 `name` 原样就是 TID
|
|
64
|
+
- 显式节点:`<node name="abi">` 里的 `name` 原样就是 TID,可跨文件引用(`tid="…"` 等价,`title="…"` 可另给显示名)
|
|
65
|
+
- 手写 `<node>` 时开闭标签各占一行;缺 `name`/`tid` 的块会整段退回正文并在 diag 里 warning,不会静默丢失
|
|
66
|
+
- 读的时候允许前缀兜底:`kb_read target=doc:02` 能命中 `doc:02-运行环境`;但**写引用仍要写全 TID**
|
|
67
|
+
- 代码区(``` 围栏与 `行内代码`)里的 `[[…]]` / `<tscl>` 是示例,不解析也不计引用——讲语法时直接写在代码里即可
|
|
65
68
|
- 段节点:`## 3.1 xxx` → `<文件TID>:3.1`,是**位置型**的:插一节就漂。链接它只是 warning(指错了跳错节),但**别拿它当需要长期稳定的锚点**——要稳定就写显式节点
|
|
66
69
|
- 改名走 `kb_rename`,它会重写全库引用
|
|
67
70
|
|