project-node-memory 1.1.3 → 1.2.1

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
@@ -104,13 +104,14 @@ config:
104
104
  | `kb_init` | 建 `.nodekb` 骨架(幂等),铺出 `doc/ lessons/ todo/ index/` 与落位约定 |
105
105
  | `kb_write` | 写库:`append` / `replace` / `create` / `node`(就地改写一个 `<node>` 块);写前校验、写后自检 |
106
106
  | `kb_rename` | 文件改名 + 全库引用一起改写;写前重读,别人刚改的不覆盖 |
107
- | `kb_migrate` | 旧语法裸引用 `[[x]]` → `[[node="x"]]` / `[[tscl="x"]]`;**默认只演练**,`dry_run=false` 才落盘 |
108
107
  | `kb_vcs_status` | 库内嵌 git 的快照历史与未提交改动(git 缺失时只说明,不报错) |
109
108
 
110
109
  ## 库怎么定位
111
110
 
112
- 1. `lib` 参数给路径(技能目录,或其下的 `.nodekb`)——必须落在工作区或已注册库内
113
- 2. `kb` 参数给名字(注册表里的名字,或工作区里发现的名字,如某个技能名)
111
+ 所有 `kb_*` 工具只有一个定位参数 `kb`,它同时接受**名字**与**路径**:
112
+
113
+ 1. `kb` 给路径(技能目录,或其下的 `.nodekb`,绝对或相对工作区)——必须落在工作区或已注册库内
114
+ 2. `kb` 给名字(注册表里的名字,或工作区里发现的名字,如某个技能名)
114
115
  3. 都不给:工作区自带的 `.nodekb/` → 工作区里发现且唯一的库 → 注册表里唯一的库
115
116
 
116
117
  **自动发现**:给了工作区之后,库里不只在工作区根找,还会按约定位置下钻(有界 BFS,默认 2 层):
@@ -193,20 +194,18 @@ config:
193
194
  2. **`kb_init` 不再建 `facts/`**。已有的 `facts/` 不会被动,`kb_read`/`kb_search` 照常;只是新库默认只有
194
195
  `doc/ lessons/ todo/ index/`,事实改写成 `<tscl>` 定义放进它所属的 `doc/` 里。
195
196
 
196
- 老库要不要迁?**不迁也能用**,只是 `kb_diag` 会给每条裸引用一条 warning。要迁就跑:
197
+ 老库要不要迁?**不迁也能用**,只是 `kb_diag` 会给每条裸引用一条 warning。要迁就让 Agent 按
198
+ `kb_diag` 的清单逐条改成 `[[node="…"]]` / `[[tscl="…"]]`(只改写法,不动 TID 与文件):
197
199
 
198
200
  ```text
199
- kb_migrate # 演练:报每个文件会改几处、哪些引用本来就悬空
200
- kb_migrate dry_run=false # 确认后落盘
201
+ kb_diag # 列出每条 `旧语法引用`
202
+ kb_write mode=node target=<TID> content=… # 逐条改,或直接改 md 再 kb_diag 复验
201
203
  ```
202
204
 
203
- 迁移只改写法(`[[doc:x]]` → `[[node="doc:x"]]`,命中定义则 `[[tscl="…"]]`),不动任何 TID、不搬文件,
204
- 所以引用关系与渲染结果都不变;围栏代码块内一律跳过。
205
-
206
205
  ## 自检(不装 DSH 也能跑)
207
206
 
208
207
  ```bash
209
- node scripts/check.mjs # 290 项:清单契约 / 工具 / 路由 / 客户端槽位 / 改名改写 / 建库写库 / tscl / 迁移 / 提示词段落 / 随包技能
208
+ node scripts/check.mjs # 280 项:清单契约 / 工具 / 路由 / 客户端槽位 / 改名改写 / 建库写库 / tscl / 提示词段落 / 随包技能
210
209
  node test/parity.mjs # 与 Python 原实现逐条对拍(需要 python 与 nodekb 源码)
211
210
  ```
212
211
 
package/README_EN.md CHANGED
@@ -101,13 +101,14 @@ The right column renders at most 10,000 characters per view (with a truncation n
101
101
  | `kb_init` | Scaffold `.nodekb` (idempotent): `doc/ lessons/ todo/ index/` plus placement conventions |
102
102
  | `kb_write` | Write into the library: `append` / `replace` / `create` / `node` (rewrite one `<node>` block in place); validates before writing, self-checks after |
103
103
  | `kb_rename` | Rename a file and rewrite every 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 |
105
104
  | `kb_vcs_status` | Snapshot history and uncommitted changes of the library's embedded git repo (explains itself when git is missing) |
106
105
 
107
106
  ## How a library is located
108
107
 
109
- 1. `lib` — a path (a skill directory or its `.nodekb`), which must fall inside the workspace or a registered library.
110
- 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).
111
112
  3. Neither — the workspace's own `.nodekb/` → the single library discovered in the workspace → the single registered library.
112
113
 
113
114
  **Discovery**: given a workspace, it looks beyond the root at conventional locations (bounded BFS, depth 2 by default):
@@ -176,21 +177,18 @@ readable after the plugin is removed. Only two things changed:
176
177
  `<tscl>` definitions inside the `doc/` file they belong to.
177
178
 
178
179
  Do old libraries need migrating? **No** — they keep working, `kb_diag` just emits one warning per
179
- bare reference. To migrate:
180
+ bare reference. To migrate, have the agent walk the `kb_diag` list and fix each one (syntax only, no
181
+ TID or file changes):
180
182
 
181
183
  ```text
182
- kb_migrate # dry run: how many refs per file, which ones are already dangling
183
- kb_migrate dry_run=false # write
184
+ kb_diag # lists every `旧语法引用` (legacy bare ref)
185
+ kb_write mode=node target=<TID> content=… # fix one at a time, or edit the md and re-run kb_diag
184
186
  ```
185
187
 
186
- Migration only changes syntax (`[[doc:x]]` → `[[node="doc:x"]]`, or `[[tscl="…"]]` when the id is a
187
- definition). No TID changes, no file moves, so references and rendered output stay identical; fenced
188
- code blocks are skipped.
189
-
190
188
  ## Self-check (no DSH required)
191
189
 
192
190
  ```bash
193
- node scripts/check.mjs # 290 checks: manifest contract / tools / routes / client slots / rename rewriting / scaffold + write / tscl / migration / prompt section / bundled skill
191
+ node scripts/check.mjs # 280 checks: manifest contract / tools / routes / client slots / rename rewriting / scaffold + write / tscl / prompt section / bundled skill
194
192
  node test/parity.mjs # case-by-case parity against the Python implementation (needs python and nodekb sources)
195
193
  ```
196
194
 
@@ -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) 无参:工作区自带 → 工作区里唯一发现 → 唯一的注册表库
@@ -21,7 +21,7 @@ export const DEFAULT_PROMPT = [
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
+ '- 旧写法 `[[TID]]` 仍能用但 `kb_diag` 会 warning,2.0 移除(老库按 diag 清单逐处改成 `[[node=…]]` / `[[tscl=…]]`)。',
25
25
  ].join('\n')
26
26
 
27
27
  function textOf(config) {
@@ -12,7 +12,6 @@ import { search, formatSearch } from '../engine/search.js'
12
12
  import { diag } from '../engine/diag.js'
13
13
  import { extractRefs } from '../engine/parse.js'
14
14
  import { renameFileNode, formatRename } from '../engine/rename.js'
15
- import { migrateLibrary, formatMigrate } from '../engine/migrate.js'
16
15
  import { openLibrary, knownLibraries, whereInfo, workspaceOf } from './runtime.js'
17
16
  import { promptState } from './prompt.js'
18
17
  import { skillState } from './skill.js'
@@ -280,14 +279,6 @@ export function registerRoutes(ctx, config) {
280
279
  return
281
280
  }
282
281
 
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
-
291
282
  send(res, 404, { error: `未知端点: ${url.pathname}` })
292
283
  } catch (e) {
293
284
  send(res, 400, { error: e && e.message ? e.message : String(e) })
package/lib/host/tools.js CHANGED
@@ -14,7 +14,6 @@ import { search, formatSearch } from '../engine/search.js'
14
14
  import { diag } from '../engine/diag.js'
15
15
  import { extractRefs } from '../engine/parse.js'
16
16
  import { renameFileNode, formatRename } from '../engine/rename.js'
17
- import { migrateLibrary, formatMigrate } from '../engine/migrate.js'
18
17
  import { scaffold } from '../engine/scaffold.js'
19
18
  import { writeInto } from '../engine/write.js'
20
19
  import { isInside } from '../engine/registry.js'
@@ -31,11 +30,7 @@ export function toolState() {
31
30
 
32
31
  const KB_PARAM = {
33
32
  type: 'string',
34
- description: '库名(注册表里的名字,或工作区自带的 workspace)。省略则用工作区 .nodekb;都不存在时用唯一已注册的库。',
35
- }
36
- const LIB_PARAM = {
37
- type: 'string',
38
- description: '库根路径的备用写法:技能目录或其下的 .nodekb 绝对路径(也可相对工作区)。仅当库没有注册名时使用。',
33
+ description: '库:名字(注册表/工作区里的名字)或目录路径(技能目录或其 .nodekb)。省略则用工作区默认库。',
39
34
  }
40
35
 
41
36
  function text(t) {
@@ -61,7 +56,7 @@ function allowedRoots(config, exec) {
61
56
  /** 库定位失败时补一句「有哪些库可用」,别让模型瞎猜 */
62
57
  function locate(config, exec, args) {
63
58
  try {
64
- return openLibrary(config, exec, { kb: args.kb, lib: args.lib })
59
+ return openLibrary(config, exec, { kb: args.kb })
65
60
  } catch (e) {
66
61
  const hint = libraryHint(config, exec)
67
62
  const err = new Error(`${e.message}${hint ? `\n${hint}` : ''}`)
@@ -101,17 +96,13 @@ export function buildTools(ctx, config) {
101
96
  tools.push({
102
97
  name: 'kb_read',
103
98
  description:
104
- '读取 nodekb 知识库的一个节点。target 是 TID(如 doc:03-工程经验与坑:3.1)或唯一前缀(如 3.1 不行、doc:03 可以)。' +
105
- 'mode 默认 subtree = 整支渲染(本节点的 [[TID]] 引用就地展开成内容,再递归子节点);' +
106
- 'toc = 只看结构不看正文(每个节点一行:tid · 标题 · 字符数,适合先定位再读);' +
107
- 'node = 只读本节点自身(不含子节点,也不展开子节);raw = 元信息 + 原始内容(含未展开的 [[TID]])。' +
108
- '比 read 工具好在:一次拿到装配好的上下文,且 TID 不受文件行号漂移影响。超过 maxlen 会报错并提示改用 toc/node。',
99
+ '读知识库的一个节点。target = TID 或其唯一前缀(空/"." = 库根)。mode: subtree(默认,整支投影)| toc(只看结构,' +
100
+ '先定位再读)| node(只本节点)| raw(元信息+原文)。比 read 好在 TID 不随行号漂移;超 maxlen 会报错并提示换 toc/node。',
109
101
  parameters: {
110
102
  type: 'object',
111
103
  properties: {
112
104
  target: { type: 'string', description: '节点 TID 或其唯一前缀。空字符串或 "." 表示库根。' },
113
105
  kb: KB_PARAM,
114
- lib: LIB_PARAM,
115
106
  mode: {
116
107
  type: 'string',
117
108
  enum: ['subtree', 'node', 'toc', 'raw'],
@@ -174,15 +165,13 @@ export function buildTools(ctx, config) {
174
165
  tools.push({
175
166
  name: 'kb_search',
176
167
  description:
177
- '在 nodekb 知识库里检索(中文自然语言可用:连续中文按 2-gram 拆分并过滤虚词,多词命中加权,无命中时退化为整句子串匹配)。' +
178
- '返回每条命中的稳定 TID(positional=true 表示命中的是位置型段落,tid 已上溯到文件/显式节点,ref 是实际命中点)、类型、标题、源文件行号与命中片段。' +
179
- '用 kb_search 找到 tid,再用 kb_read 读,比 grep 后逐个 read 省往返。',
168
+ '检索知识库(中文 2-gram + 多词加权 + 子串兜底)。返回稳定 TID、类型、标题、源行、片段。' +
169
+ '先 kb_search 拿 tid 再 kb_read,比 grep 后逐个 read 省往返。',
180
170
  parameters: {
181
171
  type: 'object',
182
172
  properties: {
183
173
  query: { type: 'string', description: '检索词或自然语言问句。' },
184
174
  kb: KB_PARAM,
185
- lib: LIB_PARAM,
186
175
  type: { type: 'string', description: '只保留该类型的节点(如 doc / todo / handover)。' },
187
176
  limit: { type: 'integer', description: '返回条数上限,默认 10。' },
188
177
  offset: { type: 'integer', description: '跳过前 N 条,用于翻页。' },
@@ -215,14 +204,12 @@ export function buildTools(ctx, config) {
215
204
  tools.push({
216
205
  name: 'kb_diag',
217
206
  description:
218
- '知识库结构自检。errors = 真损坏(悬空引用 [[TID]]、引用位置型段落 TID、循环引用、孤儿节点、树环、重复 TID);' +
219
- 'warnings = 疑似内容漂移(同一段文字出现在多个节点 / 节点内重复行),默认不影响结论。' +
220
- '改完 md 之后跑一次,比人工核对可靠。库根可放 diag_ignore.json 豁免样板块。',
207
+ '结构自检。errors = 真损坏(悬空/位置型引用、循环、孤儿、树环、重复 TID、tscl 定义问题);' +
208
+ 'warnings = 疑似漂移(同一段文字出现在多处),默认不算失败。写完 md 就跑一次。',
221
209
  parameters: {
222
210
  type: 'object',
223
211
  properties: {
224
212
  kb: KB_PARAM,
225
- lib: LIB_PARAM,
226
213
  strict: { type: 'boolean', description: '严格模式:把 warnings 也算失败。' },
227
214
  },
228
215
  },
@@ -247,14 +234,13 @@ export function buildTools(ctx, config) {
247
234
  tools.push({
248
235
  name: 'kb_backlinks',
249
236
  description:
250
- '反查:谁引用了这个节点([[TID]] 指向它)。改 TID / 删节点之前先跑,能一次列出所有会断的地方——' +
251
- '比全库 grep 更准(只认真实引用语法,不受正文里偶然出现的同名文字干扰)。',
237
+ '反查:谁引用了这个 id(`[[node=…]]`/`[[tscl=…]]`/旧式 `[[TID]]` 都算)。改 TID 或删节点前先跑,' +
238
+ '比全库 grep 准——只认真实引用语法。',
252
239
  parameters: {
253
240
  type: 'object',
254
241
  properties: {
255
242
  target: { type: 'string', description: '节点 TID 或其唯一前缀。' },
256
243
  kb: KB_PARAM,
257
- lib: LIB_PARAM,
258
244
  },
259
245
  required: ['target'],
260
246
  },
@@ -283,9 +269,8 @@ export function buildTools(ctx, config) {
283
269
  tools.push({
284
270
  name: 'kb_rename',
285
271
  description:
286
- '重命名库里的一个 md 文件,并把全库指向它的 [[TID]] 引用一起改写(含 [[tid:1.2]] 这种子节点引用)。' +
287
- 'nodekb 的 TID 由文件名派生,所以「用 mv / 编辑器改名」必然留下一堆悬空引用;要么用这个工具,' +
288
- '要么改名后自己跑 kb_diag 逐个修。写前会重读校验,别人刚改过的文件不覆盖、只报出来。',
272
+ '重命名 md 文件并改写全库指向它的引用(含子节点)。TID 由文件名派生,所以用 mv 改名必然留悬空引用;' +
273
+ '写前重读校验,别人刚改过的文件跳过不覆盖。',
289
274
  parameters: {
290
275
  type: 'object',
291
276
  properties: {
@@ -293,7 +278,6 @@ export function buildTools(ctx, config) {
293
278
  new_name: { type: 'string', description: '新文件名(.md 可省略,不能带目录)。' },
294
279
  dry_run: { type: 'boolean', description: 'true = 只报「会改哪些引用」,不动文件。' },
295
280
  kb: KB_PARAM,
296
- lib: LIB_PARAM,
297
281
  },
298
282
  required: ['target', 'new_name'],
299
283
  },
@@ -310,44 +294,15 @@ export function buildTools(ctx, config) {
310
294
  },
311
295
  })
312
296
 
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
-
340
297
  // ── kb_vcs_status ────────────────────────────────────────────────────────
341
298
  tools.push({
342
299
  name: 'kb_vcs_status',
343
300
  description:
344
- '查看知识库内嵌 git 仓库的状态(最近提交 + 未提交改动)。nodekb 每次解析后会自动快照,' +
345
- '所以这里能看到「库的改动历史」;文件系统上有改动但没进快照时也会列出来。git 不可用时返回说明而不报错。',
301
+ '库内嵌 git 的快照历史与未提交改动(每次解析后自动快照);git 不可用时只说明不报错。',
346
302
  parameters: {
347
303
  type: 'object',
348
304
  properties: {
349
305
  kb: KB_PARAM,
350
- lib: LIB_PARAM,
351
306
  },
352
307
  },
353
308
  output: {
@@ -393,11 +348,8 @@ export function buildTools(ctx, config) {
393
348
  tools.push({
394
349
  name: 'kb_init',
395
350
  description:
396
- '为一个项目建 .nodekb 知识库骨架(幂等:已存在的文件不覆盖)。' +
397
- '当用户要在项目里沉淀长期知识、而你准备开始写第一份文档之前,先调这个——' +
398
- '它一次铺出 doc/ facts/ lessons/ todo/ index/entry.md 与 .nodekb/README.md 里的落位约定,' +
399
- '比你自己拿 write 从零设计目录结构更省事,也让后续 Agent 知道该往哪写。' +
400
- '已经有 .nodekb/ 就不要调(先用 kb_list 看)。',
351
+ '建 .nodekb 骨架(幂等,已有的不覆盖):doc/ lessons/ todo/ index/entry.md + 落位约定。' +
352
+ '准备手写 docs/*.md 之前先调这个;已经有 .nodekb/ 就别调(先 kb_list 看)。',
401
353
  parameters: {
402
354
  type: 'object',
403
355
  properties: {
@@ -431,10 +383,8 @@ export function buildTools(ctx, config) {
431
383
  tools.push({
432
384
  name: 'kb_write',
433
385
  description:
434
- '往 nodekb 库里写内容,并在写后立刻自检(悬空引用/位置型引用/重复块)。' +
435
- '这是「用知识库管知识」的正路:同样写 md,但写前校验 TID 与 <node> 独占一行,写后把结构错误当场报回来;' +
436
- '直接用 write/edit 没有这一层。' +
437
- 'mode=append 追加到文件(不存在则创建)、replace 整篇覆盖、create 只新建、node 就地改写一个 <node> 块。',
386
+ '写库并立刻自检(悬空引用/重复定义/结构错误当场报回来)。写前校验 TID 与 <node> 独占一行;' +
387
+ '用 write/edit 直接改没有这层。mode=append(追加,不存在则建)| replace(整篇)| create(只新建)| node(就地改一个 <node> 块)。',
438
388
  parameters: {
439
389
  type: 'object',
440
390
  properties: {
@@ -449,7 +399,6 @@ export function buildTools(ctx, config) {
449
399
  description: '默认:路径→append,TID→node。create 遇到已存在会报错,避免覆盖。',
450
400
  },
451
401
  kb: KB_PARAM,
452
- lib: LIB_PARAM,
453
402
  },
454
403
  required: ['target', 'content'],
455
404
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "project-node-memory",
3
- "version": "1.1.3",
3
+ "version": "1.2.1",
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",
@@ -55,7 +55,7 @@ kb_search query=... # toc 里找不到再搜
55
55
  - 定义块内部的 `##` **不会**变成节点——它只是文本。
56
56
 
57
57
  旧写法 `[[TID]]` 仍能解析(有定义时当宏、否则当链接),但 `kb_diag` 会 warning,2.0 移除。
58
- 接手老库时先跑 `kb_migrate`(默认只演练),看清每处会改成 `[[node=…]]` 还是 `[[tscl=…]]` 再 `dry_run=false`。
58
+ 接手老库时按 `kb_diag` 的「旧语法引用」清单逐处改成 `[[node=…]]` 或 `[[tscl=…]]`,改完再跑一次 `kb_diag`。
59
59
 
60
60
  ## TID 规则
61
61
 
@@ -1,171 +0,0 @@
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 }