@inflexa-ai/harness 0.1.1 → 0.2.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 (268) hide show
  1. package/dist/agents/conversation-agent.d.ts +12 -11
  2. package/dist/agents/conversation-agent.js +30 -33
  3. package/dist/agents/report-builder.js +2 -1
  4. package/dist/agents/sandbox/bulk-transcriptomics-agent.js +1 -1
  5. package/dist/agents/sandbox/cheminformatics-agent.js +1 -19
  6. package/dist/agents/sandbox/chromatin-agent.js +1 -10
  7. package/dist/agents/sandbox/dna-methylation-agent.js +1 -10
  8. package/dist/agents/sandbox/drug-repurposing-agent.js +4 -14
  9. package/dist/agents/sandbox/enrichment-agent.js +1 -10
  10. package/dist/agents/sandbox/ephemeral-executor.js +1 -10
  11. package/dist/agents/sandbox/genomic-variant-agent.js +1 -3
  12. package/dist/agents/sandbox/immune-profiling-agent.js +1 -13
  13. package/dist/agents/sandbox/metabolomics-agent.js +1 -1
  14. package/dist/agents/sandbox/microbiome-agent.js +1 -1
  15. package/dist/agents/sandbox/multi-omics-integration-agent.js +1 -12
  16. package/dist/agents/sandbox/multimodal-sc-agent.js +1 -1
  17. package/dist/agents/sandbox/network-agent.js +1 -10
  18. package/dist/agents/sandbox/pkpd-clinical-response-agent.js +2 -8
  19. package/dist/agents/sandbox/proteomics-agent.js +1 -1
  20. package/dist/agents/sandbox/scientific-executor.js +1 -10
  21. package/dist/agents/sandbox/shared.d.ts +17 -6
  22. package/dist/agents/sandbox/shared.js +49 -38
  23. package/dist/agents/sandbox/single-cell-agent.js +1 -1
  24. package/dist/agents/sandbox/spatial-omics-agent.js +1 -1
  25. package/dist/agents/sandbox/statistical-modeling-agent.js +1 -1
  26. package/dist/agents/sandbox/translational-safety-agent.js +3 -9
  27. package/dist/agents/sandbox/types.d.ts +1 -1
  28. package/dist/agents/system-prompt.d.ts +37 -11
  29. package/dist/agents/system-prompt.js +35 -12
  30. package/dist/app/data-profile-orientation.d.ts +34 -0
  31. package/dist/app/data-profile-orientation.js +117 -0
  32. package/dist/contracts/schemas/chat-parts.d.ts +9 -9
  33. package/dist/execution/artifact-metadata.js +2 -1
  34. package/dist/execution/ephemeral-runner.d.ts +13 -0
  35. package/dist/execution/ephemeral-runner.js +23 -1
  36. package/dist/execution/run-synthesis.js +20 -19
  37. package/dist/execution/step-summary.js +2 -1
  38. package/dist/index.d.ts +6 -2
  39. package/dist/index.js +19 -5
  40. package/dist/loop/__fixtures__/scripted-provider.d.ts +2 -2
  41. package/dist/loop/__fixtures__/scripted-provider.js +2 -1
  42. package/dist/loop/metrics.d.ts +33 -1
  43. package/dist/loop/metrics.js +56 -2
  44. package/dist/loop/run-agent.d.ts +13 -1
  45. package/dist/loop/run-agent.js +19 -4
  46. package/dist/memory/card-builders.d.ts +8 -1
  47. package/dist/memory/card-builders.js +20 -3
  48. package/dist/memory/reconstruct-cards.js +4 -3
  49. package/dist/memory/thread-history.d.ts +27 -2
  50. package/dist/memory/thread-history.js +45 -5
  51. package/dist/memory/working-memory.d.ts +93 -10
  52. package/dist/memory/working-memory.js +220 -84
  53. package/dist/prompts/SOUL.d.ts +22 -9
  54. package/dist/prompts/SOUL.js +56 -37
  55. package/dist/prompts/analogical-reasoner.d.ts +1 -1
  56. package/dist/prompts/analogical-reasoner.js +2 -2
  57. package/dist/prompts/briefing.d.ts +100 -0
  58. package/dist/prompts/briefing.js +166 -0
  59. package/dist/prompts/conversation.d.ts +1 -1
  60. package/dist/prompts/conversation.js +94 -440
  61. package/dist/prompts/literature-reviewer.d.ts +1 -1
  62. package/dist/prompts/literature-reviewer.js +10 -10
  63. package/dist/prompts/planner.js +28 -59
  64. package/dist/prompts/report-builder.d.ts +1 -1
  65. package/dist/prompts/report-builder.js +71 -24
  66. package/dist/prompts/sandbox/bulk-transcriptomics-agent.d.ts +1 -1
  67. package/dist/prompts/sandbox/bulk-transcriptomics-agent.js +2 -7
  68. package/dist/prompts/sandbox/cheminformatics-agent.d.ts +1 -1
  69. package/dist/prompts/sandbox/cheminformatics-agent.js +11 -15
  70. package/dist/prompts/sandbox/chromatin-agent.d.ts +1 -1
  71. package/dist/prompts/sandbox/chromatin-agent.js +3 -7
  72. package/dist/prompts/sandbox/data-profiler.d.ts +1 -1
  73. package/dist/prompts/sandbox/data-profiler.js +13 -8
  74. package/dist/prompts/sandbox/dna-methylation-agent.d.ts +1 -1
  75. package/dist/prompts/sandbox/dna-methylation-agent.js +2 -7
  76. package/dist/prompts/sandbox/drug-repurposing-agent.d.ts +1 -1
  77. package/dist/prompts/sandbox/drug-repurposing-agent.js +15 -16
  78. package/dist/prompts/sandbox/enrichment-agent.d.ts +1 -1
  79. package/dist/prompts/sandbox/enrichment-agent.js +2 -9
  80. package/dist/prompts/sandbox/genomic-variant-agent.d.ts +1 -1
  81. package/dist/prompts/sandbox/genomic-variant-agent.js +2 -6
  82. package/dist/prompts/sandbox/immune-profiling-agent.d.ts +1 -1
  83. package/dist/prompts/sandbox/immune-profiling-agent.js +3 -8
  84. package/dist/prompts/sandbox/metabolomics-agent.d.ts +1 -1
  85. package/dist/prompts/sandbox/metabolomics-agent.js +1 -7
  86. package/dist/prompts/sandbox/microbiome-agent.d.ts +1 -1
  87. package/dist/prompts/sandbox/microbiome-agent.js +2 -7
  88. package/dist/prompts/sandbox/multi-omics-integration-agent.d.ts +1 -1
  89. package/dist/prompts/sandbox/multi-omics-integration-agent.js +2 -8
  90. package/dist/prompts/sandbox/multimodal-sc-agent.d.ts +1 -1
  91. package/dist/prompts/sandbox/multimodal-sc-agent.js +2 -7
  92. package/dist/prompts/sandbox/network-agent.d.ts +1 -1
  93. package/dist/prompts/sandbox/network-agent.js +2 -7
  94. package/dist/prompts/sandbox/proteomics-agent.d.ts +1 -1
  95. package/dist/prompts/sandbox/proteomics-agent.js +2 -9
  96. package/dist/prompts/sandbox/single-cell-agent.d.ts +1 -1
  97. package/dist/prompts/sandbox/single-cell-agent.js +2 -9
  98. package/dist/prompts/sandbox/spatial-omics-agent.d.ts +1 -1
  99. package/dist/prompts/sandbox/spatial-omics-agent.js +3 -6
  100. package/dist/prompts/sandbox/statistical-modeling-agent.d.ts +1 -1
  101. package/dist/prompts/sandbox/statistical-modeling-agent.js +2 -7
  102. package/dist/prompts/sandbox/translational-safety-agent.d.ts +1 -1
  103. package/dist/prompts/sandbox/translational-safety-agent.js +13 -14
  104. package/dist/prompts/sandbox-standards.d.ts +22 -14
  105. package/dist/prompts/sandbox-standards.js +91 -71
  106. package/dist/prompts/synthesis-agent.d.ts +1 -1
  107. package/dist/prompts/synthesis-agent.js +33 -131
  108. package/dist/provenance/collector.js +1 -1
  109. package/dist/providers/ai-sdk.js +31 -4
  110. package/dist/providers/prompt-cache.d.ts +74 -0
  111. package/dist/providers/prompt-cache.js +77 -0
  112. package/dist/providers/types.d.ts +33 -0
  113. package/dist/reference-data/catalog.d.ts +131 -0
  114. package/dist/reference-data/catalog.js +348 -0
  115. package/dist/reference-data/receipt.d.ts +41 -0
  116. package/dist/reference-data/receipt.js +40 -0
  117. package/dist/schemas/data-profile-schemas.js +5 -3
  118. package/dist/schemas/plan-schemas.d.ts +21 -21
  119. package/dist/schemas/plan-schemas.js +9 -3
  120. package/dist/schemas/run-synthesis.d.ts +8 -4
  121. package/dist/schemas/run-synthesis.js +92 -56
  122. package/dist/schemas/validate-plan.js +1 -1
  123. package/dist/schemas/workflow-state.d.ts +7 -1
  124. package/dist/schemas/workflow-state.js +33 -19
  125. package/dist/state/artifacts.d.ts +13 -0
  126. package/dist/state/artifacts.js +27 -0
  127. package/dist/state/data-profile.d.ts +64 -5
  128. package/dist/state/index.d.ts +3 -3
  129. package/dist/state/index.js +1 -1
  130. package/dist/tasks/data-profile.d.ts +20 -2
  131. package/dist/tasks/data-profile.js +71 -22
  132. package/dist/tools/bio/chembl.d.ts +38 -0
  133. package/dist/tools/bio/chembl.js +138 -0
  134. package/dist/tools/bio/comptox.d.ts +50 -0
  135. package/dist/tools/bio/comptox.js +149 -0
  136. package/dist/tools/bio/get-impc-ko-profile.js +9 -6
  137. package/dist/tools/bio/index.d.ts +5 -17
  138. package/dist/tools/bio/index.js +5 -17
  139. package/dist/tools/bio/keys.d.ts +14 -9
  140. package/dist/tools/bio/keys.js +16 -16
  141. package/dist/tools/bio/opentargets.d.ts +38 -0
  142. package/dist/tools/bio/opentargets.js +89 -0
  143. package/dist/tools/bio/pubchem.d.ts +29 -0
  144. package/dist/tools/bio/pubchem.js +92 -0
  145. package/dist/tools/bio/pubmed.d.ts +51 -0
  146. package/dist/tools/bio/pubmed.js +100 -0
  147. package/dist/tools/bio/search-bgee-expression.js +12 -6
  148. package/dist/tools/bio/search-clinical-trials.js +16 -5
  149. package/dist/tools/bio/search-faers.js +8 -4
  150. package/dist/tools/bio/search-geo-datasets.js +20 -6
  151. package/dist/tools/bio/search-gwas-catalog.d.ts +1 -1
  152. package/dist/tools/bio/search-pharmgkb.js +10 -4
  153. package/dist/tools/display/index.d.ts +1 -0
  154. package/dist/tools/display/index.js +1 -0
  155. package/dist/tools/display/normalize-echart-spec.d.ts +61 -0
  156. package/dist/tools/display/normalize-echart-spec.js +211 -0
  157. package/dist/tools/display/show-user.d.ts +6 -1
  158. package/dist/tools/display/show-user.js +25 -12
  159. package/dist/tools/execute-plan.js +5 -2
  160. package/dist/tools/iterate-report.d.ts +168 -118
  161. package/dist/tools/iterate-report.js +334 -96
  162. package/dist/tools/lib/chembl-client.d.ts +12 -0
  163. package/dist/tools/lib/chembl-client.js +127 -0
  164. package/dist/tools/lib/ctx-ops.d.ts +224 -0
  165. package/dist/tools/lib/ctx-ops.js +450 -0
  166. package/dist/tools/lib/iuphar-client.js +10 -1
  167. package/dist/tools/lib/openfda-client.js +11 -3
  168. package/dist/tools/lib/pathway-client.js +10 -4
  169. package/dist/tools/lib/pubchem-ops.d.ts +74 -0
  170. package/dist/tools/lib/pubchem-ops.js +224 -0
  171. package/dist/tools/memory/update-working-memory.d.ts +45 -7
  172. package/dist/tools/memory/update-working-memory.js +111 -41
  173. package/dist/tools/report/index.d.ts +3 -2
  174. package/dist/tools/report/index.js +3 -2
  175. package/dist/tools/research/generate-analogy-report.js +25 -12
  176. package/dist/tools/research/generate-plan.d.ts +12 -5
  177. package/dist/tools/research/generate-plan.js +168 -37
  178. package/dist/tools/research/index.d.ts +1 -0
  179. package/dist/tools/research/index.js +1 -0
  180. package/dist/tools/research/inspect-data-profile.d.ts +85 -0
  181. package/dist/tools/research/inspect-data-profile.js +161 -0
  182. package/dist/tools/research/literature-reviewer.js +3 -4
  183. package/dist/tools/sandbox/list-available-packages.d.ts +79 -5
  184. package/dist/tools/sandbox/list-available-packages.js +152 -10
  185. package/dist/tools/sandbox/list-available-refs.d.ts +141 -5
  186. package/dist/tools/sandbox/list-available-refs.js +647 -134
  187. package/dist/tools/sandbox/report-blocker.d.ts +39 -9
  188. package/dist/tools/sandbox/report-blocker.js +43 -20
  189. package/dist/tools/workspace/read-file.js +4 -5
  190. package/dist/tools/workspace/run-exec.d.ts +4 -4
  191. package/dist/tools/workspace/run-exec.js +4 -4
  192. package/dist/tools/workspace/show-file.js +10 -6
  193. package/dist/tools/workspace/show-plan.js +12 -6
  194. package/dist/tools/workspace/workspace-search.d.ts +1 -1
  195. package/dist/tools/workspace/workspace-search.js +11 -5
  196. package/dist/workflows/execute-analysis.d.ts +43 -4
  197. package/dist/workflows/execute-analysis.js +107 -6
  198. package/dist/workflows/target-assessment/lib/citation-validator.js +2 -2
  199. package/dist/workflows/target-assessment/lib/clinical-consequence-annotator.js +2 -1
  200. package/dist/workspace/paths.d.ts +2 -2
  201. package/dist/workspace/paths.js +2 -2
  202. package/package.json +33 -33
  203. package/dist/execution/filesystem-artifact-registry.d.ts +0 -57
  204. package/dist/execution/filesystem-artifact-registry.js +0 -92
  205. package/dist/lib/identifiers.d.ts +0 -8
  206. package/dist/lib/identifiers.js +0 -10
  207. package/dist/lib/mime-detect.d.ts +0 -17
  208. package/dist/lib/mime-detect.js +0 -29
  209. package/dist/lib/streamed-download.d.ts +0 -28
  210. package/dist/lib/streamed-download.js +0 -111
  211. package/dist/lib/trace-context.d.ts +0 -11
  212. package/dist/lib/trace-context.js +0 -49
  213. package/dist/providers/llm-capabilities.d.ts +0 -33
  214. package/dist/providers/llm-capabilities.js +0 -58
  215. package/dist/schemas/render-step-prompt.d.ts +0 -32
  216. package/dist/schemas/render-step-prompt.js +0 -76
  217. package/dist/tools/bio/find-approval-precedent.d.ts +0 -32
  218. package/dist/tools/bio/find-approval-precedent.js +0 -72
  219. package/dist/tools/bio/get-article-details.d.ts +0 -11
  220. package/dist/tools/bio/get-article-details.js +0 -20
  221. package/dist/tools/bio/get-article-full-text.d.ts +0 -19
  222. package/dist/tools/bio/get-article-full-text.js +0 -32
  223. package/dist/tools/bio/get-bioactivity.d.ts +0 -11
  224. package/dist/tools/bio/get-bioactivity.js +0 -23
  225. package/dist/tools/bio/get-drug-info.d.ts +0 -9
  226. package/dist/tools/bio/get-drug-info.js +0 -22
  227. package/dist/tools/bio/get-mechanism.d.ts +0 -8
  228. package/dist/tools/bio/get-mechanism.js +0 -18
  229. package/dist/tools/bio/get-pubchem-assays.d.ts +0 -22
  230. package/dist/tools/bio/get-pubchem-assays.js +0 -107
  231. package/dist/tools/bio/get-pubchem-cross-refs.d.ts +0 -15
  232. package/dist/tools/bio/get-pubchem-cross-refs.js +0 -68
  233. package/dist/tools/bio/get-target-safety.d.ts +0 -18
  234. package/dist/tools/bio/get-target-safety.js +0 -26
  235. package/dist/tools/bio/search-compounds.d.ts +0 -22
  236. package/dist/tools/bio/search-compounds.js +0 -160
  237. package/dist/tools/bio/search-ctx-chemical.d.ts +0 -50
  238. package/dist/tools/bio/search-ctx-chemical.js +0 -130
  239. package/dist/tools/bio/search-ctx-exposure.d.ts +0 -55
  240. package/dist/tools/bio/search-ctx-exposure.js +0 -176
  241. package/dist/tools/bio/search-ctx-hazard.d.ts +0 -111
  242. package/dist/tools/bio/search-ctx-hazard.js +0 -178
  243. package/dist/tools/bio/search-opentargets.d.ts +0 -11
  244. package/dist/tools/bio/search-opentargets.js +0 -25
  245. package/dist/tools/bio/search-pubchem-compound.d.ts +0 -26
  246. package/dist/tools/bio/search-pubchem-compound.js +0 -110
  247. package/dist/tools/bio/search-pubmed.d.ts +0 -17
  248. package/dist/tools/bio/search-pubmed.js +0 -31
  249. package/dist/tools/bio/search-targets.d.ts +0 -9
  250. package/dist/tools/bio/search-targets.js +0 -20
  251. package/dist/tools/bio/search-toxcast.d.ts +0 -36
  252. package/dist/tools/bio/search-toxcast.js +0 -141
  253. package/dist/workflows/register-workflows.d.ts +0 -51
  254. package/dist/workflows/register-workflows.js +0 -48
  255. package/dist/workflows/resume-execute-analysis.d.ts +0 -70
  256. package/dist/workflows/resume-execute-analysis.js +0 -80
  257. package/dist/workflows/target-assessment/correction-loop.d.ts +0 -45
  258. package/dist/workflows/target-assessment/correction-loop.js +0 -62
  259. package/dist/workflows/target-assessment/steps/phase0-resolve.d.ts +0 -71
  260. package/dist/workflows/target-assessment/steps/phase0-resolve.js +0 -21
  261. package/dist/workflows/target-assessment/steps/phase1-aggregate.d.ts +0 -1018
  262. package/dist/workflows/target-assessment/steps/phase1-aggregate.js +0 -25
  263. package/dist/workspace/mount-strategy.d.ts +0 -50
  264. package/dist/workspace/mount-strategy.js +0 -67
  265. package/dist/workspace/provenance-collector.d.ts +0 -34
  266. package/dist/workspace/provenance-collector.js +0 -16
  267. package/dist/workspace/workspace-paths.d.ts +0 -26
  268. package/dist/workspace/workspace-paths.js +0 -31
@@ -79,6 +79,28 @@ function getInstruments() {
79
79
  export function __resetThreadHistoryMetricsForTest() {
80
80
  instruments = undefined;
81
81
  }
82
+ /**
83
+ * Chunked-eviction block size, in whole turns — the granularity at which
84
+ * `loadRecent`'s retained window may advance its START turn.
85
+ *
86
+ * `loadRecent` snaps its eviction count UP to a multiple of this block, so the
87
+ * window's first turn — hence `messages[0]` and the whole tools+system+history
88
+ * prompt-cache prefix — stays byte-identical across a run of appends and shifts
89
+ * only once per block instead of once per turn. That single shift is the only
90
+ * message-cache miss the block costs.
91
+ *
92
+ * The block is at once (a) the cache-miss cadence — one prefix shift per
93
+ * `EVICTION_BLOCK_TURNS` appends once a thread is over budget — and (b) the most
94
+ * extra context ever sacrificed: snapping up drops up to `EVICTION_BLOCK_TURNS -
95
+ * 1` oldest turns the budget alone would have kept, trading a little history for
96
+ * a still prefix. 4 holds the prefix for ~4 turns at a cost of at most 3 turns of
97
+ * headroom — negligible against a ~120k-token budget that spans far more turns,
98
+ * while cutting cache misses on a long thread roughly fourfold. A turn-count
99
+ * block (not a token block) is deliberate: a cache miss is triggered by the START
100
+ * turn moving, a per-turn event, so counting in turns makes the miss cadence
101
+ * exact and independent of how large any individual turn is.
102
+ */
103
+ export const EVICTION_BLOCK_TURNS = 4;
82
104
  /**
83
105
  * Create a `ThreadHistory` bound to a Postgres pool — a factory closure
84
106
  * capturing `pool` (dependency injection per the harness-durable-runtime spec). The `messages` table is
@@ -125,9 +147,22 @@ export function createThreadHistory(pool) {
125
147
  const turns = groupTurns(parsed, (row) => isGenuineUserStart(row.message));
126
148
  const turnTokens = turns.map((turn) => turn.reduce((sum, row) => sum + row.tokens, 0));
127
149
  const threadTotal = turnTokens.reduce((sum, n) => sum + n, 0);
128
- // Walk turns newest-first, accumulating token cost. The most recent turn
129
- // is always included — even if it alone exceeds the budget, a valid
130
- // sequence beats an under-budget one. Older turns join while they fit.
150
+ // Chunked eviction holds the prompt-cache prefix still. The budget alone
151
+ // evicts the OLDEST turns newest-first, so the window START — the stable
152
+ // head of the tools+system+history cache prefix — advances by ~one turn
153
+ // per appended turn; every turn then ships a shifted `messages[0]` and
154
+ // rewrites the whole message cache instead of reading it back (watch
155
+ // `cortex.harness.agent.cache_read_tokens` collapse on long threads).
156
+ // Instead, take the budget-minimal eviction and snap it UP to a whole
157
+ // `EVICTION_BLOCK_TURNS` block: `evicted` stays constant while the minimum
158
+ // sits inside a block and jumps by a block when it crosses, so
159
+ // `messages[0]` is byte-identical for a block of appends (the cache reads
160
+ // survive) and shifts only once per block. Snapping UP retains a subset of
161
+ // the budget-minimal window, so the kept tokens never exceed `tokenBudget`.
162
+ //
163
+ // Budget-minimal fill: walk newest-first, always keeping the most recent
164
+ // turn (a valid over-budget single turn beats an under-budget cut), then
165
+ // add older turns while they fit.
131
166
  let included = 0;
132
167
  let cumulative = 0;
133
168
  for (let t = turns.length - 1; t >= 0; t--) {
@@ -136,13 +171,18 @@ export function createThreadHistory(pool) {
136
171
  cumulative += turnTokens[t];
137
172
  included++;
138
173
  }
139
- const turnsEvicted = turns.length - included;
174
+ const minimalEvicted = turns.length - included;
175
+ // Snap the eviction count up to a block boundary, clamped below the last
176
+ // turn so the most recent turn always ships. Both terms of the min are
177
+ // >= `minimalEvicted`, so `turnsEvicted >= minimalEvicted`: the retained
178
+ // suffix is a subset of the budget-minimal one and stays within budget.
179
+ const turnsEvicted = turns.length === 0 ? 0 : Math.min(Math.ceil(minimalEvicted / EVICTION_BLOCK_TURNS) * EVICTION_BLOCK_TURNS, turns.length - 1);
140
180
  const { totalTokens, turnsEvicted: turnsEvictedHist } = getInstruments();
141
181
  const attributes = { eviction: turnsEvicted > 0 };
142
182
  totalTokens.record(threadTotal, attributes);
143
183
  turnsEvictedHist.record(turnsEvicted, attributes);
144
184
  return turns
145
- .slice(turns.length - included)
185
+ .slice(turnsEvicted)
146
186
  .flat()
147
187
  .map((row) => row.message);
148
188
  });
@@ -6,18 +6,48 @@
6
6
  * to Markdown only when injected (see the harness-thread-store spec).
7
7
  *
8
8
  * Four sections. `goal`, `constraints`, `hypotheses` are analysis-flat;
9
- * `findings` is run-scoped, keyed by `runId`, so a finding stays attributable
10
- * to the run that produced it. There is deliberately no `context` section —
9
+ * `findings` is stored keyed by `runId`, so a finding stays attributable to
10
+ * the run that produced it. There is deliberately no `context` section —
11
11
  * analysis context lives in `cortex_analysis_state.context` and is injected
12
12
  * separately by the chat route.
13
13
  *
14
14
  * `updateSection` is a section-addressable read-modify-write: the named
15
15
  * section is replaced or amended and the other three are left byte-identical.
16
+ *
17
+ * **Working memory is re-injected, in full, into the uncached tail of every
18
+ * turn** — so every stored character is re-paid on every later turn for the
19
+ * life of the analysis. Two consequences shape this module:
20
+ *
21
+ * - **Every section is bounded at write time** (`WORKING_MEMORY_LIMITS`). An
22
+ * over-cap write is *rejected* with a model-actionable message, never
23
+ * silently truncated: the agent must shorten the text or retire a stale
24
+ * entry. Every section — findings included — therefore supports
25
+ * add/revise/retire, so memory can shrink as well as grow.
26
+ * - **Memory holds references to runs, never run content.** A finding cites
27
+ * the `runId` that produced it and renders as one line in a single flat
28
+ * list; the run's detail stays retrievable via `inspect_run`.
16
29
  */
17
- import type { ResultAsync } from "neverthrow";
30
+ import { type ResultAsync } from "neverthrow";
18
31
  import type { Pool } from "pg";
19
32
  import { z } from "zod";
20
33
  import { type DbError } from "../lib/db-result.js";
34
+ /**
35
+ * The write-time bounds on working memory. `updateSection` enforces them, the
36
+ * `update_working_memory` input schema declares them to the model (importing
37
+ * these same numbers), and `renderWorkingMemory` uses them to bound its output.
38
+ */
39
+ export declare const WORKING_MEMORY_LIMITS: {
40
+ /** Max characters in the goal. */
41
+ readonly goalChars: 500;
42
+ /** Max characters in one constraint / hypothesis / finding entry. */
43
+ readonly entryChars: 300;
44
+ /** Max constraint entries. */
45
+ readonly constraints: 20;
46
+ /** Max hypothesis entries. */
47
+ readonly hypotheses: 10;
48
+ /** Max finding entries, summed across every run. */
49
+ readonly findings: 30;
50
+ };
21
51
  /** A binding rule — user-stated or agent-derived. The highest-value section. */
22
52
  export declare const constraintSchema: z.ZodObject<{
23
53
  id: z.ZodString;
@@ -43,6 +73,11 @@ export type Finding = z.infer<typeof findingSchema>;
43
73
  /**
44
74
  * The persisted `data` shape — one JSONB object per analysis. `findings` is
45
75
  * keyed by `runId`; an analysis with no recorded findings has an empty map.
76
+ *
77
+ * Deliberately unbounded: the caps are a *write-time* rule, not a read-time
78
+ * one. A row written before the caps existed must still `load` — so this
79
+ * schema validates shape only, and `renderWorkingMemory` bounds what an
80
+ * oversized row is allowed to cost.
46
81
  */
47
82
  export declare const workingMemorySchema: z.ZodObject<{
48
83
  goal: z.ZodString;
@@ -66,6 +101,20 @@ export declare const workingMemorySchema: z.ZodObject<{
66
101
  export type WorkingMemory = z.infer<typeof workingMemorySchema>;
67
102
  /** The shape `load` returns for an analysis with no working-memory row. */
68
103
  export declare function emptyWorkingMemory(): WorkingMemory;
104
+ /**
105
+ * A refused write: a cap was hit, or an id addresses nothing. Not a storage
106
+ * failure — it is an expected outcome the *model* can correct, so `message` is
107
+ * written for the model and says what to do next (shorten this, retire that).
108
+ * The tool surfaces it as an `is_error` tool result; the row is untouched.
109
+ */
110
+ export interface WorkingMemoryRejection {
111
+ readonly type: "working_memory_rejected";
112
+ readonly message: string;
113
+ }
114
+ /** `updateSection`'s error channel: a storage failure, or a refused write. */
115
+ export type WorkingMemoryError = DbError | WorkingMemoryRejection;
116
+ /** Narrow a `WorkingMemoryError` to the model-correctable branch. */
117
+ export declare function isWorkingMemoryRejection(error: WorkingMemoryError): error is WorkingMemoryRejection;
69
118
  /**
70
119
  * A list-section amendment: append a new entry, revise an existing one's
71
120
  * text, or retire a stale one. `revise`/`retire` address an entry by the
@@ -95,6 +144,23 @@ export type ConstraintOp = {
95
144
  readonly op: "retire";
96
145
  readonly id: string;
97
146
  };
147
+ /**
148
+ * As `ListOp`, but `add` also carries the `runId` the finding is attributed
149
+ * to. `revise`/`retire` address the finding by its own id — the run holding it
150
+ * is found from that id, so the agent never has to re-cite the run.
151
+ */
152
+ export type FindingOp = {
153
+ readonly op: "add";
154
+ readonly runId: string;
155
+ readonly text: string;
156
+ } | {
157
+ readonly op: "revise";
158
+ readonly id: string;
159
+ readonly text: string;
160
+ } | {
161
+ readonly op: "retire";
162
+ readonly id: string;
163
+ };
98
164
  /** The `value` argument of `updateSection`, keyed by the `section` name. */
99
165
  export interface SectionValue {
100
166
  readonly goal: {
@@ -102,10 +168,7 @@ export interface SectionValue {
102
168
  };
103
169
  readonly constraint: ConstraintOp;
104
170
  readonly hypothesis: ListOp;
105
- readonly finding: {
106
- readonly runId: string;
107
- readonly text: string;
108
- };
171
+ readonly finding: FindingOp;
109
172
  }
110
173
  export type Section = keyof SectionValue;
111
174
  /**
@@ -118,13 +181,33 @@ export interface WorkingMemoryStore {
118
181
  load(analysisId: string): ResultAsync<WorkingMemory, DbError>;
119
182
  /**
120
183
  * Atomically read-modify-write one section. The other three sections are
121
- * left byte-identical; the row is upserted if absent.
184
+ * left byte-identical; the row is upserted if absent. A write that breaks a
185
+ * cap or addresses an unknown id is refused — `err(WorkingMemoryRejection)`,
186
+ * and the row is left exactly as it was.
122
187
  */
123
- updateSection<S extends Section>(analysisId: string, section: S, value: SectionValue[S]): ResultAsync<void, DbError>;
188
+ updateSection<S extends Section>(analysisId: string, section: S, value: SectionValue[S]): ResultAsync<void, WorkingMemoryError>;
124
189
  /** Serialize working memory to a Markdown document for injection. */
125
190
  render(analysisId: string): ResultAsync<string, DbError>;
126
191
  }
127
- /** Serialize working memory to the Markdown document injected each turn. */
192
+ /**
193
+ * Serialize working memory to the Markdown document injected each turn.
194
+ *
195
+ * Renders only what exists: an empty section is omitted entirely — no heading,
196
+ * no placeholder — and an entirely empty memory renders to the **empty
197
+ * string**, costing nothing. The chat route injects the render
198
+ * unconditionally, and the provider's outbound sanitizer drops a message whose
199
+ * content is `""` before the wire call (`providers/ai-sdk.ts`), so an empty
200
+ * memory reaches the model as no message at all.
201
+ *
202
+ * Findings render as ONE flat list, each line citing the run it came from —
203
+ * never a per-run heading block. Memory holds the reference; `inspect_run`
204
+ * holds the run.
205
+ *
206
+ * The output is bounded to `WORKING_MEMORY_LIMITS` no matter what the row
207
+ * holds. Writes are capped, so only a row written before the caps existed can
208
+ * exceed them — and it degrades to its newest entries rather than exploding
209
+ * the context window.
210
+ */
128
211
  export declare function renderWorkingMemory(wm: WorkingMemory): string;
129
212
  /**
130
213
  * Create a `WorkingMemoryStore` bound to a Postgres pool — a factory closure
@@ -6,16 +6,47 @@
6
6
  * to Markdown only when injected (see the harness-thread-store spec).
7
7
  *
8
8
  * Four sections. `goal`, `constraints`, `hypotheses` are analysis-flat;
9
- * `findings` is run-scoped, keyed by `runId`, so a finding stays attributable
10
- * to the run that produced it. There is deliberately no `context` section —
9
+ * `findings` is stored keyed by `runId`, so a finding stays attributable to
10
+ * the run that produced it. There is deliberately no `context` section —
11
11
  * analysis context lives in `cortex_analysis_state.context` and is injected
12
12
  * separately by the chat route.
13
13
  *
14
14
  * `updateSection` is a section-addressable read-modify-write: the named
15
15
  * section is replaced or amended and the other three are left byte-identical.
16
+ *
17
+ * **Working memory is re-injected, in full, into the uncached tail of every
18
+ * turn** — so every stored character is re-paid on every later turn for the
19
+ * life of the analysis. Two consequences shape this module:
20
+ *
21
+ * - **Every section is bounded at write time** (`WORKING_MEMORY_LIMITS`). An
22
+ * over-cap write is *rejected* with a model-actionable message, never
23
+ * silently truncated: the agent must shorten the text or retire a stale
24
+ * entry. Every section — findings included — therefore supports
25
+ * add/revise/retire, so memory can shrink as well as grow.
26
+ * - **Memory holds references to runs, never run content.** A finding cites
27
+ * the `runId` that produced it and renders as one line in a single flat
28
+ * list; the run's detail stays retrievable via `inspect_run`.
16
29
  */
30
+ import { err, ok, okAsync } from "neverthrow";
17
31
  import { z } from "zod";
18
32
  import { tryMutation, tryQuery, withTransaction } from "../lib/db-result.js";
33
+ /**
34
+ * The write-time bounds on working memory. `updateSection` enforces them, the
35
+ * `update_working_memory` input schema declares them to the model (importing
36
+ * these same numbers), and `renderWorkingMemory` uses them to bound its output.
37
+ */
38
+ export const WORKING_MEMORY_LIMITS = {
39
+ /** Max characters in the goal. */
40
+ goalChars: 500,
41
+ /** Max characters in one constraint / hypothesis / finding entry. */
42
+ entryChars: 300,
43
+ /** Max constraint entries. */
44
+ constraints: 20,
45
+ /** Max hypothesis entries. */
46
+ hypotheses: 10,
47
+ /** Max finding entries, summed across every run. */
48
+ findings: 30,
49
+ };
19
50
  /** A binding rule — user-stated or agent-derived. The highest-value section. */
20
51
  export const constraintSchema = z.object({
21
52
  id: z.string(),
@@ -35,6 +66,11 @@ export const findingSchema = z.object({
35
66
  /**
36
67
  * The persisted `data` shape — one JSONB object per analysis. `findings` is
37
68
  * keyed by `runId`; an analysis with no recorded findings has an empty map.
69
+ *
70
+ * Deliberately unbounded: the caps are a *write-time* rule, not a read-time
71
+ * one. A row written before the caps existed must still `load` — so this
72
+ * schema validates shape only, and `renderWorkingMemory` bounds what an
73
+ * oversized row is allowed to cost.
38
74
  */
39
75
  export const workingMemorySchema = z.object({
40
76
  goal: z.string(),
@@ -46,106 +82,198 @@ export const workingMemorySchema = z.object({
46
82
  export function emptyWorkingMemory() {
47
83
  return { goal: "", constraints: [], hypotheses: [], findings: {} };
48
84
  }
85
+ /** Narrow a `WorkingMemoryError` to the model-correctable branch. */
86
+ export function isWorkingMemoryRejection(error) {
87
+ return error.type === "working_memory_rejected";
88
+ }
49
89
  /** A short, render-friendly entry id. */
50
90
  function newId() {
51
91
  return crypto.randomUUID().slice(0, 8);
52
92
  }
53
- function applyListOp(entries, op) {
93
+ function reject(message) {
94
+ return err({ type: "working_memory_rejected", message });
95
+ }
96
+ function rejectUnknownId(section, id) {
97
+ return reject(`no ${section} with id "${id}" — nothing was changed. Copy an id verbatim from the [id] shown ` +
98
+ `against each entry in the rendered working memory, or add a new entry instead.`);
99
+ }
100
+ function rejectFull(section, limit) {
101
+ return reject(`${section} is full (${limit}/${limit}) — the new entry was NOT recorded. Working memory is ` +
102
+ `re-injected on every turn, so it is capped. Retire a stale entry first (operation "retire" ` +
103
+ `with its id), then add this one — or drop it, if it earns its place less than everything ` +
104
+ `already there.`);
105
+ }
106
+ /** Validate one entry's text against the shared entry cap. */
107
+ function checkEntryText(section, text) {
108
+ const trimmed = text.trim();
109
+ if (trimmed.length === 0)
110
+ return reject(`${section} text is empty — nothing was recorded.`);
111
+ if (trimmed.length > WORKING_MEMORY_LIMITS.entryChars) {
112
+ return reject(`${section} text is ${trimmed.length} characters — the cap is ${WORKING_MEMORY_LIMITS.entryChars}. It was ` +
113
+ `NOT recorded. Rewrite it as one concise line and retry; keep the detail in the run itself, ` +
114
+ `which inspect_run still serves.`);
115
+ }
116
+ return ok(trimmed);
117
+ }
118
+ /** Amend a flat `{id, text}` list — the shared body of constraints and hypotheses. */
119
+ function applyEntryOp(entries, op, section, limit, create) {
54
120
  switch (op.op) {
55
121
  case "add":
56
- return [...entries, { id: newId(), text: op.text }];
57
- case "revise":
58
- return entries.map((e) => (e.id === op.id ? { ...e, text: op.text } : e));
59
- case "retire":
60
- return entries.filter((e) => e.id !== op.id);
122
+ if (entries.length >= limit)
123
+ return rejectFull(section, limit);
124
+ return checkEntryText(section, op.text).map((text) => [...entries, create(text)]);
125
+ case "revise": {
126
+ if (!entries.some((e) => e.id === op.id))
127
+ return rejectUnknownId(section, op.id);
128
+ return checkEntryText(section, op.text).map((text) => entries.map((e) => (e.id === op.id ? { ...e, text } : e)));
129
+ }
130
+ case "retire": {
131
+ if (!entries.some((e) => e.id === op.id))
132
+ return rejectUnknownId(section, op.id);
133
+ return ok(entries.filter((e) => e.id !== op.id));
134
+ }
61
135
  }
62
136
  }
63
- function applyConstraintOp(entries, op) {
137
+ /** Findings across every run — the cap governs the section, not one run's bucket. */
138
+ function countFindings(findings) {
139
+ return Object.values(findings).reduce((n, list) => n + list.length, 0);
140
+ }
141
+ /** The run a finding id hangs under, or `undefined` if no run holds it. */
142
+ function runOfFinding(findings, id) {
143
+ return Object.keys(findings).find((runId) => findings[runId].some((f) => f.id === id));
144
+ }
145
+ function applyFindingOp(findings, op) {
64
146
  switch (op.op) {
65
- case "add":
66
- return [...entries, { id: newId(), text: op.text, origin: op.origin }];
67
- case "revise":
68
- return entries.map((e) => (e.id === op.id ? { ...e, text: op.text } : e));
69
- case "retire":
70
- return entries.filter((e) => e.id !== op.id);
147
+ case "add": {
148
+ if (countFindings(findings) >= WORKING_MEMORY_LIMITS.findings) {
149
+ return rejectFull("findings", WORKING_MEMORY_LIMITS.findings);
150
+ }
151
+ return checkEntryText("finding", op.text).map((text) => ({
152
+ ...findings,
153
+ [op.runId]: [...(findings[op.runId] ?? []), { id: newId(), text }],
154
+ }));
155
+ }
156
+ case "revise": {
157
+ const runId = runOfFinding(findings, op.id);
158
+ if (runId === undefined)
159
+ return rejectUnknownId("finding", op.id);
160
+ return checkEntryText("finding", op.text).map((text) => ({
161
+ ...findings,
162
+ [runId]: findings[runId].map((f) => (f.id === op.id ? { ...f, text } : f)),
163
+ }));
164
+ }
165
+ case "retire": {
166
+ const runId = runOfFinding(findings, op.id);
167
+ if (runId === undefined)
168
+ return rejectUnknownId("finding", op.id);
169
+ const kept = findings[runId].filter((f) => f.id !== op.id);
170
+ const next = { ...findings };
171
+ // An emptied run bucket is dropped whole: memory references a run only
172
+ // for as long as it holds a finding from it.
173
+ if (kept.length === 0)
174
+ delete next[runId];
175
+ else
176
+ next[runId] = kept;
177
+ return ok(next);
178
+ }
71
179
  }
72
180
  }
73
- /** Pure section-addressable amendment. Sibling sections are never touched. */
181
+ /**
182
+ * Pure section-addressable amendment. Sibling sections are never touched; a
183
+ * refused write returns `err` and the caller writes nothing.
184
+ */
74
185
  function applySectionUpdate(wm, section, value) {
75
186
  switch (section) {
76
- case "goal":
77
- return { ...wm, goal: value.text };
78
- case "constraint":
79
- return {
80
- ...wm,
81
- constraints: applyConstraintOp(wm.constraints, value),
82
- };
83
- case "hypothesis":
84
- return {
85
- ...wm,
86
- hypotheses: applyListOp(wm.hypotheses, value),
87
- };
88
- case "finding": {
89
- const { runId, text } = value;
90
- const existing = wm.findings[runId] ?? [];
91
- return {
92
- ...wm,
93
- findings: {
94
- ...wm.findings,
95
- [runId]: [...existing, { id: newId(), text }],
96
- },
97
- };
187
+ case "goal": {
188
+ const text = value.text.trim();
189
+ if (text.length > WORKING_MEMORY_LIMITS.goalChars) {
190
+ return reject(`the goal is ${text.length} characters — the cap is ${WORKING_MEMORY_LIMITS.goalChars}. It was NOT ` +
191
+ `recorded. State the objective in a sentence or two; the supporting detail belongs in ` +
192
+ `constraints and findings, not in the goal.`);
193
+ }
194
+ return ok({ ...wm, goal: text });
195
+ }
196
+ case "constraint": {
197
+ const op = value;
198
+ const origin = op.op === "add" ? op.origin : "agent";
199
+ return applyEntryOp(wm.constraints, op, "constraints", WORKING_MEMORY_LIMITS.constraints, (text) => ({
200
+ id: newId(),
201
+ text,
202
+ origin,
203
+ })).map((constraints) => ({ ...wm, constraints }));
98
204
  }
205
+ case "hypothesis":
206
+ return applyEntryOp(wm.hypotheses, value, "hypotheses", WORKING_MEMORY_LIMITS.hypotheses, (text) => ({
207
+ id: newId(),
208
+ text,
209
+ })).map((hypotheses) => ({ ...wm, hypotheses }));
210
+ case "finding":
211
+ return applyFindingOp(wm.findings, value).map((findings) => ({ ...wm, findings }));
99
212
  }
100
213
  // Unreachable — `section` is exhaustively a `Section`.
101
- return wm;
214
+ return ok(wm);
215
+ }
216
+ /** Every finding, in insertion order, each carrying its run reference. */
217
+ function flattenFindings(findings) {
218
+ return Object.entries(findings).flatMap(([runId, list]) => list.map((f) => ({ ...f, runId })));
219
+ }
220
+ /** Keep the newest `limit` entries, reporting how many older ones were dropped. */
221
+ function newest(entries, limit) {
222
+ if (entries.length <= limit)
223
+ return { shown: entries, omitted: 0 };
224
+ return { shown: entries.slice(entries.length - limit), omitted: entries.length - limit };
102
225
  }
103
- /** Serialize working memory to the Markdown document injected each turn. */
226
+ /** Bound one entry's rendered text. Only a legacy over-cap row can hit this. */
227
+ function clamp(text, limit) {
228
+ const trimmed = text.trim();
229
+ return trimmed.length <= limit ? trimmed : `${trimmed.slice(0, limit)}…`;
230
+ }
231
+ function renderSection(heading, lines, omitted, label) {
232
+ const body = omitted > 0 ? [...lines, `- … ${omitted} older ${label} omitted — over cap; retire entries above.`] : lines;
233
+ return `## ${heading}\n\n${body.join("\n")}`;
234
+ }
235
+ /**
236
+ * Serialize working memory to the Markdown document injected each turn.
237
+ *
238
+ * Renders only what exists: an empty section is omitted entirely — no heading,
239
+ * no placeholder — and an entirely empty memory renders to the **empty
240
+ * string**, costing nothing. The chat route injects the render
241
+ * unconditionally, and the provider's outbound sanitizer drops a message whose
242
+ * content is `""` before the wire call (`providers/ai-sdk.ts`), so an empty
243
+ * memory reaches the model as no message at all.
244
+ *
245
+ * Findings render as ONE flat list, each line citing the run it came from —
246
+ * never a per-run heading block. Memory holds the reference; `inspect_run`
247
+ * holds the run.
248
+ *
249
+ * The output is bounded to `WORKING_MEMORY_LIMITS` no matter what the row
250
+ * holds. Writes are capped, so only a row written before the caps existed can
251
+ * exceed them — and it degrades to its newest entries rather than exploding
252
+ * the context window.
253
+ */
104
254
  export function renderWorkingMemory(wm) {
105
- const lines = ["# Working Memory", ""];
106
- lines.push("## Goal", "");
107
- lines.push(wm.goal.trim().length > 0 ? wm.goal.trim() : "_none yet_", "");
108
- lines.push("## Constraints", "");
109
- if (wm.constraints.length === 0) {
110
- lines.push("_none yet_");
111
- }
112
- else {
113
- for (const c of wm.constraints) {
114
- lines.push(`- [${c.id}] (${c.origin}) ${c.text}`);
115
- }
116
- }
117
- lines.push("");
118
- lines.push("## Hypotheses", "");
119
- if (wm.hypotheses.length === 0) {
120
- lines.push("_none yet_");
255
+ const sections = [];
256
+ const goal = clamp(wm.goal, WORKING_MEMORY_LIMITS.goalChars);
257
+ if (goal.length > 0)
258
+ sections.push(`## Goal\n\n${goal}`);
259
+ const constraints = newest(wm.constraints, WORKING_MEMORY_LIMITS.constraints);
260
+ if (constraints.shown.length > 0) {
261
+ const lines = constraints.shown.map((c) => `- [${c.id}] (${c.origin}) ${clamp(c.text, WORKING_MEMORY_LIMITS.entryChars)}`);
262
+ sections.push(renderSection("Constraints", lines, constraints.omitted, "constraints"));
121
263
  }
122
- else {
123
- for (const h of wm.hypotheses) {
124
- lines.push(`- [${h.id}] ${h.text}`);
125
- }
126
- }
127
- lines.push("");
128
- lines.push("## Findings", "");
129
- const runIds = Object.keys(wm.findings);
130
- if (runIds.length === 0) {
131
- lines.push("_none yet_");
264
+ const hypotheses = newest(wm.hypotheses, WORKING_MEMORY_LIMITS.hypotheses);
265
+ if (hypotheses.shown.length > 0) {
266
+ const lines = hypotheses.shown.map((h) => `- [${h.id}] ${clamp(h.text, WORKING_MEMORY_LIMITS.entryChars)}`);
267
+ sections.push(renderSection("Hypotheses", lines, hypotheses.omitted, "hypotheses"));
132
268
  }
133
- else {
134
- for (const runId of runIds) {
135
- lines.push(`### Run ${runId}`, "");
136
- const findings = wm.findings[runId] ?? [];
137
- if (findings.length === 0) {
138
- lines.push("_none yet_");
139
- }
140
- else {
141
- for (const f of findings) {
142
- lines.push(`- [${f.id}] ${f.text}`);
143
- }
144
- }
145
- lines.push("");
146
- }
269
+ const findings = newest(flattenFindings(wm.findings), WORKING_MEMORY_LIMITS.findings);
270
+ if (findings.shown.length > 0) {
271
+ const lines = findings.shown.map((f) => `- [${f.id}] (${f.runId}) ${clamp(f.text, WORKING_MEMORY_LIMITS.entryChars)}`);
272
+ sections.push(renderSection("Findings", lines, findings.omitted, "findings"));
147
273
  }
148
- return `${lines.join("\n").trimEnd()}\n`;
274
+ if (sections.length === 0)
275
+ return "";
276
+ return `# Working Memory\n\n${sections.join("\n\n")}\n`;
149
277
  }
150
278
  /**
151
279
  * Create a `WorkingMemoryStore` bound to a Postgres pool — a factory closure
@@ -164,7 +292,9 @@ export function createWorkingMemory(pool) {
164
292
  return withTransaction(pool, "working-memory.updateSection", (client) =>
165
293
  // Serialize concurrent read-modify-write on this analysis — the lock
166
294
  // covers the not-yet-existing-row case an `SELECT ... FOR UPDATE`
167
- // would miss. Released automatically at COMMIT/ROLLBACK.
295
+ // would miss. Released automatically at COMMIT/ROLLBACK. The cap check
296
+ // runs under it too, so two racing adds cannot both slip past a full
297
+ // section.
168
298
  tryQuery("working-memory.updateSection.lock", () => client.query("SELECT pg_advisory_xact_lock(hashtext($1))", [analysisId]))
169
299
  .andThen(() => tryQuery("working-memory.updateSection.read", () => client.query("SELECT data FROM cortex_working_memory WHERE analysis_id = $1", [analysisId])))
170
300
  .map(({ rows }) => {
@@ -173,10 +303,16 @@ export function createWorkingMemory(pool) {
173
303
  const current = rows.length === 0 ? emptyWorkingMemory() : workingMemorySchema.parse(rows[0].data);
174
304
  return applySectionUpdate(current, section, value);
175
305
  })
176
- .andThen((next) => tryMutation("working-memory.updateSection.upsert", () => client.query(`INSERT INTO cortex_working_memory (analysis_id, data, updated_at)
306
+ .andThen((amended) =>
307
+ // A rejection rides the transaction's ok channel: it is a decision, not a
308
+ // storage failure. The transaction commits having written nothing, and the
309
+ // rejection is lifted back into the error channel below.
310
+ amended.isErr()
311
+ ? okAsync(err(amended.error))
312
+ : tryMutation("working-memory.updateSection.upsert", () => client.query(`INSERT INTO cortex_working_memory (analysis_id, data, updated_at)
177
313
  VALUES ($1, $2::jsonb, NOW())
178
314
  ON CONFLICT (analysis_id)
179
- DO UPDATE SET data = EXCLUDED.data, updated_at = NOW()`, [analysisId, JSON.stringify(next)])).map(() => undefined)));
315
+ DO UPDATE SET data = EXCLUDED.data, updated_at = NOW()`, [analysisId, JSON.stringify(amended.value)])).map(() => ok(undefined)))).andThen((written) => written);
180
316
  }
181
317
  function render(analysisId) {
182
318
  return load(analysisId).map(renderWorkingMemory);