@armadra/agent 0.7.4 → 0.7.5

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.
@@ -76,7 +76,8 @@ export async function bootstrap(argsIn, deps, io) {
76
76
  request = { kind: "resume", id };
77
77
  }
78
78
  const sessionRequest = request;
79
- const sessionManager = await step(ExitCode.Session, m().steps.session, () => deps.sessions.open(sessionRequest, { sessionDir: paths.sessionDir, cwd: paths.cwd }));
79
+ // [#165] 会话替换后跟随当前会话(闭包只经它取,启动会话的 manager 不被常驻)
80
+ let sessionManager = await step(ExitCode.Session, m().steps.session, () => deps.sessions.open(sessionRequest, { sessionDir: paths.sessionDir, cwd: paths.cwd }));
80
81
  let sessionCwd = sessionManager.cwd;
81
82
  if (!existsSync(sessionCwd)) {
82
83
  const replacement = interactive ? await deps.ui?.askCwd?.(sessionCwd) : undefined;
@@ -281,6 +282,8 @@ export async function bootstrap(argsIn, deps, io) {
281
282
  uiBroker: () => uiBroker,
282
283
  onSessionReplaced: (next) => {
283
284
  session = next;
285
+ sessionManager = next.manager ?? sessionManager;
286
+ assembly.sessionManager = sessionManager;
284
287
  },
285
288
  sessionStartContext: () => sessionStartContext,
286
289
  unattended,
@@ -302,7 +305,7 @@ export async function bootstrap(argsIn, deps, io) {
302
305
  if (Object.keys(overrides).length > 0)
303
306
  assembly.overrides = overrides;
304
307
  session = await step(ExitCode.RuntimeError, m().steps.assembly, () => deps.session.create(assembly));
305
- const active = session;
308
+ const active = session; // 只同步使用:闭包捕获它会让启动会话常驻(#165)
306
309
  let disposed;
307
310
  shutdown = (reason) => {
308
311
  disposed ??= (async () => {
@@ -310,7 +313,7 @@ export async function bootstrap(argsIn, deps, io) {
310
313
  await hooks.run("SessionEnd", { reason }).catch(() => undefined);
311
314
  await disposeHost(host, (e) => warn(m().hostDisposeFailed(String(e))));
312
315
  // 会话被替换过时 dispose 当前那个(旧会话由替换方负责)
313
- await (session ?? active).dispose();
316
+ await session?.dispose();
314
317
  })();
315
318
  return disposed;
316
319
  };
@@ -339,8 +342,13 @@ export async function bootstrap(argsIn, deps, io) {
339
342
  providers,
340
343
  model,
341
344
  thinkingLevel,
342
- sessionManager,
343
- session: active,
345
+ // [#165] 跟随当前会话(ACP 关掉启动会话后它可被回收)
346
+ get sessionManager() {
347
+ return sessionManager;
348
+ },
349
+ get session() {
350
+ return session;
351
+ },
344
352
  hooks,
345
353
  host,
346
354
  permission,
@@ -107,7 +107,7 @@ export interface SessionAssembly {
107
107
  /**
108
108
  * 模式层把当前会话换成另一个(`/new`、`/resume`、`/fork`、RPC `switch_session`)后调用:
109
109
  * bootstrap 让 HostApi.session.*、宿主 sendUser、Hook 公共字段与退出时的 dispose 跟随 `next`。
110
- * 旧会话由调用方自行 dispose;`Runtime.session` 仍指初始会话。
110
+ * 旧会话由调用方自行 dispose;`Runtime.session` / `sessionManager` 也随之指向 `next`(#165)。
111
111
  */
112
112
  onSessionReplaced(next: AgentSession): void;
113
113
  /** SessionStart Hook 的 additionalContext(系统提示 hooks 节);首次装配系统提示时读取。 */
@@ -19,9 +19,10 @@
19
19
  import { realpathSync } from "node:fs";
20
20
  import { resolve } from "node:path";
21
21
  import { stopReasonOf } from "../../ai/apis/shared.js";
22
- import { createSessionAlongside, currentSession, disposeSessionAlongside, setForegroundSession, } from "../../cli/compose-session.js";
22
+ import { createSessionAlongside, currentSession, setForegroundSession, } from "../../cli/compose-session.js";
23
23
  import { listSessions } from "../../cli/compose-store.js";
24
24
  import { AgentSessionImpl } from "../../agent/session.js";
25
+ import { taskControl } from "../../agents/task-control.js";
25
26
  import { RpcError } from "../../drivers/jsonrpc.js";
26
27
  import { ACP_METHODS, ACP_PROTOCOL_VERSION, RPC_ERRORS, } from "../../drivers/acp/types.js";
27
28
  import { isPermissionMode } from "../../permissions/modes.js";
@@ -31,7 +32,7 @@ import { AcpEventMapper, permissionModes, toolKind, toolLocations, toolTitle, }
31
32
  import { msg } from "../../i18n/index.js";
32
33
  import { CONFIG_IDS, applyConfigOption, availableCommands, buildConfigOptions, prepareConfigOptions, } from "./acp-config.js";
33
34
  import { clientCapabilitiesOf } from "./acp-connection.js";
34
- import { PromptQueue, cancelledResult, pageSessions, promptWhenIdle, sessionTitle, } from "./acp-sessions.js";
35
+ import { PromptQueue, cancelledResult, disposeQuiet, pageSessions, promptWhenIdle, quiesce, sessionTitle, } from "./acp-sessions.js";
35
36
  export const ACP_AGENT_CAPABILITIES = {
36
37
  loadSession: true,
37
38
  promptCapabilities: { image: true, audio: false, embeddedContext: true },
@@ -157,8 +158,9 @@ export class AcpServer {
157
158
  .abort()
158
159
  .catch(() => undefined);
159
160
  await this.queue.settled();
160
- for (const session of this.sessions())
161
- await session.waitForIdle().catch(() => undefined);
161
+ for (const s of this.sessions())
162
+ if (s instanceof AgentSessionImpl)
163
+ await quiesce(s, taskControl(s.state.sessionId));
162
164
  const foreground = this.session();
163
165
  for (const entry of this.pool.values())
164
166
  entry.unsubscribe();
@@ -167,7 +169,7 @@ export class AcpServer {
167
169
  continue;
168
170
  if (!(foreground instanceof AgentSessionImpl))
169
171
  continue;
170
- await disposeSessionAlongside(this.runtime, session, foreground).catch(() => undefined);
172
+ await disposeQuiet(this.runtime, session, foreground).catch(() => undefined);
171
173
  }
172
174
  this.pool.clear();
173
175
  this.standby = undefined;
@@ -311,12 +313,11 @@ export class AcpServer {
311
313
  return {};
312
314
  this.pool.delete(id);
313
315
  this.queue.cancelQueued(id);
314
- const running = this.queue.running;
315
- if (running?.sessionId === id) {
316
- running.cancelRequested = true;
317
- await entry.session.abort().catch(() => undefined);
318
- await this.queue.settled(id);
319
- }
316
+ // [#167] 通知回合不经队列:不论谁在跑都先安静下来(停后台任务、abort 到空闲)
317
+ if (this.queue.running?.sessionId === id)
318
+ this.queue.running.cancelRequested = true;
319
+ await quiesce(entry.session, taskControl(id));
320
+ await this.queue.settled(id);
320
321
  entry.unsubscribe();
321
322
  let fallback = this.session();
322
323
  if (fallback === entry.session) {
@@ -330,7 +331,7 @@ export class AcpServer {
330
331
  }
331
332
  }
332
333
  if (fallback instanceof AgentSessionImpl)
333
- await disposeSessionAlongside(this.runtime, entry.session, fallback);
334
+ await disposeQuiet(this.runtime, entry.session, fallback, taskControl(id));
334
335
  return {};
335
336
  }
336
337
  async prompt(params, signal) {
@@ -377,16 +378,17 @@ export class AcpServer {
377
378
  setForegroundSession(this.runtime, session);
378
379
  if (this.runtime.permission.mode !== entry.mode)
379
380
  session.setPermissionMode(entry.mode);
380
- const before = session.getStats().tokens;
381
+ let before = session.getStats().tokens;
381
382
  let failure;
382
383
  try {
383
- await promptWhenIdle(session, job);
384
+ // [#166] 起点取在真正发起时:等待期间结束的通知回合不计入本次 usage
385
+ await promptWhenIdle(session, job, () => void (before = session.getStats().tokens));
384
386
  }
385
387
  catch (error) {
386
388
  failure = error;
387
389
  }
388
- await this.peer.flush();
389
390
  const after = session.getStats().tokens;
391
+ await this.peer.flush();
390
392
  const usage = {
391
393
  inputTokens: after.input - before.input,
392
394
  outputTokens: after.output - before.output,
@@ -8,6 +8,8 @@
8
8
  */
9
9
  import type { ImageBlock } from "../../ai/types.js";
10
10
  import type { AgentSessionImpl } from "../../agent/session.js";
11
+ import type { TaskControl } from "../../agents/task-control.js";
12
+ import type { Runtime } from "../../cli/runtime.js";
11
13
  import type { AgentMessage, SessionListItem } from "../../session/types.js";
12
14
  import { type AcpPromptResult, type AcpListSessionsResult } from "../../drivers/acp/types.js";
13
15
  import type { PermissionMode } from "../../permissions/types.js";
@@ -86,4 +88,15 @@ export declare function pageSessions(items: readonly SessionListItem[], cursor:
86
88
  * 不用 `streamingBehavior: "followUp"` 入队:`abort()` 不清队列,等待期间的 `session/cancel`
87
89
  * 会让这条提示在之后的周期里冒出来。`job.cancelRequested`(cancel / close / 撤回)→ 不再发,直接返回。
88
90
  */
89
- export declare function promptWhenIdle(session: AgentSessionImpl, job: PromptJob): Promise<void>;
91
+ export declare function promptWhenIdle(session: AgentSessionImpl, job: PromptJob, onStart?: () => void): Promise<void>;
92
+ /**
93
+ * 关会话前把它安静下来(Issue #167):先停在跑的后台任务(被停止的任务不发完成通知),再反复 abort,
94
+ * 直到隔一个宏任务仍然空闲——已完成任务排在投递链上的通知会在上一周期结算后的微任务里开新周期,
95
+ * 一次 abort 不够。超过 `rounds` 轮仍不空闲返回 false。被打断的通知回合与用户 cancel 同样留下中断标记。
96
+ */
97
+ export declare function quiesce(session: AgentSessionImpl, control: TaskControl | undefined, rounds?: number): Promise<boolean>;
98
+ /**
99
+ * `disposeSessionAlongside` 的安静版:撞上 busy(SessionEnd Hook 期间又开了通知回合)→ 再 quiesce 重试;
100
+ * dispose 之后若还有周期(与 dispose 同一时刻开出来的)再 abort 一次。
101
+ */
102
+ export declare function disposeQuiet(runtime: Runtime, session: AgentSessionImpl, fallback: AgentSessionImpl, control?: TaskControl, retries?: number): Promise<void>;
@@ -6,6 +6,7 @@
6
6
  * - 同一时刻只跑一个回合,其它会话的 `session/prompt` 进 {@link PromptQueue}(FIFO),不再回 busy;
7
7
  * - `session/list`:按 `updatedAt` 降序、`sessionId` 次序分页,cursor 是 `updatedAt|sessionId` 的 base64。
8
8
  */
9
+ import { disposeSessionAlongside } from "../../cli/compose-session.js";
9
10
  import { RPC_ERRORS, } from "../../drivers/acp/types.js";
10
11
  import { RpcError } from "../../drivers/jsonrpc.js";
11
12
  import { AmaError } from "../../errors.js";
@@ -164,12 +165,14 @@ export function pageSessions(items, cursor, pageSize = LIST_PAGE_SIZE) {
164
165
  * 不用 `streamingBehavior: "followUp"` 入队:`abort()` 不清队列,等待期间的 `session/cancel`
165
166
  * 会让这条提示在之后的周期里冒出来。`job.cancelRequested`(cancel / close / 撤回)→ 不再发,直接返回。
166
167
  */
167
- export async function promptWhenIdle(session, job) {
168
+ export async function promptWhenIdle(session, job, onStart) {
168
169
  for (;;) {
169
170
  // 等周期而不是看 `state.isStreaming`:busy 以周期为准,周期的收尾阶段 isStreaming 已是 false
170
171
  await session.waitForIdle();
171
172
  if (job.cancelRequested)
172
173
  return;
174
+ // [#166] 每次真正发起前回调(busy 重试时再调,最后一次为准):usage 的起点不含之前的通知回合
175
+ onStart?.();
173
176
  try {
174
177
  await session.prompt(job.text, job.images.length > 0 ? { images: job.images } : {});
175
178
  return;
@@ -180,3 +183,43 @@ export async function promptWhenIdle(session, job) {
180
183
  }
181
184
  }
182
185
  }
186
+ const BUSY = Symbol("busy");
187
+ /** 当前没有周期(含收尾阶段):`waitForIdle()` 已结算即空闲。 */
188
+ async function idleNow(session) {
189
+ return (await Promise.race([session.waitForIdle(), Promise.resolve(BUSY)])) !== BUSY;
190
+ }
191
+ /**
192
+ * 关会话前把它安静下来(Issue #167):先停在跑的后台任务(被停止的任务不发完成通知),再反复 abort,
193
+ * 直到隔一个宏任务仍然空闲——已完成任务排在投递链上的通知会在上一周期结算后的微任务里开新周期,
194
+ * 一次 abort 不够。超过 `rounds` 轮仍不空闲返回 false。被打断的通知回合与用户 cancel 同样留下中断标记。
195
+ */
196
+ export async function quiesce(session, control, rounds = 50) {
197
+ for (const task of control?.list() ?? [])
198
+ if (task.status === "running")
199
+ await control?.stop(task.taskId).catch(() => undefined);
200
+ for (let i = 0; i < rounds; i++) {
201
+ await session.abort();
202
+ await new Promise((resolve) => setImmediate(resolve));
203
+ if (await idleNow(session))
204
+ return true;
205
+ }
206
+ return false;
207
+ }
208
+ /**
209
+ * `disposeSessionAlongside` 的安静版:撞上 busy(SessionEnd Hook 期间又开了通知回合)→ 再 quiesce 重试;
210
+ * dispose 之后若还有周期(与 dispose 同一时刻开出来的)再 abort 一次。
211
+ */
212
+ export async function disposeQuiet(runtime, session, fallback, control, retries = 3) {
213
+ for (let i = 0;; i++) {
214
+ try {
215
+ await disposeSessionAlongside(runtime, session, fallback);
216
+ break;
217
+ }
218
+ catch (error) {
219
+ if (i >= retries || !(error instanceof AmaError && error.code === "busy"))
220
+ throw error;
221
+ await quiesce(session, control);
222
+ }
223
+ }
224
+ await session.abort();
225
+ }
@@ -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 readonly 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,12 @@ 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
+ /** 已卸载图片的条目数(测试与诊断用)。 */
69
+ offloadedCount(): number;
54
70
  private leafLine;
55
71
  branch(leafId?: string | null): SessionEntry[];
56
72
  getEntries(since?: string): {
@@ -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
- constructor(header, entries, storage, now, leafId) {
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 { lines } = readSessionLines(file, { repair: true });
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
- return new SessionManager(header, entries, { kind: "file", file, lock }, options.now ?? (() => new Date()), leafId);
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,25 @@ 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
- appendLines(this.storage.file, [this.leafLine()]);
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
+ /** 已卸载图片的条目数(测试与诊断用)。 */
186
+ offloadedCount() {
187
+ return this.images.size;
142
188
  }
143
189
  leafLine() {
144
190
  return { type: "leaf", id: this.leaf, timestamp: this.now().toISOString() };
@@ -147,11 +193,9 @@ export class SessionManager {
147
193
  return pathToRoot(this.index, leafId === undefined ? this.leaf : leafId);
148
194
  }
149
195
  getEntries(since) {
150
- if (since === undefined)
151
- return { entries: [...this._entries], leafId: this.leaf };
152
- const at = this._entries.findIndex((entry) => entry.id === since);
196
+ const at = since === undefined ? -1 : this._entries.findIndex((entry) => entry.id === since);
153
197
  return {
154
- entries: at < 0 ? [...this._entries] : this._entries.slice(at + 1),
198
+ entries: this._entries.slice(at + 1).map((entry) => this.original(entry)),
155
199
  leafId: this.leaf,
156
200
  };
157
201
  }
@@ -172,7 +216,10 @@ export class SessionManager {
172
216
  if (!this.index.has(entryId)) {
173
217
  throw new AmaError("invalid_arguments", `no such session entry: ${entryId}`);
174
218
  }
175
- const copied = this.branch(entryId).map((entry) => structuredClone(entry));
219
+ const copied = this.branch(entryId).map((entry) => {
220
+ const original = this.original(entry);
221
+ return original === entry ? structuredClone(entry) : original;
222
+ });
176
223
  if (forkOptions.head !== undefined) {
177
224
  const head = {
178
225
  ...forkOptions.head,
@@ -196,7 +243,7 @@ export class SessionManager {
196
243
  storage = { kind: "lazy", dir: this.storage.dir };
197
244
  else
198
245
  storage = { kind: "lazy", dir: dirname(this.storage.file) };
199
- const forked = new SessionManager(header, copied, storage, this.now);
246
+ const forked = new SessionManager(header, copied, storage, this.now, undefined, this.warn);
200
247
  if (parentFile !== undefined)
201
248
  forked.flush();
202
249
  return forked;
@@ -214,11 +261,19 @@ export class SessionManager {
214
261
  return this.storage.file;
215
262
  const file = join(this.storage.dir, sessionFileName(new Date(this._header.timestamp), this.id));
216
263
  const moved = this.leaf !== (this._entries.at(-1)?.id ?? null);
217
- writeNewSessionFile(file, [
264
+ const lengths = writeNewSessionFile(file, [
218
265
  this._header,
219
266
  ...this._entries,
220
267
  ...(moved ? [this.leafLine()] : []),
221
268
  ]);
269
+ let offset = 0;
270
+ lengths.forEach((length, i) => {
271
+ const entry = i === 0 ? undefined : this._entries[i - 1];
272
+ if (entry !== undefined)
273
+ this.images.track(entry, { offset, length });
274
+ offset += length + 1;
275
+ });
276
+ this.fileBytes = offset;
222
277
  let lock;
223
278
  try {
224
279
  lock = acquireLock(file);
@@ -227,6 +282,7 @@ export class SessionManager {
227
282
  lock = undefined;
228
283
  }
229
284
  this.storage = { kind: "file", file, lock };
285
+ this.syncImages();
230
286
  return file;
231
287
  }
232
288
  isPersisted() {
@@ -0,0 +1,51 @@
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
+ * 保留 `""` 并告警,不再重试;
14
+ * - `original(entry)`:给 `getEntries` / `fork` 的原文副本,不改内存状态。
15
+ */
16
+ import type { SessionEntry } from "./types.js";
17
+ /** 一行在会话文件里的位置:行首偏移与字节数(不含行尾)。 */
18
+ export interface LineLocator {
19
+ offset: number;
20
+ length: number;
21
+ }
22
+ /** `message` 条目的内容里有图片块。 */
23
+ export declare function hasImages(entry: SessionEntry): boolean;
24
+ /** 读文件里 `locator` 处的一行并解析。 */
25
+ export declare function readLineAt(file: string, locator: LineLocator): unknown;
26
+ /** 文件里全部 `context_edit` 的目标 id(只解析 context_edit 行;读不了时为空集)。 */
27
+ export declare function editTargets(file: string): Set<string>;
28
+ export declare class ImageOffload {
29
+ private readonly warn;
30
+ /** 含图 message 条目的行位置(只有已落盘的条目才有)。 */
31
+ private readonly locators;
32
+ private readonly offloaded;
33
+ constructor(warn?: (message: string) => void);
34
+ /** 记下条目的行位置;不含图片的条目忽略。 */
35
+ track(entry: SessionEntry, locator: LineLocator): void;
36
+ /** 读文件时逐行调用:记下行位置;`strip` 时就地换成占位(刚解析的对象,别处没有引用)。 */
37
+ load(line: SessionEntry, locator: LineLocator, strip: boolean): void;
38
+ isOffloaded(id: string): boolean;
39
+ /** 已卸载的条目数(测试与诊断用)。 */
40
+ get size(): number;
41
+ /** 把条目里的图片换成占位;没有行位置(未落盘、不含图)或已卸载时返回 false。 */
42
+ offload(entry: SessionEntry): boolean;
43
+ /** 就地放回图片;读不回时返回 false(占位保留,之后不再卸载 / 回读这一条)。 */
44
+ hydrate(file: string, entry: SessionEntry): boolean;
45
+ /** 原文:已卸载的条目从文件读一份新副本(图片驻留),否则原样返回。读不回时返回占位版本。 */
46
+ original(file: string, entry: SessionEntry): SessionEntry;
47
+ /** 按活动分支对齐:分支上被生效编辑改写的含图条目卸载,其余已卸载的回读。 */
48
+ sync(file: string, branch: readonly SessionEntry[], index: ReadonlyMap<string, SessionEntry>): void;
49
+ /** 从文件读回条目(新对象);不成功时告警、忘掉行位置并返回 undefined。 */
50
+ private read;
51
+ }