@armadra/agent 0.7.4 → 0.7.6
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 +32 -0
- package/CHANGELOG.zh-CN.md +32 -0
- package/dist/agent/session-classifier.d.ts +2 -0
- package/dist/agent/session-classifier.js +24 -3
- package/dist/agent/session-extensions.d.ts +1 -0
- package/dist/agent/session-extensions.js +3 -0
- package/dist/agent/session-subagent.js +9 -2
- package/dist/agent/subagent-background.d.ts +5 -1
- package/dist/agent/subagent-background.js +13 -1
- package/dist/agent/subagent-fork.d.ts +10 -5
- package/dist/agent/subagent-fork.js +22 -6
- package/dist/agent/subagent-registry.d.ts +1 -1
- package/dist/agent/subagent-registry.js +1 -1
- package/dist/agents/catalog.d.ts +1 -0
- package/dist/ai/apis/anthropic-request.js +6 -1
- package/dist/ai/apis/cache-params.js +2 -1
- package/dist/ai/apis/google-request.js +2 -1
- package/dist/ai/apis/max-tokens.d.ts +4 -0
- package/dist/ai/apis/max-tokens.js +12 -1
- package/dist/ai/apis/openai-request.js +3 -1
- package/dist/ai/apis/openai-responses-request.js +2 -1
- package/dist/ai/json-body.d.ts +33 -1
- package/dist/ai/json-body.js +96 -18
- package/dist/ai/providers/enrich.d.ts +6 -2
- package/dist/ai/providers/enrich.js +13 -2
- package/dist/ai/providers/max-tokens-cache.d.ts +22 -0
- package/dist/ai/providers/max-tokens-cache.js +95 -0
- package/dist/ai/providers/registry.js +3 -3
- package/dist/bundle/ama.cjs +982 -442
- package/dist/checkpoints/shadow-git.d.ts +5 -1
- package/dist/checkpoints/shadow-git.js +11 -2
- package/dist/cli/bootstrap.js +13 -5
- package/dist/cli/compose-providers.d.ts +1 -0
- package/dist/cli/compose-providers.js +3 -0
- package/dist/cli/compose-store.d.ts +6 -2
- package/dist/cli/compose-store.js +8 -4
- package/dist/cli/compose.js +2 -2
- package/dist/cli/deps.d.ts +1 -1
- package/dist/config/json-schema.js +1 -0
- package/dist/config/key-docs.js +1 -0
- package/dist/config/schema-w5.js +2 -0
- package/dist/config/settings-registry.js +1 -0
- package/dist/config/types-w5.d.ts +5 -0
- package/dist/i18n/catalog.d.ts +2 -0
- package/dist/i18n/messages/config-keys.d.ts +2 -0
- package/dist/i18n/messages/config-keys.js +2 -0
- package/dist/i18n/messages/config.d.ts +2 -0
- package/dist/i18n/messages/interactive.d.ts +2 -0
- package/dist/i18n/messages/interactive.js +2 -0
- package/dist/i18n/messages/session.js +2 -2
- package/dist/modes/acp/acp-server.js +17 -15
- package/dist/modes/acp/acp-sessions.d.ts +14 -1
- package/dist/modes/acp/acp-sessions.js +44 -1
- package/dist/modes/interactive/agent-ui.js +7 -0
- package/dist/session/manager.d.ts +18 -0
- package/dist/session/manager.js +75 -14
- package/dist/session/offload.d.ts +59 -0
- package/dist/session/offload.js +218 -0
- package/dist/session/store.d.ts +12 -4
- package/dist/session/store.js +18 -8
- package/dist/tools/background-jobs.d.ts +4 -4
- package/dist/tools/background-jobs.js +4 -4
- package/dist/tools/bash.d.ts +2 -1
- package/dist/tools/bash.js +4 -3
- package/dist/tools/read-lines.d.ts +39 -0
- package/dist/tools/read-lines.js +163 -86
- package/dist/tools/read.js +3 -3
- package/dist/tools/task-ctl.js +2 -1
- package/dist/tools/task.d.ts +9 -0
- package/dist/tools/task.js +27 -1
- package/docs/acp.md +6 -3
- package/docs/agents.md +14 -3
- package/docs/en/acp.md +2 -2
- package/docs/en/permissions.md +1 -1
- package/docs/en/providers.md +4 -4
- package/docs/en/sessions.md +2 -1
- package/docs/permissions.md +1 -1
- package/docs/providers.md +9 -5
- package/docs/session-format.md +11 -0
- package/docs/sessions.md +2 -1
- package/package.json +1 -1
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
* 不复制 leaf 行;给了 `head` 时它成为新根条目,复制的首条重挂到它下面(fork 子会话的 ama.task);
|
|
12
12
|
* `cwd` 缺省同本会话([ME-A] 隔离的 fork 子会话传 worktree)。
|
|
13
13
|
* - `close()`:释放锁;之后的 append 抛错。
|
|
14
|
+
* - 图片卸载(#170,offload.ts):已落盘会话里,活动分支上被 `context_edit` 改写的含图消息只留占位
|
|
15
|
+
* (`data: ""`),`entries()` / `branch()` / `getEntry()` 返回卸载后的对象;`getEntries()` 与
|
|
16
|
+
* `fork()` 从文件回读原文;`setLeaf()` 换分支后回读不再被改写的条目。内存会话不卸载。
|
|
14
17
|
*/
|
|
15
18
|
import type { SessionEntry, SessionEntryInput, SessionHeader, SessionListItem, SessionManagerApi, SessionTreeNode } from "./types.js";
|
|
16
19
|
export interface SessionManagerOptions {
|
|
@@ -20,6 +23,8 @@ export interface SessionManagerOptions {
|
|
|
20
23
|
parentSession?: string;
|
|
21
24
|
/** 指定会话 id(`--session-id`)。 */
|
|
22
25
|
id?: string;
|
|
26
|
+
/** 图片回读失败等告警(缺省丢弃)。 */
|
|
27
|
+
warn?: (message: string) => void;
|
|
23
28
|
}
|
|
24
29
|
export declare class SessionManager implements SessionManagerApi {
|
|
25
30
|
readonly id: string;
|
|
@@ -31,6 +36,10 @@ export declare class SessionManager implements SessionManagerApi {
|
|
|
31
36
|
private storage;
|
|
32
37
|
private closed;
|
|
33
38
|
private readonly now;
|
|
39
|
+
private readonly images;
|
|
40
|
+
private warn;
|
|
41
|
+
/** 已知的会话文件字节数(新行的行首偏移)。 */
|
|
42
|
+
private fileBytes;
|
|
34
43
|
private constructor();
|
|
35
44
|
private static newHeader;
|
|
36
45
|
static inMemory(cwd: string, options?: SessionManagerOptions): SessionManager;
|
|
@@ -39,6 +48,7 @@ export declare class SessionManager implements SessionManagerApi {
|
|
|
39
48
|
static createForCwd(sessionsRoot: string, cwd: string, options?: SessionManagerOptions): SessionManager;
|
|
40
49
|
static open(file: string, options?: {
|
|
41
50
|
now?: () => Date;
|
|
51
|
+
warn?: (message: string) => void;
|
|
42
52
|
}): SessionManager;
|
|
43
53
|
/** `--continue`:本目录最近一条(跳过子 Agent 会话,只读文件头两行);没有则新建(延迟落盘)。 */
|
|
44
54
|
static continueRecent(dir: string, cwd: string): SessionManager;
|
|
@@ -51,6 +61,14 @@ export declare class SessionManager implements SessionManagerApi {
|
|
|
51
61
|
leafId(): string | null;
|
|
52
62
|
append(input: SessionEntryInput): SessionEntry;
|
|
53
63
|
setLeaf(id: string | null): void;
|
|
64
|
+
/** 按当前活动分支卸载 / 回读图片(只对已落盘的会话)。 */
|
|
65
|
+
private syncImages;
|
|
66
|
+
/** 原文条目(已卸载的从文件回读一份副本)。 */
|
|
67
|
+
private original;
|
|
68
|
+
/** 接上(或换掉)告警出口(图片读不回等);`open()` 期间缓冲的告警随即按序冲出(#183)。 */
|
|
69
|
+
setWarn(warn: (message: string) => void): void;
|
|
70
|
+
/** 已卸载图片的条目数(测试与诊断用)。 */
|
|
71
|
+
offloadedCount(): number;
|
|
54
72
|
private leafLine;
|
|
55
73
|
branch(leafId?: string | null): SessionEntry[];
|
|
56
74
|
getEntries(since?: string): {
|
package/dist/session/manager.js
CHANGED
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
* 不复制 leaf 行;给了 `head` 时它成为新根条目,复制的首条重挂到它下面(fork 子会话的 ama.task);
|
|
12
12
|
* `cwd` 缺省同本会话([ME-A] 隔离的 fork 子会话传 worktree)。
|
|
13
13
|
* - `close()`:释放锁;之后的 append 抛错。
|
|
14
|
+
* - 图片卸载(#170,offload.ts):已落盘会话里,活动分支上被 `context_edit` 改写的含图消息只留占位
|
|
15
|
+
* (`data: ""`),`entries()` / `branch()` / `getEntry()` 返回卸载后的对象;`getEntries()` 与
|
|
16
|
+
* `fork()` 从文件回读原文;`setLeaf()` 换分支后回读不再被改写的条目。内存会话不卸载。
|
|
14
17
|
*/
|
|
15
18
|
import { randomBytes, randomUUID } from "node:crypto";
|
|
16
19
|
import { dirname, join } from "node:path";
|
|
@@ -19,6 +22,7 @@ import { AmaError } from "../errors.js";
|
|
|
19
22
|
import { AMA_VERSION } from "../version.js";
|
|
20
23
|
import { listSessionItems } from "./list.js";
|
|
21
24
|
import { migrateSessionLines } from "./migrate.js";
|
|
25
|
+
import { ImageOffload, editTargets } from "./offload.js";
|
|
22
26
|
import { acquireLock, appendLines, isSubagentSessionFile, listSessionFiles, readSessionLines, sessionDirForCwd, sessionFileName, writeNewSessionFile, } from "./store.js";
|
|
23
27
|
import { buildTree, indexEntries, pathToRoot } from "./tree.js";
|
|
24
28
|
import { SESSION_FORMAT_VERSION } from "./types.js";
|
|
@@ -39,7 +43,11 @@ export class SessionManager {
|
|
|
39
43
|
storage;
|
|
40
44
|
closed = false;
|
|
41
45
|
now;
|
|
42
|
-
|
|
46
|
+
images;
|
|
47
|
+
warn;
|
|
48
|
+
/** 已知的会话文件字节数(新行的行首偏移)。 */
|
|
49
|
+
fileBytes = 0;
|
|
50
|
+
constructor(header, entries, storage, now, leafId, warn, images) {
|
|
43
51
|
this._header = header;
|
|
44
52
|
this.id = header.id;
|
|
45
53
|
this.cwd = header.cwd;
|
|
@@ -50,6 +58,8 @@ export class SessionManager {
|
|
|
50
58
|
leafId === undefined || (leafId !== null && !this.index.has(leafId)) ? last : leafId;
|
|
51
59
|
this.storage = storage;
|
|
52
60
|
this.now = now;
|
|
61
|
+
this.warn = warn;
|
|
62
|
+
this.images = images ?? new ImageOffload(warn);
|
|
53
63
|
}
|
|
54
64
|
static newHeader(cwd, options, now) {
|
|
55
65
|
const header = {
|
|
@@ -71,7 +81,7 @@ export class SessionManager {
|
|
|
71
81
|
/** `dir` 是会话文件所在目录(通常是 `sessionDirForCwd(sessionsRoot, cwd)`)。 */
|
|
72
82
|
static create(dir, cwd, options = {}) {
|
|
73
83
|
const now = options.now ?? (() => new Date());
|
|
74
|
-
return new SessionManager(this.newHeader(cwd, options, now()), [], { kind: "lazy", dir }, now);
|
|
84
|
+
return new SessionManager(this.newHeader(cwd, options, now()), [], { kind: "lazy", dir }, now, undefined, options.warn);
|
|
75
85
|
}
|
|
76
86
|
static createForCwd(sessionsRoot, cwd, options = {}) {
|
|
77
87
|
return this.create(sessionDirForCwd(sessionsRoot, cwd), cwd, options);
|
|
@@ -79,10 +89,21 @@ export class SessionManager {
|
|
|
79
89
|
static open(file, options = {}) {
|
|
80
90
|
const lock = acquireLock(file);
|
|
81
91
|
try {
|
|
82
|
-
const
|
|
92
|
+
const images = new ImageOffload(options.warn);
|
|
93
|
+
const targets = editTargets(file);
|
|
94
|
+
const { lines, bytes } = readSessionLines(file, {
|
|
95
|
+
repair: true,
|
|
96
|
+
onLine: (line, locator) => {
|
|
97
|
+
const entry = line;
|
|
98
|
+
images.load(entry, locator, targets.has(entry.id));
|
|
99
|
+
},
|
|
100
|
+
});
|
|
83
101
|
const { header, entries, leafId } = migrateSessionLines(lines, file);
|
|
84
102
|
internSessionImages(entries); // D5:同一图片的 base64 只留一份;JSONL 不变
|
|
85
|
-
|
|
103
|
+
const manager = new SessionManager(header, entries, { kind: "file", file, lock }, options.now ?? (() => new Date()), leafId, options.warn, images);
|
|
104
|
+
manager.fileBytes = bytes ?? 0;
|
|
105
|
+
manager.syncImages();
|
|
106
|
+
return manager;
|
|
86
107
|
}
|
|
87
108
|
catch (error) {
|
|
88
109
|
lock.release();
|
|
@@ -125,11 +146,19 @@ export class SessionManager {
|
|
|
125
146
|
parentId: this.leaf,
|
|
126
147
|
timestamp: this.now().toISOString(),
|
|
127
148
|
};
|
|
128
|
-
if (this.storage.kind === "file")
|
|
129
|
-
appendLines(this.storage.file, [entry]);
|
|
149
|
+
if (this.storage.kind === "file") {
|
|
150
|
+
const written = appendLines(this.storage.file, [entry]);
|
|
151
|
+
this.images.track(entry, { offset: this.fileBytes, length: written - 1 });
|
|
152
|
+
this.fileBytes += written;
|
|
153
|
+
}
|
|
130
154
|
this._entries.push(entry);
|
|
131
155
|
this.index.set(entry.id, entry);
|
|
132
156
|
this.leaf = entry.id;
|
|
157
|
+
if (entry.type === "context_edit" && this.storage.kind === "file") {
|
|
158
|
+
const target = this.index.get(entry.targetId);
|
|
159
|
+
if (target !== undefined && this.branch().includes(target))
|
|
160
|
+
this.images.offload(target);
|
|
161
|
+
}
|
|
133
162
|
return entry;
|
|
134
163
|
}
|
|
135
164
|
setLeaf(id) {
|
|
@@ -137,8 +166,30 @@ export class SessionManager {
|
|
|
137
166
|
throw new AmaError("invalid_arguments", `no such session entry: ${id}`);
|
|
138
167
|
}
|
|
139
168
|
this.leaf = id;
|
|
169
|
+
if (this.storage.kind === "file") {
|
|
170
|
+
this.fileBytes += appendLines(this.storage.file, [this.leafLine()]);
|
|
171
|
+
}
|
|
172
|
+
this.syncImages();
|
|
173
|
+
}
|
|
174
|
+
/** 按当前活动分支卸载 / 回读图片(只对已落盘的会话)。 */
|
|
175
|
+
syncImages() {
|
|
140
176
|
if (this.storage.kind === "file")
|
|
141
|
-
|
|
177
|
+
this.images.sync(this.storage.file, this.branch(), this.index);
|
|
178
|
+
}
|
|
179
|
+
/** 原文条目(已卸载的从文件回读一份副本)。 */
|
|
180
|
+
original(entry) {
|
|
181
|
+
if (this.storage.kind !== "file" || !this.images.isOffloaded(entry.id))
|
|
182
|
+
return entry;
|
|
183
|
+
return this.images.original(this.storage.file, entry);
|
|
184
|
+
}
|
|
185
|
+
/** 接上(或换掉)告警出口(图片读不回等);`open()` 期间缓冲的告警随即按序冲出(#183)。 */
|
|
186
|
+
setWarn(warn) {
|
|
187
|
+
this.warn = warn;
|
|
188
|
+
this.images.setWarn(warn);
|
|
189
|
+
}
|
|
190
|
+
/** 已卸载图片的条目数(测试与诊断用)。 */
|
|
191
|
+
offloadedCount() {
|
|
192
|
+
return this.images.size;
|
|
142
193
|
}
|
|
143
194
|
leafLine() {
|
|
144
195
|
return { type: "leaf", id: this.leaf, timestamp: this.now().toISOString() };
|
|
@@ -147,11 +198,9 @@ export class SessionManager {
|
|
|
147
198
|
return pathToRoot(this.index, leafId === undefined ? this.leaf : leafId);
|
|
148
199
|
}
|
|
149
200
|
getEntries(since) {
|
|
150
|
-
|
|
151
|
-
return { entries: [...this._entries], leafId: this.leaf };
|
|
152
|
-
const at = this._entries.findIndex((entry) => entry.id === since);
|
|
201
|
+
const at = since === undefined ? -1 : this._entries.findIndex((entry) => entry.id === since);
|
|
153
202
|
return {
|
|
154
|
-
entries:
|
|
203
|
+
entries: this._entries.slice(at + 1).map((entry) => this.original(entry)),
|
|
155
204
|
leafId: this.leaf,
|
|
156
205
|
};
|
|
157
206
|
}
|
|
@@ -172,7 +221,10 @@ export class SessionManager {
|
|
|
172
221
|
if (!this.index.has(entryId)) {
|
|
173
222
|
throw new AmaError("invalid_arguments", `no such session entry: ${entryId}`);
|
|
174
223
|
}
|
|
175
|
-
const copied = this.branch(entryId).map((entry) =>
|
|
224
|
+
const copied = this.branch(entryId).map((entry) => {
|
|
225
|
+
const original = this.original(entry);
|
|
226
|
+
return original === entry ? structuredClone(entry) : original;
|
|
227
|
+
});
|
|
176
228
|
if (forkOptions.head !== undefined) {
|
|
177
229
|
const head = {
|
|
178
230
|
...forkOptions.head,
|
|
@@ -196,7 +248,7 @@ export class SessionManager {
|
|
|
196
248
|
storage = { kind: "lazy", dir: this.storage.dir };
|
|
197
249
|
else
|
|
198
250
|
storage = { kind: "lazy", dir: dirname(this.storage.file) };
|
|
199
|
-
const forked = new SessionManager(header, copied, storage, this.now);
|
|
251
|
+
const forked = new SessionManager(header, copied, storage, this.now, undefined, this.warn);
|
|
200
252
|
if (parentFile !== undefined)
|
|
201
253
|
forked.flush();
|
|
202
254
|
return forked;
|
|
@@ -214,11 +266,19 @@ export class SessionManager {
|
|
|
214
266
|
return this.storage.file;
|
|
215
267
|
const file = join(this.storage.dir, sessionFileName(new Date(this._header.timestamp), this.id));
|
|
216
268
|
const moved = this.leaf !== (this._entries.at(-1)?.id ?? null);
|
|
217
|
-
writeNewSessionFile(file, [
|
|
269
|
+
const lengths = writeNewSessionFile(file, [
|
|
218
270
|
this._header,
|
|
219
271
|
...this._entries,
|
|
220
272
|
...(moved ? [this.leafLine()] : []),
|
|
221
273
|
]);
|
|
274
|
+
let offset = 0;
|
|
275
|
+
lengths.forEach((length, i) => {
|
|
276
|
+
const entry = i === 0 ? undefined : this._entries[i - 1];
|
|
277
|
+
if (entry !== undefined)
|
|
278
|
+
this.images.track(entry, { offset, length });
|
|
279
|
+
offset += length + 1;
|
|
280
|
+
});
|
|
281
|
+
this.fileBytes = offset;
|
|
222
282
|
let lock;
|
|
223
283
|
try {
|
|
224
284
|
lock = acquireLock(file);
|
|
@@ -227,6 +287,7 @@ export class SessionManager {
|
|
|
227
287
|
lock = undefined;
|
|
228
288
|
}
|
|
229
289
|
this.storage = { kind: "file", file, lock };
|
|
290
|
+
this.syncImages();
|
|
230
291
|
return file;
|
|
231
292
|
}
|
|
232
293
|
isPersisted() {
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 会话图片卸载(#170):被 `context_edit` 改写的旧消息不再进上下文,内存里只留它在会话文件中的行位置,
|
|
3
|
+
* 图片 base64 需要时再从文件回读。JSONL 格式不变。
|
|
4
|
+
*
|
|
5
|
+
* - 对象:活动分支上、被活动分支上的 `context_edit`(任何 reason)改写的 `message` 条目里的 `image` 块;
|
|
6
|
+
* 不在活动分支上的条目一律保持原文(rewind 回到编辑点之前、`/tree` 换分支时回读);
|
|
7
|
+
* - 打开文件时先扫一遍 `context_edit` 行(只解码行首判断类型)收集目标,读条目时目标里的图片解析完
|
|
8
|
+
* 随即剥掉,峰值不再含全部旧图;随后按活动分支对齐(不在分支上的编辑目标回读);
|
|
9
|
+
* - 卸载**不改共享的 ImageBlock**(驻留 block 被其它条目 / 会话共享):`entry.message` 换成浅拷贝,
|
|
10
|
+
* `content` 换新数组,image 块换成 `data: ""` 的占位;
|
|
11
|
+
* - 回读:按行位置 `readSync` 一行、`JSON.parse`,校验 id 与内容块数,图片经 `internImage` 驻留后
|
|
12
|
+
* **就地**放回占位所在的数组(之前拿到同一消息对象的调用方随之看到原图);读不回(文件被外部改写)时
|
|
13
|
+
* 保留 `""` 并告警,不再重试;告警通道可后接(`setWarn`):接上之前的告警先缓冲(最多
|
|
14
|
+
* `PENDING_WARNINGS` 条,其余折叠成一条计数),接上时按序冲出(#183);
|
|
15
|
+
* - `original(entry)`:给 `getEntries` / `fork` 的原文副本,不改内存状态。
|
|
16
|
+
*/
|
|
17
|
+
import type { SessionEntry } from "./types.js";
|
|
18
|
+
/** 一行在会话文件里的位置:行首偏移与字节数(不含行尾)。 */
|
|
19
|
+
export interface LineLocator {
|
|
20
|
+
offset: number;
|
|
21
|
+
length: number;
|
|
22
|
+
}
|
|
23
|
+
/** `message` 条目的内容里有图片块。 */
|
|
24
|
+
export declare function hasImages(entry: SessionEntry): boolean;
|
|
25
|
+
/** 读文件里 `locator` 处的一行并解析。 */
|
|
26
|
+
export declare function readLineAt(file: string, locator: LineLocator): unknown;
|
|
27
|
+
/** 文件里全部 `context_edit` 的目标 id(只解析 context_edit 行;读不了时为空集)。 */
|
|
28
|
+
export declare function editTargets(file: string): Set<string>;
|
|
29
|
+
/** 告警通道接上之前最多缓冲的条数;超出的只计数,冲出时折叠成一条。 */
|
|
30
|
+
export declare const PENDING_WARNINGS = 16;
|
|
31
|
+
export declare class ImageOffload {
|
|
32
|
+
/** 含图 message 条目的行位置(只有已落盘的条目才有)。 */
|
|
33
|
+
private readonly locators;
|
|
34
|
+
private readonly offloaded;
|
|
35
|
+
private warn;
|
|
36
|
+
private readonly pending;
|
|
37
|
+
private dropped;
|
|
38
|
+
constructor(warn?: (message: string) => void);
|
|
39
|
+
/** 接上(或换掉)告警通道;之前缓冲的告警按序冲出。 */
|
|
40
|
+
setWarn(warn: (message: string) => void): void;
|
|
41
|
+
/** 记下条目的行位置;不含图片的条目忽略。 */
|
|
42
|
+
track(entry: SessionEntry, locator: LineLocator): void;
|
|
43
|
+
/** 读文件时逐行调用:记下行位置;`strip` 时就地换成占位(刚解析的对象,别处没有引用)。 */
|
|
44
|
+
load(line: SessionEntry, locator: LineLocator, strip: boolean): void;
|
|
45
|
+
isOffloaded(id: string): boolean;
|
|
46
|
+
/** 已卸载的条目数(测试与诊断用)。 */
|
|
47
|
+
get size(): number;
|
|
48
|
+
/** 把条目里的图片换成占位;没有行位置(未落盘、不含图)或已卸载时返回 false。 */
|
|
49
|
+
offload(entry: SessionEntry): boolean;
|
|
50
|
+
/** 就地放回图片;读不回时返回 false(占位保留,之后不再卸载 / 回读这一条)。 */
|
|
51
|
+
hydrate(file: string, entry: SessionEntry): boolean;
|
|
52
|
+
/** 原文:已卸载的条目从文件读一份新副本(图片驻留),否则原样返回。读不回时返回占位版本。 */
|
|
53
|
+
original(file: string, entry: SessionEntry): SessionEntry;
|
|
54
|
+
/** 按活动分支对齐:分支上被生效编辑改写的含图条目卸载,其余已卸载的回读。 */
|
|
55
|
+
sync(file: string, branch: readonly SessionEntry[], index: ReadonlyMap<string, SessionEntry>): void;
|
|
56
|
+
/** 从文件读回条目(新对象);不成功时告警、忘掉行位置并返回 undefined。 */
|
|
57
|
+
private read;
|
|
58
|
+
private report;
|
|
59
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 会话图片卸载(#170):被 `context_edit` 改写的旧消息不再进上下文,内存里只留它在会话文件中的行位置,
|
|
3
|
+
* 图片 base64 需要时再从文件回读。JSONL 格式不变。
|
|
4
|
+
*
|
|
5
|
+
* - 对象:活动分支上、被活动分支上的 `context_edit`(任何 reason)改写的 `message` 条目里的 `image` 块;
|
|
6
|
+
* 不在活动分支上的条目一律保持原文(rewind 回到编辑点之前、`/tree` 换分支时回读);
|
|
7
|
+
* - 打开文件时先扫一遍 `context_edit` 行(只解码行首判断类型)收集目标,读条目时目标里的图片解析完
|
|
8
|
+
* 随即剥掉,峰值不再含全部旧图;随后按活动分支对齐(不在分支上的编辑目标回读);
|
|
9
|
+
* - 卸载**不改共享的 ImageBlock**(驻留 block 被其它条目 / 会话共享):`entry.message` 换成浅拷贝,
|
|
10
|
+
* `content` 换新数组,image 块换成 `data: ""` 的占位;
|
|
11
|
+
* - 回读:按行位置 `readSync` 一行、`JSON.parse`,校验 id 与内容块数,图片经 `internImage` 驻留后
|
|
12
|
+
* **就地**放回占位所在的数组(之前拿到同一消息对象的调用方随之看到原图);读不回(文件被外部改写)时
|
|
13
|
+
* 保留 `""` 并告警,不再重试;告警通道可后接(`setWarn`):接上之前的告警先缓冲(最多
|
|
14
|
+
* `PENDING_WARNINGS` 条,其余折叠成一条计数),接上时按序冲出(#183);
|
|
15
|
+
* - `original(entry)`:给 `getEntries` / `fork` 的原文副本,不改内存状态。
|
|
16
|
+
*/
|
|
17
|
+
import { closeSync, openSync, readSync } from "node:fs";
|
|
18
|
+
import { internImage } from "../ai/image-intern.js";
|
|
19
|
+
import { forEachLineSync, lineTypeOf } from "./line-reader.js";
|
|
20
|
+
import { collectContextEdits } from "./projection.js";
|
|
21
|
+
function isImage(block) {
|
|
22
|
+
return (typeof block === "object" &&
|
|
23
|
+
block !== null &&
|
|
24
|
+
block.type === "image" &&
|
|
25
|
+
typeof block.data === "string");
|
|
26
|
+
}
|
|
27
|
+
function contentOf(entry) {
|
|
28
|
+
if (entry.type !== "message")
|
|
29
|
+
return undefined;
|
|
30
|
+
const content = entry.message.content;
|
|
31
|
+
return Array.isArray(content) ? content : undefined;
|
|
32
|
+
}
|
|
33
|
+
/** `message` 条目的内容里有图片块。 */
|
|
34
|
+
export function hasImages(entry) {
|
|
35
|
+
return contentOf(entry)?.some(isImage) === true;
|
|
36
|
+
}
|
|
37
|
+
/** 读文件里 `locator` 处的一行并解析。 */
|
|
38
|
+
export function readLineAt(file, locator) {
|
|
39
|
+
const fd = openSync(file, "r");
|
|
40
|
+
try {
|
|
41
|
+
const buf = Buffer.allocUnsafe(locator.length);
|
|
42
|
+
let read = 0;
|
|
43
|
+
while (read < locator.length) {
|
|
44
|
+
const n = readSync(fd, buf, read, locator.length - read, locator.offset + read);
|
|
45
|
+
if (n === 0)
|
|
46
|
+
throw new Error("unexpected end of file");
|
|
47
|
+
read += n;
|
|
48
|
+
}
|
|
49
|
+
return JSON.parse(buf.toString("utf8"));
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
closeSync(fd);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** 文件里全部 `context_edit` 的目标 id(只解析 context_edit 行;读不了时为空集)。 */
|
|
56
|
+
export function editTargets(file) {
|
|
57
|
+
const targets = new Set();
|
|
58
|
+
try {
|
|
59
|
+
forEachLineSync(file, (line) => {
|
|
60
|
+
if (lineTypeOf(line)?.type !== "context_edit")
|
|
61
|
+
return;
|
|
62
|
+
try {
|
|
63
|
+
const id = JSON.parse(line.toString("utf8")).targetId;
|
|
64
|
+
if (typeof id === "string")
|
|
65
|
+
targets.add(id);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
// 末尾半行:随后的 readSessionLines 修复
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
// 读不了:由随后的 readSessionLines 报错
|
|
74
|
+
}
|
|
75
|
+
return targets;
|
|
76
|
+
}
|
|
77
|
+
const placeholder = (block) => ({ ...block, data: "" });
|
|
78
|
+
/** 告警通道接上之前最多缓冲的条数;超出的只计数,冲出时折叠成一条。 */
|
|
79
|
+
export const PENDING_WARNINGS = 16;
|
|
80
|
+
export class ImageOffload {
|
|
81
|
+
/** 含图 message 条目的行位置(只有已落盘的条目才有)。 */
|
|
82
|
+
locators = new Map();
|
|
83
|
+
offloaded = new Set();
|
|
84
|
+
warn;
|
|
85
|
+
pending = [];
|
|
86
|
+
dropped = 0;
|
|
87
|
+
constructor(warn) {
|
|
88
|
+
this.warn = warn;
|
|
89
|
+
}
|
|
90
|
+
/** 接上(或换掉)告警通道;之前缓冲的告警按序冲出。 */
|
|
91
|
+
setWarn(warn) {
|
|
92
|
+
this.warn = warn;
|
|
93
|
+
const pending = this.pending.splice(0);
|
|
94
|
+
if (this.dropped > 0)
|
|
95
|
+
pending.push(`and ${this.dropped} more session entries could not be read back`);
|
|
96
|
+
this.dropped = 0;
|
|
97
|
+
for (const message of pending)
|
|
98
|
+
warn(message);
|
|
99
|
+
}
|
|
100
|
+
/** 记下条目的行位置;不含图片的条目忽略。 */
|
|
101
|
+
track(entry, locator) {
|
|
102
|
+
if (hasImages(entry))
|
|
103
|
+
this.locators.set(entry.id, locator);
|
|
104
|
+
}
|
|
105
|
+
/** 读文件时逐行调用:记下行位置;`strip` 时就地换成占位(刚解析的对象,别处没有引用)。 */
|
|
106
|
+
load(line, locator, strip) {
|
|
107
|
+
if (typeof line.id !== "string" || !hasImages(line))
|
|
108
|
+
return;
|
|
109
|
+
this.locators.set(line.id, locator);
|
|
110
|
+
if (!strip)
|
|
111
|
+
return;
|
|
112
|
+
const content = contentOf(line);
|
|
113
|
+
content.forEach((block, i) => {
|
|
114
|
+
if (isImage(block))
|
|
115
|
+
content[i] = placeholder(block);
|
|
116
|
+
});
|
|
117
|
+
this.offloaded.add(line.id);
|
|
118
|
+
}
|
|
119
|
+
isOffloaded(id) {
|
|
120
|
+
return this.offloaded.has(id);
|
|
121
|
+
}
|
|
122
|
+
/** 已卸载的条目数(测试与诊断用)。 */
|
|
123
|
+
get size() {
|
|
124
|
+
return this.offloaded.size;
|
|
125
|
+
}
|
|
126
|
+
/** 把条目里的图片换成占位;没有行位置(未落盘、不含图)或已卸载时返回 false。 */
|
|
127
|
+
offload(entry) {
|
|
128
|
+
if (entry.type !== "message" || this.offloaded.has(entry.id))
|
|
129
|
+
return false;
|
|
130
|
+
const content = contentOf(entry);
|
|
131
|
+
if (content === undefined || !this.locators.has(entry.id))
|
|
132
|
+
return false;
|
|
133
|
+
entry.message = {
|
|
134
|
+
...entry.message,
|
|
135
|
+
content: content.map((block) => (isImage(block) ? placeholder(block) : block)),
|
|
136
|
+
};
|
|
137
|
+
this.offloaded.add(entry.id);
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
/** 就地放回图片;读不回时返回 false(占位保留,之后不再卸载 / 回读这一条)。 */
|
|
141
|
+
hydrate(file, entry) {
|
|
142
|
+
if (!this.offloaded.has(entry.id))
|
|
143
|
+
return true;
|
|
144
|
+
this.offloaded.delete(entry.id);
|
|
145
|
+
const source = this.read(file, entry);
|
|
146
|
+
const content = contentOf(entry);
|
|
147
|
+
if (source === undefined || content === undefined)
|
|
148
|
+
return false;
|
|
149
|
+
const originals = contentOf(source) ?? [];
|
|
150
|
+
content.forEach((block, i) => {
|
|
151
|
+
const original = originals[i];
|
|
152
|
+
if (isImage(block) && block.data === "" && isImage(original)) {
|
|
153
|
+
content[i] = internImage(original);
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
/** 原文:已卸载的条目从文件读一份新副本(图片驻留),否则原样返回。读不回时返回占位版本。 */
|
|
159
|
+
original(file, entry) {
|
|
160
|
+
if (!this.offloaded.has(entry.id))
|
|
161
|
+
return entry;
|
|
162
|
+
const copy = this.read(file, entry);
|
|
163
|
+
if (copy === undefined)
|
|
164
|
+
return entry;
|
|
165
|
+
const content = contentOf(copy) ?? [];
|
|
166
|
+
content.forEach((block, i) => {
|
|
167
|
+
if (isImage(block))
|
|
168
|
+
content[i] = internImage(block);
|
|
169
|
+
});
|
|
170
|
+
return copy;
|
|
171
|
+
}
|
|
172
|
+
/** 按活动分支对齐:分支上被生效编辑改写的含图条目卸载,其余已卸载的回读。 */
|
|
173
|
+
sync(file, branch, index) {
|
|
174
|
+
const edits = collectContextEdits(branch);
|
|
175
|
+
const onBranch = new Set();
|
|
176
|
+
for (const entry of branch)
|
|
177
|
+
if (edits.has(entry.id))
|
|
178
|
+
onBranch.add(entry.id);
|
|
179
|
+
for (const id of [...this.offloaded]) {
|
|
180
|
+
const entry = index.get(id);
|
|
181
|
+
if (entry !== undefined && !onBranch.has(id))
|
|
182
|
+
this.hydrate(file, entry);
|
|
183
|
+
}
|
|
184
|
+
for (const id of onBranch) {
|
|
185
|
+
const entry = index.get(id);
|
|
186
|
+
if (entry !== undefined)
|
|
187
|
+
this.offload(entry);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
/** 从文件读回条目(新对象);不成功时告警、忘掉行位置并返回 undefined。 */
|
|
191
|
+
read(file, entry) {
|
|
192
|
+
const locator = this.locators.get(entry.id);
|
|
193
|
+
try {
|
|
194
|
+
if (locator === undefined)
|
|
195
|
+
throw new Error("no line locator");
|
|
196
|
+
const line = readLineAt(file, locator);
|
|
197
|
+
const content = line.id === entry.id ? contentOf(line) : undefined;
|
|
198
|
+
if (content === undefined || content.length !== contentOf(entry)?.length) {
|
|
199
|
+
throw new Error("line does not match the entry");
|
|
200
|
+
}
|
|
201
|
+
return line;
|
|
202
|
+
}
|
|
203
|
+
catch (error) {
|
|
204
|
+
this.locators.delete(entry.id);
|
|
205
|
+
this.offloaded.delete(entry.id);
|
|
206
|
+
this.report(`cannot read session entry ${entry.id} back from ${file}: ${String(error)}`);
|
|
207
|
+
return undefined;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
report(message) {
|
|
211
|
+
if (this.warn !== undefined)
|
|
212
|
+
this.warn(message);
|
|
213
|
+
else if (this.pending.length < PENDING_WARNINGS)
|
|
214
|
+
this.pending.push(message);
|
|
215
|
+
else
|
|
216
|
+
this.dropped++;
|
|
217
|
+
}
|
|
218
|
+
}
|
package/dist/session/store.d.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* - 锁:`<file>.lock`(O_EXCL 创建,内容为 pid);持锁进程已不在 → 视为陈旧锁并接管。
|
|
8
8
|
* - prune:移到 `<sessionsRoot>/.trash/`,文件名前缀删除时间;`purgeTrash` 清理超过 7 天的。
|
|
9
9
|
*/
|
|
10
|
+
import type { LineLocator } from "./offload.js";
|
|
10
11
|
import type { SessionLine } from "./types.js";
|
|
11
12
|
export declare const SESSION_FILE_SUFFIX = ".jsonl";
|
|
12
13
|
export declare const TRASH_DIR_NAME = ".trash";
|
|
@@ -22,6 +23,8 @@ export interface ReadResult {
|
|
|
22
23
|
lines: SessionLine[];
|
|
23
24
|
/** 末尾半行被截掉时为 true。 */
|
|
24
25
|
repairedTail: boolean;
|
|
26
|
+
/** 给了 `onLine` 时:读完(含修复)后的文件字节数。 */
|
|
27
|
+
bytes?: number;
|
|
25
28
|
}
|
|
26
29
|
/** 空白行(同旧口径 `trim() === ""`):ASCII 空白直接判定,遇到非 ASCII 字节才解码再 trim。 */
|
|
27
30
|
export declare function isBlankLine(line: Buffer): boolean;
|
|
@@ -31,11 +34,16 @@ export declare function isBlankLine(line: Buffer): boolean;
|
|
|
31
34
|
*/
|
|
32
35
|
export declare function readSessionLines(file: string, options?: {
|
|
33
36
|
repair?: boolean;
|
|
37
|
+
/** 每解析出一行就回调(行位置不含行尾;#170 图片卸载在这里就地剥掉图片)。 */
|
|
38
|
+
onLine?: (line: SessionLine, locator: LineLocator) => void;
|
|
34
39
|
}): ReadResult;
|
|
35
|
-
/** 追加若干行(一次 write,O_APPEND
|
|
36
|
-
export declare function appendLines(file: string, lines: readonly SessionLine[]):
|
|
37
|
-
/**
|
|
38
|
-
|
|
40
|
+
/** 追加若干行(一次 write,O_APPEND),返回写入的字节数。 */
|
|
41
|
+
export declare function appendLines(file: string, lines: readonly SessionLine[]): number;
|
|
42
|
+
/**
|
|
43
|
+
* 新建文件:先写临时文件再 rename,避免出现只有半个头的文件。返回每行的字节数(不含行尾,与
|
|
44
|
+
* `lines` 同序)。
|
|
45
|
+
*/
|
|
46
|
+
export declare function writeNewSessionFile(file: string, lines: readonly SessionLine[]): number[];
|
|
39
47
|
export interface SessionLock {
|
|
40
48
|
readonly path: string;
|
|
41
49
|
release(): void;
|
package/dist/session/store.js
CHANGED
|
@@ -57,8 +57,9 @@ export function readSessionLines(file, options = {}) {
|
|
|
57
57
|
unterminated = true;
|
|
58
58
|
if (isBlankLine(buf))
|
|
59
59
|
return;
|
|
60
|
+
let line;
|
|
60
61
|
try {
|
|
61
|
-
|
|
62
|
+
line = JSON.parse(buf.toString("utf8"));
|
|
62
63
|
}
|
|
63
64
|
catch (error) {
|
|
64
65
|
if (last) {
|
|
@@ -71,6 +72,8 @@ export function readSessionLines(file, options = {}) {
|
|
|
71
72
|
cause: error,
|
|
72
73
|
});
|
|
73
74
|
}
|
|
75
|
+
options.onLine?.(line, { offset, length: buf.length });
|
|
76
|
+
lines.push(line);
|
|
74
77
|
});
|
|
75
78
|
}
|
|
76
79
|
catch (error) {
|
|
@@ -82,32 +85,39 @@ export function readSessionLines(file, options = {}) {
|
|
|
82
85
|
// 最后一行完整但缺 LF:补上,保证后续追加另起一行。
|
|
83
86
|
appendRaw(file, "\n");
|
|
84
87
|
}
|
|
85
|
-
|
|
88
|
+
if (options.onLine === undefined)
|
|
89
|
+
return { lines, repairedTail };
|
|
90
|
+
return { lines, repairedTail, bytes: statSync(file).size };
|
|
86
91
|
}
|
|
87
92
|
function appendRaw(file, text) {
|
|
88
93
|
const fd = openSync(file, "a");
|
|
89
94
|
try {
|
|
90
|
-
writeSync(fd, text);
|
|
95
|
+
return writeSync(fd, text);
|
|
91
96
|
}
|
|
92
97
|
finally {
|
|
93
98
|
closeSync(fd);
|
|
94
99
|
}
|
|
95
100
|
}
|
|
96
|
-
/** 追加若干行(一次 write,O_APPEND
|
|
101
|
+
/** 追加若干行(一次 write,O_APPEND),返回写入的字节数。 */
|
|
97
102
|
export function appendLines(file, lines) {
|
|
98
103
|
if (lines.length === 0)
|
|
99
|
-
return;
|
|
100
|
-
appendRaw(file, lines.map((line) => JSON.stringify(line)).join("\n") + "\n");
|
|
104
|
+
return 0;
|
|
105
|
+
return appendRaw(file, lines.map((line) => JSON.stringify(line)).join("\n") + "\n");
|
|
101
106
|
}
|
|
102
|
-
/**
|
|
107
|
+
/**
|
|
108
|
+
* 新建文件:先写临时文件再 rename,避免出现只有半个头的文件。返回每行的字节数(不含行尾,与
|
|
109
|
+
* `lines` 同序)。
|
|
110
|
+
*/
|
|
103
111
|
export function writeNewSessionFile(file, lines) {
|
|
104
112
|
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
|
105
113
|
if (existsSync(file)) {
|
|
106
114
|
throw new AmaError("session_exists", `session file already exists: ${file}`);
|
|
107
115
|
}
|
|
108
116
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
109
|
-
|
|
117
|
+
const texts = lines.map((line) => JSON.stringify(line));
|
|
118
|
+
writeFileSync(tmp, texts.join("\n") + "\n", { mode: 0o600 });
|
|
110
119
|
renameSync(tmp, file);
|
|
120
|
+
return texts.map((text) => Buffer.byteLength(text));
|
|
111
121
|
}
|
|
112
122
|
// ---------------------------------------------------------------------------
|
|
113
123
|
// 锁
|
|
@@ -5,14 +5,14 @@
|
|
|
5
5
|
* 这里只拿到 ChildProcess;stdout / stderr 直接写进 `outputPath`(文件描述符交给子进程,ama 退出也不
|
|
6
6
|
* 会因管道断开而 SIGPIPE)。POSIX 自成进程组并登记(进程退出时同步 SIGKILL 残留组)。
|
|
7
7
|
* - `wait(id, timeoutMs)`:等到退出或超时;`stop(id)`:杀整棵树(SIGTERM → 宽限 → SIGKILL);
|
|
8
|
-
* `output(id)
|
|
8
|
+
* `output(id, options?)`:读输出文件尾部(缺省 2000 行 / 50 KB,调用方可传字节上限)。
|
|
9
9
|
* - 生命周期事件经 `onChange` 发出(started / exited / stopped);`takeExited()` 取走尚未通知的已退出
|
|
10
10
|
* 任务(提醒通道 reminders.ts 据此告诉模型「后台命令 jobId 已退出(码 N)」)。
|
|
11
11
|
* - 按会话 id 登记(`jobsForSession`);`disposeSessionJobs` 在会话 dispose 时回收进程树。
|
|
12
12
|
*/
|
|
13
13
|
import type { ChildProcess } from "node:child_process";
|
|
14
14
|
import { type ProcessDeps } from "./process-tree.js";
|
|
15
|
-
import { type TruncateResult } from "./truncate.js";
|
|
15
|
+
import { type TruncateOptions, type TruncateResult } from "./truncate.js";
|
|
16
16
|
export type JobStatus = "running" | "exited" | "stopped";
|
|
17
17
|
export interface Job {
|
|
18
18
|
id: string;
|
|
@@ -49,8 +49,8 @@ export declare class BackgroundJobs {
|
|
|
49
49
|
/** 等到退出或超时(`signal` 可提前结束等待,不杀进程)。 */
|
|
50
50
|
wait(id: string, timeoutMs: number, signal?: AbortSignal): Promise<Job | undefined>;
|
|
51
51
|
stop(id: string): Promise<Job | undefined>;
|
|
52
|
-
/**
|
|
53
|
-
output(id: string): (TruncateResult & {
|
|
52
|
+
/** 输出文件尾部(缺省 2000 行 / 50 KB;调用方可传字节上限,#155)。 */
|
|
53
|
+
output(id: string, options?: TruncateOptions): (TruncateResult & {
|
|
54
54
|
job: Job;
|
|
55
55
|
}) | undefined;
|
|
56
56
|
/** 取走已退出、尚未通知的任务(提醒通道用;wait 已看到退出的也算已知)。 */
|