min-agent 0.3.0 → 0.4.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/README.md +111 -28
- package/dist/agent.js +1119 -256
- package/dist/cli/commands/chat.js +10 -0
- package/dist/cli/commands/exec.js +32 -0
- package/dist/cli/commands/history.js +58 -0
- package/dist/cli/commands/index.js +224 -0
- package/dist/cli/commands/init.js +18 -0
- package/dist/cli/commands/mcp.js +173 -0
- package/dist/cli/commands/memory.js +69 -0
- package/dist/cli/commands/models.js +21 -0
- package/dist/cli/commands/permission.js +12 -0
- package/dist/cli/commands/rules.js +33 -0
- package/dist/cli/commands/sandbox.js +13 -0
- package/dist/cli/commands/serve.js +9 -0
- package/dist/cli/commands/setup.js +4 -0
- package/dist/cli/commands/shared.js +16 -0
- package/dist/cli/commands/skills.js +119 -0
- package/dist/cli/commands/update.js +7 -0
- package/dist/cli/commands/write-config.js +30 -0
- package/dist/cli/errors.js +36 -0
- package/dist/cli/exec-prompt.js +26 -0
- package/dist/cli/option-helpers.js +53 -0
- package/dist/cli/program.js +180 -0
- package/dist/cli.js +5 -888
- package/dist/code-mode.js +32 -14
- package/dist/compaction.js +347 -160
- package/dist/config.js +119 -10
- package/dist/confirm.js +56 -9
- package/dist/context-window.js +107 -39
- package/dist/doom-loop.js +264 -29
- package/dist/fetch-timeout.js +152 -0
- package/dist/http-approvals.js +60 -0
- package/dist/instructions.js +21 -0
- package/dist/logger.js +33 -4
- package/dist/markdown.js +37 -11
- package/dist/mcp.js +328 -30
- package/dist/memory.js +97 -56
- package/dist/output.js +7 -5
- package/dist/permission-cli.js +43 -0
- package/dist/plugins.js +46 -8
- package/dist/pricing.js +4 -4
- package/dist/provider.js +23 -6
- package/dist/question-format.js +60 -0
- package/dist/sandbox-cli.js +82 -0
- package/dist/sandbox.js +403 -0
- package/dist/save-throttle.js +45 -0
- package/dist/serve/common.js +404 -0
- package/dist/serve/routes-chat.js +347 -0
- package/dist/serve/routes-mcp.js +212 -0
- package/dist/serve/routes-memory.js +66 -0
- package/dist/serve/routes-meta.js +205 -0
- package/dist/serve/routes-sessions.js +61 -0
- package/dist/serve/routes-skills.js +70 -0
- package/dist/serve.js +33 -883
- package/dist/sessions.js +53 -9
- package/dist/skills.js +82 -18
- package/dist/title-gen.js +8 -2
- package/dist/token-display.js +36 -0
- package/dist/tool-display.js +5 -0
- package/dist/tool-output.js +1 -3
- package/dist/tools/apply_patch.js +85 -11
- package/dist/tools/atomic-file.js +35 -0
- package/dist/tools/backend.js +2 -2
- package/dist/tools/bash.js +57 -19
- package/dist/tools/code_search.js +7 -1
- package/dist/tools/edit.js +11 -10
- package/dist/tools/explore.js +74 -14
- package/dist/tools/glob.js +4 -0
- package/dist/tools/grep.js +17 -10
- package/dist/tools/index.js +6 -21
- package/dist/tools/question.js +28 -9
- package/dist/tools/read.js +6 -4
- package/dist/tools/search-searxng.js +223 -0
- package/dist/tools/search-serper.js +189 -0
- package/dist/tools/task.js +84 -30
- package/dist/tools/todo.js +120 -19
- package/dist/tools/web_fetch.js +11 -3
- package/dist/tools/web_search.js +66 -556
- package/dist/tools/write.js +23 -6
- package/dist/tui/App.js +63 -14
- package/dist/tui/ConfirmBar.js +45 -13
- package/dist/tui/InputBar.js +150 -35
- package/dist/tui/MessageList.js +266 -125
- package/dist/tui/ModelPicker.js +8 -3
- package/dist/tui/QuestionBar.js +51 -19
- package/dist/tui/SessionPicker.js +79 -0
- package/dist/tui/StatusBar.js +8 -14
- package/dist/tui/agent-runner.js +142 -22
- package/dist/tui/caret-pos.js +48 -5
- package/dist/tui/caret.js +1 -1
- package/dist/tui/click-count.js +13 -0
- package/dist/tui/drag-state.js +8 -3
- package/dist/tui/hydrate.js +129 -0
- package/dist/tui/index.js +42 -13
- package/dist/tui/input-history.js +92 -11
- package/dist/tui/layout.js +75 -4
- package/dist/tui/prompt-queue.js +24 -0
- package/dist/tui/selection.js +113 -21
- package/dist/tui/session-switch.js +28 -0
- package/dist/tui/slash-commands.js +22 -6
- package/dist/tui/slash-handler.js +233 -58
- package/dist/tui/text-width.js +38 -16
- package/dist/tui/token-info.js +7 -0
- package/dist/tui/tool-children.js +19 -0
- package/dist/tui/undo-stack.js +1 -1
- package/dist/tui/use-sgr-mouse.js +3 -1
- package/dist/tui-chat.js +276 -40
- package/dist/updater.js +88 -29
- package/dist/xml-search.js +194 -0
- package/docs/API.md +257 -25
- package/docs/superpowers/plans/2026-08-20-tui-completeness.md +873 -0
- package/docs/superpowers/plans/2026-08-20-unified-tui-default.md +631 -0
- package/docs/superpowers/specs/2026-08-20-config-http-alignment-design.md +47 -0
- package/docs/superpowers/specs/2026-08-20-mcp-plugins-alignment-design.md +37 -0
- package/docs/superpowers/specs/2026-08-20-sandbox-permissions-design.md +68 -0
- package/docs/superpowers/specs/2026-08-20-tui-completeness-design.md +273 -0
- package/docs/superpowers/specs/2026-08-20-unified-tui-default-design.md +165 -0
- package/package.json +6 -1
- package/skills/self-config/SKILL.md +90 -0
- package/skills/self-config/reference.md +149 -0
package/dist/sessions.js
CHANGED
|
@@ -1,12 +1,40 @@
|
|
|
1
|
-
import { readFileSync,
|
|
1
|
+
import { readFileSync, mkdirSync, existsSync, readdirSync, unlinkSync, openSync, readSync, closeSync, statSync, } from "fs";
|
|
2
2
|
import path from "path";
|
|
3
3
|
import { randomUUID } from "crypto";
|
|
4
|
+
import { atomicWriteFileSync } from "./tools/atomic-file.js";
|
|
4
5
|
import { getConfigDir } from "./config.js";
|
|
5
6
|
import { generateTitle } from "./title-gen.js";
|
|
6
7
|
import { isSyntheticMessage } from "./synthetic.js";
|
|
8
|
+
import { parseTaskState } from "./tools/todo.js";
|
|
7
9
|
function getSessionsDir() {
|
|
8
10
|
return path.join(getConfigDir(), "sessions");
|
|
9
11
|
}
|
|
12
|
+
/**
|
|
13
|
+
* In-process per-session mutex serializing read-modify-write cycles
|
|
14
|
+
* (load → append → run → save) so concurrent requests on the same session
|
|
15
|
+
* cannot lose messages. Cross-process safety is NOT provided.
|
|
16
|
+
*/
|
|
17
|
+
const sessionLocks = new Map();
|
|
18
|
+
export async function withSessionLock(sessionId, fn) {
|
|
19
|
+
if (!sessionId)
|
|
20
|
+
return fn();
|
|
21
|
+
const prev = sessionLocks.get(sessionId) ?? Promise.resolve();
|
|
22
|
+
let release;
|
|
23
|
+
const ours = new Promise((r) => {
|
|
24
|
+
release = r;
|
|
25
|
+
});
|
|
26
|
+
const chained = prev.then(() => ours);
|
|
27
|
+
sessionLocks.set(sessionId, chained);
|
|
28
|
+
await prev;
|
|
29
|
+
try {
|
|
30
|
+
return await fn();
|
|
31
|
+
}
|
|
32
|
+
finally {
|
|
33
|
+
release();
|
|
34
|
+
if (sessionLocks.get(sessionId) === chained)
|
|
35
|
+
sessionLocks.delete(sessionId);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
10
38
|
/** Session ids are UUIDs; a strict whitelist blocks path traversal via `/v1/sessions/..%2F..%2Fconfig`. */
|
|
11
39
|
const SESSION_ID_RE = /^[a-zA-Z0-9_-]+$/;
|
|
12
40
|
function sessionPath(id) {
|
|
@@ -81,7 +109,10 @@ function generateId() {
|
|
|
81
109
|
function textOfContent(content) {
|
|
82
110
|
if (typeof content === "string")
|
|
83
111
|
return content;
|
|
84
|
-
return content
|
|
112
|
+
return content
|
|
113
|
+
.map((p) => (p.type === "text" ? p.text : ""))
|
|
114
|
+
.join(" ")
|
|
115
|
+
.trim();
|
|
85
116
|
}
|
|
86
117
|
function deriveTitle(messages) {
|
|
87
118
|
const first = messages.find((m) => m.role === "user");
|
|
@@ -118,23 +149,26 @@ export function sanitizeForStorage(messages) {
|
|
|
118
149
|
return { ...msg, content };
|
|
119
150
|
});
|
|
120
151
|
}
|
|
121
|
-
export function saveSession(messages, existingId, title, created) {
|
|
152
|
+
export function saveSession(messages, existingId, title, created, taskState) {
|
|
122
153
|
const dir = getSessionsDir();
|
|
123
154
|
mkdirSync(dir, { recursive: true });
|
|
124
155
|
const id = existingId && SESSION_ID_RE.test(existingId) ? existingId : generateId();
|
|
125
156
|
const now = new Date().toISOString();
|
|
126
|
-
const
|
|
157
|
+
const existingMeta = existingId ? readMeta(id) : null;
|
|
158
|
+
const preservedState = taskState === undefined && existingId ? loadSession(id)?.taskState : undefined;
|
|
159
|
+
const resolvedState = taskState ?? preservedState;
|
|
127
160
|
const data = {
|
|
128
161
|
meta: {
|
|
129
162
|
id,
|
|
130
|
-
title: title ??
|
|
131
|
-
created: created ??
|
|
163
|
+
title: title ?? existingMeta?.title ?? deriveTitle(messages),
|
|
164
|
+
created: created ?? existingMeta?.created ?? now,
|
|
132
165
|
updated: now,
|
|
133
166
|
messageCount: messages.length,
|
|
134
167
|
},
|
|
168
|
+
...(resolvedState ? { taskState: resolvedState } : {}),
|
|
135
169
|
messages: sanitizeForStorage(messages),
|
|
136
170
|
};
|
|
137
|
-
|
|
171
|
+
atomicWriteFileSync(sessionPath(id), JSON.stringify(data, null, 2));
|
|
138
172
|
return id;
|
|
139
173
|
}
|
|
140
174
|
/** Read only the meta head to recover the original created/title. */
|
|
@@ -165,12 +199,22 @@ export function loadSession(id) {
|
|
|
165
199
|
if (!existsSync(file))
|
|
166
200
|
return null;
|
|
167
201
|
try {
|
|
168
|
-
|
|
202
|
+
const data = JSON.parse(readFileSync(file, "utf-8"));
|
|
203
|
+
const taskState = parseTaskState(data.taskState);
|
|
204
|
+
return taskState ? { ...data, taskState } : data;
|
|
169
205
|
}
|
|
170
206
|
catch {
|
|
171
207
|
return null;
|
|
172
208
|
}
|
|
173
209
|
}
|
|
210
|
+
export function loadExecHistory(resumeSessionId) {
|
|
211
|
+
if (!resumeSessionId)
|
|
212
|
+
return { ok: true, messages: [], sessionId: undefined };
|
|
213
|
+
const session = loadSession(resumeSessionId);
|
|
214
|
+
if (!session)
|
|
215
|
+
return { ok: false, error: `会话不存在: ${resumeSessionId}` };
|
|
216
|
+
return { ok: true, messages: [...session.messages], sessionId: resumeSessionId, taskState: session.taskState };
|
|
217
|
+
}
|
|
174
218
|
export function listSessions() {
|
|
175
219
|
const dir = getSessionsDir();
|
|
176
220
|
if (!existsSync(dir))
|
|
@@ -212,7 +256,7 @@ export function renameSession(id, title) {
|
|
|
212
256
|
const data = JSON.parse(readFileSync(file, "utf-8"));
|
|
213
257
|
data.meta.title = title;
|
|
214
258
|
data.meta.updated = new Date().toISOString();
|
|
215
|
-
|
|
259
|
+
atomicWriteFileSync(file, JSON.stringify(data, null, 2));
|
|
216
260
|
return true;
|
|
217
261
|
}
|
|
218
262
|
catch {
|
package/dist/skills.js
CHANGED
|
@@ -2,9 +2,26 @@ import { tool, jsonSchema } from "ai";
|
|
|
2
2
|
import { readFileSync, existsSync, readdirSync, realpathSync } from "fs";
|
|
3
3
|
import os from "os";
|
|
4
4
|
import path from "path";
|
|
5
|
+
import { fileURLToPath } from "url";
|
|
5
6
|
import { globSync } from "glob";
|
|
6
7
|
import { loadConfig, saveConfig, loadProjectConfig, saveProjectConfig } from "./config.js";
|
|
7
8
|
import { truncateToolOutput } from "./tool-output.js";
|
|
9
|
+
/** Packaged self-management skill; keep this name in sync with `skills/self-config/SKILL.md`. */
|
|
10
|
+
export const SELF_CONFIG_SKILL = "self-config";
|
|
11
|
+
function getPackageRoot() {
|
|
12
|
+
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
13
|
+
}
|
|
14
|
+
export function getBuiltinSkillsDir() {
|
|
15
|
+
return path.join(getPackageRoot(), "skills");
|
|
16
|
+
}
|
|
17
|
+
function builtinSkillsAllowed() {
|
|
18
|
+
const value = process.env.MIN_AGENT_NO_BUILTIN_SKILLS?.trim().toLowerCase();
|
|
19
|
+
return value !== "1" && value !== "true";
|
|
20
|
+
}
|
|
21
|
+
function isUnderDir(root, filePath) {
|
|
22
|
+
const rel = path.relative(root, filePath);
|
|
23
|
+
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
|
24
|
+
}
|
|
8
25
|
/** Coerce a config skill list to an array of names, tolerating hand-edited configs. */
|
|
9
26
|
function skillNames(value) {
|
|
10
27
|
return Array.isArray(value) ? value.filter((n) => typeof n === "string") : [];
|
|
@@ -48,7 +65,8 @@ const SKILL_CONTENT_MAX_BYTES = 32 * 1024;
|
|
|
48
65
|
* Skill scan order: later entries win on duplicate `name` in frontmatter.
|
|
49
66
|
* Global user skills first, then project-local dirs so repo skills override ~/.agents.
|
|
50
67
|
* Computed lazily so process.cwd() changes are respected.
|
|
51
|
-
* `MIN_AGENT_SKILLS_DIRS` (path-delimiter separated) replaces the default list.
|
|
68
|
+
* `MIN_AGENT_SKILLS_DIRS` (path-delimiter separated) replaces the default user list.
|
|
69
|
+
* Packaged skills are scanned separately and win on name.
|
|
52
70
|
*/
|
|
53
71
|
function getSkillDirs() {
|
|
54
72
|
const override = process.env.MIN_AGENT_SKILLS_DIRS?.trim();
|
|
@@ -107,21 +125,32 @@ export function scanSkillDir(dir, warn = stderrWarn) {
|
|
|
107
125
|
}
|
|
108
126
|
return [...byName.values()];
|
|
109
127
|
}
|
|
128
|
+
function mergeSkill(next, skill, warn) {
|
|
129
|
+
const previous = next[skill.name];
|
|
130
|
+
if (previous && sameFile(previous.location, skill.location)) {
|
|
131
|
+
if (skill.builtin && !previous.builtin)
|
|
132
|
+
next[skill.name] = { ...previous, builtin: true };
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (previous) {
|
|
136
|
+
const versionNote = previous.version && skill.version && previous.version !== skill.version
|
|
137
|
+
? ` (version ${previous.version} → ${skill.version})`
|
|
138
|
+
: "";
|
|
139
|
+
warn?.(`\x1b[33m ⚠ Duplicate skill "${skill.name}": ${skill.location} overrides ${previous.location}${versionNote}\x1b[0m`);
|
|
140
|
+
}
|
|
141
|
+
next[skill.name] = skill;
|
|
142
|
+
}
|
|
110
143
|
export function discoverSkills(opts) {
|
|
111
144
|
const warn = opts?.silent ? null : stderrWarn;
|
|
112
145
|
const next = {};
|
|
113
146
|
for (const dir of getSkillDirs()) {
|
|
114
|
-
for (const skill of scanSkillDir(dir, warn))
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
: "";
|
|
122
|
-
warn?.(`\x1b[33m ⚠ Duplicate skill "${skill.name}": ${skill.location} overrides ${previous.location}${versionNote}\x1b[0m`);
|
|
123
|
-
}
|
|
124
|
-
next[skill.name] = skill;
|
|
147
|
+
for (const skill of scanSkillDir(dir, warn))
|
|
148
|
+
mergeSkill(next, skill, warn);
|
|
149
|
+
}
|
|
150
|
+
if (builtinSkillsAllowed()) {
|
|
151
|
+
const builtinDir = getBuiltinSkillsDir();
|
|
152
|
+
for (const skill of scanSkillDir(builtinDir, warn)) {
|
|
153
|
+
mergeSkill(next, { ...skill, builtin: isUnderDir(builtinDir, skill.location) }, warn);
|
|
125
154
|
}
|
|
126
155
|
}
|
|
127
156
|
const state = resolveSkillState();
|
|
@@ -231,7 +260,9 @@ function readRequiredBins(frontmatter) {
|
|
|
231
260
|
function parseSkillFile(filePath, warn) {
|
|
232
261
|
let raw;
|
|
233
262
|
try {
|
|
234
|
-
raw = readFileSync(filePath, "utf-8")
|
|
263
|
+
raw = readFileSync(filePath, "utf-8")
|
|
264
|
+
.replace(/^\uFEFF/, "")
|
|
265
|
+
.replace(/\r\n/g, "\n");
|
|
235
266
|
}
|
|
236
267
|
catch (err) {
|
|
237
268
|
warn?.(`\x1b[33m ⚠ Skill skipped (unreadable): ${filePath} — ${err.message}\x1b[0m`);
|
|
@@ -259,8 +290,17 @@ function parseSkillFile(filePath, warn) {
|
|
|
259
290
|
allowedTools: readFrontmatterList(frontmatter, "allowed-tools"),
|
|
260
291
|
requiredBins: readRequiredBins(frontmatter),
|
|
261
292
|
version: readFrontmatterValue(frontmatter, "version") ?? undefined,
|
|
293
|
+
builtin: false,
|
|
294
|
+
alwaysLoad: readFrontmatterBool(frontmatter, "always-load"),
|
|
262
295
|
};
|
|
263
296
|
}
|
|
297
|
+
function readFrontmatterBool(frontmatter, key) {
|
|
298
|
+
const raw = readFrontmatterValue(frontmatter, key);
|
|
299
|
+
if (raw == null)
|
|
300
|
+
return false;
|
|
301
|
+
const value = raw.trim().toLowerCase();
|
|
302
|
+
return value === "true" || value === "yes" || value === "1";
|
|
303
|
+
}
|
|
264
304
|
/** Every discovered skill, disabled ones included (`enabled` reflects config). */
|
|
265
305
|
export function getAllSkills() {
|
|
266
306
|
const state = resolveSkillState();
|
|
@@ -386,7 +426,9 @@ export function buildSkillContent(skill) {
|
|
|
386
426
|
? [
|
|
387
427
|
"<skill_files>",
|
|
388
428
|
...entries.map((f) => ` <file>${escapeXml(f)}</file>`),
|
|
389
|
-
total > entries.length
|
|
429
|
+
total > entries.length
|
|
430
|
+
? ` <note>${total - entries.length} more entries not listed; use glob on the base directory</note>`
|
|
431
|
+
: "",
|
|
390
432
|
"</skill_files>",
|
|
391
433
|
]
|
|
392
434
|
.filter(Boolean)
|
|
@@ -418,7 +460,9 @@ export function getSkillsTool(tracker = new Set()) {
|
|
|
418
460
|
}),
|
|
419
461
|
execute: async ({ name }) => {
|
|
420
462
|
const state = resolveSkillState();
|
|
421
|
-
const available = Object.keys(loadedSkills)
|
|
463
|
+
const available = Object.keys(loadedSkills)
|
|
464
|
+
.filter((n) => !state.disabled.has(n))
|
|
465
|
+
.sort();
|
|
422
466
|
if (state.disabled.has(name)) {
|
|
423
467
|
return `Skill "${name}" is disabled (${state.scopeOf.get(name)} scope). Available skills: ${available.join(", ") || "none"}`;
|
|
424
468
|
}
|
|
@@ -455,9 +499,13 @@ function unescapeXmlAttr(value) {
|
|
|
455
499
|
export function buildSkillsPrompt(skills) {
|
|
456
500
|
if (skills.length === 0)
|
|
457
501
|
return "";
|
|
502
|
+
const hasAlwaysLoad = skills.some((s) => s.alwaysLoad);
|
|
458
503
|
return [
|
|
459
504
|
"Skills provide specialized instructions and workflows for specific tasks.",
|
|
460
505
|
"Use the skill tool to load a skill when a task matches its description.",
|
|
506
|
+
hasAlwaysLoad
|
|
507
|
+
? "Skills with always_load already have their full instructions in this prompt. Do not call the skill tool for those unless compaction dropped them."
|
|
508
|
+
: "",
|
|
461
509
|
"",
|
|
462
510
|
"<available_skills>",
|
|
463
511
|
...skills.flatMap((s) => [
|
|
@@ -465,10 +513,14 @@ export function buildSkillsPrompt(skills) {
|
|
|
465
513
|
` <name>${escapeXml(s.name)}</name>`,
|
|
466
514
|
` <description>${escapeXml(s.description)}</description>`,
|
|
467
515
|
` <location>${escapeXml(s.location)}</location>`,
|
|
516
|
+
s.alwaysLoad ? " <always_load>true</always_load>" : "",
|
|
517
|
+
s.builtin ? " <builtin>true</builtin>" : "",
|
|
468
518
|
" </skill>",
|
|
469
519
|
]),
|
|
470
520
|
"</available_skills>",
|
|
471
|
-
]
|
|
521
|
+
]
|
|
522
|
+
.filter((line) => line !== "")
|
|
523
|
+
.join("\n");
|
|
472
524
|
}
|
|
473
525
|
const SKILL_CONTENT_MARKER = /<skill_content name="([^"]*)"/g;
|
|
474
526
|
function textOf(part) {
|
|
@@ -530,6 +582,18 @@ export function attachSkills(tools, tracker) {
|
|
|
530
582
|
const skills = getSkills();
|
|
531
583
|
if (skills.length === 0)
|
|
532
584
|
return "";
|
|
533
|
-
|
|
534
|
-
|
|
585
|
+
const loadTracker = tracker ?? new Set();
|
|
586
|
+
const auto = skills.filter((s) => s.alwaysLoad);
|
|
587
|
+
for (const skill of auto)
|
|
588
|
+
loadTracker.add(skill.name);
|
|
589
|
+
tools.skill = getSkillsTool(loadTracker);
|
|
590
|
+
const catalog = buildSkillsPrompt(skills);
|
|
591
|
+
if (auto.length === 0)
|
|
592
|
+
return catalog;
|
|
593
|
+
return [
|
|
594
|
+
catalog,
|
|
595
|
+
"",
|
|
596
|
+
"The following skills are already loaded for this conversation. Follow them without calling the skill tool again.",
|
|
597
|
+
...auto.map(buildSkillContent),
|
|
598
|
+
].join("\n");
|
|
535
599
|
}
|
package/dist/title-gen.js
CHANGED
|
@@ -16,7 +16,10 @@ export async function generateTitle(model, messages) {
|
|
|
16
16
|
const userContent = typeof userMsg.content === "string"
|
|
17
17
|
? userMsg.content
|
|
18
18
|
: Array.isArray(userMsg.content)
|
|
19
|
-
? userMsg.content
|
|
19
|
+
? userMsg.content
|
|
20
|
+
.filter((p) => "text" in p)
|
|
21
|
+
.map((p) => p.text ?? "")
|
|
22
|
+
.join(" ")
|
|
20
23
|
: "";
|
|
21
24
|
if (!userContent.trim())
|
|
22
25
|
return null;
|
|
@@ -40,7 +43,10 @@ export async function generateTitle(model, messages) {
|
|
|
40
43
|
temperature: 0.5,
|
|
41
44
|
maxOutputTokens: 30,
|
|
42
45
|
});
|
|
43
|
-
const title = result.text
|
|
46
|
+
const title = result.text
|
|
47
|
+
.trim()
|
|
48
|
+
.replace(/^["']|["']$/g, "")
|
|
49
|
+
.slice(0, 60);
|
|
44
50
|
return title || null;
|
|
45
51
|
}
|
|
46
52
|
catch {
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export function formatTokenCount(n) {
|
|
2
|
+
const v = Math.max(0, Math.round(n));
|
|
3
|
+
if (v < 1000)
|
|
4
|
+
return String(v);
|
|
5
|
+
if (v < 100_000)
|
|
6
|
+
return `${(v / 1000).toFixed(1).replace(/\.0$/, "")}k`;
|
|
7
|
+
if (v < 1_000_000)
|
|
8
|
+
return `${Math.round(v / 1000)}k`;
|
|
9
|
+
if (v < 10_000_000)
|
|
10
|
+
return `${(v / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
|
|
11
|
+
return `${Math.round(v / 1_000_000)}M`;
|
|
12
|
+
}
|
|
13
|
+
export function contextUsagePercent(context, contextWindow) {
|
|
14
|
+
if (context <= 0 || contextWindow <= 0)
|
|
15
|
+
return null;
|
|
16
|
+
return Math.round((context / contextWindow) * 100);
|
|
17
|
+
}
|
|
18
|
+
export function tokenInfoFromTracker(tracker, contextWindow, estimatedContext = 0) {
|
|
19
|
+
return {
|
|
20
|
+
context: tracker.lastInputTokens > 0 ? tracker.lastInputTokens : estimatedContext,
|
|
21
|
+
input: tracker.totalInputTokens,
|
|
22
|
+
output: tracker.totalOutputTokens,
|
|
23
|
+
contextWindow,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export function formatTokenStatusLabel(info) {
|
|
27
|
+
const parts = [];
|
|
28
|
+
const pct = contextUsagePercent(info.context, info.contextWindow);
|
|
29
|
+
if (pct != null) {
|
|
30
|
+
parts.push(`${formatTokenCount(info.context)}/${formatTokenCount(info.contextWindow)} ${pct}%`);
|
|
31
|
+
}
|
|
32
|
+
if (info.input > 0 || info.output > 0) {
|
|
33
|
+
parts.push(`累计 ${formatTokenCount(info.input)} in · ${formatTokenCount(info.output)} out`);
|
|
34
|
+
}
|
|
35
|
+
return parts.join(" · ");
|
|
36
|
+
}
|
package/dist/tool-display.js
CHANGED
|
@@ -159,6 +159,11 @@ export function summarizeToolResult(toolName, output, isError = false, maxLen =
|
|
|
159
159
|
return `${found[1]} results`;
|
|
160
160
|
if (/^No search results found/m.test(text))
|
|
161
161
|
return "no results";
|
|
162
|
+
const titles = text.match(/^\d+\.\s+Title\s*:/gm);
|
|
163
|
+
if (titles && titles.length > 0)
|
|
164
|
+
return `${titles.length} results`;
|
|
165
|
+
if (/^Search results for /m.test(text))
|
|
166
|
+
return "results";
|
|
162
167
|
}
|
|
163
168
|
if (toolName === "skill") {
|
|
164
169
|
const loaded = /^<skill_content name="([^"]*)"/m.exec(text);
|
package/dist/tool-output.js
CHANGED
|
@@ -121,8 +121,6 @@ export function truncateToolOutput(text, options = {}) {
|
|
|
121
121
|
const filePath = writeFullToolOutput(text);
|
|
122
122
|
const pv = preview(text, maxLines, maxBytes, direction, lines, totalBytes);
|
|
123
123
|
const header = "...output truncated...\n\n";
|
|
124
|
-
const content = direction === "tail"
|
|
125
|
-
? `${header}${hint(filePath)}\n\n${pv.text}`
|
|
126
|
-
: `${pv.text}\n\n${header}${hint(filePath)}`;
|
|
124
|
+
const content = direction === "tail" ? `${header}${hint(filePath)}\n\n${pv.text}` : `${pv.text}\n\n${header}${hint(filePath)}`;
|
|
127
125
|
return { content, truncated: true, outputPath: filePath };
|
|
128
126
|
}
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { tool, jsonSchema } from "ai";
|
|
2
|
-
import { readFileSync,
|
|
2
|
+
import { readFileSync, existsSync } from "fs";
|
|
3
|
+
import { mkdir, readFile, rename, unlink, writeFile } from "fs/promises";
|
|
3
4
|
import path from "path";
|
|
4
|
-
import { confirm,
|
|
5
|
+
import { confirm, isEditAutoApprove } from "../confirm.js";
|
|
6
|
+
import { pathAccessError } from "../sandbox.js";
|
|
7
|
+
import { atomicWriteFile, tempPathFor } from "./atomic-file.js";
|
|
5
8
|
const OFFSET_TOLERANCE = 3;
|
|
6
9
|
export function parseUnifiedDiff(diff) {
|
|
7
10
|
const files = [];
|
|
@@ -127,7 +130,7 @@ export function buildPatchedFiles(files, cwd = process.cwd()) {
|
|
|
127
130
|
lines.length = 0;
|
|
128
131
|
lines.push(...r.lines);
|
|
129
132
|
}
|
|
130
|
-
contents.push({ file, content: lines.join("\n") + (lines.length > 0 ? "\n" : "") });
|
|
133
|
+
contents.push({ file, content: lines.join("\n") + (lines.length > 0 ? "\n" : ""), original: null });
|
|
131
134
|
continue;
|
|
132
135
|
}
|
|
133
136
|
if (!exists)
|
|
@@ -137,7 +140,7 @@ export function buildPatchedFiles(files, cwd = process.cwd()) {
|
|
|
137
140
|
content = readFileSync(target, "utf-8");
|
|
138
141
|
}
|
|
139
142
|
catch (err) {
|
|
140
|
-
return { ok: false, error: `${file.path}: 读取失败 ${err.message}` };
|
|
143
|
+
return { ok: false, error: `${file.path}: 读取失败 ${err instanceof Error ? err.message : String(err)}` };
|
|
141
144
|
}
|
|
142
145
|
const lines = content.split("\n");
|
|
143
146
|
for (const hunk of file.hunks) {
|
|
@@ -147,10 +150,77 @@ export function buildPatchedFiles(files, cwd = process.cwd()) {
|
|
|
147
150
|
lines.length = 0;
|
|
148
151
|
lines.push(...r.lines);
|
|
149
152
|
}
|
|
150
|
-
contents.push({ file, content: lines.join("\n") });
|
|
153
|
+
contents.push({ file, content: lines.join("\n"), original: content });
|
|
151
154
|
}
|
|
152
155
|
return { ok: true, contents };
|
|
153
156
|
}
|
|
157
|
+
function errMessage(err) {
|
|
158
|
+
return err instanceof Error ? err.message : String(err);
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* 事务化写盘:先把所有新内容写入目标旁的临时文件,再统一 rename 就位。
|
|
162
|
+
* 第一阶段失败时工作区零改动;rename 中途失败时回滚已就位的文件。
|
|
163
|
+
*/
|
|
164
|
+
async function commitPatchedFiles(contents) {
|
|
165
|
+
const cwd = process.cwd();
|
|
166
|
+
const staged = [];
|
|
167
|
+
// 漂移检查:确认弹窗期间文件可能被用户或其他进程改动。
|
|
168
|
+
for (const c of contents) {
|
|
169
|
+
const target = path.resolve(cwd, c.file.path);
|
|
170
|
+
if (c.original === null) {
|
|
171
|
+
if (existsSync(target))
|
|
172
|
+
return { ok: false, error: `${c.file.path}: 文件已存在,未应用任何更改` };
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
let current;
|
|
176
|
+
try {
|
|
177
|
+
current = await readFile(target, "utf-8");
|
|
178
|
+
}
|
|
179
|
+
catch (err) {
|
|
180
|
+
return { ok: false, error: `${c.file.path}: 读取失败 ${errMessage(err)}` };
|
|
181
|
+
}
|
|
182
|
+
if (current !== c.original) {
|
|
183
|
+
return { ok: false, error: `${c.file.path}: 文件在确认期间已被修改,未应用任何更改` };
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
for (const c of contents) {
|
|
187
|
+
const target = path.resolve(cwd, c.file.path);
|
|
188
|
+
const tmp = tempPathFor(target);
|
|
189
|
+
try {
|
|
190
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
191
|
+
await writeFile(tmp, c.content, "utf-8");
|
|
192
|
+
}
|
|
193
|
+
catch (err) {
|
|
194
|
+
for (const s of staged)
|
|
195
|
+
await unlink(s.tmp).catch(() => { });
|
|
196
|
+
await unlink(tmp).catch(() => { });
|
|
197
|
+
return { ok: false, error: `${c.file.path}: 写入失败 ${errMessage(err)}` };
|
|
198
|
+
}
|
|
199
|
+
staged.push({ rel: c.file.path, target, tmp, original: c.original });
|
|
200
|
+
}
|
|
201
|
+
const renamed = [];
|
|
202
|
+
for (const s of staged) {
|
|
203
|
+
try {
|
|
204
|
+
await rename(s.tmp, s.target);
|
|
205
|
+
renamed.push(s);
|
|
206
|
+
}
|
|
207
|
+
catch (err) {
|
|
208
|
+
for (const d of [...renamed].reverse()) {
|
|
209
|
+
try {
|
|
210
|
+
if (d.original !== null)
|
|
211
|
+
await atomicWriteFile(d.target, d.original);
|
|
212
|
+
else
|
|
213
|
+
await unlink(d.target).catch(() => { });
|
|
214
|
+
}
|
|
215
|
+
catch { }
|
|
216
|
+
}
|
|
217
|
+
for (const s2 of staged.slice(renamed.length))
|
|
218
|
+
await unlink(s2.tmp).catch(() => { });
|
|
219
|
+
return { ok: false, error: `${s.rel}: 写入失败 ${errMessage(err)},已回滚其他文件` };
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return { ok: true };
|
|
223
|
+
}
|
|
154
224
|
export const applyPatchTool = tool({
|
|
155
225
|
description: "Apply a unified diff to the working tree. Use this for precise multi-file edits or when you have a generated diff. " +
|
|
156
226
|
"Supports standard unified diff format (---/+++ headers, @@ hunks, context/delete/add lines). " +
|
|
@@ -166,6 +236,12 @@ export const applyPatchTool = tool({
|
|
|
166
236
|
const parsed = parseUnifiedDiff(diff);
|
|
167
237
|
if (parsed.length === 0)
|
|
168
238
|
return "Error: 无法解析 diff(缺少 ---/+++ 文件头)";
|
|
239
|
+
// 先鉴权再读盘:任何目标路径不可写都直接拒绝,不触碰文件内容。
|
|
240
|
+
for (const f of parsed) {
|
|
241
|
+
const denied = pathAccessError(f.path, "write");
|
|
242
|
+
if (denied)
|
|
243
|
+
return `Error: ${denied}`;
|
|
244
|
+
}
|
|
169
245
|
const built = buildPatchedFiles(parsed);
|
|
170
246
|
if (!built.ok)
|
|
171
247
|
return `Error: ${built.error}`;
|
|
@@ -176,16 +252,14 @@ export const applyPatchTool = tool({
|
|
|
176
252
|
return ` ${c.file.path} ${c.file.isNew ? "(new)" : ""} -${del}/+${add}`;
|
|
177
253
|
})
|
|
178
254
|
.join("\n");
|
|
179
|
-
if (!
|
|
255
|
+
if (!isEditAutoApprove()) {
|
|
180
256
|
const approved = await confirm(`Apply patch to ${built.contents.length} file(s)?\n${summary}`);
|
|
181
257
|
if (!approved)
|
|
182
258
|
return "Patch rejected by user.";
|
|
183
259
|
}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
writeFileSync(target, c.content, "utf-8");
|
|
188
|
-
}
|
|
260
|
+
const committed = await commitPatchedFiles(built.contents);
|
|
261
|
+
if (!committed.ok)
|
|
262
|
+
return `Error: ${committed.error}`;
|
|
189
263
|
return `Applied patch:\n${summary}`;
|
|
190
264
|
},
|
|
191
265
|
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { writeFile, rename, unlink } from "fs/promises";
|
|
2
|
+
import { writeFileSync, renameSync, unlinkSync } from "fs";
|
|
3
|
+
import { randomBytes } from "crypto";
|
|
4
|
+
import path from "path";
|
|
5
|
+
/** Temp file path living next to the target so the rename stays on the same filesystem. */
|
|
6
|
+
export function tempPathFor(target) {
|
|
7
|
+
return path.join(path.dirname(target), `.${path.basename(target)}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`);
|
|
8
|
+
}
|
|
9
|
+
/** Write content to a sibling temp file, then atomically rename it over the target. */
|
|
10
|
+
export async function atomicWriteFile(target, content) {
|
|
11
|
+
const tmp = tempPathFor(target);
|
|
12
|
+
try {
|
|
13
|
+
await writeFile(tmp, content, "utf-8");
|
|
14
|
+
await rename(tmp, target);
|
|
15
|
+
}
|
|
16
|
+
catch (err) {
|
|
17
|
+
await unlink(tmp).catch(() => { });
|
|
18
|
+
throw err;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** Sync variant for call sites that must stay synchronous (config/session stores). */
|
|
22
|
+
export function atomicWriteFileSync(target, content) {
|
|
23
|
+
const tmp = tempPathFor(target);
|
|
24
|
+
try {
|
|
25
|
+
writeFileSync(tmp, content, "utf-8");
|
|
26
|
+
renameSync(tmp, target);
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
try {
|
|
30
|
+
unlinkSync(tmp);
|
|
31
|
+
}
|
|
32
|
+
catch { }
|
|
33
|
+
throw err;
|
|
34
|
+
}
|
|
35
|
+
}
|
package/dist/tools/backend.js
CHANGED
|
@@ -39,9 +39,9 @@ export function normalizeBaseURL(raw, source) {
|
|
|
39
39
|
*/
|
|
40
40
|
export function resolveBackendBase(spec, configValue) {
|
|
41
41
|
const env = process.env[spec.envName];
|
|
42
|
-
if (env
|
|
42
|
+
if (env?.trim())
|
|
43
43
|
return normalizeBaseURL(env, spec.envName);
|
|
44
|
-
if (configValue
|
|
44
|
+
if (configValue?.trim())
|
|
45
45
|
return normalizeBaseURL(configValue, `"${spec.configKey}" in config.json`);
|
|
46
46
|
return normalizeBaseURL(spec.fallback, "the built-in default backend URL");
|
|
47
47
|
}
|