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.
- package/CHANGELOG.md +48 -1
- package/dist/server/agent-service.js +763 -61
- package/dist/server/client-state.js +70 -5
- package/dist/server/compaction-markers.js +287 -0
- package/dist/server/delegate-task.js +12 -1
- package/dist/server/dsh/dsh-agent-service.js +21 -1
- package/dist/server/file-transfer-routes.js +4 -1
- package/dist/server/icon-svg.js +35 -0
- package/dist/server/index.js +120 -30
- package/dist/server/plugin-catalog.js +5 -0
- package/dist/server/plugin-facilities.js +42 -11
- package/dist/server/plugins.js +47 -8
- package/dist/server/prompt-composer.js +1 -1
- package/dist/server/protocol-version.js +1 -1
- 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/settings-service.js +40 -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/tool-manager.js +7 -1
- package/package.json +2 -1
- package/plugins/catalog.json +10 -0
- package/web/dist/assets/{TerminalPanel-CkxM4Byl.js → TerminalPanel-GtDQ1h85.js} +1 -1
- package/web/dist/assets/es-DIDZppIF.js +2 -0
- package/web/dist/assets/index-CdsKltOS.css +1 -0
- package/web/dist/assets/index-OoULNF70.js +363 -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
|
@@ -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
|
+
}
|
package/dist/server/scm.js
CHANGED
|
@@ -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)
|