pi-web-ui 0.94.1 → 0.96.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.
Files changed (100) hide show
  1. package/CHANGELOG.md +91 -2
  2. package/README.md +5 -6
  3. package/README.zh-CN.md +4 -5
  4. package/bin/pi-web-ui.mjs +910 -237
  5. package/dist/server/agent-service.js +2655 -231
  6. package/dist/server/approval-rules.js +596 -0
  7. package/dist/server/attachment-store.js +129 -0
  8. package/dist/server/attachments.js +92 -174
  9. package/dist/server/bg-servers.js +45 -7
  10. package/dist/server/claim-files-tool.js +4 -8
  11. package/dist/server/claim-store.js +4 -2
  12. package/dist/server/client-state.js +67 -14
  13. package/dist/server/compact-context-tool.js +126 -0
  14. package/dist/server/composer-drafts.js +9 -0
  15. package/dist/server/context-budget.js +317 -0
  16. package/dist/server/control-socket.js +57 -28
  17. package/dist/server/conversation-read-tool.js +7 -17
  18. package/dist/server/dangling-tools.js +229 -0
  19. package/dist/server/delegate-task.js +25 -28
  20. package/dist/server/dsh/dsh-agent-service.js +69 -66
  21. package/dist/server/edit-soft-tool.js +84 -16
  22. package/dist/server/eval-tool.js +588 -0
  23. package/dist/server/file-archives.js +16 -5
  24. package/dist/server/files-service.js +65 -18
  25. package/dist/server/goal-service.js +371 -77
  26. package/dist/server/hashline-engine.js +703 -0
  27. package/dist/server/host-guard.js +94 -0
  28. package/dist/server/host-metrics.js +26 -2
  29. package/dist/server/i18n.js +3 -3
  30. package/dist/server/index.js +456 -80
  31. package/dist/server/lsp-tool.js +1371 -0
  32. package/dist/server/mcp-bridge.js +47 -3
  33. package/dist/server/model-admin.js +112 -32
  34. package/dist/server/office-parse.js +375 -0
  35. package/dist/server/patch-tool.js +85 -0
  36. package/dist/server/permission-preset.js +25 -0
  37. package/dist/server/plan-manager.js +114 -0
  38. package/dist/server/plugin-api-catalog.js +296 -0
  39. package/dist/server/plugin-catalog-sync.js +36 -19
  40. package/dist/server/plugin-catalog.js +11 -4
  41. package/dist/server/plugin-facilities.js +92 -21
  42. package/dist/server/plugin-install-spec.js +196 -0
  43. package/dist/server/plugin-installer.js +73 -0
  44. package/dist/server/plugin-llm.js +71 -65
  45. package/dist/server/plugin-manifest-validate.js +305 -0
  46. package/dist/server/plugin-project.js +117 -13
  47. package/dist/server/plugin-tool-guard.js +120 -0
  48. package/dist/server/plugin-updater.js +115 -12
  49. package/dist/server/plugins.js +950 -205
  50. package/dist/server/present-files-tool.js +9 -12
  51. package/dist/server/process-utils.js +16 -6
  52. package/dist/server/prompt-composer.js +9 -0
  53. package/dist/server/protocol-version.js +1 -1
  54. package/dist/server/read-tool.js +69 -30
  55. package/dist/server/resolve-global-sdk.js +30 -16
  56. package/dist/server/schedule-agent-tool.js +12 -15
  57. package/dist/server/scheduler-tasks.js +6 -0
  58. package/dist/server/sdk-origin.js +18 -2
  59. package/dist/server/serialize.js +111 -14
  60. package/dist/server/settings-service.js +112 -3
  61. package/dist/server/skill-tool.js +5 -7
  62. package/dist/server/subagent-templates.js +51 -0
  63. package/dist/server/subagents.js +83 -66
  64. package/dist/server/terminals.js +248 -81
  65. package/dist/server/tool-approval.js +84 -0
  66. package/dist/server/tool-manager.js +237 -12
  67. package/dist/server/tool-overrides.js +59 -0
  68. package/dist/server/update-check.js +28 -1
  69. package/dist/server/uploads.js +17 -2
  70. package/dist/server/wait-subscription-scan.js +18 -21
  71. package/dist/server/workspace-snapshot.js +113 -0
  72. package/dist/server/ws-client-id.js +29 -0
  73. package/dist/server/ws-pending-queue.js +60 -0
  74. package/extensions/webui.ts +60 -2
  75. package/package.json +5 -3
  76. package/plugin-sdk/README.md +25 -0
  77. package/plugin-sdk/index.d.ts +31 -38
  78. package/plugin-sdk/index.mjs +35 -19
  79. package/plugins/catalog.json +40 -0
  80. package/themes/aetheris.css +457 -0
  81. package/themes/ayu-light.css +6 -6
  82. package/themes/catppuccin-latte.css +6 -6
  83. package/themes/claude-code-dark.css +144 -0
  84. package/themes/codex.css +6 -6
  85. package/themes/everforest-light.css +6 -6
  86. package/themes/geist.css +6 -6
  87. package/themes/gruvbox-light.css +6 -6
  88. package/themes/kanagawa-lotus.css +6 -6
  89. package/themes/rose-pine-dawn.css +6 -6
  90. package/themes/solarized-light.css +6 -6
  91. package/themes/vs-code-dark.css +146 -0
  92. package/themes/zhupi-dark.css +658 -0
  93. package/themes/zhupi.css +711 -0
  94. package/web/dist/assets/{TerminalPanel-MVoxpJOA.js → TerminalPanel-DDChcYOh.js} +1 -1
  95. package/web/dist/assets/index-Do9RgJC3.js +366 -0
  96. package/web/dist/assets/index-DryOsILO.css +1 -0
  97. package/web/dist/assets/{markdown-eUQn_o9D.js → markdown-DXwnfD9T.js} +1 -1
  98. package/web/dist/index.html +3 -3
  99. package/web/dist/assets/index-B3S9MxnN.css +0 -1
  100. package/web/dist/assets/index-DVLrHI2E.js +0 -364
@@ -0,0 +1,375 @@
1
+ /**
2
+ * Office 文档纯文本提取(docx / xlsx / xlsm,csv 由调用方按普通文本处理)。
3
+ *
4
+ * 给「随处可看」用的:文件预览(files-service readFile)、附件等走 file_content
5
+ * 下发 Markdown 文本,前端 FilePreview 按 Markdown 渲染表格/段落,无需改协议。
6
+ *
7
+ * 零依赖:docx / xlsx 本质是 zip 包,这里用 node:zlib + 手写 zip 中央目录解析
8
+ * 做最小解包(只支持 stored / deflate,Office 写出的文件都是这两种)。
9
+ *
10
+ * 注意:plugins/office-preview/index.mjs 里有一份同逻辑的 JS 拷贝(插件必须
11
+ * 自包含、不能 import 服务端 TS,见架构文档插件章)。两边改解析时请同步另一份。
12
+ */
13
+ import { inflateRawSync } from "node:zlib";
14
+ const OFFICE_EXTS = new Set([".docx", ".xlsx", ".xlsm"]);
15
+ /** 单个文件上限(与 office-preview 插件一致)。 */
16
+ export const OFFICE_MAX_FILE_BYTES = 15 * 1024 * 1024;
17
+ /** 解包后总量上限(防 zip 炸弹:小包解出巨量内容)。 */
18
+ export const OFFICE_MAX_UNCOMPRESSED_BYTES = 64 * 1024 * 1024;
19
+ /** 下发文本上限(字符;超出截断并置 truncated)。 */
20
+ export const OFFICE_MAX_TEXT_CHARS = 200_000;
21
+ /** 表格上限:每表行数 × 列数(预览首屏口径)。 */
22
+ const MAX_ROWS = 500;
23
+ const MAX_COLS = 20;
24
+ export function isOfficeFile(name) {
25
+ const dot = String(name ?? "")
26
+ .toLowerCase()
27
+ .lastIndexOf(".");
28
+ const ext = dot > 0 ? String(name).toLowerCase().slice(dot) : "";
29
+ return OFFICE_EXTS.has(ext);
30
+ }
31
+ function findEocd(buf) {
32
+ const sig = 0x06054b50;
33
+ const minLen = 22;
34
+ if (buf.length < minLen)
35
+ throw new Error("不是有效的 zip 文件(太小)");
36
+ const start = Math.max(0, buf.length - 65536 - minLen);
37
+ for (let i = buf.length - minLen; i >= start; i--) {
38
+ if (buf.readUInt32LE(i) === sig)
39
+ return i;
40
+ }
41
+ throw new Error("不是有效的 zip 文件(找不到 EOCD)");
42
+ }
43
+ /** 返回 Map<文件名, Buffer>(只解要的文件,其余跳过)。 */
44
+ export function unzipFiles(buf, wanted) {
45
+ const want = new Set(wanted);
46
+ const eocd = findEocd(buf);
47
+ const cdCount = buf.readUInt16LE(eocd + 10);
48
+ const cdOffset = buf.readUInt32LE(eocd + 16);
49
+ const files = new Map();
50
+ let p = cdOffset;
51
+ for (let i = 0; i < cdCount; i++) {
52
+ if (buf.readUInt32LE(p) !== 0x02014b50)
53
+ throw new Error("zip 中央目录损坏");
54
+ const flag = buf.readUInt16LE(p + 8);
55
+ const method = buf.readUInt16LE(p + 10);
56
+ const compSize = buf.readUInt32LE(p + 20);
57
+ const uncompSize = buf.readUInt32LE(p + 24);
58
+ const nameLen = buf.readUInt16LE(p + 28);
59
+ const extraLen = buf.readUInt16LE(p + 30);
60
+ const commentLen = buf.readUInt16LE(p + 32);
61
+ const localOffset = buf.readUInt32LE(p + 42);
62
+ const name = buf.toString("utf8", p + 46, p + 46 + nameLen);
63
+ if (want.has(name))
64
+ files.set(name, { method, compSize, flag, localOffset, uncompSize });
65
+ p += 46 + nameLen + extraLen + commentLen;
66
+ }
67
+ let totalUncomp = 0;
68
+ const out = new Map();
69
+ for (const [name, meta] of files) {
70
+ if (meta.uncompSize > OFFICE_MAX_UNCOMPRESSED_BYTES) {
71
+ throw new Error("解包后内容过大(疑似 zip 炸弹),拒绝预览");
72
+ }
73
+ const lp = meta.localOffset;
74
+ if (buf.readUInt32LE(lp) !== 0x04034b50)
75
+ throw new Error(`zip 局部头损坏:${name}`);
76
+ const lMethod = buf.readUInt16LE(lp + 8);
77
+ const lNameLen = buf.readUInt16LE(lp + 26);
78
+ const lExtraLen = buf.readUInt16LE(lp + 28);
79
+ const dataStart = lp + 30 + lNameLen + lExtraLen;
80
+ const raw = buf.subarray(dataStart, dataStart + meta.compSize);
81
+ if (meta.flag & 0x1)
82
+ throw new Error(`不支持加密 zip 条目:${name}`);
83
+ const method = lMethod || meta.method;
84
+ let decompressed;
85
+ if (method === 0) {
86
+ decompressed = Buffer.from(raw);
87
+ }
88
+ else if (method === 8) {
89
+ const remainingQuota = OFFICE_MAX_UNCOMPRESSED_BYTES - totalUncomp;
90
+ if (remainingQuota <= 0)
91
+ throw new Error("解包后内容过大(疑似 zip 炸弹),拒绝预览");
92
+ try {
93
+ decompressed = Buffer.from(inflateRawSync(raw, { maxOutputLength: remainingQuota }));
94
+ }
95
+ catch (err) {
96
+ if (err.message?.includes("maxOutputLength") ||
97
+ err.code === "ERR_BUFFER_TOO_LARGE") {
98
+ throw new Error("解包后内容过大(疑似 zip 炸弹),拒绝预览");
99
+ }
100
+ throw err;
101
+ }
102
+ }
103
+ else {
104
+ throw new Error(`不支持的压缩方式 ${method}:${name}`);
105
+ }
106
+ totalUncomp += decompressed.length;
107
+ if (totalUncomp > OFFICE_MAX_UNCOMPRESSED_BYTES) {
108
+ throw new Error("解包后内容过大(疑似 zip 炸弹),拒绝预览");
109
+ }
110
+ out.set(name, decompressed);
111
+ }
112
+ return out;
113
+ }
114
+ const NAMED_ENTITIES = { lt: "<", gt: ">", amp: "&", quot: '"', apos: "'" };
115
+ /**
116
+ * 单轮解码命名实体与十/十六进制数字实体(一次扫描同时匹配三类)。
117
+ *
118
+ * 旧实现分三段 replace:`&amp;#60;` 会先被命名段解码成 `&#60;`、再被数字段
119
+ * 解码成 `<`——双重解码让转义文本"逃出"字面量(可注入标签)。单轮扫描把
120
+ * `&…;` 整体消费、解码产物不参与后续匹配:`&amp;#60;` 正确地得到 `&#60;`。
121
+ */
122
+ function decodeEntities(s) {
123
+ return String(s ?? "").replace(/&(?:#([0-9]+);|#x([0-9a-fA-F]+);|([a-zA-Z]+);)/g, (_whole, dec, hex, name) => {
124
+ if (dec !== undefined) {
125
+ try {
126
+ return String.fromCodePoint(Number(dec));
127
+ }
128
+ catch {
129
+ return "";
130
+ }
131
+ }
132
+ if (hex !== undefined) {
133
+ try {
134
+ return String.fromCodePoint(Number.parseInt(hex, 16));
135
+ }
136
+ catch {
137
+ return "";
138
+ }
139
+ }
140
+ // 未知的命名实体保留原文(与旧实现一致,不臆造映射)
141
+ return NAMED_ENTITIES[name ?? ""] ?? _whole;
142
+ });
143
+ }
144
+ const stripTags = (s) => decodeEntities(String(s ?? "").replace(/<[^>]+>/g, ""));
145
+ /** document.xml 解压后的字节上限:超大 XML 会让段落正则扫描退化成秒级卡顿(同步事件循环被挂死)。 */
146
+ const DOCX_MAX_XML_BYTES = 20 * 1024 * 1024;
147
+ /** docx → 段落数组。 */
148
+ export function parseDocxParagraphs(buf) {
149
+ const files = unzipFiles(buf, ["word/document.xml"]);
150
+ const xml = files.get("word/document.xml")?.toString("utf8");
151
+ if (!xml)
152
+ throw new Error("docx 里找不到 word/document.xml");
153
+ // 预检 1:解压后过大的 document.xml 在下面的正则扫描里代价爆炸(一次性物化
154
+ // 全部段落、非贪婪匹配最坏回溯到文本末尾),直接友好报错而不是挂住进程。
155
+ if (xml.length > DOCX_MAX_XML_BYTES) {
156
+ throw new Error(`文档内容过大(document.xml 解压后 ${(xml.length / 1048576).toFixed(1)} MB,上限 20 MB),拒绝预览`);
157
+ }
158
+ // 预检 2:正常文档的段落闭合标签与开标签同量级。"只有开标签、没有闭标签"
159
+ // 的恶意结构会让非贪婪正则在每个候选起点都回溯扫描到文本末尾(O(n²)),
160
+ // 同步挂死事件循环——开标签远多于闭标签(>2 倍)且闭标签为 0 时直接报错。
161
+ const opens = xml.match(/<w:p[\s>]/g)?.length ?? 0;
162
+ const closes = xml.match(/<\/w:p>/g)?.length ?? 0;
163
+ if (opens > 0 && closes === 0) {
164
+ throw new Error("文档结构异常(段落标签大量未闭合),疑似恶意文档,拒绝预览");
165
+ }
166
+ const paragraphs = [];
167
+ for (const m of xml.matchAll(/<w:p[\s>][\s\S]*?<\/w:p>/g)) {
168
+ const pXml = m[0];
169
+ let text = "";
170
+ for (const n of pXml.matchAll(/<w:(t|tab|br)[^>]*\/?>([^<]*)(?:<\/w:t>)?/g)) {
171
+ if (n[1] === "tab")
172
+ text += "\t";
173
+ else if (n[1] === "br")
174
+ text += "\n";
175
+ else
176
+ text += decodeEntities(n[2]);
177
+ }
178
+ paragraphs.push(text);
179
+ }
180
+ while (paragraphs.length > 1 &&
181
+ paragraphs[paragraphs.length - 1] === "" &&
182
+ paragraphs[paragraphs.length - 2] === "") {
183
+ paragraphs.pop();
184
+ }
185
+ return paragraphs;
186
+ }
187
+ function colLettersToIndex(letters) {
188
+ let n = 0;
189
+ for (const ch of letters.toUpperCase())
190
+ n = n * 26 + (ch.charCodeAt(0) - 64);
191
+ return n - 1;
192
+ }
193
+ function splitCellRef(ref) {
194
+ const m = /^([A-Za-z]+)(\d+)$/.exec(String(ref ?? "").trim());
195
+ if (!m)
196
+ return null;
197
+ return { col: colLettersToIndex(m[1]), row: Number(m[2]) - 1 };
198
+ }
199
+ function parseSharedStrings(xml) {
200
+ if (!xml)
201
+ return [];
202
+ const out = [];
203
+ for (const m of xml.matchAll(/<si>([\s\S]*?)<\/si>/g)) {
204
+ const texts = [...m[1].matchAll(/<t[^>]*>([\s\S]*?)<\/t>/g)].map((x) => decodeEntities(x[1]));
205
+ out.push(texts.join(""));
206
+ }
207
+ return out;
208
+ }
209
+ const MAX_PARSE_ROWS = 1000;
210
+ const MAX_PARSE_COLS = 100;
211
+ function parseSheet(xml, shared) {
212
+ const rows = [];
213
+ let maxRowSeen = 0;
214
+ let maxColSeen = 0;
215
+ for (const m of xml.matchAll(/<row[^>]*>([\s\S]*?)<\/row>/g)) {
216
+ const rowAttr = m[0].slice(0, m[0].indexOf(">"));
217
+ const rawRNum = /r="(\d+)"/.exec(rowAttr)?.[1];
218
+ const rNum = rawRNum ? Number(rawRNum) - 1 : rows.length;
219
+ if (!Number.isFinite(rNum) || rNum < 0)
220
+ continue;
221
+ maxRowSeen = Math.max(maxRowSeen, rNum + 1);
222
+ // 防 OOM:巨大行号不进行无边界预分配,仅计入总量
223
+ if (rNum >= MAX_PARSE_ROWS)
224
+ continue;
225
+ while (rows.length <= rNum)
226
+ rows.push([]);
227
+ const row = rows[rNum];
228
+ for (const c of m[1].matchAll(/<c\b([^>]*)>([\s\S]*?)<\/c>/g)) {
229
+ const attrs = c[1];
230
+ const ref = /r="([^"]+)"/.exec(attrs)?.[1];
231
+ const t = /t="([^"]+)"/.exec(attrs)?.[1];
232
+ const pos = splitCellRef(ref ?? "");
233
+ if (!pos || pos.col < 0)
234
+ continue;
235
+ maxColSeen = Math.max(maxColSeen, pos.col + 1);
236
+ // 防 OOM:巨大列号不进行无边界空字符串 push
237
+ if (pos.col >= MAX_PARSE_COLS)
238
+ continue;
239
+ const inner = c[2];
240
+ let val = "";
241
+ if (t === "inlineStr") {
242
+ val = [...inner.matchAll(/<t[^>]*>([\s\S]*?)<\/t>/g)].map((x) => decodeEntities(x[1])).join("");
243
+ }
244
+ else if (t === "s") {
245
+ const idx = Number(stripTags(/<v>([\s\S]*?)<\/v>/.exec(inner)?.[1] ?? ""));
246
+ val = Number.isFinite(idx) && shared[idx] !== undefined ? shared[idx] : "";
247
+ }
248
+ else if (t === "b") {
249
+ val = stripTags(/<v>([\s\S]*?)<\/v>/.exec(inner)?.[1] ?? "") === "1" ? "TRUE" : "FALSE";
250
+ }
251
+ else {
252
+ val = stripTags(/<v>([\s\S]*?)<\/v>/.exec(inner)?.[1] ?? "");
253
+ if (!val)
254
+ val = stripTags(inner);
255
+ }
256
+ while (row.length <= pos.col)
257
+ row.push("");
258
+ row[pos.col] = val;
259
+ }
260
+ }
261
+ return {
262
+ rows,
263
+ nRows: Math.max(rows.length, maxRowSeen),
264
+ nCols: Math.max(Math.max(0, ...rows.map((r) => r.length)), maxColSeen),
265
+ };
266
+ }
267
+ /** xlsx/xlsm → sheet 数组(名按 workbook 还原,取不到时回落 sheetN)。 */
268
+ export function parseXlsxSheets(buf) {
269
+ const probe = unzipFiles(buf, ["xl/workbook.xml", "xl/_rels/workbook.xml.rels"]);
270
+ const wbXml = probe.get("xl/workbook.xml")?.toString("utf8") ?? "";
271
+ const relsXml = probe.get("xl/_rels/workbook.xml.rels")?.toString("utf8") ?? "";
272
+ const relTarget = new Map();
273
+ for (const m of relsXml.matchAll(/<Relationship\b[^>]*>/g)) {
274
+ const id = /Id="([^"]+)"/.exec(m[0])?.[1];
275
+ const target = /Target="([^"]+)"/.exec(m[0])?.[1];
276
+ if (id && target)
277
+ relTarget.set(id, target.replace(/^\/+/, "").replace(/^xl\//, ""));
278
+ }
279
+ const sheetDefs = [];
280
+ for (const m of wbXml.matchAll(/<sheet\b[^>]*\/>/g)) {
281
+ const name = /name="([^"]*)"/.exec(m[0])?.[1] ?? `Sheet${sheetDefs.length + 1}`;
282
+ const rid = /r:id="([^"]+)"/.exec(m[0])?.[1];
283
+ sheetDefs.push({ name: decodeEntities(name), rid });
284
+ }
285
+ let targets = sheetDefs.map((d, i) => {
286
+ const t = d.rid && relTarget.get(d.rid);
287
+ return { name: d.name, file: t ? `xl/${t}` : `xl/worksheets/sheet${i + 1}.xml` };
288
+ });
289
+ if (targets.length === 0)
290
+ targets = [{ name: "Sheet1", file: "xl/worksheets/sheet1.xml" }];
291
+ const files = unzipFiles(buf, ["xl/sharedStrings.xml", ...targets.map((t) => t.file)]);
292
+ const shared = parseSharedStrings(files.get("xl/sharedStrings.xml")?.toString("utf8"));
293
+ return targets.map((t) => {
294
+ const xml = files.get(t.file)?.toString("utf8");
295
+ const parsed = xml ? parseSheet(xml, shared) : { rows: [], nRows: 0, nCols: 0 };
296
+ const fullCols = parsed.nCols;
297
+ const fullRows = parsed.nRows;
298
+ const nCols = Math.min(MAX_COLS, fullCols);
299
+ const cut = parsed.rows.slice(0, MAX_ROWS).map((r) => {
300
+ const row = r.slice(0, MAX_COLS);
301
+ while (row.length < nCols)
302
+ row.push("");
303
+ return row;
304
+ });
305
+ return {
306
+ name: t.name,
307
+ nRows: fullRows,
308
+ nCols: fullCols,
309
+ rows: cut,
310
+ truncated: fullRows > MAX_ROWS || fullCols > MAX_COLS,
311
+ };
312
+ });
313
+ }
314
+ const mdCell = (s) => String(s ?? "")
315
+ .replace(/\|/g, "\\|")
316
+ .replace(/\r?\n/g, " ");
317
+ /** 列号 → Excel 式列名(0→A,25→Z,26→AA)。 */
318
+ function colName(i) {
319
+ let s = "";
320
+ let n = i;
321
+ do {
322
+ s = String.fromCharCode(65 + (n % 26)) + s;
323
+ n = Math.floor(n / 26) - 1;
324
+ } while (n >= 0);
325
+ return s;
326
+ }
327
+ /**
328
+ * 表头固定用 A/B/C…(与 Excel 列标一致),而不拿第一行数据冒充表头——
329
+ * 很多表的第一行是通知/标题文字,染成紫色表头又怪又误导。
330
+ */
331
+ function sheetToMarkdown(sheet) {
332
+ const head = `## ${sheet.name}(${sheet.nRows} 行 × ${sheet.nCols} 列${sheet.truncated ? ",只看前一部分" : ""})`;
333
+ if (sheet.rows.length === 0)
334
+ return `${head}\n\n(空表)`;
335
+ const width = Math.max(1, ...sheet.rows.map((r) => r.length));
336
+ const pad = (r) => {
337
+ const row = r.slice(0, width);
338
+ while (row.length < width)
339
+ row.push("");
340
+ return row;
341
+ };
342
+ const lines = [Array.from({ length: width }, (_, i) => colName(i)), ...sheet.rows.map(pad)].map((r) => `| ${r.map(mdCell).join(" | ")} |`);
343
+ lines.splice(1, 0, `| ${Array(width).fill("---").join(" | ")} |`);
344
+ return [head, "", ...lines].join("\n");
345
+ }
346
+ /**
347
+ * Office 文件 → Markdown 预览文本(前端 FilePreview 按 Markdown 渲染)。
348
+ * 非 office 扩展名返回 null;zip 损坏/加密等抛错(调用方回落旧的二进制嗅探)。
349
+ */
350
+ export function extractOfficeText(filename, buf) {
351
+ const dot = String(filename ?? "")
352
+ .toLowerCase()
353
+ .lastIndexOf(".");
354
+ const ext = dot > 0 ? String(filename).toLowerCase().slice(dot) : "";
355
+ if (!OFFICE_EXTS.has(ext))
356
+ return null;
357
+ if (buf.length > OFFICE_MAX_FILE_BYTES) {
358
+ throw new Error(`文件太大(${(buf.length / 1048576).toFixed(1)} MB),Office 预览上限 15 MB`);
359
+ }
360
+ let text;
361
+ let truncated = false;
362
+ if (ext === ".docx") {
363
+ const paras = parseDocxParagraphs(buf);
364
+ text = paras.join("\n");
365
+ truncated = text.length > OFFICE_MAX_TEXT_CHARS;
366
+ }
367
+ else {
368
+ const sheets = parseXlsxSheets(buf);
369
+ text = sheets.map(sheetToMarkdown).join("\n\n");
370
+ truncated = text.length > OFFICE_MAX_TEXT_CHARS || sheets.some((s) => s.truncated);
371
+ }
372
+ if (text.length > OFFICE_MAX_TEXT_CHARS)
373
+ text = text.slice(0, OFFICE_MAX_TEXT_CHARS);
374
+ return { text, truncated };
375
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * patch-tool.ts — 导出给 AI Agent 的结构化补丁工具(Hashline Patch Tool)。
3
+ *
4
+ * 核心设计:
5
+ * - 解决标准 edit 工具容易因缩进幻觉、空白字符或行号漂移导致编辑失败的问题。
6
+ * - 补丁语言基于 [path#TAG] 内容哈希锚点,支持精确行替换(PUT N.=M:)、
7
+ * 语法块级替换(PUT N*:,自动匹配闭合括号/缩进)、行前/后插入(PUT <N: / PUT >N:)、
8
+ * 剪切与寄存器粘贴(CUT / PUT @reg)、文件删除(REM)与重命名(MV)。
9
+ * - 当文件被外部改动导致哈希不一致时,自动尝试 3-Way Merge 冲突自愈。
10
+ */
11
+ import { resolve } from "node:path";
12
+ import { defineTool } from "@earendil-works/pi-coding-agent";
13
+ import { Type } from "typebox";
14
+ import { applyHashlinePatch, computeFileHash, formatHashlineHeader, formatNumberedLines, globalSnapshotStore, } from "./hashline-engine.js";
15
+ import { getLiveLspDiagnostics } from "./lsp-tool.js";
16
+ export const PATCH_TOOL_NAME = "patch";
17
+ export function makePatchTool(options) {
18
+ const cwd = options.cwd;
19
+ return defineTool({
20
+ name: PATCH_TOOL_NAME,
21
+ label: "Apply Hashline patch",
22
+ description: `Apply content-hashed, line-anchored patches to workspace files — prevents stale edits, line drift, and indentation hallucination.
23
+ Each file section starts with \`[path#TAG]\` (or \`[path]\` if the hash is unknown yet). Operations:
24
+ - \`PUT N.=M:\` replace lines N..M (inclusive) with the following \`+TEXT\` lines.
25
+ - \`PUT N*:\` replace the whole syntactic block starting at line N (closing brace/indent auto-resolved).
26
+ - \`PUT <N:\` insert before line N (\`<1\` = head of file); \`PUT >N:\` insert after (\`>$\` = end).
27
+ - \`CUT N.=M [@name]\` / \`CUT N* [@name]\` delete lines and save to register; \`PUT <N @name\` / \`PUT >N @name\` paste it.
28
+ - \`REM\` delete file; \`MV dest/path\` move/rename file.
29
+ - Body rows under \`:\` headers MUST start with \`+\` (\`+\` alone = blank line).
30
+ On divergence the engine attempts a 3-way merge. After success the tool returns the next anchor tag and live LSP diagnostics.`,
31
+ parameters: Type.Object({
32
+ patch: Type.String({
33
+ description: "The complete Hashline patch text containing one or more [path#TAG] sections with PUT/CUT/REM/MV operations.",
34
+ }),
35
+ timeout: Type.Optional(Type.Number({
36
+ description: "Optional execution timeout in seconds.",
37
+ })),
38
+ }),
39
+ async execute(_callId, params, _signal, _onUpdate, _ctx) {
40
+ const patchText = typeof params?.patch === "string" ? params.patch : "";
41
+ if (!patchText.trim()) {
42
+ const emptyReport = {
43
+ ok: false,
44
+ summary: "Empty patch text provided.",
45
+ results: [],
46
+ error: "Empty patch",
47
+ };
48
+ return {
49
+ content: [{ type: "text", text: "Error: No patch content provided." }],
50
+ details: emptyReport,
51
+ };
52
+ }
53
+ const report = applyHashlinePatch(patchText, {
54
+ cwd,
55
+ snapshotStore: globalSnapshotStore,
56
+ });
57
+ if (!report.ok) {
58
+ return {
59
+ content: [{ type: "text", text: `Patch Failed:\n${report.summary}` }],
60
+ details: report,
61
+ };
62
+ }
63
+ const textOutput = [report.summary];
64
+ // 为成功修改的文件输出最新的头部标签,方便 Agent 连续进行下一步修改
65
+ for (const r of report.results) {
66
+ if (r.op !== "deleted" && r.newHash) {
67
+ const targetPath = r.newPath || r.filePath;
68
+ textOutput.push(`\nNext edit anchor for ${targetPath}: \`${formatHashlineHeader(targetPath, r.newHash)}\``);
69
+ try {
70
+ const fullPath = resolve(cwd, targetPath);
71
+ const diags = await getLiveLspDiagnostics(fullPath, cwd);
72
+ if (diags) {
73
+ textOutput.push(`\nLive LSP diagnostics for ${targetPath}:\n${diags}`);
74
+ }
75
+ }
76
+ catch { }
77
+ }
78
+ }
79
+ return {
80
+ content: [{ type: "text", text: textOutput.join("\n") }],
81
+ details: report,
82
+ };
83
+ },
84
+ });
85
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * 权限预设相关辅助逻辑与会话回放。
3
+ */
4
+ /**
5
+ * 从会话 sessionManager entries 回放恢复最后设定的权限预设(若从未设定则返回 undefined)。
6
+ * customType 为 "permission/preset",载荷结构为 { preset: string }。
7
+ */
8
+ export function readPermissionFromSession(sm) {
9
+ try {
10
+ const mgr = sm;
11
+ if (typeof mgr?.getEntries !== "function")
12
+ return undefined;
13
+ const entries = mgr.getEntries();
14
+ for (let i = entries.length - 1; i >= 0; i--) {
15
+ const e = entries[i];
16
+ if (e?.type === "custom" && e.customType === "permission/preset" && typeof e.data?.preset === "string") {
17
+ return e.data.preset;
18
+ }
19
+ }
20
+ }
21
+ catch {
22
+ // ignore
23
+ }
24
+ return undefined;
25
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * server/plan-manager.ts
3
+ *
4
+ * 结构化任务计划看板与步骤状态机(Plan Mode / Step State Machine)。
5
+ *
6
+ * 借鉴 DeepSeek Harness (DSH) 的 dsh-plan-mode:
7
+ * 1. 任务步骤状态机(Step State Machine):
8
+ * - 步骤字段:id, title, status ("pending" | "in_progress" | "done" | "failed"), description
9
+ * - 总体进度与当前执行中步骤
10
+ * 2. 计划管理与更新(PlanManager):
11
+ * - 模型可通过 customTool `plan_update` 更新
12
+ * - 客户端也可通过协议消息 `plan_update` 调整
13
+ * - 状态自动同步到快照 `UiState.plan`
14
+ */
15
+ const VALID_STATUSES = new Set(["pending", "in_progress", "done", "failed"]);
16
+ export class PlanManager {
17
+ plans = new Map();
18
+ /** 获取指定会话的计划状态。 */
19
+ getPlan(conversationId) {
20
+ return this.plans.get(conversationId) ?? null;
21
+ }
22
+ /** 设置/全量更新指定会话的计划。 */
23
+ setPlan(conversationId, steps, activeStepId) {
24
+ const normalizedSteps = (Array.isArray(steps) ? steps : []).map((s, idx) => {
25
+ const status = (VALID_STATUSES.has(s.status) ? s.status : "pending");
26
+ return {
27
+ id: String(s.id || `step-${idx + 1}`),
28
+ title: String(s.title || "").slice(0, 200),
29
+ status,
30
+ ...(s.description ? { description: String(s.description).slice(0, 1000) } : {}),
31
+ };
32
+ });
33
+ // 自动推断 activeStepId:优先显式传参;否则推断第一个 in_progress 的步骤
34
+ let effectiveActiveId = activeStepId;
35
+ if (effectiveActiveId === undefined) {
36
+ const inProg = normalizedSteps.find((s) => s.status === "in_progress");
37
+ effectiveActiveId = inProg ? inProg.id : null;
38
+ }
39
+ const state = {
40
+ steps: normalizedSteps,
41
+ activeStepId: effectiveActiveId,
42
+ updatedAt: Date.now(),
43
+ };
44
+ this.plans.set(conversationId, state);
45
+ return state;
46
+ }
47
+ /** 增量更新单个步骤的状态或内容。 */
48
+ updateStep(conversationId, stepId, patch) {
49
+ const current = this.plans.get(conversationId);
50
+ if (!current)
51
+ return null;
52
+ const idx = current.steps.findIndex((s) => s.id === stepId);
53
+ if (idx === -1)
54
+ return null;
55
+ const currentStep = current.steps[idx];
56
+ const nextStatus = patch.status && VALID_STATUSES.has(patch.status) ? patch.status : currentStep.status;
57
+ // patch 里剥掉 id:步骤 id 是 activeStepId / first-match 推进的锚点,
58
+ // 被改掉会让 activeStepId 悬空(指向不存在的步骤)。
59
+ const { id: _ignored, ...rest } = patch;
60
+ const nextStep = {
61
+ ...currentStep,
62
+ ...rest,
63
+ status: nextStatus,
64
+ // title/description 与 setPlan 同口径截断(200/1000),防超长内容撑爆快照。
65
+ title: String(rest.title ?? currentStep.title ?? "").slice(0, 200),
66
+ };
67
+ const description = String(rest.description ?? currentStep.description ?? "").slice(0, 1000);
68
+ if (description)
69
+ nextStep.description = description;
70
+ else
71
+ delete nextStep.description;
72
+ const nextSteps = [...current.steps];
73
+ nextSteps[idx] = nextStep;
74
+ let nextActive = current.activeStepId;
75
+ if (patch.status === "in_progress") {
76
+ nextActive = stepId;
77
+ }
78
+ else if (current.activeStepId === stepId && patch.status) {
79
+ // 当前活动步骤已完成或失败,自动推进到下一个待执行步骤
80
+ const nextPending = nextSteps.find((s) => s.status === "pending" || s.status === "in_progress");
81
+ nextActive = nextPending ? nextPending.id : null;
82
+ }
83
+ const nextState = {
84
+ steps: nextSteps,
85
+ activeStepId: nextActive,
86
+ updatedAt: Date.now(),
87
+ };
88
+ this.plans.set(conversationId, nextState);
89
+ return nextState;
90
+ }
91
+ /** 清除指定会话的计划。 */
92
+ clearPlan(conversationId) {
93
+ this.plans.delete(conversationId);
94
+ }
95
+ /** 格式化计划为简洁文本,供模型上下文或诊断使用。 */
96
+ describePlan(conversationId) {
97
+ const plan = this.getPlan(conversationId);
98
+ if (!plan || plan.steps.length === 0)
99
+ return "No active plan.";
100
+ const doneCount = plan.steps.filter((s) => s.status === "done").length;
101
+ const total = plan.steps.length;
102
+ const lines = [`Plan Progress: ${doneCount}/${total} completed`];
103
+ for (let i = 0; i < plan.steps.length; i++) {
104
+ const s = plan.steps[i];
105
+ const icon = s.status === "done" ? "[x]" : s.status === "in_progress" ? "[>]" : s.status === "failed" ? "[!]" : "[ ]";
106
+ const activeTag = s.id === plan.activeStepId ? " (current)" : "";
107
+ lines.push(`${icon} ${i + 1}. ${s.title}${activeTag}`);
108
+ if (s.description) {
109
+ lines.push(` ${s.description}`);
110
+ }
111
+ }
112
+ return lines.join("\n");
113
+ }
114
+ }