@armadra/agent 0.6.8 → 0.7.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 +61 -0
- package/CHANGELOG.zh-CN.md +43 -0
- package/README.md +4 -3
- package/README.zh-CN.md +1 -1
- package/dist/acp.d.ts +2 -2
- package/dist/acp.js +2 -2
- package/dist/ai/apis/shared.d.ts +7 -1
- package/dist/ai/apis/shared.js +10 -0
- package/dist/ai/fake/fake-provider.js +5 -1
- package/dist/ai/fake/fake-script.d.ts +5 -2
- package/dist/ai/fake/fake-script.js +6 -2
- package/dist/ai/types.d.ts +2 -1
- package/dist/bundle/ama.cjs +48757 -47456
- package/dist/cli/args.d.ts +6 -0
- package/dist/cli/args.js +46 -0
- package/dist/cli/bootstrap.js +13 -2
- package/dist/cli/compose-events.d.ts +10 -0
- package/dist/cli/compose-events.js +97 -0
- package/dist/cli/compose-session.d.ts +25 -4
- package/dist/cli/compose-session.js +130 -103
- package/dist/cli/compose.js +6 -1
- package/dist/cli/runtime.d.ts +3 -1
- package/dist/cli/subcommands/auth.d.ts +13 -1
- package/dist/cli/subcommands/auth.js +28 -1
- package/dist/drivers/acp/client.d.ts +7 -2
- package/dist/drivers/acp/client.js +10 -1
- package/dist/drivers/acp/driver.d.ts +15 -4
- package/dist/drivers/acp/driver.js +110 -33
- package/dist/drivers/acp/testing/fake-agent-main.d.ts +1 -1
- package/dist/drivers/acp/testing/fake-agent-main.js +9 -2
- package/dist/drivers/acp/testing/fake-agent.d.ts +17 -2
- package/dist/drivers/acp/testing/fake-agent.js +108 -25
- package/dist/drivers/acp/types.d.ts +76 -12
- package/dist/drivers/acp/types.js +11 -3
- package/dist/drivers/jsonrpc.d.ts +13 -3
- package/dist/drivers/jsonrpc.js +40 -7
- package/dist/drivers/turn.d.ts +15 -2
- package/dist/drivers/turn.js +33 -4
- package/dist/i18n/catalog.d.ts +59 -16
- package/dist/i18n/catalog.js +4 -1
- package/dist/i18n/messages/acp.d.ts +125 -0
- package/dist/i18n/messages/acp.js +126 -0
- package/dist/i18n/messages/auth.d.ts +2 -0
- package/dist/i18n/messages/auth.js +4 -2
- package/dist/i18n/messages/cli-args.d.ts +2 -0
- package/dist/i18n/messages/cli-args.js +2 -0
- package/dist/i18n/messages/cli.d.ts +4 -0
- package/dist/i18n/messages/cli.js +2 -0
- package/dist/i18n/messages/print.d.ts +3 -33
- package/dist/i18n/messages/print.js +3 -33
- package/dist/modes/acp/acp-auth-gate.d.ts +40 -0
- package/dist/modes/acp/acp-auth-gate.js +201 -0
- package/dist/modes/acp/acp-config.d.ts +43 -0
- package/dist/modes/acp/acp-config.js +151 -0
- package/dist/modes/acp/acp-connection.d.ts +29 -0
- package/dist/modes/acp/acp-connection.js +37 -0
- package/dist/modes/acp/acp-events.d.ts +59 -23
- package/dist/modes/acp/acp-events.js +154 -88
- package/dist/modes/acp/acp-mode.d.ts +13 -2
- package/dist/modes/acp/acp-mode.js +23 -8
- package/dist/modes/acp/acp-server.d.ts +59 -32
- package/dist/modes/acp/acp-server.js +322 -128
- package/dist/modes/acp/acp-sessions.d.ts +80 -0
- package/dist/modes/acp/acp-sessions.js +157 -0
- package/dist/modes/acp/acp-tool-text.d.ts +23 -0
- package/dist/modes/acp/acp-tool-text.js +83 -0
- package/dist/modes/print/json-event.d.ts +2 -1
- package/dist/modes/print/json-event.js +6 -1
- package/dist/tools/edit.d.ts +6 -2
- package/dist/tools/edit.js +19 -2
- package/dist/tools/types.d.ts +11 -0
- package/dist/tools/types.js +2 -0
- package/dist/tools/write.d.ts +1 -0
- package/dist/tools/write.js +21 -3
- package/docs/acp.md +143 -29
- package/docs/agents.md +2 -0
- package/docs/codemode.md +1 -1
- package/docs/en/acp.md +192 -0
- package/package.json +1 -1
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ama --mode acp` 的多会话:会话池条目、FIFO 提示队列、`session/list` 的分页与标题(docs/acp-plan.md
|
|
3
|
+
* D1、D2、D11、§2.1)。[ACP-B]
|
|
4
|
+
*
|
|
5
|
+
* - 每个 ACP sessionId 一个常驻会话({@link PooledSession}),各有自己的事件映射器与权限模式;
|
|
6
|
+
* - 同一时刻只跑一个回合,其它会话的 `session/prompt` 进 {@link PromptQueue}(FIFO),不再回 busy;
|
|
7
|
+
* - `session/list`:按 `updatedAt` 降序、`sessionId` 次序分页,cursor 是 `updatedAt|sessionId` 的 base64。
|
|
8
|
+
*/
|
|
9
|
+
import type { ImageBlock } from "../../ai/types.js";
|
|
10
|
+
import type { AgentSessionImpl } from "../../agent/session.js";
|
|
11
|
+
import type { AgentMessage, SessionListItem } from "../../session/types.js";
|
|
12
|
+
import { type AcpPromptResult, type AcpListSessionsResult } from "../../drivers/acp/types.js";
|
|
13
|
+
import type { PermissionMode } from "../../permissions/types.js";
|
|
14
|
+
import type { AcpEventMapper } from "./acp-events.js";
|
|
15
|
+
/** `session/list` 每页条数。 */
|
|
16
|
+
export declare const LIST_PAGE_SIZE = 50;
|
|
17
|
+
/** 列表与 `session_info_update` 的标题上限(字符)。 */
|
|
18
|
+
export declare const TITLE_LIMIT = 80;
|
|
19
|
+
/** 会话池里的一个会话。 */
|
|
20
|
+
export interface PooledSession {
|
|
21
|
+
readonly id: string;
|
|
22
|
+
readonly session: AgentSessionImpl;
|
|
23
|
+
readonly mapper: AcpEventMapper;
|
|
24
|
+
readonly unsubscribe: () => void;
|
|
25
|
+
/** 这个会话的权限模式(共享管线只在它是前台时等于它,出队时重放)。 */
|
|
26
|
+
mode: PermissionMode;
|
|
27
|
+
}
|
|
28
|
+
/** 排队或在跑的一次 `session/prompt`。 */
|
|
29
|
+
export interface PromptJob {
|
|
30
|
+
readonly sessionId: string;
|
|
31
|
+
readonly text: string;
|
|
32
|
+
readonly images: ImageBlock[];
|
|
33
|
+
/** `session/cancel` / `session/close` / `$/cancel_request` 要求中断。 */
|
|
34
|
+
cancelRequested: boolean;
|
|
35
|
+
resolve(result: AcpPromptResult): void;
|
|
36
|
+
reject(error: unknown): void;
|
|
37
|
+
}
|
|
38
|
+
export declare const ZERO_USAGE: {
|
|
39
|
+
readonly inputTokens: 0;
|
|
40
|
+
readonly outputTokens: 0;
|
|
41
|
+
readonly cachedReadTokens: 0;
|
|
42
|
+
readonly cachedWriteTokens: 0;
|
|
43
|
+
readonly totalTokens: 0;
|
|
44
|
+
};
|
|
45
|
+
/** 还没开始就被取消的提示:直接回 cancelled。 */
|
|
46
|
+
export declare function cancelledResult(): AcpPromptResult;
|
|
47
|
+
/**
|
|
48
|
+
* 全进程一个回合:FIFO,出队时调 `run`。`run` 的结果 / 错误原样交回该提示的调用方。
|
|
49
|
+
*/
|
|
50
|
+
export declare class PromptQueue {
|
|
51
|
+
private readonly run;
|
|
52
|
+
private readonly waiting;
|
|
53
|
+
private current;
|
|
54
|
+
constructor(run: (job: PromptJob) => Promise<AcpPromptResult>);
|
|
55
|
+
/** 在跑的提示。 */
|
|
56
|
+
get running(): PromptJob | undefined;
|
|
57
|
+
/** 排队中的提示数。 */
|
|
58
|
+
get size(): number;
|
|
59
|
+
push(job: PromptJob): void;
|
|
60
|
+
/** 排队中属于该会话的提示全部回 cancelled 并出队;返回出队条数。 */
|
|
61
|
+
cancelQueued(sessionId?: string): number;
|
|
62
|
+
/** 等在跑的那个提示结束(`sessionId` 给了且在跑的不是它 → 立即返回)。 */
|
|
63
|
+
settled(sessionId?: string): Promise<void>;
|
|
64
|
+
private drain;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* 标题清洗:去掉嵌入资源块(`<resource …>…</resource>`,被截断没有结尾的也去),取第一个非空行,
|
|
68
|
+
* 压成一行,≤ {@link TITLE_LIMIT} 字。空 → null。
|
|
69
|
+
*/
|
|
70
|
+
export declare function cleanTitle(raw: string | undefined): string | null;
|
|
71
|
+
/** 会话标题:会话名,否则首条用户提示(清洗后)。 */
|
|
72
|
+
export declare function sessionTitle(name: string | undefined, messages: readonly AgentMessage[]): string | null;
|
|
73
|
+
export declare function encodeCursor(updatedAt: string, sessionId: string): string;
|
|
74
|
+
/** 非法 cursor → invalid params。 */
|
|
75
|
+
export declare function decodeCursor(cursor: unknown): {
|
|
76
|
+
updatedAt: string;
|
|
77
|
+
sessionId: string;
|
|
78
|
+
};
|
|
79
|
+
/** 一页会话(`cursor` 缺省从头)。 */
|
|
80
|
+
export declare function pageSessions(items: readonly SessionListItem[], cursor: unknown, pageSize?: number): AcpListSessionsResult;
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ama --mode acp` 的多会话:会话池条目、FIFO 提示队列、`session/list` 的分页与标题(docs/acp-plan.md
|
|
3
|
+
* D1、D2、D11、§2.1)。[ACP-B]
|
|
4
|
+
*
|
|
5
|
+
* - 每个 ACP sessionId 一个常驻会话({@link PooledSession}),各有自己的事件映射器与权限模式;
|
|
6
|
+
* - 同一时刻只跑一个回合,其它会话的 `session/prompt` 进 {@link PromptQueue}(FIFO),不再回 busy;
|
|
7
|
+
* - `session/list`:按 `updatedAt` 降序、`sessionId` 次序分页,cursor 是 `updatedAt|sessionId` 的 base64。
|
|
8
|
+
*/
|
|
9
|
+
import { RPC_ERRORS, } from "../../drivers/acp/types.js";
|
|
10
|
+
import { RpcError } from "../../drivers/jsonrpc.js";
|
|
11
|
+
import { msg } from "../../i18n/index.js";
|
|
12
|
+
/** `session/list` 每页条数。 */
|
|
13
|
+
export const LIST_PAGE_SIZE = 50;
|
|
14
|
+
/** 列表与 `session_info_update` 的标题上限(字符)。 */
|
|
15
|
+
export const TITLE_LIMIT = 80;
|
|
16
|
+
export const ZERO_USAGE = {
|
|
17
|
+
inputTokens: 0,
|
|
18
|
+
outputTokens: 0,
|
|
19
|
+
cachedReadTokens: 0,
|
|
20
|
+
cachedWriteTokens: 0,
|
|
21
|
+
totalTokens: 0,
|
|
22
|
+
};
|
|
23
|
+
/** 还没开始就被取消的提示:直接回 cancelled。 */
|
|
24
|
+
export function cancelledResult() {
|
|
25
|
+
return { stopReason: "cancelled", usage: { ...ZERO_USAGE } };
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* 全进程一个回合:FIFO,出队时调 `run`。`run` 的结果 / 错误原样交回该提示的调用方。
|
|
29
|
+
*/
|
|
30
|
+
export class PromptQueue {
|
|
31
|
+
run;
|
|
32
|
+
waiting = [];
|
|
33
|
+
current;
|
|
34
|
+
constructor(run) {
|
|
35
|
+
this.run = run;
|
|
36
|
+
}
|
|
37
|
+
/** 在跑的提示。 */
|
|
38
|
+
get running() {
|
|
39
|
+
return this.current?.job;
|
|
40
|
+
}
|
|
41
|
+
/** 排队中的提示数。 */
|
|
42
|
+
get size() {
|
|
43
|
+
return this.waiting.length;
|
|
44
|
+
}
|
|
45
|
+
push(job) {
|
|
46
|
+
this.waiting.push(job);
|
|
47
|
+
this.drain();
|
|
48
|
+
}
|
|
49
|
+
/** 排队中属于该会话的提示全部回 cancelled 并出队;返回出队条数。 */
|
|
50
|
+
cancelQueued(sessionId) {
|
|
51
|
+
let removed = 0;
|
|
52
|
+
for (let i = this.waiting.length - 1; i >= 0; i--) {
|
|
53
|
+
const job = this.waiting[i];
|
|
54
|
+
if (sessionId !== undefined && job.sessionId !== sessionId)
|
|
55
|
+
continue;
|
|
56
|
+
this.waiting.splice(i, 1);
|
|
57
|
+
job.cancelRequested = true;
|
|
58
|
+
job.resolve(cancelledResult());
|
|
59
|
+
removed++;
|
|
60
|
+
}
|
|
61
|
+
return removed;
|
|
62
|
+
}
|
|
63
|
+
/** 等在跑的那个提示结束(`sessionId` 给了且在跑的不是它 → 立即返回)。 */
|
|
64
|
+
async settled(sessionId) {
|
|
65
|
+
while (this.current !== undefined) {
|
|
66
|
+
if (sessionId !== undefined && this.current.job.sessionId !== sessionId)
|
|
67
|
+
return;
|
|
68
|
+
await this.current.done;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
drain() {
|
|
72
|
+
if (this.current !== undefined)
|
|
73
|
+
return;
|
|
74
|
+
const job = this.waiting.shift();
|
|
75
|
+
if (job === undefined)
|
|
76
|
+
return;
|
|
77
|
+
const done = this.run(job).then((result) => job.resolve(result), (error) => job.reject(error));
|
|
78
|
+
this.current = { job, done };
|
|
79
|
+
void done.finally(() => {
|
|
80
|
+
this.current = undefined;
|
|
81
|
+
this.drain();
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* 标题清洗:去掉嵌入资源块(`<resource …>…</resource>`,被截断没有结尾的也去),取第一个非空行,
|
|
87
|
+
* 压成一行,≤ {@link TITLE_LIMIT} 字。空 → null。
|
|
88
|
+
*/
|
|
89
|
+
export function cleanTitle(raw) {
|
|
90
|
+
if (raw === undefined)
|
|
91
|
+
return null;
|
|
92
|
+
const stripped = raw.replace(/<resource\b[^>]*>[\s\S]*?(?:<\/resource>|$)/g, "");
|
|
93
|
+
const line = stripped
|
|
94
|
+
.split(/\r?\n/)
|
|
95
|
+
.map((l) => l.replace(/\s+/g, " ").trim())
|
|
96
|
+
.find((l) => l !== "");
|
|
97
|
+
if (line === undefined)
|
|
98
|
+
return null;
|
|
99
|
+
return line.length > TITLE_LIMIT ? `${line.slice(0, TITLE_LIMIT - 1)}…` : line;
|
|
100
|
+
}
|
|
101
|
+
/** 会话标题:会话名,否则首条用户提示(清洗后)。 */
|
|
102
|
+
export function sessionTitle(name, messages) {
|
|
103
|
+
if (name !== undefined && name.trim() !== "")
|
|
104
|
+
return cleanTitle(name);
|
|
105
|
+
for (const message of messages) {
|
|
106
|
+
if (!("role" in message) || message.role !== "user")
|
|
107
|
+
continue;
|
|
108
|
+
const { content } = message;
|
|
109
|
+
const text = typeof content === "string"
|
|
110
|
+
? content
|
|
111
|
+
: content.map((block) => (block.type === "text" ? block.text : "")).join("");
|
|
112
|
+
return cleanTitle(text);
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
export function encodeCursor(updatedAt, sessionId) {
|
|
117
|
+
return Buffer.from(`${updatedAt}|${sessionId}`, "utf8").toString("base64");
|
|
118
|
+
}
|
|
119
|
+
/** 非法 cursor → invalid params。 */
|
|
120
|
+
export function decodeCursor(cursor) {
|
|
121
|
+
if (typeof cursor === "string" && cursor !== "") {
|
|
122
|
+
const text = Buffer.from(cursor, "base64").toString("utf8");
|
|
123
|
+
const at = text.indexOf("|");
|
|
124
|
+
if (at > 0 &&
|
|
125
|
+
at < text.length - 1 &&
|
|
126
|
+
encodeCursor(text.slice(0, at), text.slice(at + 1)) === cursor)
|
|
127
|
+
return { updatedAt: text.slice(0, at), sessionId: text.slice(at + 1) };
|
|
128
|
+
}
|
|
129
|
+
throw new RpcError(RPC_ERRORS.invalidParams, msg().acp.session.invalidCursor);
|
|
130
|
+
}
|
|
131
|
+
/** 排序键:`updatedAt` 降序,同时刻按 `sessionId` 降序。 */
|
|
132
|
+
function before(a, b) {
|
|
133
|
+
return a.updatedAt === b.updatedAt ? a.sessionId > b.sessionId : a.updatedAt > b.updatedAt;
|
|
134
|
+
}
|
|
135
|
+
/** 一页会话(`cursor` 缺省从头)。 */
|
|
136
|
+
export function pageSessions(items, cursor, pageSize = LIST_PAGE_SIZE) {
|
|
137
|
+
const all = items
|
|
138
|
+
.map((item) => ({
|
|
139
|
+
sessionId: item.id,
|
|
140
|
+
cwd: item.cwd,
|
|
141
|
+
title: cleanTitle(item.name) ?? cleanTitle(item.firstPrompt),
|
|
142
|
+
updatedAt: item.modifiedAt,
|
|
143
|
+
}))
|
|
144
|
+
.sort((a, b) => (before(a, b) ? -1 : 1));
|
|
145
|
+
let start = 0;
|
|
146
|
+
if (cursor !== undefined && cursor !== null) {
|
|
147
|
+
const after = decodeCursor(cursor);
|
|
148
|
+
start = all.findIndex((s) => before(after, s));
|
|
149
|
+
if (start < 0)
|
|
150
|
+
start = all.length;
|
|
151
|
+
}
|
|
152
|
+
const sessions = all.slice(start, start + pageSize);
|
|
153
|
+
const last = all[start + pageSize - 1];
|
|
154
|
+
return start + pageSize < all.length && last !== undefined
|
|
155
|
+
? { sessions, nextCursor: encodeCursor(last.updatedAt, last.sessionId) }
|
|
156
|
+
: { sessions };
|
|
157
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ama --mode acp` 工具调用的展示字段:title / kind / locations、结果文本(前 4 KB)与 diff 内容。
|
|
3
|
+
* [ACP-C] 从 acp-events.ts 拆出(docs/acp-plan.md §2.3、D5、D6)。
|
|
4
|
+
*
|
|
5
|
+
* - codemode 内层调用(带 parentToolCallId)的 title 加前缀 `acp.tools.codemodePrefix`;
|
|
6
|
+
* - `tool_execution_end` 的 `content` = `[diff?, text]`:diff 来自 `ToolResult.fileChange`(edit / write
|
|
7
|
+
* 填,超过单侧上限不填),text 为结果前 4 KB;`locations[].line` = `fileChange.firstChangedLine`。
|
|
8
|
+
*/
|
|
9
|
+
import type { AcpToolCallContent, AcpToolCallLocation, AcpToolKind } from "../../drivers/acp/types.js";
|
|
10
|
+
import type { ContentBlock } from "../../ai/types.js";
|
|
11
|
+
import type { ToolResult } from "../../tools/types.js";
|
|
12
|
+
export declare const TOOL_OUTPUT_LIMIT: number;
|
|
13
|
+
export declare function toolKind(name: string): AcpToolKind;
|
|
14
|
+
/** 工具调用标题:`<工具名>: <命令 / 路径 / 模式>`;`nested` 为 codemode 内层调用时加前缀。 */
|
|
15
|
+
export declare function toolTitle(name: string, args: unknown, nested?: boolean): string;
|
|
16
|
+
export declare function toolLocations(args: unknown, cwd: string): AcpToolCallLocation[] | undefined;
|
|
17
|
+
/** 工具结果的前 {@link TOOL_OUTPUT_LIMIT} 字符(超出附截断说明);非文本块写成 `[type]`。 */
|
|
18
|
+
export declare function resultText(content: string | readonly ContentBlock[]): string;
|
|
19
|
+
/** `tool_execution_end` 的 content 与 locations:`[diff?, text]`,有 fileChange 时位置带首个改动行。 */
|
|
20
|
+
export declare function resultContent(result: ToolResult): {
|
|
21
|
+
content: AcpToolCallContent[];
|
|
22
|
+
locations?: AcpToolCallLocation[];
|
|
23
|
+
};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ama --mode acp` 工具调用的展示字段:title / kind / locations、结果文本(前 4 KB)与 diff 内容。
|
|
3
|
+
* [ACP-C] 从 acp-events.ts 拆出(docs/acp-plan.md §2.3、D5、D6)。
|
|
4
|
+
*
|
|
5
|
+
* - codemode 内层调用(带 parentToolCallId)的 title 加前缀 `acp.tools.codemodePrefix`;
|
|
6
|
+
* - `tool_execution_end` 的 `content` = `[diff?, text]`:diff 来自 `ToolResult.fileChange`(edit / write
|
|
7
|
+
* 填,超过单侧上限不填),text 为结果前 4 KB;`locations[].line` = `fileChange.firstChangedLine`。
|
|
8
|
+
*/
|
|
9
|
+
import { isAbsolute, resolve } from "node:path";
|
|
10
|
+
import { oneLine } from "../../drivers/turn.js";
|
|
11
|
+
import { msg } from "../../i18n/index.js";
|
|
12
|
+
export const TOOL_OUTPUT_LIMIT = 4 * 1024;
|
|
13
|
+
const KINDS = {
|
|
14
|
+
read: "read",
|
|
15
|
+
write: "edit",
|
|
16
|
+
edit: "edit",
|
|
17
|
+
bash: "execute",
|
|
18
|
+
grep: "search",
|
|
19
|
+
glob: "search",
|
|
20
|
+
ls: "search",
|
|
21
|
+
find: "search",
|
|
22
|
+
web_fetch: "fetch",
|
|
23
|
+
web_search: "fetch",
|
|
24
|
+
todo: "think",
|
|
25
|
+
};
|
|
26
|
+
export function toolKind(name) {
|
|
27
|
+
return KINDS[name] ?? "other";
|
|
28
|
+
}
|
|
29
|
+
function argOf(args, ...keys) {
|
|
30
|
+
if (args === null || typeof args !== "object")
|
|
31
|
+
return undefined;
|
|
32
|
+
for (const key of keys) {
|
|
33
|
+
const value = args[key];
|
|
34
|
+
if (typeof value === "string" && value !== "")
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
/** 工具调用标题:`<工具名>: <命令 / 路径 / 模式>`;`nested` 为 codemode 内层调用时加前缀。 */
|
|
40
|
+
export function toolTitle(name, args, nested = false) {
|
|
41
|
+
const detail = argOf(args, "command") ??
|
|
42
|
+
argOf(args, "path", "file_path", "filePath") ??
|
|
43
|
+
argOf(args, "pattern", "query", "url", "prompt");
|
|
44
|
+
const title = detail === undefined ? name : `${name}: ${oneLine(detail, 100)}`;
|
|
45
|
+
return nested ? msg().acp.tools.codemodePrefix(title) : title;
|
|
46
|
+
}
|
|
47
|
+
export function toolLocations(args, cwd) {
|
|
48
|
+
const path = argOf(args, "path", "file_path", "filePath");
|
|
49
|
+
if (path === undefined)
|
|
50
|
+
return undefined;
|
|
51
|
+
return [{ path: isAbsolute(path) ? path : resolve(cwd, path) }];
|
|
52
|
+
}
|
|
53
|
+
/** 工具结果的前 {@link TOOL_OUTPUT_LIMIT} 字符(超出附截断说明);非文本块写成 `[type]`。 */
|
|
54
|
+
export function resultText(content) {
|
|
55
|
+
const text = typeof content === "string"
|
|
56
|
+
? content
|
|
57
|
+
: content.map((b) => (b.type === "text" ? b.text : `[${b.type}]`)).join("");
|
|
58
|
+
return text.length > TOOL_OUTPUT_LIMIT
|
|
59
|
+
? `${text.slice(0, TOOL_OUTPUT_LIMIT)}\n${msg().acp.tools.truncated(text.length)}`
|
|
60
|
+
: text;
|
|
61
|
+
}
|
|
62
|
+
/** `tool_execution_end` 的 content 与 locations:`[diff?, text]`,有 fileChange 时位置带首个改动行。 */
|
|
63
|
+
export function resultContent(result) {
|
|
64
|
+
const text = {
|
|
65
|
+
type: "content",
|
|
66
|
+
content: { type: "text", text: resultText(result.content) },
|
|
67
|
+
};
|
|
68
|
+
const change = result.fileChange;
|
|
69
|
+
if (change === undefined)
|
|
70
|
+
return { content: [text] };
|
|
71
|
+
return {
|
|
72
|
+
content: [
|
|
73
|
+
{ type: "diff", path: change.path, oldText: change.oldText, newText: change.newText },
|
|
74
|
+
text,
|
|
75
|
+
],
|
|
76
|
+
locations: [
|
|
77
|
+
{
|
|
78
|
+
path: change.path,
|
|
79
|
+
...(change.firstChangedLine !== undefined ? { line: change.firstChangedLine } : {}),
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* 线上事件形状(设计 §13.2、src/rpc.ts):RPC 与 `--output-format stream-json` 共用。[B6]
|
|
3
3
|
*
|
|
4
4
|
* - `message_update` 变成纯增量:去掉累计消息与 `partial`,附最新 `usage`;`done / error`
|
|
5
|
-
* 保留最终 `message
|
|
5
|
+
* 保留最终 `message`。`tool_execution_end` 去掉 `result.fileChange`([ACP-C0] 改前 / 改后全文只给
|
|
6
|
+
* ACP 映射用,不上 RPC / stream-json 线)。其余 `SessionEvent` 原样。
|
|
6
7
|
* - `toJsonLine()`:一行 JSON;Error 序列化为 `{ name, message }`,bigint 转字符串,
|
|
7
8
|
* U+2028 / U+2029 转义(部分 JSONL 客户端会把它们当换行);图片 base64 不截断。
|
|
8
9
|
*/
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* 线上事件形状(设计 §13.2、src/rpc.ts):RPC 与 `--output-format stream-json` 共用。[B6]
|
|
3
3
|
*
|
|
4
4
|
* - `message_update` 变成纯增量:去掉累计消息与 `partial`,附最新 `usage`;`done / error`
|
|
5
|
-
* 保留最终 `message
|
|
5
|
+
* 保留最终 `message`。`tool_execution_end` 去掉 `result.fileChange`([ACP-C0] 改前 / 改后全文只给
|
|
6
|
+
* ACP 映射用,不上 RPC / stream-json 线)。其余 `SessionEvent` 原样。
|
|
6
7
|
* - `toJsonLine()`:一行 JSON;Error 序列化为 `{ name, message }`,bigint 转字符串,
|
|
7
8
|
* U+2028 / U+2029 转义(部分 JSONL 客户端会把它们当换行);图片 base64 不截断。
|
|
8
9
|
*/
|
|
@@ -13,6 +14,10 @@ export function toWireAssistantEvent(event) {
|
|
|
13
14
|
return rest;
|
|
14
15
|
}
|
|
15
16
|
export function toWireEvent(event) {
|
|
17
|
+
if (event.type === "tool_execution_end" && event.result.fileChange !== undefined) {
|
|
18
|
+
const { fileChange: _fileChange, ...result } = event.result;
|
|
19
|
+
return { ...event, result };
|
|
20
|
+
}
|
|
16
21
|
if (event.type !== "message_update")
|
|
17
22
|
return event;
|
|
18
23
|
const wire = {
|
package/dist/tools/edit.d.ts
CHANGED
|
@@ -4,9 +4,11 @@
|
|
|
4
4
|
* - 每处 `oldText` 都在**原文**上匹配,必须唯一(`replaceAll` 例外)且互不重叠;
|
|
5
5
|
* - 先精确匹配;任一处精确匹配失败则整批转入模糊空间(edit-fuzzy.ts),只改写被触及的行;
|
|
6
6
|
* - 不唯一 → 错误里给出现次数与首两处行号;
|
|
7
|
-
* - 保留 BOM 与 CRLF;要求先 read;`details.diff` 是统一 diff(给 TUI
|
|
7
|
+
* - 保留 BOM 与 CRLF;要求先 read;`details.diff` 是统一 diff(给 TUI);
|
|
8
|
+
* - `fileChange` 是改前 / 改后的磁盘原文(含 BOM / CRLF,给 ACP 的 diff 内容),任一侧超过
|
|
9
|
+
* `FILE_CHANGE_TEXT_LIMIT` 不填;它不进转录(tool-runner 只拷 details)。
|
|
8
10
|
*/
|
|
9
|
-
import type
|
|
11
|
+
import { type FileChange, type ToolContext, type ToolDefinition, type ToolResult } from "./types.js";
|
|
10
12
|
export interface EditOp {
|
|
11
13
|
oldText: string;
|
|
12
14
|
newText: string;
|
|
@@ -34,6 +36,8 @@ export interface EditPlan {
|
|
|
34
36
|
export declare function planEdits(content: string, edits: readonly EditOp[], replaceAll?: boolean): EditPlan;
|
|
35
37
|
/** 统一 diff(LF 文本,3 行上下文);无差异返回空串。 */
|
|
36
38
|
export declare function unifiedDiff(oldText: string, newText: string, path: string, context?: number): string;
|
|
39
|
+
/** 改前 / 改后全文([ACP-C] D6):任一侧超过 {@link FILE_CHANGE_TEXT_LIMIT} 返回 undefined。 */
|
|
40
|
+
export declare function fileChangeOf(path: string, oldText: string | null, newText: string, firstChangedLine?: number): FileChange | undefined;
|
|
37
41
|
/** 检查点:写前备份(docs/rewind-plan.md §2);钩子按约定不抛,这里再兜一层,不让编辑失败。 */
|
|
38
42
|
export declare function beforeWrite(ctx: ToolContext, abs: string): Promise<void>;
|
|
39
43
|
export declare function executeEdit(input: EditInput, ctx: ToolContext): Promise<ToolResult>;
|
package/dist/tools/edit.js
CHANGED
|
@@ -4,9 +4,12 @@
|
|
|
4
4
|
* - 每处 `oldText` 都在**原文**上匹配,必须唯一(`replaceAll` 例外)且互不重叠;
|
|
5
5
|
* - 先精确匹配;任一处精确匹配失败则整批转入模糊空间(edit-fuzzy.ts),只改写被触及的行;
|
|
6
6
|
* - 不唯一 → 错误里给出现次数与首两处行号;
|
|
7
|
-
* - 保留 BOM 与 CRLF;要求先 read;`details.diff` 是统一 diff(给 TUI
|
|
7
|
+
* - 保留 BOM 与 CRLF;要求先 read;`details.diff` 是统一 diff(给 TUI);
|
|
8
|
+
* - `fileChange` 是改前 / 改后的磁盘原文(含 BOM / CRLF,给 ACP 的 diff 内容),任一侧超过
|
|
9
|
+
* `FILE_CHANGE_TEXT_LIMIT` 不填;它不进转录(tool-runner 只拷 details)。
|
|
8
10
|
*/
|
|
9
11
|
import { readFile, stat, writeFile } from "node:fs/promises";
|
|
12
|
+
import { FILE_CHANGE_TEXT_LIMIT, } from "./types.js";
|
|
10
13
|
import { displayPath, resolvePath } from "./paths.js";
|
|
11
14
|
import { withFileMutex } from "./file-mutex.js";
|
|
12
15
|
import { applyPreservingLines, applyReplacements, detectLineEnding, findAll, lineNumberAt, normalizeForFuzzy, normalizeToLF, restoreLineEndings, splitBom, } from "./edit-fuzzy.js";
|
|
@@ -161,6 +164,16 @@ function firstChangedLine(oldText, newText) {
|
|
|
161
164
|
i++;
|
|
162
165
|
return i + 1;
|
|
163
166
|
}
|
|
167
|
+
/** 改前 / 改后全文([ACP-C] D6):任一侧超过 {@link FILE_CHANGE_TEXT_LIMIT} 返回 undefined。 */
|
|
168
|
+
export function fileChangeOf(path, oldText, newText, firstChangedLine) {
|
|
169
|
+
if (newText.length > FILE_CHANGE_TEXT_LIMIT)
|
|
170
|
+
return undefined;
|
|
171
|
+
if (oldText !== null && oldText.length > FILE_CHANGE_TEXT_LIMIT)
|
|
172
|
+
return undefined;
|
|
173
|
+
return firstChangedLine === undefined
|
|
174
|
+
? { path, oldText, newText }
|
|
175
|
+
: { path, oldText, newText, firstChangedLine };
|
|
176
|
+
}
|
|
164
177
|
// ---------------------------------------------------------------------------
|
|
165
178
|
// 工具
|
|
166
179
|
// ---------------------------------------------------------------------------
|
|
@@ -216,7 +229,11 @@ export async function executeEdit(input, ctx) {
|
|
|
216
229
|
};
|
|
217
230
|
const note = plan.fuzzy ? " (matched after whitespace/quote normalization)" : "";
|
|
218
231
|
const count = `${plan.replacements} replacement${plan.replacements === 1 ? "" : "s"}`;
|
|
219
|
-
|
|
232
|
+
const result = { content: `Edited ${shown}: ${count}${note}`, details };
|
|
233
|
+
const fileChange = fileChangeOf(abs, raw, output, details.firstChangedLine);
|
|
234
|
+
if (fileChange !== undefined)
|
|
235
|
+
result.fileChange = fileChange;
|
|
236
|
+
return result;
|
|
220
237
|
});
|
|
221
238
|
}
|
|
222
239
|
export function createEditTool() {
|
package/dist/tools/types.d.ts
CHANGED
|
@@ -66,7 +66,18 @@ export interface ToolResult {
|
|
|
66
66
|
structured?: unknown;
|
|
67
67
|
/** 整批工具结果都为 true 才提前结束 run。 */
|
|
68
68
|
terminate?: boolean;
|
|
69
|
+
/** [ACP-C0] 文件改动的改前 / 改后全文(edit / write 填);只随事件走,不落盘(tool-runner 只拷 details)。 */
|
|
70
|
+
fileChange?: FileChange;
|
|
69
71
|
}
|
|
72
|
+
/** [ACP-C0] 文件改动:`oldText` 为 null 表示新建;`firstChangedLine` 从 1 起。 */
|
|
73
|
+
export interface FileChange {
|
|
74
|
+
path: string;
|
|
75
|
+
oldText: string | null;
|
|
76
|
+
newText: string;
|
|
77
|
+
firstChangedLine?: number;
|
|
78
|
+
}
|
|
79
|
+
/** [ACP-C0] `fileChange` 单侧文本上限(字符数),超过就不填。 */
|
|
80
|
+
export declare const FILE_CHANGE_TEXT_LIMIT: number;
|
|
70
81
|
export interface SubagentRequest {
|
|
71
82
|
prompt: string;
|
|
72
83
|
description?: string;
|
package/dist/tools/types.js
CHANGED
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
* 统一入口 `SubagentRunner / RunnerHandle / SubagentEvent`(ama 子会话、外部 CLI、宿主 runner);
|
|
19
19
|
* `ToolContext.tasks` 为任务注册表的只读视图(W5-G 提供)。
|
|
20
20
|
*/
|
|
21
|
+
/** [ACP-C0] `fileChange` 单侧文本上限(字符数),超过就不填。 */
|
|
22
|
+
export const FILE_CHANGE_TEXT_LIMIT = 256 * 1024;
|
|
21
23
|
/** 恒等函数,帮助推断输入类型。 */
|
|
22
24
|
export function defineTool(tool) {
|
|
23
25
|
return tool;
|
package/dist/tools/write.d.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 整文件覆盖、自动建父目录;文件已存在且不在 `ctx.readFiles` → 错误「先 read」;若原文件有 BOM
|
|
5
5
|
* 或 CRLF,写回时保留(新内容自带 BOM / CRLF 时不重复加)。`details: { bytes, created }`。
|
|
6
|
+
* `fileChange`([ACP-C] D6):改前 / 改后的磁盘原文,新文件 `oldText: null`;任一侧超过上限不填。
|
|
6
7
|
*/
|
|
7
8
|
import type { ToolContext, ToolDefinition, ToolResult } from "./types.js";
|
|
8
9
|
export interface WriteInput {
|
package/dist/tools/write.js
CHANGED
|
@@ -3,12 +3,13 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 整文件覆盖、自动建父目录;文件已存在且不在 `ctx.readFiles` → 错误「先 read」;若原文件有 BOM
|
|
5
5
|
* 或 CRLF,写回时保留(新内容自带 BOM / CRLF 时不重复加)。`details: { bytes, created }`。
|
|
6
|
+
* `fileChange`([ACP-C] D6):改前 / 改后的磁盘原文,新文件 `oldText: null`;任一侧超过上限不填。
|
|
6
7
|
*/
|
|
7
8
|
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
8
9
|
import { dirname } from "node:path";
|
|
9
10
|
import { displayPath, resolvePath } from "./paths.js";
|
|
10
11
|
import { withFileMutex } from "./file-mutex.js";
|
|
11
|
-
import { beforeWrite } from "./edit.js";
|
|
12
|
+
import { beforeWrite, fileChangeOf } from "./edit.js";
|
|
12
13
|
import { BOM, detectLineEnding, normalizeToLF, restoreLineEndings, splitBom, } from "./edit-fuzzy.js";
|
|
13
14
|
async function exists(path) {
|
|
14
15
|
try {
|
|
@@ -29,6 +30,17 @@ export function conformToOriginal(original, content) {
|
|
|
29
30
|
}
|
|
30
31
|
return (bom !== "" || incoming.bom !== "" ? BOM : "") + body;
|
|
31
32
|
}
|
|
33
|
+
/** 首个不同的行(从 1 起,按 LF 归一后比较;内容不变时为末行);新文件为 1。 */
|
|
34
|
+
function firstLineChanged(original, output) {
|
|
35
|
+
if (original === null)
|
|
36
|
+
return 1;
|
|
37
|
+
const a = normalizeToLF(splitBom(original).text).split("\n");
|
|
38
|
+
const b = normalizeToLF(splitBom(output).text).split("\n");
|
|
39
|
+
let i = 0;
|
|
40
|
+
while (i < a.length && i < b.length && a[i] === b[i])
|
|
41
|
+
i++;
|
|
42
|
+
return Math.min(i + 1, b.length);
|
|
43
|
+
}
|
|
32
44
|
export async function executeWrite(input, ctx) {
|
|
33
45
|
const abs = resolvePath(input.path, ctx.cwd);
|
|
34
46
|
const shown = displayPath(abs, ctx.cwd);
|
|
@@ -40,6 +52,7 @@ export async function executeWrite(input, ctx) {
|
|
|
40
52
|
if (state === "dir")
|
|
41
53
|
return { content: `${shown} is a directory`, isError: true };
|
|
42
54
|
let output = input.content;
|
|
55
|
+
let original = null;
|
|
43
56
|
if (state === "file") {
|
|
44
57
|
if (!ctx.readFiles.has(abs)) {
|
|
45
58
|
return {
|
|
@@ -47,7 +60,8 @@ export async function executeWrite(input, ctx) {
|
|
|
47
60
|
isError: true,
|
|
48
61
|
};
|
|
49
62
|
}
|
|
50
|
-
|
|
63
|
+
original = await readFile(abs, "utf8");
|
|
64
|
+
output = conformToOriginal(original, input.content);
|
|
51
65
|
}
|
|
52
66
|
await beforeWrite(ctx, abs);
|
|
53
67
|
await mkdir(dirname(abs), { recursive: true });
|
|
@@ -60,7 +74,11 @@ export async function executeWrite(input, ctx) {
|
|
|
60
74
|
created: state === false,
|
|
61
75
|
};
|
|
62
76
|
const verb = details.created ? "Created" : "Overwrote";
|
|
63
|
-
|
|
77
|
+
const result = { content: `${verb} ${shown} (${details.bytes} bytes)`, details };
|
|
78
|
+
const fileChange = fileChangeOf(abs, original, output, firstLineChanged(original, output));
|
|
79
|
+
if (fileChange !== undefined)
|
|
80
|
+
result.fileChange = fileChange;
|
|
81
|
+
return result;
|
|
64
82
|
});
|
|
65
83
|
}
|
|
66
84
|
export function createWriteTool() {
|