pi-web-ui 0.90.1 → 0.92.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +81 -1
- package/bin/pi-web-ui.mjs +21 -2
- package/dist/server/agent-service.js +1206 -106
- package/dist/server/claim-files-tool.js +116 -0
- package/dist/server/claim-store.js +301 -0
- package/dist/server/client-state.js +119 -5
- package/dist/server/compaction-markers.js +287 -0
- package/dist/server/conversation-read-tool.js +369 -27
- package/dist/server/conversation-touches.js +357 -0
- package/dist/server/delegate-task.js +12 -1
- package/dist/server/dsh/dsh-agent-service.js +28 -1
- package/dist/server/file-transfer-routes.js +4 -1
- package/dist/server/icon-svg.js +35 -0
- package/dist/server/index.js +152 -34
- package/dist/server/model-admin.js +124 -2
- package/dist/server/model-enrich.js +478 -0
- package/dist/server/plugin-catalog.js +5 -0
- package/dist/server/plugin-facilities.js +42 -11
- package/dist/server/plugin-schedule.js +27 -2
- package/dist/server/plugins.js +112 -18
- package/dist/server/present-files-tool.js +303 -0
- package/dist/server/prompt-composer.js +1 -1
- package/dist/server/protocol-version.js +1 -1
- package/dist/server/read-tool.js +91 -0
- package/dist/server/schedule-agent-tool.js +23 -5
- package/dist/server/scheduler-tasks.js +60 -1
- package/dist/server/scm-commitmsg.js +140 -0
- package/dist/server/scm.js +42 -0
- package/dist/server/serialize.js +21 -1
- package/dist/server/settings-service.js +47 -0
- package/dist/server/skill-tool.js +120 -0
- package/dist/server/soft-cap.js +68 -0
- package/dist/server/subagent-templates.js +6 -0
- package/dist/server/subagents.js +157 -16
- package/dist/server/text-sniff.js +15 -0
- package/dist/server/themes.js +17 -2
- package/dist/server/tool-manager.js +13 -1
- package/package.json +2 -1
- package/plugin-sdk/README.md +1 -0
- package/plugins/catalog.json +20 -0
- package/themes/catppuccin-latte.css +136 -0
- package/themes/catppuccin.css +140 -0
- package/themes/nord.css +130 -0
- package/themes/one-dark.css +131 -0
- package/themes/paper.css +12 -12
- package/themes/solarized-light.css +131 -0
- package/themes/tokyo-night.css +140 -0
- package/web/dist/assets/{TerminalPanel-CkxM4Byl.js → TerminalPanel-RACpk6AX.js} +1 -1
- package/web/dist/assets/es-DIDZppIF.js +2 -0
- package/web/dist/assets/index-BgrnP9Jz.js +364 -0
- package/web/dist/assets/index-Cy8dSTZt.css +1 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-DJIV4A-t.js +0 -355
- package/web/dist/assets/index-DLdcRBs_.css +0 -1
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// conversation-touches.ts — 会话「触碰文件集」提取(纯函数,零 node 依赖)
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// 背景:同项目多对话并行时,服务端完全知道「谁碰过哪些文件」(实时消息与落盘
|
|
5
|
+
// 转录里都有工具调用记录),但既没算也没给 AI,导致 AI 只能靠猜冲突。本模块
|
|
6
|
+
// 只实现一次触碰集解析器,三处复用:并行提醒(agent-service.ts)、
|
|
7
|
+
// conversation_read 的 action:"files" 与 action:"status"。
|
|
8
|
+
//
|
|
9
|
+
// 输入形状(两条路都已核实,见下「数据来源」):
|
|
10
|
+
// - 实时路:session.agent.state.messages(SDK AgentMessage),assistant 消息
|
|
11
|
+
// 的 content 里是 {type:"toolCall", id, name, arguments} 块;
|
|
12
|
+
// - 历史路:转录 jsonl 的 message.content,原样保留同一形状(parseTranscript
|
|
13
|
+
// Lines 透传 content)。
|
|
14
|
+
// 注意:textOfContent 只在「渲染文本」里丢了参数(仍渲染 `[tool call: name]`),
|
|
15
|
+
// content 结构里的 arguments 一直都在。所以 toolCalls 字段是 additive 的便捷
|
|
16
|
+
// 访问点(一处解析、多处复用,并行 toolCall 也能表示),不是新数据源;取不到
|
|
17
|
+
// 时回落扫 content,再取不到就给空集 —— 绝不伪造。
|
|
18
|
+
//
|
|
19
|
+
// 提取规则(只算写,不算读 —— 这是准确性的关键):
|
|
20
|
+
// 写工具白名单:edit / write / edit_soft(参数 path;兼容 file_path/filePath
|
|
21
|
+
// 别名)。白名单之外的一切工具调用一律忽略,所以 read/ls/glob/grep 等读操作
|
|
22
|
+
// 天然不算 —— 不需要也不维护「读工具黑名单」。
|
|
23
|
+
// bash 高置信度写入迹象(拿不准就丢,宁可漏报不要假阳性):
|
|
24
|
+
// - 重定向 `>` / `>>` 的目标词(含 `2> file` 这类 fd 前缀;排除 `>&N`、
|
|
25
|
+
// /dev/null、NUL、&N;heredoc `<<`/`<<<` 不算写入);
|
|
26
|
+
// - rm / rmdir / mkdir / touch 的非 flag 参数;
|
|
27
|
+
// - cp / mv 只取最后一个非 flag 参数(目标端;源端是读/被删,不算写)。
|
|
28
|
+
// 解析是轻量分词器(引号/转义感知,`;`/`&&`/`||`/`|`/`&`/`(`/`)` 切分命令
|
|
29
|
+
// 段),不是完整 shell 解析器。
|
|
30
|
+
//
|
|
31
|
+
// 已知局限(如实记录,调用方展示时不要夸大):
|
|
32
|
+
// 1. 压缩(compaction)后旧消息被摘要替代,压缩前的触碰会丢失 —— 触碰集是
|
|
33
|
+
// 「当前可见转录」的下界,不是全集。
|
|
34
|
+
// 2. 相对路径按字面记录(edit 常给绝对路径、bash 常给相对路径),跨会话交集
|
|
35
|
+
// 可能因此漏报;但绝不做后缀模糊匹配(同名不同目录会假阳性,比漏报更糟)。
|
|
36
|
+
// 3. 路径归一化只做:去首尾空白、反斜杠转斜杠、去 `./` 前缀。Windows 大小写
|
|
37
|
+
// 不敏感不做归一。
|
|
38
|
+
// 4. powershell/cmd 风格写入(Set-Content/Out-File/del/copy)、git apply、
|
|
39
|
+
// tee 落盘、xargs/timeout/nice 等包装词之后的子命令 —— 一律不提取
|
|
40
|
+
// (sudo/env/command/time/nohup/doas 这几个常见包装词能跳过)。
|
|
41
|
+
// 5. 引号内的 `>`(如 echo "a > b")不提取;但 `eval`/`sh -c` 套娃串内的重定
|
|
42
|
+
// 向会被当成普通命令解析 —— 置信度下降,属已知噪声源。
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
/** 工具调用的参数键:SDK 用 arguments,兼容 input/args 两种历史写法。 */
|
|
45
|
+
function argsOfBlock(blk) {
|
|
46
|
+
if (typeof blk.arguments !== "undefined")
|
|
47
|
+
return blk.arguments;
|
|
48
|
+
if (typeof blk.input !== "undefined")
|
|
49
|
+
return blk.input;
|
|
50
|
+
return blk.args;
|
|
51
|
+
}
|
|
52
|
+
/** content 块数组里工具调用的 (name, args) 对(非 toolCall 块跳过)。 */
|
|
53
|
+
export function toolCallRefsOfContent(content) {
|
|
54
|
+
if (!Array.isArray(content))
|
|
55
|
+
return [];
|
|
56
|
+
const out = [];
|
|
57
|
+
for (const b of content) {
|
|
58
|
+
if (!b || typeof b !== "object")
|
|
59
|
+
continue;
|
|
60
|
+
const blk = b;
|
|
61
|
+
if (blk.type !== "toolCall" || typeof blk.name !== "string" || !blk.name)
|
|
62
|
+
continue;
|
|
63
|
+
const args = argsOfBlock(blk);
|
|
64
|
+
if (typeof args === "undefined")
|
|
65
|
+
out.push({ name: blk.name });
|
|
66
|
+
else
|
|
67
|
+
out.push({ name: blk.name, args });
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
/** 一条消息的全部工具调用:优先已解析的 toolCalls 字段,回落扫 content。 */
|
|
72
|
+
export function toolCallsOf(m) {
|
|
73
|
+
if (!m || typeof m !== "object")
|
|
74
|
+
return [];
|
|
75
|
+
if (Array.isArray(m.toolCalls) && m.toolCalls.length > 0) {
|
|
76
|
+
return m.toolCalls.filter((t) => !!t && typeof t === "object" && typeof t.name === "string");
|
|
77
|
+
}
|
|
78
|
+
return toolCallRefsOfContent(m.content);
|
|
79
|
+
}
|
|
80
|
+
/** 写工具白名单:只有这些工具的目标路径才算「写」。 */
|
|
81
|
+
const WRITE_TOOL_NAMES = new Set(["edit", "write", "edit_soft"]);
|
|
82
|
+
function pathOfWriteArgs(args) {
|
|
83
|
+
if (!args || typeof args !== "object")
|
|
84
|
+
return undefined;
|
|
85
|
+
const a = args;
|
|
86
|
+
for (const k of ["path", "file_path", "filePath"]) {
|
|
87
|
+
const v = a[k];
|
|
88
|
+
if (typeof v === "string" && v.trim())
|
|
89
|
+
return v.trim();
|
|
90
|
+
}
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
/** 路径轻归一(见文件头局限 3):只做无损、低风险的规范化。 */
|
|
94
|
+
export function normalizeTouchPath(p) {
|
|
95
|
+
let s = p.trim().replace(/\\/g, "/");
|
|
96
|
+
while (s.startsWith("./"))
|
|
97
|
+
s = s.slice(2);
|
|
98
|
+
return s;
|
|
99
|
+
}
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// bash 命令解析(高置信度写入迹象,见文件头规则)
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
/** shell 分词:引号/反斜杠转义感知,引号剥掉(`echo "a > b"` 的 > 不会误报)。 */
|
|
104
|
+
function tokenizeShell(cmd) {
|
|
105
|
+
const toks = [];
|
|
106
|
+
let cur = "";
|
|
107
|
+
let quote = null;
|
|
108
|
+
let i = 0;
|
|
109
|
+
const push = () => {
|
|
110
|
+
if (cur !== "") {
|
|
111
|
+
toks.push(cur);
|
|
112
|
+
cur = "";
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
while (i < cmd.length) {
|
|
116
|
+
const ch = cmd[i];
|
|
117
|
+
if (quote) {
|
|
118
|
+
if (ch === "\\" && quote === '"' && i + 1 < cmd.length) {
|
|
119
|
+
cur += cmd[i + 1];
|
|
120
|
+
i += 2;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (ch === quote)
|
|
124
|
+
quote = null;
|
|
125
|
+
else
|
|
126
|
+
cur += ch;
|
|
127
|
+
i++;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (ch === "\\" && i + 1 < cmd.length) {
|
|
131
|
+
cur += cmd[i + 1];
|
|
132
|
+
i += 2;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (ch === "'" || ch === '"') {
|
|
136
|
+
quote = ch;
|
|
137
|
+
i++;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (ch === " " || ch === "\t" || ch === "\r" || ch === "\n") {
|
|
141
|
+
push();
|
|
142
|
+
i++;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
// 多字符操作符先行:>>、<<、&&、||、2>、1>> 等。
|
|
146
|
+
const three = cmd.slice(i, i + 3);
|
|
147
|
+
const two = cmd.slice(i, i + 2);
|
|
148
|
+
if (/^\d>>/.test(three) || three === "<<<") {
|
|
149
|
+
push();
|
|
150
|
+
toks.push(three);
|
|
151
|
+
i += 3;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (two === ">>" || two === "<<" || two === "&&" || two === "||" || /^\d>$/.test(two) || /^\d</.test(two)) {
|
|
155
|
+
push();
|
|
156
|
+
toks.push(two);
|
|
157
|
+
i += 2;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (ch === ">" || ch === "<" || ch === ";" || ch === "|" || ch === "&" || ch === "(" || ch === ")") {
|
|
161
|
+
push();
|
|
162
|
+
toks.push(ch);
|
|
163
|
+
i++;
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
cur += ch;
|
|
167
|
+
i++;
|
|
168
|
+
}
|
|
169
|
+
push();
|
|
170
|
+
return toks;
|
|
171
|
+
}
|
|
172
|
+
const SEGMENT_SEPS = new Set([";", "&&", "||", "|", "&", "(", ")"]);
|
|
173
|
+
/** 命令段首可跳过的包装词(之后仍是命令位置)。 */
|
|
174
|
+
const WRAPPER_WORDS = new Set(["sudo", "env", "command", "time", "nohup", "doas"]);
|
|
175
|
+
const MULTI_TARGET_WRITES = new Set(["rm", "rmdir", "mkdir", "touch"]);
|
|
176
|
+
/** 只算目标端(最后一个非 flag 参数)的命令。 */
|
|
177
|
+
const DEST_ONLY_WRITES = new Set(["cp", "mv"]);
|
|
178
|
+
/** 重定向目标黑名单:不是文件。 */
|
|
179
|
+
function isNonFileTarget(t) {
|
|
180
|
+
if (t === "/dev/null" || t.toLowerCase() === "nul")
|
|
181
|
+
return true;
|
|
182
|
+
if (/^&\d*$/.test(t))
|
|
183
|
+
return true;
|
|
184
|
+
if (t === "-")
|
|
185
|
+
return true;
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
/** bash 命令 → 高置信度写入路径(去重前,调用方计数)。 */
|
|
189
|
+
export function bashWriteTargets(command) {
|
|
190
|
+
if (typeof command !== "string" || !command.trim())
|
|
191
|
+
return [];
|
|
192
|
+
const toks = tokenizeShell(command);
|
|
193
|
+
const out = [];
|
|
194
|
+
// 命令段内状态:cmd = 当前命令词(null = 还在找命令词),args = 该段非 flag 参数。
|
|
195
|
+
let cmd = null;
|
|
196
|
+
let pendingMulti = [];
|
|
197
|
+
let pendingDest = null;
|
|
198
|
+
let afterDoubleDash = false;
|
|
199
|
+
let skipNext = false;
|
|
200
|
+
const flush = () => {
|
|
201
|
+
out.push(...pendingMulti);
|
|
202
|
+
if (pendingDest !== null)
|
|
203
|
+
out.push(pendingDest);
|
|
204
|
+
pendingMulti = [];
|
|
205
|
+
pendingDest = null;
|
|
206
|
+
};
|
|
207
|
+
for (let i = 0; i < toks.length; i++) {
|
|
208
|
+
const t = toks[i];
|
|
209
|
+
if (skipNext) {
|
|
210
|
+
skipNext = false;
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
if (SEGMENT_SEPS.has(t)) {
|
|
214
|
+
flush();
|
|
215
|
+
cmd = null;
|
|
216
|
+
afterDoubleDash = false;
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
// 重定向:`[fd]>[>] 目标`;heredoc 一律跳过。分隔符不能当目标吃掉
|
|
220
|
+
// (`echo a > ; rm x` 里 `;` 必须保留,否则后半段命令会错位)。
|
|
221
|
+
if (/^(\d*)(>>?)$/.test(t) || t === "<<" || t === "<<<" || /^(\d*)<<<?$/.test(t)) {
|
|
222
|
+
const next = toks[i + 1];
|
|
223
|
+
if (typeof next !== "undefined" && !SEGMENT_SEPS.has(next)) {
|
|
224
|
+
if (t.includes(">") && !isNonFileTarget(next))
|
|
225
|
+
out.push(next);
|
|
226
|
+
skipNext = true;
|
|
227
|
+
}
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
if (t === "--") {
|
|
231
|
+
afterDoubleDash = true;
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
if (cmd === null) {
|
|
235
|
+
if (WRAPPER_WORDS.has(t))
|
|
236
|
+
continue;
|
|
237
|
+
if (t.includes("=") && !t.startsWith("-"))
|
|
238
|
+
continue; // VAR=val 前缀赋值
|
|
239
|
+
cmd = t;
|
|
240
|
+
if (cmd === "xargs")
|
|
241
|
+
cmd = "\0xargs-opaque"; // xargs 后的是子命令+参数,别当文件(局限 4)
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if (cmd === "\0xargs-opaque")
|
|
245
|
+
continue;
|
|
246
|
+
if (!afterDoubleDash && t.startsWith("-") && t.length > 1)
|
|
247
|
+
continue; // flag
|
|
248
|
+
if (MULTI_TARGET_WRITES.has(cmd))
|
|
249
|
+
pendingMulti.push(t);
|
|
250
|
+
else if (DEST_ONLY_WRITES.has(cmd))
|
|
251
|
+
pendingDest = t; // 只留最后一个
|
|
252
|
+
}
|
|
253
|
+
flush();
|
|
254
|
+
return out.filter((p) => p !== "" && !isNonFileTarget(p));
|
|
255
|
+
}
|
|
256
|
+
/** 转录消息 → 触碰文件集(按次数降序,次数相同按最后时间降序)。 */
|
|
257
|
+
export function extractTouches(messages, opts) {
|
|
258
|
+
if (!Array.isArray(messages) || messages.length === 0)
|
|
259
|
+
return [];
|
|
260
|
+
const max = Math.max(1, Math.floor(opts?.maxMessages ?? 500));
|
|
261
|
+
const slice = messages.slice(Math.max(0, messages.length - max));
|
|
262
|
+
const acc = new Map();
|
|
263
|
+
const bump = (raw, ts) => {
|
|
264
|
+
const path = normalizeTouchPath(raw);
|
|
265
|
+
if (!path)
|
|
266
|
+
return;
|
|
267
|
+
const e = acc.get(path);
|
|
268
|
+
if (e) {
|
|
269
|
+
e.count++;
|
|
270
|
+
if (ts > e.lastTs)
|
|
271
|
+
e.lastTs = ts;
|
|
272
|
+
}
|
|
273
|
+
else {
|
|
274
|
+
acc.set(path, { count: 1, lastTs: ts });
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
for (const m of slice) {
|
|
278
|
+
if (!m || typeof m !== "object")
|
|
279
|
+
continue;
|
|
280
|
+
const ts = typeof m.timestamp === "number" && Number.isFinite(m.timestamp) ? m.timestamp : 0;
|
|
281
|
+
for (const call of toolCallsOf(m)) {
|
|
282
|
+
const name = call.name;
|
|
283
|
+
if (WRITE_TOOL_NAMES.has(name)) {
|
|
284
|
+
const p = pathOfWriteArgs(call.args);
|
|
285
|
+
if (p)
|
|
286
|
+
bump(p, ts);
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
if (name === "bash" && call.args && typeof call.args === "object") {
|
|
290
|
+
const c = call.args.command;
|
|
291
|
+
if (typeof c === "string") {
|
|
292
|
+
for (const p of bashWriteTargets(c))
|
|
293
|
+
bump(p, ts);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
// `!` 命令 / bashExecution 转录消息:命令在顶层 command 字段。
|
|
298
|
+
if (m.role === "bashExecution" && typeof m.command === "string") {
|
|
299
|
+
for (const p of bashWriteTargets(m.command))
|
|
300
|
+
bump(p, ts);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return [...acc.entries()]
|
|
304
|
+
.map(([path, e]) => ({ path, count: e.count, lastTs: e.lastTs }))
|
|
305
|
+
.sort((a, b) => b.count - a.count || b.lastTs - a.lastTs);
|
|
306
|
+
}
|
|
307
|
+
/** 两个触碰集求并(sidecar 合并用):sidecar 是历史快照、live 是当前全量,
|
|
308
|
+
* 两者消息集重叠,所以 count 取大不相加 —— 相加会把同一批触碰算两遍。
|
|
309
|
+
* 压缩后次数只能保证是下界(presence 与 lastTs 精确,交集计算不受影响)。 */
|
|
310
|
+
export function unionTouchLists(a, b) {
|
|
311
|
+
const acc = new Map();
|
|
312
|
+
for (const f of [...(Array.isArray(a) ? a : []), ...(Array.isArray(b) ? b : [])]) {
|
|
313
|
+
if (!f || typeof f.path !== "string")
|
|
314
|
+
continue;
|
|
315
|
+
const e = acc.get(f.path);
|
|
316
|
+
const count = typeof f.count === "number" && Number.isFinite(f.count) ? f.count : 0;
|
|
317
|
+
const lastTs = typeof f.lastTs === "number" && Number.isFinite(f.lastTs) ? f.lastTs : 0;
|
|
318
|
+
if (e) {
|
|
319
|
+
if (count > e.count)
|
|
320
|
+
e.count = count;
|
|
321
|
+
if (lastTs > e.lastTs)
|
|
322
|
+
e.lastTs = lastTs;
|
|
323
|
+
}
|
|
324
|
+
else {
|
|
325
|
+
acc.set(f.path, { count, lastTs });
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return [...acc.entries()]
|
|
329
|
+
.map(([path, e]) => ({ path, count: e.count, lastTs: e.lastTs }))
|
|
330
|
+
.sort((x, y) => y.count - x.count || y.lastTs - x.lastTs);
|
|
331
|
+
}
|
|
332
|
+
/** 两个触碰集的交集:取 a 中同时出现在 b 里的条目(计数/时间沿用 a 的)。 */
|
|
333
|
+
export function intersectTouches(a, b) {
|
|
334
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length === 0 || b.length === 0)
|
|
335
|
+
return [];
|
|
336
|
+
const inB = new Set(b.map((f) => f.path));
|
|
337
|
+
return a.filter((f) => inB.has(f.path));
|
|
338
|
+
}
|
|
339
|
+
/** 单条触碰格式化(供提醒用):条目超长时优先保路径完整 —— 路径永不截断
|
|
340
|
+
* (半截路径不可用,还可能误导 AI 去碰错文件),只丢 `×N` 后缀。 */
|
|
341
|
+
export function formatTouchEntry(f, perItemCap = 60) {
|
|
342
|
+
const full = f.count > 1 ? `${f.path} ×${f.count}` : f.path;
|
|
343
|
+
if (full.length <= perItemCap)
|
|
344
|
+
return full;
|
|
345
|
+
return f.path;
|
|
346
|
+
}
|
|
347
|
+
/** 触碰集紧凑格式化:前 N 条完整路径 + `… (+K)` 计数(静默丢弃是大忌)。 */
|
|
348
|
+
export function formatTouchesCompact(files, opts) {
|
|
349
|
+
if (!Array.isArray(files) || files.length === 0)
|
|
350
|
+
return "";
|
|
351
|
+
const maxItems = Math.max(1, Math.floor(opts?.maxItems ?? 3));
|
|
352
|
+
const cap = Math.max(10, Math.floor(opts?.perItemCap ?? 60));
|
|
353
|
+
const shown = files.slice(0, maxItems).map((f) => formatTouchEntry(f, cap));
|
|
354
|
+
if (files.length > maxItems)
|
|
355
|
+
shown.push(`… (+${files.length - maxItems})`);
|
|
356
|
+
return shown.join("、");
|
|
357
|
+
}
|
|
@@ -159,7 +159,18 @@ export function makeDelegateTaskTool(host, lang) {
|
|
|
159
159
|
if (err)
|
|
160
160
|
return text(err, { delegated: false, agent: input.agent });
|
|
161
161
|
const prompt = buildDelegationPrompt(input, getLang());
|
|
162
|
-
|
|
162
|
+
// 与 subagent_spawn 同理:spawn 通道是唯一的失败面(数量上限 / runtime
|
|
163
|
+
// 创建失败都经由 host 抛错),转成返回文本让 AI 能读到原因并修正重试。
|
|
164
|
+
let convId;
|
|
165
|
+
try {
|
|
166
|
+
convId = await host.spawnSubagent(prompt, "delegate", ctx.cwd, input.agent, input.model);
|
|
167
|
+
}
|
|
168
|
+
catch (err) {
|
|
169
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
170
|
+
return text(pick(getLang(), `派单启动失败:${msg}`, `Failed to start delegation: ${msg}`, "delegate.start.failed", {
|
|
171
|
+
error: msg,
|
|
172
|
+
}), { delegated: false, agent: input.agent });
|
|
173
|
+
}
|
|
163
174
|
const title = subagentTitle(prompt);
|
|
164
175
|
const modelLineZh = input.model ? `\n模型:${input.model}` : "";
|
|
165
176
|
const modelLineEn = input.model ? `\nModel: ${input.model}` : "";
|
|
@@ -125,6 +125,7 @@ const DEFAULT_SETTINGS = {
|
|
|
125
125
|
editSoftEnabled: false,
|
|
126
126
|
questionnaireEnabled: true,
|
|
127
127
|
goalModeEnabled: true,
|
|
128
|
+
parallelReminderEnabled: true,
|
|
128
129
|
thinkingWrap: false,
|
|
129
130
|
toolsWrap: true,
|
|
130
131
|
disabledPlugins: [],
|
|
@@ -269,6 +270,7 @@ export class DshClientSession {
|
|
|
269
270
|
editSoftEnabled: savedSettings.editSoftEnabled,
|
|
270
271
|
questionnaireEnabled: savedSettings.questionnaireEnabled ?? true,
|
|
271
272
|
goalModeEnabled: savedSettings.goalModeEnabled ?? true,
|
|
273
|
+
parallelReminderEnabled: savedSettings.parallelReminderEnabled ?? true,
|
|
272
274
|
thinkingWrap: savedSettings.thinkingWrap,
|
|
273
275
|
toolsWrap: savedSettings.toolsWrap,
|
|
274
276
|
disabledPlugins: savedSettings.disabledPlugins ?? [],
|
|
@@ -1485,8 +1487,9 @@ export class DshClientSession {
|
|
|
1485
1487
|
}
|
|
1486
1488
|
// issue #145:同项目并行感知(与 pi 引擎同语义;DSH 无 display:false 的
|
|
1487
1489
|
// custom 消息通道,提醒以前置系统文本随本轮发给运行时,用户气泡保持原文)。
|
|
1490
|
+
// parallelReminderEnabled=false 时整段跳过。
|
|
1488
1491
|
let sysPrefix = "";
|
|
1489
|
-
if (!conv.isStreaming) {
|
|
1492
|
+
if (!conv.isStreaming && this.settings.parallelReminderEnabled !== false) {
|
|
1490
1493
|
const localTitles = [...this.convs.values()]
|
|
1491
1494
|
.filter((c) => c.id !== conv.id && c.cwd === conv.cwd && c.isStreaming)
|
|
1492
1495
|
.map((c) => `本窗口「${c.title}」`);
|
|
@@ -2587,11 +2590,17 @@ export class DshClientSession {
|
|
|
2587
2590
|
terminalToolsEnabled: this.settings.terminalToolsEnabled,
|
|
2588
2591
|
terminalBash: this.settings.terminalBash,
|
|
2589
2592
|
terminalBashIdleMs: this.settings.terminalBashIdleMs,
|
|
2593
|
+
// DSH 引擎无 customTool 注册面(工具来自 shipped preset),read 目录覆盖面不存在。
|
|
2594
|
+
readDirEnabled: true,
|
|
2590
2595
|
editSoftEnabled: this.settings.editSoftEnabled,
|
|
2591
2596
|
// DSH 无独立重试配置(pi 引擎才暴露),保持默认。
|
|
2592
2597
|
retryMaxAttempts: DEFAULT_RETRY_MAX_ATTEMPTS,
|
|
2598
|
+
// DSH 另有运行时:压缩软上限是 pi 引擎概念,这里保持关闭保协议完整。
|
|
2599
|
+
softCapTokens: 0,
|
|
2600
|
+
softCapByModel: {},
|
|
2593
2601
|
questionnaireEnabled: this.settings.questionnaireEnabled,
|
|
2594
2602
|
goalModeEnabled: this.settings.goalModeEnabled,
|
|
2603
|
+
parallelReminderEnabled: this.settings.parallelReminderEnabled,
|
|
2595
2604
|
thinkingWrap: this.settings.thinkingWrap,
|
|
2596
2605
|
toolsWrap: this.settings.toolsWrap,
|
|
2597
2606
|
// DSH 无 skill 全文注入概念,给空保协议完整。
|
|
@@ -2600,6 +2609,9 @@ export class DshClientSession {
|
|
|
2600
2609
|
visionBridgeModel: null,
|
|
2601
2610
|
visionBridgePromptMode: "append",
|
|
2602
2611
|
visionBridgePrompt: "",
|
|
2612
|
+
// DSH 无「AI 生成提交信息」(scm_commitmsg 分发处直接报错),保协议完整。
|
|
2613
|
+
scmCommitMsgPromptMode: "append",
|
|
2614
|
+
scmCommitMsgPrompt: "",
|
|
2603
2615
|
reviewPrompt: this.settings.reviewPrompt,
|
|
2604
2616
|
reviewDisabledSkills: [],
|
|
2605
2617
|
disabledPlugins: this.settings.disabledPlugins,
|
|
@@ -2611,6 +2623,7 @@ export class DshClientSession {
|
|
|
2611
2623
|
// DSH 引擎不接标准 pi 的 customTool 工具 schema(走 goal-rpc),此处给空。
|
|
2612
2624
|
toolsSchema: "",
|
|
2613
2625
|
visionBridgeDefaultPrompt: "",
|
|
2626
|
+
scmCommitMsgDefaultPrompt: "",
|
|
2614
2627
|
visionModels: [],
|
|
2615
2628
|
skills: this.skillsCache,
|
|
2616
2629
|
reviewSkills: [],
|
|
@@ -2652,6 +2665,8 @@ export class DshClientSession {
|
|
|
2652
2665
|
this.settings.questionnaireEnabled = partial.questionnaireEnabled;
|
|
2653
2666
|
if (partial.goalModeEnabled !== undefined)
|
|
2654
2667
|
this.settings.goalModeEnabled = partial.goalModeEnabled;
|
|
2668
|
+
if (partial.parallelReminderEnabled !== undefined)
|
|
2669
|
+
this.settings.parallelReminderEnabled = partial.parallelReminderEnabled;
|
|
2655
2670
|
if (partial.thinkingWrap !== undefined)
|
|
2656
2671
|
this.settings.thinkingWrap = partial.thinkingWrap;
|
|
2657
2672
|
if (partial.toolsWrap !== undefined)
|
|
@@ -2683,8 +2698,12 @@ export class DshClientSession {
|
|
|
2683
2698
|
editSoftEnabled: this.settings.editSoftEnabled,
|
|
2684
2699
|
// DSH 无独立重试配置(pi 引擎才暴露),保持默认。
|
|
2685
2700
|
retryMaxAttempts: DEFAULT_RETRY_MAX_ATTEMPTS,
|
|
2701
|
+
// DSH 另有运行时:压缩软上限是 pi 引擎概念,这里保持关闭保协议完整。
|
|
2702
|
+
softCapTokens: 0,
|
|
2703
|
+
softCapByModel: {},
|
|
2686
2704
|
questionnaireEnabled: this.settings.questionnaireEnabled,
|
|
2687
2705
|
goalModeEnabled: this.settings.goalModeEnabled,
|
|
2706
|
+
parallelReminderEnabled: this.settings.parallelReminderEnabled,
|
|
2688
2707
|
thinkingWrap: this.settings.thinkingWrap,
|
|
2689
2708
|
toolsWrap: this.settings.toolsWrap,
|
|
2690
2709
|
disabledPlugins: this.settings.disabledPlugins,
|
|
@@ -3004,6 +3023,9 @@ export class DshClientSession {
|
|
|
3004
3023
|
// DSH 无 skill 全文注入概念,给空保预设类型完整。
|
|
3005
3024
|
skillsFullText: [],
|
|
3006
3025
|
retryMaxAttempts: DEFAULT_RETRY_MAX_ATTEMPTS,
|
|
3026
|
+
// DSH 另有运行时:压缩软上限是 pi 引擎概念,预设里保持关闭。
|
|
3027
|
+
softCapTokens: 0,
|
|
3028
|
+
softCapByModel: {},
|
|
3007
3029
|
visionBridgePromptMode: "append",
|
|
3008
3030
|
visionBridgePrompt: "",
|
|
3009
3031
|
reviewPrompt: this.settings.reviewPrompt,
|
|
@@ -3860,6 +3882,11 @@ export class DshClientSession {
|
|
|
3860
3882
|
this.emit({ type: "notice", level: "error", text: error, textEn: errorEn });
|
|
3861
3883
|
this.emit({ type: "clone_provider_result", reqId, ok: false, error });
|
|
3862
3884
|
}
|
|
3885
|
+
async enrichModels(reqId, _ids, _hints) {
|
|
3886
|
+
const error = pick(this.getLang(), "DSH 引擎不支持自定义 provider", "The DSH engine does not support custom providers", "dsh.provider.custom.unsupported");
|
|
3887
|
+
this.emit({ type: "enrich_models_result", reqId, ok: false, error });
|
|
3888
|
+
}
|
|
3889
|
+
abortEnrichModels(_reqId) { }
|
|
3863
3890
|
// -----------------------------------------------------------------------
|
|
3864
3891
|
// 其他
|
|
3865
3892
|
// -----------------------------------------------------------------------
|
|
@@ -59,7 +59,10 @@ export function registerFileTransferRoutes(app, getCwd, temporaryRoot = tmpdir()
|
|
|
59
59
|
if ((await stat(archive.path)).size > 200 * 1024 * 1024)
|
|
60
60
|
throw new Error("Temporary download exceeds 200 MiB; compress in place and download the archive instead");
|
|
61
61
|
// The callback runs on completion AND disconnect. Never expose a reusable temp URL.
|
|
62
|
-
|
|
62
|
+
// dotfiles: allow — issue #223:Express 5 的 send 默认 dotfiles=ignore,
|
|
63
|
+
// TMPDIR 等临时根位于隐藏目录下(如 ~/.cache)时下载会被判 404。
|
|
64
|
+
// 路径是服务端刚创建的受控临时包,放行安全。
|
|
65
|
+
await new Promise((ok, fail) => res.download(archive.path, archive.name, { dotfiles: "allow" }, (err) => (err ? fail(err) : ok())));
|
|
63
66
|
}
|
|
64
67
|
else
|
|
65
68
|
res.json({ name: archive.name });
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 插件内联 SVG 图标(manifest.json `iconSvg` / catalog `iconSvg`)的校验与规范化。
|
|
3
|
+
*
|
|
4
|
+
* 为什么是纯函数独立模块:
|
|
5
|
+
* - server/plugins.ts 与 server/plugin-catalog.ts 都要用;normalize 放 plugins.ts
|
|
6
|
+
* 里会形成循环依赖(plugins.ts 已 import plugin-catalog.ts)。
|
|
7
|
+
* - 前端另有一份 sanitize(web/src/plugin-icon.tsx):服务端只做形状校验与长度
|
|
8
|
+
* 截断(入库前),渲染前的前端 sanitize 负责真正的 XSS 过滤(去 script/on*)。
|
|
9
|
+
*/
|
|
10
|
+
/** iconSvg 上限:16px 展示位,正常图标 <2KB;超了直接拒掉(不是截断,截断会断标签)。 */
|
|
11
|
+
export const MAX_ICON_SVG_LENGTH = 8192;
|
|
12
|
+
/**
|
|
13
|
+
* 校验并规范化内联 SVG 图标;非法返回 undefined。
|
|
14
|
+
* 接受:以 `<svg` 开头(含 `<?xml` 前导也认)、`</svg>` 结尾、不含 `<script`、
|
|
15
|
+
* 不含行内事件处理器(`on*=`) 的字符串。返回 trim 后的原文(不改写内容)。
|
|
16
|
+
*/
|
|
17
|
+
export function normalizeIconSvg(raw) {
|
|
18
|
+
if (typeof raw !== "string")
|
|
19
|
+
return undefined;
|
|
20
|
+
const s = raw.trim();
|
|
21
|
+
if (!s || s.length > MAX_ICON_SVG_LENGTH)
|
|
22
|
+
return undefined;
|
|
23
|
+
const body = s.startsWith("<?xml") ? s.slice(s.indexOf("?>") + 2).trimStart() : s;
|
|
24
|
+
if (!/^<svg[\s>]/.test(body))
|
|
25
|
+
return undefined;
|
|
26
|
+
if (!/<\/svg\s*>\s*$/.test(body))
|
|
27
|
+
return undefined;
|
|
28
|
+
if (/<script[\s>]/i.test(body))
|
|
29
|
+
return undefined;
|
|
30
|
+
if (/\son\w+\s*=/i.test(body))
|
|
31
|
+
return undefined;
|
|
32
|
+
if (/javascript\s*:/i.test(body))
|
|
33
|
+
return undefined;
|
|
34
|
+
return s;
|
|
35
|
+
}
|