kcode-pi 0.1.38 → 0.1.40
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/dist/cli/kcode.js +24 -0
- package/dist/harness/prompt-policy.d.ts +2 -0
- package/dist/harness/prompt-policy.js +21 -0
- package/docs/CHANGELOG.md +37 -0
- package/docs/COMMAND_REFERENCE.md +1 -0
- package/docs/HARNESS_WORKFLOW.md +3 -0
- package/docs/TROUBLESHOOTING.md +33 -0
- package/extensions/kingdee-harness.ts +15 -4
- package/extensions/kingdee-tools.ts +101 -0
- package/package.json +1 -1
- package/src/cli/kcode.ts +27 -0
- package/src/harness/delegation.ts +3 -0
- package/src/harness/prompt-policy.ts +23 -0
- package/src/harness/question-memory.ts +34 -2
- package/src/tools/file-search.ts +107 -0
package/dist/cli/kcode.js
CHANGED
|
@@ -73,6 +73,7 @@ export function doctor(cwd, args = []) {
|
|
|
73
73
|
lines.push(`KCode package:${packageRoot}`);
|
|
74
74
|
lines.push(`项目配置:${existsSync(settingsPath) ? settingsPath : "未创建,运行 kcode init"}`);
|
|
75
75
|
lines.push(`项目上下文:${existsSync(projectContextPath) ? projectContextPath : "未创建,运行 kcode context"}`);
|
|
76
|
+
lines.push(`Windows bash:${process.platform === "win32" ? formatWindowsBashStatus(readSettingsSafe(settingsPath)) : "不适用"}`);
|
|
76
77
|
if (existsSync(settingsPath)) {
|
|
77
78
|
const settingsResult = readSettingsSafe(settingsPath);
|
|
78
79
|
if (!settingsResult.ok) {
|
|
@@ -109,6 +110,10 @@ export function doctor(cwd, args = []) {
|
|
|
109
110
|
warnings++;
|
|
110
111
|
lines.push("[WARN] 项目上下文不存在,运行 kcode context --refresh 或 kcode repair。");
|
|
111
112
|
}
|
|
113
|
+
if (process.platform === "win32" && !findWindowsBash(readSettingsSafe(settingsPath))) {
|
|
114
|
+
warnings++;
|
|
115
|
+
lines.push("[WARN] Pi 内置 bash 在当前 Windows 环境不可用;KCode 已覆盖 bash 为 PowerShell,并提供 kd_find_file/kd_list_dir。安装 Git for Windows 可恢复 Pi 原生 bash。");
|
|
116
|
+
}
|
|
112
117
|
const npmPrefix = spawnSync("npm", ["prefix", "-g"], { encoding: "utf8" });
|
|
113
118
|
const npmRoot = spawnSync("npm", ["root", "-g"], { encoding: "utf8" });
|
|
114
119
|
lines.push(`npm prefix -g:${npmPrefix.status === 0 ? npmPrefix.stdout.trim() : "不可用"}`);
|
|
@@ -305,3 +310,22 @@ function helpText() {
|
|
|
305
310
|
" kcode start 初始化项目配置后启动 KCode 工作环境",
|
|
306
311
|
].join("\n");
|
|
307
312
|
}
|
|
313
|
+
function formatWindowsBashStatus(settingsResult) {
|
|
314
|
+
const bash = findWindowsBash(settingsResult);
|
|
315
|
+
return bash ? `可用(${bash})` : "不可用;KCode 将使用 PowerShell 覆盖工具和 kd_find_file/kd_list_dir";
|
|
316
|
+
}
|
|
317
|
+
function findWindowsBash(settingsResult) {
|
|
318
|
+
const configured = settingsResult?.ok && typeof settingsResult.settings.shellPath === "string" ? settingsResult.settings.shellPath : undefined;
|
|
319
|
+
if (configured && existsSync(configured))
|
|
320
|
+
return configured;
|
|
321
|
+
const candidates = [
|
|
322
|
+
process.env.ProgramFiles ? join(process.env.ProgramFiles, "Git", "bin", "bash.exe") : undefined,
|
|
323
|
+
process.env["ProgramFiles(x86)"] ? join(process.env["ProgramFiles(x86)"], "Git", "bin", "bash.exe") : undefined,
|
|
324
|
+
...pathEntries().map((entry) => join(entry, "bash.exe")),
|
|
325
|
+
].filter((item) => Boolean(item));
|
|
326
|
+
return candidates.find((candidate) => existsSync(candidate));
|
|
327
|
+
}
|
|
328
|
+
function pathEntries() {
|
|
329
|
+
const pathKey = Object.keys(process.env).find((key) => key.toLowerCase() === "path") ?? "PATH";
|
|
330
|
+
return (process.env[pathKey] ?? "").split(";").filter(Boolean);
|
|
331
|
+
}
|
|
@@ -15,5 +15,7 @@ export declare const PHASE_GUIDANCE: Record<KdPhase, string>;
|
|
|
15
15
|
export declare const PLAN_REQUIRED_CHECK_LINES: string[];
|
|
16
16
|
export declare function formatPromptLines(lines: string[]): string[];
|
|
17
17
|
export declare function fieldLabels(fields: ContractField[]): string[];
|
|
18
|
+
export declare function allFactLabels(): string[];
|
|
19
|
+
export declare function formatFactLabelCatalog(): string;
|
|
18
20
|
export declare function questionForMissingLabel(label: string): string | undefined;
|
|
19
21
|
export declare function canonicalFactLabel(label: string): string | undefined;
|
|
@@ -144,6 +144,9 @@ export const PROJECT_PERSISTENT_RULES = [
|
|
|
144
144
|
"用户回答 open question 后,先用 `kd_question action=answer` 写入答案;用户更正事实时用 `kd_question action=revise`,禁止重复询问已确认事实。",
|
|
145
145
|
"企业版 Python 插件通常没有本地构建可替代验证;BOS 注册、外部系统操作、人工功能测试和生产环境验证必须由用户提供可核验证据并记录来源。",
|
|
146
146
|
"提示语集中管理为正式工程指令;禁止口语化、闲聊式、鼓励式提示词进入运行时规则。",
|
|
147
|
+
"工具使用必须匹配当前会话实际可用工具和操作系统;Windows 默认使用 PowerShell、`rg`、`Get-ChildItem`、`Get-Content`,禁止假设 bash 可用。",
|
|
148
|
+
"Windows 查找文件和目录时优先使用 `kd_find_file`、`kd_list_dir` 或 PowerShell;禁止用 Linux find/ls 语法反复调用 Pi 内置 bash。",
|
|
149
|
+
"文件定位必须使用真实搜索或目录读取结果;工具不可用时明确说明阻塞原因和需要用户执行的命令,禁止猜测路径、反复自述工具失败或用 kd_subagent 代替基础文件搜索。",
|
|
147
150
|
"不做旧状态迁移兼容或旧问题答案推断;坏状态只过滤,缺失事实必须重新提问确认。",
|
|
148
151
|
];
|
|
149
152
|
export const PROMPT_STYLE_RULES = [
|
|
@@ -167,6 +170,7 @@ export const CORE_WORKFLOW_CONSTRAINTS = [
|
|
|
167
170
|
"evidence 必须记录命令、Exit 和关键输出;命令无法运行时记录阻塞原因。",
|
|
168
171
|
"外部系统操作、BOS 注册、人工功能测试和生产环境验证不能由 LLM 代办;必须要求用户提供验证结果或可核验证据,并记录证据来源。",
|
|
169
172
|
"Windows 路径规则:项目相对路径为默认;绝对路径使用 D:\\... 形式。",
|
|
173
|
+
"工具规则:按当前会话实际 shell 和工具执行;Windows 不假设 bash,优先用 kd_find_file/kd_list_dir/PowerShell;禁止猜路径或用 kd_subagent 代替基础搜索失败。",
|
|
170
174
|
];
|
|
171
175
|
export const PHASE_GUIDANCE = {
|
|
172
176
|
discuss: "梳理需求来源、范围、已知事实;如缺通用实现契约、数据源或第三方接口关键事实,使用 kd_question 登记一个最阻塞问题。",
|
|
@@ -192,6 +196,23 @@ export function formatPromptLines(lines) {
|
|
|
192
196
|
export function fieldLabels(fields) {
|
|
193
197
|
return fields.map((field) => field.label);
|
|
194
198
|
}
|
|
199
|
+
export function allFactLabels() {
|
|
200
|
+
return fieldLabels([...IMPLEMENTATION_CONTRACT_FIELDS, ...DATA_SOURCE_CONTEXT_FIELDS, ...INTEGRATION_CONTEXT_FIELDS]);
|
|
201
|
+
}
|
|
202
|
+
export function formatFactLabelCatalog() {
|
|
203
|
+
return [
|
|
204
|
+
"## 实现契约事实标签",
|
|
205
|
+
...formatPromptLines(fieldLabels(IMPLEMENTATION_CONTRACT_FIELDS)),
|
|
206
|
+
"",
|
|
207
|
+
"## 数据源上下文事实标签",
|
|
208
|
+
...formatPromptLines(fieldLabels(DATA_SOURCE_CONTEXT_FIELDS)),
|
|
209
|
+
"",
|
|
210
|
+
"## 第三方对接事实标签",
|
|
211
|
+
...formatPromptLines(fieldLabels(INTEGRATION_CONTEXT_FIELDS)),
|
|
212
|
+
"",
|
|
213
|
+
"产品类型/产品画像不是 factLabel;使用 /kd-product <产品> 或先登记无 factLabel 的产品确认问题。",
|
|
214
|
+
].join("\n");
|
|
215
|
+
}
|
|
195
216
|
export function questionForMissingLabel(label) {
|
|
196
217
|
const fields = [...IMPLEMENTATION_CONTRACT_FIELDS, ...DATA_SOURCE_CONTEXT_FIELDS, ...INTEGRATION_CONTEXT_FIELDS];
|
|
197
218
|
return fields.find((field) => field.label === label)?.question;
|
package/docs/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,43 @@
|
|
|
6
6
|
|
|
7
7
|
- 暂无。
|
|
8
8
|
|
|
9
|
+
## 0.1.40 - 2026-06-07
|
|
10
|
+
|
|
11
|
+
### 修复
|
|
12
|
+
|
|
13
|
+
- Windows 下覆盖 Pi 内置 `bash` 工具为 PowerShell 执行,避免未安装 Git Bash 时反复搜索 `C:\Program Files\Git\bin\bash.exe`。
|
|
14
|
+
- 新增 `kd_find_file` 和 `kd_list_dir`,用于不依赖 shell 的文件查找和目录探索。
|
|
15
|
+
- `kcode doctor --deep` 增加 Windows bash 状态诊断,并提示 KCode 的 PowerShell 覆盖和文件工具。
|
|
16
|
+
- 持久规则明确 Windows 文件定位优先使用 `kd_find_file`、`kd_list_dir` 或 PowerShell。
|
|
17
|
+
- `kd_question action=labels` 可列出合法 `factLabel`,未知标签错误会返回完整标签目录,避免 LLM 反复猜标签。
|
|
18
|
+
- `kd_question` 拒绝把产品类型和 FormId/字段/插件事件等多个事实塞进同一个 `factLabel`。
|
|
19
|
+
|
|
20
|
+
### 验证
|
|
21
|
+
|
|
22
|
+
- `npm run check`
|
|
23
|
+
- `npm run smoke:harness`
|
|
24
|
+
- `npm run smoke:kcode-command`
|
|
25
|
+
- `npm run smoke:package`
|
|
26
|
+
- `npm run smoke:official`
|
|
27
|
+
- `npm run build:cli`
|
|
28
|
+
- `git diff --check`
|
|
29
|
+
|
|
30
|
+
## 0.1.39 - 2026-06-07
|
|
31
|
+
|
|
32
|
+
### 修复
|
|
33
|
+
|
|
34
|
+
- 持久规则和核心约束新增工具环境规则:Windows 默认使用 PowerShell、`rg`、`Get-ChildItem`、`Get-Content`,禁止假设 bash 可用。
|
|
35
|
+
- 明确文件定位必须基于真实搜索或目录读取结果;工具不可用时记录阻塞原因,禁止猜测路径或反复自述工具失败。
|
|
36
|
+
- 强化 `kd_subagent` 委派边界:子 agent 不能作为 shell/read/grep 失败的替代方案,不能用来绕过基础文件搜索。
|
|
37
|
+
|
|
38
|
+
### 验证
|
|
39
|
+
|
|
40
|
+
- `npm run check`
|
|
41
|
+
- `npm run smoke:harness`
|
|
42
|
+
- `npm run smoke:kcode-command`
|
|
43
|
+
- `npm run build:cli`
|
|
44
|
+
- `git diff --check`
|
|
45
|
+
|
|
9
46
|
## 0.1.38 - 2026-06-07
|
|
10
47
|
|
|
11
48
|
### 修复
|
package/docs/HARNESS_WORKFLOW.md
CHANGED
|
@@ -151,6 +151,7 @@ KCode 会阻止过早写入 Java/XML/SQL/C# 等产品代码:
|
|
|
151
151
|
- API 文档、SDK 文档和知识库只能作为技术用法证据,不能替代 FormId、字段/实体、插件事件、SQL/KSQL 表名、数据库字段名、字段映射、并发/幂等、日志脱敏和验收数据等业务事实。
|
|
152
152
|
- 信息不足时登记一个最阻塞的结构化问题;禁止生成模板代码或占位实现。
|
|
153
153
|
- 内部插件、自动下推、第三方对接、字段改写和数据同步统一按实现契约、数据源上下文和对接上下文补齐事实,不为单个业务场景写死提示词。
|
|
154
|
+
- 工具使用必须匹配当前环境。Windows 项目默认使用 PowerShell、`rg`、`Get-ChildItem`、`Get-Content`;禁止假设 bash 可用,禁止猜测路径。
|
|
154
155
|
|
|
155
156
|
## 项目持久规则
|
|
156
157
|
|
|
@@ -162,6 +163,7 @@ KCode 会阻止过早写入 Java/XML/SQL/C# 等产品代码:
|
|
|
162
163
|
- `factLabel` 必须使用集中定义的事实标签或别名。
|
|
163
164
|
- PLAN 自由文本不能单独证明 FormId、字段、插件事件或读写方式。
|
|
164
165
|
- 外部系统操作、BOS 注册、人工功能测试和生产验证必须由用户提供可核验证据。
|
|
166
|
+
- 工具不可用时必须说明阻塞原因和需要用户执行的命令;禁止反复自述工具失败或用 `kd_subagent` 代替基础文件搜索。
|
|
165
167
|
- 不做旧状态迁移兼容或旧问题答案推断;坏状态只过滤,缺失事实重新提问确认。
|
|
166
168
|
|
|
167
169
|
证据和门禁细节见 [证据和门禁](EVIDENCE_AND_GATES.md)。
|
|
@@ -189,6 +191,7 @@ KCode 支持把局部任务委派给隔离子 agent,用来降低长上下文
|
|
|
189
191
|
- `doc` 只写明确指定的文档或阶段产物。
|
|
190
192
|
- `code` 只能在 `execute` 阶段运行,并且只能修改 `PLAN.md` 批准文件。
|
|
191
193
|
- `verify` 只读分析验证命令、失败证据和风险;实际命令和结果记录仍由主 agent 执行。
|
|
194
|
+
- 子 agent 不是 shell、read、grep 失败的替代方案;主 agent 必须先使用当前环境可用工具定位文件。
|
|
192
195
|
|
|
193
196
|
`--dry-run` 会预览发送给子 agent 的上下文包,用来检查上下文是否过长、是否包含不该交给子 agent 的信息。
|
|
194
197
|
|
package/docs/TROUBLESHOOTING.md
CHANGED
|
@@ -133,3 +133,36 @@ ENOENT: no such file or directory, access 'D:\mnt\d\projects\xxx\src\main\java\F
|
|
|
133
133
|
```powershell
|
|
134
134
|
kcode context --refresh
|
|
135
135
|
```
|
|
136
|
+
|
|
137
|
+
## Windows 下 bash 工具一直搜索 Git Bash
|
|
138
|
+
|
|
139
|
+
现象:
|
|
140
|
+
|
|
141
|
+
```text
|
|
142
|
+
Searched Git Bash in:
|
|
143
|
+
C:\Program Files\Git\bin\bash.exe
|
|
144
|
+
C:\Program Files (x86)\Git\bin\bash.exe
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
原因是 Pi 内置 `bash` 工具在 Windows 上要求 Git Bash、Cygwin 或 MSYS2。KCode 在 Windows 下会覆盖该工具为 PowerShell,并提供 `kd_find_file`、`kd_list_dir` 用于文件查找和目录探索。
|
|
148
|
+
|
|
149
|
+
处理:
|
|
150
|
+
|
|
151
|
+
```powershell
|
|
152
|
+
kcode repair
|
|
153
|
+
kcode doctor --deep
|
|
154
|
+
kcode start
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
在 KCode 会话中查找文件时使用:
|
|
158
|
+
|
|
159
|
+
```text
|
|
160
|
+
kd_find_file name=MesApiClient.cs root=C:\Users\Administrator\Desktop\lf-project
|
|
161
|
+
kd_list_dir path=C:\Users\Administrator\Desktop\lf-project
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
如果需要 Pi 原生 bash 行为,安装 Git for Windows 后重新运行:
|
|
165
|
+
|
|
166
|
+
```powershell
|
|
167
|
+
kcode doctor --deep
|
|
168
|
+
```
|
|
@@ -30,7 +30,8 @@ import { windowsPathHint } from "../src/platform/path.ts";
|
|
|
30
30
|
import { repairPromptForRun, workflowPromptForRun } from "../src/harness/prompt.ts";
|
|
31
31
|
import { recordVerifyResult, type VerifyResultOutcome } from "../src/harness/repair.ts";
|
|
32
32
|
import { isSubagentChild, subagentRoleFromEnv, subagentToolCallBlockReason } from "../src/harness/delegation.ts";
|
|
33
|
-
import { questionAskBlockReason } from "../src/harness/question-memory.ts";
|
|
33
|
+
import { questionAskBlockReason, questionFactLabelMismatchReason } from "../src/harness/question-memory.ts";
|
|
34
|
+
import { formatFactLabelCatalog } from "../src/harness/prompt-policy.ts";
|
|
34
35
|
|
|
35
36
|
function requireRun(cwd: string): ReturnType<typeof readActiveRun> {
|
|
36
37
|
return readActiveRun(cwd);
|
|
@@ -175,9 +176,9 @@ const kdQuestionTool = defineTool({
|
|
|
175
176
|
name: "kd_question",
|
|
176
177
|
label: "KD 问题",
|
|
177
178
|
description:
|
|
178
|
-
"
|
|
179
|
+
"创建、回答、修订、列出金蝶 Harness 结构化问题,或列出合法 factLabel。每次必须只记录一个最阻塞的短问题。",
|
|
179
180
|
parameters: Type.Object({
|
|
180
|
-
action: Type.Optional(Type.String({ description: "操作类型:ask、answer、revise 或
|
|
181
|
+
action: Type.Optional(Type.String({ description: "操作类型:ask、answer、revise、list 或 labels,默认 ask。" })),
|
|
181
182
|
id: Type.Optional(Type.String({ description: "回答问题时的问题编号,例如 Q-001。" })),
|
|
182
183
|
question: Type.Optional(Type.String({ description: "提问内容;必须是一个短问题。" })),
|
|
183
184
|
answer: Type.Optional(Type.String({ description: "用户答案,action=answer 时使用。" })),
|
|
@@ -198,6 +199,9 @@ const kdQuestionTool = defineTool({
|
|
|
198
199
|
}
|
|
199
200
|
|
|
200
201
|
const action = (params.action ?? "ask").toLowerCase();
|
|
202
|
+
if (action === "labels") {
|
|
203
|
+
return { content: [{ type: "text", text: formatFactLabelCatalog() }], details: { action: "labels" } };
|
|
204
|
+
}
|
|
201
205
|
if (action === "list") {
|
|
202
206
|
const text = formatQuestions(run);
|
|
203
207
|
return { content: [{ type: "text", text }], details: { questions: run.questions ?? [] } };
|
|
@@ -260,7 +264,7 @@ const kdQuestionTool = defineTool({
|
|
|
260
264
|
|
|
261
265
|
if (action !== "ask") {
|
|
262
266
|
return {
|
|
263
|
-
content: [{ type: "text", text: `未知 kd_question action:${params.action}。有效值:ask、answer、revise、list。` }],
|
|
267
|
+
content: [{ type: "text", text: `未知 kd_question action:${params.action}。有效值:ask、answer、revise、list、labels。` }],
|
|
264
268
|
details: { error: "unknown-action", action: params.action },
|
|
265
269
|
};
|
|
266
270
|
}
|
|
@@ -291,6 +295,13 @@ const kdQuestionTool = defineTool({
|
|
|
291
295
|
details: { error: "batched-question" },
|
|
292
296
|
};
|
|
293
297
|
}
|
|
298
|
+
const mismatchReason = questionFactLabelMismatchReason({ question: params.question, factLabel: params.factLabel });
|
|
299
|
+
if (mismatchReason) {
|
|
300
|
+
return {
|
|
301
|
+
content: [{ type: "text", text: mismatchReason }],
|
|
302
|
+
details: { error: "fact-label-question-mismatch", factLabel: params.factLabel },
|
|
303
|
+
};
|
|
304
|
+
}
|
|
294
305
|
const askBlockReason = questionAskBlockReason(run, { factLabel: params.factLabel });
|
|
295
306
|
if (askBlockReason) {
|
|
296
307
|
return {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { dirname, join, extname } from "node:path";
|
|
2
2
|
import { fileURLToPath } from "node:url";
|
|
3
3
|
import { readFileSync, readFile as fsReadFile } from "node:fs";
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
4
5
|
import { Type } from "@earendil-works/pi-ai";
|
|
5
6
|
import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
6
7
|
import { formatSearchResults, formatTableSchema } from "../src/knowledge/format.ts";
|
|
@@ -25,6 +26,7 @@ import { resolveWorkspacePath } from "../src/platform/path.ts";
|
|
|
25
26
|
import { advanceRunIfReady, readActiveRun } from "../src/harness/state.ts";
|
|
26
27
|
import { writeEvidenceFile } from "../src/harness/evidence.ts";
|
|
27
28
|
import { SDK_SIGNATURE_EVIDENCE } from "../src/harness/sdk-policy.ts";
|
|
29
|
+
import { findFiles, formatFileSearchResults, listDirectory } from "../src/tools/file-search.ts";
|
|
28
30
|
|
|
29
31
|
const extensionDir = dirname(fileURLToPath(import.meta.url));
|
|
30
32
|
const knowledgePath = join(extensionDir, "..", "knowledge");
|
|
@@ -479,6 +481,58 @@ const kdDocReadTool = defineTool({
|
|
|
479
481
|
},
|
|
480
482
|
});
|
|
481
483
|
|
|
484
|
+
const kdFindFileTool = defineTool({
|
|
485
|
+
name: "kd_find_file",
|
|
486
|
+
label: "KD 文件查找",
|
|
487
|
+
description: "在 Windows/PowerShell 环境下按文件名递归查找文件,不依赖 Pi 内置 bash。查找源码文件时优先使用此工具。",
|
|
488
|
+
parameters: Type.Object({
|
|
489
|
+
name: Type.String({ description: "文件名或通配符,例如 MesApiClient.cs 或 *.cs。" }),
|
|
490
|
+
root: Type.Optional(Type.String({ description: "搜索根目录;支持项目相对路径或 Windows 绝对路径。默认当前项目根。" })),
|
|
491
|
+
maxResults: Type.Optional(Type.Number({ description: "最大结果数,默认 50,最多 500。" })),
|
|
492
|
+
}),
|
|
493
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
494
|
+
try {
|
|
495
|
+
const results = findFiles(ctx.cwd, { root: params.root, name: params.name, maxResults: params.maxResults });
|
|
496
|
+
return { content: [{ type: "text", text: formatFileSearchResults(results) }], details: { count: results.length, root: params.root ?? ".", name: params.name } };
|
|
497
|
+
} catch (error) {
|
|
498
|
+
return { content: [{ type: "text", text: error instanceof Error ? error.message : String(error) }], details: { error: "find-file-failed" } };
|
|
499
|
+
}
|
|
500
|
+
},
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
const kdListDirTool = defineTool({
|
|
504
|
+
name: "kd_list_dir",
|
|
505
|
+
label: "KD 目录列表",
|
|
506
|
+
description: "列出目录内容,不读取目录为文件,不依赖 Pi 内置 bash。探索项目结构时优先使用此工具。",
|
|
507
|
+
parameters: Type.Object({
|
|
508
|
+
path: Type.Optional(Type.String({ description: "目录路径;支持项目相对路径或 Windows 绝对路径。默认当前项目根。" })),
|
|
509
|
+
maxEntries: Type.Optional(Type.Number({ description: "最大返回条目数,默认 50,最多 500。" })),
|
|
510
|
+
}),
|
|
511
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
512
|
+
try {
|
|
513
|
+
const results = listDirectory(ctx.cwd, { path: params.path, maxEntries: params.maxEntries });
|
|
514
|
+
return { content: [{ type: "text", text: formatFileSearchResults(results) }], details: { count: results.length, path: params.path ?? "." } };
|
|
515
|
+
} catch (error) {
|
|
516
|
+
return { content: [{ type: "text", text: error instanceof Error ? error.message : String(error) }], details: { error: "list-dir-failed" } };
|
|
517
|
+
}
|
|
518
|
+
},
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
const windowsPowerShellBashTool = defineTool({
|
|
522
|
+
name: "bash",
|
|
523
|
+
label: "PowerShell",
|
|
524
|
+
description:
|
|
525
|
+
"Windows KCode shell override. Execute a PowerShell command in the current working directory. Do not use Linux/bash syntax; use PowerShell commands such as Get-ChildItem, Select-String, Get-Content, and dotnet.",
|
|
526
|
+
promptSnippet: "Execute Windows PowerShell commands. Use PowerShell syntax, not bash syntax.",
|
|
527
|
+
parameters: Type.Object({
|
|
528
|
+
command: Type.String({ description: "PowerShell command to execute. Use PowerShell syntax, not bash syntax." }),
|
|
529
|
+
timeout: Type.Optional(Type.Number({ description: "Timeout in seconds." })),
|
|
530
|
+
}),
|
|
531
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
532
|
+
return runPowerShell(params.command, ctx.cwd, params.timeout, signal);
|
|
533
|
+
},
|
|
534
|
+
});
|
|
535
|
+
|
|
482
536
|
async function extractPdf(filePath: string, maxPages: number): Promise<string> {
|
|
483
537
|
const { PDFParse } = await import("pdf-parse");
|
|
484
538
|
const buffer = readFileSync(filePath);
|
|
@@ -558,6 +612,7 @@ function extractCsv(filePath: string, maxRows: number): string {
|
|
|
558
612
|
}
|
|
559
613
|
|
|
560
614
|
export default function (pi: ExtensionAPI) {
|
|
615
|
+
if (process.platform === "win32") pi.registerTool(windowsPowerShellBashTool);
|
|
561
616
|
pi.registerTool(kdSearchTool);
|
|
562
617
|
pi.registerTool(kdTableTool);
|
|
563
618
|
pi.registerTool(kdCheckTool);
|
|
@@ -569,6 +624,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
569
624
|
pi.registerTool(kdBuildTool);
|
|
570
625
|
pi.registerTool(kdDebugTool);
|
|
571
626
|
pi.registerTool(kdDocReadTool);
|
|
627
|
+
pi.registerTool(kdFindFileTool);
|
|
628
|
+
pi.registerTool(kdListDirTool);
|
|
572
629
|
}
|
|
573
630
|
|
|
574
631
|
function writeSdkSignatureEvidence(cwd: string, content: string): string | undefined {
|
|
@@ -593,3 +650,47 @@ function writeSdkSignatureEvidence(cwd: string, content: string): string | undef
|
|
|
593
650
|
{ kind: "sdk-signature", command: "kd_sdk_signature", exitCode: 0 },
|
|
594
651
|
);
|
|
595
652
|
}
|
|
653
|
+
|
|
654
|
+
function runPowerShell(command: string, cwd: string, timeout: number | undefined, signal: AbortSignal | undefined): Promise<{ content: Array<{ type: "text"; text: string }>; details: Record<string, unknown> }> {
|
|
655
|
+
return new Promise((resolve) => {
|
|
656
|
+
const shell = process.env.ComSpec?.replace(/cmd\.exe$/i, "WindowsPowerShell\\v1.0\\powershell.exe") || "powershell.exe";
|
|
657
|
+
const child = spawn(shell, ["-NoLogo", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", command], {
|
|
658
|
+
cwd,
|
|
659
|
+
windowsHide: true,
|
|
660
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
661
|
+
});
|
|
662
|
+
let stdout = "";
|
|
663
|
+
let stderr = "";
|
|
664
|
+
let timedOut = false;
|
|
665
|
+
const timeoutHandle =
|
|
666
|
+
timeout && timeout > 0
|
|
667
|
+
? setTimeout(() => {
|
|
668
|
+
timedOut = true;
|
|
669
|
+
child.kill();
|
|
670
|
+
}, timeout * 1000)
|
|
671
|
+
: undefined;
|
|
672
|
+
const onAbort = () => child.kill();
|
|
673
|
+
if (signal) signal.addEventListener("abort", onAbort, { once: true });
|
|
674
|
+
child.stdout?.on("data", (data) => {
|
|
675
|
+
stdout += data.toString();
|
|
676
|
+
});
|
|
677
|
+
child.stderr?.on("data", (data) => {
|
|
678
|
+
stderr += data.toString();
|
|
679
|
+
});
|
|
680
|
+
child.on("error", (error) => {
|
|
681
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
682
|
+
if (signal) signal.removeEventListener("abort", onAbort);
|
|
683
|
+
resolve({ content: [{ type: "text", text: `PowerShell 启动失败:${error.message}` }], details: { shell: "powershell", error: "spawn-failed" } });
|
|
684
|
+
});
|
|
685
|
+
child.on("close", (exitCode) => {
|
|
686
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
687
|
+
if (signal) signal.removeEventListener("abort", onAbort);
|
|
688
|
+
const output = [stdout.trimEnd(), stderr.trimEnd()].filter(Boolean).join("\n");
|
|
689
|
+
const suffix = timedOut ? `\n\n[PowerShell timeout after ${timeout}s]` : "";
|
|
690
|
+
resolve({
|
|
691
|
+
content: [{ type: "text", text: `${output || "(no output)"}${suffix}` }],
|
|
692
|
+
details: { shell: "powershell", exitCode, timedOut, command },
|
|
693
|
+
});
|
|
694
|
+
});
|
|
695
|
+
});
|
|
696
|
+
}
|
package/package.json
CHANGED
package/src/cli/kcode.ts
CHANGED
|
@@ -12,6 +12,7 @@ export interface KcodeCliResult {
|
|
|
12
12
|
|
|
13
13
|
interface PiSettings {
|
|
14
14
|
packages?: string[];
|
|
15
|
+
shellPath?: string;
|
|
15
16
|
[key: string]: unknown;
|
|
16
17
|
}
|
|
17
18
|
|
|
@@ -100,6 +101,7 @@ export function doctor(cwd: string, args: string[] = []): KcodeCliResult {
|
|
|
100
101
|
lines.push(`KCode package:${packageRoot}`);
|
|
101
102
|
lines.push(`项目配置:${existsSync(settingsPath) ? settingsPath : "未创建,运行 kcode init"}`);
|
|
102
103
|
lines.push(`项目上下文:${existsSync(projectContextPath) ? projectContextPath : "未创建,运行 kcode context"}`);
|
|
104
|
+
lines.push(`Windows bash:${process.platform === "win32" ? formatWindowsBashStatus(readSettingsSafe(settingsPath)) : "不适用"}`);
|
|
103
105
|
|
|
104
106
|
if (existsSync(settingsPath)) {
|
|
105
107
|
const settingsResult = readSettingsSafe(settingsPath);
|
|
@@ -135,6 +137,10 @@ export function doctor(cwd: string, args: string[] = []): KcodeCliResult {
|
|
|
135
137
|
warnings++;
|
|
136
138
|
lines.push("[WARN] 项目上下文不存在,运行 kcode context --refresh 或 kcode repair。");
|
|
137
139
|
}
|
|
140
|
+
if (process.platform === "win32" && !findWindowsBash(readSettingsSafe(settingsPath))) {
|
|
141
|
+
warnings++;
|
|
142
|
+
lines.push("[WARN] Pi 内置 bash 在当前 Windows 环境不可用;KCode 已覆盖 bash 为 PowerShell,并提供 kd_find_file/kd_list_dir。安装 Git for Windows 可恢复 Pi 原生 bash。");
|
|
143
|
+
}
|
|
138
144
|
const npmPrefix = spawnSync("npm", ["prefix", "-g"], { encoding: "utf8" });
|
|
139
145
|
const npmRoot = spawnSync("npm", ["root", "-g"], { encoding: "utf8" });
|
|
140
146
|
lines.push(`npm prefix -g:${npmPrefix.status === 0 ? npmPrefix.stdout.trim() : "不可用"}`);
|
|
@@ -351,3 +357,24 @@ function helpText(): string {
|
|
|
351
357
|
" kcode start 初始化项目配置后启动 KCode 工作环境",
|
|
352
358
|
].join("\n");
|
|
353
359
|
}
|
|
360
|
+
|
|
361
|
+
function formatWindowsBashStatus(settingsResult: ReturnType<typeof readSettingsSafe>): string {
|
|
362
|
+
const bash = findWindowsBash(settingsResult);
|
|
363
|
+
return bash ? `可用(${bash})` : "不可用;KCode 将使用 PowerShell 覆盖工具和 kd_find_file/kd_list_dir";
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function findWindowsBash(settingsResult?: ReturnType<typeof readSettingsSafe>): string | undefined {
|
|
367
|
+
const configured = settingsResult?.ok && typeof settingsResult.settings.shellPath === "string" ? settingsResult.settings.shellPath : undefined;
|
|
368
|
+
if (configured && existsSync(configured)) return configured;
|
|
369
|
+
const candidates = [
|
|
370
|
+
process.env.ProgramFiles ? join(process.env.ProgramFiles, "Git", "bin", "bash.exe") : undefined,
|
|
371
|
+
process.env["ProgramFiles(x86)"] ? join(process.env["ProgramFiles(x86)"]!, "Git", "bin", "bash.exe") : undefined,
|
|
372
|
+
...pathEntries().map((entry) => join(entry, "bash.exe")),
|
|
373
|
+
].filter((item): item is string => Boolean(item));
|
|
374
|
+
return candidates.find((candidate) => existsSync(candidate));
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function pathEntries(): string[] {
|
|
378
|
+
const pathKey = Object.keys(process.env).find((key) => key.toLowerCase() === "path") ?? "PATH";
|
|
379
|
+
return (process.env[pathKey] ?? "").split(";").filter(Boolean);
|
|
380
|
+
}
|
|
@@ -22,6 +22,7 @@ export interface ParsedDelegationArgs extends DelegationRequest {
|
|
|
22
22
|
const ROLE_GUIDANCE: Record<DelegationRole, string[]> = {
|
|
23
23
|
research: [
|
|
24
24
|
"只读调研项目、文档、SDK 线索和现有实现。",
|
|
25
|
+
"必须基于真实读取或搜索结果定位文件;禁止猜测路径。",
|
|
25
26
|
"输出压缩结论、证据文件/代码位置、风险和下一步指令。",
|
|
26
27
|
"禁止修改文件;禁止推进 Harness 状态。",
|
|
27
28
|
],
|
|
@@ -180,6 +181,7 @@ export function delegationGuidanceForWorkflow(): string {
|
|
|
180
181
|
"- 任务包含大量调研、独立交叉审查、长上下文复盘或可并行拆分内容时,使用 kd_subagent。",
|
|
181
182
|
"- 自动委派只做旁路工作;主 agent 仍负责采纳结论、修改文件、记录 evidence 和推进阶段。",
|
|
182
183
|
"- code 委派只能在 execute 阶段且限于 PLAN.md 批准文件;review/research 默认只读。",
|
|
184
|
+
"- kd_subagent 不是 shell/read/grep 失败的替代方案;基础文件搜索失败时,主 agent 必须报告阻塞原因或改用当前环境可用工具。",
|
|
183
185
|
].join("\n");
|
|
184
186
|
}
|
|
185
187
|
|
|
@@ -221,6 +223,7 @@ export function buildDelegationPrompt(cwd: string, run: ActiveRun | undefined, r
|
|
|
221
223
|
...roleGuidance.map((item) => `- ${item}`),
|
|
222
224
|
"- 子 agent 不是主状态机;禁止调用 /kd-advance、/kd-finish 或改变 run 生命周期。",
|
|
223
225
|
"- 禁止创建子 agent;输出结果交回主 agent 统一决策。",
|
|
226
|
+
"- 禁止假设 bash、Linux 路径或猜测文件位置;Windows 项目按 PowerShell/Windows 路径语义描述命令。",
|
|
224
227
|
"",
|
|
225
228
|
"Harness 状态:",
|
|
226
229
|
status,
|
|
@@ -156,6 +156,9 @@ export const PROJECT_PERSISTENT_RULES = [
|
|
|
156
156
|
"用户回答 open question 后,先用 `kd_question action=answer` 写入答案;用户更正事实时用 `kd_question action=revise`,禁止重复询问已确认事实。",
|
|
157
157
|
"企业版 Python 插件通常没有本地构建可替代验证;BOS 注册、外部系统操作、人工功能测试和生产环境验证必须由用户提供可核验证据并记录来源。",
|
|
158
158
|
"提示语集中管理为正式工程指令;禁止口语化、闲聊式、鼓励式提示词进入运行时规则。",
|
|
159
|
+
"工具使用必须匹配当前会话实际可用工具和操作系统;Windows 默认使用 PowerShell、`rg`、`Get-ChildItem`、`Get-Content`,禁止假设 bash 可用。",
|
|
160
|
+
"Windows 查找文件和目录时优先使用 `kd_find_file`、`kd_list_dir` 或 PowerShell;禁止用 Linux find/ls 语法反复调用 Pi 内置 bash。",
|
|
161
|
+
"文件定位必须使用真实搜索或目录读取结果;工具不可用时明确说明阻塞原因和需要用户执行的命令,禁止猜测路径、反复自述工具失败或用 kd_subagent 代替基础文件搜索。",
|
|
159
162
|
"不做旧状态迁移兼容或旧问题答案推断;坏状态只过滤,缺失事实必须重新提问确认。",
|
|
160
163
|
];
|
|
161
164
|
|
|
@@ -181,6 +184,7 @@ export const CORE_WORKFLOW_CONSTRAINTS = [
|
|
|
181
184
|
"evidence 必须记录命令、Exit 和关键输出;命令无法运行时记录阻塞原因。",
|
|
182
185
|
"外部系统操作、BOS 注册、人工功能测试和生产环境验证不能由 LLM 代办;必须要求用户提供验证结果或可核验证据,并记录证据来源。",
|
|
183
186
|
"Windows 路径规则:项目相对路径为默认;绝对路径使用 D:\\... 形式。",
|
|
187
|
+
"工具规则:按当前会话实际 shell 和工具执行;Windows 不假设 bash,优先用 kd_find_file/kd_list_dir/PowerShell;禁止猜路径或用 kd_subagent 代替基础搜索失败。",
|
|
184
188
|
];
|
|
185
189
|
|
|
186
190
|
export const PHASE_GUIDANCE: Record<KdPhase, string> = {
|
|
@@ -211,6 +215,25 @@ export function fieldLabels(fields: ContractField[]): string[] {
|
|
|
211
215
|
return fields.map((field) => field.label);
|
|
212
216
|
}
|
|
213
217
|
|
|
218
|
+
export function allFactLabels(): string[] {
|
|
219
|
+
return fieldLabels([...IMPLEMENTATION_CONTRACT_FIELDS, ...DATA_SOURCE_CONTEXT_FIELDS, ...INTEGRATION_CONTEXT_FIELDS]);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function formatFactLabelCatalog(): string {
|
|
223
|
+
return [
|
|
224
|
+
"## 实现契约事实标签",
|
|
225
|
+
...formatPromptLines(fieldLabels(IMPLEMENTATION_CONTRACT_FIELDS)),
|
|
226
|
+
"",
|
|
227
|
+
"## 数据源上下文事实标签",
|
|
228
|
+
...formatPromptLines(fieldLabels(DATA_SOURCE_CONTEXT_FIELDS)),
|
|
229
|
+
"",
|
|
230
|
+
"## 第三方对接事实标签",
|
|
231
|
+
...formatPromptLines(fieldLabels(INTEGRATION_CONTEXT_FIELDS)),
|
|
232
|
+
"",
|
|
233
|
+
"产品类型/产品画像不是 factLabel;使用 /kd-product <产品> 或先登记无 factLabel 的产品确认问题。",
|
|
234
|
+
].join("\n");
|
|
235
|
+
}
|
|
236
|
+
|
|
214
237
|
export function questionForMissingLabel(label: string): string | undefined {
|
|
215
238
|
const fields = [...IMPLEMENTATION_CONTRACT_FIELDS, ...DATA_SOURCE_CONTEXT_FIELDS, ...INTEGRATION_CONTEXT_FIELDS];
|
|
216
239
|
return fields.find((field) => field.label === label)?.question;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ActiveRun, KdFact, KdQuestion } from "./types.ts";
|
|
2
|
-
import { canonicalFactLabel } from "./prompt-policy.ts";
|
|
2
|
+
import { allFactLabels, canonicalFactLabel, formatFactLabelCatalog } from "./prompt-policy.ts";
|
|
3
3
|
|
|
4
4
|
export function formatQuestionMemory(run: ActiveRun): string {
|
|
5
5
|
const questions = Array.isArray(run.questions) ? run.questions : [];
|
|
@@ -98,7 +98,7 @@ export function questionAskBlockReason(run: ActiveRun, input: { factLabel?: stri
|
|
|
98
98
|
const rawLabel = input.factLabel?.trim();
|
|
99
99
|
if (!rawLabel) return undefined;
|
|
100
100
|
const label = canonicalFactLabel(rawLabel);
|
|
101
|
-
if (!label) return `未知 factLabel:${rawLabel}。必须使用 KCode
|
|
101
|
+
if (!label) return [`未知 factLabel:${rawLabel}。必须使用 KCode 集中定义的实现契约、数据源上下文或第三方对接事实标签。`, "", "执行 `kd_question action=labels` 查看完整合法标签。", "", formatFactLabelCatalog()].join("\n");
|
|
102
102
|
const key = factKey(label);
|
|
103
103
|
const open = (Array.isArray(run.questions) ? run.questions : []).find(
|
|
104
104
|
(question) => question.status === "open" && question.blocking && question.factLabel && factKey(question.factLabel) === key,
|
|
@@ -113,6 +113,26 @@ export function questionAskBlockReason(run: ActiveRun, input: { factLabel?: stri
|
|
|
113
113
|
return undefined;
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
export function questionFactLabelMismatchReason(input: { question: string; factLabel?: string }): string | undefined {
|
|
117
|
+
const question = input.question.trim();
|
|
118
|
+
const label = input.factLabel?.trim() ? canonicalFactLabel(input.factLabel) : undefined;
|
|
119
|
+
if (!label) return undefined;
|
|
120
|
+
const asksProduct = /产品类型|产品画像|金蝶产品|技术栈|苍穹|星瀚|星空旗舰版|旗舰版|企业版|Cloud\s*BOS|Cosmic|BOS/i.test(question);
|
|
121
|
+
const asksOtherFact = allFactLabels().some((candidate) => candidate !== label && labelAppearsInQuestion(question, candidate));
|
|
122
|
+
const conjunction = /以及|和|并|同时|还有|及|\/|、|,|,/.test(question);
|
|
123
|
+
if (asksProduct && (asksOtherFact || conjunction)) {
|
|
124
|
+
return [
|
|
125
|
+
`问题文本同时要求确认产品类型和 ${label},但 factLabel 只能记录一个结构化事实。`,
|
|
126
|
+
"产品类型/产品画像不是 factLabel;先使用 /kd-product <产品>,或登记一个无 factLabel 的产品确认问题。",
|
|
127
|
+
`确认产品后,再单独登记 ${label}。`,
|
|
128
|
+
].join("\n");
|
|
129
|
+
}
|
|
130
|
+
if (asksOtherFact && conjunction) {
|
|
131
|
+
return `问题文本包含多个结构化事实,但 factLabel 只能是一个:${label}。请拆成多个问题,每次只登记当前最阻塞的一个。`;
|
|
132
|
+
}
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
|
|
116
136
|
export function invalidFactValueReason(value: string): string | undefined {
|
|
117
137
|
return isInvalidFactValue(value) ? "事实值不是可核验内容,不能使用待确认、未知、按实际环境、TODO/TBD、单独确认/否定或口头确认语解除门禁。" : undefined;
|
|
118
138
|
}
|
|
@@ -218,3 +238,15 @@ function normalizePersistedFact(fact: KdFact): KdFact[] {
|
|
|
218
238
|
if (!label || !value || invalidFactValueReason(value)) return [];
|
|
219
239
|
return [{ ...fact, key: factKey(label), label, value }];
|
|
220
240
|
}
|
|
241
|
+
|
|
242
|
+
function labelAppearsInQuestion(question: string, label: string): boolean {
|
|
243
|
+
const normalizedQuestion = question.toLowerCase().replace(/\s+/g, "");
|
|
244
|
+
const normalizedLabel = label.toLowerCase().replace(/\s+/g, "");
|
|
245
|
+
if (normalizedQuestion.includes(normalizedLabel)) return true;
|
|
246
|
+
if (label === "目标 FormId/单据或表单标识") return /formid|formid|单据标识|表单标识|目标单据|目标表单/i.test(question);
|
|
247
|
+
if (label === "插件类型和触发事件") return /插件类型|触发事件|生命周期|挂载点|审核事件|保存事件/i.test(question);
|
|
248
|
+
if (label === "字段/实体/分录标识") return /字段标识|实体标识|分录标识|字段|实体|单据体/i.test(question);
|
|
249
|
+
if (label === "数据读取写入方式") return /读取方式|写入方式|读写|取数|数据访问|SQL|KSQL/i.test(question);
|
|
250
|
+
if (label === "SQL/KSQL 表名和数据库字段名") return /表名|数据库字段名|SQL字段|KSQL字段/i.test(question);
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { basename, join, relative } from "node:path";
|
|
3
|
+
import { resolveWorkspacePath } from "../platform/path.ts";
|
|
4
|
+
|
|
5
|
+
const IGNORED_DIRS = new Set([".git", ".idea", ".pi", ".tmp", ".vscode", "bin", "build", "dist", "node_modules", "obj", "out", "target"]);
|
|
6
|
+
const DEFAULT_MAX_RESULTS = 50;
|
|
7
|
+
const MAX_ENTRIES = 50000;
|
|
8
|
+
|
|
9
|
+
export interface FileSearchResult {
|
|
10
|
+
path: string;
|
|
11
|
+
type: "file" | "directory";
|
|
12
|
+
size?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function findFiles(cwd: string, input: { root?: string; name: string; maxResults?: number }): FileSearchResult[] {
|
|
16
|
+
const root = resolveWorkspacePath(cwd, input.root || ".");
|
|
17
|
+
const pattern = input.name.trim();
|
|
18
|
+
if (!pattern) throw new Error("kd_find_file requires a non-empty name.");
|
|
19
|
+
if (!existsSync(root) || !statSync(root).isDirectory()) throw new Error(`Search root is not a directory: ${root}`);
|
|
20
|
+
|
|
21
|
+
const maxResults = clampLimit(input.maxResults);
|
|
22
|
+
const matcher = wildcardMatcher(pattern);
|
|
23
|
+
const results: FileSearchResult[] = [];
|
|
24
|
+
const queue = [root];
|
|
25
|
+
let visited = 0;
|
|
26
|
+
|
|
27
|
+
while (queue.length > 0 && results.length < maxResults && visited < MAX_ENTRIES) {
|
|
28
|
+
const current = queue.shift();
|
|
29
|
+
if (!current) break;
|
|
30
|
+
|
|
31
|
+
let children;
|
|
32
|
+
try {
|
|
33
|
+
children = readdirSync(current, { withFileTypes: true });
|
|
34
|
+
} catch {
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
for (const child of children) {
|
|
39
|
+
if (results.length >= maxResults || visited >= MAX_ENTRIES) break;
|
|
40
|
+
const fullPath = join(current, child.name);
|
|
41
|
+
visited++;
|
|
42
|
+
|
|
43
|
+
if (child.isDirectory()) {
|
|
44
|
+
if (!IGNORED_DIRS.has(child.name)) queue.push(fullPath);
|
|
45
|
+
if (matcher(child.name)) results.push({ path: displayPath(cwd, fullPath), type: "directory" });
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (child.isFile() && matcher(child.name)) {
|
|
50
|
+
let size: number | undefined;
|
|
51
|
+
try {
|
|
52
|
+
size = statSync(fullPath).size;
|
|
53
|
+
} catch {
|
|
54
|
+
size = undefined;
|
|
55
|
+
}
|
|
56
|
+
results.push({ path: displayPath(cwd, fullPath), type: "file", size });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return results;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function listDirectory(cwd: string, input: { path?: string; maxEntries?: number }): FileSearchResult[] {
|
|
65
|
+
const dir = resolveWorkspacePath(cwd, input.path || ".");
|
|
66
|
+
if (!existsSync(dir) || !statSync(dir).isDirectory()) throw new Error(`Path is not a directory: ${dir}`);
|
|
67
|
+
const maxEntries = clampLimit(input.maxEntries);
|
|
68
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
69
|
+
.slice(0, maxEntries)
|
|
70
|
+
.map((entry) => {
|
|
71
|
+
const fullPath = join(dir, entry.name);
|
|
72
|
+
if (entry.isDirectory()) return { path: displayPath(cwd, fullPath), type: "directory" as const };
|
|
73
|
+
let size: number | undefined;
|
|
74
|
+
try {
|
|
75
|
+
size = statSync(fullPath).size;
|
|
76
|
+
} catch {
|
|
77
|
+
size = undefined;
|
|
78
|
+
}
|
|
79
|
+
return { path: displayPath(cwd, fullPath), type: "file" as const, size };
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function formatFileSearchResults(results: FileSearchResult[]): string {
|
|
84
|
+
if (results.length === 0) return "未找到匹配项。";
|
|
85
|
+
return results.map((item) => `- ${item.type === "directory" ? "[dir]" : "[file]"} ${item.path}${item.size === undefined ? "" : ` (${item.size} bytes)`}`).join("\n");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function clampLimit(value: number | undefined): number {
|
|
89
|
+
return Math.max(1, Math.min(Math.trunc(value ?? DEFAULT_MAX_RESULTS), 500));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function displayPath(cwd: string, fullPath: string): string {
|
|
93
|
+
const rel = relative(cwd, fullPath);
|
|
94
|
+
if (rel && !rel.startsWith("..") && !/^[a-zA-Z]:/.test(rel)) return rel.replace(/\//g, "\\");
|
|
95
|
+
return fullPath;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function wildcardMatcher(pattern: string): (name: string) => boolean {
|
|
99
|
+
const normalized = pattern.trim();
|
|
100
|
+
if (!/[*?]/.test(normalized)) {
|
|
101
|
+
const target = normalized.toLowerCase();
|
|
102
|
+
return (name) => basename(name).toLowerCase() === target;
|
|
103
|
+
}
|
|
104
|
+
const escaped = normalized.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
|
|
105
|
+
const regex = new RegExp(`^${escaped}$`, "i");
|
|
106
|
+
return (name) => regex.test(basename(name));
|
|
107
|
+
}
|