@inflexa-ai/harness 0.39.2 → 0.40.0

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 (184) hide show
  1. package/README.md +2 -3
  2. package/dist/agents/conversation-agent.d.ts +3 -0
  3. package/dist/agents/conversation-agent.js +13 -2
  4. package/dist/agents/report-session-agent.d.ts +3 -0
  5. package/dist/agents/report-session-agent.js +2 -0
  6. package/dist/agents/sandbox/cheminformatics-agent.js +0 -3
  7. package/dist/agents/sandbox/shared.d.ts +11 -2
  8. package/dist/agents/sandbox/shared.js +4 -0
  9. package/dist/app/chat-turn.d.ts +75 -12
  10. package/dist/app/chat-turn.js +149 -21
  11. package/dist/app/message-assembly.d.ts +16 -52
  12. package/dist/app/message-assembly.js +41 -68
  13. package/dist/app/run-activity.d.ts +4 -6
  14. package/dist/app/run-activity.js +7 -27
  15. package/dist/app/spawn-report-session.d.ts +6 -11
  16. package/dist/app/spawn-report-session.js +5 -6
  17. package/dist/app/synthesize-run.d.ts +3 -0
  18. package/dist/app/synthesize-run.js +1 -0
  19. package/dist/billing/usage-recorder.d.ts +3 -4
  20. package/dist/billing/usage-recorder.js +3 -4
  21. package/dist/contracts/chat-parts.d.ts +11 -1
  22. package/dist/contracts/chat-parts.js +1 -0
  23. package/dist/contracts/index.d.ts +1 -1
  24. package/dist/contracts/part-registry.js +1 -0
  25. package/dist/contracts/report-blocks.d.ts +8 -8
  26. package/dist/contracts/schemas/chat-parts.d.ts +32 -9
  27. package/dist/contracts/schemas/chat-parts.js +10 -0
  28. package/dist/execution/artifact-metadata.d.ts +26 -33
  29. package/dist/execution/artifact-metadata.js +44 -106
  30. package/dist/execution/post-step-pipeline.d.ts +23 -16
  31. package/dist/execution/post-step-pipeline.js +19 -21
  32. package/dist/execution/run-synthesis.d.ts +3 -0
  33. package/dist/execution/run-synthesis.js +11 -1
  34. package/dist/execution/step-summary.d.ts +21 -30
  35. package/dist/execution/step-summary.js +30 -57
  36. package/dist/index.d.ts +9 -8
  37. package/dist/index.js +15 -34
  38. package/dist/lib/zod-issues.js +2 -4
  39. package/dist/loop/compaction.d.ts +20 -0
  40. package/dist/loop/compaction.js +2 -0
  41. package/dist/loop/continue-agent.d.ts +40 -0
  42. package/dist/loop/continue-agent.js +38 -0
  43. package/dist/loop/metrics.d.ts +30 -22
  44. package/dist/loop/metrics.js +51 -22
  45. package/dist/loop/run-agent.d.ts +111 -13
  46. package/dist/loop/run-agent.js +467 -220
  47. package/dist/loop/run-to-terminal.d.ts +16 -17
  48. package/dist/loop/run-to-terminal.js +24 -35
  49. package/dist/loop/tool-mask.d.ts +23 -0
  50. package/dist/loop/tool-mask.js +46 -0
  51. package/dist/loop/tool-output.d.ts +48 -0
  52. package/dist/loop/tool-output.js +79 -0
  53. package/dist/loop/types.d.ts +6 -3
  54. package/dist/memory/ai-sdk-message-storage.d.ts +49 -2
  55. package/dist/memory/ai-sdk-message-storage.js +78 -2
  56. package/dist/memory/conversation-display-recorder.d.ts +6 -0
  57. package/dist/memory/conversation-display-recorder.js +64 -22
  58. package/dist/memory/conversation-display-replay.d.ts +5 -14
  59. package/dist/memory/conversation-display-replay.js +66 -32
  60. package/dist/memory/conversation-display-storage.d.ts +2 -1
  61. package/dist/memory/conversation-display-storage.js +2 -1
  62. package/dist/memory/conversation-view.d.ts +53 -0
  63. package/dist/memory/conversation-view.js +130 -0
  64. package/dist/memory/count-tokens.d.ts +5 -7
  65. package/dist/memory/count-tokens.js +7 -9
  66. package/dist/memory/thread-history.d.ts +74 -102
  67. package/dist/memory/thread-history.js +181 -246
  68. package/dist/memory/thread-store.d.ts +3 -3
  69. package/dist/memory/thread-store.js +7 -2
  70. package/dist/memory/tool-call-integrity.d.ts +15 -21
  71. package/dist/memory/tool-call-integrity.js +31 -38
  72. package/dist/memory/working-memory.d.ts +2 -5
  73. package/dist/memory/working-memory.js +2 -5
  74. package/dist/prompts/SOUL.d.ts +2 -2
  75. package/dist/prompts/SOUL.js +2 -7
  76. package/dist/prompts/analogical-reasoner.d.ts +1 -1
  77. package/dist/prompts/analogical-reasoner.js +16 -32
  78. package/dist/prompts/briefing.js +1 -1
  79. package/dist/prompts/compaction.d.ts +4 -0
  80. package/dist/prompts/compaction.js +24 -0
  81. package/dist/prompts/conversation.d.ts +1 -1
  82. package/dist/prompts/conversation.js +26 -35
  83. package/dist/prompts/execute-analysis/step-summary.js +7 -5
  84. package/dist/prompts/literature-reviewer.d.ts +1 -1
  85. package/dist/prompts/literature-reviewer.js +29 -34
  86. package/dist/prompts/planner.js +15 -29
  87. package/dist/prompts/report-session.d.ts +1 -1
  88. package/dist/prompts/report-session.js +2 -33
  89. package/dist/prompts/sandbox/bulk-transcriptomics-agent.d.ts +1 -1
  90. package/dist/prompts/sandbox/bulk-transcriptomics-agent.js +1 -2
  91. package/dist/prompts/sandbox/cheminformatics-agent.d.ts +1 -1
  92. package/dist/prompts/sandbox/cheminformatics-agent.js +10 -29
  93. package/dist/prompts/sandbox/chromatin-agent.d.ts +1 -1
  94. package/dist/prompts/sandbox/chromatin-agent.js +1 -2
  95. package/dist/prompts/sandbox/data-profiler.js +6 -3
  96. package/dist/prompts/sandbox/dna-methylation-agent.d.ts +1 -1
  97. package/dist/prompts/sandbox/dna-methylation-agent.js +2 -7
  98. package/dist/prompts/sandbox/drug-repurposing-agent.d.ts +1 -1
  99. package/dist/prompts/sandbox/drug-repurposing-agent.js +20 -24
  100. package/dist/prompts/sandbox/enrichment-agent.d.ts +1 -1
  101. package/dist/prompts/sandbox/enrichment-agent.js +3 -2
  102. package/dist/prompts/sandbox/genomic-variant-agent.d.ts +1 -1
  103. package/dist/prompts/sandbox/genomic-variant-agent.js +0 -2
  104. package/dist/prompts/sandbox/immune-profiling-agent.d.ts +1 -1
  105. package/dist/prompts/sandbox/immune-profiling-agent.js +7 -14
  106. package/dist/prompts/sandbox/metabolomics-agent.d.ts +1 -1
  107. package/dist/prompts/sandbox/metabolomics-agent.js +0 -2
  108. package/dist/prompts/sandbox/microbiome-agent.d.ts +1 -1
  109. package/dist/prompts/sandbox/microbiome-agent.js +14 -21
  110. package/dist/prompts/sandbox/multi-omics-integration-agent.d.ts +1 -1
  111. package/dist/prompts/sandbox/multi-omics-integration-agent.js +9 -19
  112. package/dist/prompts/sandbox/multimodal-sc-agent.d.ts +1 -1
  113. package/dist/prompts/sandbox/multimodal-sc-agent.js +4 -5
  114. package/dist/prompts/sandbox/network-agent.d.ts +1 -1
  115. package/dist/prompts/sandbox/network-agent.js +7 -8
  116. package/dist/prompts/sandbox/pkpd-clinical-response-agent.d.ts +1 -1
  117. package/dist/prompts/sandbox/pkpd-clinical-response-agent.js +19 -19
  118. package/dist/prompts/sandbox/proteomics-agent.d.ts +1 -1
  119. package/dist/prompts/sandbox/proteomics-agent.js +3 -4
  120. package/dist/prompts/sandbox/scientific-executor.d.ts +1 -1
  121. package/dist/prompts/sandbox/scientific-executor.js +7 -10
  122. package/dist/prompts/sandbox/single-cell-agent.d.ts +1 -1
  123. package/dist/prompts/sandbox/single-cell-agent.js +2 -4
  124. package/dist/prompts/sandbox/spatial-omics-agent.d.ts +1 -1
  125. package/dist/prompts/sandbox/spatial-omics-agent.js +3 -5
  126. package/dist/prompts/sandbox/statistical-modeling-agent.d.ts +1 -1
  127. package/dist/prompts/sandbox/statistical-modeling-agent.js +1 -1
  128. package/dist/prompts/sandbox/translational-safety-agent.d.ts +1 -1
  129. package/dist/prompts/sandbox/translational-safety-agent.js +10 -19
  130. package/dist/prompts/sandbox-standards.d.ts +7 -7
  131. package/dist/prompts/sandbox-standards.js +74 -76
  132. package/dist/prompts/synthesis-agent.d.ts +1 -1
  133. package/dist/prompts/synthesis-agent.js +16 -42
  134. package/dist/providers/ai-sdk.d.ts +39 -6
  135. package/dist/providers/ai-sdk.js +117 -18
  136. package/dist/providers/errors.d.ts +2 -0
  137. package/dist/providers/errors.js +5 -0
  138. package/dist/providers/prompt-cache.d.ts +43 -41
  139. package/dist/providers/prompt-cache.js +47 -41
  140. package/dist/providers/reasoning.d.ts +10 -21
  141. package/dist/providers/reasoning.js +10 -21
  142. package/dist/providers/types.d.ts +21 -5
  143. package/dist/report-model/authoring-grammar.d.ts +2 -2
  144. package/dist/runtime/assemble.d.ts +9 -7
  145. package/dist/runtime/assemble.js +7 -3
  146. package/dist/sandbox/create-sandbox.d.ts +2 -2
  147. package/dist/sandbox/submit-exec.d.ts +1 -1
  148. package/dist/schemas/artifact-manifest.d.ts +4 -4
  149. package/dist/state/init.js +39 -4
  150. package/dist/state/purge-analysis.js +4 -0
  151. package/dist/state/tool-outputs.d.ts +4 -0
  152. package/dist/state/tool-outputs.js +30 -0
  153. package/dist/tasks/data-profile.d.ts +3 -0
  154. package/dist/tasks/data-profile.js +2 -0
  155. package/dist/tools/ad-hoc-router.d.ts +8 -0
  156. package/dist/tools/ad-hoc-router.js +21 -2
  157. package/dist/tools/display/show-user.d.ts +1 -1
  158. package/dist/tools/execute-analysis.d.ts +3 -0
  159. package/dist/tools/execute-analysis.js +9 -1
  160. package/dist/tools/read-tool-output.d.ts +7 -0
  161. package/dist/tools/read-tool-output.js +130 -0
  162. package/dist/tools/research/generate-analogy-report.d.ts +10 -26
  163. package/dist/tools/research/generate-analogy-report.js +76 -163
  164. package/dist/tools/research/generate-plan.d.ts +3 -0
  165. package/dist/tools/research/generate-plan.js +4 -1
  166. package/dist/tools/research/literature-reviewer.d.ts +3 -0
  167. package/dist/tools/research/literature-reviewer.js +3 -0
  168. package/dist/tools/sandbox/report-blocker.d.ts +18 -5
  169. package/dist/tools/sandbox/report-blocker.js +54 -6
  170. package/dist/tools/sandbox/submit-file-metadata.d.ts +30 -0
  171. package/dist/tools/sandbox/submit-file-metadata.js +67 -0
  172. package/dist/tools/start-report-session.d.ts +1 -3
  173. package/dist/tools/start-report-session.js +0 -2
  174. package/dist/tools/workspace/execute-command.js +7 -4
  175. package/dist/tools/workspace/result-bounds.d.ts +4 -4
  176. package/dist/tools/workspace/result-bounds.js +6 -5
  177. package/dist/tools/workspace/workspace-search.d.ts +1 -1
  178. package/dist/workflows/execute-analysis.d.ts +3 -0
  179. package/dist/workflows/execute-analysis.js +1 -0
  180. package/dist/workflows/sandbox-step.d.ts +30 -9
  181. package/dist/workflows/sandbox-step.js +37 -8
  182. package/package.json +9 -9
  183. package/dist/providers/anthropic.d.ts +0 -25
  184. package/dist/providers/anthropic.js +0 -28
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { PART_REGISTRY } from "../contracts/part-registry.js";
3
3
  import { CortexChatPartSchema } from "../contracts/schemas/chat-parts.js";
4
+ import { compactionExchangeOf, compactionMarkerOf } from "./ai-sdk-message-storage.js";
4
5
  import { conversationDisplayPart } from "./conversation-display-storage.js";
5
6
  function jsonCopy(value) {
6
7
  return JSON.parse(JSON.stringify(value));
@@ -28,7 +29,34 @@ function dataPart(event) {
28
29
  data: jsonCopy(data),
29
30
  };
30
31
  }
32
+ /** The text of the assistant messages of a round. The text of an exchange is a summary, and no reply. */
33
+ function assistantText(messages) {
34
+ return messages
35
+ .filter((message) => message.role === "assistant" && compactionExchangeOf(message) === undefined)
36
+ .map((message) => typeof message.content === "string"
37
+ ? message.content
38
+ : message.content
39
+ .filter((part) => part.type === "text")
40
+ .map((part) => part.text)
41
+ .join(""))
42
+ .join("");
43
+ }
44
+ /** The divider of a compaction marker: a `system` message with the part at the final status of the compaction. */
45
+ function compactionDivider(marker) {
46
+ const part = conversationDisplayPart({
47
+ type: "data-compaction",
48
+ id: marker.id,
49
+ status: marker.kind === "summary" ? "done" : "failed",
50
+ tokensBefore: marker.tokensBefore,
51
+ tokensAfter: marker.tokensAfter,
52
+ durationMs: marker.durationMs,
53
+ });
54
+ return { id: marker.id, role: "system", parts: [part] };
55
+ }
31
56
  export function createConversationDisplayRecorder(options) {
57
+ // One id for each message of the turn, thus the rounds of a turn replay as one assistant message.
58
+ const userMessageId = options.userMessageId ?? randomUUID();
59
+ let assistantMessageId = options.assistantMessageId ?? randomUUID();
32
60
  const assistantParts = [];
33
61
  // Where a reconciling part lives, keyed by `type:id`. Type-qualified because a
34
62
  // stable id is only unique within its own family — a plan and a run card may
@@ -115,36 +143,50 @@ export function createConversationDisplayRecorder(options) {
115
143
  }
116
144
  return options.sink(event);
117
145
  };
146
+ function closeParts() {
147
+ // Calls need no closing pass: one that never reached `tool-finished` still holds
148
+ // the `incomplete` it was recorded with at dispatch, which is the whole of what
149
+ // the harness observed. An approval does need one — `pending` is a question, and
150
+ // a turn that ends without answering it aborted it.
151
+ for (const part of assistantParts) {
152
+ if (part.type === "data-ask" && part.data.status === "pending")
153
+ part.data.status = "aborted";
154
+ if (part.type === "text")
155
+ part.state = "done";
156
+ }
157
+ }
158
+ function takeOpening() {
159
+ return [{ id: userMessageId, role: "user", parts: [{ type: "text", text: options.userText, state: "done" }] }];
160
+ }
161
+ function takeRound(messages) {
162
+ if (!assistantParts.some((part) => part.type === "text"))
163
+ appendText(assistantText(messages));
164
+ // Only a round that a throw ends can hold a pending approval.
165
+ closeParts();
166
+ const round = assistantParts.length === 0 ? [] : [{ id: assistantMessageId, role: "assistant", parts: jsonCopy(assistantParts) }];
167
+ // A later update of a part lands in the next round, and the replay replaces the earlier copy.
168
+ assistantParts.length = 0;
169
+ reconcileIndexes.clear();
170
+ const marker = messages.map(compactionMarkerOf).find((found) => found !== undefined);
171
+ if (marker !== undefined) {
172
+ round.push(compactionDivider(marker));
173
+ // The divider splits the turn into two assistant messages of the replay, and two messages must not share an id.
174
+ assistantMessageId = randomUUID();
175
+ }
176
+ return round;
177
+ }
118
178
  function finish(finishOptions) {
119
179
  if (!finished) {
120
180
  finished = true;
121
181
  if (!assistantParts.some((part) => part.type === "text") && finishOptions?.fallbackText) {
122
182
  appendText(finishOptions.fallbackText);
123
183
  }
124
- // Calls need no closing pass: one that never reached `tool-finished` still holds
125
- // the `incomplete` it was recorded with at dispatch, which is the whole of what
126
- // the harness observed. An approval does need one — `pending` is a question, and
127
- // a turn that ends without answering it aborted it.
128
- for (const part of assistantParts) {
129
- if (part.type === "data-ask" && part.data.status === "pending") {
130
- part.data.status = "aborted";
131
- }
132
- }
133
- for (const part of assistantParts) {
134
- if (part.type === "text")
135
- part.state = "done";
136
- }
184
+ closeParts();
137
185
  }
138
- const messages = [
139
- {
140
- id: options.userMessageId ?? randomUUID(),
141
- role: "user",
142
- parts: [{ type: "text", text: options.userText, state: "done" }],
143
- },
144
- ];
186
+ const messages = takeOpening();
145
187
  if (assistantParts.length > 0) {
146
188
  messages.push({
147
- id: options.assistantMessageId ?? randomUUID(),
189
+ id: assistantMessageId,
148
190
  role: "assistant",
149
191
  ...(finishOptions?.interrupted ? { metadata: { interrupted: true } } : {}),
150
192
  parts: jsonCopy(assistantParts),
@@ -152,5 +194,5 @@ export function createConversationDisplayRecorder(options) {
152
194
  }
153
195
  return messages;
154
196
  }
155
- return { emit, finish };
197
+ return { emit, takeOpening, takeRound, finish };
156
198
  }
@@ -1,19 +1,10 @@
1
1
  /**
2
- * The runtime transcript read: stored display projections → `CortexMessage[]`.
2
+ * The runtime transcript read: stored display projections → `CortexMessage[]`. It walks the rows in
3
+ * `seq` order, merges the rounds of a turn into one assistant message, and folds the turn record onto
4
+ * that message. It consults no tool name, no card builder, and no workspace or database.
3
5
  *
4
- * `ThreadHistory.loadAll` returns a thread's rows in `seq` order, and every
5
- * append's first row carries the display projection for that append. Replay is
6
- * therefore a concatenation: walk the rows, emit each envelope's messages in
7
- * order, done. No turn grouping, no tool-name recognition, no card rebuilding,
8
- * no filesystem or database lookup — nothing that could render a reloaded
9
- * conversation differently from the one the user was shown.
10
- *
11
- * A row with no envelope predates the display projection and is skipped — the
12
- * only handling there is or will be. Reconstructing one from the model
13
- * transcript instead is what the deleted migration renderer did, and rebuilding
14
- * a display from a transcript that never carried one makes every later change to
15
- * a tool or a card silently rewrite history. Skipping keeps the gap observable
16
- * and the read total in what it consults: the stored projection, nothing else.
6
+ * A row with no envelope predates the display projection and is skipped. A display rebuilt from the
7
+ * model transcript would let each later change of a tool or a card rewrite history.
17
8
  */
18
9
  import type { CortexMessage } from "../contracts/message.js";
19
10
  import type { StoredMessage } from "./thread-history.js";
@@ -1,27 +1,56 @@
1
1
  /**
2
- * The runtime transcript read: stored display projections → `CortexMessage[]`.
2
+ * The runtime transcript read: stored display projections → `CortexMessage[]`. It walks the rows in
3
+ * `seq` order, merges the rounds of a turn into one assistant message, and folds the turn record onto
4
+ * that message. It consults no tool name, no card builder, and no workspace or database.
3
5
  *
4
- * `ThreadHistory.loadAll` returns a thread's rows in `seq` order, and every
5
- * append's first row carries the display projection for that append. Replay is
6
- * therefore a concatenation: walk the rows, emit each envelope's messages in
7
- * order, done. No turn grouping, no tool-name recognition, no card rebuilding,
8
- * no filesystem or database lookup — nothing that could render a reloaded
9
- * conversation differently from the one the user was shown.
10
- *
11
- * A row with no envelope predates the display projection and is skipped — the
12
- * only handling there is or will be. Reconstructing one from the model
13
- * transcript instead is what the deleted migration renderer did, and rebuilding
14
- * a display from a transcript that never carried one makes every later change to
15
- * a tool or a card silently rewrite history. Skipping keeps the gap observable
16
- * and the read total in what it consults: the stored projection, nothing else.
6
+ * A row with no envelope predates the display projection and is skipped. A display rebuilt from the
7
+ * model transcript would let each later change of a tool or a card rewrite history.
17
8
  */
9
+ import { isReconciling } from "../contracts/part-registry.js";
10
+ import { isSyntheticUserMessage } from "./ai-sdk-message-storage.js";
18
11
  import { conversationUIToCortexMessages } from "./conversation-display-storage.js";
12
+ /** The key that a part reconciles on, or `undefined` for a part that each round adds again. */
13
+ function reconcileKey(part) {
14
+ if (part.type === "tool-call")
15
+ return `tool-call:${part.toolCallId}`;
16
+ if (part.type === "text" || !isReconciling(part.type))
17
+ return undefined;
18
+ return "id" in part && typeof part.id === "string" ? `${part.type}:${part.id}` : undefined;
19
+ }
20
+ /** Add the parts of a later round to the assistant message of its turn. */
21
+ function mergeRound(target, round) {
22
+ for (const part of round.parts) {
23
+ const key = reconcileKey(part);
24
+ const earlier = key === undefined ? -1 : target.parts.findIndex((candidate) => reconcileKey(candidate) === key);
25
+ if (earlier >= 0)
26
+ target.parts[earlier] = part;
27
+ else
28
+ target.parts.push(part);
29
+ }
30
+ }
31
+ function foldTurnRecord(message, record) {
32
+ if (message === undefined || record === undefined)
33
+ return;
34
+ if (record.usage)
35
+ message.usage = record.usage;
36
+ if (record.durationMs !== undefined)
37
+ message.durationMs = record.durationMs;
38
+ if (record.status === "aborted")
39
+ message.interrupted = true;
40
+ }
19
41
  export function storedMessagesToCortex(messages) {
20
42
  const out = [];
21
- // The messages the append currently being walked produced. A row bearing an
22
- // envelope opens one; every row after it belongs to it until the next does.
23
- let append = null;
43
+ // The last message of the group being walked, as `out` holds it.
44
+ let groupLast;
45
+ // The record of the turn being walked, and the last assistant message of that turn.
46
+ let turnRecord;
47
+ let turnAssistant;
24
48
  for (const row of messages) {
49
+ if (row.message.role === "user" && !isSyntheticUserMessage(row.message)) {
50
+ foldTurnRecord(turnAssistant, turnRecord);
51
+ turnRecord = row.turn;
52
+ turnAssistant = undefined;
53
+ }
25
54
  if (row.displayEnvelope) {
26
55
  // The author and the creation time are facts about the ROW, the same as
27
56
  // the rollup below, and the projection holds neither. Both ride the row
@@ -32,29 +61,34 @@ export function storedMessagesToCortex(messages) {
32
61
  // does not survive the JSON crossing typed, hence the ISO string here.
33
62
  const createdAt = row.createdAt === undefined ? {} : { createdAt: row.createdAt.toISOString() };
34
63
  const author = row.author === undefined ? {} : { author: row.author };
35
- append = conversationUIToCortexMessages(row.displayEnvelope.messages).map((message) => ({
36
- ...message,
37
- ...createdAt,
38
- ...(message.role === "user" ? author : {}),
39
- }));
40
- out.push(...append);
64
+ groupLast = undefined;
65
+ for (const message of conversationUIToCortexMessages(row.displayEnvelope.messages)) {
66
+ const stamped = { ...message, ...createdAt, ...(message.role === "user" ? author : {}) };
67
+ const previous = out.at(-1);
68
+ // The merged message keeps the creation time of its first round.
69
+ if (stamped.role === "assistant" && previous?.role === "assistant" && previous.id === stamped.id)
70
+ mergeRound(previous, stamped);
71
+ else
72
+ out.push(stamped);
73
+ groupLast = out.at(-1);
74
+ if (groupLast?.role === "assistant")
75
+ turnAssistant = groupLast;
76
+ }
41
77
  }
42
- // The reported rollup of the turn and its duration both ride the model row that
78
+ // The reported rollup of an older turn and its duration both ride the model row that
43
79
  // ENDED the turn, and not the display projection. Each one is a fact about what the
44
80
  // turn cost, and not about what the turn showed. A write in both places would let
45
81
  // the two disagree. Thus the replay folds them onto the assistant reply that a
46
82
  // reader ties to the figures. The duration reads against `undefined`, and never
47
83
  // against falsiness: a measured zero is a figure, and an absent value alone means
48
84
  // that nobody measured the turn.
49
- if (append && (row.usage || row.durationMs !== undefined)) {
50
- const last = append.at(-1);
51
- if (last?.role === "assistant") {
52
- if (row.usage)
53
- last.usage = row.usage;
54
- if (row.durationMs !== undefined)
55
- last.durationMs = row.durationMs;
56
- }
85
+ if (groupLast?.role === "assistant" && (row.usage || row.durationMs !== undefined)) {
86
+ if (row.usage)
87
+ groupLast.usage = row.usage;
88
+ if (row.durationMs !== undefined)
89
+ groupLast.durationMs = row.durationMs;
57
90
  }
58
91
  }
92
+ foldTurnRecord(turnAssistant, turnRecord);
59
93
  return out;
60
94
  }
@@ -15,7 +15,7 @@
15
15
  * reconstruction.
16
16
  */
17
17
  import { type DataUIPart, type UIMessage } from "ai";
18
- import type { AskPart, ChildSessionStartedPart, FileReferencePart, PlanPart, PresentationPart, ReportRenderedPart, RunCardPart } from "../contracts/chat-parts.js";
18
+ import type { AskPart, ChildSessionStartedPart, CompactionPart, FileReferencePart, PlanPart, PresentationPart, ReportRenderedPart, RunCardPart } from "../contracts/chat-parts.js";
19
19
  import type { ToolCallOutcome } from "../contracts/chat-events.js";
20
20
  import type { CortexMessage, CortexPart, ToolCallPart } from "../contracts/message.js";
21
21
  import type { Logger } from "../lib/logger.js";
@@ -41,6 +41,7 @@ export type ConversationUIData = {
41
41
  ask: Payload<AskPart>;
42
42
  "child-session-started": Payload<ChildSessionStartedPart>;
43
43
  "report-rendered": Payload<ReportRenderedPart>;
44
+ compaction: Payload<CompactionPart>;
44
45
  };
45
46
  export interface ConversationDisplayMetadata {
46
47
  interrupted?: boolean;
@@ -16,7 +16,7 @@
16
16
  */
17
17
  import { validateUIMessages } from "ai";
18
18
  import { z } from "zod";
19
- import { AskPartSchema, ChildSessionStartedPartSchema, FileReferencePartSchema, PlanPartSchema, PresentationPartSchema, ReportRenderedPartSchema, RunCardPartSchema, } from "../contracts/schemas/chat-parts.js";
19
+ import { AskPartSchema, ChildSessionStartedPartSchema, CompactionPartSchema, FileReferencePartSchema, PlanPartSchema, PresentationPartSchema, ReportRenderedPartSchema, RunCardPartSchema, } from "../contracts/schemas/chat-parts.js";
20
20
  import { ToolCallOutcomeSchema } from "../contracts/schemas/chat-events.js";
21
21
  export const SUPPORTED_DISPLAY_SCHEMA_VERSION = 1;
22
22
  const DisplayEnvelopeHeaderSchema = z.object({
@@ -68,6 +68,7 @@ const dataSchemas = {
68
68
  ask: AskPartSchema.omit({ type: true }),
69
69
  "child-session-started": ChildSessionStartedPartSchema.omit({ type: true }),
70
70
  "report-rendered": ReportRenderedPartSchema.omit({ type: true }),
71
+ compaction: CompactionPartSchema.omit({ type: true }),
71
72
  };
72
73
  export function envelopeDisplayMessages(messages) {
73
74
  if (messages.length === 0)
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The view of a conversation, which the loop sends and `loadRecent` gives: the head, then the latest
3
+ * summary marker, then the body. An exchange message never joins it, and a drop marker keeps the last
4
+ * turns of the body without their reasoning. The loop and the reader call this one function, thus the
5
+ * next turn reads back the prefix that the last request sent.
6
+ */
7
+ import type { ModelMessage } from "ai";
8
+ /**
9
+ * A turn starts on a `user` message that a human actually sent. Two kinds of
10
+ * message carry the `user` role without opening a turn, and both are excluded
11
+ * here:
12
+ *
13
+ * - a tool result, which in AI SDK terms is a `tool`-role message, so a mid-turn
14
+ * tool continuation never matches in the first place;
15
+ * - a message the LOOP or the HOST synthesized (`syntheticUserMessage`) — the
16
+ * truncated-reply nudge the loop inserts mid-turn, and the record an embedder
17
+ * appends between turns for work that happened outside the conversation, such as
18
+ * an analysis run's outcome. Both carry the `user` role for the wire format and
19
+ * neither is user input. Reading one as a boundary would split one turn into
20
+ * two: a drop would keep half a turn, and `retractLastTurn` would cut
21
+ * its tail in the middle of a turn rather than at its head.
22
+ *
23
+ * A host-appended record therefore belongs to the turn preceding it, so a tail
24
+ * retraction that removes that turn removes the record with it. That is the
25
+ * accepted consequence of the exclusion, not a defect — the alternative, letting
26
+ * it open a turn, is the exact failure the marker exists to prevent.
27
+ *
28
+ * `GENUINE_USER_START_SQL` in `thread-history.ts` is the twin of this predicate over stored
29
+ * envelopes; the two are built from the same constants so they cannot drift.
30
+ */
31
+ export declare function isGenuineUserStart(message: ModelMessage): boolean;
32
+ /**
33
+ * Group rows (oldest-first) into turns at genuine-user-start boundaries.
34
+ * Generic over the row shape so the view rule and the display read
35
+ * (`loadAll`) share it, each supplying its own start predicate.
36
+ */
37
+ export declare function groupTurns<T>(rows: readonly T[], isStart: (row: T) => boolean): T[][];
38
+ export interface ConversationViewOptions {
39
+ /** Keep the first turn in front of the view: the seed of a report thread. */
40
+ readonly keepFirstTurn?: boolean;
41
+ }
42
+ export interface ConversationView {
43
+ readonly messages: ModelMessage[];
44
+ /** `sources[i]` is the index of the input message that gives `messages[i]`. */
45
+ readonly sources: number[];
46
+ }
47
+ export declare function conversationView(messages: readonly ModelMessage[], options: ConversationViewOptions): ConversationView;
48
+ /** The message with no reasoning part, or `undefined` for an assistant message with no part left. */
49
+ export declare function withoutReasoning(message: ModelMessage): ModelMessage | undefined;
50
+ /** The estimate of the messages, by the count that the store writes to the `tokens` column. */
51
+ export declare function viewTokens(messages: readonly ModelMessage[]): number;
52
+ /** The count of the turns that a drop keeps: the newest turn, and each older turn while the view stays within `budget`. */
53
+ export declare function keptTurnsForDrop(messages: readonly ModelMessage[], options: ConversationViewOptions, budget: number): number;
@@ -0,0 +1,130 @@
1
+ /**
2
+ * The view of a conversation, which the loop sends and `loadRecent` gives: the head, then the latest
3
+ * summary marker, then the body. An exchange message never joins it, and a drop marker keeps the last
4
+ * turns of the body without their reasoning. The loop and the reader call this one function, thus the
5
+ * next turn reads back the prefix that the last request sent.
6
+ */
7
+ import { compactionExchangeOf, compactionMarkerOf, isSyntheticUserMessage } from "./ai-sdk-message-storage.js";
8
+ import { countTokens } from "./count-tokens.js";
9
+ /**
10
+ * A turn starts on a `user` message that a human actually sent. Two kinds of
11
+ * message carry the `user` role without opening a turn, and both are excluded
12
+ * here:
13
+ *
14
+ * - a tool result, which in AI SDK terms is a `tool`-role message, so a mid-turn
15
+ * tool continuation never matches in the first place;
16
+ * - a message the LOOP or the HOST synthesized (`syntheticUserMessage`) — the
17
+ * truncated-reply nudge the loop inserts mid-turn, and the record an embedder
18
+ * appends between turns for work that happened outside the conversation, such as
19
+ * an analysis run's outcome. Both carry the `user` role for the wire format and
20
+ * neither is user input. Reading one as a boundary would split one turn into
21
+ * two: a drop would keep half a turn, and `retractLastTurn` would cut
22
+ * its tail in the middle of a turn rather than at its head.
23
+ *
24
+ * A host-appended record therefore belongs to the turn preceding it, so a tail
25
+ * retraction that removes that turn removes the record with it. That is the
26
+ * accepted consequence of the exclusion, not a defect — the alternative, letting
27
+ * it open a turn, is the exact failure the marker exists to prevent.
28
+ *
29
+ * `GENUINE_USER_START_SQL` in `thread-history.ts` is the twin of this predicate over stored
30
+ * envelopes; the two are built from the same constants so they cannot drift.
31
+ */
32
+ export function isGenuineUserStart(message) {
33
+ return message.role === "user" && !isSyntheticUserMessage(message);
34
+ }
35
+ /**
36
+ * Group rows (oldest-first) into turns at genuine-user-start boundaries.
37
+ * Generic over the row shape so the view rule and the display read
38
+ * (`loadAll`) share it, each supplying its own start predicate.
39
+ */
40
+ export function groupTurns(rows, isStart) {
41
+ const turns = [];
42
+ for (const row of rows) {
43
+ if (turns.length === 0 || isStart(row)) {
44
+ turns.push([row]);
45
+ }
46
+ else {
47
+ turns[turns.length - 1].push(row);
48
+ }
49
+ }
50
+ return turns;
51
+ }
52
+ function isCompactionMessage(message) {
53
+ return compactionMarkerOf(message) !== undefined || compactionExchangeOf(message) !== undefined;
54
+ }
55
+ function startsTurn(entry) {
56
+ return isGenuineUserStart(entry.message);
57
+ }
58
+ function withoutReasoningEntries(entries) {
59
+ return entries.flatMap((entry) => {
60
+ const message = withoutReasoning(entry.message);
61
+ return message === undefined ? [] : [{ message, source: entry.source }];
62
+ });
63
+ }
64
+ function viewParts(messages, options) {
65
+ // The head ends at a marker or an exchange message too, thus the stored rows and a view that
66
+ // starts with the seed and a summary give the same head.
67
+ let headEnd = 0;
68
+ if (options.keepFirstTurn === true) {
69
+ while (headEnd < messages.length) {
70
+ const message = messages[headEnd];
71
+ if (isCompactionMessage(message) || (headEnd > 0 && isGenuineUserStart(message)))
72
+ break;
73
+ headEnd++;
74
+ }
75
+ }
76
+ const head = messages.slice(0, headEnd).map((message, source) => ({ message, source }));
77
+ let front;
78
+ let body = [];
79
+ for (let source = headEnd; source < messages.length; source++) {
80
+ const message = messages[source];
81
+ if (compactionExchangeOf(message) !== undefined)
82
+ continue;
83
+ const marker = compactionMarkerOf(message);
84
+ if (marker?.kind === "summary") {
85
+ front = { message, source };
86
+ body = [];
87
+ }
88
+ else if (marker?.kind === "drop") {
89
+ // The drop changes the prefix that the signature of each kept reasoning part binds.
90
+ const turns = groupTurns(body, startsTurn);
91
+ body = withoutReasoningEntries(turns.slice(Math.max(turns.length - marker.keptTurns, 0)).flat());
92
+ }
93
+ else {
94
+ body.push({ message, source });
95
+ }
96
+ }
97
+ return { head, front, body };
98
+ }
99
+ export function conversationView(messages, options) {
100
+ const { head, front, body } = viewParts(messages, options);
101
+ const entries = [...head, ...(front === undefined ? [] : [front]), ...body];
102
+ return { messages: entries.map((entry) => entry.message), sources: entries.map((entry) => entry.source) };
103
+ }
104
+ /** The message with no reasoning part, or `undefined` for an assistant message with no part left. */
105
+ export function withoutReasoning(message) {
106
+ if (message.role !== "assistant" || typeof message.content === "string")
107
+ return message;
108
+ const content = message.content.filter((part) => part.type !== "reasoning" && part.type !== "reasoning-file");
109
+ if (content.length === message.content.length)
110
+ return message;
111
+ return content.length === 0 ? undefined : { ...message, content };
112
+ }
113
+ /** The estimate of the messages, by the count that the store writes to the `tokens` column. */
114
+ export function viewTokens(messages) {
115
+ return messages.reduce((sum, message) => sum + countTokens(message.content), 0);
116
+ }
117
+ /** The count of the turns that a drop keeps: the newest turn, and each older turn while the view stays within `budget`. */
118
+ export function keptTurnsForDrop(messages, options, budget) {
119
+ const { head, front, body } = viewParts(messages, options);
120
+ const turnTokens = groupTurns(body, startsTurn).map((turn) => viewTokens(withoutReasoningEntries(turn).map((entry) => entry.message)));
121
+ let total = viewTokens([...head, ...(front === undefined ? [] : [front])].map((entry) => entry.message));
122
+ let kept = 0;
123
+ for (let t = turnTokens.length - 1; t >= 0; t--) {
124
+ if (kept > 0 && total + turnTokens[t] > budget)
125
+ break;
126
+ total += turnTokens[t];
127
+ kept++;
128
+ }
129
+ return kept;
130
+ }
@@ -1,9 +1,7 @@
1
1
  /**
2
- * Write-time token counting for the conversation message store.
3
- *
4
- * `appendTurn` stamps a `tokens` count on every message row so `loadRecent`
5
- * windows by token budget without tokenizing on the read path. Counted once
6
- * at write, applied at load.
2
+ * Token counting for the conversation. The store stamps the count of each message
3
+ * row as its `tokens` column, and the loop estimates the view of a request with the
4
+ * same count. `loadRecent` has no budget.
7
5
  *
8
6
  * Providers do not publish one shared offline tokenizer,
9
7
  * so this uses `js-tiktoken`'s `cl100k_base` BPE. It is an approximation:
@@ -12,7 +10,7 @@
12
10
  */
13
11
  import type { ModelMessage } from "ai";
14
12
  /**
15
- * Token count of a message's content. Used only at write time. Empty
16
- * content (an empty array or empty string) counts as `0`.
13
+ * Token count of a message's content. Empty content (an empty array or empty
14
+ * string) counts as `0`.
17
15
  */
18
16
  export declare function countTokens(content: ModelMessage["content"]): number;
@@ -1,9 +1,7 @@
1
1
  /**
2
- * Write-time token counting for the conversation message store.
3
- *
4
- * `appendTurn` stamps a `tokens` count on every message row so `loadRecent`
5
- * windows by token budget without tokenizing on the read path. Counted once
6
- * at write, applied at load.
2
+ * Token counting for the conversation. The store stamps the count of each message
3
+ * row as its `tokens` column, and the loop estimates the view of a request with the
4
+ * same count. `loadRecent` has no budget.
7
5
  *
8
6
  * Providers do not publish one shared offline tokenizer,
9
7
  * so this uses `js-tiktoken`'s `cl100k_base` BPE. It is an approximation:
@@ -23,8 +21,8 @@ function enc() {
23
21
  * the text is prompt text. A file rides the wire as an attachment, and the
24
22
  * provider bills it at its own rate, thus its bytes count as `0` — the same rule
25
23
  * as a top-level `file` block. A file that is stringified into the count inflates
26
- * the row of the message by tens of thousands of tokens, and the load window then
27
- * drops the true history.
24
+ * the row of the message by tens of thousands of tokens, and the loop then
25
+ * compacts the view too early.
28
26
  *
29
27
  * Each other output arm is plain JSON, thus it is stringified whole.
30
28
  */
@@ -76,8 +74,8 @@ function tokenizableText(block) {
76
74
  }
77
75
  }
78
76
  /**
79
- * Token count of a message's content. Used only at write time. Empty
80
- * content (an empty array or empty string) counts as `0`.
77
+ * Token count of a message's content. Empty content (an empty array or empty
78
+ * string) counts as `0`.
81
79
  */
82
80
  export function countTokens(content) {
83
81
  if (typeof content === "string") {