pi-web-ui 0.90.1 → 0.91.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 (33) hide show
  1. package/CHANGELOG.md +48 -1
  2. package/dist/server/agent-service.js +763 -61
  3. package/dist/server/client-state.js +70 -5
  4. package/dist/server/compaction-markers.js +287 -0
  5. package/dist/server/delegate-task.js +12 -1
  6. package/dist/server/dsh/dsh-agent-service.js +21 -1
  7. package/dist/server/file-transfer-routes.js +4 -1
  8. package/dist/server/icon-svg.js +35 -0
  9. package/dist/server/index.js +120 -30
  10. package/dist/server/plugin-catalog.js +5 -0
  11. package/dist/server/plugin-facilities.js +42 -11
  12. package/dist/server/plugins.js +47 -8
  13. package/dist/server/prompt-composer.js +1 -1
  14. package/dist/server/protocol-version.js +1 -1
  15. package/dist/server/schedule-agent-tool.js +23 -5
  16. package/dist/server/scheduler-tasks.js +60 -1
  17. package/dist/server/scm-commitmsg.js +140 -0
  18. package/dist/server/scm.js +42 -0
  19. package/dist/server/settings-service.js +40 -0
  20. package/dist/server/skill-tool.js +120 -0
  21. package/dist/server/soft-cap.js +68 -0
  22. package/dist/server/subagent-templates.js +6 -0
  23. package/dist/server/subagents.js +157 -16
  24. package/dist/server/tool-manager.js +7 -1
  25. package/package.json +2 -1
  26. package/plugins/catalog.json +10 -0
  27. package/web/dist/assets/{TerminalPanel-CkxM4Byl.js → TerminalPanel-GtDQ1h85.js} +1 -1
  28. package/web/dist/assets/es-DIDZppIF.js +2 -0
  29. package/web/dist/assets/index-CdsKltOS.css +1 -0
  30. package/web/dist/assets/index-OoULNF70.js +363 -0
  31. package/web/dist/index.html +2 -2
  32. package/web/dist/assets/index-DJIV4A-t.js +0 -355
  33. package/web/dist/assets/index-DLdcRBs_.css +0 -1
@@ -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
+ }
@@ -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 {
@@ -289,6 +291,7 @@ export class SettingsService {
289
291
  terminalBashIdleMs: this.settings.terminalBashIdleMs,
290
292
  editSoftEnabled: legacyTools.editSoftEnabled,
291
293
  questionnaireEnabled: legacyTools.questionnaireEnabled,
294
+ parallelReminderEnabled: this.settings.parallelReminderEnabled ?? true,
292
295
  goalModeEnabled: this.settings.goalModeEnabled,
293
296
  devNoCache: this.settings.devNoCache ?? this.defaultDevNoCache(),
294
297
  autoReload: this.settings.autoReload ?? this.defaultDevNoCache(),
@@ -298,6 +301,8 @@ export class SettingsService {
298
301
  visionBridgeModel: this.settings.visionBridgeModel,
299
302
  visionBridgePromptMode: this.settings.visionBridgePromptMode,
300
303
  visionBridgePrompt: this.settings.visionBridgePrompt,
304
+ scmCommitMsgPromptMode: this.settings.scmCommitMsgPromptMode,
305
+ scmCommitMsgPrompt: this.settings.scmCommitMsgPrompt,
301
306
  reviewPrompt: this.settings.reviewPrompt,
302
307
  reviewDisabledSkills: [...this.settings.reviewDisabledSkills],
303
308
  disabledPlugins: [...(this.settings.disabledPlugins ?? [])],
@@ -310,6 +315,7 @@ export class SettingsService {
310
315
  // 发给模型的工具 schema(name/description/parameters)—— 只读预览。
311
316
  toolsSchema: promptSnap.toolsSchema,
312
317
  visionBridgeDefaultPrompt: SYSTEM_PROMPT,
318
+ scmCommitMsgDefaultPrompt: COMMITMSG_SYSTEM_PROMPT,
313
319
  visionModels: this.collectVisionModels(),
314
320
  disabledSkills: [...this.settings.disabledSkills],
315
321
  disabledExtensions: [...this.settings.disabledExtensions],
@@ -328,6 +334,8 @@ export class SettingsService {
328
334
  subagentDefaultTemplates: DEFAULT_TEMPLATES.map((t) => t.name),
329
335
  subagentDefaultModel: this.settings.subagentDefaultModel ?? null,
330
336
  retryMaxAttempts: this.settings.retryMaxAttempts,
337
+ softCapTokens: this.settings.softCapTokens,
338
+ softCapByModel: { ...this.settings.softCapByModel },
331
339
  subagentModels: this.collectSubagentModels(),
332
340
  quickPhrases: [...this.settings.quickPhrases],
333
341
  quickPhrasesEnabled: this.settings.quickPhrasesEnabled,
@@ -454,6 +462,10 @@ export class SettingsService {
454
462
  if (partial.goalModeEnabled !== undefined) {
455
463
  this.settings.goalModeEnabled = partial.goalModeEnabled;
456
464
  }
465
+ // 同项目并行提醒开关:同上,发送入口逐轮实时读取,无需 reload。
466
+ if (partial.parallelReminderEnabled !== undefined) {
467
+ this.settings.parallelReminderEnabled = partial.parallelReminderEnabled;
468
+ }
457
469
  if (partial.devNoCache !== undefined) {
458
470
  this.settings.devNoCache = partial.devNoCache;
459
471
  }
@@ -483,6 +495,12 @@ export class SettingsService {
483
495
  if (partial.visionBridgePrompt !== undefined) {
484
496
  this.settings.visionBridgePrompt = partial.visionBridgePrompt;
485
497
  }
498
+ if (partial.scmCommitMsgPromptMode !== undefined) {
499
+ this.settings.scmCommitMsgPromptMode = partial.scmCommitMsgPromptMode;
500
+ }
501
+ if (partial.scmCommitMsgPrompt !== undefined) {
502
+ this.settings.scmCommitMsgPrompt = partial.scmCommitMsgPrompt;
503
+ }
486
504
  if (partial.reviewPrompt !== undefined) {
487
505
  this.settings.reviewPrompt = partial.reviewPrompt;
488
506
  }
@@ -500,6 +518,18 @@ export class SettingsService {
500
518
  this.settings.retryMaxAttempts = normalizeRetryMaxAttempts(partial.retryMaxAttempts);
501
519
  this.host.applyRetryOverrides();
502
520
  }
521
+ if (partial.softCapTokens !== undefined || partial.softCapByModel !== undefined) {
522
+ // 压缩软上限:持久化 + 即时重算各会话的 compaction reserveTokens
523
+ // 覆盖(SDK 每次自动压缩检查前都重读 getCompactionSettings,无需
524
+ // reload;见宿主 applyCompactionOverrides,issue #229)。
525
+ if (partial.softCapTokens !== undefined)
526
+ this.settings.softCapTokens = normalizeSoftCapTokens(partial.softCapTokens);
527
+ if (partial.softCapByModel !== undefined)
528
+ this.settings.softCapByModel = normalizeSoftCapByModel(partial.softCapByModel);
529
+ this.host.applyCompactionOverrides();
530
+ // 底栏标记线读快照的 contextUsage.softCap——推一次快照让在线页即时看到。
531
+ this.host.flushSnapshot();
532
+ }
503
533
  if (partial.quickPhrases !== undefined) {
504
534
  // 归一化:去空白/空项/重名,单条 ≤200 字,最多 30 条。纯 UI 偏好,不 reload。
505
535
  const seen = new Set();
@@ -546,6 +576,8 @@ export class SettingsService {
546
576
  terminalBashIdleMs: this.settings.terminalBashIdleMs,
547
577
  editSoftEnabled: this.settings.editSoftEnabled,
548
578
  retryMaxAttempts: this.settings.retryMaxAttempts,
579
+ softCapTokens: this.settings.softCapTokens,
580
+ softCapByModel: { ...this.settings.softCapByModel },
549
581
  reviewPrompt: this.settings.reviewPrompt,
550
582
  reviewDisabledSkills: [...this.settings.reviewDisabledSkills],
551
583
  skillsFullText: [...normalizeSkillList(this.settings.skillsFullText)],
@@ -594,8 +626,13 @@ export class SettingsService {
594
626
  editSoftEnabled: presetLegacy.editSoftEnabled,
595
627
  // 重试次数随预设走;旧预设缺字段时保留当前值,应用后即时注入各会话。
596
628
  retryMaxAttempts: p.retryMaxAttempts ?? this.settings.retryMaxAttempts,
629
+ // 压缩软上限同样随预设走(issue #229);旧预设缺字段时保留当前值。
630
+ softCapTokens: normalizeSoftCapTokens(p.softCapTokens ?? this.settings.softCapTokens),
631
+ softCapByModel: normalizeSoftCapByModel(p.softCapByModel ?? this.settings.softCapByModel),
597
632
  // 问卷开关不进预设——保留当前值。
598
633
  questionnaireEnabled: this.settings.questionnaireEnabled,
634
+ // 同项目并行提醒开关不进预设——保留当前值。
635
+ parallelReminderEnabled: this.settings.parallelReminderEnabled ?? true,
599
636
  // 目标模式总开关不进预设——保留当前值。
600
637
  goalModeEnabled: this.settings.goalModeEnabled,
601
638
  reviewPrompt: p.reviewPrompt ?? this.settings.reviewPrompt,
@@ -614,6 +651,9 @@ export class SettingsService {
614
651
  visionBridgeModel: this.settings.visionBridgeModel,
615
652
  visionBridgePromptMode: this.settings.visionBridgePromptMode,
616
653
  visionBridgePrompt: this.settings.visionBridgePrompt,
654
+ // 「AI 提交信息」提示词同样不进预设——保留当前值。
655
+ scmCommitMsgPromptMode: this.settings.scmCommitMsgPromptMode,
656
+ scmCommitMsgPrompt: this.settings.scmCommitMsgPrompt,
617
657
  // 子代理默认模型也不进预设——保留当前值。
618
658
  subagentDefaultModel: this.settings.subagentDefaultModel,
619
659
  // 快捷短语是纯 UI 偏好,不进预设——保留当前值。
@@ -0,0 +1,120 @@
1
+ // ---------------------------------------------------------------------------
2
+ // skill-tool.ts — 让 AI 按名取技能全文(名录 + skill 工具)
3
+ // ---------------------------------------------------------------------------
4
+ // 背景:{{skills}} 默认只渲染名录(name/description/location),引导语原来让
5
+ // 模型“用 read 工具加载技能文件”——模型要自己拼 location 路径调 read,权限大
6
+ // 且容易猜错路径。本工具提供专用通道:skill({name}) 精确命中,读文件包成与
7
+ // SDK 一致的 `<skill name location>` 块(前端 parseSkillBlock 直接认,渲染折叠
8
+ // 卡片);不给 name 则返回名录 + 用法,找错名则给纠错列表。
9
+ //
10
+ // 约束(第 1 条定稿,不破坏现有体系):
11
+ // - 名录渲染不动;skillsFullText 名单注入不动;/skill:name 手势不动;
12
+ // - 正文每次重读(不缓存),单文件 8KB 封顶(与 fillSkillContents 同口径);
13
+ // - 禁用集由 host 在调用时过滤(与 skillsOverride 主会话语义一致),工具侧
14
+ // 查不到被禁用的技能;
15
+ // - 与 SDK 同名工具撞名时 customTools 按 name 覆盖(bash 本体是先例)。
16
+ //
17
+ // 双语约定(issue #91):definition 走 bilingual(en, zh) 内联双语;per-call
18
+ // 返回文本按 lang 取 pick(lang, zh, en, key),缺表回落英文内联。
19
+ // ---------------------------------------------------------------------------
20
+ import { statSync, readFileSync } from "node:fs";
21
+ import { defineTool } from "@earendil-works/pi-coding-agent";
22
+ import { Type } from "typebox";
23
+ import { bilingual, pick } from "./i18n.js";
24
+ import { decodeText } from "./text-sniff.js";
25
+ /** 单文件正文上限(与 agent-service fillSkillContents 同口径:8KB)。 */
26
+ export const SKILL_TOOL_FILE_CAP = 8192;
27
+ /** 精确名匹配(大小写敏感,与 skillsFullText 名单语义一致)。 */
28
+ export function findSkill(skills, name) {
29
+ const n = name.trim();
30
+ if (!n)
31
+ return undefined;
32
+ return skills.find((s) => s.name === n);
33
+ }
34
+ /** 大小写不敏感子串纠错(空 query 全匹配,调用方截行数)。 */
35
+ export function suggestSkills(skills, name) {
36
+ const q = name.trim().toLowerCase();
37
+ if (!q)
38
+ return skills;
39
+ return skills.filter((s) => s.name.toLowerCase().includes(q) || s.description.toLowerCase().includes(q));
40
+ }
41
+ /** 名录文本(一行一名 + 一句话描述,无技能时给空目录句)。 */
42
+ export function formatSkillCatalog(skills, lang) {
43
+ if (skills.length === 0) {
44
+ return pick(lang, "当前没有可用技能。", "No skills are currently available.", "skill.catalog.empty");
45
+ }
46
+ const head = pick(lang, `可用技能(${skills.length}):`, `Available skills (${skills.length}):`, "skill.catalog.title", { count: skills.length });
47
+ const lines = skills.map((s) => `- ${s.name}${s.description.trim() ? ` — ${s.description.trim()}` : ""}`);
48
+ return `${head}\n${lines.join("\n")}`;
49
+ }
50
+ /** 技能全文块(与 SDK 的 /skill:name 展开格式一致,前端按 skill 卡片渲染)。 */
51
+ export function renderSkillContent(name, location, body) {
52
+ return `<skill name="${name}" location="${location}">\n${body.trim()}\n</skill>`;
53
+ }
54
+ export function makeSkillTool(host, lang) {
55
+ const getLang = lang ?? (() => "en");
56
+ const text = (t, details = {}) => ({
57
+ content: [{ type: "text", text: t }],
58
+ details,
59
+ });
60
+ return defineTool({
61
+ name: "skill",
62
+ label: "Load a skill",
63
+ description: bilingual("Load a skill's full text by its exact name (see the <available_skills> catalog in the system prompt). " +
64
+ "Prefer this over reading the skill file with the read tool — no path guessing needed. " +
65
+ "Call without a name to list the current catalog.", "按技能精确名加载其全文(名录见系统提示词里的 <available_skills>)。" +
66
+ "优先用它,不要自己拼路径调 read 工具读技能文件。" +
67
+ "不给名则返回当前名录。"),
68
+ promptSnippet: bilingual("load a skill's full text by name (skill tool, preferred over read)", "按名加载技能全文(skill 工具,优先于 read)"),
69
+ parameters: Type.Object({
70
+ name: Type.Optional(Type.String({
71
+ description: "Exact skill name from the catalog. Omit to list the current catalog.",
72
+ })),
73
+ }),
74
+ execute: async (_id, p) => {
75
+ let skills = [];
76
+ try {
77
+ skills = host.listSkills();
78
+ }
79
+ catch {
80
+ skills = [];
81
+ }
82
+ const L = getLang();
83
+ const name = typeof p.name === "string" ? p.name.trim() : "";
84
+ // 无名 → 名录 + 用法。
85
+ if (!name) {
86
+ const hint = pick(L, "用 skill({name}) 按名取全文。", "Use skill({name}) to load the full text by name.", "skill.catalog.hint");
87
+ return text(`${formatSkillCatalog(skills, L)}\n${hint}`, { count: skills.length });
88
+ }
89
+ const found = findSkill(skills, name);
90
+ // 找错名 → 纠错列表(不抛错,报名录)。
91
+ if (!found) {
92
+ const sug = suggestSkills(skills, name).slice(0, 10);
93
+ const head = pick(L, `没有名为 ${name} 的技能。`, `No skill named ${name}.`, "skill.not.found", { name });
94
+ const sugText = sug.length > 0 ? `\n${formatSkillCatalog(sug, L)}` : `\n${formatSkillCatalog(skills, L)}`;
95
+ return text(`${head}${sugText}`, { name, count: skills.length });
96
+ }
97
+ // 命中 → 最好努力读正文(每次重读,不缓存;失败/超限给可读错)。
98
+ if (!found.filePath) {
99
+ return text(pick(L, `技能 ${name} 没有关联文件路径,取不到全文。`, `Skill ${name} has no file path; full text unavailable.`, "skill.no.filepath", { name }));
100
+ }
101
+ try {
102
+ const st = statSync(found.filePath);
103
+ if (!st.isFile() || st.size <= 0 || st.size > SKILL_TOOL_FILE_CAP) {
104
+ return text(pick(L, `技能 ${name} 的文件不可读(不存在/超 ${SKILL_TOOL_FILE_CAP / 1024}KB 上限)。`, `Skill ${name} file unreadable (missing or over the ${SKILL_TOOL_FILE_CAP / 1024}KB cap).`, "skill.file.unreadable", { name }));
105
+ }
106
+ const body = decodeText(readFileSync(found.filePath).subarray(0, st.size)).trim();
107
+ if (!body) {
108
+ return text(pick(L, `技能 ${name} 的文件是空的。`, `Skill ${name} file is empty.`, "skill.file.empty", { name }));
109
+ }
110
+ return text(renderSkillContent(found.name, found.filePath, body), {
111
+ name: found.name,
112
+ location: found.filePath,
113
+ });
114
+ }
115
+ catch {
116
+ return text(pick(L, `技能 ${name} 的文件读取失败(可能已被删除或移走)。`, `Failed to read skill ${name} file (may have been deleted or moved).`, "skill.file.failed", { name }));
117
+ }
118
+ },
119
+ });
120
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * 自定义上下文压缩阈值(Soft Cap,issue #229)。
3
+ *
4
+ * 背景:SDK 的自动压缩只看物理上限(shouldCompact:tokens > window - reserveTokens,
5
+ * reserveTokens 默认 16384)。对 1M 上下文的模型会堆到 ~900K 才压;对 Grok 这类
6
+ * 200K 起阶梯翻倍计费的模型,会在高价区跑很久,还伴随长上下文降智。
7
+ *
8
+ * 实现:软上限 C 转成 reserveTokens = W - C,经
9
+ * session.settingsManager.applyOverrides({ compaction: { reserveTokens } })
10
+ * 注入(与重试次数覆盖同一 live 机制,reload/建会话/换模型后重放)。
11
+ * 关掉软上限时回填 SDK 默认(DEFAULT_COMPACTION_SETTINGS.reserveTokens),
12
+ * 不让旧覆盖泄漏。
13
+ */
14
+ import { DEFAULT_COMPACTION_SETTINGS } from "@earendil-works/pi-coding-agent";
15
+ /** SDK 默认保留 token(软上限关闭时的回填值)。 */
16
+ export const DEFAULT_COMPACTION_RESERVE_TOKENS = DEFAULT_COMPACTION_SETTINGS.reserveTokens;
17
+ /** 软上限下限(tokens):低于此值每轮都压,无意义,直接视为关闭。 */
18
+ export const SOFT_CAP_MIN_TOKENS = 2000;
19
+ /** 压缩后至少要剩这么多工作空间(摘要本身也要占 reserve*0.8 的预算)。 */
20
+ export const SOFT_CAP_MIN_HEADROOM = 2048;
21
+ /** 单客户端最多存多少条按模型覆盖(防手滑粘贴刷爆 client-state)。 */
22
+ export const SOFT_CAP_MAX_OVERRIDES = 64;
23
+ /** 归一化全局软上限:非数值/<=0 = 关闭;钳制到 [0, 10_000_000] 整数。 */
24
+ export function normalizeSoftCapTokens(v) {
25
+ const n = Math.floor(Number(v));
26
+ if (!Number.isFinite(n) || n <= 0)
27
+ return 0;
28
+ return Math.min(10_000_000, n);
29
+ }
30
+ /** 归一化按模型覆盖:key = "provider/id" 非空(≤200 字),value 走
31
+ * normalizeSoftCapTokens;0 值条目丢弃(= 用全局);上限 64 条。纯函数。 */
32
+ export function normalizeSoftCapByModel(v) {
33
+ if (!v || typeof v !== "object" || Array.isArray(v))
34
+ return {};
35
+ const out = {};
36
+ for (const [k, val] of Object.entries(v)) {
37
+ if (Object.keys(out).length >= SOFT_CAP_MAX_OVERRIDES)
38
+ break;
39
+ const key = String(k).trim();
40
+ if (!key || key.length > 200)
41
+ continue;
42
+ const n = normalizeSoftCapTokens(val);
43
+ if (n > 0)
44
+ out[key] = n;
45
+ }
46
+ return out;
47
+ }
48
+ /** 当前模型的生效软上限(0 = 关闭):按模型覆盖优先于全局。 */
49
+ export function effectiveSoftCap(globalCap, byModel, modelId) {
50
+ if (modelId) {
51
+ const per = byModel?.[modelId.trim()];
52
+ if (per !== undefined && per > 0)
53
+ return per;
54
+ }
55
+ return globalCap > 0 ? globalCap : 0;
56
+ }
57
+ /** 软上限 → SDK reserveTokens(null = 不合法/关闭,走 SDK 默认)。
58
+ * 合法条件:cap ≥ SOFT_CAP_MIN_TOKENS 且压完至少剩 SOFT_CAP_MIN_HEADROOM。 */
59
+ export function softCapToReserve(contextWindow, cap) {
60
+ if (!Number.isFinite(contextWindow) || contextWindow <= 0)
61
+ return null;
62
+ if (!Number.isFinite(cap) || cap < SOFT_CAP_MIN_TOKENS)
63
+ return null;
64
+ const reserve = Math.floor(contextWindow - cap);
65
+ if (reserve < SOFT_CAP_MIN_HEADROOM)
66
+ return null;
67
+ return reserve;
68
+ }
@@ -623,6 +623,12 @@ export class SubagentTemplatesStore {
623
623
  const t = normalize(input);
624
624
  if (!t)
625
625
  return `模板名称不合法(去空白后 1-${NAME_MAX} 字符)`;
626
+ // replace + 空提示词 = 子代理静默用默认 persona 运行(模板等于没生效),
627
+ // 在保存期直接拦下:只想限定白名单请用 append 模式。读盘的老数据不拦
628
+ // (向后兼容,只拦新保存)。
629
+ if (t.promptMode === "replace" && !t.systemPrompt.trim() && !(t.systemPromptEn ?? "").trim()) {
630
+ return `模板 "${t.name}" 用了 replace 模式但系统提示词为空:replace 会整体替换子代理 persona,空提示词等于模板没生效。请填写提示词(中文或英文至少一个),只想限定技能/扩展白名单请用 append 模式`;
631
+ }
626
632
  const list = this.load();
627
633
  const i = list.findIndex((x) => x.name === t.name);
628
634
  if (i >= 0)