project-node-memory 1.3.2 → 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 +21 -23
- package/README_EN.md +16 -26
- package/lib/engine/registry.js +32 -4
- package/lib/host/prompt.js +1 -0
- package/lib/host/runtime.js +95 -23
- package/lib/host/tools.js +113 -19
- package/package.json +1 -1
- package/skills/project-node-memory-guide/SKILL.md +1 -0
package/README.md
CHANGED
|
@@ -2,15 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
> 简体中文 · [English](README_EN.md)
|
|
4
4
|
|
|
5
|
-
把 **Markdown 目录当成知识库** 的 DSH 插件:为 Agent 提供
|
|
5
|
+
把 **Markdown 目录当成知识库** 的 DSH 插件:为 Agent 提供 9 个 `kb_*` 工具,为人类提供知识节点面板(侧栏入口名「项目节点知识库」)。
|
|
6
6
|
|
|
7
7
|
没有索引数据库、没有向量、没有后台进程。完整知识库位于项目下的 `.nodekb/` 目录——一堆 md 文件,删掉插件之后它仍然是普通 md。
|
|
8
8
|
|
|
9
|
+
本 README 只描述**当前版本**的行为;每个版本的变更记录在 [Releases](https://github.com/levodoubt/project-node-memory/releases)。
|
|
10
|
+
|
|
9
11
|
```
|
|
10
12
|
项目/
|
|
11
13
|
└── .nodekb/
|
|
12
14
|
├── index/entry.md ## 3.1 编译契约
|
|
13
|
-
├── doc/03-build.md <
|
|
15
|
+
├── doc/03-build.md <tscl id="fact:abi">…</tscl>
|
|
14
16
|
├── lessons/001.md
|
|
15
17
|
└── diag_ignore.json 可选:豁免样板块
|
|
16
18
|
```
|
|
@@ -65,7 +67,7 @@ dsh plugin add ./project-node-memory-<版本>.tgz
|
|
|
65
67
|
|
|
66
68
|
| 键 | 默认 | 作用 |
|
|
67
69
|
| ---------------- | ----------------------------------------------- | ------------------------------------------------------------- |
|
|
68
|
-
| `workspace` |
|
|
70
|
+
| `workspace` | 无(自动判定) | 覆盖「工作区」。判定顺序见下;工具侧优先取会话头里的 cwd |
|
|
69
71
|
| `libraries` | 无 | `名字: 目录`,显式登记工作区之外的库(目录下需有 `.nodekb/`) |
|
|
70
72
|
| `registries` | 工作区 `.nodekb/registry.json` + 用户全局注册表 | 额外的 `kb_registry.json`(与 Python 版同格式) |
|
|
71
73
|
| `exclude` | 见下 | 库发现时忽略的目录名,支持 `*` 通配;`!pattern` 强制放行 |
|
|
@@ -96,12 +98,13 @@ config:
|
|
|
96
98
|
|
|
97
99
|
| 工具 | 干什么 |
|
|
98
100
|
| --------------- | ---------------------------------------------------------------------------------------------- |
|
|
99
|
-
| `kb_list` |
|
|
101
|
+
| `kb_list` | 有哪些库(含绝对路径与来源)、本会话判定的工作区。不确定传什么 `kb` 时先调它 |
|
|
100
102
|
| `kb_read` | `target` + `mode`(`subtree` 默认 / `node` / `toc` / `raw`)+ `maxlen` |
|
|
101
103
|
| `kb_search` | 中文友好检索(2-gram + 多词加权 + 少见时退化子串),返回稳定 TID、类型、源行、片段 |
|
|
102
104
|
| `kb_diag` | 结构自检;有 error 时直接失败,别读完才发现引用断了 |
|
|
103
105
|
| `kb_backlinks` | 谁引用了它。改 TID / 删节点前先跑 |
|
|
104
106
|
| `kb_init` | 建 `.nodekb` 骨架(幂等),铺出 `doc/ lessons/ todo/ index/` 与落位约定 |
|
|
107
|
+
| `kb_register` | 把已有的库登记进**项目自己**的 `kb_registry.json`(不写全局表、不改配置) |
|
|
105
108
|
| `kb_write` | 写库:`append` / `replace` / `create` / `node`(就地改写一个 `<node>` 块);写前校验、写后自检 |
|
|
106
109
|
| `kb_rename` | 文件改名 + 全库引用一起改写;写前重读,别人刚改的不覆盖 |
|
|
107
110
|
| `kb_vcs_status` | 库内嵌 git 的快照历史与未提交改动(git 缺失时只说明,不报错) |
|
|
@@ -110,7 +113,7 @@ config:
|
|
|
110
113
|
|
|
111
114
|
所有 `kb_*` 工具只有一个定位参数 `kb`,它同时接受**名字**与**路径**:
|
|
112
115
|
|
|
113
|
-
1. `kb` 给路径(技能目录,或其下的 `.nodekb
|
|
116
|
+
1. `kb` 给路径(技能目录,或其下的 `.nodekb`,绝对或相对工作区)——必须落在**本会话工作区、任一已登记工作区或已注册库**内
|
|
114
117
|
2. `kb` 给名字(注册表里的名字,或工作区里发现的名字,如某个技能名)
|
|
115
118
|
3. 都不给:工作区自带的 `.nodekb/` → 工作区里发现且唯一的库 → 注册表里唯一的库
|
|
116
119
|
|
|
@@ -138,7 +141,19 @@ config:
|
|
|
138
141
|
- D:\shared\kb_registry.json
|
|
139
142
|
```
|
|
140
143
|
|
|
141
|
-
|
|
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`(建议随仓库提交),不碰全局注册表、不碰插件配置——所以权限问题可以在工具集内部自己解决,不用去改一个模型看不见的文件。
|
|
142
157
|
|
|
143
158
|
面板拿不到会话上下文,所以它默认跟随**最近一次 `kb_*` 工具调用所在的工作区**(Agent 读过哪个项目,面板就显示哪个项目);顶栏的目录下拉只列出 DSH 用过的、且**真的有 `.nodekb`** 的项目目录(标了各自发现几个库),点右上「目录」也可以手填路径。选择存在浏览器 localStorage,只影响面板,不影响工具。
|
|
144
159
|
|
|
@@ -197,23 +212,6 @@ ABI 校验见 [[tscl="fact:ver"]]。
|
|
|
197
212
|
|
|
198
213
|
旧写法 `[[TID]]` 仍兼容(有定义时当宏、否则当链接),`kb_diag` 会 warning,2.0 移除。
|
|
199
214
|
|
|
200
|
-
## 从 1.0 升到 1.1
|
|
201
|
-
|
|
202
|
-
数据格式没变——md 还是 md,TID 还是那些 TID,插件卸了照样能读。变化只有两条:
|
|
203
|
-
|
|
204
|
-
1. **`[[x]]` 现在只出链接**,不再把被引用节点的正文复制进来(要复制原文请显式写 `<tscl>`)。
|
|
205
|
-
好处是引用不再撑大渲染结果,位置型小节 `## 3.1` 也能点了。
|
|
206
|
-
2. **`kb_init` 不再建 `facts/`**。已有的 `facts/` 不会被动,`kb_read`/`kb_search` 照常;只是新库默认只有
|
|
207
|
-
`doc/ lessons/ todo/ index/`,事实改写成 `<tscl>` 定义放进它所属的 `doc/` 里。
|
|
208
|
-
|
|
209
|
-
老库要不要迁?**不迁也能用**,只是 `kb_diag` 会给每条裸引用一条 warning。要迁就让 Agent 按
|
|
210
|
-
`kb_diag` 的清单逐条改成 `[[node="…"]]` / `[[tscl="…"]]`(只改写法,不动 TID 与文件):
|
|
211
|
-
|
|
212
|
-
```text
|
|
213
|
-
kb_diag # 列出每条 `旧语法引用`
|
|
214
|
-
kb_write mode=node target=<TID> content=… # 逐条改,或直接改 md 再 kb_diag 复验
|
|
215
|
-
```
|
|
216
|
-
|
|
217
215
|
## 自检(不装 DSH 也能跑)
|
|
218
216
|
|
|
219
217
|
```bash
|
package/README_EN.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
A DSH plugin that treats **a directory of Markdown as a database**: nine `kb_*` tools for the agent, and a center-column panel for humans (sidebar entry: 「项目节点知识库」).
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
This README describes **the current version only**; per-version changes live in [Releases](https://github.com/levodoubt/project-node-memory/releases).
|
|
8
8
|
|
|
9
9
|
No index database, no embeddings, no background process. The library *is* the `.nodekb/` directory in your project — a pile of Markdown files that stay ordinary Markdown if you uninstall the plugin.
|
|
10
10
|
|
|
@@ -12,7 +12,7 @@ No index database, no embeddings, no background process. The library *is* the `.
|
|
|
12
12
|
project/
|
|
13
13
|
└── .nodekb/
|
|
14
14
|
├── index/entry.md ## 3.1 Compile contract
|
|
15
|
-
├── doc/03-build.md <
|
|
15
|
+
├── doc/03-build.md <tscl id="fact:abi">…</tscl>
|
|
16
16
|
├── lessons/001.md
|
|
17
17
|
└── diag_ignore.json optional: whitelist boilerplate blocks
|
|
18
18
|
```
|
|
@@ -89,16 +89,17 @@ The Markdown renderer is a small home-grown one (headings, lists, code, tables,
|
|
|
89
89
|
The panel is read-only; data flows through host routes under `/nodekb/api/*`. Writing a library means writing Markdown — via `write` / `edit`, or `kb_write`.
|
|
90
90
|
The right column renders at most 10,000 characters per view (with a truncation notice); wide tables scroll horizontally and long text scrolls vertically.
|
|
91
91
|
|
|
92
|
-
## The
|
|
92
|
+
## The nine tools
|
|
93
93
|
|
|
94
94
|
| Tool | What it does |
|
|
95
95
|
|---|---|
|
|
96
|
-
| `kb_list` | Which libraries exist and
|
|
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
|
-
|
|
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
|
|
|
@@ -179,27 +190,6 @@ and who references it.
|
|
|
179
190
|
The old `[[TID]]` form still works (macro when a definition exists, link otherwise) with a
|
|
180
191
|
`kb_diag` warning, and goes away in 2.0.
|
|
181
192
|
|
|
182
|
-
## Upgrading from 1.0
|
|
183
|
-
|
|
184
|
-
The data format is unchanged — Markdown stays Markdown, TIDs stay the same, and the library remains
|
|
185
|
-
readable after the plugin is removed. Only two things changed:
|
|
186
|
-
|
|
187
|
-
1. **`[[x]]` now renders a link only**; it no longer copies the referenced node's body into the
|
|
188
|
-
output (use an explicit `<tscl>` for that). References stop inflating rendered size, and
|
|
189
|
-
positional sections (`## 3.1`) become clickable.
|
|
190
|
-
2. **`kb_init` no longer creates `facts/`.** Existing `facts/` folders are untouched and still
|
|
191
|
-
readable/searchable; new libraries get `doc/ lessons/ todo/ index/` only, and facts become
|
|
192
|
-
`<tscl>` definitions inside the `doc/` file they belong to.
|
|
193
|
-
|
|
194
|
-
Do old libraries need migrating? **No** — they keep working, `kb_diag` just emits one warning per
|
|
195
|
-
bare reference. To migrate, have the agent walk the `kb_diag` list and fix each one (syntax only, no
|
|
196
|
-
TID or file changes):
|
|
197
|
-
|
|
198
|
-
```text
|
|
199
|
-
kb_diag # lists every `旧语法引用` (legacy bare ref)
|
|
200
|
-
kb_write mode=node target=<TID> content=… # fix one at a time, or edit the md and re-run kb_diag
|
|
201
|
-
```
|
|
202
|
-
|
|
203
193
|
## Self-check (no DSH required)
|
|
204
194
|
|
|
205
195
|
```bash
|
package/lib/engine/registry.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
218
|
-
|
|
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
|
+
}
|
package/lib/host/prompt.js
CHANGED
|
@@ -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) {
|
package/lib/host/runtime.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
/**
|
|
63
|
-
* exec
|
|
64
|
-
export function
|
|
65
|
-
const e = exec && typeof exec === 'object' ? exec :
|
|
66
|
-
const
|
|
67
|
-
|
|
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
|
-
|
|
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 =
|
|
96
|
-
//
|
|
97
|
-
|
|
98
|
-
|
|
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 { ...
|
|
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 { ...
|
|
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 {
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
84
|
-
const
|
|
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/
|
|
111
|
+
lines.push(' (无)在工作区建 .nodekb/ 并放 md 文件,或先调 kb_init。')
|
|
87
112
|
}
|
|
88
113
|
for (const r of rows) {
|
|
89
|
-
|
|
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
|
|
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
|
|
388
|
-
if (
|
|
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
|
-
|
|
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
|
@@ -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>` 定义,两处都引用它。
|