@bubblebrain-ai/bubble 0.0.45 → 0.0.47

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 (93) hide show
  1. package/dist/agent/child-runner.js +11 -0
  2. package/dist/agent/internal-reminder-sanitizer.d.ts +7 -0
  3. package/dist/agent/internal-reminder-sanitizer.js +13 -0
  4. package/dist/agent/subagent/router.d.ts +65 -0
  5. package/dist/agent/subagent/router.js +198 -0
  6. package/dist/agent/subagent/runtime.d.ts +203 -0
  7. package/dist/agent/subagent/runtime.js +789 -0
  8. package/dist/agent/subagent-control.d.ts +5 -0
  9. package/dist/agent/subagent-store.d.ts +18 -2
  10. package/dist/agent/subagent-store.js +32 -1
  11. package/dist/agent/tool-intent.d.ts +10 -0
  12. package/dist/agent/tool-intent.js +18 -0
  13. package/dist/agent/tool-visibility.d.ts +10 -0
  14. package/dist/agent/tool-visibility.js +6 -0
  15. package/dist/agent/workflow/runs.d.ts +72 -0
  16. package/dist/agent/workflow/runs.js +157 -0
  17. package/dist/agent.d.ts +78 -96
  18. package/dist/agent.js +266 -1072
  19. package/dist/context/compact-llm.js +14 -5
  20. package/dist/context/compact.d.ts +9 -0
  21. package/dist/context/compact.js +36 -10
  22. package/dist/context/compaction-files.d.ts +44 -0
  23. package/dist/context/compaction-files.js +148 -0
  24. package/dist/context/llm-compactor.js +24 -5
  25. package/dist/feishu/agent-host/run-driver.js +3 -0
  26. package/dist/feishu/serve.js +13 -9
  27. package/dist/goal/tools.js +12 -3
  28. package/dist/main.js +24 -19
  29. package/dist/model-catalog.js +36 -2
  30. package/dist/model-config.d.ts +2 -0
  31. package/dist/model-data.generated.d.ts +16 -0
  32. package/dist/model-data.generated.js +275 -0
  33. package/dist/model-pricing.d.ts +11 -7
  34. package/dist/model-pricing.js +0 -0
  35. package/dist/oauth/grok.d.ts +1 -1
  36. package/dist/oauth/grok.js +58 -29
  37. package/dist/oauth/openai-codex.js +13 -0
  38. package/dist/oauth/storage.d.ts +9 -2
  39. package/dist/oauth/storage.js +16 -9
  40. package/dist/orchestrator/default-hooks.d.ts +20 -0
  41. package/dist/orchestrator/default-hooks.js +47 -295
  42. package/dist/orchestrator/hooks.d.ts +6 -39
  43. package/dist/permission/mode.js +1 -1
  44. package/dist/prompt/reminders.d.ts +9 -35
  45. package/dist/prompt/reminders.js +30 -136
  46. package/dist/prompt/task-reminders.d.ts +0 -31
  47. package/dist/prompt/task-reminders.js +0 -87
  48. package/dist/provider-ai-sdk.d.ts +2 -0
  49. package/dist/provider-ai-sdk.js +1 -0
  50. package/dist/provider-anthropic.d.ts +2 -0
  51. package/dist/provider-anthropic.js +3 -1
  52. package/dist/provider-ark-responses.d.ts +2 -0
  53. package/dist/provider-ark-responses.js +1 -0
  54. package/dist/provider-registry.d.ts +13 -0
  55. package/dist/provider-registry.js +83 -0
  56. package/dist/provider-transform.js +37 -1
  57. package/dist/provider.d.ts +7 -0
  58. package/dist/provider.js +13 -1
  59. package/dist/sdk/index.d.ts +152 -0
  60. package/dist/sdk/index.js +400 -0
  61. package/dist/session.js +9 -1
  62. package/dist/slash-commands/commands.js +33 -19
  63. package/dist/tools/exit-plan-mode.js +8 -0
  64. package/dist/tui/detect-theme.d.ts +1 -0
  65. package/dist/tui/detect-theme.js +11 -5
  66. package/dist/tui-ink/app.d.ts +2 -6
  67. package/dist/tui-ink/app.js +11 -18
  68. package/dist/tui-ink/display-history.d.ts +0 -2
  69. package/dist/tui-ink/display-history.js +0 -52
  70. package/dist/tui-ink/footer.js +2 -2
  71. package/dist/tui-ink/message-list.js +62 -23
  72. package/dist/tui-ink/run.js +10 -5
  73. package/dist/tui-ink/sync-output.d.ts +17 -0
  74. package/dist/tui-ink/sync-output.js +35 -0
  75. package/dist/tui-ink/theme.d.ts +0 -1
  76. package/dist/tui-ink/theme.js +0 -2
  77. package/dist/tui-ink/trace-groups.js +1 -1
  78. package/dist/types.d.ts +21 -1
  79. package/package.json +16 -2
  80. package/dist/agent/discovery-barrier.d.ts +0 -21
  81. package/dist/agent/discovery-barrier.js +0 -173
  82. package/dist/agent/evidence-tracker.d.ts +0 -15
  83. package/dist/agent/evidence-tracker.js +0 -93
  84. package/dist/agent/execution-governor.d.ts +0 -36
  85. package/dist/agent/execution-governor.js +0 -218
  86. package/dist/agent/task-classifier.d.ts +0 -3
  87. package/dist/agent/task-classifier.js +0 -114
  88. package/dist/agent/task-size.d.ts +0 -9
  89. package/dist/agent/task-size.js +0 -33
  90. package/dist/agent/tool-arbiter.d.ts +0 -7
  91. package/dist/agent/tool-arbiter.js +0 -33
  92. package/dist/orchestrator/workflow.d.ts +0 -10
  93. package/dist/orchestrator/workflow.js +0 -22
@@ -6,7 +6,9 @@
6
6
  * message. Falls back to the heuristic `compactMessages` if the LLM call
7
7
  * fails.
8
8
  */
9
- import { buildCompactionSummaryMessage, clonePinnedUserMessage, compactMessages as compactMessagesHeuristic, findFirstRealUserIndex, isCompactionSummaryMessage, isRealUserMessage, splitLeadingContext, } from "./compact.js";
9
+ import { buildCompactionSummaryMessage, clonePinnedUserMessage, collectCompactionFileOps, compactMessages as compactMessagesHeuristic, findFirstRealUserIndex, isCompactionSummaryMessage, isRealUserMessage, messageText, splitLeadingContext, } from "./compact.js";
10
+ import { sanitizeInternalReminderBlocks } from "../agent/internal-reminder-sanitizer.js";
11
+ import { appendFileBlocks, stripFileBlocks } from "./compaction-files.js";
10
12
  export const COMPACT_SYSTEM_PROMPT = `You are a conversation summarizer. Your job is to produce a structured
11
13
  summary of an earlier portion of a software-engineering assistant's
12
14
  conversation so that the assistant can continue working without the full
@@ -65,13 +67,16 @@ export async function compactMessagesWithLLM(messages, options) {
65
67
  // pin the heuristic and llm-compactor paths already had).
66
68
  const pinnedIndex = findFirstRealUserIndex(oldMessages);
67
69
  const pinnedMessage = pinnedIndex >= 0 ? oldMessages[pinnedIndex] : undefined;
70
+ // File blocks are stripped from prior summaries before summarization: the
71
+ // deterministic merge below owns the file lists end to end.
68
72
  const summarizable = [
69
73
  ...priorSummaries.map((m) => ({
70
74
  role: "user",
71
- content: `[Prior compaction summary]\n${typeof m.content === "string" ? m.content : ""}`,
75
+ content: `[Prior compaction summary]\n${stripFileBlocks(messageText(m))}`,
72
76
  })),
73
77
  ...oldMessages.filter((_, index) => index !== pinnedIndex),
74
78
  ];
79
+ const fileOps = collectCompactionFileOps(oldMessages, priorSummaries);
75
80
  let summary;
76
81
  try {
77
82
  summary = await generateSummary(summarizable, options);
@@ -79,16 +84,20 @@ export async function compactMessagesWithLLM(messages, options) {
79
84
  catch {
80
85
  return compactMessagesHeuristic(messages, { keepRecentTurns, maxSummaryItems: options.maxSummaryItems });
81
86
  }
82
- if (!summary.trim()) {
87
+ // Models quote their input, and the transcript can contain projected
88
+ // reminder blocks; this summary is persisted, so scrub markup first.
89
+ summary = sanitizeInternalReminderBlocks(summary).trim();
90
+ if (!summary) {
83
91
  return compactMessagesHeuristic(messages, { keepRecentTurns, maxSummaryItems: options.maxSummaryItems });
84
92
  }
93
+ const summaryWithFiles = appendFileBlocks(summary, fileOps);
85
94
  return {
86
95
  compacted: true,
87
- summary,
96
+ summary: summaryWithFiles,
88
97
  messages: [
89
98
  ...leading,
90
99
  ...(pinnedMessage ? [clonePinnedUserMessage(pinnedMessage)] : []),
91
- buildCompactionSummaryMessage(summary),
100
+ buildCompactionSummaryMessage(summaryWithFiles),
92
101
  ...keptMessages,
93
102
  ],
94
103
  droppedEntries: oldMessages.length,
@@ -1,5 +1,6 @@
1
1
  import type { Message } from "../types.js";
2
2
  import type { SessionLogEntry } from "../session-types.js";
3
+ import { type CompactionFileOps } from "./compaction-files.js";
3
4
  /**
4
5
  * Compaction must never summarize away what the user actually asked for:
5
6
  * requirements that appear late in a long instruction ("commit when done",
@@ -10,6 +11,7 @@ import type { SessionLogEntry } from "../session-types.js";
10
11
  export declare const PINNED_INSTRUCTION_MAX_CHARS = 8192;
11
12
  export declare const COMPACTION_SUMMARY_PREFIX = "Previous conversation summary:";
12
13
  export declare const SUBTURN_SUMMARY_PREFIX = "Earlier in this turn (compacted to free context):";
14
+ export declare function messageText(message: Message): string;
13
15
  /**
14
16
  * Identity check for compaction summaries across every form they can take:
15
17
  * the first-class meta form, the legacy raw-system form, and the projected
@@ -68,6 +70,13 @@ export type SessionCompactionPlan = {
68
70
  keptEntries: SessionLogEntry[];
69
71
  };
70
72
  export declare function planSessionCompaction(entries: SessionLogEntry[], options?: CompactOptions): SessionCompactionPlan;
73
+ /**
74
+ * Cumulative file ops for a compaction: the blocks prior summaries carried,
75
+ * unioned with the ops found in the messages being evicted now. Every
76
+ * compaction path — LLM, heuristic, sub-turn, session-entry — must thread
77
+ * this through, or the "cumulative" record resets on that path.
78
+ */
79
+ export declare function collectCompactionFileOps(evicted: Message[], priorSummaries: Message[]): CompactionFileOps;
71
80
  /**
72
81
  * Assemble the post-compaction entry list from a plan and a (possibly
73
82
  * LLM-generated) summary string. The summary entry is keyed off the full
@@ -1,4 +1,5 @@
1
- import { isInternalBlockContent } from "../agent/internal-reminder-sanitizer.js";
1
+ import { isInternalBlockContent, sanitizeInternalReminderBlocks } from "../agent/internal-reminder-sanitizer.js";
2
+ import { appendFileBlocks, extractFileOps, mergeFileOps, parseFileBlocks, stripFileBlocks, } from "./compaction-files.js";
2
3
  /**
3
4
  * Compaction must never summarize away what the user actually asked for:
4
5
  * requirements that appear late in a long instruction ("commit when done",
@@ -23,7 +24,7 @@ const PROJECTED_SUMMARY_PREFIXES = [
23
24
  `<bubble_internal_context kind="runtime-system">\n${COMPACTION_SUMMARY_PREFIX}`,
24
25
  `<bubble_internal_context kind="runtime-system">\n${SUBTURN_SUMMARY_PREFIX}`,
25
26
  ];
26
- function messageText(message) {
27
+ export function messageText(message) {
27
28
  if (typeof message.content === "string")
28
29
  return message.content;
29
30
  if (Array.isArray(message.content)) {
@@ -139,6 +140,15 @@ export function planSessionCompaction(entries, options = {}) {
139
140
  keptEntries: activeEntries.slice(keepStartIndex),
140
141
  };
141
142
  }
143
+ /**
144
+ * Cumulative file ops for a compaction: the blocks prior summaries carried,
145
+ * unioned with the ops found in the messages being evicted now. Every
146
+ * compaction path — LLM, heuristic, sub-turn, session-entry — must thread
147
+ * this through, or the "cumulative" record resets on that path.
148
+ */
149
+ export function collectCompactionFileOps(evicted, priorSummaries) {
150
+ return mergeFileOps(...priorSummaries.map((message) => parseFileBlocks(messageText(message))), extractFileOps(evicted));
151
+ }
142
152
  /**
143
153
  * Assemble the post-compaction entry list from a plan and a (possibly
144
154
  * LLM-generated) summary string. The summary entry is keyed off the full
@@ -148,7 +158,9 @@ export function buildCompactedEntries(entries, plan, summary) {
148
158
  const summaryEntry = {
149
159
  id: nextSummaryId(entries),
150
160
  type: "summary",
151
- summary,
161
+ // Sink-level backstop: whatever producer built this string (LLM or
162
+ // heuristic), internal markup never lands in the session file.
163
+ summary: sanitizeInternalReminderBlocks(summary),
152
164
  timestamp: Date.now(),
153
165
  };
154
166
  return [...plan.metadataEntries, summaryEntry, ...plan.keptEntries];
@@ -167,10 +179,12 @@ export function compactSessionEntries(entries, options = {}) {
167
179
  if (!summary) {
168
180
  return { compacted: false };
169
181
  }
182
+ const oldMessages = entriesToMessages(plan.oldEntries);
183
+ const summaryWithFiles = appendFileBlocks(summary, collectCompactionFileOps(oldMessages.filter((message) => !isCompactionSummaryMessage(message)), oldMessages.filter(isCompactionSummaryMessage)));
170
184
  return {
171
185
  compacted: true,
172
- summary,
173
- entries: buildCompactedEntries(entries, plan, summary),
186
+ summary: summaryWithFiles,
187
+ entries: buildCompactedEntries(entries, plan, summaryWithFiles),
174
188
  droppedEntries: plan.oldEntries.length,
175
189
  };
176
190
  }
@@ -182,6 +196,7 @@ export function compactMessages(messages, options = {}) {
182
196
  // template-grade (goal truncated to 140 chars, constant next-steps line);
183
197
  // the pinned original instruction is the durable record, not old summaries.
184
198
  const { leading, body } = splitLeadingContext(messages);
199
+ const priorSummaries = body.filter(isCompactionSummaryMessage);
185
200
  const bodyMessages = body.filter((message) => !isCompactionSummaryMessage(message));
186
201
  // Turn boundaries anchor on REAL user messages only. Projected reminders
187
202
  // and summary envelopes used to inflate the turn count, shrinking the keep
@@ -211,15 +226,16 @@ export function compactMessages(messages, options = {}) {
211
226
  if (!summary) {
212
227
  return { compacted: false };
213
228
  }
229
+ const summaryWithFiles = appendFileBlocks(summary, collectCompactionFileOps(oldMessages, priorSummaries));
214
230
  const compactedMessages = [
215
231
  ...leading.map((message) => cloneMessage(message)),
216
232
  ...(pinnedMessage ? [clonePinnedUserMessage(pinnedMessage)] : []),
217
- buildCompactionSummaryMessage(summary),
233
+ buildCompactionSummaryMessage(summaryWithFiles),
218
234
  ...keptMessages.map((message) => cloneMessage(message)),
219
235
  ];
220
236
  return {
221
237
  compacted: true,
222
- summary,
238
+ summary: summaryWithFiles,
223
239
  messages: compactedMessages,
224
240
  droppedEntries: summaryInput.length,
225
241
  };
@@ -271,6 +287,9 @@ export function compactCurrentTurnToolGroups(messages, options = {}) {
271
287
  if (!freshSummary)
272
288
  return { compacted: false };
273
289
  const summary = mergeSubturnSummaryLines(priorSubturnSummaries, freshSummary, maxSummaryItems);
290
+ // Evicted groups' file ops would otherwise vanish before any multi-turn
291
+ // compactor runs; blocks on the sub-turn summary get merged upward later.
292
+ const summaryWithFiles = appendFileBlocks(summary, collectCompactionFileOps(evictable.flatMap((g) => [g.assistant, ...g.toolResults]), priorSubturnSummaries));
274
293
  const survivingGroups = groups.filter((g) => !evictable.includes(g));
275
294
  const flatSurvivors = [];
276
295
  for (const g of survivingGroups) {
@@ -284,7 +303,7 @@ export function compactCurrentTurnToolGroups(messages, options = {}) {
284
303
  {
285
304
  role: "meta",
286
305
  kind: "subturn-compaction-summary",
287
- content: `${SUBTURN_SUMMARY_PREFIX}\n${summary}`,
306
+ content: `${SUBTURN_SUMMARY_PREFIX}\n${summaryWithFiles}`,
288
307
  },
289
308
  ...flatSurvivors,
290
309
  ];
@@ -313,7 +332,9 @@ function mergeSubturnSummaryLines(priorSummaries, freshSummary, maxItems) {
313
332
  merged.push(line);
314
333
  };
315
334
  for (const prior of priorSummaries) {
316
- for (const line of messageText(prior).split("\n")) {
335
+ // File blocks are merged structurally by collectCompactionFileOps, not as
336
+ // prose lines — strip them so paths don't leak into the line dedupe.
337
+ for (const line of stripFileBlocks(messageText(prior)).split("\n")) {
317
338
  if (line.startsWith(SUBTURN_SUMMARY_PREFIX) || line.startsWith("<bubble_internal_"))
318
339
  continue;
319
340
  push(line);
@@ -397,7 +418,12 @@ function buildMessageSummary(messages, maxSummaryItems) {
397
418
  if (messages.length === 0) {
398
419
  return "";
399
420
  }
400
- const userMessages = messages.filter((message) => message.role === "user");
421
+ // Internal blocks (goal kicks, task wakes, projected reminders) must not
422
+ // become the Goal/Progress lines: 140-char truncation would turn them into
423
+ // markup FRAGMENTS, which defeat the anchored display filters downstream.
424
+ const userMessages = messages.filter((message) => message.role === "user"
425
+ && typeof message.content === "string"
426
+ && !isInternalBlockContent(message.content));
401
427
  const assistantMessages = messages.filter((message) => message.role === "assistant");
402
428
  const toolCalls = collectToolCalls(messages);
403
429
  const relevantFiles = collectRelevantFiles(toolCalls);
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Cumulative file-operation tracking across compactions.
3
+ *
4
+ * The LLM summary handles semantics (progress, decisions, next steps); which
5
+ * files were touched is an enumerable fact the model reliably drops after a
6
+ * couple of rolling summaries. So it never goes through the model: at each
7
+ * compaction, deterministic code extracts file ops from the evicted messages,
8
+ * unions them with the lists the previous summary carried, and appends the
9
+ * result as structured blocks after the summary text.
10
+ *
11
+ * Robustness rules (each earned by a review finding):
12
+ * - appendFileBlocks sanitizes its input first, so a summary can never carry
13
+ * two generations of blocks — or model-echoed tags — at once.
14
+ * - Only tool calls with a successful result count: a rejected or failed edit
15
+ * must not report the file as modified.
16
+ * - Lists are capped; modified entries are the ones worth keeping complete.
17
+ * - Serialization is sorted and deduped so identical sets are identical bytes.
18
+ */
19
+ import type { Message } from "../types.js";
20
+ export interface CompactionFileOps {
21
+ read: string[];
22
+ modified: string[];
23
+ }
24
+ /**
25
+ * File ops mentioned by tool calls in the given (about-to-be-evicted)
26
+ * messages. A call only counts when its tool result is present and not an
27
+ * error — a rejected edit or failed read must not enter the record.
28
+ */
29
+ export declare function extractFileOps(messages: Message[]): CompactionFileOps;
30
+ /** Parse every block a previous compaction summary carried (empty when absent). */
31
+ export declare function parseFileBlocks(text: string): CompactionFileOps;
32
+ /**
33
+ * Remove all blocks AND stray tags, e.g. before feeding a prior summary back
34
+ * to the model. Whitespace is only reflowed when something was removed.
35
+ */
36
+ export declare function stripFileBlocks(text: string): string;
37
+ /** Union, deduped and sorted; a file that was ever modified never lists as read-only. */
38
+ export declare function mergeFileOps(...ops: CompactionFileOps[]): CompactionFileOps;
39
+ /**
40
+ * Append the blocks to a summary text. Sanitizes the summary first so the
41
+ * output always contains exactly one generation of blocks, even when the
42
+ * model echoed tags into its own prose. No-op when both lists are empty.
43
+ */
44
+ export declare function appendFileBlocks(summary: string, ops: CompactionFileOps): string;
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Cumulative file-operation tracking across compactions.
3
+ *
4
+ * The LLM summary handles semantics (progress, decisions, next steps); which
5
+ * files were touched is an enumerable fact the model reliably drops after a
6
+ * couple of rolling summaries. So it never goes through the model: at each
7
+ * compaction, deterministic code extracts file ops from the evicted messages,
8
+ * unions them with the lists the previous summary carried, and appends the
9
+ * result as structured blocks after the summary text.
10
+ *
11
+ * Robustness rules (each earned by a review finding):
12
+ * - appendFileBlocks sanitizes its input first, so a summary can never carry
13
+ * two generations of blocks — or model-echoed tags — at once.
14
+ * - Only tool calls with a successful result count: a rejected or failed edit
15
+ * must not report the file as modified.
16
+ * - Lists are capped; modified entries are the ones worth keeping complete.
17
+ * - Serialization is sorted and deduped so identical sets are identical bytes.
18
+ */
19
+ const READ_TOOLS = new Set(["read"]);
20
+ const MODIFY_TOOLS = new Set(["edit", "write"]);
21
+ /** Cap per list. Modified is the higher-value list; both stay bounded. */
22
+ const MAX_FILES_PER_LIST = 200;
23
+ const MORE_MARKER = /^\(\+\d+ more\)$/;
24
+ const READ_BLOCKS = /<read-files>[\s\S]*?<\/read-files>/g;
25
+ const MODIFIED_BLOCKS = /<modified-files>[\s\S]*?<\/modified-files>/g;
26
+ /** Stray (unpaired / model-echoed) tags left after full blocks are removed. */
27
+ const STRAY_TAGS = /<\/?(?:read|modified)-files>/g;
28
+ /**
29
+ * File ops mentioned by tool calls in the given (about-to-be-evicted)
30
+ * messages. A call only counts when its tool result is present and not an
31
+ * error — a rejected edit or failed read must not enter the record.
32
+ */
33
+ export function extractFileOps(messages) {
34
+ const succeeded = new Set();
35
+ for (const message of messages) {
36
+ if (message.role === "tool" && !message.isError)
37
+ succeeded.add(message.toolCallId);
38
+ }
39
+ const read = new Set();
40
+ const modified = new Set();
41
+ for (const message of messages) {
42
+ if (message.role !== "assistant" || !message.toolCalls)
43
+ continue;
44
+ for (const toolCall of message.toolCalls) {
45
+ if (!succeeded.has(toolCall.id))
46
+ continue;
47
+ const target = READ_TOOLS.has(toolCall.name)
48
+ ? read
49
+ : MODIFY_TOOLS.has(toolCall.name)
50
+ ? modified
51
+ : undefined;
52
+ if (!target)
53
+ continue;
54
+ const path = pathFromArguments(toolCall.arguments);
55
+ if (path)
56
+ target.add(path);
57
+ }
58
+ }
59
+ return { read: [...read], modified: [...modified] };
60
+ }
61
+ /** Parse every block a previous compaction summary carried (empty when absent). */
62
+ export function parseFileBlocks(text) {
63
+ return {
64
+ read: parseBlocks(text, READ_BLOCKS, "<read-files>", "</read-files>"),
65
+ modified: parseBlocks(text, MODIFIED_BLOCKS, "<modified-files>", "</modified-files>"),
66
+ };
67
+ }
68
+ /**
69
+ * Remove all blocks AND stray tags, e.g. before feeding a prior summary back
70
+ * to the model. Whitespace is only reflowed when something was removed.
71
+ */
72
+ export function stripFileBlocks(text) {
73
+ const without = text.replace(READ_BLOCKS, "").replace(MODIFIED_BLOCKS, "").replace(STRAY_TAGS, "");
74
+ if (without === text)
75
+ return text;
76
+ return without.replace(/\n{3,}/g, "\n\n").trim();
77
+ }
78
+ /** Union, deduped and sorted; a file that was ever modified never lists as read-only. */
79
+ export function mergeFileOps(...ops) {
80
+ const read = new Set();
81
+ const modified = new Set();
82
+ for (const op of ops) {
83
+ for (const path of op.modified)
84
+ modified.add(normalizePath(path));
85
+ for (const path of op.read)
86
+ read.add(normalizePath(path));
87
+ }
88
+ for (const path of modified)
89
+ read.delete(path);
90
+ return { read: [...read].sort(), modified: [...modified].sort() };
91
+ }
92
+ /**
93
+ * Append the blocks to a summary text. Sanitizes the summary first so the
94
+ * output always contains exactly one generation of blocks, even when the
95
+ * model echoed tags into its own prose. No-op when both lists are empty.
96
+ */
97
+ export function appendFileBlocks(summary, ops) {
98
+ const clean = stripFileBlocks(summary);
99
+ const blocks = [];
100
+ if (ops.read.length > 0)
101
+ blocks.push(`<read-files>\n${capped(ops.read)}\n</read-files>`);
102
+ if (ops.modified.length > 0)
103
+ blocks.push(`<modified-files>\n${capped(ops.modified)}\n</modified-files>`);
104
+ if (blocks.length === 0)
105
+ return clean;
106
+ return `${clean.trimEnd()}\n\n${blocks.join("\n")}`;
107
+ }
108
+ function capped(paths) {
109
+ if (paths.length <= MAX_FILES_PER_LIST)
110
+ return paths.join("\n");
111
+ const kept = paths.slice(0, MAX_FILES_PER_LIST);
112
+ return `${kept.join("\n")}\n(+${paths.length - MAX_FILES_PER_LIST} more)`;
113
+ }
114
+ function parseBlocks(text, pattern, open, close) {
115
+ const out = new Set();
116
+ for (const match of text.match(pattern) ?? []) {
117
+ const inner = match.slice(open.length, match.length - close.length);
118
+ for (const line of inner.split("\n")) {
119
+ const trimmed = line.trim();
120
+ if (trimmed && !MORE_MARKER.test(trimmed))
121
+ out.add(trimmed);
122
+ }
123
+ }
124
+ return [...out];
125
+ }
126
+ /**
127
+ * Best-effort normalization so "./src/a.ts" and "src/a.ts" dedupe. Relative vs
128
+ * absolute spellings of the same file can still coexist — resolving that needs
129
+ * a cwd this layer doesn't have; models are consistent enough within a session
130
+ * that the union stays useful.
131
+ */
132
+ function normalizePath(path) {
133
+ let out = path.trim().replace(/\/{2,}/g, "/");
134
+ while (out.startsWith("./"))
135
+ out = out.slice(2);
136
+ return out;
137
+ }
138
+ function pathFromArguments(rawArguments) {
139
+ if (!rawArguments)
140
+ return undefined;
141
+ try {
142
+ const parsed = JSON.parse(rawArguments);
143
+ return typeof parsed.path === "string" && parsed.path.trim() ? parsed.path.trim() : undefined;
144
+ }
145
+ catch {
146
+ return undefined;
147
+ }
148
+ }
@@ -9,8 +9,10 @@
9
9
  //
10
10
  // Failure modes are explicit: returns { compacted: false, reason } so the
11
11
  // caller can fall back to algorithmic compaction without an exception.
12
+ import { sanitizeInternalReminderBlocks } from "../agent/internal-reminder-sanitizer.js";
12
13
  import { estimateContextTokens } from "./budget.js";
13
- import { buildCompactionSummaryMessage, clonePinnedUserMessage, findFirstRealUserIndex, isCompactionSummaryMessage, isRealUserMessage, splitLeadingContext, } from "./compact.js";
14
+ import { appendFileBlocks, stripFileBlocks } from "./compaction-files.js";
15
+ import { collectCompactionFileOps, messageText, buildCompactionSummaryMessage, clonePinnedUserMessage, findFirstRealUserIndex, isCompactionSummaryMessage, isRealUserMessage, splitLeadingContext, } from "./compact.js";
14
16
  export const LLM_COMPACTION_PROMPT = `You are performing a CONTEXT CHECKPOINT COMPACTION. Create a handoff summary for another LLM that will resume the task.
15
17
 
16
18
  Include:
@@ -81,15 +83,21 @@ export async function compactWithLLM(messages, options) {
81
83
  const keptGroups = groups.slice(groups.length - keptGroupCount);
82
84
  // What we'll send to the model to summarize: prior summaries (semantic
83
85
  // rolling), prior turns, and the older groups in the current turn. The
84
- // pinned first instruction is excluded — it survives verbatim.
86
+ // pinned first instruction is excluded — it survives verbatim. File blocks
87
+ // are stripped from prior summaries here: the deterministic merge below owns
88
+ // the file lists, and feeding them to the model only invites a lossy echo.
85
89
  const toSummarize = [
86
90
  ...priorSummaries.map((m) => ({
87
91
  role: "user",
88
- content: `[Prior compaction summary]\n${typeof m.content === "string" ? m.content : ""}`,
92
+ content: `[Prior compaction summary]\n${stripFileBlocks(messageText(m))}`,
89
93
  })),
90
94
  ...summarizablePriorTurns,
91
95
  ...evictedGroups.flatMap((g) => [g.assistant, ...g.toolResults]),
92
96
  ];
97
+ // Cumulative file tracking (deterministic, never via the model): union the
98
+ // lists the prior summaries carried with the ops in what we're evicting now.
99
+ // Tool results ride along so failed/rejected calls don't count as touches.
100
+ const fileOps = collectCompactionFileOps([...summarizablePriorTurns, ...evictedGroups.flatMap((g) => [g.assistant, ...g.toolResults])], priorSummaries);
93
101
  if (toSummarize.length === 0) {
94
102
  return { compacted: false, reason: "nothing to evict" };
95
103
  }
@@ -113,6 +121,14 @@ export async function compactWithLLM(messages, options) {
113
121
  if (!summaryText || summaryText.trim().length === 0) {
114
122
  return { compacted: false, reason: "compactor returned empty summary" };
115
123
  }
124
+ // The summarizer's input transcript can contain projected reminder blocks
125
+ // (resident history holds them after pruned-mode projection), and models
126
+ // quote their input. This summary is persisted via onCompactionApplied, so
127
+ // scrub markup before it can reach the session file.
128
+ summaryText = sanitizeInternalReminderBlocks(summaryText).trim();
129
+ if (!summaryText) {
130
+ return { compacted: false, reason: "compactor returned only internal markup" };
131
+ }
116
132
  // New history shape (prefix-cache-friendly: preserved system+meta stay at the
117
133
  // absolute prefix unchanged; summary is injected after as a user-role envelope
118
134
  // so it can't pollute the cacheable system-prompt prefix):
@@ -130,13 +146,16 @@ export async function compactWithLLM(messages, options) {
130
146
  const compacted = [
131
147
  ...leading.map(cloneMessage),
132
148
  ...(pinnedFirstUser ? [clonePinnedUserMessage(pinnedFirstUser)] : []),
133
- buildCompactionSummaryMessage(`${LLM_SUMMARY_PREFIX}\n${summaryText.trim()}`),
149
+ buildCompactionSummaryMessage(appendFileBlocks(`${LLM_SUMMARY_PREFIX}\n${summaryText}`, fileOps)),
134
150
  cloneMessage(lastUser),
135
151
  ...flatKept,
136
152
  ];
137
153
  return {
138
154
  compacted: true,
139
- summary: summaryText,
155
+ // Same payload the resident message carries (minus the envelope prefix —
156
+ // session replay adds its own "Previous conversation summary:" header), so
157
+ // the persisted checkpoint matches the in-memory state, file blocks included.
158
+ summary: appendFileBlocks(summaryText, fileOps),
140
159
  messages: compacted,
141
160
  };
142
161
  }
@@ -135,6 +135,9 @@ export class RunDriver {
135
135
  recordMemoryCitations(session.cwd, message.content);
136
136
  }
137
137
  },
138
+ onCompactionApplied: (summary) => {
139
+ session.manager.applyLLMCompaction(summary);
140
+ },
138
141
  onToolResult: (toolName, result) => {
139
142
  if (toolName !== "skill" || result.isError)
140
143
  return;
@@ -91,15 +91,19 @@ export async function serveFeishu(opts = {}) {
91
91
  if (mcpLoaded.servers.length > 0) {
92
92
  await mcpManager.start();
93
93
  }
94
- const createProvider = (providerId, apiKey, baseURL, promptCacheKey) => createProviderInstance({
95
- providerId,
96
- apiKey,
97
- baseURL,
98
- promptCacheKey,
99
- protocol: providerRegistry.getConfigured().find((provider) => provider.id === providerId)?.protocol,
100
- openAICodexAuth: providerRegistry.createOpenAICodexAuthAdapter(providerId),
101
- grokAuth: providerRegistry.createGrokAuthAdapter(providerId),
102
- });
94
+ const createProvider = (providerId, apiKey, baseURL, promptCacheKey) => {
95
+ const profile = providerRegistry.getConfigured().find((provider) => provider.id === providerId);
96
+ return createProviderInstance({
97
+ providerId,
98
+ apiKey,
99
+ baseURL,
100
+ promptCacheKey,
101
+ protocol: profile?.protocol,
102
+ headers: profile?.headers,
103
+ openAICodexAuth: providerRegistry.createOpenAICodexAuthAdapter(providerId),
104
+ grokAuth: providerRegistry.createGrokAuthAdapter(providerId),
105
+ });
106
+ };
103
107
  const createProviderForRoute = async (route, promptCacheKey) => {
104
108
  const target = providerRegistry.getConfigured().find((p) => p.id === route.providerId);
105
109
  if (!target?.apiKey) {
@@ -8,8 +8,8 @@
8
8
  * goal turn's prompt, so a get_goal call could only return what the model was
9
9
  * just told.
10
10
  */
11
- const UPDATE_GOAL_DESCRIPTION = `Update the active thread goal's status. Use this tool only to mark the goal achieved or genuinely blocked; it returns an error if there is no active goal.
12
- Set status to "complete" only when the objective has actually been achieved and no required work remains — never merely because the budget is nearly exhausted or you are stopping.
11
+ const UPDATE_GOAL_DESCRIPTION = `Errors unless the user has set an active goal via /goal — do NOT call this to wrap up an ordinary turn; most sessions have no goal.
12
+ Update the active thread goal's status. Set status to "complete" only when the objective has actually been achieved and no required work remains — never merely because the budget is nearly exhausted or you are stopping.
13
13
  Set status to "blocked" only when the same blocking condition has repeated for at least three consecutive goal turns (counting the original turn and automatic continuations) and you cannot make meaningful progress without user input or an external-state change. Do not use "blocked" because work is hard, slow, uncertain, or incomplete.
14
14
  You cannot pause, resume, or set a budget through this tool; those are controlled by the user.`;
15
15
  export function createGoalTools(store) {
@@ -29,7 +29,16 @@ export function createGoalTools(store) {
29
29
  additionalProperties: false,
30
30
  },
31
31
  effect: "unknown",
32
- promptSnippet: "Mark the goal complete (objective achieved) or blocked (true impasse).",
32
+ // Goalless sessions must not see this tool AT ALL: a completion-marker
33
+ // in the function list gets spuriously called at turn end even by strong
34
+ // models at high effort (observed with gpt-5.6-sol) — wording does not
35
+ // beat the trained close-out-the-task prior. The enabled() gate removes
36
+ // it from the provider's tool list until a goal is active (re-evaluated
37
+ // every model call, so /goal mid-session takes effect immediately), and
38
+ // there is deliberately no promptSnippet since the static system prompt
39
+ // cannot track goal state. Goal turns inject full usage guidance
40
+ // separately (goal/prompts.ts), so the legitimate path loses nothing.
41
+ enabled: () => store.isActive(),
33
42
  async execute(args) {
34
43
  const goal = store.snapshot();
35
44
  if (!goal)
package/dist/main.js CHANGED
@@ -94,20 +94,25 @@ async function main() {
94
94
  thinkingLevel: args.thinkingLevel,
95
95
  promptCacheKey: sessionPromptCacheKey,
96
96
  protocol: defaultProvider.protocol,
97
+ headers: defaultProvider.headers,
97
98
  openAICodexAuth: registry.createOpenAICodexAuthAdapter(defaultProvider.id),
98
99
  grokAuth: registry.createGrokAuthAdapter(defaultProvider.id),
99
100
  })
100
101
  : createUnavailableProvider(unavailableProviderMessage);
101
- const createProvider = (providerId, apiKey, baseURL) => createProviderInstance({
102
- providerId,
103
- apiKey,
104
- baseURL,
105
- thinkingLevel: args.thinkingLevel,
106
- promptCacheKey: sessionPromptCacheKey,
107
- protocol: registry.getConfigured().find((provider) => provider.id === providerId)?.protocol,
108
- openAICodexAuth: registry.createOpenAICodexAuthAdapter(providerId),
109
- grokAuth: registry.createGrokAuthAdapter(providerId),
110
- });
102
+ const createProvider = (providerId, apiKey, baseURL) => {
103
+ const profile = registry.getConfigured().find((provider) => provider.id === providerId);
104
+ return createProviderInstance({
105
+ providerId,
106
+ apiKey,
107
+ baseURL,
108
+ thinkingLevel: args.thinkingLevel,
109
+ promptCacheKey: sessionPromptCacheKey,
110
+ protocol: profile?.protocol,
111
+ headers: profile?.headers,
112
+ openAICodexAuth: registry.createOpenAICodexAuthAdapter(providerId),
113
+ grokAuth: registry.createGrokAuthAdapter(providerId),
114
+ });
115
+ };
111
116
  const createProviderForRoute = async (route) => {
112
117
  const providerId = route.providerId;
113
118
  if (!providerId) {
@@ -196,18 +201,13 @@ async function main() {
196
201
  for (const d of mcpLoaded.diagnostics) {
197
202
  console.error(chalk.yellow(`[mcp:${d.scope}] ${d.path}: ${d.message}`));
198
203
  }
199
- const mcpManager = new McpManager({ servers: mcpLoaded.servers });
204
+ // Silent diagnostics: connect failures are captured per-server and shown by
205
+ // /mcp on demand. Printing them at startup (or mid-session, where stderr
206
+ // writes corrupt the Ink display) only duplicates that surface.
207
+ const mcpManager = new McpManager({ servers: mcpLoaded.servers, onDiagnostic: () => { } });
200
208
  let externalRuntime;
201
209
  if (mcpLoaded.servers.length > 0) {
202
210
  await mcpManager.start();
203
- // Only surface failures at startup. Successful connections would push the
204
- // welcome screen above the visible area on small terminals. /mcp shows the
205
- // full status.
206
- for (const state of mcpManager.getStates()) {
207
- if (state.status.kind === "failed") {
208
- console.error(chalk.yellow(`[mcp] ${state.name}: failed — ${state.status.error}`));
209
- }
210
- }
211
211
  tools.push(...mcpManager.getToolEntries());
212
212
  }
213
213
  // Expose MCP prompts as slash commands. Queried live at each lookup so
@@ -420,6 +420,11 @@ async function main() {
420
420
  recordMemoryCitations(args.cwd, message.content);
421
421
  }
422
422
  },
423
+ // Auto-compaction summaries are meta messages (dropped above); persist the
424
+ // compacted state as a session summary entry so it survives resume.
425
+ onCompactionApplied: (summary) => {
426
+ sessionManager?.applyLLMCompaction(summary);
427
+ },
423
428
  onToolResult: (toolName, result) => {
424
429
  if (!sessionManager)
425
430
  return;