@bubblebrain-ai/bubble 0.0.58 → 0.0.60

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 (177) hide show
  1. package/README.md +56 -6
  2. package/dist/agent/child-runner.js +5 -1
  3. package/dist/agent/internal-reminder-sanitizer.d.ts +1 -1
  4. package/dist/agent/internal-reminder-sanitizer.js +7 -1
  5. package/dist/agent/profiles.js +4 -4
  6. package/dist/agent/routing-catalog.d.ts +19 -1
  7. package/dist/agent/routing-catalog.js +32 -13
  8. package/dist/agent/subagent/runtime.d.ts +28 -0
  9. package/dist/agent/subagent/runtime.js +34 -30
  10. package/dist/agent/subagent-control.d.ts +7 -0
  11. package/dist/agent/subagent-control.js +2 -0
  12. package/dist/agent/subagent-lifecycle-reminder.js +2 -1
  13. package/dist/agent/subagent-wait-guidance.d.ts +2 -0
  14. package/dist/agent/subagent-wait-guidance.js +8 -0
  15. package/dist/agent/subtask-policy.js +4 -4
  16. package/dist/agent.d.ts +20 -11
  17. package/dist/agent.js +200 -117
  18. package/dist/anthropic-thinking.d.ts +4 -0
  19. package/dist/anthropic-thinking.js +11 -0
  20. package/dist/approval/controller.d.ts +20 -6
  21. package/dist/approval/controller.js +67 -8
  22. package/dist/approval/protected-paths.d.ts +1 -0
  23. package/dist/approval/protected-paths.js +35 -0
  24. package/dist/approval/session-cache.d.ts +12 -26
  25. package/dist/approval/session-cache.js +20 -50
  26. package/dist/approval/types.d.ts +27 -1
  27. package/dist/cli.d.ts +5 -0
  28. package/dist/cli.js +25 -2
  29. package/dist/config.d.ts +3 -0
  30. package/dist/config.js +7 -0
  31. package/dist/context/checkpoint.d.ts +18 -0
  32. package/dist/context/checkpoint.js +117 -0
  33. package/dist/context/compact-llm.js +7 -0
  34. package/dist/context/compact.d.ts +8 -0
  35. package/dist/context/compact.js +40 -7
  36. package/dist/context/llm-compactor.d.ts +36 -2
  37. package/dist/context/llm-compactor.js +227 -44
  38. package/dist/context/session-write-lock.d.ts +20 -0
  39. package/dist/context/session-write-lock.js +162 -0
  40. package/dist/context/tool-images.d.ts +3 -0
  41. package/dist/context/tool-images.js +35 -0
  42. package/dist/context/usage.js +22 -5
  43. package/dist/feishu/agent-host/run-driver.js +56 -9
  44. package/dist/lsp/service.d.ts +13 -1
  45. package/dist/lsp/service.js +163 -43
  46. package/dist/main.js +82 -16
  47. package/dist/mcp/config.d.ts +4 -0
  48. package/dist/mcp/config.js +17 -0
  49. package/dist/mcp/manager.d.ts +13 -1
  50. package/dist/mcp/manager.js +32 -1
  51. package/dist/mcp/transports.js +11 -0
  52. package/dist/memory/db.d.ts +1 -0
  53. package/dist/memory/db.js +20 -4
  54. package/dist/memory/phase1.js +122 -16
  55. package/dist/memory/prompts.js +3 -0
  56. package/dist/model-catalog.js +19 -3
  57. package/dist/model-config.d.ts +1 -0
  58. package/dist/model-config.js +5 -5
  59. package/dist/model-pricing.js +26 -9
  60. package/dist/network/provider-transport-log.d.ts +12 -0
  61. package/dist/network/provider-transport-log.js +32 -0
  62. package/dist/network/provider-transport.js +6 -1
  63. package/dist/network/retry.d.ts +1 -1
  64. package/dist/network/retry.js +1 -1
  65. package/dist/oauth/grok.d.ts +1 -0
  66. package/dist/oauth/grok.js +20 -16
  67. package/dist/oauth/openai-codex.d.ts +1 -0
  68. package/dist/oauth/openai-codex.js +29 -25
  69. package/dist/oauth/refresh-control.d.ts +12 -0
  70. package/dist/oauth/refresh-control.js +42 -0
  71. package/dist/oauth/storage.d.ts +55 -1
  72. package/dist/oauth/storage.js +281 -16
  73. package/dist/permissions/rule.d.ts +6 -3
  74. package/dist/permissions/rule.js +71 -12
  75. package/dist/permissions/settings.d.ts +28 -1
  76. package/dist/permissions/settings.js +93 -3
  77. package/dist/permissions/shell-command.d.ts +36 -0
  78. package/dist/permissions/shell-command.js +226 -0
  79. package/dist/permissions/trust-prompt.d.ts +10 -0
  80. package/dist/permissions/trust-prompt.js +34 -0
  81. package/dist/permissions/trust.d.ts +26 -0
  82. package/dist/permissions/trust.js +113 -0
  83. package/dist/prompt/compose.d.ts +3 -0
  84. package/dist/prompt/compose.js +24 -7
  85. package/dist/prompt/environment.js +2 -2
  86. package/dist/prompt/reminders.js +1 -1
  87. package/dist/prompt/runtime.js +1 -1
  88. package/dist/provider-anthropic.d.ts +6 -2
  89. package/dist/provider-anthropic.js +98 -18
  90. package/dist/provider-error-record.d.ts +9 -0
  91. package/dist/provider-error-record.js +42 -14
  92. package/dist/provider-grok.js +5 -1
  93. package/dist/provider-openai-codex.d.ts +8 -0
  94. package/dist/provider-openai-codex.js +107 -24
  95. package/dist/provider-registry.d.ts +45 -2
  96. package/dist/provider-registry.js +300 -88
  97. package/dist/provider-transform.js +8 -0
  98. package/dist/provider-zen-errors.d.ts +9 -0
  99. package/dist/provider-zen-errors.js +36 -0
  100. package/dist/provider.js +36 -5
  101. package/dist/sdk/index.d.ts +69 -8
  102. package/dist/sdk/index.js +252 -39
  103. package/dist/sdk/replay-event-log.d.ts +7 -0
  104. package/dist/sdk/replay-event-log.js +66 -16
  105. package/dist/session-context-fence.d.ts +16 -0
  106. package/dist/session-context-fence.js +26 -0
  107. package/dist/session-log.d.ts +11 -2
  108. package/dist/session-log.js +187 -39
  109. package/dist/session-types.d.ts +5 -1
  110. package/dist/session.d.ts +39 -7
  111. package/dist/session.js +341 -93
  112. package/dist/skills/format.d.ts +31 -1
  113. package/dist/skills/format.js +45 -24
  114. package/dist/slash-commands/commands.d.ts +3 -0
  115. package/dist/slash-commands/commands.js +29 -14
  116. package/dist/system-prompt.d.ts +3 -1
  117. package/dist/tasks/manager.d.ts +7 -0
  118. package/dist/tasks/manager.js +97 -18
  119. package/dist/tools/agent-lifecycle.js +11 -7
  120. package/dist/tools/child-tools.d.ts +1 -1
  121. package/dist/tools/child-tools.js +4 -4
  122. package/dist/tools/edit-apply.d.ts +0 -3
  123. package/dist/tools/edit-apply.js +45 -249
  124. package/dist/tools/edit.js +3 -3
  125. package/dist/tools/exit-plan-mode.d.ts +5 -0
  126. package/dist/tools/exit-plan-mode.js +3 -2
  127. package/dist/tools/file-state.d.ts +4 -0
  128. package/dist/tools/file-state.js +12 -3
  129. package/dist/tools/grep.js +20 -1
  130. package/dist/tools/index.d.ts +1 -1
  131. package/dist/tools/index.js +3 -3
  132. package/dist/tools/ls.d.ts +3 -0
  133. package/dist/tools/ls.js +85 -0
  134. package/dist/tools/path-utils.d.ts +6 -0
  135. package/dist/tools/path-utils.js +38 -1
  136. package/dist/tools/sensitive-paths.d.ts +8 -0
  137. package/dist/tools/sensitive-paths.js +85 -3
  138. package/dist/tools/skill.d.ts +2 -0
  139. package/dist/tools/skill.js +34 -2
  140. package/dist/tools/task-tools.js +11 -7
  141. package/dist/tui/app.js +22 -12
  142. package/dist/tui/components/approval-dialog.d.ts +5 -5
  143. package/dist/tui/components/approval-dialog.js +16 -51
  144. package/dist/tui/components/skills-panel.d.ts +4 -0
  145. package/dist/tui/components/skills-panel.js +15 -1
  146. package/dist/tui/components/streaming-message.js +7 -2
  147. package/dist/tui/components/tasks-pane.d.ts +38 -0
  148. package/dist/tui/components/tasks-pane.js +182 -32
  149. package/dist/tui/components/transcript.d.ts +11 -4
  150. package/dist/tui/components/transcript.js +158 -71
  151. package/dist/tui/composer-autocomplete.d.ts +17 -2
  152. package/dist/tui/composer-autocomplete.js +79 -2
  153. package/dist/tui/controller/controller.d.ts +9 -4
  154. package/dist/tui/controller/controller.js +87 -95
  155. package/dist/tui/controller/goal-runtime-controller.js +1 -1
  156. package/dist/tui/controller/session-transition.js +1 -1
  157. package/dist/tui/model/subagent-view.d.ts +11 -19
  158. package/dist/tui/model/subagent-view.js +56 -85
  159. package/dist/tui/model/task-lifecycle.d.ts +57 -0
  160. package/dist/tui/model/task-lifecycle.js +124 -0
  161. package/dist/tui/model/trace-groups.js +10 -9
  162. package/dist/tui/trace-groups.js +10 -9
  163. package/dist/types.d.ts +20 -0
  164. package/dist/variant/variant-resolver.js +2 -0
  165. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui-alt-screen.d.ts +1 -0
  166. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui-alt-screen.d.ts.map +1 -1
  167. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui-alt-screen.js +6 -0
  168. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui-alt-screen.js.map +1 -1
  169. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui.d.ts +2 -0
  170. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui.d.ts.map +1 -1
  171. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui.js +8 -4
  172. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui.js.map +1 -1
  173. package/package.json +11 -3
  174. package/dist/prompt/skills.d.ts +0 -2
  175. package/dist/prompt/skills.js +0 -4
  176. package/dist/tools/glob.d.ts +0 -5
  177. package/dist/tools/glob.js +0 -199
package/dist/session.js CHANGED
@@ -2,22 +2,79 @@
2
2
  * Session Manager - Append-only JSONL persistence over a structured session log.
3
3
  */
4
4
  import { randomUUID } from "node:crypto";
5
- import { mkdirSync, appendFileSync, existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
5
+ import { mkdirSync, appendFileSync, existsSync, readFileSync, readdirSync, statSync, writeFileSync, renameSync, unlinkSync, openSync, closeSync, fsyncSync, ftruncateSync, readSync } from "node:fs";
6
6
  import { basename, dirname, join } from "node:path";
7
7
  import { isInternalBlockOnlyContent } from "./agent/internal-reminder-sanitizer.js";
8
8
  import { getBubbleHome } from "./bubble-home.js";
9
9
  import { CheckpointStore } from "./checkpoints.js";
10
- import { buildCompactedEntries, compactSessionEntries, planOldMessages, planSessionCompaction, } from "./context/compact.js";
11
- import { SessionLog } from "./session-log.js";
10
+ import { withSessionWriteLock } from "./context/session-write-lock.js";
11
+ import { compactMessages, compactCurrentTurnToolGroups, buildCompactionSummaryMessage, isCompactionSummaryMessage, } from "./context/compact.js";
12
+ import { createContextCheckpoint, checkpointMessages } from "./context/checkpoint.js";
13
+ import { SessionLog, affectsContextRevision } from "./session-log.js";
12
14
  import { normalizeSingleLine, truncateVisual } from "./text-display.js";
13
15
  import { deterministicTitleFromUserContent } from "./session-title.js";
14
- const AUTO_COMPACT_ENTRY_THRESHOLD = 180;
15
- const AUTO_COMPACT_KEEP_RECENT_TURNS = 3;
16
+ /** A fenced write found the session log diverged from the writer's resident
17
+ * snapshot — a foreign append, clear, or rewind landed while a turn was in
18
+ * flight. The write is refused; the host must roll its resident history back
19
+ * to the file's truth (SessionContextFence.reloadHistory, then replace the
20
+ * agent's messages) before writing again, or every later fenced write keeps
21
+ * rejecting on the stale revision. */
22
+ export class SessionHistoryDivergedError extends Error {
23
+ constructor(message = "Session changed during active turn; reload before committing context") {
24
+ super(message);
25
+ this.name = "SessionHistoryDivergedError";
26
+ }
27
+ }
16
28
  export class SessionManager {
17
29
  sessionFile;
18
30
  log = new SessionLog();
19
31
  checkpoints;
32
+ diskRevision = "missing";
33
+ pendingCompaction;
34
+ /** Conversational revision excludes metadata-only writes (for example titles). */
35
+ getRevision() { return this.log.getRevision(); }
36
+ refresh() {
37
+ if (this.currentDiskRevision() !== this.diskRevision)
38
+ this.load();
39
+ }
40
+ currentDiskRevision() {
41
+ try {
42
+ const s = statSync(this.sessionFile, { bigint: true });
43
+ return `${s.ino}:${s.size}:${s.mtimeNs}`;
44
+ }
45
+ catch (error) {
46
+ if (error.code === "ENOENT")
47
+ return "missing";
48
+ throw error;
49
+ }
50
+ }
51
+ withWriteLock(write, expectedRevision) {
52
+ mkdirSync(dirname(this.sessionFile), { recursive: true });
53
+ return withSessionWriteLock(`${this.sessionFile}.write-lock`, () => {
54
+ if (expectedRevision !== undefined) {
55
+ // Refresh and compare the caller's snapshot under the same lock as the
56
+ // append. Refreshing must never legitimize a stale caller revision.
57
+ this.refresh();
58
+ if (this.getRevision() !== expectedRevision)
59
+ throw new SessionHistoryDivergedError();
60
+ }
61
+ else if (this.currentDiskRevision() !== this.diskRevision) {
62
+ throw new SessionHistoryDivergedError("Session changed; reload before committing context");
63
+ }
64
+ return write();
65
+ });
66
+ }
20
67
  metadataListeners = new Set();
68
+ contextListeners = new Set();
69
+ /** Local commits only: disk refreshes must never advance a host's history fence. */
70
+ subscribeContextCommits(listener) {
71
+ this.contextListeners.add(listener);
72
+ return () => this.contextListeners.delete(listener);
73
+ }
74
+ publishContextCommit(previous, replacement = false) {
75
+ for (const listener of this.contextListeners)
76
+ listener(previous, this.getRevision(), replacement);
77
+ }
21
78
  constructor(sessionFile) {
22
79
  this.sessionFile = sessionFile;
23
80
  if (existsSync(sessionFile)) {
@@ -88,11 +145,19 @@ export class SessionManager {
88
145
  return summaries.sort((a, b) => b.mtime - a.mtime);
89
146
  }
90
147
  load() {
91
- const content = readFileSync(this.sessionFile, "utf-8");
92
- const lines = content.split("\n").filter((line) => line.trim() !== "");
93
- this.log.load(lines);
148
+ for (let attempt = 0; attempt < 3; attempt++) {
149
+ const before = this.currentDiskRevision();
150
+ const content = readFileSync(this.sessionFile, "utf-8");
151
+ const after = this.currentDiskRevision();
152
+ if (before !== after)
153
+ continue;
154
+ this.log.load(content.split("\n").filter(line => line.trim() !== ""));
155
+ this.diskRevision = after;
156
+ return;
157
+ }
158
+ throw new Error("Session changed while loading; retry with a stable snapshot");
94
159
  }
95
- persist(entry) {
160
+ persist(entry, lockHeld = false) {
96
161
  const dir = dirname(this.sessionFile);
97
162
  if (!existsSync(dir)) {
98
163
  mkdirSync(dir, { recursive: true });
@@ -101,15 +166,62 @@ export class SessionManager {
101
166
  if (entries.length === 0) {
102
167
  return;
103
168
  }
104
- appendFileSync(this.sessionFile, entries.map((item) => JSON.stringify(item)).join("\n") + "\n");
105
- }
106
- rewrite(entries) {
107
- const dir = dirname(this.sessionFile);
108
- if (!existsSync(dir)) {
109
- mkdirSync(dir, { recursive: true });
110
- }
111
- writeFileSync(this.sessionFile, entries.map((entry) => JSON.stringify(entry)).join("\n") + "\n");
112
- this.log.replace(entries);
169
+ const write = () => {
170
+ const fd = openSync(this.sessionFile, "a+");
171
+ const before = statSync(this.sessionFile).size;
172
+ try {
173
+ // Isolate an incomplete crash tail, without adding empty records normally.
174
+ const tail = Buffer.alloc(1);
175
+ if (before > 0)
176
+ readSync(fd, tail, 0, 1, before - 1);
177
+ const separator = before > 0 && tail[0] !== 10 ? "\n" : "";
178
+ appendFileSync(fd, separator + entries.map((item) => JSON.stringify(item)).join("\n") + "\n");
179
+ fsyncSync(fd);
180
+ }
181
+ catch (error) {
182
+ ftruncateSync(fd, before);
183
+ fsyncSync(fd);
184
+ throw error;
185
+ }
186
+ finally {
187
+ closeSync(fd);
188
+ this.diskRevision = this.currentDiskRevision();
189
+ }
190
+ };
191
+ if (lockHeld)
192
+ write();
193
+ else
194
+ this.withWriteLock(write);
195
+ }
196
+ /** Refresh, derive the replacement from the refreshed log, and swap the file
197
+ * as one locked transaction; `derive` returning undefined leaves it untouched. */
198
+ rewrite(derive) {
199
+ mkdirSync(dirname(this.sessionFile), { recursive: true });
200
+ return withSessionWriteLock(`${this.sessionFile}.write-lock`, () => {
201
+ this.refresh();
202
+ const derived = derive(this.log.list());
203
+ if (!derived)
204
+ return undefined;
205
+ const temp = `${this.sessionFile}.${randomUUID()}.tmp`;
206
+ try {
207
+ writeFileSync(temp, derived.entries.map((entry) => JSON.stringify(entry)).join("\n") + "\n", { mode: 0o600 });
208
+ const fd = openSync(temp, "r");
209
+ try {
210
+ fsyncSync(fd);
211
+ }
212
+ finally {
213
+ closeSync(fd);
214
+ }
215
+ renameSync(temp, this.sessionFile);
216
+ this.diskRevision = this.currentDiskRevision();
217
+ this.log.replace(derived.entries);
218
+ }
219
+ finally {
220
+ if (existsSync(temp))
221
+ unlinkSync(temp);
222
+ }
223
+ return derived.result;
224
+ });
113
225
  }
114
226
  getMetadata() {
115
227
  return this.log.getMetadata();
@@ -132,8 +244,26 @@ export class SessionManager {
132
244
  return promptCacheKey;
133
245
  }
134
246
  setMetadata(metadata) {
135
- const nextEntries = this.log.setMetadata(metadata);
136
- this.rewrite(nextEntries);
247
+ this.mutateMetadata(() => metadata);
248
+ }
249
+ /** Refresh, derive, and append as one locked transaction. Metadata records
250
+ * are full snapshots, so a snapshot built outside the lock would silently
251
+ * overwrite a concurrent writer's fields. */
252
+ mutateMetadata(build) {
253
+ mkdirSync(dirname(this.sessionFile), { recursive: true });
254
+ try {
255
+ withSessionWriteLock(`${this.sessionFile}.write-lock`, () => {
256
+ this.refresh();
257
+ const entry = { id: `metadata-${randomUUID()}`, type: "metadata",
258
+ metadata: build(this.log.getMetadata()), timestamp: Date.now() };
259
+ this.persist(entry, true);
260
+ this.log.appendEntries([entry]);
261
+ });
262
+ }
263
+ catch (error) {
264
+ this.reloadAfterWriteFailure();
265
+ throw error;
266
+ }
137
267
  const committed = this.log.getMetadata();
138
268
  for (const listener of this.metadataListeners) {
139
269
  try {
@@ -146,40 +276,75 @@ export class SessionManager {
146
276
  }
147
277
  }
148
278
  updateMetadata(patch) {
149
- this.setMetadata({
150
- ...this.log.getMetadata(),
151
- ...dropUndefined(patch),
152
- });
279
+ this.mutateMetadata((current) => ({ ...current, ...dropUndefined(patch) }));
153
280
  }
154
281
  clearTitleMetadata() {
155
- const { title: _title, titleSource: _titleSource, titleUpdatedAt: _titleUpdatedAt, titleUserMessageId: _titleUserMessageId, ...metadata } = this.log.getMetadata();
156
- this.setMetadata(metadata);
282
+ this.mutateMetadata(({ title: _title, titleSource: _titleSource, titleUpdatedAt: _titleUpdatedAt, titleUserMessageId: _titleUserMessageId, ...metadata }) => metadata);
157
283
  }
158
284
  clearExternalRuntimeMetadata() {
159
- const { externalRuntime: _externalRuntime, ...metadata } = this.log.getMetadata();
160
- this.setMetadata(metadata);
161
- }
162
- appendMessage(message) {
163
- const entries = this.log.appendMessage(message);
164
- this.persist(entries);
165
- this.maybeAutoCompact();
166
- }
167
- appendCompaction(summary) {
168
- const entry = this.log.appendSummary(summary);
169
- this.persist(entry);
285
+ this.mutateMetadata(({ externalRuntime: _externalRuntime, ...metadata }) => metadata);
286
+ }
287
+ appendMessage(message, expectedRevision) {
288
+ this.refresh();
289
+ const revision = expectedRevision ?? this.getRevision();
290
+ try {
291
+ this.withWriteLock(() => {
292
+ const entries = this.log.appendMessage(message);
293
+ this.persist(entries, true);
294
+ }, revision);
295
+ }
296
+ catch (error) {
297
+ this.reloadAfterWriteFailure();
298
+ throw error;
299
+ }
300
+ this.publishContextCommit(revision);
301
+ // Persistence never decides model context policy by record count.
170
302
  }
171
- appendMarker(kind, value) {
172
- const entry = this.log.appendMarker(kind, value);
173
- this.persist(entry);
303
+ reloadAfterWriteFailure() {
304
+ if (existsSync(this.sessionFile))
305
+ this.load();
306
+ else {
307
+ this.log = new SessionLog();
308
+ this.diskRevision = "missing";
309
+ }
174
310
  }
175
- appendProviderError(error) {
176
- const entry = this.log.appendProviderError(error);
177
- this.persist(entry);
311
+ appendMarker(kind, value, expectedRevision) {
312
+ this.refresh();
313
+ const revision = expectedRevision ?? this.getRevision();
314
+ try {
315
+ this.withWriteLock(() => {
316
+ const entry = this.log.appendMarker(kind, value);
317
+ this.persist(entry, true);
318
+ }, revision);
319
+ }
320
+ catch (error) {
321
+ this.reloadAfterWriteFailure();
322
+ throw error;
323
+ }
324
+ this.publishContextCommit(revision, kind === "conversation_clear");
325
+ }
326
+ appendProviderError(error, expectedRevision) {
327
+ this.refresh();
328
+ try {
329
+ this.withWriteLock(() => {
330
+ const entry = this.log.appendProviderError(error);
331
+ this.persist(entry, true);
332
+ }, expectedRevision ?? this.getRevision());
333
+ }
334
+ catch (failure) {
335
+ this.reloadAfterWriteFailure();
336
+ throw failure;
337
+ }
178
338
  }
179
339
  compact(options) {
180
- const result = compactSessionEntries(this.log.list(), options);
181
- if (result.compacted && result.entries) {
182
- this.rewrite(result.entries);
340
+ const messages = this.getMessages();
341
+ let result = compactMessages(messages, options);
342
+ if (!result.compacted)
343
+ result = compactCurrentTurnToolGroups(messages);
344
+ if (result.compacted && result.messages) {
345
+ const revision = this.getRevision();
346
+ this.commitContextCheckpoint(createContextCheckpoint(result.messages, "manual", result.summary, revision));
347
+ this.publishContextCommit(revision, true);
183
348
  }
184
349
  return result;
185
350
  }
@@ -190,32 +355,53 @@ export class SessionManager {
190
355
  * caller should then report "already compact enough" without calling a model.
191
356
  */
192
357
  getCompactionPlan(options) {
193
- const plan = planSessionCompaction(this.log.list(), options);
194
- if (!plan.compactable)
358
+ const messages = this.getMessages();
359
+ let candidate = compactMessages(messages, options);
360
+ if (!candidate.compacted)
361
+ candidate = compactCurrentTurnToolGroups(messages);
362
+ if (!candidate.compacted) {
363
+ this.pendingCompaction = undefined;
195
364
  return null;
196
- return { oldMessages: planOldMessages(plan) };
365
+ }
366
+ this.pendingCompaction = { revision: this.getRevision(), candidate };
367
+ // Only the portion the candidate's summary replaces: kept recent turns
368
+ // survive verbatim, so feeding them to the summarizer would duplicate them
369
+ // in the summary and risk overflowing the compaction request into the
370
+ // heuristic fallback. Prior summary carriers are included so their facts
371
+ // roll forward.
372
+ return { oldMessages: candidate.evictedMessages ?? messages };
197
373
  }
198
374
  /**
199
375
  * Apply a precomputed (typically LLM-generated) summary as the compaction
200
- * checkpoint, rewriting the log to [metadata, summary, kept turns]. Mirrors
376
+ * checkpoint, appending an exact projection without deleting originals. Mirrors
201
377
  * `compact()` but skips the built-in heuristic summarizer. Returns
202
378
  * `{ compacted: false }` if the session is no longer compactable.
203
379
  */
204
380
  applyLLMCompaction(summary, options) {
205
- const entries = this.log.list();
206
- const plan = planSessionCompaction(entries, options);
207
- if (!plan.compactable)
381
+ const pending = this.pendingCompaction;
382
+ this.pendingCompaction = undefined;
383
+ const messages = this.getMessages();
384
+ if (pending && pending.revision !== this.getRevision())
385
+ throw new Error("Stale compaction plan; session changed during summarization");
386
+ let result = pending?.candidate ?? compactMessages(messages, options);
387
+ if (!result.compacted)
388
+ result = compactCurrentTurnToolGroups(messages);
389
+ if (!result.compacted || !result.messages)
208
390
  return { compacted: false };
209
- const nextEntries = buildCompactedEntries(entries, plan, summary);
210
- this.rewrite(nextEntries);
211
- return {
212
- compacted: true,
213
- summary,
214
- entries: nextEntries,
215
- droppedEntries: plan.oldEntries.length,
216
- };
391
+ // Replace only the summary that stands for the evicted input. A sub-turn
392
+ // candidate also carries earlier multi-turn summaries in its pre-turn; the
393
+ // summarizer never saw those, so they must survive verbatim.
394
+ const summaryIndex = result.summaryIndex ?? result.messages.findIndex(isCompactionSummaryMessage);
395
+ if (summaryIndex < 0)
396
+ return { compacted: false };
397
+ const next = result.messages.map((message, index) => index === summaryIndex ? buildCompactionSummaryMessage(summary) : message);
398
+ const revision = this.getRevision();
399
+ this.commitContextCheckpoint(createContextCheckpoint(next, "manual", summary, revision));
400
+ this.publishContextCommit(revision, true);
401
+ return { ...result, summary, messages: next };
217
402
  }
218
403
  getMessages() {
404
+ this.refresh();
219
405
  return this.log.toMessages();
220
406
  }
221
407
  /**
@@ -228,18 +414,26 @@ export class SessionManager {
228
414
  }
229
415
  return this.checkpoints;
230
416
  }
231
- /** Entry id of the most recent user message, or "0" before the first one. */
417
+ /**
418
+ * Entry id of the most recent user turn (as listUserTurns counts them), or
419
+ * "0" before the first one. Checkpoints are keyed by it, so edits made after
420
+ * a tool screenshot or a harness-injected block stay with the user's turn.
421
+ */
232
422
  lastUserEntryId() {
233
423
  const entries = this.log.list();
234
424
  for (let i = entries.length - 1; i >= 0; i--) {
235
425
  const entry = entries[i];
236
- if (entry.type === "user_message")
237
- return entry.id;
426
+ if (entry.type !== "user_message")
427
+ continue;
428
+ if (entry.message.toolObservation || isInternalBlockOnlyContent(entry.message.content))
429
+ continue;
430
+ return entry.id;
238
431
  }
239
432
  return "0";
240
433
  }
241
434
  /** User messages after the latest /clear, oldest first — the valid rewind anchors. */
242
435
  listUserTurns() {
436
+ this.refresh();
243
437
  const entries = this.log.list();
244
438
  let start = 0;
245
439
  for (let i = entries.length - 1; i >= 0; i--) {
@@ -256,7 +450,8 @@ export class SessionManager {
256
450
  continue;
257
451
  // Harness-injected turns (goal kicks, task wakes) are internal blocks —
258
452
  // not rewind anchors, and their markup must never render in the picker.
259
- if (isInternalBlockOnlyContent(entry.message.content))
453
+ // Tool screenshots are user-role only for the provider; not turns either.
454
+ if (isInternalBlockOnlyContent(entry.message.content) || entry.message.toolObservation)
260
455
  continue;
261
456
  const text = messageText(entry.message);
262
457
  turns.push({
@@ -273,21 +468,46 @@ export class SessionManager {
273
468
  * entry id. Returns undefined when the id does not name a user message.
274
469
  */
275
470
  rewindToEntry(entryId) {
276
- const entries = this.log.list();
277
- const index = entries.findIndex((entry) => entry.id === entryId && entry.type === "user_message");
278
- if (index < 0)
279
- return undefined;
280
- const target = entries[index];
281
- const removed = entries.slice(index);
282
- this.rewrite(entries.slice(0, index));
283
- const metadata = this.log.getMetadata();
284
- if (metadata.titleUserMessageId && removed.some((entry) => entry.id === metadata.titleUserMessageId)) {
285
- this.clearTitleMetadata();
471
+ // A rewind replaces history by explicit user action, so it selects from the
472
+ // refreshed log under the lock rather than fencing on a resident revision:
473
+ // a stale in-memory snapshot must not reject (or misplace) the rewrite.
474
+ let revision = this.getRevision();
475
+ let rewound;
476
+ try {
477
+ rewound = this.rewrite((entries) => {
478
+ const index = entries.findIndex((entry) => entry.id === entryId && entry.type === "user_message");
479
+ if (index < 0)
480
+ return undefined;
481
+ revision = this.getRevision();
482
+ const target = entries[index];
483
+ const removed = entries.slice(index);
484
+ // Metadata records are full snapshots, not history deltas. Preserve the
485
+ // latest snapshot (including absent/cleared fields) in the atomic rewrite.
486
+ const metadata = { ...this.log.getMetadata() };
487
+ if (metadata.titleUserMessageId && removed.some((entry) => entry.id === metadata.titleUserMessageId)) {
488
+ delete metadata.title;
489
+ delete metadata.titleSource;
490
+ delete metadata.titleUpdatedAt;
491
+ delete metadata.titleUserMessageId;
492
+ }
493
+ return {
494
+ entries: [...entries.slice(0, index), {
495
+ id: `metadata-${randomUUID()}`, type: "metadata", metadata, timestamp: Date.now(),
496
+ }],
497
+ result: {
498
+ removedEntries: removed.length,
499
+ targetText: target.type === "user_message" ? messageText(target.message) : "",
500
+ },
501
+ };
502
+ });
286
503
  }
287
- return {
288
- removedEntries: removed.length,
289
- targetText: target.type === "user_message" ? messageText(target.message) : "",
290
- };
504
+ catch (error) {
505
+ this.reloadAfterWriteFailure();
506
+ throw error;
507
+ }
508
+ if (rewound)
509
+ this.publishContextCommit(revision, true);
510
+ return rewound;
291
511
  }
292
512
  getEntries() {
293
513
  return this.log.list();
@@ -295,17 +515,35 @@ export class SessionManager {
295
515
  getSessionFile() {
296
516
  return this.sessionFile;
297
517
  }
298
- maybeAutoCompact() {
299
- const entries = this.log.list();
300
- if (entries.length < AUTO_COMPACT_ENTRY_THRESHOLD) {
301
- return;
302
- }
303
- const result = compactSessionEntries(entries, {
304
- keepRecentTurns: AUTO_COMPACT_KEEP_RECENT_TURNS,
305
- });
306
- if (result.compacted && result.entries) {
307
- this.rewrite(result.entries);
308
- }
518
+ /** Commit the exact conversational projection, never delete its source history. */
519
+ commitContextCheckpoint(checkpoint, expectedRevision) {
520
+ const revision = expectedRevision ?? this.getRevision();
521
+ this.withWriteLock(() => {
522
+ const entries = this.log.list();
523
+ // Replay tolerates a structurally invalid (hand-edited) checkpoint record;
524
+ // it must not then make every later commit throw here.
525
+ const existing = entries.find(entry => entry.type === "context_checkpoint"
526
+ && entry.checkpoint?.compactionId === checkpoint.compactionId);
527
+ if (existing) {
528
+ if (existing.type !== "context_checkpoint" || JSON.stringify(existing.checkpoint) !== JSON.stringify(checkpoint)) {
529
+ throw new Error("Conflicting context checkpoint id");
530
+ }
531
+ // An old receipt is not permission to restore context after clear/rewind
532
+ // or after new conversation data has superseded this exact projection.
533
+ if (entries.filter(affectsContextRevision).at(-1) !== existing)
534
+ throw new Error("Stale context checkpoint receipt");
535
+ return;
536
+ }
537
+ if (checkpoint.baseRevision !== undefined && checkpoint.baseRevision !== this.getRevision()) {
538
+ throw new Error("Stale context checkpoint; session advanced during summarization");
539
+ }
540
+ checkpointMessages(checkpoint); // Validate before writing or publishing.
541
+ const entry = { id: `checkpoint-${checkpoint.compactionId}`, type: "context_checkpoint",
542
+ checkpoint: structuredClone(checkpoint), timestamp: Date.now() };
543
+ this.persist(entry, true);
544
+ this.log.appendEntries([entry]);
545
+ }, revision);
546
+ this.publishContextCommit(revision);
309
547
  }
310
548
  }
311
549
  export function getSessionsDir(cwd) {
@@ -336,11 +574,21 @@ function summarizeSessionFile(file, cwdDir) {
336
574
  const lines = content.split("\n").filter((line) => line.trim() !== "");
337
575
  if (lines.length === 0)
338
576
  return undefined;
577
+ // One unreadable session — an unsupported record, a hand-edited message the
578
+ // title/preview code cannot handle — must not take the whole listing down.
579
+ try {
580
+ return summarizeSessionLines(lines, file, cwdDir, stat.mtimeMs);
581
+ }
582
+ catch {
583
+ return undefined;
584
+ }
585
+ }
586
+ function summarizeSessionLines(lines, file, cwdDir, mtime) {
339
587
  const log = new SessionLog();
340
588
  log.load(lines);
589
+ const messages = log.toMessages();
341
590
  const metadata = log.getMetadata();
342
591
  const entries = log.list();
343
- const messages = log.toMessages();
344
592
  const firstUserEntry = firstUserEntryAfterLatestClear(entries);
345
593
  const firstUserText = firstUserEntry ? messageText(firstUserEntry.message) : "";
346
594
  const preview = firstUserText
@@ -357,7 +605,7 @@ function summarizeSessionFile(file, cwdDir) {
357
605
  preview,
358
606
  firstUserMessage: preview,
359
607
  messageCount: messages.length,
360
- mtime: stat.mtimeMs,
608
+ mtime,
361
609
  };
362
610
  }
363
611
  function decodeCwdDir(safe) {
@@ -377,7 +625,7 @@ function firstUserEntryAfterLatestClear(entries) {
377
625
  const entry = entries[i];
378
626
  // Skip harness-injected internal blocks (goal kicks, task wakes): they
379
627
  // must not become the /resume preview or the deterministic title.
380
- if (entry.type === "user_message" && !isInternalBlockOnlyContent(entry.message.content)) {
628
+ if (entry.type === "user_message" && !isInternalBlockOnlyContent(entry.message.content) && !entry.message.toolObservation) {
381
629
  return entry;
382
630
  }
383
631
  }
@@ -1,2 +1,32 @@
1
1
  import type { SkillSummary } from "./types.js";
2
- export declare function formatSkillsPrompt(skills: SkillSummary[]): string;
2
+ /** Default size of the always-present skill catalog (the listed lines, not the header). */
3
+ export declare const SKILL_CATALOG_CHARS = 12000;
4
+ /** Each description is cut to this length; it only has to tell the model when to load. */
5
+ export declare const SKILL_CATALOG_DESC_CHARS = 120;
6
+ export declare const SKILL_CATALOG_HEADING = "## Skills";
7
+ /** First line under the heading; together they mark the catalog unambiguously. */
8
+ export declare const SKILL_CATALOG_INTRO = "Skills are task-specific instructions loaded on demand. When a task matches a skill below, call `skill` with its exact name before starting, then follow it. Load only what clearly applies.";
9
+ export interface SkillCatalogOptions {
10
+ /** Budget for the listed lines; 0 turns the catalog off. */
11
+ budgetChars?: number;
12
+ /** skill_search is available, so the overflow line can point to it. */
13
+ searchable?: boolean;
14
+ }
15
+ export interface SkillCatalog {
16
+ /** The system-prompt section, or "" when there is nothing to list. */
17
+ text: string;
18
+ listed: number;
19
+ unlisted: number;
20
+ /** Characters used by the listed lines, against budgetChars. */
21
+ chars: number;
22
+ budgetChars: number;
23
+ }
24
+ /**
25
+ * The always-present skill catalog: name and a one-line description per skill,
26
+ * so the model knows what exists and loads a body with `skill` only when it
27
+ * applies. Ordering is deterministic (source, then name) so the system prompt
28
+ * stays byte-identical, and cacheable, while the skill set is unchanged.
29
+ */
30
+ export declare function buildSkillCatalog(skills: SkillSummary[], options?: SkillCatalogOptions): SkillCatalog;
31
+ /** The catalog section text (see buildSkillCatalog). */
32
+ export declare function formatSkillsPrompt(skills: SkillSummary[], options?: SkillCatalogOptions): string;