project-node-memory 1.1.4 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -108,8 +108,10 @@ config:
108
108
 
109
109
  ## 库怎么定位
110
110
 
111
- 1. `lib` 参数给路径(技能目录,或其下的 `.nodekb`)——必须落在工作区或已注册库内
112
- 2. `kb` 参数给名字(注册表里的名字,或工作区里发现的名字,如某个技能名)
111
+ 所有 `kb_*` 工具只有一个定位参数 `kb`,它同时接受**名字**与**路径**:
112
+
113
+ 1. `kb` 给路径(技能目录,或其下的 `.nodekb`,绝对或相对工作区)——必须落在工作区或已注册库内
114
+ 2. `kb` 给名字(注册表里的名字,或工作区里发现的名字,如某个技能名)
113
115
  3. 都不给:工作区自带的 `.nodekb/` → 工作区里发现且唯一的库 → 注册表里唯一的库
114
116
 
115
117
  **自动发现**:给了工作区之后,库里不只在工作区根找,还会按约定位置下钻(有界 BFS,默认 2 层):
@@ -181,6 +183,18 @@ config:
181
183
 
182
184
  硬规则:`<node>` / `</node>` 必须各占整行;`<tscl>` 内部不做结构解析(里面的 `##` 不算标题);
183
185
  同一 `tscl` id 全库只能定义一次,且不能与节点 TID 同名。
186
+
187
+ 定义也可以**就地写在句子中间**,渲染时标签被剥掉、值留在原处:
188
+
189
+ ```md
190
+ 当前版本号为:<tscl id="fact:ver">v1.23.45</tscl>,向下兼容 v1.22。
191
+ ABI 校验见 [[tscl="fact:ver"]]。
192
+ ```
193
+
194
+ 句内定义**必须单行闭合**(行首的 `<tscl>` 才有跨行保护);定义别写进表格行(`|` 会破格,
195
+ `kb_diag` 会 warning)。要核一个值,`kb_read target=<定义 id>` 直接回
196
+ 「定义体 / 定义在哪个节点 / 被谁引用」。
197
+
184
198
  旧写法 `[[TID]]` 仍兼容(有定义时当宏、否则当链接),`kb_diag` 会 warning,2.0 移除。
185
199
 
186
200
  ## 从 1.0 升到 1.1
@@ -203,7 +217,7 @@ kb_write mode=node target=<TID> content=… # 逐条改,或直接改 md 再 k
203
217
  ## 自检(不装 DSH 也能跑)
204
218
 
205
219
  ```bash
206
- node scripts/check.mjs # 275 项:清单契约 / 工具 / 路由 / 客户端槽位 / 改名改写 / 建库写库 / tscl / 提示词段落 / 随包技能
220
+ node scripts/check.mjs # 292 项:清单契约 / 工具 / 路由 / 客户端槽位 / 改名改写 / 建库写库 / tscl / 提示词段落 / 随包技能
207
221
  node test/parity.mjs # 与 Python 原实现逐条对拍(需要 python 与 nodekb 源码)
208
222
  ```
209
223
 
package/README_EN.md CHANGED
@@ -105,8 +105,10 @@ The right column renders at most 10,000 characters per view (with a truncation n
105
105
 
106
106
  ## How a library is located
107
107
 
108
- 1. `lib` — a path (a skill directory or its `.nodekb`), which must fall inside the workspace or a registered library.
109
- 2. `kb` — a name (from a registry, or discovered in the workspace, e.g. a skill name).
108
+ Every `kb_*` tool takes exactly one location parameter, `kb`, which accepts **either a name or a path**:
109
+
110
+ 1. `kb` = a path (a skill directory or its `.nodekb`, absolute or workspace-relative) — must fall inside the workspace or a registered library.
111
+ 2. `kb` = a name (from a registry, or discovered in the workspace, e.g. a skill name).
110
112
  3. Neither — the workspace's own `.nodekb/` → the single library discovered in the workspace → the single registered library.
111
113
 
112
114
  **Discovery**: given a workspace, it looks beyond the root at conventional locations (bounded BFS, depth 2 by default):
@@ -159,8 +161,23 @@ leave references at every use site, so a single edit updates the whole library.
159
161
 
160
162
  Hard rules: `<node>` / `</node>` must each occupy an entire line; nothing inside `<tscl>` is parsed
161
163
  as structure (a `##` there is not a heading); a `tscl` id may be defined exactly once and must not
162
- collide with a node TID. The old `[[TID]]` form still works (macro when a definition exists, link
163
- otherwise) with a `kb_diag` warning, and goes away in 2.0.
164
+ collide with a node TID.
165
+
166
+ A definition may also sit **inline in the middle of a sentence** — the tags are stripped when
167
+ rendered and the value stays where it is written:
168
+
169
+ ```md
170
+ Current version: <tscl id="fact:ver">v1.23.45</tscl>, backward compatible with v1.22.
171
+ ABI check: [[tscl="fact:ver"]].
172
+ ```
173
+
174
+ An inline definition **must close on the same line** (only a line-start `<tscl>` gets cross-line
175
+ protection), and it must not go into a table row (a `|` inside the body breaks the row; `kb_diag`
176
+ warns). To check one value, `kb_read target=<definition id>` returns the body, where it is defined,
177
+ and who references it.
178
+
179
+ The old `[[TID]]` form still works (macro when a definition exists, link otherwise) with a
180
+ `kb_diag` warning, and goes away in 2.0.
164
181
 
165
182
  ## Upgrading from 1.0
166
183
 
@@ -186,7 +203,7 @@ kb_write mode=node target=<TID> content=… # fix one at a time, or edit the md
186
203
  ## Self-check (no DSH required)
187
204
 
188
205
  ```bash
189
- node scripts/check.mjs # 275 checks: manifest contract / tools / routes / client slots / rename rewriting / scaffold + write / tscl / prompt section / bundled skill
206
+ node scripts/check.mjs # 292 checks: manifest contract / tools / routes / client slots / rename rewriting / scaffold + write / tscl / prompt section / bundled skill
190
207
  node test/parity.mjs # case-by-case parity against the Python implementation (needs python and nodekb sources)
191
208
  ```
192
209
 
package/lib/client.js CHANGED
@@ -1190,6 +1190,21 @@
1190
1190
  setQuery('')
1191
1191
  }
1192
1192
 
1193
+ /** 点开一个 [[tscl="id"]]:跳到定义所在的节点(值就在那一段话里) */
1194
+ function openDef(id) {
1195
+ if (!kb || !id) return
1196
+ get('/defs', { kb: kb, id: id })
1197
+ .then(function (r) {
1198
+ if (!r || !r.owner_tid) { setErr('未找到定义: ' + id); return }
1199
+ setErr('')
1200
+ setHits(null)
1201
+ setQuery('')
1202
+ setSel(r.owner_tid)
1203
+ setView('node')
1204
+ })
1205
+ .catch(function (e) { setErr(e.message) })
1206
+ }
1207
+
1193
1208
  function runSearch() {
1194
1209
  var q = (query || '').trim()
1195
1210
  if (!q) { setHits(null); return }
@@ -1441,7 +1456,7 @@
1441
1456
  : null,
1442
1457
  )
1443
1458
  : view === 'raw' || view === 'source' || view === 'toc'
1444
- ? h(Rich, { text: shownText, onOpen: open })
1459
+ ? h(Rich, { text: shownText, onOpen: open, onMacro: openDef })
1445
1460
  : h(Md, { text: shownText, onOpen: open })
1446
1461
  var cutNotice = textCut
1447
1462
  ? h(
@@ -9,7 +9,7 @@
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
+ import { collectDefs, refsToDef, formatDefRead } from './defs.js'
13
13
  import { lib_root_tids } from './mdlib.js'
14
14
 
15
15
  // 整支渲染字符上限(0 = 不限)。防止一次调用打爆上下文(root ≈ 23.5 万字符)
@@ -30,12 +30,18 @@ export function cmdRead(nodes, target, { node = false, raw = false, toc = false,
30
30
  return { text: e.message, code: 2 }
31
31
  }
32
32
  if (tid === null) {
33
+ // 不是节点:再看是不是一个 tscl 定义 id(值的老家可能只是一句话)
34
+ const defsMap = defs || collectDefs(nodes).defs
35
+ const def = defsMap.get(target)
36
+ if (def) return { text: formatDefRead(def, refsToDef(nodes, target)), code: 0, def: target }
33
37
  const cands = [...nodes.keys()]
34
38
  .filter((t) => t.startsWith(target) || t.startsWith(target + ':'))
35
39
  .sort()
36
40
  .slice(0, 10)
37
41
  let msg = `未找到节点: ${target}`
38
42
  if (cands.length) msg += ';候选: ' + cands.join('、')
43
+ const defSuffix = [...defsMap.keys()].filter((d) => d.includes(target)).sort().slice(0, 5)
44
+ if (defSuffix.length) msg += ';同名 tscl 定义候选: ' + defSuffix.join('、')
39
45
  return { text: msg, code: 2 }
40
46
  }
41
47
  try {
@@ -8,7 +8,7 @@
8
8
  * 无人引用,全部以 problems 形式交回给 diag 定级。
9
9
  */
10
10
 
11
- import { extract_defs } from './parse.js'
11
+ import { extract_defs, extractRefs } from './parse.js'
12
12
 
13
13
  const NESTED_OPEN = /<tscl\b[^>]*>(?:(?!<\/tscl>)[\s\S])*?<tscl\b/
14
14
 
@@ -58,3 +58,41 @@ export function collectDefs(nodes) {
58
58
  export function isMultiline(body) {
59
59
  return String(body || '').replace(/\s+$/, '').includes('\n')
60
60
  }
61
+
62
+ /** 谁在正文里引用了这个定义([[tscl="id"]],或指向它的旧语法裸引用)→ [{tid,title,source,line,legacy}] */
63
+ export function refsToDef(nodes, id) {
64
+ const out = []
65
+ for (const node of nodes.values()) {
66
+ const content = String(node.content || '')
67
+ if (!content.includes(id)) continue
68
+ for (const ref of extractRefs(content)) {
69
+ if (ref.id !== id) continue
70
+ if (ref.kind !== 'tscl' && ref.kind !== 'legacy') continue
71
+ out.push({
72
+ tid: node.tid,
73
+ title: node.title || '',
74
+ source: node.source || '',
75
+ line: content.slice(0, ref.index).split('\n').length,
76
+ legacy: ref.kind === 'legacy',
77
+ })
78
+ }
79
+ }
80
+ return out
81
+ }
82
+
83
+ /** 定义 → 人读文本(kb_read 按 tscl id 读、面板点宏时跳转用) */
84
+ export function formatDefRead(def, refs = []) {
85
+ const src = String(def.source || '').split(/[\\/]/).filter(Boolean).slice(-2).join('/')
86
+ const lines = [`定义体 <tscl id="${def.id}"> · 定义在 ${def.ownerTid}${src ? `(${src})` : ''}`, '', String(def.body || '').replace(/\s+$/, '')]
87
+ if (def.count > 1) lines.push('', `重复定义 ${def.count} 处(必须只剩一处): ${def.owners.join('、')}`)
88
+ if (refs.length) {
89
+ lines.push('', `被引用 ${refs.length} 处:`)
90
+ for (const r of refs) {
91
+ const at = String(r.source || '').split(/[\\/]/).filter(Boolean).slice(-2).join('/')
92
+ lines.push(`- ${r.tid}${r.title ? ` · ${r.title}` : ''}${at ? `(${at}#L${r.line})` : ''}${r.legacy ? ` ← 旧语法,建议 [[tscl="${def.id}"]]` : ''}`)
93
+ }
94
+ } else {
95
+ lines.push('', '尚未被任何 [[tscl="…"]] 引用。')
96
+ }
97
+ return lines.join('\n')
98
+ }
@@ -229,6 +229,17 @@ export function diag(nodes, { problems = [], strict = false, libRoot, defs = nul
229
229
  warnings.push(`位置型 TID 链接(插章节会漂移): ${node.tid} → ${ref.raw}`)
230
230
  }
231
231
  }
232
+
233
+ // 定义写在表格行里:表格只放单行宏,定义体含 | 会破格
234
+ if (content.includes('<tscl')) {
235
+ const lines = content.split('\n')
236
+ for (let li = 0; li < lines.length; li++) {
237
+ if (!/<tscl\b/.test(lines[li])) continue
238
+ if (/^\s*\|/.test(lines[li])) {
239
+ warnings.push(`定义写在表格行里: ${node.tid} 第 ${li + 1} 行 → <tscl>(定义体含 | 会破格;把定义移到正文段落)`)
240
+ }
241
+ }
242
+ }
232
243
  }
233
244
 
234
245
  for (const def of defsMap.values()) {
@@ -194,6 +194,11 @@ export function discoverLibraries(root, { depth = 2, budget = 300, limit = 60, e
194
194
  */
195
195
  export function resolveLibrary({ kb, lib, workspace, config } = {}) {
196
196
  const ws = path.resolve(workspace || process.cwd())
197
+ // kb 同时接受「库名」与「路径」:带分隔符 / 绝对路径 / 指向 .nodekb 的一律当路径。
198
+ const raw = kb === undefined || kb === null ? '' : String(kb).trim()
199
+ const looksLikePath = !!raw && (path.isAbsolute(raw) || /[\\/]/.test(raw) || /\.nodekb$/i.test(raw))
200
+ const explicit = lib || (looksLikePath ? raw : undefined)
201
+ const kbName = looksLikePath ? undefined : raw
197
202
  const exclude = config && config.exclude
198
203
  const discover = () => discoverLibraries(ws, { exclude })
199
204
  const registries = loadRegistries(
@@ -202,10 +207,10 @@ export function resolveLibrary({ kb, lib, workspace, config } = {}) {
202
207
  )
203
208
 
204
209
  // 1) 显式路径
205
- if (lib) {
206
- const p = path.resolve(ws, lib)
210
+ if (explicit) {
211
+ const p = path.resolve(ws, explicit)
207
212
  const nodekb = path.basename(p) === '.nodekb' ? p : path.join(p, '.nodekb')
208
- if (!isDir(nodekb)) throw new Error(`库目录不存在: ${nodekb}(你传入: ${lib})`)
213
+ if (!isDir(nodekb)) throw new Error(`库目录不存在: ${nodekb}(你传入: ${explicit})`)
209
214
  const allowed = [ws, ...[...registries.values()].map((e) => e.path)]
210
215
  if (!allowed.some((root) => isInside(nodekb, root))) {
211
216
  throw new Error(
@@ -217,7 +222,7 @@ export function resolveLibrary({ kb, lib, workspace, config } = {}) {
217
222
  }
218
223
 
219
224
  // 2) 名字
220
- const name = kb && String(kb).trim()
225
+ const name = kbName
221
226
  if (name) {
222
227
  const entry = registries.get(name)
223
228
  if (entry) {
@@ -234,7 +239,7 @@ export function resolveLibrary({ kb, lib, workspace, config } = {}) {
234
239
  if (wsLib) return wsLib
235
240
  }
236
241
  const names = [...registries.keys(), ...discover().map((r) => r.name)]
237
- throw new Error(`未注册的库: ${name}(可用: ${names.join(', ') || '无'};或用 lib 参数给绝对路径)`)
242
+ throw new Error(`未注册的库: ${name}(可用: ${names.join(', ') || '无'};kb 也可以直接传库目录路径)`)
238
243
  }
239
244
 
240
245
  // 3) 无参:工作区自带 → 工作区里唯一发现 → 唯一的注册表库
@@ -20,7 +20,7 @@ export const DEFAULT_PROMPT = [
20
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
- '- 引用分两种,别混:**结构**用 `[[node="doc:03-build:3.1"]]`(只出一个链接,不复制正文);**值只写一处**——定义 `<tscl id="fact:abi">…</tscl>`,各处写 `[[tscl="fact:abi"]]`,同一 id 全库只能定义一次。',
23
+ '- 引用分两种,别混:**结构**用 `[[node="doc:03-build:3.1"]]`(只出一个链接,不复制正文);**值只写一处**——定义 `<tscl id="fact:abi">…</tscl>`,各处写 `[[tscl="fact:abi"]]`,同一 id 全库只能定义一次。定义可就近写在正文句子里(单行、别写进表格)。读一个值用 `kb_read target=<定义 id>`。',
24
24
  '- 旧写法 `[[TID]]` 仍能用但 `kb_diag` 会 warning,2.0 移除(老库按 diag 清单逐处改成 `[[node=…]]` / `[[tscl=…]]`)。',
25
25
  ].join('\n')
26
26
 
@@ -11,6 +11,7 @@ import { render_node, render_subtree, toc_tree, node_info, resolve_path } from '
11
11
  import { search, formatSearch } from '../engine/search.js'
12
12
  import { diag } from '../engine/diag.js'
13
13
  import { extractRefs } from '../engine/parse.js'
14
+ import { refsToDef, formatDefRead } from '../engine/defs.js'
14
15
  import { renameFileNode, formatRename } from '../engine/rename.js'
15
16
  import { openLibrary, knownLibraries, whereInfo, workspaceOf } from './runtime.js'
16
17
  import { promptState } from './prompt.js'
@@ -187,6 +188,40 @@ export function registerRoutes(ctx, config) {
187
188
  return
188
189
  }
189
190
 
191
+ if (url.pathname === `${PREFIX}/defs`) {
192
+ const lib = open()
193
+ const id = q.get('id')
194
+ if (id) {
195
+ const def = lib.defs.get(id)
196
+ if (!def) {
197
+ send(res, 404, { error: `未找到定义: ${id}`, kb: lib.name })
198
+ return
199
+ }
200
+ const refs = refsToDef(lib.nodes, id)
201
+ send(res, 200, {
202
+ kb: lib.name,
203
+ id,
204
+ owner_tid: def.ownerTid,
205
+ owner_title: def.ownerTitle || '',
206
+ source: def.source,
207
+ body: def.body,
208
+ count: def.count,
209
+ refs,
210
+ text: formatDefRead(def, refs),
211
+ })
212
+ return
213
+ }
214
+ const defs = [...lib.defs.values()].map((d) => ({
215
+ id: d.id,
216
+ owner_tid: d.ownerTid,
217
+ source: d.source,
218
+ chars: String(d.body || '').length,
219
+ count: d.count,
220
+ }))
221
+ send(res, 200, { kb: lib.name, count: defs.length, defs })
222
+ return
223
+ }
224
+
190
225
  if (url.pathname === `${PREFIX}/source`) {
191
226
  const lib = open()
192
227
  const target = q.get('tid') || 'root'
package/lib/host/tools.js CHANGED
@@ -13,6 +13,7 @@ import { render_node, render_subtree, toc_lines, node_info, resolve_path } from
13
13
  import { search, formatSearch } from '../engine/search.js'
14
14
  import { diag } from '../engine/diag.js'
15
15
  import { extractRefs } from '../engine/parse.js'
16
+ import { refsToDef, formatDefRead } from '../engine/defs.js'
16
17
  import { renameFileNode, formatRename } from '../engine/rename.js'
17
18
  import { scaffold } from '../engine/scaffold.js'
18
19
  import { writeInto } from '../engine/write.js'
@@ -30,11 +31,7 @@ export function toolState() {
30
31
 
31
32
  const KB_PARAM = {
32
33
  type: 'string',
33
- description: '库名(注册表里的名字,或工作区自带的 workspace)。省略则用工作区 .nodekb;都不存在时用唯一已注册的库。',
34
- }
35
- const LIB_PARAM = {
36
- type: 'string',
37
- description: '库根路径的备用写法:技能目录或其下的 .nodekb 绝对路径(也可相对工作区)。仅当库没有注册名时使用。',
34
+ description: '库:名字(注册表/工作区里的名字)或目录路径(技能目录或其 .nodekb)。省略则用工作区默认库。',
38
35
  }
39
36
 
40
37
  function text(t) {
@@ -60,7 +57,7 @@ function allowedRoots(config, exec) {
60
57
  /** 库定位失败时补一句「有哪些库可用」,别让模型瞎猜 */
61
58
  function locate(config, exec, args) {
62
59
  try {
63
- return openLibrary(config, exec, { kb: args.kb, lib: args.lib })
60
+ return openLibrary(config, exec, { kb: args.kb })
64
61
  } catch (e) {
65
62
  const hint = libraryHint(config, exec)
66
63
  const err = new Error(`${e.message}${hint ? `\n${hint}` : ''}`)
@@ -100,17 +97,14 @@ export function buildTools(ctx, config) {
100
97
  tools.push({
101
98
  name: 'kb_read',
102
99
  description:
103
- '读取 nodekb 知识库的一个节点。target 是 TID(如 doc:03-工程经验与坑:3.1)或唯一前缀(如 3.1 不行、doc:03 可以)。' +
104
- 'mode 默认 subtree = 整支渲染(本节点的 [[TID]] 引用就地展开成内容,再递归子节点);' +
105
- 'toc = 只看结构不看正文(每个节点一行:tid · 标题 · 字符数,适合先定位再读);' +
106
- 'node = 只读本节点自身(不含子节点,也不展开子节);raw = 元信息 + 原始内容(含未展开的 [[TID]])。' +
107
- '比 read 工具好在:一次拿到装配好的上下文,且 TID 不受文件行号漂移影响。超过 maxlen 会报错并提示改用 toc/node。',
100
+ '读知识库的一个节点。target = TID 或其唯一前缀(空/"." = 库根),也可以是 <tscl id> 定义的名字(回定义体、定义处、被谁引用)。' +
101
+ 'mode: subtree(默认,整支投影)| toc(只看结构,' +
102
+ '先定位再读)| node(只本节点)| raw(元信息+原文)。比 read 好在 TID 不随行号漂移;超 maxlen 会报错并提示换 toc/node。',
108
103
  parameters: {
109
104
  type: 'object',
110
105
  properties: {
111
106
  target: { type: 'string', description: '节点 TID 或其唯一前缀。空字符串或 "." 表示库根。' },
112
107
  kb: KB_PARAM,
113
- lib: LIB_PARAM,
114
108
  mode: {
115
109
  type: 'string',
116
110
  enum: ['subtree', 'node', 'toc', 'raw'],
@@ -132,8 +126,26 @@ export function buildTools(ctx, config) {
132
126
 
133
127
  const tid = resolve_path(lib.nodes, target)
134
128
  if (tid === null) {
129
+ // target 也可能是 tscl 定义 id(值的老家可能只是一句话,没有节点)
130
+ const def = lib.defs.get(target)
131
+ if (def) {
132
+ return {
133
+ tid: target,
134
+ kind: 'def',
135
+ title: def.ownerTitle || '',
136
+ type: '',
137
+ source: def.source || '',
138
+ kb: lib.name,
139
+ mode,
140
+ chars: String(def.body || '').length,
141
+ text: warnPrefix(lib) + formatDefRead(def, refsToDef(lib.nodes, target)),
142
+ }
143
+ }
135
144
  const cands = [...lib.nodes.keys()].filter((t) => t.startsWith(target) || t.startsWith(target + ':')).sort().slice(0, 10)
136
- throw new Error(`未找到节点: ${target}${cands.length ? `;候选: ${cands.join('、')}` : ''}`)
145
+ const defCands = [...lib.defs.keys()].filter((d) => d.includes(target)).sort().slice(0, 5)
146
+ throw new Error(
147
+ `未找到节点: ${target}${cands.length ? `;候选: ${cands.join('、')}` : ''}${defCands.length ? `;tscl 定义候选: ${defCands.join('、')}` : ''}`,
148
+ )
137
149
  }
138
150
 
139
151
  let out
@@ -173,15 +185,13 @@ export function buildTools(ctx, config) {
173
185
  tools.push({
174
186
  name: 'kb_search',
175
187
  description:
176
- '在 nodekb 知识库里检索(中文自然语言可用:连续中文按 2-gram 拆分并过滤虚词,多词命中加权,无命中时退化为整句子串匹配)。' +
177
- '返回每条命中的稳定 TID(positional=true 表示命中的是位置型段落,tid 已上溯到文件/显式节点,ref 是实际命中点)、类型、标题、源文件行号与命中片段。' +
178
- '用 kb_search 找到 tid,再用 kb_read 读,比 grep 后逐个 read 省往返。',
188
+ '检索知识库(中文 2-gram + 多词加权 + 子串兜底)。返回稳定 TID、类型、标题、源行、片段。' +
189
+ '先 kb_search 拿 tid 再 kb_read,比 grep 后逐个 read 省往返。',
179
190
  parameters: {
180
191
  type: 'object',
181
192
  properties: {
182
193
  query: { type: 'string', description: '检索词或自然语言问句。' },
183
194
  kb: KB_PARAM,
184
- lib: LIB_PARAM,
185
195
  type: { type: 'string', description: '只保留该类型的节点(如 doc / todo / handover)。' },
186
196
  limit: { type: 'integer', description: '返回条数上限,默认 10。' },
187
197
  offset: { type: 'integer', description: '跳过前 N 条,用于翻页。' },
@@ -214,14 +224,12 @@ export function buildTools(ctx, config) {
214
224
  tools.push({
215
225
  name: 'kb_diag',
216
226
  description:
217
- '知识库结构自检。errors = 真损坏(悬空引用 [[TID]]、引用位置型段落 TID、循环引用、孤儿节点、树环、重复 TID);' +
218
- 'warnings = 疑似内容漂移(同一段文字出现在多个节点 / 节点内重复行),默认不影响结论。' +
219
- '改完 md 之后跑一次,比人工核对可靠。库根可放 diag_ignore.json 豁免样板块。',
227
+ '结构自检。errors = 真损坏(悬空/位置型引用、循环、孤儿、树环、重复 TID、tscl 定义问题);' +
228
+ 'warnings = 疑似漂移(同一段文字出现在多处),默认不算失败。写完 md 就跑一次。',
220
229
  parameters: {
221
230
  type: 'object',
222
231
  properties: {
223
232
  kb: KB_PARAM,
224
- lib: LIB_PARAM,
225
233
  strict: { type: 'boolean', description: '严格模式:把 warnings 也算失败。' },
226
234
  },
227
235
  },
@@ -246,14 +254,13 @@ export function buildTools(ctx, config) {
246
254
  tools.push({
247
255
  name: 'kb_backlinks',
248
256
  description:
249
- '反查:谁引用了这个节点([[TID]] 指向它)。改 TID / 删节点之前先跑,能一次列出所有会断的地方——' +
250
- '比全库 grep 更准(只认真实引用语法,不受正文里偶然出现的同名文字干扰)。',
257
+ '反查:谁引用了这个 id(`[[node=…]]`/`[[tscl=…]]`/旧式 `[[TID]]` 都算)。改 TID 或删节点前先跑,' +
258
+ '比全库 grep 准——只认真实引用语法。',
251
259
  parameters: {
252
260
  type: 'object',
253
261
  properties: {
254
262
  target: { type: 'string', description: '节点 TID 或其唯一前缀。' },
255
263
  kb: KB_PARAM,
256
- lib: LIB_PARAM,
257
264
  },
258
265
  required: ['target'],
259
266
  },
@@ -282,9 +289,8 @@ export function buildTools(ctx, config) {
282
289
  tools.push({
283
290
  name: 'kb_rename',
284
291
  description:
285
- '重命名库里的一个 md 文件,并把全库指向它的 [[TID]] 引用一起改写(含 [[tid:1.2]] 这种子节点引用)。' +
286
- 'nodekb 的 TID 由文件名派生,所以「用 mv / 编辑器改名」必然留下一堆悬空引用;要么用这个工具,' +
287
- '要么改名后自己跑 kb_diag 逐个修。写前会重读校验,别人刚改过的文件不覆盖、只报出来。',
292
+ '重命名 md 文件并改写全库指向它的引用(含子节点)。TID 由文件名派生,所以用 mv 改名必然留悬空引用;' +
293
+ '写前重读校验,别人刚改过的文件跳过不覆盖。',
288
294
  parameters: {
289
295
  type: 'object',
290
296
  properties: {
@@ -292,7 +298,6 @@ export function buildTools(ctx, config) {
292
298
  new_name: { type: 'string', description: '新文件名(.md 可省略,不能带目录)。' },
293
299
  dry_run: { type: 'boolean', description: 'true = 只报「会改哪些引用」,不动文件。' },
294
300
  kb: KB_PARAM,
295
- lib: LIB_PARAM,
296
301
  },
297
302
  required: ['target', 'new_name'],
298
303
  },
@@ -313,13 +318,11 @@ export function buildTools(ctx, config) {
313
318
  tools.push({
314
319
  name: 'kb_vcs_status',
315
320
  description:
316
- '查看知识库内嵌 git 仓库的状态(最近提交 + 未提交改动)。nodekb 每次解析后会自动快照,' +
317
- '所以这里能看到「库的改动历史」;文件系统上有改动但没进快照时也会列出来。git 不可用时返回说明而不报错。',
321
+ '库内嵌 git 的快照历史与未提交改动(每次解析后自动快照);git 不可用时只说明不报错。',
318
322
  parameters: {
319
323
  type: 'object',
320
324
  properties: {
321
325
  kb: KB_PARAM,
322
- lib: LIB_PARAM,
323
326
  },
324
327
  },
325
328
  output: {
@@ -365,11 +368,8 @@ export function buildTools(ctx, config) {
365
368
  tools.push({
366
369
  name: 'kb_init',
367
370
  description:
368
- '为一个项目建 .nodekb 知识库骨架(幂等:已存在的文件不覆盖)。' +
369
- '当用户要在项目里沉淀长期知识、而你准备开始写第一份文档之前,先调这个——' +
370
- '它一次铺出 doc/ facts/ lessons/ todo/ index/entry.md 与 .nodekb/README.md 里的落位约定,' +
371
- '比你自己拿 write 从零设计目录结构更省事,也让后续 Agent 知道该往哪写。' +
372
- '已经有 .nodekb/ 就不要调(先用 kb_list 看)。',
371
+ '建 .nodekb 骨架(幂等,已有的不覆盖):doc/ lessons/ todo/ index/entry.md + 落位约定。' +
372
+ '准备手写 docs/*.md 之前先调这个;已经有 .nodekb/ 就别调(先 kb_list 看)。',
373
373
  parameters: {
374
374
  type: 'object',
375
375
  properties: {
@@ -403,10 +403,8 @@ export function buildTools(ctx, config) {
403
403
  tools.push({
404
404
  name: 'kb_write',
405
405
  description:
406
- '往 nodekb 库里写内容,并在写后立刻自检(悬空引用/位置型引用/重复块)。' +
407
- '这是「用知识库管知识」的正路:同样写 md,但写前校验 TID 与 <node> 独占一行,写后把结构错误当场报回来;' +
408
- '直接用 write/edit 没有这一层。' +
409
- 'mode=append 追加到文件(不存在则创建)、replace 整篇覆盖、create 只新建、node 就地改写一个 <node> 块。',
406
+ '写库并立刻自检(悬空引用/重复定义/结构错误当场报回来)。写前校验 TID 与 <node> 独占一行;' +
407
+ '用 write/edit 直接改没有这层。mode=append(追加,不存在则建)| replace(整篇)| create(只新建)| node(就地改一个 <node> 块)。',
410
408
  parameters: {
411
409
  type: 'object',
412
410
  properties: {
@@ -421,7 +419,6 @@ export function buildTools(ctx, config) {
421
419
  description: '默认:路径→append,TID→node。create 遇到已存在会报错,避免覆盖。',
422
420
  },
423
421
  kb: KB_PARAM,
424
- lib: LIB_PARAM,
425
422
  },
426
423
  required: ['target', 'content'],
427
424
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "project-node-memory",
3
- "version": "1.1.4",
3
+ "version": "1.3.0",
4
4
  "description": "Project node knowledge base for DSH agents: markdown-as-database, stable TIDs, transclusion projection, structure self-check.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,17 +1,17 @@
1
1
  ---
2
2
  name: project-node-memory-guide
3
- description: 用 .nodekb/ + kb_* 工具沉淀项目长期知识(设计决策、踩坑、事实常量、待办、交接)时的写法规范——什么时候该写、文件怎么分、TID 怎么起、唯一事实用 tscl 怎么共享、先读什么再写什么。准备手写 docs/*.md 之前先读这一条。
3
+ description: 用 .nodekb/ + kb_* 工具沉淀项目长期知识(设计决策、踩坑、事实常量、待办、交接)时的写法规范——什么时候该写、文件怎么分、TID 怎么起、唯一事实用 tscl 怎么共享、先读什么再写什么。准备创建知识库时先读该技能。
4
4
  ---
5
5
 
6
6
  # 写项目节点知识库(`.nodekb/`)
7
7
 
8
8
  ## 什么时候该写
9
9
 
10
- 值得沉淀的:**设计决策与理由**、**踩过的坑(症状 + 根因 + 对策)**、**事实常量**(版本号、路径、阈值、接口字段)、**待办与交接**、**环境和操作手册**。
10
+ 值得沉淀的:**设计决策与理由**、**踩过的坑(症状 + 根因 + 对策)**、**事实常量**(版本号、路径、阈值、接口字段)、**待办与交接**、**环境和操作手册**等。
11
11
 
12
12
  不该写的:一次性的调试输出、能从代码直接读出来的内容(函数签名、目录列表)、临时状态。判据是「下次换个人/换个会话,不知道这条会不会重做一遍错事」。
13
13
 
14
- ## 先读,再写
14
+ ## 先读再写
15
15
 
16
16
  ```
17
17
  kb_read mode=toc # 结构 + 每节字符数,几百 token
@@ -19,44 +19,45 @@ kb_read target=<TID> # 按需读一支
19
19
  kb_search query=... # toc 里找不到再搜
20
20
  ```
21
21
 
22
- **不要**一次把整库读进上下文。写之前先确认:这个结论是不是已经写在别处了?是的话改那一处,别新开一条(同一结论写两遍,两份迟早打架)。
22
+ **不要**一次把整库读进上下文。写之前先确认:这个结论是不是已经写在别处了?是的话改那一处,不要把同一结论写两遍。
23
23
 
24
- ## 目录怎么分
24
+ ## 目录规划
25
25
 
26
- | 目录 | 放什么 | 判据 |
27
- |---|---|---|
28
- | `doc/` | 主题文档:架构、模块、流程、配置 | 「是什么、怎么运转」 |
29
- | `lessons/` | 踩坑记录 | 「症状 → 根因 → 对策」三行齐全 |
30
- | `todo/` | 未完成事项、阻塞、交接 | 有明确的下一步动作 |
31
- | `index/entry.md` | 总入口与落位约定 | 只放导航,不放正文 |
26
+ | 目录 | 放什么 | 判据 |
27
+ | ---------------- | -------------------------------- | ------------------------------ |
28
+ | `doc/` | 主题文档:架构、模块、流程、配置 | 「是什么、怎么运转」 |
29
+ | `lessons/` | 踩坑记录 | 「症状 → 根因 → 对策」三行齐全 |
30
+ | `todo/` | 未完成事项、阻塞、交接 | 有明确的下一步动作 |
31
+ | `index/entry.md` | 总入口与落位约定 | 只放导航,不放正文 |
32
32
 
33
- 这张表是「不知道放哪时」的默认答案,**不是白名单**:项目没有待办就别建 `todo/`,需要 `adr/`、`api/`、`runbook/`、`metrics/` 就自己加;节点 `type` 同样自由(`type="decision"`、`type="api"` 都行)。
33
+ 这张表是**推荐做法**,你可以自行根据项目实际情况进行规划。如项目没有待办就别建 `todo/`,需要 `adr/`、`api/` 等就自己加;节点 `type` 同样自由。
34
34
 
35
35
  一个主题一个文件,**单文件取 < 4KB**;变大就按子主题拆成 `## 3.x` 段或独立文件。
36
36
 
37
- ## 两种引用,别混
37
+ ## 两种引用方法
38
38
 
39
- | 写法 | 渲染成 | 用在 |
40
- |---|---|---|
41
- | `[[node="doc:03-build:3.1"]]` | 一个链接,**不复制正文** | 指向另一段结构:「细节见 …」 |
42
- | `[[tscl="fact:abi"]]` | 把 `<tscl id="fact:abi">` 的定义体**替换进来** | 同一句话/同一组数字必须在多处出现 |
39
+ | 写法 | 渲染成 | 用在 |
40
+ | ----------------------------- | ---------------------------------------------- | --------------------------------- |
41
+ | `[[node="doc:03-build:3.1"]]` | 一个链接,**不复制正文** | 指向另一段结构:「细节见 …」 |
42
+ | `[[tscl="fact:abi"]]` | 把 `<tscl id="fact:abi">` 的定义体**替换进来** | 同一句话/同一组数字必须在多处出现 |
43
43
 
44
44
  ```md
45
- <tscl id="fact:abi">
46
- 三条硬规则:1) 只用 C ABI;2) 名字加前缀 `fm_`;3) 导出结构体必须定长。
45
+ <tscl id="fact:sample">
46
+ tscl 机制支持段落标记与行内标记
47
47
  </tscl>
48
+
49
+ <tscl id="fact:sample2">tscl 机制支持段落标记与行内标记</tscl>
48
50
  ```
49
51
 
50
52
  规则:
51
53
 
52
- - **同一个 id 全库只能定义一次**,否则 `kb_diag` 报 error(重复定义)。
53
- - id 不能和节点 TID 同名(命名空间冲突 = error)。惯例:值用 `fact:` 前缀,结构用目录前缀。
54
- - 宏体**跨行**时不要内联进表格单元格(diag 会 warning,渲染也会断行)。
54
+ - **同一个 id 全库只能定义一次**。
55
+ - id 不能和节点 TID 同名。惯例:值用 `fact:` 前缀,结构用目录前缀。
56
+ - 定义可以**就地写在正文句子里**:`当前版本号为:<tscl id="fact:ver">v1.23.45</tscl>,向下兼容 v1.22。`(渲染时标签被剥掉,别处写 `[[tscl="fact:ver"]]` 展开同一个值)。这种句内定义**必须单行闭合**。
57
+ - 宏体**跨行**时不要内联进表格单元格;定义本身也别写进表格行(`|` 会破格)。
58
+ - 要核一个值,`kb_read target=<定义 id>` 直接回定义体、定义处、被谁引用。
55
59
  - 定义块内部的 `##` **不会**变成节点——它只是文本。
56
60
 
57
- 旧写法 `[[TID]]` 仍能解析(有定义时当宏、否则当链接),但 `kb_diag` 会 warning,2.0 移除。
58
- 接手老库时按 `kb_diag` 的「旧语法引用」清单逐处改成 `[[node=…]]` 或 `[[tscl=…]]`,改完再跑一次 `kb_diag`。
59
-
60
61
  ## TID 规则
61
62
 
62
63
  - 文件节点:`<目录>:<文件名 slug>`,例如 `doc/03-工程经验与坑.md` → `doc:03-工程经验与坑`
@@ -72,12 +73,13 @@ kb_write target=<显式节点TID> mode=node content=... # 只换 <
72
73
  kb_write target=doc/03-...md mode=append content=...
73
74
  ```
74
75
 
75
- - 写完**看返回值里的校验结果**:悬空引用、重复定义、`<node>` 没独占一行,都会当场报回来。看到就修,别留着。
76
+ - 写完**看返回值里的校验结果**:悬空引用、重复定义、`<node>` 没独占一行,都会当场报回来。
76
77
  - 改已有内容用 `append` / `node`,不要 `replace` 整篇(会丢掉别人刚写的段落)。
77
78
  - 新建文件前先 `kb_read mode=toc` 确认没有同主题的旧文件。
78
79
 
79
- ## 三个反例
80
+ ## 注意事项
81
+
82
+ 1. 不要把整段对话贴进 `doc/` —— 记的是过程,不是结论。
83
+ 2. 不要把同一件事在 `doc/` 和 `lessons/` 各写一份 —— 抽成 `<tscl>` 定义,两处都引用它。
84
+
80
85
 
81
- 1. 把整段对话贴进 `doc/` —— 记的是过程,不是结论。
82
- 2. 同一件事在 `doc/` 和 `lessons/` 各写一份 —— 抽成 `<tscl>` 定义,两处都引用它。
83
- 3. 只写「改了什么」不写「为什么」 —— 半年后没人知道能不能改回去。