kcode-pi 0.1.30 → 0.1.34

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.
@@ -365,6 +365,16 @@ export default function (pi: ExtensionAPI) {
365
365
  return { block: true, reason };
366
366
  }
367
367
 
368
+ // Redirect read tool to kd_doc_read for office document formats
369
+ if (event.toolName === "read" && path) {
370
+ const ext = path.toLowerCase().split(".").pop();
371
+ if (ext === "pdf" || ext === "docx" || ext === "doc" || ext === "xlsx" || ext === "xls" || ext === "csv") {
372
+ const reason = `read 工具无法解析 .${ext} 二进制格式。下一步:改用 kd_doc_read 工具读取此文件,参数 path="${path}"。`;
373
+ if (ctx.hasUI) ctx.ui.notify(reason, "info");
374
+ return { block: true, reason };
375
+ }
376
+ }
377
+
368
378
  const subagentRole = isSubagentChild() ? subagentRoleFromEnv() : undefined;
369
379
  if (subagentRole) {
370
380
  const run = readActiveRun(ctx.cwd);
@@ -4,6 +4,96 @@ import { readActiveRun } from "../src/harness/state.ts";
4
4
  import type { ActiveRun, GateResult } from "../src/harness/types.ts";
5
5
  import { formatProductProfile } from "../src/product/profile.ts";
6
6
 
7
+ /** ANSI escape sequence pattern: CSI, OSC, APC. */
8
+ const ANSI_RE = /\x1b\[[0-9;]*[A-Za-z]|\x1b\][^\x07]*\x07|\x1b_[^\x1b]*\x1b\\/g;
9
+
10
+ /** Visible width of a string (strips ANSI codes; CJK/wide chars = 2 columns). */
11
+ function visibleWidth(str: string): number {
12
+ let width = 0;
13
+ const clean = str.replace(ANSI_RE, "");
14
+ for (const ch of clean) {
15
+ const code = ch.codePointAt(0)!;
16
+ width +=
17
+ code >= 0x1100 &&
18
+ !(code >= 0x00a0 && code <= 0x00ff) &&
19
+ ((code >= 0x1100 && code <= 0x115f) ||
20
+ (code >= 0x2329 && code <= 0x232a) ||
21
+ (code >= 0x2e80 && code <= 0x303e) ||
22
+ (code >= 0x3040 && code <= 0x3247) ||
23
+ (code >= 0x3250 && code <= 0x4dbf) ||
24
+ (code >= 0x4e00 && code <= 0xa4c6) ||
25
+ (code >= 0xa960 && code <= 0xa97c) ||
26
+ (code >= 0xac00 && code <= 0xd7a3) ||
27
+ (code >= 0xf900 && code <= 0xfaff) ||
28
+ (code >= 0xfe10 && code <= 0xfe19) ||
29
+ (code >= 0xfe30 && code <= 0xfe6b) ||
30
+ (code >= 0xff01 && code <= 0xff60) ||
31
+ (code >= 0xffe0 && code <= 0xffe6) ||
32
+ (code >= 0x1f300 && code <= 0x1f9ff) ||
33
+ (code >= 0x20000 && code <= 0x2fffd))
34
+ ? 2
35
+ : 1;
36
+ }
37
+ return width;
38
+ }
39
+
40
+ /**
41
+ * If the line's visible width exceeds `maxWidth`, truncate visible characters
42
+ * and append `>` so the result fits. Preserves ANSI codes in the kept portion
43
+ * and appends SGR reset before the `>`. No padding — pi-tui only requires
44
+ * visibleWidth <= width.
45
+ */
46
+ export function clipLine(text: string, maxWidth: number): string {
47
+ if (maxWidth <= 0) return "";
48
+ const vw = visibleWidth(text);
49
+ if (vw <= maxWidth) return text;
50
+
51
+ const targetW = maxWidth - 1; // reserve 1 col for ">"
52
+ let result = "";
53
+ let visibleSoFar = 0;
54
+ let i = 0;
55
+
56
+ while (i < text.length && visibleSoFar < targetW) {
57
+ // Preserve ANSI escape sequences
58
+ if (text[i] === "\x1b") {
59
+ const m = text.slice(i).match(/^\x1b(?:\[[0-9;]*[A-Za-z]|\][^\x07]*\x07|_[^\x1b]*\x1b\\)/);
60
+ if (m) {
61
+ result += m[0];
62
+ i += m[0].length;
63
+ continue;
64
+ }
65
+ }
66
+
67
+ const ch = text[i];
68
+ const code = ch.codePointAt(0)!;
69
+ const wide =
70
+ (code >= 0x1100 && code <= 0x115f) ||
71
+ (code >= 0x2329 && code <= 0x232a) ||
72
+ (code >= 0x2e80 && code <= 0x303e) ||
73
+ (code >= 0x3040 && code <= 0x3247) ||
74
+ (code >= 0x3250 && code <= 0x4dbf) ||
75
+ (code >= 0x4e00 && code <= 0xa4c6) ||
76
+ (code >= 0xa960 && code <= 0xa97c) ||
77
+ (code >= 0xac00 && code <= 0xd7a3) ||
78
+ (code >= 0xf900 && code <= 0xfaff) ||
79
+ (code >= 0xfe10 && code <= 0xfe19) ||
80
+ (code >= 0xfe30 && code <= 0xfe6b) ||
81
+ (code >= 0xff01 && code <= 0xff60) ||
82
+ (code >= 0xffe0 && code <= 0xffe6) ||
83
+ (code >= 0x1f300 && code <= 0x1f9ff) ||
84
+ (code >= 0x20000 && code <= 0x2fffd);
85
+ const charW = wide ? 2 : 1;
86
+
87
+ if (visibleSoFar + charW > targetW) break;
88
+
89
+ result += ch;
90
+ visibleSoFar += charW;
91
+ i++;
92
+ }
93
+
94
+ return result + "\x1b[0m>";
95
+ }
96
+
7
97
  function formatProduct(run: ActiveRun | undefined): string {
8
98
  if (!run) return "未选择";
9
99
  if (run.profile?.product === "unknown") return "未确认";
@@ -43,12 +133,6 @@ function riskColor(risk: string): "error" | "warning" | "muted" | "success" {
43
133
  return "success";
44
134
  }
45
135
 
46
- function padOrTrim(text: string, width: number): string {
47
- if (width <= 0) return "";
48
- if (text.length > width) return text.slice(0, Math.max(0, width - 1)) + ">";
49
- return text + " ".repeat(width - text.length);
50
- }
51
-
52
136
  function logoLines(theme: Theme): string[] {
53
137
  const accent = (text: string) => theme.fg("accent", text);
54
138
  const muted = (text: string) => theme.fg("muted", text);
@@ -87,9 +171,9 @@ export default function (pi: ExtensionAPI) {
87
171
 
88
172
  return [
89
173
  "",
90
- ...logoLines(theme).map((line) => padOrTrim(line, width)),
91
- padOrTrim(status, width),
92
- padOrTrim(theme.fg("dim", `run:${runId}`), width),
174
+ ...logoLines(theme).map((line) => clipLine(line, width)),
175
+ clipLine(status, width),
176
+ clipLine(theme.fg("dim", `run:${runId}`), width),
93
177
  "",
94
178
  ];
95
179
  },
@@ -1,6 +1,6 @@
1
- import { dirname, join } from "node:path";
1
+ import { dirname, join, extname } from "node:path";
2
2
  import { fileURLToPath } from "node:url";
3
- import { readFileSync } from "node:fs";
3
+ import { readFileSync, readFile as fsReadFile } from "node:fs";
4
4
  import { Type } from "@earendil-works/pi-ai";
5
5
  import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
6
  import { formatSearchResults, formatTableSchema } from "../src/knowledge/format.ts";
@@ -430,6 +430,133 @@ const kdDebugTool = defineTool({
430
430
  },
431
431
  });
432
432
 
433
+ const kdDocReadTool = defineTool({
434
+ name: "kd_doc_read",
435
+ label: "KD 文档读取",
436
+ description:
437
+ "读取 PDF、Word (.docx/.doc)、Excel (.xlsx/.xls) 或 CSV 文件并提取文本内容。对于 .pdf、.docx、.doc、.xlsx、.xls、.csv 文件,必须使用此工具而非 read 工具,因为 read 无法解析这些二进制格式。PDF 目前只支持可复制文本抽取;扫描型 PDF 需要另行提供图片或 OCR 结果。",
438
+ parameters: Type.Object({
439
+ path: Type.String({ description: "文档文件路径,支持 .pdf、.docx、.doc、.xlsx、.xls、.csv。" }),
440
+ sheet: Type.Optional(Type.String({ description: "Excel 工作表名或序号(从 1 开始)。默认读取第一个工作表。" })),
441
+ maxRows: Type.Optional(Type.Number({ description: "Excel/CSV 最大返回行数。默认 200。" })),
442
+ maxPages: Type.Optional(Type.Number({ description: "PDF 最大提取页数。默认 50。" })),
443
+ }),
444
+
445
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
446
+ const filePath = resolveWorkspacePath(ctx.cwd, params.path);
447
+ const ext = extname(filePath).toLowerCase();
448
+
449
+ try {
450
+ let text: string;
451
+
452
+ if (ext === ".pdf") {
453
+ text = await extractPdf(filePath, params.maxPages ?? 50);
454
+ } else if (ext === ".docx") {
455
+ text = await extractDocx(filePath);
456
+ } else if (ext === ".doc") {
457
+ text = await extractDoc(filePath);
458
+ } else if (ext === ".xlsx" || ext === ".xls") {
459
+ text = extractXlsx(filePath, params.sheet, params.maxRows ?? 200);
460
+ } else if (ext === ".csv") {
461
+ text = extractCsv(filePath, params.maxRows ?? 200);
462
+ } else {
463
+ return {
464
+ content: [{ type: "text", text: `不支持的文件格式:${ext}。支持 .pdf、.docx、.doc、.xlsx、.xls、.csv。` }],
465
+ details: { error: "unsupported-format", ext },
466
+ };
467
+ }
468
+
469
+ return {
470
+ content: [{ type: "text", text }],
471
+ details: { path: params.path, format: ext },
472
+ };
473
+ } catch (error) {
474
+ return {
475
+ content: [{ type: "text", text: `读取文档失败:${error instanceof Error ? error.message : String(error)}` }],
476
+ details: { error: "doc-read-failed", path: params.path },
477
+ };
478
+ }
479
+ },
480
+ });
481
+
482
+ async function extractPdf(filePath: string, maxPages: number): Promise<string> {
483
+ const { PDFParse } = await import("pdf-parse");
484
+ const buffer = readFileSync(filePath);
485
+ const parser = new PDFParse({ data: new Uint8Array(buffer) });
486
+ const result = await parser.getText({ last: maxPages });
487
+ const pages = result.total;
488
+ const text = result.text.trim();
489
+ return `[PDF] ${pages} 页\n\n${text}`;
490
+ }
491
+
492
+ async function extractDocx(filePath: string): Promise<string> {
493
+ const mammoth = await import("mammoth");
494
+ const buffer = readFileSync(filePath);
495
+ const result = await mammoth.extractRawText({ buffer });
496
+ return `[Word .docx]\n\n${result.value.trim()}`;
497
+ }
498
+
499
+ async function extractDoc(filePath: string): Promise<string> {
500
+ const word = await import("word");
501
+ const doc = await word.readFile(filePath);
502
+ const text = word.to_text(doc);
503
+ return `[Word .doc]\n\n${text.trim()}`;
504
+ }
505
+
506
+ function extractXlsx(filePath: string, sheetNameOrIndex?: string, maxRows?: number): string {
507
+ const XLSX = require("xlsx");
508
+ const workbook = XLSX.readFile(filePath);
509
+ const sheetNames = workbook.SheetNames;
510
+
511
+ let sheetName: string;
512
+ if (sheetNameOrIndex) {
513
+ const idx = Number(sheetNameOrIndex);
514
+ if (!isNaN(idx) && idx >= 1 && idx <= sheetNames.length) {
515
+ sheetName = sheetNames[idx - 1];
516
+ } else if (sheetNames.includes(sheetNameOrIndex)) {
517
+ sheetName = sheetNameOrIndex;
518
+ } else {
519
+ return `[Excel] 工作表 "${sheetNameOrIndex}" 不存在。可用:${sheetNames.join(", ")}`;
520
+ }
521
+ } else {
522
+ sheetName = sheetNames[0];
523
+ }
524
+
525
+ const sheet = workbook.Sheets[sheetName];
526
+ const rows: Record<string, unknown>[] = XLSX.utils.sheet_to_json(sheet, { defval: "" });
527
+ const limit = Math.min(rows.length, maxRows ?? 200);
528
+
529
+ if (rows.length === 0) return `[Excel] 工作表 "${sheetName}" 为空。`;
530
+
531
+ const headers = Object.keys(rows[0]);
532
+ const lines: string[] = [
533
+ `[Excel] 工作表:${sheetName}(${sheetNames.length} 个:${sheetNames.join(", ")})`,
534
+ `行数:${rows.length}${rows.length > limit ? `(显示前 ${limit} 行)` : ""}`,
535
+ "",
536
+ "| " + headers.join(" | ") + " |",
537
+ "| " + headers.map(() => "---").join(" | ") + " |",
538
+ ];
539
+
540
+ for (let i = 0; i < limit; i++) {
541
+ const row = rows[i];
542
+ lines.push("| " + headers.map((h) => String(row[h] ?? "")).join(" | ") + " |");
543
+ }
544
+
545
+ return lines.join("\n");
546
+ }
547
+
548
+ function extractCsv(filePath: string, maxRows: number): string {
549
+ const XLSX = require("xlsx");
550
+ const workbook = XLSX.readFile(filePath, { raw: true });
551
+ const sheetName = workbook.SheetNames[0];
552
+ const sheet = workbook.Sheets[sheetName];
553
+ const csv = XLSX.utils.sheet_to_csv(sheet);
554
+ const lines = csv.split("\n");
555
+ const limit = Math.min(lines.length, maxRows + 1); // +1 for header
556
+ const truncated = lines.length > limit;
557
+ return `[CSV] ${lines.length - 1} 行${truncated ? `(显示前 ${limit - 1} 行)` : ""}\n\n${lines.slice(0, limit).join("\n")}`;
558
+ }
559
+
433
560
  export default function (pi: ExtensionAPI) {
434
561
  pi.registerTool(kdSearchTool);
435
562
  pi.registerTool(kdTableTool);
@@ -441,6 +568,7 @@ export default function (pi: ExtensionAPI) {
441
568
  pi.registerTool(kdKsqlLintTool);
442
569
  pi.registerTool(kdBuildTool);
443
570
  pi.registerTool(kdDebugTool);
571
+ pi.registerTool(kdDocReadTool);
444
572
  }
445
573
 
446
574
  function writeSdkSignatureEvidence(cwd: string, content: string): string | undefined {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kcode-pi",
3
- "version": "0.1.30",
3
+ "version": "0.1.34",
4
4
  "description": "面向金蝶开发的 Pi Coding Agent 启动器、工具包和 Harness 工作流",
5
5
  "type": "module",
6
6
  "private": false,
@@ -47,7 +47,11 @@
47
47
  },
48
48
  "dependencies": {
49
49
  "@earendil-works/pi-ai": "^0.78.1",
50
- "@earendil-works/pi-coding-agent": "^0.78.1"
50
+ "@earendil-works/pi-coding-agent": "^0.78.1",
51
+ "mammoth": "^1.12.0",
52
+ "pdf-parse": "^2.4.5",
53
+ "word": "^0.4.0",
54
+ "xlsx": "^0.18.5"
51
55
  },
52
56
  "devDependencies": {
53
57
  "@types/node": "^25.9.2",
@@ -16,6 +16,7 @@ import {
16
16
  missingForTargetReason,
17
17
  missingMarkerReason,
18
18
  openQuestionsReason,
19
+ repairBlockedReason,
19
20
  unknownProductReason,
20
21
  unknownRiskReason,
21
22
  } from "./messages.ts";
@@ -71,6 +72,7 @@ function collectGateProblems(cwd: string, run: ActiveRun, phase: KdPhase, mode:
71
72
  const stepProblem = inspectStepState(cwd, run, phase);
72
73
  const evidenceProblem = mode === "inspect" ? inspectEvidence(cwd, run, phase) : undefined;
73
74
  const questionProblem = inspectOpenQuestions(run);
75
+ const repairProblem = inspectRepairState(run);
74
76
 
75
77
  if (flagshipPathProblem) {
76
78
  reasonParts.push(flagshipPathProblem);
@@ -87,6 +89,9 @@ function collectGateProblems(cwd: string, run: ActiveRun, phase: KdPhase, mode:
87
89
  if (questionProblem) {
88
90
  reasonParts.push(questionProblem);
89
91
  }
92
+ if (repairProblem) {
93
+ reasonParts.push(repairProblem);
94
+ }
90
95
 
91
96
  if (mode === "enter") {
92
97
  missing.push(...missingEvidenceForPhase(cwd, run, phase));
@@ -148,6 +153,11 @@ function inspectOpenQuestions(run: ActiveRun): string | undefined {
148
153
  return openQuestionsReason(open);
149
154
  }
150
155
 
156
+ function inspectRepairState(run: ActiveRun): string | undefined {
157
+ if (run.repair?.status !== "blocked") return undefined;
158
+ return repairBlockedReason(run.repair.attempts, run.repair.maxAttempts, run.repair.lastFailureEvidence);
159
+ }
160
+
151
161
  function inspectStepState(cwd: string, run: ActiveRun, phase: KdPhase): string | undefined {
152
162
  if (phase === "execute") {
153
163
  const plan = readArtifact(cwd, run, "plan") ?? "";
@@ -19,6 +19,11 @@ export function openQuestionsReason(questions: Array<{ id: string; question: str
19
19
  ].join("。");
20
20
  }
21
21
 
22
+ export function repairBlockedReason(attempts: number, maxAttempts: number, evidence?: string): string {
23
+ const evidenceText = evidence ? `,最近失败证据:${evidence}` : "";
24
+ return `自动修复已停止:验证失败 ${attempts}/${maxAttempts}${evidenceText}。下一步:回到 plan 调整范围,或重新创建/回答修复问题选择“继续修复”。`;
25
+ }
26
+
22
27
  export function missingMarkerReason(phase: KdPhase, missing: string[]): string {
23
28
  return `${PHASE_ARTIFACTS[phase]} 缺少必需章节:${missing.join(", ")}。下一步:更新 ${PHASE_ARTIFACTS[phase]},补齐这些章节并写入真实内容。`;
24
29
  }
@@ -123,6 +123,7 @@ export function answerQuestion(cwd: string, run: ActiveRun, id: string, answer:
123
123
  question.status = "answered";
124
124
  question.answer = answer.trim();
125
125
  question.answeredAt = new Date().toISOString();
126
+ applyRepairQuestionAnswer(cwd, run, question);
126
127
  run.gate = inspectGate(cwd, run);
127
128
  writeActiveRun(cwd, run);
128
129
  return question;
@@ -216,6 +217,62 @@ function writeRunState(cwd: string, run: ActiveRun): void {
216
217
  writeFileSync(runStatePath(cwd, run), `${JSON.stringify(run, null, 2)}\n`, "utf8");
217
218
  }
218
219
 
220
+ function applyRepairQuestionAnswer(cwd: string, run: ActiveRun, question: KdQuestion): void {
221
+ if (!isRepairLimitQuestion(question)) return;
222
+ const answer = question.answer?.trim() ?? "";
223
+ const now = new Date().toISOString();
224
+ const attempts = run.repair?.attempts ?? 0;
225
+ const maxAttempts = run.repair?.maxAttempts ?? 3;
226
+ const extendedMaxAttempts = attempts + maxAttempts;
227
+
228
+ if (/^继续修复\b|^继续|continue/i.test(answer)) {
229
+ run.phase = "execute";
230
+ ensureArtifact(cwd, run, "execute", defaultArtifactContent("execute", run.goal, run.profile));
231
+ run.repair = {
232
+ attempts,
233
+ maxAttempts: extendedMaxAttempts,
234
+ lastFailureEvidence: run.repair?.lastFailureEvidence,
235
+ lastFailureSignature: run.repair?.lastFailureSignature,
236
+ status: "repairing",
237
+ updatedAt: now,
238
+ };
239
+ return;
240
+ }
241
+
242
+ if (/^回到\s*plan\b|^回到计划|^返回\s*plan\b|^重新计划/i.test(answer)) {
243
+ run.phase = "plan";
244
+ ensureArtifact(cwd, run, "plan", defaultArtifactContent("plan", run.goal, run.profile));
245
+ run.repair = {
246
+ attempts,
247
+ maxAttempts: extendedMaxAttempts,
248
+ lastFailureEvidence: run.repair?.lastFailureEvidence,
249
+ lastFailureSignature: run.repair?.lastFailureSignature,
250
+ status: "idle",
251
+ updatedAt: now,
252
+ };
253
+ return;
254
+ }
255
+
256
+ if (/^停止|^stop\b/i.test(answer)) {
257
+ run.repair = {
258
+ attempts: run.repair?.attempts ?? maxAttempts,
259
+ maxAttempts,
260
+ lastFailureEvidence: run.repair?.lastFailureEvidence,
261
+ lastFailureSignature: run.repair?.lastFailureSignature,
262
+ status: "blocked",
263
+ updatedAt: now,
264
+ };
265
+ }
266
+ }
267
+
268
+ function isRepairLimitQuestion(question: KdQuestion): boolean {
269
+ return (
270
+ question.phase === "verify" &&
271
+ /验证失败已达到/.test(question.question) &&
272
+ (Array.isArray(question.choices) ? question.choices.includes("继续修复") : true)
273
+ );
274
+ }
275
+
219
276
  function hydrateRun(parsed: ActiveRun): ActiveRun | undefined {
220
277
  if (!parsed || typeof parsed !== "object") return undefined;
221
278
  if (typeof parsed.id !== "string" || !parsed.id.trim()) return undefined;