@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
@@ -3,8 +3,9 @@
3
3
  *
4
4
  * Conversation-scoped store with an explicit two-method interface and
5
5
  * nothing else bundled in (no semantic recall, no working memory, no title
6
- * generation). It is also the home of context-window management — the read
7
- * side, `loadRecent`, returns a token-bounded window.
6
+ * generation). The read side, `loadRecent`, gives the view of the latest
7
+ * compaction marker: the stored markers decide what a turn sends, and the store
8
+ * deletes no row.
8
9
  *
9
10
  * Scope (see the harness-thread-store spec): conversation threads only. A `threadId` is the
10
11
  * UI-generated id of one conversation thread (a random UUID — an analysis
@@ -14,11 +15,10 @@
14
15
  * conversation-turn shaped on purpose, so reaching for it inside a
15
16
  * workflow step feels immediately wrong.
16
17
  *
17
- * The window is always a valid AI SDK model-message sequence: it begins on a
18
- * `user` message that is genuine user input — never a `tool`-role
19
- * continuation — and never splits a tool-call/tool-result pair. The turn
20
- * is the atomic unit; `appendTurn` writes one atomically and `loadRecent`
21
- * rounds the budget walk to turn boundaries.
18
+ * The view is always a valid AI SDK model-message sequence: it begins on a
19
+ * `user` message — never a `tool`-role continuation — and never splits a
20
+ * tool-call/tool-result pair. A compaction starts only between two rounds, a
21
+ * drop keeps whole turns, and each write of a group is atomic.
22
22
  */
23
23
  import { randomUUID } from "node:crypto";
24
24
  import { metrics } from "@opentelemetry/api";
@@ -27,7 +27,8 @@ import { stripNulCharacters } from "../input-sanitization.js";
27
27
  import { tryMutation, tryQuery, withTransaction } from "../lib/db-result.js";
28
28
  import { hasReportedUsage } from "../loop/metrics.js";
29
29
  import { countTokens } from "./count-tokens.js";
30
- import { HARNESS_PROVIDER_NAMESPACE, SYNTHETIC_MESSAGE_KEY, envelopeMessage, isSyntheticUserMessage, parseStoredMessageEnvelope, syntheticRecordMessage, } from "./ai-sdk-message-storage.js";
30
+ import { conversationView, groupTurns, isGenuineUserStart } from "./conversation-view.js";
31
+ import { HARNESS_PROVIDER_NAMESPACE, SYNTHETIC_MESSAGE_KEY, envelopeMessage, parseStoredMessageEnvelope, syntheticRecordMessage, } from "./ai-sdk-message-storage.js";
31
32
  import { envelopeDisplayMessages, parseStoredDisplayEnvelope } from "./conversation-display-storage.js";
32
33
  /** A resolved `ok(undefined)` ResultAsync — the empty/seed transaction step. */
33
34
  function okVoid() {
@@ -51,34 +52,8 @@ export function conversationRecordTurn(text) {
51
52
  };
52
53
  }
53
54
  /**
54
- * A turn starts on a `user` message that a human actually sent. Two kinds of
55
- * message carry the `user` role without opening a turn, and both are excluded
56
- * here:
57
- *
58
- * - a tool result, which in AI SDK terms is a `tool`-role message, so a mid-turn
59
- * tool continuation never matches in the first place;
60
- * - a message the LOOP or the HOST synthesized (`syntheticUserMessage`) — the
61
- * truncated-reply nudge the loop inserts mid-turn, and the record an embedder
62
- * appends between turns for work that happened outside the conversation, such as
63
- * an analysis run's outcome. Both carry the `user` role for the wire format and
64
- * neither is user input. Reading one as a boundary would split one turn into
65
- * two: the token window would evict half a turn, and `retractLastTurn` would cut
66
- * its tail in the middle of a turn rather than at its head.
67
- *
68
- * A host-appended record therefore belongs to the turn preceding it, so a tail
69
- * retraction that removes that turn removes the record with it. That is the
70
- * accepted consequence of the exclusion, not a defect — the alternative, letting
71
- * it open a turn, is the exact failure the marker exists to prevent.
72
- *
73
- * {@link GENUINE_USER_START_SQL} is the twin of this predicate over stored
74
- * envelopes; the two are built from the same constants so they cannot drift.
75
- */
76
- function isGenuineUserStart(message) {
77
- return message.role === "user" && !isSyntheticUserMessage(message);
78
- }
79
- /**
80
- * {@link isGenuineUserStart} expressed over a stored `message_envelope` — the
81
- * boundary predicate `retractLastTurn` cuts on.
55
+ * {@link isGenuineUserStart} (`conversation-view.ts`) expressed over a stored
56
+ * `message_envelope` — the boundary predicate `retractLastTurn` cuts on.
82
57
  *
83
58
  * Interpolating here, and only here, is safe: both interpolated values are
84
59
  * module constants shared with the TypeScript predicate, never caller input, so
@@ -105,35 +80,17 @@ const GENUINE_USER_START_SQL = `message_envelope->'message'->>'role' = 'user'
105
80
  function serializeEnvelope(message) {
106
81
  return JSON.stringify(envelopeMessage(message), (_key, value) => (typeof value === "string" ? stripNulCharacters(value) : value));
107
82
  }
108
- /**
109
- * Group rows (oldest-first) into turns at genuine-user-start boundaries.
110
- * Generic over the row shape so the token-windowed read (`loadRecent`) and
111
- * the display read (`loadAll`) share it, each supplying its own start
112
- * predicate.
113
- */
114
- function groupTurns(rows, isStart) {
115
- const turns = [];
116
- for (const row of rows) {
117
- if (turns.length === 0 || isStart(row)) {
118
- turns.push([row]);
119
- }
120
- else {
121
- turns[turns.length - 1].push(row);
122
- }
123
- }
124
- return turns;
125
- }
126
83
  let instruments;
127
84
  function getInstruments() {
128
85
  if (instruments === undefined) {
129
86
  const meter = metrics.getMeter("cortex.harness.memory");
130
87
  instruments = {
131
88
  totalTokens: meter.createHistogram("cortex.harness.thread.total_tokens", {
132
- description: "Total token count of a conversation thread, sampled on every loadRecent",
89
+ description: "Total estimated token count of the rows of a conversation thread, sampled on every loadRecent",
133
90
  unit: "{token}",
134
91
  }),
135
92
  turnsEvicted: meter.createHistogram("cortex.harness.thread.turns_evicted", {
136
- description: "Conversation turns dropped by loadRecent's token-budget window",
93
+ description: "Conversation turns of which the view of loadRecent holds no message",
137
94
  unit: "{turn}",
138
95
  }),
139
96
  };
@@ -148,27 +105,115 @@ export function __resetThreadHistoryMetricsForTest() {
148
105
  instruments = undefined;
149
106
  }
150
107
  /**
151
- * Chunked-eviction block size, in whole turns — the granularity at which
152
- * `loadRecent`'s retained window may advance its START turn.
153
- *
154
- * `loadRecent` snaps its eviction count UP to a multiple of this block, so the
155
- * window's first turn — hence `messages[0]` and the whole tools+system+history
156
- * prompt-cache prefix — stays byte-identical across a run of appends and shifts
157
- * only once per block instead of once per turn. That single shift is the only
158
- * message-cache miss the block costs.
159
- *
160
- * The block is at once (a) the cache-miss cadence — one prefix shift per
161
- * `EVICTION_BLOCK_TURNS` appends once a thread is over budget — and (b) the most
162
- * extra context ever sacrificed: snapping up drops up to `EVICTION_BLOCK_TURNS -
163
- * 1` oldest turns the budget alone would have kept, trading a little history for
164
- * a still prefix. 4 holds the prefix for ~4 turns at a cost of at most 3 turns of
165
- * headroom — negligible against a ~120k-token budget that spans far more turns,
166
- * while cutting cache misses on a long thread roughly fourfold. A turn-count
167
- * block (not a token block) is deliberate: a cache miss is triggered by the START
168
- * turn moving, a per-turn event, so counting in turns makes the miss cadence
169
- * exact and independent of how large any individual turn is.
108
+ * Serialize the writes of one thread — without the lock, two transactions can
109
+ * both read the same MAX(seq) and collide on the (thread_id, seq) primary key.
110
+ * Released automatically at COMMIT/ROLLBACK.
170
111
  */
171
- export const EVICTION_BLOCK_TURNS = 4;
112
+ function lockThread(client, threadId, op) {
113
+ return tryQuery(`${op}.lock`, () => client.query("SELECT pg_advisory_xact_lock(hashtext($1))", [threadId])).map(() => undefined);
114
+ }
115
+ /** The `seq` of the next row of the thread. Read it under {@link lockThread}. */
116
+ function nextSeq(client, threadId, op) {
117
+ return tryQuery(`${op}.maxSeq`, async () => {
118
+ const { rows } = await client.query("SELECT COALESCE(MAX(seq), -1)::text AS max_seq FROM messages WHERE thread_id = $1", [
119
+ threadId,
120
+ ]);
121
+ return Number(rows[0].max_seq) + 1;
122
+ });
123
+ }
124
+ /** Insert the rows of one group, from `startSeq` on. */
125
+ function insertGroup(client, threadId, startSeq, group, op) {
126
+ const { modelMessages: messages, displayMessages, author } = group;
127
+ // The display projection rides the group's FIRST row, so one SELECT of a
128
+ // thread's rows yields every envelope in order with no join and no grouping.
129
+ // Whichever row that is — a genuine user message opening a turn, or the lone
130
+ // record row of an out-of-band append — is the row a tail retraction removes
131
+ // the envelope with, because it is the row the projection describes.
132
+ const displayEnvelope = displayMessages.length > 0 ? JSON.stringify(envelopeDisplayMessages(displayMessages)) : null;
133
+ // The author rides the row the display projection rides, and only when that
134
+ // row is a message a person actually sent — an assistant row, a tool row,
135
+ // and the synthetic opening row of a record append are not. NUL is dropped
136
+ // rather than refused: a 0x00 byte in a `text` parameter fails the
137
+ // statement, and one byte in a label would roll the whole turn back.
138
+ const turnAuthor = author === undefined ? null : stripNulCharacters(author);
139
+ const authorRow = messages[0] !== undefined && isGenuineUserStart(messages[0]) ? 0 : -1;
140
+ // Insert the group's messages in order. Each insert chains off the
141
+ // prior so the first `err` short-circuits — and `withTransaction`
142
+ // re-throws it to force ROLLBACK (a returned `err` that does not
143
+ // reach `withTransaction` would COMMIT silently).
144
+ return messages.reduce((chain, message, i) => chain.andThen(() => tryMutation(`${op}.insert`, () => client.query(
145
+ // `message_envelope::json`, never `::jsonb`; `display_envelope::jsonb`,
146
+ // never `::json` — see the column comments in the state-init DDL.
147
+ `INSERT INTO messages (thread_id, seq, message_envelope, display_envelope, tokens, author)
148
+ VALUES ($1, $2, $3::json, $4::jsonb, $5, $6)
149
+ ON CONFLICT (thread_id, seq) DO UPDATE
150
+ SET message_envelope = EXCLUDED.message_envelope,
151
+ display_envelope = EXCLUDED.display_envelope,
152
+ tokens = EXCLUDED.tokens,
153
+ author = EXCLUDED.author`, [
154
+ threadId,
155
+ startSeq + i,
156
+ serializeEnvelope(message),
157
+ i === 0 ? displayEnvelope : null,
158
+ countTokens(message.content),
159
+ i === authorRow ? turnAuthor : null,
160
+ ])).map(() => undefined)), okVoid());
161
+ }
162
+ /** Move `cortex_analysis_threads.updated_at` forward inside the write transaction. */
163
+ function touchThread(client, threadId, op) {
164
+ // Thread listings sort on `cortex_analysis_threads.updated_at`, and
165
+ // the only other writer of it is the title update — so without this
166
+ // touch "most recently updated" degrades to "most recently created
167
+ // or renamed" and an actively-used older thread sorts last. Writing
168
+ // it from here (a row the thread store otherwise owns) buys the
169
+ // guarantee for every host with no wiring, and inside the turn's own
170
+ // transaction there is no window where the rows exist but the thread
171
+ // reads stale.
172
+ //
173
+ // The breadcrumb is never worth the turn: the touch may fail without
174
+ // failing the append, and affecting zero rows is equally normal — a
175
+ // thread with no metadata row, or a soft-deleted one this leaves
176
+ // alone rather than reviving (every other writer of the table filters
177
+ // the tombstone too). Tolerating the failure takes the savepoint, not
178
+ // just the swallowed `err`: Postgres poisons a transaction at its
179
+ // first failed statement and downgrades the eventual COMMIT to a
180
+ // ROLLBACK — without a rewind point the turn's inserts would go with
181
+ // it, and silently, since that COMMIT still reports success.
182
+ //
183
+ // `GREATEST(updated_at, clock_timestamp())` because `NOW()` is
184
+ // transaction-START time while this transaction has since waited on
185
+ // the advisory lock and spent a round trip per message: a title update
186
+ // that began later can already have stamped a newer `updated_at`, and
187
+ // a plain assignment would rewind it. Activity only ever moves the
188
+ // timestamp forward.
189
+ return tryMutation(`${op}.touchThread.savepoint`, () => client.query("SAVEPOINT touch_thread"))
190
+ .andThen(() => tryMutation(`${op}.touchThread`, () => client.query(`UPDATE cortex_analysis_threads
191
+ SET updated_at = GREATEST(updated_at, clock_timestamp())
192
+ WHERE thread_id = $1 AND deleted_at IS NULL`, [threadId])))
193
+ .map(() => undefined)
194
+ .orElse(() =>
195
+ // Rewind to before the touch so the transaction is usable again
196
+ // and the write commits. Nothing follows in the chain of a caller,
197
+ // so COMMIT releases the savepoint on the success path.
198
+ tryMutation(`${op}.touchThread.rewind`, () => client.query("ROLLBACK TO SAVEPOINT touch_thread"))
199
+ .map(() => undefined)
200
+ .orElse(() => okVoid()));
201
+ }
202
+ /** Add the `open` record of a turn. The upsert replaces a record whose rows are gone, because the opening row holds its key now. */
203
+ function openTurnRecord(client, threadId, startSeq) {
204
+ return tryMutation("thread-history.writeTurn.openRecord", () => client.query(`INSERT INTO cortex_thread_turns (thread_id, start_seq, status)
205
+ VALUES ($1, $2, 'open')
206
+ ON CONFLICT (thread_id, start_seq) DO UPDATE
207
+ SET status = 'open', reason = NULL, reported_usage = NULL, turn_duration_ms = NULL, opened_at = NOW(), closed_at = NULL`, [threadId, startSeq])).map(() => undefined);
208
+ }
209
+ /** Set the outcome of a turn record that is still `open`, thus a second close changes nothing. */
210
+ function closeTurnRecord(client, threadId, startSeq, close) {
211
+ // The predicate of the loop decides whether a rollup reported a quantity, thus the two cannot drift.
212
+ const rollup = hasReportedUsage(close.turnUsage) ? JSON.stringify(close.turnUsage) : null;
213
+ return tryMutation("thread-history.writeTurn.closeRecord", () => client.query(`UPDATE cortex_thread_turns
214
+ SET status = $3, reason = $4, reported_usage = $5::jsonb, turn_duration_ms = $6, closed_at = NOW()
215
+ WHERE thread_id = $1 AND start_seq = $2 AND status = 'open'`, [threadId, startSeq, close.status, close.reason === undefined ? null : stripNulCharacters(close.reason), rollup, close.turnDurationMs ?? null])).map(() => undefined);
216
+ }
172
217
  /**
173
218
  * Create a `ThreadHistory` bound to a Postgres pool — a factory closure
174
219
  * capturing `pool` (dependency injection per the harness-durable-runtime spec). The `messages` table is
@@ -181,114 +226,36 @@ export const EVICTION_BLOCK_TURNS = 4;
181
226
  */
182
227
  export function createThreadHistory(pool, logger) {
183
228
  function appendTurn(threadId, turn) {
184
- const { modelMessages: messages, displayMessages, turnUsage, turnDurationMs, author } = turn;
185
- if (messages.length === 0)
229
+ if (turn.modelMessages.length === 0)
186
230
  return okVoid();
187
- // The display projection rides the append's FIRST row, so one SELECT of a
188
- // thread's rows yields every envelope in order with no join and no grouping.
189
- // Whichever row that is — a genuine user message opening a turn, or the lone
190
- // record row of an out-of-band append — is the row a tail retraction removes
191
- // the envelope with, because it is the row the projection describes.
192
- const displayEnvelope = displayMessages.length > 0 ? JSON.stringify(envelopeDisplayMessages(displayMessages)) : null;
193
- // A rollup that reports no quantity is stored as absent, not as a rollup of
194
- // absences, so "no figure" has exactly one representation in storage.
195
- // `hasReportedUsage` is the loop's own predicate for the same question,
196
- // reused rather than restated so the write and the loop cannot drift.
197
- const rollup = hasReportedUsage(turnUsage) ? JSON.stringify(turnUsage) : null;
198
- // The duration takes no predicate of its own, and it keeps its own column.
199
- // A measured zero is a figure, thus only an absent value means that nobody
200
- // measured the turn. The rollup goes absent under its own predicate above,
201
- // and the duration does not go with it.
202
- const duration = turnDurationMs ?? null;
203
- // The two figures describe the turn, and the assistant reply is the row a
204
- // reader associates with the answer. The LAST assistant row, not any of them: a
205
- // serial-tool turn writes one assistant row per step and only the last ends
206
- // the turn. -1 (a turn that persisted no reply — an abort before any output)
207
- // matches no index below, so nothing is written and the append still succeeds.
208
- const lastAssistantRow = messages.reduce((last, m, i) => (m.role === "assistant" ? i : last), -1);
209
- // The author rides the row the display projection rides, and only when that
210
- // row is a message a person actually sent — an assistant row, a tool row,
211
- // and the synthetic opening row of a record append are not. NUL is dropped
212
- // rather than refused: a 0x00 byte in a `text` parameter fails the
213
- // statement, and one byte in a label would roll the whole turn back.
214
- const turnAuthor = author === undefined ? null : stripNulCharacters(author);
215
- const authorRow = messages[0] !== undefined && isGenuineUserStart(messages[0]) ? 0 : -1;
216
- return withTransaction(pool, "thread-history.appendTurn", (client) =>
217
- // Serialize concurrent appends on this thread — without the lock, two
218
- // transactions can both read the same MAX(seq) and collide on the
219
- // (thread_id, seq) primary key. Released automatically at COMMIT/ROLLBACK.
220
- tryQuery("thread-history.appendTurn.lock", () => client.query("SELECT pg_advisory_xact_lock(hashtext($1))", [threadId]))
221
- .andThen(() => tryQuery("thread-history.appendTurn.maxSeq", async () => {
222
- const { rows } = await client.query("SELECT COALESCE(MAX(seq), -1)::text AS max_seq FROM messages WHERE thread_id = $1", [threadId]);
223
- return Number(rows[0].max_seq) + 1;
224
- }))
225
- .andThen((startSeq) =>
226
- // Insert the turn's messages in order. Each insert chains off the
227
- // prior so the first `err` short-circuits — and `withTransaction`
228
- // re-throws it to force ROLLBACK (a returned `err` that does not
229
- // reach `withTransaction` would COMMIT silently).
230
- messages.reduce((chain, message, i) => chain.andThen(() => tryMutation("thread-history.appendTurn.insert", () => client.query(
231
- // `message_envelope::json`, never `::jsonb`; `display_envelope` and
232
- // `reported_usage::jsonb`, never `::json` — see the column comments
233
- // in the state-init DDL.
234
- `INSERT INTO messages (thread_id, seq, message_envelope, display_envelope, tokens, reported_usage, turn_duration_ms, author)
235
- VALUES ($1, $2, $3::json, $4::jsonb, $5, $6::jsonb, $7, $8)
236
- ON CONFLICT (thread_id, seq) DO UPDATE
237
- SET message_envelope = EXCLUDED.message_envelope,
238
- display_envelope = EXCLUDED.display_envelope,
239
- tokens = EXCLUDED.tokens,
240
- reported_usage = EXCLUDED.reported_usage,
241
- turn_duration_ms = EXCLUDED.turn_duration_ms,
242
- author = EXCLUDED.author`, [
243
- threadId,
244
- startSeq + i,
245
- serializeEnvelope(message),
246
- i === 0 ? displayEnvelope : null,
247
- countTokens(message.content),
248
- i === lastAssistantRow ? rollup : null,
249
- i === lastAssistantRow ? duration : null,
250
- i === authorRow ? turnAuthor : null,
251
- ])).map(() => undefined)), okVoid()))
252
- .andThen(() =>
253
- // Thread listings sort on `cortex_analysis_threads.updated_at`, and
254
- // the only other writer of it is the title update — so without this
255
- // touch "most recently updated" degrades to "most recently created
256
- // or renamed" and an actively-used older thread sorts last. Writing
257
- // it from here (a row the thread store otherwise owns) buys the
258
- // guarantee for every host with no wiring, and inside the turn's own
259
- // transaction there is no window where the rows exist but the thread
260
- // reads stale.
261
- //
262
- // The breadcrumb is never worth the turn: the touch may fail without
263
- // failing the append, and affecting zero rows is equally normal — a
264
- // thread with no metadata row, or a soft-deleted one this leaves
265
- // alone rather than reviving (every other writer of the table filters
266
- // the tombstone too). Tolerating the failure takes the savepoint, not
267
- // just the swallowed `err`: Postgres poisons a transaction at its
268
- // first failed statement and downgrades the eventual COMMIT to a
269
- // ROLLBACK — without a rewind point the turn's inserts would go with
270
- // it, and silently, since that COMMIT still reports success.
271
- //
272
- // `GREATEST(updated_at, clock_timestamp())` because `NOW()` is
273
- // transaction-START time while this transaction has since waited on
274
- // the advisory lock and spent a round trip per message: a title update
275
- // that began later can already have stamped a newer `updated_at`, and
276
- // a plain assignment would rewind it. Activity only ever moves the
277
- // timestamp forward.
278
- tryMutation("thread-history.appendTurn.touchThread.savepoint", () => client.query("SAVEPOINT touch_thread"))
279
- .andThen(() => tryMutation("thread-history.appendTurn.touchThread", () => client.query(`UPDATE cortex_analysis_threads
280
- SET updated_at = GREATEST(updated_at, clock_timestamp())
281
- WHERE thread_id = $1 AND deleted_at IS NULL`, [threadId])))
282
- .map(() => undefined)
283
- .orElse(() =>
284
- // Rewind to before the touch so the transaction is usable again
285
- // and the turn commits. Nothing follows in this chain, so COMMIT
286
- // releases the savepoint on the success path.
287
- tryMutation("thread-history.appendTurn.touchThread.rewind", () => client.query("ROLLBACK TO SAVEPOINT touch_thread"))
288
- .map(() => undefined)
289
- .orElse(() => okVoid()))));
231
+ return withTransaction(pool, "thread-history.appendTurn", (client) => lockThread(client, threadId, "thread-history.appendTurn")
232
+ .andThen(() => nextSeq(client, threadId, "thread-history.appendTurn"))
233
+ .andThen((startSeq) => insertGroup(client, threadId, startSeq, turn, "thread-history.appendTurn"))
234
+ .andThen(() => touchThread(client, threadId, "thread-history.appendTurn")));
290
235
  }
291
- function loadRecent(threadId, tokenBudget, options) {
236
+ function writeTurn(threadId, write) {
237
+ const { close } = write;
238
+ const groups = [...(write.opening === undefined ? [] : [write.opening]), ...write.rounds, ...(close?.note === undefined ? [] : [close.note])];
239
+ return withTransaction(pool, "thread-history.writeTurn", (client) => lockThread(client, threadId, "thread-history.writeTurn")
240
+ .andThen(() => nextSeq(client, threadId, "thread-history.writeTurn"))
241
+ .andThen((firstSeq) => {
242
+ const startSeq = write.opening === undefined ? write.startSeq : firstSeq;
243
+ const steps = [];
244
+ let seq = firstSeq;
245
+ for (const [index, group] of groups.entries()) {
246
+ const groupSeq = seq;
247
+ seq += group.modelMessages.length;
248
+ steps.push(() => insertGroup(client, threadId, groupSeq, group, "thread-history.writeTurn"));
249
+ if (index === 0 && write.opening !== undefined)
250
+ steps.push(() => openTurnRecord(client, threadId, startSeq));
251
+ }
252
+ if (close !== undefined)
253
+ steps.push(() => closeTurnRecord(client, threadId, startSeq, close));
254
+ steps.push(() => touchThread(client, threadId, "thread-history.writeTurn"));
255
+ return steps.reduce((chain, step) => chain.andThen(step), okVoid()).map(() => ({ startSeq }));
256
+ }));
257
+ }
258
+ function loadRecent(threadId, options) {
292
259
  return tryQuery("thread-history.loadRecent", async () => {
293
260
  const { rows } = await pool.query(
294
261
  // ORDER BY must qualify `messages.seq` — a bare `seq` would bind to the
@@ -297,72 +264,26 @@ export function createThreadHistory(pool, logger) {
297
264
  // "10" before "2". Scrambled order splits a tool-call/tool-result pair
298
265
  // across an intervening turn. The qualified name forces the bigint column.
299
266
  //
300
- // `reported_usage` is deliberately NOT selected. Windowing budgets on
301
- // `tokens` — an offline estimate every row carries — and a turn that
302
- // reported nothing has no rollup at all, so budgeting on the rollup
303
- // would silently stop evicting on exactly those threads. The two are
304
- // different measurements sharing a unit; see the column comments.
267
+ // `reported_usage` is deliberately NOT selected. The metric reads the
268
+ // `tokens` estimate that every row carries, and a turn that reported
269
+ // nothing has no rollup at all. The two are different measurements
270
+ // sharing a unit; see the column comments.
305
271
  `SELECT seq::text AS seq, message_envelope, tokens
306
272
  FROM messages WHERE thread_id = $1 ORDER BY messages.seq ASC`, [threadId]);
307
- const parsed = rows.map((row) => ({
273
+ const parsed = rows.map((row, index) => ({
274
+ index,
308
275
  message: parseStoredMessageEnvelope(row.message_envelope, `${threadId}/${row.seq}`).message,
309
276
  tokens: row.tokens,
310
277
  }));
278
+ const view = conversationView(parsed.map((row) => row.message), options ?? {});
279
+ const held = new Set(view.sources);
311
280
  const turns = groupTurns(parsed, (row) => isGenuineUserStart(row.message));
312
- const turnTokens = turns.map((turn) => turn.reduce((sum, row) => sum + row.tokens, 0));
313
- const threadTotal = turnTokens.reduce((sum, n) => sum + n, 0);
314
- // Chunked eviction holds the prompt-cache prefix still. The budget alone
315
- // evicts the OLDEST turns newest-first, so the window START — the stable
316
- // head of the tools+system+history cache prefix — advances by ~one turn
317
- // per appended turn; every turn then ships a shifted `messages[0]` and
318
- // rewrites the whole message cache instead of reading it back (watch
319
- // `cortex.harness.agent.cache_read_tokens` collapse on long threads).
320
- // Instead, take the budget-minimal eviction and snap it UP to a whole
321
- // `EVICTION_BLOCK_TURNS` block: `evicted` stays constant while the minimum
322
- // sits inside a block and jumps by a block when it crosses, so
323
- // `messages[0]` is byte-identical for a block of appends (the cache reads
324
- // survive) and shifts only once per block. Snapping UP retains a subset of
325
- // the budget-minimal window, so the kept tokens never exceed `tokenBudget`.
326
- //
327
- // Budget-minimal fill: walk newest-first, always keeping the most recent
328
- // turn (a valid over-budget single turn beats an under-budget cut), then
329
- // add older turns while they fit.
330
- let included = 0;
331
- let cumulative = 0;
332
- for (let t = turns.length - 1; t >= 0; t--) {
333
- if (included > 0 && cumulative + turnTokens[t] > tokenBudget)
334
- break;
335
- cumulative += turnTokens[t];
336
- included++;
337
- }
338
- const minimalEvicted = turns.length - included;
339
- // Snap the eviction count up to a block boundary, clamped below the last
340
- // turn so the most recent turn always ships. Both terms of the min are
341
- // >= `minimalEvicted`, so `turnsEvicted >= minimalEvicted`: the retained
342
- // suffix is a subset of the budget-minimal one and stays within budget.
343
- const turnsEvicted = turns.length === 0 ? 0 : Math.min(Math.ceil(minimalEvicted / EVICTION_BLOCK_TURNS) * EVICTION_BLOCK_TURNS, turns.length - 1);
344
- // The retained head. The first turn of a report session carries the
345
- // brief and the copy of the working-memory render, and no tail message
346
- // replaces them. Thus `keepFirstTurn` puts that turn in front of the
347
- // retained suffix, and `messages[0]` never moves again. An eviction of
348
- // zero already holds the first turn at the head, thus the option adds
349
- // nothing there and the window never carries the turn two times.
350
- const keepsFirstTurn = options?.keepFirstTurn === true && turnsEvicted > 0;
351
- const { totalTokens, turnsEvicted: turnsEvictedHist } = getInstruments();
352
- // A retained head gives one evicted turn back to the window, thus the
353
- // histogram reports one turn less than the budget walk cut.
354
- const turnsDropped = keepsFirstTurn ? turnsEvicted - 1 : turnsEvicted;
355
- // The flag reads the cut of the budget walk, and never the count that
356
- // the retained head reduced. The two answer different questions: the
357
- // flag says that the window lost a turn, and the histogram says how
358
- // many. A walk that cut one block, under a block size of one, drops
359
- // its whole cut back into the window. The flag must still report the
360
- // eviction, because the thread crossed its budget.
361
- const attributes = { eviction: turnsEvicted > 0 };
362
- totalTokens.record(threadTotal, attributes);
363
- turnsEvictedHist.record(turnsDropped, attributes);
364
- const retained = turns.slice(turnsEvicted);
365
- return (keepsFirstTurn ? [turns[0], ...retained] : retained).flat().map((row) => row.message);
281
+ const turnsLeftOut = turns.filter((turn) => !turn.some((row) => held.has(row.index))).length;
282
+ const { totalTokens, turnsEvicted } = getInstruments();
283
+ const attributes = { eviction: turnsLeftOut > 0 };
284
+ totalTokens.record(parsed.reduce((sum, row) => sum + row.tokens, 0), attributes);
285
+ turnsEvicted.record(turnsLeftOut, attributes);
286
+ return view.messages;
366
287
  });
367
288
  }
368
289
  // Display reads are turn-bounded, not row-bounded. A serial-tool assistant
@@ -377,8 +298,13 @@ export function createThreadHistory(pool, logger) {
377
298
  // `seq::text AS seq` output alias (Postgres resolves an unqualified
378
299
  // ORDER BY name to the output column), sorting the bigint as text:
379
300
  // "10" before "2". The qualified name forces the bigint column.
380
- `SELECT seq::text AS seq, message_envelope, display_envelope, reported_usage, turn_duration_ms::text AS turn_duration_ms, author, created_at
381
- FROM messages WHERE thread_id = $1
301
+ `SELECT messages.seq::text AS seq, messages.message_envelope, messages.display_envelope, messages.reported_usage,
302
+ messages.turn_duration_ms::text AS turn_duration_ms, messages.author, messages.created_at,
303
+ turns.status AS turn_status, turns.reason AS turn_reason, turns.reported_usage AS turn_usage,
304
+ turns.turn_duration_ms::text AS turn_record_duration_ms
305
+ FROM messages
306
+ LEFT JOIN cortex_thread_turns turns ON turns.thread_id = messages.thread_id AND turns.start_seq = messages.seq
307
+ WHERE messages.thread_id = $1
382
308
  ORDER BY messages.seq ASC`, [threadId]);
383
309
  // Spread rather than `usage: r.reported_usage ?? undefined`, so a row with
384
310
  // no rollup carries no `usage` KEY at all — absent, not present-and-undefined.
@@ -388,6 +314,14 @@ export function createThreadHistory(pool, logger) {
388
314
  const stored = await Promise.all(rows.map(async (r) => {
389
315
  const envelope = parseStoredMessageEnvelope(r.message_envelope, `${threadId}/${r.seq}`);
390
316
  const displayEnvelope = r.display_envelope == null ? undefined : await parseStoredDisplayEnvelope(r.display_envelope, `${threadId}/${r.seq}/display`, logger);
317
+ const turn = r.turn_status === null
318
+ ? undefined
319
+ : {
320
+ status: r.turn_status,
321
+ ...(r.turn_reason === null ? {} : { reason: r.turn_reason }),
322
+ ...(r.turn_usage === null ? {} : { usage: r.turn_usage }),
323
+ ...(r.turn_record_duration_ms === null ? {} : { durationMs: Number(r.turn_record_duration_ms) }),
324
+ };
391
325
  return {
392
326
  seq: Number(r.seq),
393
327
  envelope,
@@ -395,6 +329,7 @@ export function createThreadHistory(pool, logger) {
395
329
  ...(displayEnvelope ? { displayEnvelope } : {}),
396
330
  ...(r.reported_usage === null ? {} : { usage: r.reported_usage }),
397
331
  ...(r.turn_duration_ms === null ? {} : { durationMs: Number(r.turn_duration_ms) }),
332
+ ...(turn === undefined ? {} : { turn }),
398
333
  ...(r.author === null ? {} : { author: r.author }),
399
334
  createdAt: r.created_at,
400
335
  };
@@ -444,7 +379,7 @@ export function createThreadHistory(pool, logger) {
444
379
  // Delete the whole tail turn: every row at or past the boundary seq.
445
380
  // `$2::bigint` compares the bigint column against a bigint, never a
446
381
  // text projection, keeping the comparison exact for large seqs.
447
- return tryMutation("thread-history.retractLastTurn.delete", () => client.query("DELETE FROM messages WHERE thread_id = $1 AND seq >= $2::bigint", [threadId, boundary])).map((res) => ({ kind: "retracted", messages: res.rowCount ?? 0 }));
382
+ return tryMutation("thread-history.retractLastTurn.delete", () => client.query("DELETE FROM messages WHERE thread_id = $1 AND seq >= $2::bigint", [threadId, boundary])).andThen((res) => tryMutation("thread-history.retractLastTurn.deleteTurnRecords", () => client.query("DELETE FROM cortex_thread_turns WHERE thread_id = $1 AND start_seq >= $2::bigint", [threadId, boundary])).map(() => ({ kind: "retracted", messages: res.rowCount ?? 0 })));
448
383
  }));
449
384
  }
450
385
  function latestSeq(threadId) {
@@ -481,5 +416,5 @@ export function createThreadHistory(pool, logger) {
481
416
  AND seq > $2::bigint
482
417
  AND ${GENUINE_USER_START_SQL}`, [threadId, seq])).map(({ rows }) => Number(rows[0].turns));
483
418
  }
484
- return { appendTurn, loadRecent, loadAll, retractLastTurn, latestSeq, latestTurnAt, countUserTurnsAfter };
419
+ return { appendTurn, writeTurn, loadRecent, loadAll, retractLastTurn, latestSeq, latestTurnAt, countUserTurnsAfter };
485
420
  }
@@ -22,8 +22,8 @@
22
22
  * row and the thread reads as it did; a listing widened with `includeArchived`
23
23
  * is the only way to obtain the id it takes, so the recovery is reachable by a
24
24
  * host that holds no thread ids it obtained elsewhere.
25
- * `purgeThread` is not recoverable: it removes the `messages` rows, the report
26
- * session-state rows, and the metadata rows of the whole subtree in one
25
+ * `purgeThread` is not recoverable: it removes the `messages` rows, the turn
26
+ * records, the report session-state rows, the kept tool outputs, and the metadata rows of the whole subtree in one
27
27
  * transaction, so a failure partway leaves them all — never a thread stripped of
28
28
  * its transcript, nor a transcript with nothing naming it. It is the thread-scoped member of this package's
29
29
  * reclamation vocabulary — `purgeAnalysis` reclaims an analysis's whole
@@ -222,7 +222,7 @@ export interface ThreadStore {
222
222
  /**
223
223
  * Reclaim the subtree's whole footprint: the named thread, every descendant
224
224
  * reachable through `parent_thread_id` at any depth, and the `messages` rows
225
- * of every one of them, removed together in one transaction. Unrecoverable —
225
+ * and the turn records of every one of them, removed together in one transaction. Unrecoverable —
226
226
  * nothing survives, and no tombstone marks that any of it existed. A
227
227
  * `thread_id` with no row succeeds as a no-op.
228
228
  *
@@ -22,8 +22,8 @@
22
22
  * row and the thread reads as it did; a listing widened with `includeArchived`
23
23
  * is the only way to obtain the id it takes, so the recovery is reachable by a
24
24
  * host that holds no thread ids it obtained elsewhere.
25
- * `purgeThread` is not recoverable: it removes the `messages` rows, the report
26
- * session-state rows, and the metadata rows of the whole subtree in one
25
+ * `purgeThread` is not recoverable: it removes the `messages` rows, the turn
26
+ * records, the report session-state rows, the kept tool outputs, and the metadata rows of the whole subtree in one
27
27
  * transaction, so a failure partway leaves them all — never a thread stripped of
28
28
  * its transcript, nor a transcript with nothing naming it. It is the thread-scoped member of this package's
29
29
  * reclamation vocabulary — `purgeAnalysis` reclaims an analysis's whole
@@ -295,8 +295,13 @@ export function createThreadStore(pool) {
295
295
  // set that can still change, and after it the rows are gone.
296
296
  return withTransaction(pool, "thread-store.purgeThread", (client) => tryMutation("thread-store.purgeThread.messages", () => client.query(`${SUBTREE_CTE}
297
297
  DELETE FROM messages WHERE thread_id IN (SELECT thread_id FROM subtree)`, [threadId]))
298
+ .andThen(() => tryMutation("thread-store.purgeThread.turnRecords", () => client.query(`${SUBTREE_CTE}
299
+ DELETE FROM cortex_thread_turns WHERE thread_id IN (SELECT thread_id FROM subtree)`, [threadId])))
298
300
  .andThen(() => tryMutation("thread-store.purgeThread.reportSessionState", () => client.query(`${SUBTREE_CTE}
299
301
  DELETE FROM cortex_report_session_state WHERE thread_id IN (SELECT thread_id FROM subtree)`, [threadId])))
302
+ // A kept text of a run names no thread, thus it stays for the analysis purge.
303
+ .andThen(() => tryMutation("thread-store.purgeThread.toolOutputs", () => client.query(`${SUBTREE_CTE}
304
+ DELETE FROM cortex_tool_outputs WHERE thread_id IN (SELECT thread_id FROM subtree)`, [threadId])))
300
305
  .andThen(() => tryMutation("thread-store.purgeThread.thread", () => client.query(`${SUBTREE_CTE}
301
306
  DELETE FROM cortex_analysis_threads WHERE thread_id IN (SELECT thread_id FROM subtree)
302
307
  RETURNING thread_id`, [threadId])))
@@ -10,37 +10,31 @@
10
10
  * streams before the stop reason arrives, and only a `tool-calls` (and, for its
11
11
  * leading calls, a `length`) finish dispatches it.
12
12
  *
13
- * The repair is a STRIP, never an invented result. A call that was not
14
- * dispatched did not happen, and a fabricated result would record an execution
15
- * that did not occur — the model would read it as ground truth on the next
16
- * turn. The prose of the message survives the strip; a message left with
17
- * nothing the wire can render disappears entirely.
13
+ * The repair only appends an answer; it never edits or removes a message,
14
+ * because a signed thinking block and the prompt cache both key on the exact
15
+ * prior prefix.
18
16
  */
19
- import type { ModelMessage } from "ai";
20
- /** One tool call removed by {@link stripUnansweredToolCalls}, identified for the caller's diagnostic record. */
21
- export interface DroppedToolCall {
17
+ import type { ModelMessage, ToolCallPart, ToolResultPart } from "ai";
18
+ export declare const NOT_RUN_TOOL_RESULT = "Not run: the turn ended before this call ran.";
19
+ export declare function notRunResult(call: Pick<ToolCallPart, "toolCallId" | "toolName">): ToolResultPart;
20
+ /** One tool call that {@link answerUnansweredToolCalls} answered, identified for the caller's diagnostic record. */
21
+ export interface AnsweredToolCall {
22
22
  readonly toolCallId: string;
23
23
  readonly toolName: string;
24
24
  }
25
25
  /**
26
- * Remove, in place, every tool-call part at or past `fromIndex` that has no
27
- * matching tool result anywhere in `messages`. Returns the removed calls in
28
- * transcript order — empty on the healthy path — so the caller can log what
29
- * a reader of the stored thread would otherwise never learn.
26
+ * Answer, in place, every tool call at or past `fromIndex` that has no matching
27
+ * tool result anywhere in `messages`. No message is removed or changed. Returns
28
+ * the answered calls in transcript order — empty on the healthy path — so the
29
+ * caller can log what a reader of the stored thread would otherwise never learn.
30
30
  *
31
31
  * Results are matched by id across the whole array, not by adjacency: the loop
32
32
  * appends a round's results directly after its assistant message, but a
33
33
  * deferred-image user message can ride between rounds, and the stored shape is
34
34
  * not this function's to assume.
35
35
  *
36
- * A message that keeps a text or file part survives the strip — refusal prose
37
- * is the one account the reader has of a filtered reply. A message left with
38
- * no such part (a call-only message, or reasoning beside the removed call) is
39
- * removed whole: reasoning without the output it preceded is not replayable,
40
- * and an empty assistant message is itself a wire violation.
41
- *
42
36
  * `fromIndex` bounds the REPAIR, not the result scan. The loop passes its
43
- * `initial.length` so it never rewrites the caller's prefix; the assembly
44
- * repair passes nothing and covers the whole stored window.
37
+ * `initial.length` so it never answers inside the caller's prefix; the history
38
+ * load passes nothing and covers the whole loaded window.
45
39
  */
46
- export declare function stripUnansweredToolCalls(messages: ModelMessage[], fromIndex?: number): DroppedToolCall[];
40
+ export declare function answerUnansweredToolCalls(messages: ModelMessage[], fromIndex?: number): AnsweredToolCall[];