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.
Files changed (54) hide show
  1. package/CHANGELOG.md +81 -1
  2. package/bin/pi-web-ui.mjs +21 -2
  3. package/dist/server/agent-service.js +1206 -106
  4. package/dist/server/claim-files-tool.js +116 -0
  5. package/dist/server/claim-store.js +301 -0
  6. package/dist/server/client-state.js +119 -5
  7. package/dist/server/compaction-markers.js +287 -0
  8. package/dist/server/conversation-read-tool.js +369 -27
  9. package/dist/server/conversation-touches.js +357 -0
  10. package/dist/server/delegate-task.js +12 -1
  11. package/dist/server/dsh/dsh-agent-service.js +28 -1
  12. package/dist/server/file-transfer-routes.js +4 -1
  13. package/dist/server/icon-svg.js +35 -0
  14. package/dist/server/index.js +152 -34
  15. package/dist/server/model-admin.js +124 -2
  16. package/dist/server/model-enrich.js +478 -0
  17. package/dist/server/plugin-catalog.js +5 -0
  18. package/dist/server/plugin-facilities.js +42 -11
  19. package/dist/server/plugin-schedule.js +27 -2
  20. package/dist/server/plugins.js +112 -18
  21. package/dist/server/present-files-tool.js +303 -0
  22. package/dist/server/prompt-composer.js +1 -1
  23. package/dist/server/protocol-version.js +1 -1
  24. package/dist/server/read-tool.js +91 -0
  25. package/dist/server/schedule-agent-tool.js +23 -5
  26. package/dist/server/scheduler-tasks.js +60 -1
  27. package/dist/server/scm-commitmsg.js +140 -0
  28. package/dist/server/scm.js +42 -0
  29. package/dist/server/serialize.js +21 -1
  30. package/dist/server/settings-service.js +47 -0
  31. package/dist/server/skill-tool.js +120 -0
  32. package/dist/server/soft-cap.js +68 -0
  33. package/dist/server/subagent-templates.js +6 -0
  34. package/dist/server/subagents.js +157 -16
  35. package/dist/server/text-sniff.js +15 -0
  36. package/dist/server/themes.js +17 -2
  37. package/dist/server/tool-manager.js +13 -1
  38. package/package.json +2 -1
  39. package/plugin-sdk/README.md +1 -0
  40. package/plugins/catalog.json +20 -0
  41. package/themes/catppuccin-latte.css +136 -0
  42. package/themes/catppuccin.css +140 -0
  43. package/themes/nord.css +130 -0
  44. package/themes/one-dark.css +131 -0
  45. package/themes/paper.css +12 -12
  46. package/themes/solarized-light.css +131 -0
  47. package/themes/tokyo-night.css +140 -0
  48. package/web/dist/assets/{TerminalPanel-CkxM4Byl.js → TerminalPanel-RACpk6AX.js} +1 -1
  49. package/web/dist/assets/es-DIDZppIF.js +2 -0
  50. package/web/dist/assets/index-BgrnP9Jz.js +364 -0
  51. package/web/dist/assets/index-Cy8dSTZt.css +1 -0
  52. package/web/dist/index.html +2 -2
  53. package/web/dist/assets/index-DJIV4A-t.js +0 -355
  54. package/web/dist/assets/index-DLdcRBs_.css +0 -1
@@ -0,0 +1,91 @@
1
+ /**
2
+ * read-tool.ts —— 覆盖 SDK 内置 read:路径是目录时列出目录条目。
3
+ *
4
+ * 背景:SDK 内置 `read` 只处理文件,`read('server')` 直接抛
5
+ * `EISDIR: illegal operation on a directory, read`;模型想「看一眼这个目录」
6
+ * 只能改用 bash(`ls`)。SDK 自带的 `ls` 工具不在默认活跃集里
7
+ * (默认 `["read","bash","edit","write"]`),模型并不总能想到它。
8
+ *
9
+ * 做法(与 bash 覆盖同一机制):customTools 按 name 覆盖内置定义 —— 用
10
+ * `createReadToolDefinition(cwd)` 拿原实现当基底,只在「路径确实是目录」时
11
+ * 分流到 `createLsToolDefinition(cwd)`(排序、目录 `/` 后缀、条目/字节截断
12
+ * 与 SDK ls 完全一致);其余情况(文件、图片、路径不存在、读取报错)原样
13
+ * 转发基底,行为与内置完全一致。
14
+ *
15
+ * 开关:`readDirEnabled`(设置面板「工具」页,默认开)。**行为开关**不是
16
+ * ActiveSet 开关(read 本体不可关,关了 agent 就残了),因此不进
17
+ * tool-manager 的 AGENT_TOOL_CATALOG;每次调用实时读设置,改动即时生效。
18
+ *
19
+ * 双语约定(issue #91):definition 走 bilingual(en, zh) 内联双语;per-call
20
+ * 返回文本(目录头)按 lang 取 pick(lang, zh, en, key),缺表回落英文内联。
21
+ *
22
+ * DSH 引擎无 customTool 注册面(工具来自 shipped preset),本覆盖只服务 pi 引擎。
23
+ */
24
+ import { stat } from "node:fs/promises";
25
+ import { homedir } from "node:os";
26
+ import { isAbsolute, join, resolve as nodeResolve } from "node:path";
27
+ import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, createLsToolDefinition, createReadToolDefinition, defineTool, } from "@earendil-works/pi-coding-agent";
28
+ import { bilingual, pick } from "./i18n.js";
29
+ const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
30
+ /**
31
+ * 目录判定用的路径归一:~ / @ 前缀、Unicode 空格、相对 → 绝对(对齐 SDK
32
+ * resolveToCwd 的主要语义)。这里只是「猜」,猜不中(例如 macOS 截图名的
33
+ * 变体路径)就走内置实现,不会比现状更差。
34
+ */
35
+ export function resolvePathForDirCheck(input, cwd) {
36
+ let p = String(input ?? "").replace(UNICODE_SPACES, " ");
37
+ if (p.startsWith("@"))
38
+ p = p.slice(1);
39
+ if (p === "~")
40
+ return homedir();
41
+ if (p.startsWith("~/") || p.startsWith("~\\"))
42
+ return join(homedir(), p.slice(2));
43
+ return isAbsolute(p) ? p : nodeResolve(cwd, p);
44
+ }
45
+ /** 路径是不是目录(不存在/无权限/非目录一律 false → 交回内置实现)。 */
46
+ export async function isDirectoryPath(absolutePath) {
47
+ try {
48
+ const st = await stat(absolutePath);
49
+ return st.isDirectory();
50
+ }
51
+ catch {
52
+ return false;
53
+ }
54
+ }
55
+ /**
56
+ * 生成「read 读目录」覆盖定义。cwd 仅供创建时固定;执行时优先 ctx.cwd
57
+ * (会话工作区)。
58
+ */
59
+ export function makeReadDirTool(fallbackCwd, options = {}) {
60
+ const base = createReadToolDefinition(fallbackCwd);
61
+ const ls = createLsToolDefinition(fallbackCwd);
62
+ const dirEnabled = options.dirEnabled ?? (() => true);
63
+ const getLang = options.getLang ?? (() => "en");
64
+ return defineTool({
65
+ ...base,
66
+ description: bilingual(`${base.description} If the path is a directory, its entries are listed instead of file contents (one entry per line, directories suffixed with '/'); in that case \`limit\` caps the number of entries and \`offset\` is ignored.`, `读取文件内容。支持文本文件与图片(jpg, png, gif, webp, bmp),图片作为附件发出。文本输出截断到 ${DEFAULT_MAX_LINES} 行或 ${DEFAULT_MAX_BYTES / 1024}KB(先到者为准),大文件用 offset/limit 续读。路径是目录时改为列出目录条目(一行一项,目录带 '/' 后缀;此时 limit 是条目上限,offset 忽略)。`),
67
+ promptSnippet: bilingual("Read file contents (a directory path lists its entries)", "读取文件内容(传目录则列出其条目)"),
68
+ promptGuidelines: [
69
+ ...(base.promptGuidelines ?? []),
70
+ bilingual("Use read on a directory to list its entries — no need to shell out to `ls`", "要看目录内容直接把目录路径交给 read,不必再走 bash 的 ls"),
71
+ ],
72
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
73
+ const input = (params ?? {});
74
+ const path = typeof input.path === "string" ? input.path : "";
75
+ if (path && dirEnabled()) {
76
+ const cwd = typeof ctx?.cwd === "string" ? ctx.cwd : fallbackCwd;
77
+ if (await isDirectoryPath(resolvePathForDirCheck(path, cwd))) {
78
+ const limit = typeof input.limit === "number" && input.limit > 0 ? Math.floor(input.limit) : undefined;
79
+ // 列目录本体完全复用 SDK 的 ls(排序/`/` 后缀/截断提示口径一致)。
80
+ const listed = await ls.execute(toolCallId, { path, ...(limit !== undefined ? { limit } : {}) }, signal, onUpdate, ctx);
81
+ const header = pick(getLang(), `[目录:${path}]`, `[Directory: ${path}]`, "read.dir.header", { path });
82
+ // 只取列出来的正文:截断/条目上限提示已在正文末尾,read 卡片的
83
+ // details 不需要 ls 的字段。
84
+ const content = listed.content.map((part, index) => index === 0 && part.type === "text" ? { ...part, text: `${header}\n${part.text}` } : part);
85
+ return { content, details: undefined };
86
+ }
87
+ }
88
+ return base.execute(toolCallId, params, signal, onUpdate, ctx);
89
+ },
90
+ });
91
+ }
@@ -111,12 +111,12 @@ export function makeScheduleTools(host, ownerConversationId, lang) {
111
111
  "Use it when the user asks for periodic checks, delayed reminders, or scheduled summaries — do NOT fake it with sleep loops (a sleeping script can never push a message back). " +
112
112
  'Schedule accepts a 5-field cron ("0 * * * *" = hourly) or a relative delay ("in 30m", "in 1h", "30m"); minimum interval is 60s. ' +
113
113
  "One-shot by default (recurring=false auto-deletes the task after it fires); pass recurring=true for repeats. " +
114
- "If the conversation is gone when it fires (closed/restarted), the task runs headless in its project and the report stays in the scheduler panel history. " +
114
+ "The wake-up binds the originating conversation AND its persisted session file: after context compaction or a restart the scheduler re-binds to the same session automatically; if the original conversation is gone it first falls back to the project's active conversation (and visibly reports the move), and only runs headless (report in panel history) when no live conversation exists for the project. " +
115
115
  "Manage with schedule_list / schedule_cancel; the user can also cancel from the background-tasks panel.", "在**当前对话**里创建一个定时唤醒:到指定 cron 时间或延迟后,调度器自动把你的 prompt 投回本对话,你继续执行并向用户汇报。" +
116
116
  "用户要求定时巡检、延时提醒、定时汇总时用它 —— 不要用 sleep 死循环假装答应(休眠的脚本推不回任何消息)。" +
117
117
  "时间写法收 5 字段 cron(“0 * * * *”=每小时)或相对延迟(“in 30m”“in 1h”“30m”);最短间隔 60s。" +
118
118
  "默认单次(recurring=false 触发后自动删除);传 recurring=true 做周期任务。" +
119
- "触发时对话已不在(关闭/重启)则回落无头执行,报告留在调度面板历史里。" +
119
+ "唤醒绑定发起对话与其落盘会话文件:上下文压缩或重启后自动重绑到同一会话;原对话不在时先回落到同项目的活跃对话(并明确提示迁移),同项目无存活对话才无头执行,报告留在调度面板历史里。" +
120
120
  "用 schedule_list / schedule_cancel 管理;用户也可从后台任务面板取消。"),
121
121
  promptSnippet: "schedule a wake-up in this conversation (cron or delay), auto-report on fire",
122
122
  parameters: Type.Object({
@@ -154,16 +154,34 @@ export function makeScheduleTools(host, ownerConversationId, lang) {
154
154
  const labelRaw = typeof p.label === "string" ? p.label.trim().slice(0, 80) : "";
155
155
  const name = labelRaw || prompt.split("\n")[0].slice(0, 40) || "定时任务";
156
156
  const convId = (ownerConversationId ?? "").trim() || String(host.activeConversationId() ?? "").trim();
157
+ // 稳定绑定(issue #231):owner 对话的落盘会话文件 —— 压缩/重启后靠它重认同一会话,
158
+ // 内存对话 id(c1/c2…)只做首选唤醒键。宿主没实现 conversationInfo 时按原来只绑 id。
159
+ let bindCwd = cwd;
160
+ let bindSessionFile = "";
161
+ try {
162
+ const info = host.conversationInfo?.((ownerConversationId ?? "").trim() || convId || undefined);
163
+ if (info) {
164
+ if (String(info.cwd ?? "").trim())
165
+ bindCwd = String(info.cwd).trim();
166
+ bindSessionFile = String(info.sessionFile ?? "")
167
+ .trim()
168
+ .slice(0, 1024);
169
+ }
170
+ }
171
+ catch {
172
+ /* 绑定快照尽力而为:拿不到稳定键就按原来的 id 绑定触发 */
173
+ }
157
174
  const recurring = p.recurring === true;
158
175
  let task;
159
176
  try {
160
177
  task = store.upsert({
161
178
  name,
162
- cwd,
179
+ cwd: bindCwd,
163
180
  kind: parsed.kind,
164
181
  spec: parsed.spec,
165
182
  prompt,
166
183
  conversationId: convId,
184
+ sessionFile: bindSessionFile,
167
185
  oneShot: !recurring,
168
186
  });
169
187
  }
@@ -178,8 +196,8 @@ export function makeScheduleTools(host, ownerConversationId, lang) {
178
196
  : `Fires: ${parsed.kind === "cron" ? `cron ${parsed.spec}` : `every ${Math.round(Number(parsed.spec) / 1000)}s`}, next ${fmtTime(next, L)}${recurring ? " (recurring)" : " (one-shot, auto-deleted after firing)"}`;
179
197
  const targetLine = convId
180
198
  ? L === "zh"
181
- ? `汇报:触发时自动唤醒本对话(${convId});对话已不在则无头执行,报告进调度面板历史。`
182
- : `Reports: wakes this conversation (${convId}) on fire; runs headless (report in panel history) if it is gone.`
199
+ ? `汇报:触发时自动唤醒本对话(${convId});压缩/重启后按会话文件自动重绑,原对话不在时先回落同项目活跃对话(明确提示),无存活对话才无头执行。`
200
+ : `Reports: wakes this conversation (${convId}) on fire; re-binds by session file after compaction/restart, falls back to the project's active conversation (with a visible note), headless only with no live conversation.`
183
201
  : L === "zh"
184
202
  ? "汇报:无头执行,报告进调度面板历史(创建时没拿到对话 id)。"
185
203
  : "Reports: headless run, report in panel history (no conversation id captured at creation).";
@@ -79,6 +79,11 @@ export function normalizeSchedulerInput(input, now = Date.now()) {
79
79
  const conversationId = String(input.conversationId ?? "")
80
80
  .trim()
81
81
  .slice(0, 128);
82
+ // 落盘会话文件:稳定身份键(issue #231)。只做归一化(去首尾空白、封顶),
83
+ // 不校验存在性 —— 创建时刻文件可能还没落盘,触发时按它匹配不上即回落。
84
+ const sessionFile = String(input.sessionFile ?? "")
85
+ .trim()
86
+ .slice(0, 1024);
82
87
  return {
83
88
  id,
84
89
  name,
@@ -92,6 +97,7 @@ export function normalizeSchedulerInput(input, now = Date.now()) {
92
97
  thinkingLevel,
93
98
  catchUp,
94
99
  conversationId,
100
+ sessionFile,
95
101
  oneShot: input.oneShot === true,
96
102
  createdAt: now,
97
103
  updatedAt: now,
@@ -152,6 +158,17 @@ function sanitizeHistory(v) {
152
158
  }
153
159
  return out.sort((a, b) => b.at - a.at).slice(0, SCHEDULER_HISTORY_MAX);
154
160
  }
161
+ /** 落盘会话路径是否指向同一会话(纯函数,可单测)。
162
+ * Windows 上 `C:\a\b` 与 `C:/a/b`、尾部分隔符差异都算同一文件;
163
+ * 空串永不相等(没拿到稳定键的任务不配对)。 */
164
+ export function sameSessionFile(a, b) {
165
+ const x = String(a ?? "").trim();
166
+ const y = String(b ?? "").trim();
167
+ if (!x || !y)
168
+ return false;
169
+ const norm = (s) => s.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
170
+ return norm(x) === norm(y);
171
+ }
155
172
  export class SchedulerStore {
156
173
  dataDir;
157
174
  opts;
@@ -230,6 +247,7 @@ export class SchedulerStore {
230
247
  thinkingLevel: r.thinkingLevel,
231
248
  catchUp: r.catchUp,
232
249
  conversationId: r.conversationId,
250
+ sessionFile: r.sessionFile,
233
251
  oneShot: r.oneShot,
234
252
  });
235
253
  task.createdAt = typeof r.createdAt === "number" && Number.isFinite(r.createdAt) ? r.createdAt : Date.now();
@@ -288,6 +306,7 @@ export class SchedulerStore {
288
306
  thinkingLevel: t.thinkingLevel,
289
307
  catchUp: t.catchUp,
290
308
  conversationId: t.conversationId,
309
+ sessionFile: t.sessionFile ?? "",
291
310
  oneShot: t.oneShot,
292
311
  createdAt: t.createdAt,
293
312
  updatedAt: t.updatedAt,
@@ -299,13 +318,20 @@ export class SchedulerStore {
299
318
  })
300
319
  .sort((a, b) => a.name.localeCompare(b.name, "zh"));
301
320
  }
302
- /** 新建或全量更新(同名 id 覆盖);返回归一化后的任务。 */
321
+ /** 新建或全量更新(同名 id 覆盖);返回归一化后的任务。
322
+ * issue #231:面板编辑不带绑定字段(conversationId/sessionFile 缺席)时保留旧绑定 ——
323
+ * 否则用户在面板改个名字就会把 AI 任务的会话绑定洗掉,下次触发直接变无头。
324
+ * 字段显式出现(含空串)仍按全量语义覆盖。 */
303
325
  upsert(input) {
304
326
  const prev = this.tasks.get(String(input.id ?? "").trim());
305
327
  const task = normalizeSchedulerInput(input);
306
328
  if (prev) {
307
329
  task.createdAt = prev.createdAt;
308
330
  task.updatedAt = Date.now();
331
+ if (input.conversationId === undefined && input.sessionFile === undefined) {
332
+ task.conversationId = prev.conversationId;
333
+ task.sessionFile = prev.sessionFile ?? "";
334
+ }
309
335
  }
310
336
  const history = prev?.history ? sanitizeHistory(prev.history) : [];
311
337
  this.tasks.set(task.id, { ...task, history });
@@ -335,6 +361,39 @@ export class SchedulerStore {
335
361
  this.changed();
336
362
  return t;
337
363
  }
364
+ /** 会话重绑定(issue #231):触发时按稳定键找到了新的对话句柄,把任务的
365
+ * 投递目标迁移过去(只改 conversationId/sessionFile/updatedAt,不碰下次触发
366
+ * 与历史)。无变化回 false(调用方免一次落盘+推送);未知任务回 false。 */
367
+ rebind(id, binding) {
368
+ const t = this.tasks.get(id);
369
+ if (!t)
370
+ return false;
371
+ let dirty = false;
372
+ if (binding.conversationId !== undefined) {
373
+ const v = String(binding.conversationId ?? "")
374
+ .trim()
375
+ .slice(0, 128);
376
+ if (v !== t.conversationId) {
377
+ t.conversationId = v;
378
+ dirty = true;
379
+ }
380
+ }
381
+ if (binding.sessionFile !== undefined) {
382
+ const v = String(binding.sessionFile ?? "")
383
+ .trim()
384
+ .slice(0, 1024);
385
+ if (v !== (t.sessionFile ?? "")) {
386
+ t.sessionFile = v;
387
+ dirty = true;
388
+ }
389
+ }
390
+ if (!dirty)
391
+ return false;
392
+ t.updatedAt = Date.now();
393
+ this.save();
394
+ this.changed();
395
+ return true;
396
+ }
338
397
  async tick() {
339
398
  const now = Date.now();
340
399
  for (const task of this.tasks.values()) {
@@ -0,0 +1,140 @@
1
+ /**
2
+ * AI commit-message generation — pure prompt assembly + response cleanup.
3
+ *
4
+ * The git gathering lives in scm.ts (scmCommitContext); the one-off model
5
+ * call lives in agent-service (ClientSession.scmGenCommitMessage, same
6
+ * completeSimple path as the vision bridge). This module owns everything
7
+ * deterministic so it stays unit-testable:
8
+ *
9
+ * buildCommitMsgInput(ctx, fallbackLang) → user message (null = nothing
10
+ * to commit — the caller turns that into a friendly error)
11
+ * COMMITMSG_SYSTEM_PROMPT → system prompt
12
+ * sanitizeCommitMessage(raw) → the single-line subject that
13
+ * ends up in the commit input (models are told to emit exactly that, but
14
+ * fences/labels/quotes/newlines are stripped defensively)
15
+ *
16
+ * Language policy: the generated message follows the repository's own commit
17
+ * subjects (style AND language — a repo writing "feat(xxx): 描述" keeps
18
+ * getting that). Only a repo with no commits yet falls back to the UI
19
+ * language passed as fallbackLang.
20
+ */
21
+ /** Hard cap for the generated subject — the commit input is single-line and
22
+ * this keeps a chatty model from filling the whole header row. */
23
+ export const MAX_COMMITMSG_LEN = 200;
24
+ /** File list cap inside the prompt (porcelain lines); the diffs carry the
25
+ * details, the list is orientation only. */
26
+ const MAX_FILE_LINES = 200;
27
+ export const COMMITMSG_SYSTEM_PROMPT = `You write git commit messages. You receive the pending changes of a repository and output exactly one commit message: a single subject line.
28
+
29
+ Rules:
30
+ 1. Match the style, format and language of the repository's recent commit subjects when provided — conventional commits ("type(scope): summary"), prefixes, punctuation, language: copy what the repo actually does.
31
+ 2. Describe what the change does at a glance (the user-facing effect), not a file-by-file listing.
32
+ 3. The staged diff is the subject of the commit. Describe staged changes when any exist; otherwise describe the unstaged and untracked changes shown.
33
+ 4. One line. No quotes, no markdown, no code fences, no "Commit message:" label, no explanation. Under 72 characters when the repository's language allows it (CJK text may naturally be shorter).
34
+ 5. Never invent scope names, issue numbers or details not visible in the provided material.
35
+ 6. If the repository has no commits yet, write the message in the language stated in the request.
36
+
37
+ Output ONLY the subject line.`;
38
+ /**
39
+ * Assemble the effective system prompt from the settings-panel prefs (same
40
+ * contract as the vision bridge's buildVisionBridgePrompt): mode "append"
41
+ * puts custom text after the built-in default (empty custom = pure default);
42
+ * mode "replace" swaps in the custom text, but an empty custom still falls
43
+ * back to the default (never send an empty system prompt).
44
+ */
45
+ export function buildCommitMsgPrompt(mode, custom) {
46
+ const text = custom?.trim() ?? "";
47
+ if (mode === "replace" && text)
48
+ return text;
49
+ if (text)
50
+ return `${COMMITMSG_SYSTEM_PROMPT}\n\n${text}`;
51
+ return COMMITMSG_SYSTEM_PROMPT;
52
+ }
53
+ /** "path → [add,del]" map → compact "+12/-3 path" lines (sorted by path). */
54
+ function formatNumStat(stat) {
55
+ return Object.keys(stat)
56
+ .sort()
57
+ .map((p) => `+${stat[p][0]}/-${stat[p][1]} ${p}`);
58
+ }
59
+ /**
60
+ * Assemble the user message from the gathered context. Returns null when
61
+ * there is nothing to describe (no staged/unstaged diff and no untracked
62
+ * files) — the caller replies "no changes" instead of calling the model.
63
+ */
64
+ export function buildCommitMsgInput(ctx, fallbackLang) {
65
+ const parts = [];
66
+ if (ctx.subjects.length > 0) {
67
+ parts.push("Recent commit subjects (newest first — style reference):");
68
+ parts.push(...ctx.subjects.map((s) => `- ${s}`));
69
+ }
70
+ else {
71
+ parts.push(fallbackLang === "zh"
72
+ ? "(仓库还没有任何提交;请用中文写这条提交信息)"
73
+ : "(The repository has no commits yet; write this commit message in English.)");
74
+ }
75
+ if (ctx.files.length > 0) {
76
+ const shown = ctx.files.slice(0, MAX_FILE_LINES);
77
+ const more = ctx.files.length - shown.length;
78
+ parts.push("Changed files (porcelain status):");
79
+ parts.push(...shown.map((f) => `${f.x}${f.y} ${f.path}`));
80
+ if (more > 0)
81
+ parts.push(`… (+${more} more files)`);
82
+ }
83
+ if (ctx.stagedPatch.trim()) {
84
+ parts.push("Staged diff (these changes are the commit's subject):");
85
+ if (Object.keys(ctx.stagedStat).length > 0) {
86
+ parts.push(...formatNumStat(ctx.stagedStat));
87
+ }
88
+ parts.push(ctx.stagedPatch);
89
+ }
90
+ if (ctx.worktreePatch.trim()) {
91
+ parts.push("Unstaged diff (worktree only):");
92
+ if (Object.keys(ctx.worktreeStat).length > 0) {
93
+ parts.push(...formatNumStat(ctx.worktreeStat));
94
+ }
95
+ parts.push(ctx.worktreePatch);
96
+ }
97
+ const untracked = ctx.files.filter((f) => f.x === "?" && f.y === "?");
98
+ if (untracked.length > 0 && !ctx.stagedPatch.trim()) {
99
+ // Untracked content never appears in `git diff` — name the files so
100
+ // the model knows they are part of the change set (commit-all flow).
101
+ parts.push(`Untracked files (new, content not shown): ${untracked
102
+ .map((f) => f.path)
103
+ .join(", ")
104
+ .slice(0, 2000)}`);
105
+ }
106
+ if (!ctx.stagedPatch.trim() && !ctx.worktreePatch.trim() && untracked.length === 0)
107
+ return null;
108
+ return parts.join("\n");
109
+ }
110
+ /**
111
+ * Normalize whatever the model emitted into the single-line subject that goes
112
+ * into the commit input: drop fences, labels and wrapping quotes, keep only
113
+ * the first non-empty line, collapse whitespace, cap the length.
114
+ */
115
+ export function sanitizeCommitMessage(raw) {
116
+ let text = raw.trim();
117
+ // ```…``` fences wrapping the whole answer.
118
+ const fence = text.match(/^```[a-zA-Z]*\s*\n?([\s\S]*?)\n?```$/);
119
+ if (fence)
120
+ text = fence[1].trim();
121
+ // Leading labels some models add despite the instructions.
122
+ text = text.replace(/^(commit(\s+message)?|subject|提交信息|提交消息|提交说明)\s*[::]\s*/i, "");
123
+ // First non-empty line only — the input field is single-line by design.
124
+ const firstLine = text
125
+ .split("\n")
126
+ .map((l) => l.trim())
127
+ .find((l) => l.length > 0) ?? "";
128
+ let out = firstLine.replace(/\s+/g, " ").trim();
129
+ // Matching wrapping quotes.
130
+ if (out.length >= 2) {
131
+ const a = out[0];
132
+ const b = out[out.length - 1];
133
+ if ((a === '"' && b === '"') || (a === "'" && b === "'") || (a === "`" && b === "`")) {
134
+ out = out.slice(1, -1).trim();
135
+ }
136
+ }
137
+ if (out.length > MAX_COMMITMSG_LEN)
138
+ out = `${out.slice(0, MAX_COMMITMSG_LEN - 1).trimEnd()}…`;
139
+ return out;
140
+ }
@@ -295,3 +295,45 @@ export async function scmFileDiff(cwd, path, lang) {
295
295
  export async function scmCommitDetail(cwd, hash, lang) {
296
296
  return git(cwd, ["show", "--no-color", "--no-ext-diff", "--find-renames", "--format=fuller", "--stat", "--patch", hash], lang);
297
297
  }
298
+ /* ------------------------------------------------------------------ */
299
+ /* AI commit-message context */
300
+ /* ------------------------------------------------------------------ */
301
+ /** Cap per patch (staged / worktree) fed to the commit-message prompt —
302
+ * enough for the model to see what most commits are about without letting
303
+ * one huge repo blow up the one-off completion request. */
304
+ const MAX_COMMITMSG_PATCH_CHARS = 12_000;
305
+ /** Cap one patch at MAX_COMMITMSG_PATCH_CHARS, keeping the head (early files
306
+ * matter most for a commit message) and marking the cut. */
307
+ function capPatch(text) {
308
+ if (text.length <= MAX_COMMITMSG_PATCH_CHARS)
309
+ return text;
310
+ return `${text.slice(0, MAX_COMMITMSG_PATCH_CHARS)}\n… (diff truncated)`;
311
+ }
312
+ /** Gather staged/worktree patches, numstats, status and recent subjects for
313
+ * the AI commit-message generator. Throws the usual git errors (including
314
+ * "not a git repository") — the caller maps them to friendly replies. */
315
+ export async function scmCommitContext(cwd, lang) {
316
+ const [stagedPatch, worktreePatch, stagedStatText, worktreeStatText, statusText, subjectText] = await Promise.all([
317
+ git(cwd, ["diff", "--cached", "--no-color", "--no-ext-diff", "--find-renames"], lang).catch(() => ""),
318
+ git(cwd, ["diff", "--no-color", "--no-ext-diff", "--find-renames"], lang).catch(() => ""),
319
+ git(cwd, ["diff", "--cached", "--numstat"], lang).catch(() => ""),
320
+ git(cwd, ["diff", "--numstat"], lang).catch(() => ""),
321
+ git(cwd, ["status", "--porcelain=v1", "--find-renames"], lang),
322
+ // Empty repos (no commits yet) make `git log` fail — that's a valid
323
+ // "no style reference" answer, not an error worth failing the request.
324
+ git(cwd, ["log", "-n", "15", "--pretty=format:%s"], lang).then((s) => s, () => ""),
325
+ ]);
326
+ const subjects = subjectText
327
+ .split("\n")
328
+ .map((s) => s.trim())
329
+ .filter(Boolean);
330
+ return {
331
+ subjects,
332
+ emptyRepo: subjects.length === 0,
333
+ files: parseStatusFiles(statusText),
334
+ stagedStat: parseNumStat(stagedStatText),
335
+ worktreeStat: parseNumStat(worktreeStatText),
336
+ stagedPatch: capPatch(stagedPatch),
337
+ worktreePatch: capPatch(worktreePatch),
338
+ };
339
+ }
@@ -1,6 +1,13 @@
1
1
  const TEXT_CAP = 200_000;
2
2
  const TOOL_OUTPUT_CAP = 100_000;
3
3
  const ARGS_CAP = 20_000;
4
+ /**
5
+ * toolResult.details 的体积上限。details 是给 UI 用的结构化元数据(如
6
+ * present_files 的卡片数据、ask_user_question 的答案),快照每 60ms 推一次,
7
+ * 不能无节制。超过上限时**整个丢掉**(而不是截断 —— 截断后的 JSON 不可解析,
8
+ * 前端还得写容错);工具作者应自己封顶(见 present-files-tool.ts 的摘录预算)。
9
+ */
10
+ const TOOL_DETAILS_CAP = 64_000;
4
11
  function truncate(s, cap) {
5
12
  if (s.length <= cap)
6
13
  return { text: s, truncated: false };
@@ -106,7 +113,7 @@ export function serializeMessage(m, seq) {
106
113
  case "toolResult": {
107
114
  const raw = m.content.map((c) => (c.type === "text" ? c.text : "[image result]")).join("\n");
108
115
  const { text, truncated } = truncate(raw, TOOL_OUTPUT_CAP);
109
- return {
116
+ const msg = {
110
117
  id: `t-${m.toolCallId}`,
111
118
  role: "toolResult",
112
119
  content: [{ type: "text", text, truncated }],
@@ -115,6 +122,19 @@ export function serializeMessage(m, seq) {
115
122
  isError: m.isError,
116
123
  timestamp: m.timestamp,
117
124
  };
125
+ // 结构化元数据(tool result details)也要下发:present_files 的预览卡片
126
+ // 就靠它拿 kind/size/excerpt(前端不能靠再打一次 HTTP 才知类型),
127
+ // ask_user_question/todo_list 同理。体积封顶,超限整丢(见 TOOL_DETAILS_CAP)。
128
+ if (m.details !== undefined) {
129
+ try {
130
+ if (JSON.stringify(m.details).length <= TOOL_DETAILS_CAP)
131
+ msg.details = m.details;
132
+ }
133
+ catch {
134
+ // 循环引用等序列化不了的值:details 是附加信息,丢掉不影响消息本体。
135
+ }
136
+ }
137
+ return msg;
118
138
  }
119
139
  case "bashExecution": {
120
140
  const { text, truncated } = truncate(m.output, TOOL_OUTPUT_CAP);
@@ -10,7 +10,9 @@ import { existsSync, readdirSync, readFileSync } from "node:fs";
10
10
  import { basename, dirname, join } from "node:path";
11
11
  import { fileURLToPath } from "node:url";
12
12
  import { extensionKey, normalizeDisabledPluginTools, normalizeRetryMaxAttempts, normalizeSkillList, normalizeUiLayout, } from "./client-state.js";
13
+ import { normalizeSoftCapByModel, normalizeSoftCapTokens } from "./soft-cap.js";
13
14
  import { findVisionModels, SYSTEM_PROMPT } from "./vision-bridge.js";
15
+ import { COMMITMSG_SYSTEM_PROMPT } from "./scm-commitmsg.js";
14
16
  import { DEFAULT_TEMPLATES } from "./subagent-templates.js";
15
17
  import { deriveLegacy, foldLegacyIntoDisabled, normalizeDisabledAgentTools } from "./tool-manager.js";
16
18
  export class SettingsService {
@@ -287,8 +289,10 @@ export class SettingsService {
287
289
  terminalToolsEnabled: legacyTools.terminalToolsEnabled,
288
290
  terminalBash: this.settings.terminalBash,
289
291
  terminalBashIdleMs: this.settings.terminalBashIdleMs,
292
+ readDirEnabled: this.settings.readDirEnabled !== false,
290
293
  editSoftEnabled: legacyTools.editSoftEnabled,
291
294
  questionnaireEnabled: legacyTools.questionnaireEnabled,
295
+ parallelReminderEnabled: this.settings.parallelReminderEnabled ?? true,
292
296
  goalModeEnabled: this.settings.goalModeEnabled,
293
297
  devNoCache: this.settings.devNoCache ?? this.defaultDevNoCache(),
294
298
  autoReload: this.settings.autoReload ?? this.defaultDevNoCache(),
@@ -298,6 +302,8 @@ export class SettingsService {
298
302
  visionBridgeModel: this.settings.visionBridgeModel,
299
303
  visionBridgePromptMode: this.settings.visionBridgePromptMode,
300
304
  visionBridgePrompt: this.settings.visionBridgePrompt,
305
+ scmCommitMsgPromptMode: this.settings.scmCommitMsgPromptMode,
306
+ scmCommitMsgPrompt: this.settings.scmCommitMsgPrompt,
301
307
  reviewPrompt: this.settings.reviewPrompt,
302
308
  reviewDisabledSkills: [...this.settings.reviewDisabledSkills],
303
309
  disabledPlugins: [...(this.settings.disabledPlugins ?? [])],
@@ -310,6 +316,7 @@ export class SettingsService {
310
316
  // 发给模型的工具 schema(name/description/parameters)—— 只读预览。
311
317
  toolsSchema: promptSnap.toolsSchema,
312
318
  visionBridgeDefaultPrompt: SYSTEM_PROMPT,
319
+ scmCommitMsgDefaultPrompt: COMMITMSG_SYSTEM_PROMPT,
313
320
  visionModels: this.collectVisionModels(),
314
321
  disabledSkills: [...this.settings.disabledSkills],
315
322
  disabledExtensions: [...this.settings.disabledExtensions],
@@ -328,6 +335,8 @@ export class SettingsService {
328
335
  subagentDefaultTemplates: DEFAULT_TEMPLATES.map((t) => t.name),
329
336
  subagentDefaultModel: this.settings.subagentDefaultModel ?? null,
330
337
  retryMaxAttempts: this.settings.retryMaxAttempts,
338
+ softCapTokens: this.settings.softCapTokens,
339
+ softCapByModel: { ...this.settings.softCapByModel },
331
340
  subagentModels: this.collectSubagentModels(),
332
341
  quickPhrases: [...this.settings.quickPhrases],
333
342
  quickPhrasesEnabled: this.settings.quickPhrasesEnabled,
@@ -450,10 +459,18 @@ export class SettingsService {
450
459
  if (partial.terminalBashIdleMs !== undefined) {
451
460
  this.settings.terminalBashIdleMs = Math.max(0, Math.floor(partial.terminalBashIdleMs) || 0);
452
461
  }
462
+ // read 读目录开关:覆盖定义每次调用实时读取,改动即时生效,无需 reload。
463
+ if (partial.readDirEnabled !== undefined) {
464
+ this.settings.readDirEnabled = partial.readDirEnabled;
465
+ }
453
466
  // 目标模式总开关:运行时无需重载(goal bar / 服务端入口实时读取)。
454
467
  if (partial.goalModeEnabled !== undefined) {
455
468
  this.settings.goalModeEnabled = partial.goalModeEnabled;
456
469
  }
470
+ // 同项目并行提醒开关:同上,发送入口逐轮实时读取,无需 reload。
471
+ if (partial.parallelReminderEnabled !== undefined) {
472
+ this.settings.parallelReminderEnabled = partial.parallelReminderEnabled;
473
+ }
457
474
  if (partial.devNoCache !== undefined) {
458
475
  this.settings.devNoCache = partial.devNoCache;
459
476
  }
@@ -483,6 +500,12 @@ export class SettingsService {
483
500
  if (partial.visionBridgePrompt !== undefined) {
484
501
  this.settings.visionBridgePrompt = partial.visionBridgePrompt;
485
502
  }
503
+ if (partial.scmCommitMsgPromptMode !== undefined) {
504
+ this.settings.scmCommitMsgPromptMode = partial.scmCommitMsgPromptMode;
505
+ }
506
+ if (partial.scmCommitMsgPrompt !== undefined) {
507
+ this.settings.scmCommitMsgPrompt = partial.scmCommitMsgPrompt;
508
+ }
486
509
  if (partial.reviewPrompt !== undefined) {
487
510
  this.settings.reviewPrompt = partial.reviewPrompt;
488
511
  }
@@ -500,6 +523,18 @@ export class SettingsService {
500
523
  this.settings.retryMaxAttempts = normalizeRetryMaxAttempts(partial.retryMaxAttempts);
501
524
  this.host.applyRetryOverrides();
502
525
  }
526
+ if (partial.softCapTokens !== undefined || partial.softCapByModel !== undefined) {
527
+ // 压缩软上限:持久化 + 即时重算各会话的 compaction reserveTokens
528
+ // 覆盖(SDK 每次自动压缩检查前都重读 getCompactionSettings,无需
529
+ // reload;见宿主 applyCompactionOverrides,issue #229)。
530
+ if (partial.softCapTokens !== undefined)
531
+ this.settings.softCapTokens = normalizeSoftCapTokens(partial.softCapTokens);
532
+ if (partial.softCapByModel !== undefined)
533
+ this.settings.softCapByModel = normalizeSoftCapByModel(partial.softCapByModel);
534
+ this.host.applyCompactionOverrides();
535
+ // 底栏标记线读快照的 contextUsage.softCap——推一次快照让在线页即时看到。
536
+ this.host.flushSnapshot();
537
+ }
503
538
  if (partial.quickPhrases !== undefined) {
504
539
  // 归一化:去空白/空项/重名,单条 ≤200 字,最多 30 条。纯 UI 偏好,不 reload。
505
540
  const seen = new Set();
@@ -546,6 +581,8 @@ export class SettingsService {
546
581
  terminalBashIdleMs: this.settings.terminalBashIdleMs,
547
582
  editSoftEnabled: this.settings.editSoftEnabled,
548
583
  retryMaxAttempts: this.settings.retryMaxAttempts,
584
+ softCapTokens: this.settings.softCapTokens,
585
+ softCapByModel: { ...this.settings.softCapByModel },
549
586
  reviewPrompt: this.settings.reviewPrompt,
550
587
  reviewDisabledSkills: [...this.settings.reviewDisabledSkills],
551
588
  skillsFullText: [...normalizeSkillList(this.settings.skillsFullText)],
@@ -591,11 +628,18 @@ export class SettingsService {
591
628
  // 终端接管偏好随预设走;旧预设缺字段时保留当前值。
592
629
  terminalBash: p.terminalBash ?? this.settings.terminalBash,
593
630
  terminalBashIdleMs: p.terminalBashIdleMs ?? this.settings.terminalBashIdleMs,
631
+ // read 读目录是纯运行行为开关,不进预设——保留当前值。
632
+ readDirEnabled: this.settings.readDirEnabled !== false,
594
633
  editSoftEnabled: presetLegacy.editSoftEnabled,
595
634
  // 重试次数随预设走;旧预设缺字段时保留当前值,应用后即时注入各会话。
596
635
  retryMaxAttempts: p.retryMaxAttempts ?? this.settings.retryMaxAttempts,
636
+ // 压缩软上限同样随预设走(issue #229);旧预设缺字段时保留当前值。
637
+ softCapTokens: normalizeSoftCapTokens(p.softCapTokens ?? this.settings.softCapTokens),
638
+ softCapByModel: normalizeSoftCapByModel(p.softCapByModel ?? this.settings.softCapByModel),
597
639
  // 问卷开关不进预设——保留当前值。
598
640
  questionnaireEnabled: this.settings.questionnaireEnabled,
641
+ // 同项目并行提醒开关不进预设——保留当前值。
642
+ parallelReminderEnabled: this.settings.parallelReminderEnabled ?? true,
599
643
  // 目标模式总开关不进预设——保留当前值。
600
644
  goalModeEnabled: this.settings.goalModeEnabled,
601
645
  reviewPrompt: p.reviewPrompt ?? this.settings.reviewPrompt,
@@ -614,6 +658,9 @@ export class SettingsService {
614
658
  visionBridgeModel: this.settings.visionBridgeModel,
615
659
  visionBridgePromptMode: this.settings.visionBridgePromptMode,
616
660
  visionBridgePrompt: this.settings.visionBridgePrompt,
661
+ // 「AI 提交信息」提示词同样不进预设——保留当前值。
662
+ scmCommitMsgPromptMode: this.settings.scmCommitMsgPromptMode,
663
+ scmCommitMsgPrompt: this.settings.scmCommitMsgPrompt,
617
664
  // 子代理默认模型也不进预设——保留当前值。
618
665
  subagentDefaultModel: this.settings.subagentDefaultModel,
619
666
  // 快捷短语是纯 UI 偏好,不进预设——保留当前值。