@armadra/agent 0.7.3 → 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.
Files changed (179) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/CHANGELOG.zh-CN.md +48 -0
  3. package/dist/agent/retry.d.ts +14 -5
  4. package/dist/agent/retry.js +30 -8
  5. package/dist/agent/session-cache-key.d.ts +8 -0
  6. package/dist/agent/session-cache-key.js +46 -0
  7. package/dist/agent/session-cache.d.ts +2 -3
  8. package/dist/agent/session-cache.js +19 -37
  9. package/dist/agent/session-classifier.d.ts +3 -2
  10. package/dist/agent/session-classifier.js +36 -10
  11. package/dist/agent/session-compaction.d.ts +24 -5
  12. package/dist/agent/session-compaction.js +54 -20
  13. package/dist/agent/session-core.d.ts +2 -0
  14. package/dist/agent/session-run.d.ts +12 -0
  15. package/dist/agent/session-run.js +29 -10
  16. package/dist/agent/session-subagent.d.ts +8 -1
  17. package/dist/agent/session-subagent.js +37 -14
  18. package/dist/agent/session-sync.d.ts +8 -2
  19. package/dist/agent/session-sync.js +7 -10
  20. package/dist/agent/session-tools.js +3 -2
  21. package/dist/agent/subagent-fork.d.ts +67 -0
  22. package/dist/agent/subagent-fork.js +87 -0
  23. package/dist/agent/subagent-registry.d.ts +2 -2
  24. package/dist/agent/subagent-registry.js +2 -2
  25. package/dist/agent/tool-availability.d.ts +8 -0
  26. package/dist/agent/tool-availability.js +8 -0
  27. package/dist/agent/tool-runner.js +13 -4
  28. package/dist/agent/worktree.d.ts +8 -0
  29. package/dist/agent/worktree.js +51 -4
  30. package/dist/agents/parse.js +6 -1
  31. package/dist/agents/task-record.js +3 -0
  32. package/dist/agents/types.d.ts +2 -0
  33. package/dist/ai/apis/anthropic-messages.js +10 -5
  34. package/dist/ai/apis/anthropic-request.d.ts +2 -1
  35. package/dist/ai/apis/anthropic-request.js +15 -4
  36. package/dist/ai/apis/cache-params.js +2 -1
  37. package/dist/ai/apis/google-generative-ai.js +4 -2
  38. package/dist/ai/apis/google-request.js +2 -1
  39. package/dist/ai/apis/max-tokens.d.ts +42 -0
  40. package/dist/ai/apis/max-tokens.js +121 -0
  41. package/dist/ai/apis/openai-completions.js +10 -5
  42. package/dist/ai/apis/openai-request.js +4 -2
  43. package/dist/ai/apis/openai-responses-request.js +3 -2
  44. package/dist/ai/apis/openai-responses.js +9 -5
  45. package/dist/ai/apis/shared.d.ts +3 -1
  46. package/dist/ai/apis/shared.js +21 -3
  47. package/dist/ai/cache/fingerprint.d.ts +4 -1
  48. package/dist/ai/cache/fingerprint.js +18 -1
  49. package/dist/ai/cache/miss.d.ts +2 -1
  50. package/dist/ai/cache/miss.js +9 -3
  51. package/dist/ai/cache/reporting.js +5 -1
  52. package/dist/ai/cache/types.d.ts +10 -2
  53. package/dist/ai/cache/warmer.d.ts +6 -1
  54. package/dist/ai/cache/warmer.js +7 -0
  55. package/dist/ai/context.d.ts +12 -4
  56. package/dist/ai/context.js +43 -24
  57. package/dist/ai/fake/fake-provider.d.ts +8 -1
  58. package/dist/ai/fake/fake-provider.js +19 -7
  59. package/dist/ai/fake/fake-script.d.ts +2 -0
  60. package/dist/ai/http.d.ts +17 -9
  61. package/dist/ai/http.js +33 -13
  62. package/dist/ai/image-intern.d.ts +29 -0
  63. package/dist/ai/image-intern.js +58 -0
  64. package/dist/ai/json-body.d.ts +63 -0
  65. package/dist/ai/json-body.js +286 -0
  66. package/dist/ai/overflow.js +5 -0
  67. package/dist/ai/providers/catalog-data.js +13 -13
  68. package/dist/ai/providers/catalog.d.ts +33 -0
  69. package/dist/ai/providers/catalog.js +105 -6
  70. package/dist/ai/providers/enrich.d.ts +13 -3
  71. package/dist/ai/providers/enrich.js +87 -31
  72. package/dist/ai/sse.d.ts +3 -2
  73. package/dist/ai/sse.js +3 -2
  74. package/dist/ai/types.d.ts +9 -2
  75. package/dist/bundle/ama.cjs +3637 -1973
  76. package/dist/cli/bootstrap.js +13 -5
  77. package/dist/cli/compose-agents.js +3 -0
  78. package/dist/cli/compose-extensions.js +2 -0
  79. package/dist/cli/compose-request.d.ts +20 -0
  80. package/dist/cli/compose-request.js +37 -0
  81. package/dist/cli/compose-session.js +2 -2
  82. package/dist/cli/deps.d.ts +1 -1
  83. package/dist/cli/subcommands/model-meta.d.ts +2 -1
  84. package/dist/cli/subcommands/model-meta.js +3 -2
  85. package/dist/codemode/host-side.d.ts +11 -4
  86. package/dist/codemode/host-side.js +25 -6
  87. package/dist/codemode/tool.d.ts +2 -0
  88. package/dist/codemode/tool.js +4 -0
  89. package/dist/config/json-schema.js +9 -2
  90. package/dist/config/key-docs.js +13 -4
  91. package/dist/config/schema-w5.js +9 -1
  92. package/dist/config/schema.d.ts +1 -1
  93. package/dist/config/schema.js +19 -3
  94. package/dist/config/settings-registry.js +6 -0
  95. package/dist/config/types-w5.d.ts +2 -0
  96. package/dist/config/types.d.ts +19 -1
  97. package/dist/config/types.js +4 -0
  98. package/dist/i18n/catalog.d.ts +13 -2
  99. package/dist/i18n/messages/cli.d.ts +2 -0
  100. package/dist/i18n/messages/cli.js +2 -0
  101. package/dist/i18n/messages/config-keys.d.ts +10 -0
  102. package/dist/i18n/messages/config-keys.js +12 -2
  103. package/dist/i18n/messages/config.d.ts +14 -0
  104. package/dist/i18n/messages/config.js +4 -0
  105. package/dist/i18n/messages/errors.d.ts +3 -2
  106. package/dist/i18n/messages/errors.js +15 -2
  107. package/dist/i18n/messages/report.d.ts +2 -0
  108. package/dist/i18n/messages/report.js +2 -0
  109. package/dist/i18n/messages/settings.d.ts +4 -0
  110. package/dist/i18n/messages/settings.js +6 -2
  111. package/dist/i18n/messages/subcommands.d.ts +2 -2
  112. package/dist/i18n/messages/subcommands.js +4 -2
  113. package/dist/modes/acp/acp-server.js +30 -18
  114. package/dist/modes/acp/acp-sessions.d.ts +22 -0
  115. package/dist/modes/acp/acp-sessions.js +68 -0
  116. package/dist/modes/context-report.js +3 -2
  117. package/dist/modes/print/json-event.d.ts +12 -1
  118. package/dist/modes/print/json-event.js +48 -1
  119. package/dist/modes/rpc/rpc-mode.d.ts +1 -0
  120. package/dist/modes/rpc/rpc-mode.js +9 -2
  121. package/dist/modes/session-report.d.ts +1 -1
  122. package/dist/modes/session-report.js +12 -6
  123. package/dist/rpc.d.ts +5 -2
  124. package/dist/session/line-reader.d.ts +28 -0
  125. package/dist/session/line-reader.js +82 -0
  126. package/dist/session/list.d.ts +14 -0
  127. package/dist/session/list.js +150 -0
  128. package/dist/session/manager.d.ts +23 -3
  129. package/dist/session/manager.js +91 -62
  130. package/dist/session/migrate.d.ts +2 -1
  131. package/dist/session/migrate.js +2 -1
  132. package/dist/session/offload.d.ts +51 -0
  133. package/dist/session/offload.js +195 -0
  134. package/dist/session/projection.d.ts +16 -3
  135. package/dist/session/projection.js +96 -29
  136. package/dist/session/scan.d.ts +3 -3
  137. package/dist/session/scan.js +9 -18
  138. package/dist/session/store.d.ts +15 -4
  139. package/dist/session/store.js +54 -38
  140. package/dist/session/types.d.ts +7 -2
  141. package/dist/tools/bash.d.ts +1 -1
  142. package/dist/tools/bash.js +5 -4
  143. package/dist/tools/glob.d.ts +2 -2
  144. package/dist/tools/glob.js +2 -2
  145. package/dist/tools/grep.js +4 -6
  146. package/dist/tools/image-file.js +2 -1
  147. package/dist/tools/read-lines.d.ts +71 -0
  148. package/dist/tools/read-lines.js +224 -0
  149. package/dist/tools/read.d.ts +6 -2
  150. package/dist/tools/read.js +47 -27
  151. package/dist/tools/task.d.ts +9 -0
  152. package/dist/tools/task.js +21 -8
  153. package/dist/tools/truncate.d.ts +7 -0
  154. package/dist/tools/truncate.js +11 -0
  155. package/dist/tools/types.d.ts +13 -0
  156. package/dist/trace/build-index.js +13 -1
  157. package/dist/tui/ansi.d.ts +2 -0
  158. package/dist/tui/ansi.js +7 -3
  159. package/dist/tui/components/editor-buffer.js +2 -2
  160. package/dist/tui/components/editor.js +5 -6
  161. package/dist/tui/keys.js +2 -2
  162. package/dist/tui/stdin-buffer.js +3 -3
  163. package/docs/acp.md +8 -1
  164. package/docs/agents.md +42 -6
  165. package/docs/codemode.md +4 -2
  166. package/docs/en/acp.md +3 -2
  167. package/docs/en/host-api.md +1 -1
  168. package/docs/en/permissions.md +2 -2
  169. package/docs/en/providers.md +54 -20
  170. package/docs/en/rpc.md +23 -7
  171. package/docs/en/sessions.md +4 -2
  172. package/docs/en/tui.md +2 -2
  173. package/docs/permissions.md +4 -1
  174. package/docs/providers.md +85 -22
  175. package/docs/rpc.md +22 -6
  176. package/docs/session-format.md +13 -2
  177. package/docs/sessions.md +3 -1
  178. package/docs/tui.md +1 -1
  179. package/package.json +2 -2
@@ -0,0 +1,150 @@
1
+ /**
2
+ * 会话列表(`SessionManager.list` 的实现)。[M-C] 按块逐行读(docs/memory-plan.md D6、§2.4):
3
+ * 只解析头、第一条条目、`leaf` / `session_info` 行、要取 `firstPrompt` 的那一条 user 消息与末行;
4
+ * 其余 ama 写的行只看行首的 type / role(`lineTypeOf`),不生成全文字符串、不留条目对象。
5
+ *
6
+ * 口径与「`migrateSessionLines(readSessionLines(file).lines)` 再统计」一致:空行跳过、末尾半行忽略、
7
+ * 头必须是 `type: "session"` / `version === SESSION_FORMAT_VERSION` / id 与 cwd 为字符串、`leaf` 行不算
8
+ * 条目、重复的头与解析失败的中间行让整个文件跳过。差别只有一处:不解析的 ama 行不再逐条检查
9
+ * `id`(ama 写入的条目总有 id);行尾不是 `}` 的(写到一半又被追加的坏行)仍会退回完整解析。
10
+ * 不经 `migrateSessionLines`——格式升到 v2 时要同时改这里(R6,见 migrate.ts)。
11
+ */
12
+ import { statSync } from "node:fs";
13
+ import { forEachLineSync, lineTypeOf } from "./line-reader.js";
14
+ import { isBlankLine, isSubagentSession, listSessionFiles } from "./store.js";
15
+ import { SESSION_FORMAT_VERSION } from "./types.js";
16
+ class SkipFile extends Error {
17
+ }
18
+ function isObject(value) {
19
+ return typeof value === "object" && value !== null && !Array.isArray(value);
20
+ }
21
+ function endsWithBrace(line) {
22
+ for (let i = line.length - 1; i >= 0; i--) {
23
+ const byte = line[i];
24
+ if (byte === 0x20 || (byte >= 0x09 && byte <= 0x0d))
25
+ continue;
26
+ return byte === 0x7d;
27
+ }
28
+ return false;
29
+ }
30
+ function promptText(content) {
31
+ if (typeof content === "string")
32
+ return content;
33
+ return content
34
+ .map((block) => (block.type === "text" ? block.text : ""))
35
+ .join("");
36
+ }
37
+ /** 统计一条已解析的行(头之后);不合 v1 规则时抛 SkipFile。 */
38
+ function tallyParsed(tally, line) {
39
+ if (isObject(line) && line["type"] === "leaf") {
40
+ const id = line["id"];
41
+ if (id !== null && typeof id !== "string")
42
+ throw new SkipFile();
43
+ return;
44
+ }
45
+ if (!isObject(line) || typeof line["id"] !== "string" || typeof line["type"] !== "string") {
46
+ throw new SkipFile();
47
+ }
48
+ if (line["type"] === "session")
49
+ throw new SkipFile();
50
+ if (!tally.hasFirst) {
51
+ tally.hasFirst = true;
52
+ tally.first = line;
53
+ }
54
+ if (line["type"] === "session_info" && line["name"] !== undefined) {
55
+ tally.name = line["name"];
56
+ }
57
+ if (line["type"] !== "message")
58
+ return;
59
+ const message = line["message"];
60
+ if (message.role === "system")
61
+ return;
62
+ tally.messageCount++;
63
+ if (tally.firstPrompt === undefined && message.role === "user") {
64
+ tally.firstPrompt = promptText(message.content);
65
+ }
66
+ }
67
+ function parseHeader(line) {
68
+ if (!isObject(line) || line["type"] !== "session")
69
+ throw new SkipFile();
70
+ if (line["version"] !== SESSION_FORMAT_VERSION)
71
+ throw new SkipFile();
72
+ if (typeof line["id"] !== "string" || typeof line["cwd"] !== "string")
73
+ throw new SkipFile();
74
+ return line;
75
+ }
76
+ /** 不解析就能统计的 ama 行:已过第一条条目、类型确定、行尾完整,且不需要读出内容。 */
77
+ function tallyByType(tally, line, firstPromptTaken) {
78
+ if (!tally.hasFirst || !endsWithBrace(line))
79
+ return false;
80
+ const kind = lineTypeOf(line);
81
+ if (kind === undefined)
82
+ return false;
83
+ if (kind.type === "leaf" || kind.type === "session_info" || kind.type === "session")
84
+ return false;
85
+ if (kind.type !== "message")
86
+ return true;
87
+ if (kind.role === undefined)
88
+ return false;
89
+ if (kind.role === "system")
90
+ return true;
91
+ if (kind.role === "user" && !firstPromptTaken)
92
+ return false;
93
+ tally.messageCount++;
94
+ return true;
95
+ }
96
+ function tallyFile(file) {
97
+ const tally = { hasFirst: false, messageCount: 0 };
98
+ forEachLineSync(file, (buf, _index, last) => {
99
+ if (isBlankLine(buf))
100
+ return;
101
+ if (tally.header !== undefined && !last) {
102
+ if (tallyByType(tally, buf, tally.firstPrompt !== undefined))
103
+ return;
104
+ }
105
+ let parsed;
106
+ try {
107
+ parsed = JSON.parse(buf.toString("utf8"));
108
+ }
109
+ catch {
110
+ if (last)
111
+ return false; // 末尾半行:与 readSessionLines 一样忽略
112
+ throw new SkipFile();
113
+ }
114
+ if (tally.header === undefined)
115
+ tally.header = parseHeader(parsed);
116
+ else
117
+ tallyParsed(tally, parsed);
118
+ });
119
+ return tally;
120
+ }
121
+ /** 只读列出目录下的会话(最新在前);损坏的文件跳过。 */
122
+ export function listSessionItems(dir) {
123
+ const items = [];
124
+ for (const file of listSessionFiles(dir)) {
125
+ try {
126
+ const { header, first, name, firstPrompt, messageCount } = tallyFile(file);
127
+ if (header === undefined)
128
+ continue;
129
+ const item = {
130
+ id: header.id,
131
+ file,
132
+ cwd: header.cwd,
133
+ createdAt: header.timestamp,
134
+ modifiedAt: statSync(file).mtime.toISOString(),
135
+ messageCount,
136
+ };
137
+ if (name !== undefined)
138
+ item.name = name;
139
+ if (isSubagentSession(header, first))
140
+ item.subagent = true;
141
+ if (firstPrompt !== undefined)
142
+ item.firstPrompt = firstPrompt.slice(0, 200);
143
+ items.push(item);
144
+ }
145
+ catch {
146
+ // 损坏 / 不可读:列表里跳过(sessions show 会给出具体错误)
147
+ }
148
+ }
149
+ return items;
150
+ }
@@ -7,9 +7,13 @@
7
7
  * - `open(file)`:读文件(修复末尾半行)、校验版本、加锁;叶子 = 文件中最后一条条目,
8
8
  * 若其后还有 `leaf` 行(`/tree` 换叶子落盘,第三波 A7)则取最后一条 leaf 行。
9
9
  * - `setLeaf(id)`:已落盘时追加一行 `leaf{id, timestamp}`;延迟会话在 `flush()` 时补写。
10
- * - `fork(entryId)`:复制 root → entryId 的分支到新文件(头的 parentSession 指回本文件),
11
- * 不复制 leaf 行。
10
+ * - `fork(entryId, { head })`:复制 root → entryId 的分支到新文件(头的 parentSession 指回本文件),
11
+ * 不复制 leaf 行;给了 `head` 时它成为新根条目,复制的首条重挂到它下面(fork 子会话的 ama.task);
12
+ * `cwd` 缺省同本会话([ME-A] 隔离的 fork 子会话传 worktree)。
12
13
  * - `close()`:释放锁;之后的 append 抛错。
14
+ * - 图片卸载(#170,offload.ts):已落盘会话里,活动分支上被 `context_edit` 改写的含图消息只留占位
15
+ * (`data: ""`),`entries()` / `branch()` / `getEntry()` 返回卸载后的对象;`getEntries()` 与
16
+ * `fork()` 从文件回读原文;`setLeaf()` 换分支后回读不再被改写的条目。内存会话不卸载。
13
17
  */
14
18
  import type { SessionEntry, SessionEntryInput, SessionHeader, SessionListItem, SessionManagerApi, SessionTreeNode } from "./types.js";
15
19
  export interface SessionManagerOptions {
@@ -19,6 +23,8 @@ export interface SessionManagerOptions {
19
23
  parentSession?: string;
20
24
  /** 指定会话 id(`--session-id`)。 */
21
25
  id?: string;
26
+ /** 图片回读失败等告警(缺省丢弃)。 */
27
+ warn?: (message: string) => void;
22
28
  }
23
29
  export declare class SessionManager implements SessionManagerApi {
24
30
  readonly id: string;
@@ -30,6 +36,10 @@ export declare class SessionManager implements SessionManagerApi {
30
36
  private storage;
31
37
  private closed;
32
38
  private readonly now;
39
+ private readonly images;
40
+ private readonly warn;
41
+ /** 已知的会话文件字节数(新行的行首偏移)。 */
42
+ private fileBytes;
33
43
  private constructor();
34
44
  private static newHeader;
35
45
  static inMemory(cwd: string, options?: SessionManagerOptions): SessionManager;
@@ -38,6 +48,7 @@ export declare class SessionManager implements SessionManagerApi {
38
48
  static createForCwd(sessionsRoot: string, cwd: string, options?: SessionManagerOptions): SessionManager;
39
49
  static open(file: string, options?: {
40
50
  now?: () => Date;
51
+ warn?: (message: string) => void;
41
52
  }): SessionManager;
42
53
  /** `--continue`:本目录最近一条(跳过子 Agent 会话,只读文件头两行);没有则新建(延迟落盘)。 */
43
54
  static continueRecent(dir: string, cwd: string): SessionManager;
@@ -50,6 +61,12 @@ export declare class SessionManager implements SessionManagerApi {
50
61
  leafId(): string | null;
51
62
  append(input: SessionEntryInput): SessionEntry;
52
63
  setLeaf(id: string | null): void;
64
+ /** 按当前活动分支卸载 / 回读图片(只对已落盘的会话)。 */
65
+ private syncImages;
66
+ /** 原文条目(已卸载的从文件回读一份副本)。 */
67
+ private original;
68
+ /** 已卸载图片的条目数(测试与诊断用)。 */
69
+ offloadedCount(): number;
53
70
  private leafLine;
54
71
  branch(leafId?: string | null): SessionEntry[];
55
72
  getEntries(since?: string): {
@@ -59,7 +76,10 @@ export declare class SessionManager implements SessionManagerApi {
59
76
  getTree(): SessionTreeNode[];
60
77
  name(): string | undefined;
61
78
  setName(name: string): void;
62
- fork(entryId: string): SessionManager;
79
+ fork(entryId: string, forkOptions?: {
80
+ head?: SessionEntryInput;
81
+ cwd?: string;
82
+ }): SessionManager;
63
83
  /** 延迟会话:建文件、写头与缓存条目、加锁。返回文件路径(内存会话为 undefined)。幂等。 */
64
84
  flush(): string | undefined;
65
85
  isPersisted(): boolean;
@@ -7,17 +7,23 @@
7
7
  * - `open(file)`:读文件(修复末尾半行)、校验版本、加锁;叶子 = 文件中最后一条条目,
8
8
  * 若其后还有 `leaf` 行(`/tree` 换叶子落盘,第三波 A7)则取最后一条 leaf 行。
9
9
  * - `setLeaf(id)`:已落盘时追加一行 `leaf{id, timestamp}`;延迟会话在 `flush()` 时补写。
10
- * - `fork(entryId)`:复制 root → entryId 的分支到新文件(头的 parentSession 指回本文件),
11
- * 不复制 leaf 行。
10
+ * - `fork(entryId, { head })`:复制 root → entryId 的分支到新文件(头的 parentSession 指回本文件),
11
+ * 不复制 leaf 行;给了 `head` 时它成为新根条目,复制的首条重挂到它下面(fork 子会话的 ama.task);
12
+ * `cwd` 缺省同本会话([ME-A] 隔离的 fork 子会话传 worktree)。
12
13
  * - `close()`:释放锁;之后的 append 抛错。
14
+ * - 图片卸载(#170,offload.ts):已落盘会话里,活动分支上被 `context_edit` 改写的含图消息只留占位
15
+ * (`data: ""`),`entries()` / `branch()` / `getEntry()` 返回卸载后的对象;`getEntries()` 与
16
+ * `fork()` 从文件回读原文;`setLeaf()` 换分支后回读不再被改写的条目。内存会话不卸载。
13
17
  */
14
18
  import { randomBytes, randomUUID } from "node:crypto";
15
- import { statSync } from "node:fs";
16
19
  import { dirname, join } from "node:path";
20
+ import { internSessionImages } from "../ai/image-intern.js";
17
21
  import { AmaError } from "../errors.js";
18
22
  import { AMA_VERSION } from "../version.js";
23
+ import { listSessionItems } from "./list.js";
19
24
  import { migrateSessionLines } from "./migrate.js";
20
- import { acquireLock, appendLines, isSubagentSession, isSubagentSessionFile, listSessionFiles, readSessionLines, sessionDirForCwd, sessionFileName, writeNewSessionFile, } from "./store.js";
25
+ import { ImageOffload, editTargets } from "./offload.js";
26
+ import { acquireLock, appendLines, isSubagentSessionFile, listSessionFiles, readSessionLines, sessionDirForCwd, sessionFileName, writeNewSessionFile, } from "./store.js";
21
27
  import { buildTree, indexEntries, pathToRoot } from "./tree.js";
22
28
  import { SESSION_FORMAT_VERSION } from "./types.js";
23
29
  function newEntryId(taken) {
@@ -37,7 +43,11 @@ export class SessionManager {
37
43
  storage;
38
44
  closed = false;
39
45
  now;
40
- constructor(header, entries, storage, now, leafId) {
46
+ images;
47
+ warn;
48
+ /** 已知的会话文件字节数(新行的行首偏移)。 */
49
+ fileBytes = 0;
50
+ constructor(header, entries, storage, now, leafId, warn, images) {
41
51
  this._header = header;
42
52
  this.id = header.id;
43
53
  this.cwd = header.cwd;
@@ -48,6 +58,8 @@ export class SessionManager {
48
58
  leafId === undefined || (leafId !== null && !this.index.has(leafId)) ? last : leafId;
49
59
  this.storage = storage;
50
60
  this.now = now;
61
+ this.warn = warn;
62
+ this.images = images ?? new ImageOffload(warn);
51
63
  }
52
64
  static newHeader(cwd, options, now) {
53
65
  const header = {
@@ -69,7 +81,7 @@ export class SessionManager {
69
81
  /** `dir` 是会话文件所在目录(通常是 `sessionDirForCwd(sessionsRoot, cwd)`)。 */
70
82
  static create(dir, cwd, options = {}) {
71
83
  const now = options.now ?? (() => new Date());
72
- 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);
73
85
  }
74
86
  static createForCwd(sessionsRoot, cwd, options = {}) {
75
87
  return this.create(sessionDirForCwd(sessionsRoot, cwd), cwd, options);
@@ -77,9 +89,21 @@ export class SessionManager {
77
89
  static open(file, options = {}) {
78
90
  const lock = acquireLock(file);
79
91
  try {
80
- 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
+ });
81
101
  const { header, entries, leafId } = migrateSessionLines(lines, file);
82
- return new SessionManager(header, entries, { kind: "file", file, lock }, options.now ?? (() => new Date()), leafId);
102
+ internSessionImages(entries); // D5:同一图片的 base64 只留一份;JSONL 不变
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;
83
107
  }
84
108
  catch (error) {
85
109
  lock.release();
@@ -93,48 +117,7 @@ export class SessionManager {
93
117
  }
94
118
  /** 只读列出目录下的会话(最新在前);损坏的文件跳过。 */
95
119
  static list(dir) {
96
- const items = [];
97
- for (const file of listSessionFiles(dir)) {
98
- try {
99
- const { header, entries } = migrateSessionLines(readSessionLines(file).lines, file);
100
- let name;
101
- let firstPrompt;
102
- let messageCount = 0;
103
- for (const entry of entries) {
104
- if (entry.type === "session_info" && entry.name !== undefined)
105
- name = entry.name;
106
- if (entry.type !== "message" || entry.message.role === "system")
107
- continue;
108
- messageCount++;
109
- if (firstPrompt === undefined && entry.message.role === "user") {
110
- const { content } = entry.message;
111
- firstPrompt =
112
- typeof content === "string"
113
- ? content
114
- : content.map((block) => (block.type === "text" ? block.text : "")).join("");
115
- }
116
- }
117
- const item = {
118
- id: header.id,
119
- file,
120
- cwd: header.cwd,
121
- createdAt: header.timestamp,
122
- modifiedAt: statSync(file).mtime.toISOString(),
123
- messageCount,
124
- };
125
- if (name !== undefined)
126
- item.name = name;
127
- if (isSubagentSession(header, entries[0]))
128
- item.subagent = true;
129
- if (firstPrompt !== undefined)
130
- item.firstPrompt = firstPrompt.slice(0, 200);
131
- items.push(item);
132
- }
133
- catch {
134
- // 损坏 / 不可读:列表里跳过(sessions show 会给出具体错误)
135
- }
136
- }
137
- return items;
120
+ return listSessionItems(dir);
138
121
  }
139
122
  // -------------------------------------------------------------------------
140
123
  // SessionManagerApi
@@ -163,11 +146,19 @@ export class SessionManager {
163
146
  parentId: this.leaf,
164
147
  timestamp: this.now().toISOString(),
165
148
  };
166
- if (this.storage.kind === "file")
167
- 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
+ }
168
154
  this._entries.push(entry);
169
155
  this.index.set(entry.id, entry);
170
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
+ }
171
162
  return entry;
172
163
  }
173
164
  setLeaf(id) {
@@ -175,8 +166,25 @@ export class SessionManager {
175
166
  throw new AmaError("invalid_arguments", `no such session entry: ${id}`);
176
167
  }
177
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() {
178
176
  if (this.storage.kind === "file")
179
- 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;
180
188
  }
181
189
  leafLine() {
182
190
  return { type: "leaf", id: this.leaf, timestamp: this.now().toISOString() };
@@ -185,11 +193,9 @@ export class SessionManager {
185
193
  return pathToRoot(this.index, leafId === undefined ? this.leaf : leafId);
186
194
  }
187
195
  getEntries(since) {
188
- if (since === undefined)
189
- return { entries: [...this._entries], leafId: this.leaf };
190
- const at = this._entries.findIndex((entry) => entry.id === since);
196
+ const at = since === undefined ? -1 : this._entries.findIndex((entry) => entry.id === since);
191
197
  return {
192
- entries: at < 0 ? [...this._entries] : this._entries.slice(at + 1),
198
+ entries: this._entries.slice(at + 1).map((entry) => this.original(entry)),
193
199
  leafId: this.leaf,
194
200
  };
195
201
  }
@@ -206,16 +212,30 @@ export class SessionManager {
206
212
  setName(name) {
207
213
  this.append({ type: "session_info", name });
208
214
  }
209
- fork(entryId) {
215
+ fork(entryId, forkOptions = {}) {
210
216
  if (!this.index.has(entryId)) {
211
217
  throw new AmaError("invalid_arguments", `no such session entry: ${entryId}`);
212
218
  }
213
- 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
+ });
223
+ if (forkOptions.head !== undefined) {
224
+ const head = {
225
+ ...forkOptions.head,
226
+ id: newEntryId(indexEntries(copied)),
227
+ parentId: null,
228
+ timestamp: this.now().toISOString(),
229
+ };
230
+ if (copied[0] !== undefined)
231
+ copied[0].parentId = head.id;
232
+ copied.unshift(head);
233
+ }
214
234
  const options = { now: this.now };
215
235
  const parentFile = this.file();
216
236
  if (parentFile !== undefined)
217
237
  options.parentSession = parentFile;
218
- const header = SessionManager.newHeader(this.cwd, options, this.now());
238
+ const header = SessionManager.newHeader(forkOptions.cwd ?? this.cwd, options, this.now());
219
239
  let storage;
220
240
  if (this.storage.kind === "memory")
221
241
  storage = { kind: "memory" };
@@ -223,7 +243,7 @@ export class SessionManager {
223
243
  storage = { kind: "lazy", dir: this.storage.dir };
224
244
  else
225
245
  storage = { kind: "lazy", dir: dirname(this.storage.file) };
226
- const forked = new SessionManager(header, copied, storage, this.now);
246
+ const forked = new SessionManager(header, copied, storage, this.now, undefined, this.warn);
227
247
  if (parentFile !== undefined)
228
248
  forked.flush();
229
249
  return forked;
@@ -241,11 +261,19 @@ export class SessionManager {
241
261
  return this.storage.file;
242
262
  const file = join(this.storage.dir, sessionFileName(new Date(this._header.timestamp), this.id));
243
263
  const moved = this.leaf !== (this._entries.at(-1)?.id ?? null);
244
- writeNewSessionFile(file, [
264
+ const lengths = writeNewSessionFile(file, [
245
265
  this._header,
246
266
  ...this._entries,
247
267
  ...(moved ? [this.leafLine()] : []),
248
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;
249
277
  let lock;
250
278
  try {
251
279
  lock = acquireLock(file);
@@ -254,6 +282,7 @@ export class SessionManager {
254
282
  lock = undefined;
255
283
  }
256
284
  this.storage = { kind: "file", file, lock };
285
+ this.syncImages();
257
286
  return file;
258
287
  }
259
288
  isPersisted() {
@@ -7,7 +7,8 @@
7
7
  * - 缺头 / 头损坏 / 条目缺 id → 拒绝,不猜测、不重写文件。
8
8
  * - [W3-C1b] `leaf` 行(`/tree` 位置,第三波 A7)不是条目:不进 entries;最后一条 leaf 行若在
9
9
  * 最后一条条目之后,作为 `leafId` 返回(之后又追加了条目则作废)。id 须为字符串或 null。
10
- * 将来格式升级时在这里加「旧版本 → 当前」的内存迁移(文件只追加,不回写)。
10
+ * 将来格式升级时在这里加「旧版本 → 当前」的内存迁移(文件只追加,不回写);`list.ts` 不经本函数、
11
+ * 自己按 v1 规则流式统计,升级格式时要同时改它(docs/memory-plan.md R6)。
11
12
  */
12
13
  import type { SessionEntry, SessionHeader, SessionLine } from "./types.js";
13
14
  export interface MigratedSession {
@@ -7,7 +7,8 @@
7
7
  * - 缺头 / 头损坏 / 条目缺 id → 拒绝,不猜测、不重写文件。
8
8
  * - [W3-C1b] `leaf` 行(`/tree` 位置,第三波 A7)不是条目:不进 entries;最后一条 leaf 行若在
9
9
  * 最后一条条目之后,作为 `leafId` 返回(之后又追加了条目则作废)。id 须为字符串或 null。
10
- * 将来格式升级时在这里加「旧版本 → 当前」的内存迁移(文件只追加,不回写)。
10
+ * 将来格式升级时在这里加「旧版本 → 当前」的内存迁移(文件只追加,不回写);`list.ts` 不经本函数、
11
+ * 自己按 v1 规则流式统计,升级格式时要同时改它(docs/memory-plan.md R6)。
11
12
  */
12
13
  import { AmaError } from "../errors.js";
13
14
  import { SESSION_FORMAT_VERSION } from "./types.js";
@@ -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
+ }