project-node-memory 1.3.3 → 1.4.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
@@ -67,7 +67,7 @@ dsh plugin add ./project-node-memory-<版本>.tgz
67
67
 
68
68
  | 键 | 默认 | 作用 |
69
69
  | ---------------- | ----------------------------------------------- | ------------------------------------------------------------- |
70
- | `workspace` | 会话工作区 → 进程 cwd | 覆盖「工作区」,库发现与路径白名单都以它为界 |
70
+ | `workspace` | 无(自动判定) | 覆盖「工作区」。判定顺序见下;工具侧优先取会话头里的 cwd |
71
71
  | `libraries` | 无 | `名字: 目录`,显式登记工作区之外的库(目录下需有 `.nodekb/`) |
72
72
  | `registries` | 工作区 `.nodekb/registry.json` + 用户全局注册表 | 额外的 `kb_registry.json`(与 Python 版同格式) |
73
73
  | `exclude` | 见下 | 库发现时忽略的目录名,支持 `*` 通配;`!pattern` 强制放行 |
@@ -94,16 +94,17 @@ config:
94
94
  面板只读,数据走宿主侧注册的 `/nodekb/api/*`。写库就是写 md——用 `write` / `edit` 工具,或者 `kb_write`。
95
95
  右栏正文单次最多渲染 10000 字符(超出显示截断提示);宽表格横向滚动,长文本纵向滚动。
96
96
 
97
- ## 9 个工具
97
+ ## 10 个工具
98
98
 
99
99
  | 工具 | 干什么 |
100
100
  | --------------- | ---------------------------------------------------------------------------------------------- |
101
- | `kb_list` | 有哪些库、哪个是默认。不确定传什么 `kb` 时先调它 |
101
+ | `kb_list` | 有哪些库(含绝对路径与来源)、本会话判定的工作区。不确定传什么 `kb` 时先调它 |
102
102
  | `kb_read` | `target` + `mode`(`subtree` 默认 / `node` / `toc` / `raw`)+ `maxlen` |
103
103
  | `kb_search` | 中文友好检索(2-gram + 多词加权 + 少见时退化子串),返回稳定 TID、类型、源行、片段 |
104
104
  | `kb_diag` | 结构自检;有 error 时直接失败,别读完才发现引用断了 |
105
105
  | `kb_backlinks` | 谁引用了它。改 TID / 删节点前先跑 |
106
106
  | `kb_init` | 建 `.nodekb` 骨架(幂等),铺出 `doc/ lessons/ todo/ index/` 与落位约定 |
107
+ | `kb_register` | 把已有的库登记进**项目自己**的 `kb_registry.json`(不写全局表、不改配置) |
107
108
  | `kb_write` | 写库:`append` / `replace` / `create` / `node`(就地改写一个 `<node>` 块);写前校验、写后自检 |
108
109
  | `kb_rename` | 文件改名 + 全库引用一起改写;写前重读,别人刚改的不覆盖 |
109
110
  | `kb_vcs_status` | 库内嵌 git 的快照历史与未提交改动(git 缺失时只说明,不报错) |
@@ -112,7 +113,7 @@ config:
112
113
 
113
114
  所有 `kb_*` 工具只有一个定位参数 `kb`,它同时接受**名字**与**路径**:
114
115
 
115
- 1. `kb` 给路径(技能目录,或其下的 `.nodekb`,绝对或相对工作区)——必须落在工作区或已注册库内
116
+ 1. `kb` 给路径(技能目录,或其下的 `.nodekb`,绝对或相对工作区)——必须落在**本会话工作区、任一已登记工作区或已注册库**内
116
117
  2. `kb` 给名字(注册表里的名字,或工作区里发现的名字,如某个技能名)
117
118
  3. 都不给:工作区自带的 `.nodekb/` → 工作区里发现且唯一的库 → 注册表里唯一的库
118
119
 
@@ -140,7 +141,19 @@ config:
140
141
  - D:\shared\kb_registry.json
141
142
  ```
142
143
 
143
- `config.workspace` 可覆盖「工作区」;优先级是 **exec 上下文(面板点的目录 / 会话工作区)→ config.workspace → 环境变量 `DSH_NODEKB_WORKSPACE` → 最近一次工具用过的目录 → 进程 cwd**。所以在面板里换目录总能生效,配置只当默认值。
144
+ 「工作区」是解析库与判断可写范围的基准,判定顺序是:
145
+
146
+ 1. **会话头里的 `cwd`**(工具调用时)——`exec.agent.id` → `ctx.get('sessions').get(id).header.cwd`。这是唯一可靠来源:DSH 交给工具的 `exec` 里没有 `cwd`/`workspace` 字段。
147
+ 2. `exec` 里显式的目录(面板请求带的 `ws`)。
148
+ 3. `config.workspace`。
149
+ 4. 环境变量 `DSH_NODEKB_WORKSPACE`。
150
+ 5. 面板没有会话,才退到「最近一次工具用过的工作区」;最后是进程 cwd。
151
+
152
+ 每次 `kb_list` / `kb_init` 的回执都会写明**这次用的是哪个工作区、来源是哪一层**;`GET /nodekb/api/where` 给同样的信息。
153
+
154
+ 写库或建库时,若目标落在**另一个**已登记工作区里,`kb_init` 会直接拒绝并打印双方路径——这是为了防止相对路径被解析到别的项目里。真要在那边建,去那个项目开会话;或者先用 `kb_register` 把已有的库登记进当前项目。
155
+
156
+ `kb_register` 只写 `<项目>/kb_registry.json`(建议随仓库提交),不碰全局注册表、不碰插件配置——所以权限问题可以在工具集内部自己解决,不用去改一个模型看不见的文件。
144
157
 
145
158
  面板拿不到会话上下文,所以它默认跟随**最近一次 `kb_*` 工具调用所在的工作区**(Agent 读过哪个项目,面板就显示哪个项目);顶栏的目录下拉只列出 DSH 用过的、且**真的有 `.nodekb`** 的项目目录(标了各自发现几个库),点右上「目录」也可以手填路径。选择存在浏览器 localStorage,只影响面板,不影响工具。
146
159
 
package/README_EN.md CHANGED
@@ -93,12 +93,13 @@ The right column renders at most 10,000 characters per view (with a truncation n
93
93
 
94
94
  | Tool | What it does |
95
95
  |---|---|
96
- | `kb_list` | Which libraries exist and which is default. Call this first when unsure what `kb` to pass |
96
+ | `kb_list` | Which libraries exist (absolute paths and origins) plus the workspace this call resolved to. Call this first when unsure what `kb` to pass |
97
97
  | `kb_read` | `target` + `mode` (`subtree` default / `node` / `toc` / `raw`) + `maxlen` |
98
98
  | `kb_search` | CJK-friendly retrieval (2-gram index + multi-term weighting + substring fallback), returns stable TID, type, source line, snippet |
99
99
  | `kb_diag` | Structure self-check; fails outright on errors so you don't finish a read before noticing a broken ref |
100
100
  | `kb_backlinks` | Who references this node. Run before renaming a TID or deleting a node |
101
101
  | `kb_init` | Scaffold `.nodekb` (idempotent): `doc/ lessons/ todo/ index/` plus placement conventions |
102
+ | `kb_register` | Register an existing library in the **project's own** `kb_registry.json` (never the global registry or the plugin config) |
102
103
  | `kb_write` | Write into the library: `append` / `replace` / `create` / `node` (rewrite one `<node>` block in place); validates before writing, self-checks after |
103
104
  | `kb_rename` | Rename a file and rewrite every reference pointing at it; re-reads before writing, so concurrent edits are never clobbered |
104
105
  | `kb_vcs_status` | Snapshot history and uncommitted changes of the library's embedded git repo (explains itself when git is missing) |
@@ -135,7 +136,17 @@ Registries use the same format as the Python build's `kb_registry.json`; by defa
135
136
  - D:\shared\kb_registry.json
136
137
  ```
137
138
 
138
- `config.workspace` overrides "the workspace", which otherwise comes from the session and falls back to the process cwd.
139
+ "The workspace" is the basis for both library resolution and the writable allowlist:
140
+
141
+ 1. **the session header's `cwd`** (for tool calls) — `exec.agent.id` → `ctx.get('sessions').get(id).header.cwd`. This is the only reliable source: the `exec` DSH hands a tool has no `cwd`/`workspace` field at all.
142
+ 2. an explicit directory in `exec` (the `ws` a panel request carries).
143
+ 3. `config.workspace`.
144
+ 4. the `DSH_NODEKB_WORKSPACE` environment variable.
145
+ 5. only for the panel (which has no session): the workspace of the most recent tool call; last resort, the process cwd.
146
+
147
+ Every `kb_list` / `kb_init` receipt states which workspace was used and which layer it came from; `GET /nodekb/api/where` reports the same.
148
+
149
+ `kb_init` refuses to write into a **different** registered workspace and prints both paths — so a relative path can never silently land in another project. To work there, open a session in that project; or use `kb_register` to register an existing library with the current project. `kb_register` writes only `<project>/kb_registry.json` (commit it), never the global registry or the plugin config — permissions become solvable from inside the tool set.
139
150
 
140
151
  The panel has no session context, so it follows **the workspace of the most recent `kb_*` tool call** (whichever project the agent read, the panel shows). The directory dropdown in the header lists projects DSH has used, annotated with how many libraries each contains; the 「目录」 button lets you type a path. The choice is stored in browser localStorage and affects the panel only, never the tools.
141
152
 
@@ -192,7 +192,7 @@ export function discoverLibraries(root, { depth = 2, budget = 300, limit = 60, e
192
192
  * 定位库根(返回 .nodekb 目录)。
193
193
  * @throws 带可行动提示的错误
194
194
  */
195
- export function resolveLibrary({ kb, lib, workspace, config } = {}) {
195
+ export function resolveLibrary({ kb, lib, workspace, config, extraRoots = [] } = {}) {
196
196
  const ws = path.resolve(workspace || process.cwd())
197
197
  // kb 同时接受「库名」与「路径」:带分隔符 / 绝对路径 / 指向 .nodekb 的一律当路径。
198
198
  const raw = kb === undefined || kb === null ? '' : String(kb).trim()
@@ -211,11 +211,15 @@ export function resolveLibrary({ kb, lib, workspace, config } = {}) {
211
211
  const p = path.resolve(ws, explicit)
212
212
  const nodekb = path.basename(p) === '.nodekb' ? p : path.join(p, '.nodekb')
213
213
  if (!isDir(nodekb)) throw new Error(`库目录不存在: ${nodekb}(你传入: ${explicit})`)
214
- const allowed = [ws, ...[...registries.values()].map((e) => e.path)]
214
+ // 允许范围 = 本会话工作区 + 全部已登记工作区 + 注册表里的库。
215
+ // 「已登记工作区」是关键:多项目并行时,工具能读到的库不该只有一个根决定的那个。
216
+ const allowed = [ws, ...extraRoots, ...[...registries.values()].map((e) => e.path)]
215
217
  if (!allowed.some((root) => isInside(nodekb, root))) {
216
218
  throw new Error(
217
- `拒绝访问工作区外的库: ${nodekb}\n` +
218
- `允许范围 = 工作区 ${ws} + 已注册库。需要长期使用请在插件配置 config.libraries 里登记。`,
219
+ `拒绝访问未登记位置的库: ${nodekb}\n` +
220
+ `已判定的工作区 = ${ws}\n` +
221
+ `允许范围 = 该工作区 + 已登记工作区${extraRoots.length ? `(${extraRoots.length} 个)` : ''} + 已注册库。\n` +
222
+ `要长期使用这个库,把它登记进项目:kb_register(kb="<该库所在目录>"),会在 <项目>/kb_registry.json 里记一行。`,
219
223
  )
220
224
  }
221
225
  return { name: path.basename(nodekb), base: path.dirname(nodekb), nodekb, origin: 'lib' }
@@ -300,3 +304,27 @@ export function listLibraries({ workspace, config, scope, includeHidden = false
300
304
  }
301
305
  return rows
302
306
  }
307
+
308
+ /**
309
+ * 把一个库登记进**项目自己**的 kb_registry.json(`<workspace>/kb_registry.json`)。
310
+ * 不写全局注册表、不改 profile 配置:登记随项目走,换人 clone 下来就在,
311
+ * 也不会像全局注册表那样把 A 项目的库漏进 B 项目的选择器。
312
+ */
313
+ export function writeProjectRegistry({ workspace, name, dir, hidden = false }) {
314
+ const ws = path.resolve(workspace)
315
+ const file = path.join(ws, 'kb_registry.json')
316
+ let data = {}
317
+ try {
318
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'))
319
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) data = parsed
320
+ } catch {
321
+ data = {}
322
+ }
323
+ if (!data.libraries || typeof data.libraries !== 'object' || Array.isArray(data.libraries)) data.libraries = {}
324
+ const entry = { path: path.resolve(dir) }
325
+ if (hidden) entry.hidden = true
326
+ const replaced = !!data.libraries[name]
327
+ data.libraries[name] = entry
328
+ fs.writeFileSync(file, JSON.stringify(data, null, 2) + '\n', 'utf8')
329
+ return { file, name, path: entry.path, replaced }
330
+ }
@@ -21,6 +21,7 @@ export const DEFAULT_PROMPT = [
21
21
  '- 读库:先 `kb_read mode=toc` 看结构,再按 TID 读一支;找不到就 `kb_search`。别一次性把整库读进上下文。',
22
22
  '- 改名前先 `kb_backlinks`,改名用 `kb_rename`(会重写全库引用并跳过被并发改过的文件)。',
23
23
  '- 引用分两种:结构用 `[[node="xxx"]]`(只出一个链接,不复制正文);值只写一处——定义 `<tscl id="xxx:xxx">…</tscl>`,嵌入处写 `[[tscl="xxx:xxx"]]`,同一 id 全库只能定义一次。定义可就近写在正文句子里(单行、别写进表格)。读一个值用 `kb_read target=<定义 id>`。',
24
+ '- 库找不到时先 `kb_list`(它会写明本会话判定的工作区与来源);已有的库可用 `kb_register` 登记进项目,不必改配置。',
24
25
  ].join('\n')
25
26
 
26
27
  function textOf(config) {
@@ -3,6 +3,11 @@
3
3
  *
4
4
  * 缓存键是 .nodekb 的绝对路径,有效性靠「全库 md 的 mtime/size 签名」判断:
5
5
  * 编辑任何一个 md 都会让签名变化,所以不需要 fs.watch(也就没有 watcher 丢事件的问题)。
6
+ *
7
+ * 会话工作区从哪来(1.4.0 起):DSH 交给工具的 `exec` 里**没有** workspace/cwd,
8
+ * 只有 `exec.agent.id`(= 会话 id)。会话头里才有真话:
9
+ * ctx.get('sessions').get(sessionId).header.cwd
10
+ * 所以这里先问会话头,再退显式参数与配置;进程 cwd 只作最后兜底。
6
11
  */
7
12
 
8
13
  import fs from 'node:fs'
@@ -14,9 +19,63 @@ import { resolveLibrary, listLibraries } from '../engine/registry.js'
14
19
  const CACHE = new Map() // nodekb -> { sig, nodes, problems, defs }
15
20
  const MAX_CACHE = 8
16
21
 
22
+ // 宿主服务句柄(apply 时注入):sessions / workspaceRegistry 都是可选服务。
23
+ let SERVICES = null
24
+
25
+ export function bindServices(ctx) {
26
+ SERVICES = ctx || null
27
+ }
28
+
29
+ function service(name) {
30
+ if (!SERVICES) return null
31
+ try {
32
+ if (typeof SERVICES.get === 'function') return SERVICES.get(name) || null
33
+ return SERVICES[name] || null
34
+ } catch {
35
+ return null
36
+ }
37
+ }
38
+
39
+ /** 调用方会话 id:DSH 把它放在 exec.agent.id 上(ToolExecutionInput.agent)。 */
40
+ export function sessionIdOf(exec) {
41
+ const agent = exec && typeof exec === 'object' ? exec.agent : null
42
+ const id = agent && typeof agent === 'object' ? agent.id : null
43
+ return typeof id === 'string' && id ? id : ''
44
+ }
45
+
46
+ /** 会话头里记录的工作目录(会话真正的项目根)。 */
47
+ export function sessionCwd(exec) {
48
+ const sid = sessionIdOf(exec)
49
+ if (!sid) return ''
50
+ const sessions = service('sessions')
51
+ if (!sessions || typeof sessions.get !== 'function') return ''
52
+ try {
53
+ const session = sessions.get(sid)
54
+ const header = session && session.header
55
+ const cwd = header && header.cwd
56
+ return typeof cwd === 'string' && cwd ? path.resolve(cwd) : ''
57
+ } catch {
58
+ return ''
59
+ }
60
+ }
61
+
62
+ /** 已登记工作区的全部路径(允许范围用,不再只认「解析出来的那一个」)。 */
63
+ export function workspaceRoots() {
64
+ const reg = service('workspaceRegistry')
65
+ if (!reg || typeof reg.list !== 'function') return []
66
+ try {
67
+ return reg
68
+ .list()
69
+ .map((w) => (w && typeof w.path === 'string' ? path.resolve(w.path) : ''))
70
+ .filter(Boolean)
71
+ } catch {
72
+ return []
73
+ }
74
+ }
75
+
17
76
  // 面板没有会话上下文,只能靠人指定工作区(ws 参数)。
18
- // 但 Agent 调 kb_* 工具时是带着工作区的——把那一次记下来,
19
- // 面板就能「跟着 Agent 走」,不必每次手填目录。
77
+ // 但 Agent 调 kb_* 工具时是带着会话的——把那次的工作区记下来,面板就能「跟着 Agent 走」。
78
+ // 这条兜底**只服务面板**:带 exec 的工具调用永远不吃它(否则会把上一个项目的路径带进来)。
20
79
  let LAST_WORKSPACE = ''
21
80
 
22
81
  export function rememberWorkspace(ws) {
@@ -59,31 +118,44 @@ function signature(nodekb) {
59
118
  return `${count}:${size}:${maxM}`
60
119
  }
61
120
 
62
- /** 取会话工作区:exec 上下文(面板显式 ws / 会话工作区) → 配置 → 环境变量 → 上次记住的 → 进程 cwd
63
- * exec 放最前面:面板点了哪个工作区、Agent 在哪个会话里,都必须压过配置里的默认值。 */
64
- export function workspaceOf(config, exec) {
65
- const e = exec && typeof exec === 'object' ? exec : {}
66
- const candidates = [e.workspace, e.cwd, e.session && e.session.workspace, e.session && e.session.cwd]
67
- for (const c of candidates) if (typeof c === 'string' && c) return path.resolve(c)
121
+ /** 判定工作区,并说明它是怎么定下来的。
122
+ * 顺序:显式 exec.workspace → exec.cwd → 会话头 cwd → 配置 → 环境变量 →(仅面板)上次记住的 → 进程 cwd */
123
+ export function workspaceSource(config, exec) {
124
+ const e = exec && typeof exec === 'object' ? exec : null
125
+ const explicit = e && typeof e.workspace === 'string' && e.workspace ? path.resolve(e.workspace) : ''
126
+ if (explicit) return { path: explicit, from: 'exec.workspace' }
127
+ const cwd = e && typeof e.cwd === 'string' && e.cwd ? path.resolve(e.cwd) : ''
128
+ if (cwd) return { path: cwd, from: 'exec.cwd' }
129
+ const scwd = sessionCwd(e)
130
+ if (scwd) return { path: scwd, from: 'session.header.cwd' }
68
131
  const configured = config && typeof config.workspace === 'string' ? config.workspace : ''
69
- if (configured) return path.resolve(configured)
132
+ if (configured) return { path: path.resolve(configured), from: 'config.workspace' }
70
133
  const env = process.env.DSH_NODEKB_WORKSPACE
71
- if (env) return path.resolve(env)
72
- if (LAST_WORKSPACE) return LAST_WORKSPACE
73
- return process.cwd()
134
+ 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
+ return { path: process.cwd(), from: 'process.cwd' }
137
+ }
138
+
139
+ export function workspaceOf(config, exec) {
140
+ return workspaceSource(config, exec).path
74
141
  }
75
142
 
76
143
  /** 诊断用:把「工作区是怎么定下来的」原样吐出来(面板/工具都对不上时靠它) */
77
144
  export function whereInfo(config, exec) {
78
- const e = exec && typeof exec === 'object' ? exec : {}
145
+ const e = exec && typeof exec === 'object' ? exec : null
146
+ const src = workspaceSource(config, e)
79
147
  return {
148
+ sessionId: sessionIdOf(e),
149
+ sessionCwd: sessionCwd(e),
150
+ execWorkspace: (e && e.workspace && String(e.workspace)) || '',
151
+ execCwd: (e && e.cwd && String(e.cwd)) || '',
80
152
  configured: (config && config.workspace) || '',
81
153
  env: process.env.DSH_NODEKB_WORKSPACE || '',
82
- execWorkspace: (e.workspace && String(e.workspace)) || '',
83
- execCwd: (e.cwd && String(e.cwd)) || '',
84
154
  lastWorkspace: LAST_WORKSPACE || '',
85
155
  processCwd: process.cwd(),
86
- resolved: workspaceOf(config, exec),
156
+ workspaceRoots: workspaceRoots(),
157
+ resolved: src.path,
158
+ from: src.from,
87
159
  }
88
160
  }
89
161
 
@@ -92,15 +164,15 @@ export function whereInfo(config, exec) {
92
164
  * @returns {{name:string, base:string, nodekb:string, nodes:Map, problems:string[], cached:boolean}}
93
165
  */
94
166
  export function openLibrary(config, exec, { kb, lib, remember = true } = {}) {
95
- const workspace = workspaceOf(config, exec)
96
- // 只有「Agent 带着会话工作区」的调用才值得记下来;
97
- // 面板用 ws 参数指定目录是显式选择,别拿它污染工具侧的默认工作区。
98
- if (remember) rememberWorkspace(workspace)
99
- const resolved = resolveLibrary({ kb, lib, workspace, config })
167
+ const { path: workspace, from } = workspaceSource(config, exec)
168
+ // 只有「带着会话的调用」才值得记下来;面板用 ws 参数指定目录是显式选择,别污染工具侧默认工作区。
169
+ if (remember && exec && typeof exec === 'object' && !exec.wsParam) rememberWorkspace(workspace)
170
+ const resolved = resolveLibrary({ kb, lib, workspace, config, extraRoots: workspaceRoots() })
100
171
  const sig = signature(resolved.nodekb)
101
172
  const hit = CACHE.get(resolved.nodekb)
173
+ const base = { ...resolved, workspace, workspaceFrom: from }
102
174
  if (hit && hit.sig === sig) {
103
- return { ...resolved, nodes: hit.nodes, defs: hit.defs, problems: hit.problems, cached: true }
175
+ return { ...base, nodes: hit.nodes, defs: hit.defs, problems: hit.problems, cached: true }
104
176
  }
105
177
  const problems = []
106
178
  const nodes = parse_library(resolved.nodekb, problems)
@@ -111,7 +183,7 @@ export function openLibrary(config, exec, { kb, lib, remember = true } = {}) {
111
183
  }
112
184
  CACHE.set(resolved.nodekb, { sig, nodes, defs: found.defs, problems })
113
185
  if (CACHE.size > MAX_CACHE) CACHE.delete(CACHE.keys().next().value)
114
- return { ...resolved, nodes, defs: found.defs, problems, cached: false }
186
+ return { ...base, nodes, defs: found.defs, problems, cached: false }
115
187
  }
116
188
 
117
189
  export function knownLibraries(config, exec, opts = {}) {
package/lib/host/tools.js CHANGED
@@ -7,6 +7,7 @@
7
7
  * 设计取向:description 本身就是 prompt,所以它要写清楚「什么时候用我、什么时候别用我」。
8
8
  */
9
9
 
10
+ import fs from 'node:fs'
10
11
  import path from 'node:path'
11
12
  import { execFileSync } from 'node:child_process'
12
13
  import { render_node, render_subtree, toc_lines, node_info, resolve_path } from '../engine/render.js'
@@ -17,8 +18,17 @@ import { refsToDef, formatDefRead } from '../engine/defs.js'
17
18
  import { renameFileNode, formatRename } from '../engine/rename.js'
18
19
  import { scaffold } from '../engine/scaffold.js'
19
20
  import { writeInto } from '../engine/write.js'
20
- import { isInside } from '../engine/registry.js'
21
- import { openLibrary, workspaceOf, libraryHint, knownLibraries } from './runtime.js'
21
+ import { isInside, writeProjectRegistry } from '../engine/registry.js'
22
+ import {
23
+ openLibrary,
24
+ workspaceOf,
25
+ workspaceSource,
26
+ libraryHint,
27
+ knownLibraries,
28
+ workspaceRoots,
29
+ sessionIdOf,
30
+ bindServices,
31
+ } from './runtime.js'
22
32
 
23
33
  const DEFAULT_MAXLEN = 50000
24
34
 
@@ -43,15 +53,24 @@ function warnPrefix(lib) {
43
53
  return `⚠ 库有 ${lib.problems.length} 处 TID 冲突,已跳过冲突项:\n - ${lib.problems.slice(0, 5).join('\n - ')}\n\n`
44
54
  }
45
55
 
46
- /** 允许建库/写库的根:工作区 + 所有已注册库(技能目录也在其中) */
56
+ /** 允许建库/写库的根:本会话工作区 + 全部已登记工作区 + 所有已注册库(技能目录也在其中) */
47
57
  function allowedRoots(config, exec) {
58
+ const out = []
48
59
  try {
49
- return knownLibraries(config, exec)
50
- .map((r) => r.path || r.nodekb)
51
- .filter((p) => typeof p === 'string' && p)
60
+ for (const r of knownLibraries(config, exec)) {
61
+ const p = r.path || r.nodekb
62
+ if (typeof p === 'string' && p) out.push(p)
63
+ }
64
+ } catch {
65
+ /* 库列表拿不到就只靠 workspaceRoots */
66
+ }
67
+ for (const r of workspaceRoots()) out.push(r)
68
+ try {
69
+ out.push(workspaceOf(config, exec))
52
70
  } catch {
53
- return []
71
+ /* ignore */
54
72
  }
73
+ return [...new Set(out.filter(Boolean))]
55
74
  }
56
75
 
57
76
  /** 库定位失败时补一句「有哪些库可用」,别让模型瞎猜 */
@@ -67,28 +86,37 @@ function locate(config, exec, args) {
67
86
  }
68
87
 
69
88
  export function buildTools(ctx, config) {
89
+ bindServices(ctx)
70
90
  const tools = []
71
91
 
72
92
  // ── kb_list ──────────────────────────────────────────────────────────────
73
93
  tools.push({
74
94
  name: 'kb_list',
75
95
  description:
76
- '列出当前可用的 nodekb 知识库以及本次调用实际会命中的默认库。不确定该传什么 kb 参数时先用它。',
96
+ '列出当前可用的 nodekb 知识库,以及本次调用实际会命中的工作区与默认库。建库前/不确定该传什么 kb 时先用它。',
77
97
  parameters: { type: 'object', properties: {} },
78
98
  output: {
79
99
  schema: {},
80
100
  render: (_args, value) => text(value.text),
81
101
  },
82
- async execute() {
83
- const rows = knownLibraries(config, null)
84
- const lines = ['可用于 kb_* 工具的 nodekb 库:']
102
+ async execute(_args, exec) {
103
+ const src = workspaceSource(config, exec)
104
+ const rows = knownLibraries(config, exec)
105
+ const byName = new Map()
106
+ for (const r of rows) byName.set(r.name, (byName.get(r.name) || 0) + 1)
107
+ const lines = [`工作区: ${src.path}(来源: ${src.from})`]
108
+ if (sessionIdOf(exec)) lines.push(`会话: ${sessionIdOf(exec)}`)
109
+ lines.push('', '可用于 kb_* 工具的 nodekb 库:')
85
110
  if (!rows.length) {
86
- lines.push(' (无)在工作区建 .nodekb/ 并放入 md 文件,或在插件配置 config.libraries / config.registries 里登记。')
111
+ lines.push(' (无)在工作区建 .nodekb/ 并放 md 文件,或先调 kb_init。')
87
112
  }
88
113
  for (const r of rows) {
89
- lines.push(`- ${r.name} ${r.path}${r.exists ? '' : ' [路径不存在]'}${r.origin === 'workspace' ? ' (工作区默认)' : ''}`)
114
+ const mark = r.exists ? '' : ' [路径不存在]'
115
+ const origin = r.origin === 'workspace' ? ' (工作区默认)' : ` (${r.origin})`
116
+ const dup = byName.get(r.name) > 1 ? ' ⚠ 同名库,按名字调用有歧义,请用路径' : ''
117
+ lines.push(`- ${r.name} ${r.path}${mark}${origin}${dup}`)
90
118
  }
91
- return { text: lines.join('\n'), libs: rows }
119
+ return { text: lines.join('\n'), workspace: src.path, workspaceFrom: src.from, libs: rows }
92
120
  },
93
121
  })
94
122
 
@@ -381,18 +409,84 @@ export function buildTools(ctx, config) {
381
409
  render: (_args, value) => text(value.text),
382
410
  },
383
411
  async execute(args, exec) {
384
- const ws = workspaceOf(config, exec)
412
+ const src = workspaceSource(config, exec)
413
+ const ws = src.path
385
414
  const rel = typeof args.dir === 'string' && args.dir.trim() ? args.dir.trim() : '.nodekb'
386
415
  const target = path.resolve(ws, rel)
387
- const okRoot = isInside(target, ws) || allowedRoots(config, exec).some((r) => isInside(target, r))
388
- if (!okRoot) throw new Error(`拒绝在工作区外建库: ${target}`)
416
+ const otherWorkspace = workspaceRoots().find((r) => r !== ws && isInside(target, r))
417
+ if (otherWorkspace) {
418
+ throw new Error(
419
+ `拒绝把库建在另一个已登记的工作区里: ${target}\n` +
420
+ `本会话判定的工作区 = ${ws}(来源: ${src.from})\n` +
421
+ `目标却落在 = ${otherWorkspace}\n` +
422
+ `如果确实要建在那个项目里,请在那个项目下开会话再执行;如果只是想在本会话工作区里建,传相对路径(如 .nodekb 或 .dsh/skills/<技能>/.nodekb)。`,
423
+ )
424
+ }
425
+ const okRoot = allowedRoots(config, exec).some((r) => isInside(target, r))
426
+ if (!okRoot) {
427
+ throw new Error(
428
+ `拒绝在工作区外建库: ${target}\n` +
429
+ `本会话工作区 = ${ws}(来源: ${src.from})\n` +
430
+ `允许范围 = 该工作区 + 已登记工作区 + 已注册库:\n - ` +
431
+ allowedRoots(config, exec).join('\n - '),
432
+ )
433
+ }
389
434
  const res = scaffold(target)
390
- const lines = [`库目录: ${res.nodekb}`]
435
+ const lines = [`库目录: ${res.nodekb}`, `工作区: ${ws}(来源: ${src.from})`]
391
436
  lines.push(res.created.length ? `新建 ${res.created.length} 个文件:\n` + res.created.map((f) => ' + ' + f).join('\n') : '没有新建文件(骨架已存在)')
392
437
  if (res.skipped.length) lines.push(`跳过 ${res.skipped.length} 个已存在文件: ${res.skipped.join('、')}`)
393
438
  lines.push('')
394
439
  lines.push('下一步:把这次要记的东西用 kb_write 写进对应目录(先读 .nodekb/README.md 的落位表),写完 kb_diag 一遍。')
395
- return { text: lines.join('\n'), nodekb: res.nodekb, created: res.created, skipped: res.skipped }
440
+ lines.push('提示:文件名建议用 ASCII(中文标题写进 # 一级标题里),Windows 控制台显示中文路径会乱码。')
441
+ return { text: lines.join('\n'), nodekb: res.nodekb, workspace: ws, workspaceFrom: src.from, created: res.created, skipped: res.skipped }
442
+ },
443
+ })
444
+
445
+ // ── kb_register ──────────────────────────────────────────────────────────
446
+ tools.push({
447
+ name: 'kb_register',
448
+ description:
449
+ '把一个已存在的 .nodekb 库登记进当前项目的 kb_registry.json,之后就能用库名访问它。' +
450
+ '只写项目内文件,不改全局注册表、不改插件配置。',
451
+ parameters: {
452
+ type: 'object',
453
+ properties: {
454
+ dir: {
455
+ type: 'string',
456
+ description: '库所在目录(含 .nodekb 的那一层);绝对路径或相对本会话工作区。也可以直接传 .nodekb 目录。',
457
+ },
458
+ name: { type: 'string', description: '库名,缺省取目录名。' },
459
+ hidden: { type: 'boolean', description: '登记但不出现在库列表里(给老文档里的别名用)。' },
460
+ },
461
+ required: ['dir'],
462
+ },
463
+ output: {
464
+ schema: {},
465
+ render: (_args, value) => text(value.text),
466
+ },
467
+ async execute(args, exec) {
468
+ const src = workspaceSource(config, exec)
469
+ const ws = src.path
470
+ const raw = typeof args.dir === 'string' ? args.dir.trim() : ''
471
+ if (!raw) throw new Error('dir 不能为空:传库所在目录(含 .nodekb 的那一层)')
472
+ const p = path.resolve(ws, raw)
473
+ const dir = path.basename(p) === '.nodekb' ? path.dirname(p) : p
474
+ const nodekb = path.join(dir, '.nodekb')
475
+ let isDirNow = false
476
+ try {
477
+ isDirNow = fs.statSync(nodekb).isDirectory()
478
+ } catch {
479
+ isDirNow = false
480
+ }
481
+ if (!isDirNow) throw new Error(`这里没有 .nodekb: ${nodekb}(先 kb_init 建库,或确认目录)`)
482
+ const name = (typeof args.name === 'string' && args.name.trim()) || path.basename(dir)
483
+ const res = writeProjectRegistry({ workspace: ws, name, dir, hidden: args.hidden === true })
484
+ const lines = [
485
+ `已登记: ${res.name} → ${res.path}${res.replaced ? '(覆盖了同名登记)' : ''}`,
486
+ `写入: ${res.file}`,
487
+ `之后可直接用 kb="${res.name}";这个文件建议随仓库一起提交,换台机器就能用。`,
488
+ ]
489
+ return { text: lines.join('\n'), name: res.name, dir: res.path, file: res.file }
396
490
  },
397
491
  })
398
492
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "project-node-memory",
3
- "version": "1.3.3",
3
+ "version": "1.4.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",
@@ -79,5 +79,6 @@ kb_write target=doc/03-...md mode=append content=...
79
79
 
80
80
  ## 注意事项
81
81
 
82
+ 0. 库定位找不到时先 `kb_list`:它会写明本会话判定的工作区、来源(会话头 / 配置 / 面板)与每个库的绝对路径。已有的库用 `kb_register dir=<库目录> name=<名字>` 登记进项目的 `kb_registry.json`,不要让人去改插件配置。
82
83
  1. 不要把整段对话贴进 `doc/` —— 记的是过程,不是结论。
83
84
  2. 不要把同一件事在 `doc/` 和 `lessons/` 各写一份 —— 抽成 `<tscl>` 定义,两处都引用它。