@migor/cli 0.9.2 → 0.9.4
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/CHANGELOG.md +216 -1
- package/dist/AGENTS.md +10 -7
- package/dist/dev-child.js +94 -0
- package/dist/dev-logic.js +46 -0
- package/dist/dev-machine.js +489 -0
- package/dist/dev-runner.js +8 -1
- package/dist/dev-watch.js +223 -0
- package/dist/dev.js +377 -626
- package/dist/inspector-page.html +94 -10
- package/dist/inspector-routes.js +530 -0
- package/dist/inspector.js +44 -421
- package/dist/markdown.js +20 -1
- package/dist/native-pick.js +168 -0
- package/dist/panel-logic.js +32 -0
- package/dist/templates/package.json +2 -2
- package/dist/templates/src/app.ts +3 -0
- package/dist/templates/src/dev.config.ts +5 -0
- package/dist/templates/src/registry.ts +5 -0
- package/dist/templates/src/session-store.ts +11 -2
- package/dist/templates/src/tools/read-file/index.ts +50 -3
- package/package.json +1 -1
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OS 原生文件夹选择器(dev 面板「工作目录」控件的「用系统选择器…」路径)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么存在:浏览器**拿不到**用户所选文件夹的绝对路径(`<input webkitdirectory>` 只给
|
|
5
|
+
* 相对路径、File System Access API 只给 handle.name),而 dev 环要的就是绝对路径。
|
|
6
|
+
* 但 CLI 是本机进程 —— 它可以替用户拉起 OS 原生的目录选择框,把选中的绝对路径拿回来。
|
|
7
|
+
* `/api/fs` 的「浏览…」目录浏览器是这条路的**降级**(命令缺失 / 平台不支持时用)。
|
|
8
|
+
*
|
|
9
|
+
* ⚠️ **绝不能在 e2e / CI 里真触发**:osascript / zenity 会弹出**真对话框**把流水线挂死。
|
|
10
|
+
* 所以本模块的「平台 → 命令候选」「取消判定」「输出归一化」全是纯函数(带单测),
|
|
11
|
+
* 真正 spawn 的 `pickFolderNative()` 只由 dev 钩子在用户点击时调用。
|
|
12
|
+
*
|
|
13
|
+
* 防孤儿:选择框是用户可能要放着想一会儿的东西(**不设**超时),但 dev 进程退出时
|
|
14
|
+
* 必须收掉 —— 否则留下一个没人看的 osascript 对话框孤儿。在飞的子进程登记在模块级
|
|
15
|
+
* `active` 里,dev.ts 收尾时调 `killActivePickers()`。
|
|
16
|
+
*/
|
|
17
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
18
|
+
const MACOS_PICKER = {
|
|
19
|
+
command: 'osascript',
|
|
20
|
+
args: ['-e', 'POSIX path of (choose folder with prompt "选择 agent 的工作目录")'],
|
|
21
|
+
};
|
|
22
|
+
const WINDOWS_PICKER = {
|
|
23
|
+
command: 'powershell',
|
|
24
|
+
args: [
|
|
25
|
+
'-NoProfile',
|
|
26
|
+
'-NonInteractive',
|
|
27
|
+
'-Command',
|
|
28
|
+
'Add-Type -AssemblyName System.Windows.Forms; ' +
|
|
29
|
+
'$d = New-Object System.Windows.Forms.FolderBrowserDialog; ' +
|
|
30
|
+
"if ($d.ShowDialog() -eq 'OK') { $d.SelectedPath }",
|
|
31
|
+
],
|
|
32
|
+
};
|
|
33
|
+
const LINUX_PICKERS = [
|
|
34
|
+
['zenity', { command: 'zenity', args: ['--file-selection', '--directory'] }],
|
|
35
|
+
['kdialog', { command: 'kdialog', args: ['--getexistingdirectory'] }],
|
|
36
|
+
];
|
|
37
|
+
/**
|
|
38
|
+
* 「平台 → 命令候选」的纯解析(**导出为单测用**;`has` = 该命令在 PATH 上可用)。
|
|
39
|
+
*
|
|
40
|
+
* darwin / win32 的候选是确定的(osascript / powershell 是系统自带),不需要探测;
|
|
41
|
+
* linux 没有系统级唯一答案,按 zenity → kdialog 顺序探测,都没有 ⇒ `null`
|
|
42
|
+
* (调用方翻译成 501 + 可读报错)。
|
|
43
|
+
*/
|
|
44
|
+
export function resolvePicker(platform, has) {
|
|
45
|
+
if (platform === 'darwin')
|
|
46
|
+
return MACOS_PICKER;
|
|
47
|
+
if (platform === 'win32')
|
|
48
|
+
return WINDOWS_PICKER;
|
|
49
|
+
if (platform === 'linux') {
|
|
50
|
+
for (const [cmd, candidate] of LINUX_PICKERS) {
|
|
51
|
+
if (has(cmd))
|
|
52
|
+
return candidate;
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* 「这次退出算不算用户取消」的纯判定(**导出为单测用**)。
|
|
60
|
+
*
|
|
61
|
+
* 三个平台三种取消信号:
|
|
62
|
+
* - darwin:osascript 取消 = 退出码非 0 + stderr 带 **错误码 `(-128)`** —— AppleScript 的
|
|
63
|
+
* 「用户取消」错误码恒为 -128、**不随系统语言本地化**;文案("User canceled")才是
|
|
64
|
+
* 本地化的那部分,只当兜底匹配(非英文系统上只认文案会把「取消」误判成失败 ⇒ 501)。
|
|
65
|
+
* - win32:`ShowDialog()` 不是 OK 就什么都不打印(退出码 0);powershell 自身出错会写
|
|
66
|
+
* stderr —— 所以判「**两个流都空**」而不是只看退出码,否则真错误会被误报成「取消」;
|
|
67
|
+
* - linux:zenity / kdialog 取消 = 退出码 1、**无输出**;而「没有 DISPLAY」这类真错误
|
|
68
|
+
* 会往 stderr 写一句 —— 同样落「两个流都空即取消」。
|
|
69
|
+
* - 被 signal 杀掉(dev 进程退出时 `killActivePickers` 收编)一律视同取消。
|
|
70
|
+
*/
|
|
71
|
+
export function isCancel(platform, code, signal, stderr, stdout) {
|
|
72
|
+
if (signal !== null)
|
|
73
|
+
return true;
|
|
74
|
+
if (platform === 'darwin') {
|
|
75
|
+
return code !== 0 && (stderr.includes('(-128)') || /user canceled/i.test(stderr));
|
|
76
|
+
}
|
|
77
|
+
return stdout.trim().length === 0 && stderr.trim().length === 0;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* 选中输出的归一化(**导出为单测用**):空输出 ⇒ `null`(取消)。
|
|
81
|
+
*
|
|
82
|
+
* 两个坑:
|
|
83
|
+
* - 尾部换行 / `\r\n`(powershell)要去掉,但**只去尾部** —— 路径含空格、非 ASCII
|
|
84
|
+
* 都要原样扛住;
|
|
85
|
+
* - macOS 的 osascript `POSIX path of` 输出**带尾斜杠**(`/Users/x/`),要归一掉
|
|
86
|
+
* (面板会拿它与 `/api/fs` 列出的路径拼接比对);根目录 `/` 是唯一例外,不能归一成空串。
|
|
87
|
+
*/
|
|
88
|
+
export function normalizePickedPath(platform, stdout) {
|
|
89
|
+
const trimmed = stdout.replace(/[\r\n]+$/, '');
|
|
90
|
+
if (trimmed.length === 0)
|
|
91
|
+
return null;
|
|
92
|
+
if (platform === 'darwin' && trimmed.endsWith('/')) {
|
|
93
|
+
return trimmed.replace(/\/+$/, '') || '/';
|
|
94
|
+
}
|
|
95
|
+
return trimmed;
|
|
96
|
+
}
|
|
97
|
+
/** 在飞的选择框子进程 —— dev 进程退出时由 `killActivePickers()` 收编,不留 osascript 孤儿 */
|
|
98
|
+
const active = new Set();
|
|
99
|
+
/**
|
|
100
|
+
* 收掉所有还挂着的原生选择框(dev 收尾 / 进程退出时调用)。
|
|
101
|
+
* 被杀掉的子进程在 `pickFolderNative` 那边走「视同取消」(resolve null)——
|
|
102
|
+
* 那时进程本就要没了,没人会读这个值,但**不许挂着一个永不 settle 的 Promise**。
|
|
103
|
+
*/
|
|
104
|
+
export function killActivePickers() {
|
|
105
|
+
for (const child of active) {
|
|
106
|
+
try {
|
|
107
|
+
child.kill();
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
/* 已经退出了 */
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/** linux 上的命令探测(`which` 是事实标准;探测本身失败就当没有) */
|
|
115
|
+
function hasCommand(cmd) {
|
|
116
|
+
try {
|
|
117
|
+
return spawnSync('which', [cmd], { stdio: 'ignore' }).status === 0;
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* 拉起 OS 原生目录选择框,返回用户选中的**绝对路径**;用户取消返回 `null`。
|
|
125
|
+
*
|
|
126
|
+
* 平台不支持或命令缺失时**抛错**(消息可读,并指向降级路径「浏览…」)——
|
|
127
|
+
* 调用方(dev 钩子)把它翻译成 501。
|
|
128
|
+
*
|
|
129
|
+
* **不设超时**:用户可能把对话框放着想一会儿;进程退出由 `killActivePickers()` 收编。
|
|
130
|
+
*/
|
|
131
|
+
export function pickFolderNative() {
|
|
132
|
+
const picker = resolvePicker(process.platform, hasCommand);
|
|
133
|
+
if (picker === null) {
|
|
134
|
+
return Promise.reject(new Error(process.platform === 'linux'
|
|
135
|
+
? '找不到 zenity / kdialog —— 原生文件夹选择器不可用,请改用面板的「浏览…」'
|
|
136
|
+
: `这个平台(${process.platform})没有原生文件夹选择器 —— 请改用面板的「浏览…」`));
|
|
137
|
+
}
|
|
138
|
+
return new Promise((resolve, reject) => {
|
|
139
|
+
const child = spawn(picker.command, picker.args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
140
|
+
active.add(child);
|
|
141
|
+
let stdout = '';
|
|
142
|
+
let stderr = '';
|
|
143
|
+
child.stdout.on('data', (c) => {
|
|
144
|
+
stdout += c.toString('utf8');
|
|
145
|
+
});
|
|
146
|
+
child.stderr.on('data', (c) => {
|
|
147
|
+
stderr += c.toString('utf8');
|
|
148
|
+
});
|
|
149
|
+
child.on('error', (e) => {
|
|
150
|
+
active.delete(child);
|
|
151
|
+
// 命令缺失(ENOENT)也走这条路 —— 与「平台不支持」同一条可读口径
|
|
152
|
+
reject(new Error(`原生文件夹选择器起不来(${picker.command}:${e.message})—— 请改用面板的「浏览…」`));
|
|
153
|
+
});
|
|
154
|
+
child.on('close', (code, signal) => {
|
|
155
|
+
active.delete(child);
|
|
156
|
+
if (isCancel(process.platform, code, signal, stderr, stdout)) {
|
|
157
|
+
resolve(null);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (code !== 0) {
|
|
161
|
+
reject(new Error(`原生文件夹选择器失败(${picker.command} 退出 code=${code}):` +
|
|
162
|
+
`${stderr.trim() || '无输出'} —— 请改用面板的「浏览…」`));
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
resolve(normalizePickedPath(process.platform, stdout));
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
}
|
package/dist/panel-logic.js
CHANGED
|
@@ -24,6 +24,23 @@ export function normalizeToolSources(selected, all) {
|
|
|
24
24
|
export function capabilityBadge(selected, all) {
|
|
25
25
|
return `${selected.length}/${all.length}`;
|
|
26
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* 渲染前丢掉**已失效**的 token(能力被删 / runner 重启后菜单变了)。不过滤徽标就说谎:
|
|
29
|
+
* 分子里混着菜单上已不存在的名字 ⇒ `4/3`。空集返回空数组(「回落全选」是**调用方**的
|
|
30
|
+
* 规则),且保留入参顺序 —— 过滤不重排,排序是 `normalizeToolSources` 发送前的活。
|
|
31
|
+
*/
|
|
32
|
+
export function filterSelected(selected, all) {
|
|
33
|
+
const known = new Set(all);
|
|
34
|
+
return [...new Set(selected)].filter((s) => known.has(s));
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* 一条 run 的「已选来源」显示口径(`null` = 全量,与 `RunNote.toolSources` 同义)。
|
|
38
|
+
* **两处共用一份**:run 列表第三行与 `run-start` 提示条 —— 各写一份就会出现「同一组
|
|
39
|
+
* 能力两个说法」,而面板的纪律是「显示的就是生效的那份」。
|
|
40
|
+
*/
|
|
41
|
+
export function formatToolSources(sources) {
|
|
42
|
+
return sources === null ? '全量' : sources.join(',');
|
|
43
|
+
}
|
|
27
44
|
/**
|
|
28
45
|
* 多轮开关的初始值。
|
|
29
46
|
*
|
|
@@ -187,6 +204,21 @@ export function mergeConversation(messages, runs, sessionId) {
|
|
|
187
204
|
}
|
|
188
205
|
return turns;
|
|
189
206
|
}
|
|
207
|
+
/**
|
|
208
|
+
* 对话视图出不出现。三条**都**满足才显示,不做空壳:① dev 环在场且可用(只读面板没有
|
|
209
|
+
* 输入条 ⇒ 没有多轮);② 多轮开着(单轮不写会话文件);③ 会话文件读得到(工程没配
|
|
210
|
+
* `SessionStore` 时它是 null)。缺一条就只剩空壳 —— 那比不显示更糟:用户以为对话跑丢了。
|
|
211
|
+
*/
|
|
212
|
+
export function chatViewVisible(dev, multiTurn, session) {
|
|
213
|
+
return dev?.available === true && multiTurn && session !== null;
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* 对话轮折叠态的键:**必须混进 sessionId**。清空对话换 id 后下标从 0 重来,
|
|
217
|
+
* 只用下标当键会把旧会话第 0 轮的展开态套到新会话第 0 轮上(张冠李戴)。
|
|
218
|
+
*/
|
|
219
|
+
export function turnKey(sessionId, index) {
|
|
220
|
+
return `turn:${sessionId}:${index}`;
|
|
221
|
+
}
|
|
190
222
|
// ---------- CLI 侧记账(面板发出去的东西,CLI 自己记一笔) ----------
|
|
191
223
|
/**
|
|
192
224
|
* 往 notes 表里记一条,并**按插入序**把表压在 `max` 以内。
|
|
@@ -9,10 +9,10 @@
|
|
|
9
9
|
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {
|
|
12
|
-
"@migor/agentia": "^0.9.
|
|
12
|
+
"@migor/agentia": "^0.9.4"
|
|
13
13
|
},
|
|
14
14
|
"devDependencies": {
|
|
15
|
-
"@migor/cli": "^0.9.
|
|
15
|
+
"@migor/cli": "^0.9.4",
|
|
16
16
|
"tsx": "^4.19.0",
|
|
17
17
|
"typescript": "^7.0.2",
|
|
18
18
|
"@types/node": "^22.0.0"
|
|
@@ -78,6 +78,9 @@ export async function createAgentApp(opts: CreateAgentAppOptions = {}): Promise<
|
|
|
78
78
|
// 里声明 `{ provide: '<能力 token>', useClass: Xxx, deps: ['WORKDIR'] }`
|
|
79
79
|
// (discover 自动注册的 provider **没有 deps**,所以要注入的能力得从 discover 挪到显式
|
|
80
80
|
// providers;同一个 token 混用不会重复收集菜单)。
|
|
81
|
+
// ⚠️ 若同时用 src/registry.ts(那里也有一份 `WORKDIR = process.cwd()`):
|
|
82
|
+
// 同 token **后注册覆盖先注册**,registry 的 providers 必须排在**这份之前**,
|
|
83
|
+
// 否则面板喂进来的 workdir 会被 process.cwd() 静默顶掉。
|
|
81
84
|
{ provide: 'WORKDIR', useValue: opts.workdir ?? PROJECT_ROOT },
|
|
82
85
|
// 例子:把工作目录注入给 read-file 工具(脚手架自带的第二个能力,
|
|
83
86
|
// 也是「面板上的文件夹选择器真的生效」的那个接线)
|
|
@@ -36,5 +36,10 @@ export default {
|
|
|
36
36
|
* 面板上「点一下就跑一次真 agent」,而真 agent 真花钱 —— 一个跑偏的循环就是一张账单。
|
|
37
37
|
* 这里可以按工程调;调小更安全,调大更放得开。
|
|
38
38
|
* 例:budget: { maxCostUsd: 5, maxTotalTokens: 1_000_000 },
|
|
39
|
+
*
|
|
40
|
+
* ⚠️ 覆盖关系:runner 把这份预算以 **run 级选项**传给框架,而框架的规则是
|
|
41
|
+
* run 级**优先于** createApp 应用级 ⇒ 你在 `src/app.ts` 的 createApp 里自配的
|
|
42
|
+
* `maxCostUsd` / `maxTotalTokens` 在 dev 环会被这里(或缺省值)压回去。
|
|
43
|
+
* 想改 dev 环的预算就在这里的 `budget` 字段改,别去 app.ts 里找。
|
|
39
44
|
*/
|
|
40
45
|
};
|
|
@@ -7,6 +7,11 @@ import type { Provider } from '@migor/agentia';
|
|
|
7
7
|
export const providers: Provider[] = [
|
|
8
8
|
// 「工作目录」以**值**注入给需要它的能力(消费方式见 src/tools/read-file/index.ts)。
|
|
9
9
|
// 想改成固定路径 / 从环境变量取,改这一行即可 —— 这是显式装配路线的意义。
|
|
10
|
+
//
|
|
11
|
+
// ⚠️ 与 src/app.ts 里那份 `WORKDIR` 是**二选一**的关系,混用时**顺序承重**:
|
|
12
|
+
// createApp 对同 token 是「后注册覆盖先注册」—— 把这份 providers 拼在 app.ts 那份
|
|
13
|
+
// **之后**,dev 面板喂进来的 workdir 会被这里的 `process.cwd()` 静默顶掉。
|
|
14
|
+
// 要混用就让本注册表排在前(让 app.ts 的值赢),否则只留一边。
|
|
10
15
|
{ provide: 'WORKDIR', useValue: process.cwd() },
|
|
11
16
|
// @agentia:entries
|
|
12
17
|
// @agentia:entries-end
|
|
@@ -31,6 +31,9 @@ export class FileSessionStore implements SessionStore {
|
|
|
31
31
|
mkdirSync(dirname(this.file), { recursive: true });
|
|
32
32
|
// 原子写:先写同目录的临时文件再 rename。直接覆写的话,进程在写一半时被杀
|
|
33
33
|
// 会留下半截 JSON,下一次 load 直接抛 —— 整段历史因为一次崩溃而不可读。
|
|
34
|
+
// ⚠️ 这只保**单进程**内的写完整:tmp 路径固定是 `${file}.tmp`,两个进程同时
|
|
35
|
+
// append 会在 rename 上竞态、丢一边。dev 环是单进程 runner,无此问题;要多进程
|
|
36
|
+
// 共享这份文件,契约要求调用方自行串行化(与 SessionStore 的 append-only 约定一致)。
|
|
34
37
|
const tmp = `${this.file}.tmp`;
|
|
35
38
|
writeFileSync(tmp, JSON.stringify({ sessions: all }, null, 2), 'utf8');
|
|
36
39
|
renameSync(tmp, this.file);
|
|
@@ -38,8 +41,14 @@ export class FileSessionStore implements SessionStore {
|
|
|
38
41
|
|
|
39
42
|
/**
|
|
40
43
|
* 读整份文件。文件不存在 = 还没有历史(空)。
|
|
41
|
-
*
|
|
42
|
-
*
|
|
44
|
+
*
|
|
45
|
+
* 解析失败**抛错**而不是当作空历史 —— 静默当成空会把「历史读不出来」伪装成
|
|
46
|
+
* 「这是第一轮」,然后下一轮 append 再把它覆盖掉:整段对话无声消失。
|
|
47
|
+
*
|
|
48
|
+
* ⚠️ 但要如实说清楚这个错误会走到哪:框架在 run 期间会**刻意吞掉**会话侧的异常
|
|
49
|
+
* (辅助动作不击穿 run),所以这里的抛错不会把 run 打崩、也不会直接可见。
|
|
50
|
+
* 真正的可见性出口是 dev runner **启动期**的那次探测:它主动 `load()` 一次,
|
|
51
|
+
* 把原因送上 warning 通道(面板顶部可见)—— 所以别把这里的抛错改成静默。
|
|
43
52
|
*/
|
|
44
53
|
private readAll(): Record<string, MessageParam[]> {
|
|
45
54
|
let raw: string;
|
|
@@ -1,15 +1,23 @@
|
|
|
1
|
-
import { readFile } from 'node:fs/promises';
|
|
1
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
2
2
|
import { resolve, sep } from 'node:path';
|
|
3
3
|
import { Tool } from '@migor/agentia';
|
|
4
4
|
|
|
5
|
+
/** 目录清单的输出上限(条)。截断会在输出里**明示** —— 静默少给等于让模型以为目录里没有 */
|
|
6
|
+
const LIST_LIMIT = 200;
|
|
7
|
+
|
|
5
8
|
/**
|
|
6
|
-
*
|
|
9
|
+
* 工作目录的一对工具:**先看有什么(list_files)→ 再读(read_file)**。
|
|
7
10
|
*
|
|
8
11
|
* 它是脚手架自带的第二个能力,作用是把「工作目录」这条线**接通**:
|
|
9
12
|
* `agentia dev` 的面板上有「工作目录」控件,而只有当**某个能力真的消费它**时,
|
|
10
13
|
* 那个控件才有可观测的效果。做法就是这里 —— 根由 DI 注入(`WORKDIR`),
|
|
11
14
|
* 不在代码里写死。见 app.ts 的 providers。
|
|
12
15
|
*
|
|
16
|
+
* 为什么是一对而不是只有 read_file:换了目录之后,模型既不知道自己此刻在哪个目录、
|
|
17
|
+
* 也不知道里面有什么(run 的 prompt 不会变)⇒ 只能瞎猜文件名,行为看起来「没变」。
|
|
18
|
+
* `list_files` 输出的**第一行就是工作目录的绝对路径** —— 那是模型「知道自己在哪」
|
|
19
|
+
* 的通道;列出来的名字则回答「里面有什么」。
|
|
20
|
+
*
|
|
13
21
|
* 换成别的形态(把 `workdir` 换成某个 API 的 baseUrl、某个数据目录)完全一样:
|
|
14
22
|
* 值从构造器进来,方法里用 `this.root`。
|
|
15
23
|
*/
|
|
@@ -33,7 +41,41 @@ export default class ReadFileTool {
|
|
|
33
41
|
}
|
|
34
42
|
|
|
35
43
|
@Tool({
|
|
36
|
-
description:
|
|
44
|
+
description:
|
|
45
|
+
'列出工作目录(或其中一个相对子目录)的内容:先看这里有什么,再用 read_file 读具体文件。' +
|
|
46
|
+
'输出第一行是当前工作目录的绝对路径 —— 换过目录后用它确认自己此刻在哪个目录下工作。',
|
|
47
|
+
schema: {
|
|
48
|
+
type: 'object',
|
|
49
|
+
properties: {
|
|
50
|
+
dir: { type: 'string', description: '相对工作目录的子目录路径(缺省 = 工作目录本身)' },
|
|
51
|
+
},
|
|
52
|
+
additionalProperties: false,
|
|
53
|
+
},
|
|
54
|
+
strict: true,
|
|
55
|
+
})
|
|
56
|
+
// 目录名带 `/` 后缀(一眼可辨),目录在前、文件在后。越界判定复用 safeResolve ——
|
|
57
|
+
// 读目录也是读,同一道闸。输出有上限,截断**明示**(不许静默少给)。
|
|
58
|
+
async list_files(input: { dir?: string }): Promise<string> {
|
|
59
|
+
const abs = safeResolve(this.root, input.dir ?? '.');
|
|
60
|
+
const entries = await readdir(abs, { withFileTypes: true });
|
|
61
|
+
const names = entries
|
|
62
|
+
.map((e) => (e.isDirectory() ? `${e.name}/` : e.name))
|
|
63
|
+
.sort((a, b) => {
|
|
64
|
+
const dirA = a.endsWith('/') ? 0 : 1;
|
|
65
|
+
const dirB = b.endsWith('/') ? 0 : 1;
|
|
66
|
+
return dirA - dirB || a.localeCompare(b);
|
|
67
|
+
});
|
|
68
|
+
const lines = [`工作目录:${this.root}`];
|
|
69
|
+
if (input.dir !== undefined && input.dir !== '.') lines.push(`所列目录:${abs}`);
|
|
70
|
+
lines.push(...names.slice(0, LIST_LIMIT));
|
|
71
|
+
if (names.length > LIST_LIMIT) {
|
|
72
|
+
lines.push(`(还有 ${names.length - LIST_LIMIT} 条未列出 —— 输出截断在 ${LIST_LIMIT} 条)`);
|
|
73
|
+
}
|
|
74
|
+
return lines.join('\n');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
@Tool({
|
|
78
|
+
description: '读取工作目录下的一个文本文件(给相对路径,如 README.md);不知道文件名时先用 list_files 看',
|
|
37
79
|
schema: {
|
|
38
80
|
type: 'object',
|
|
39
81
|
properties: { rel: { type: 'string', description: '相对工作目录的文件路径' } },
|
|
@@ -45,6 +87,7 @@ export default class ReadFileTool {
|
|
|
45
87
|
// 方法名 = 模型看到的工具名 ⇒ 与其余模板一律 snake_case(doc_reviewer / note_writer …)。
|
|
46
88
|
// 顺带解掉一个坑:这里若叫 readFile,就与上面 import 进来的 readFile 同名 ——
|
|
47
89
|
// 方法体内 `this.readFile(...)` 会变成自我递归,而裸写 `readFile(...)` 读的是 import 的那个。
|
|
90
|
+
// 分工:list_files 负责「先看有什么」,这里负责「读这个具体文件」。
|
|
48
91
|
async read_file(input: { rel: string }): Promise<string> {
|
|
49
92
|
return readFile(safeResolve(this.root, input.rel), 'utf8');
|
|
50
93
|
}
|
|
@@ -59,6 +102,10 @@ export default class ReadFileTool {
|
|
|
59
102
|
* ⚠️ 两端都归一化再比:`root` 由调用方给(可能带尾斜杠),而下面判越界用的是
|
|
60
103
|
* **字符串前缀** —— `resolve(root) + sep` 不归一化时会是 `/a/b//`,于是每一次调用
|
|
61
104
|
* 都误报越界。归一化放在函数内部(不只依赖构造器):本函数是导出的,调用方可能不止一个。
|
|
105
|
+
*
|
|
106
|
+
* ⚠️ 已知边界:**不解 realpath** —— 工作目录**内**的符号链接可以指到目录外。
|
|
107
|
+
* 它防的是「模型乱给相对路径跑出工作目录」,不是一道安全边界(read-only,危害有限);
|
|
108
|
+
* 真要拿它当边界用(比如可写工具),先在这里补 realpath 比较。
|
|
62
109
|
*/
|
|
63
110
|
export function safeResolve(root: string, rel: string): string {
|
|
64
111
|
const base = resolve(root);
|