project-node-memory 1.4.0 → 1.4.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.
@@ -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()
@@ -59,10 +59,13 @@ export function isMultiline(body) {
59
59
  return String(body || '').replace(/\s+$/, '').includes('\n')
60
60
  }
61
61
 
62
- /** 谁在正文里引用了这个定义([[tscl="id"]],或指向它的旧语法裸引用)→ [{tid,title,source,line,legacy}] */
63
- export function refsToDef(nodes, id) {
62
+ /** 谁在正文里引用了这个定义([[tscl="id"]],或指向它的旧语法裸引用)→ [{tid,title,source,line,legacy}]
63
+ * `ownerTid` 传进来的话,定义所在节点自己不算「引用者」——定义就写在它里面,
64
+ * 再把它列成一处引用会让计数虚高。 */
65
+ export function refsToDef(nodes, id, ownerTid = '') {
64
66
  const out = []
65
67
  for (const node of nodes.values()) {
68
+ if (ownerTid && node.tid === ownerTid) continue
66
69
  const content = String(node.content || '')
67
70
  if (!content.includes(id)) continue
68
71
  for (const ref of extractRefs(content)) {
@@ -82,13 +85,14 @@ export function refsToDef(nodes, id) {
82
85
 
83
86
  /** 定义 → 人读文本(kb_read 按 tscl id 读、面板点宏时跳转用) */
84
87
  export function formatDefRead(def, refs = []) {
85
- const src = String(def.source || '').split(/[\\/]/).filter(Boolean).slice(-2).join('/')
88
+ const fileOf = (source) => String(source || '').split('#')[0].split(/[\\/]/).filter(Boolean).slice(-2).join('/')
89
+ const src = fileOf(def.source)
86
90
  const lines = [`定义体 <tscl id="${def.id}"> · 定义在 ${def.ownerTid}${src ? `(${src})` : ''}`, '', String(def.body || '').replace(/\s+$/, '')]
87
91
  if (def.count > 1) lines.push('', `重复定义 ${def.count} 处(必须只剩一处): ${def.owners.join('、')}`)
88
92
  if (refs.length) {
89
93
  lines.push('', `被引用 ${refs.length} 处:`)
90
94
  for (const r of refs) {
91
- const at = String(r.source || '').split(/[\\/]/).filter(Boolean).slice(-2).join('/')
95
+ const at = fileOf(r.source)
92
96
  lines.push(`- ${r.tid}${r.title ? ` · ${r.title}` : ''}${at ? `(${at}#L${r.line})` : ''}${r.legacy ? ` ← 旧语法,建议 [[tscl="${def.id}"]]` : ''}`)
93
97
  }
94
98
  } else {
@@ -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
- /** 把一段文本里的 oldTid 引用改写成 newTid(含 `oldTid:…` 子节点引用)。 */
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
- if (inner === oldTid) {
40
- count += 1
41
- return `[[${newTid}]]`
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
- const rel = wanted.split(path.sep).join('/')
102
- node = [...nodes.values()].find((n) => n.kind === 'file' && String(n.source).split('#')[0] === rel)
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(oldRefPrefix(node.tid))) continue
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 = []
@@ -14,6 +14,7 @@
14
14
 
15
15
  import path from 'node:path'
16
16
  import { anyRefRegex, strip_tags } from './parse.js'
17
+ import { ROOT_TID } from './mdlib.js'
17
18
 
18
19
  const MAX_EMBED = 64 // 单次渲染最多展开的宏个数(防病态文件自引用爆炸)
19
20
 
@@ -169,15 +170,18 @@ export function node_info(nodes, tid) {
169
170
  }
170
171
  }
171
172
 
172
- /** 路径解析(三级):精确 TID → 段边界前缀唯一 → 裸前缀(模糊)唯一 */
173
+ /** 路径解析(三级):精确 TID → 段边界前缀唯一 → 裸前缀(模糊)唯一。
174
+ * 空串与 "." 都当库根(README 一直这么写,代码以前只认空串)。 */
173
175
  export function resolve_path(nodes, p) {
174
- if (nodes.has(p)) return p
176
+ const raw = String(p ?? '').trim()
177
+ const target = raw === '' || raw === '.' || raw === './' ? ROOT_TID : raw
178
+ if (nodes.has(target)) return target
175
179
  const keys = [...nodes.keys()]
176
- let hits = keys.filter((t) => t === p || t.startsWith(p + ':')).sort()
180
+ let hits = keys.filter((t) => t === target || t.startsWith(target + ':')).sort()
177
181
  if (hits.length === 1) return hits[0]
178
182
  if (!hits.length) {
179
183
  // 段边界无命中 → 裸前缀模糊(fact:mod → fact:modid / fact:modid-places / fact:modid-trap)
180
- hits = keys.filter((t) => t.startsWith(p)).sort()
184
+ hits = keys.filter((t) => t.startsWith(target)).sort()
181
185
  if (hits.length === 1) return hits[0]
182
186
  }
183
187
  return null
@@ -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,
@@ -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
- return `${count}:${size}:${maxM}`
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)
@@ -335,6 +336,8 @@ export function buildTools(ctx, config) {
335
336
  const report = renameFileNode(lib, String(args.target ?? ''), String(args.new_name ?? ''), {
336
337
  dryRun: Boolean(args.dry_run),
337
338
  })
339
+ // 改名只动文件名:缓存签名里的 mtime/size 都不变,不主动失效就会继续用旧索引
340
+ if (!report.dry_run && report.renamed) forgetLibrary(lib.nodekb)
338
341
  return { ...report, kb: lib.name, text: warnPrefix(lib) + formatRename(report) }
339
342
  },
340
343
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "project-node-memory",
3
- "version": "1.4.0",
3
+ "version": "1.4.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",