@jskit-ai/assistant-core 0.2.7 → 0.2.8

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 (214) hide show
  1. package/examples/conversation/package.json +5 -5
  2. package/package.json +18 -7
  3. package/src/client/browser.js +21 -0
  4. package/src/client/conversation/AssistantAttachmentQueue.vue +25 -108
  5. package/src/client/conversation/AssistantComposerActions.vue +1 -1
  6. package/src/client/conversation/AssistantConversationElement.vue +14 -52
  7. package/src/client/conversation/AssistantGoalControl.vue +13 -10
  8. package/src/client/conversation/AssistantPromptInput.vue +6 -91
  9. package/src/client/conversation/AssistantTranscript.css +505 -0
  10. package/src/client/conversation/AssistantTranscript.vue +24 -1197
  11. package/src/client/conversation/attachmentFiles.js +158 -0
  12. package/src/client/conversation/attachmentQueueItems.js +121 -0
  13. package/src/client/conversation/attachmentUploadRows.js +265 -0
  14. package/src/client/conversation/conversationDisplay.js +54 -0
  15. package/src/client/conversation/messageDelivery.js +13 -9
  16. package/src/client/conversation/retainedConversation.js +14 -3
  17. package/src/client/conversation/submitText.js +13 -2
  18. package/src/client/conversation/transcriptEntries.js +225 -0
  19. package/src/client/conversation/useAssistantAttachments.js +86 -388
  20. package/src/client/conversation/useAssistantQuestions.js +16 -3
  21. package/src/client/conversation/useAssistantSuggestions.js +16 -2
  22. package/src/client/conversation/useScrollToBottom.js +17 -19
  23. package/src/client/conversation/useTextareaAutoGrow.js +115 -0
  24. package/src/client/conversation/useTranscriptScrollFollow.js +525 -0
  25. package/src/client/lib/assistantApi.js +192 -161
  26. package/src/client/types.js +110 -0
  27. package/src/server/conversation/attachments.js +21 -6
  28. package/src/server/conversation/boundedToolLoop.js +1 -0
  29. package/src/server/conversation/claudeClient.js +71 -20
  30. package/src/server/conversation/claudeCommandRun.js +191 -0
  31. package/src/server/conversation/claudeHistory.js +7 -0
  32. package/src/server/conversation/claudeNativeTurn.js +708 -0
  33. package/src/server/conversation/claudeProcess.js +186 -112
  34. package/src/server/conversation/claudeProcessStart.js +214 -0
  35. package/src/server/conversation/claudePromptDelivery.js +183 -0
  36. package/src/server/conversation/claudeSessionCleanup.js +248 -0
  37. package/src/server/conversation/claudeTools.js +15 -3
  38. package/src/server/conversation/claudeTurn.js +212 -1388
  39. package/src/server/conversation/codexAccountReader.js +168 -0
  40. package/src/server/conversation/codexAuthPreflight.js +5 -4
  41. package/src/server/conversation/codexClient.js +18 -6
  42. package/src/server/conversation/codexConfiguration.js +8 -844
  43. package/src/server/conversation/codexConversationStops.js +377 -0
  44. package/src/server/conversation/codexConversationStore.js +119 -85
  45. package/src/server/conversation/codexConversationTurns.js +378 -0
  46. package/src/server/conversation/codexConversations.js +134 -866
  47. package/src/server/conversation/codexDetachedConversation.js +203 -0
  48. package/src/server/conversation/codexDetachedTurn.js +145 -104
  49. package/src/server/conversation/codexEvents.js +5 -200
  50. package/src/server/conversation/codexGoalCommands.js +14 -4
  51. package/src/server/conversation/codexHelperExecution.js +57 -57
  52. package/src/server/conversation/codexHelperThreadLedger.js +30 -288
  53. package/src/server/conversation/codexHelperThreadRecord.js +304 -0
  54. package/src/server/conversation/codexHelperThreadRecovery.js +54 -76
  55. package/src/server/conversation/codexHelperThreads.js +94 -93
  56. package/src/server/conversation/codexHistoryAdapter.js +13 -8
  57. package/src/server/conversation/codexIsolation.js +672 -0
  58. package/src/server/conversation/codexLiveItems.js +379 -0
  59. package/src/server/conversation/codexMessageCommands.js +68 -286
  60. package/src/server/conversation/codexMessageDelivery.js +46 -37
  61. package/src/server/conversation/codexModelCatalog.js +86 -7
  62. package/src/server/conversation/codexNotificationObserver.js +81 -88
  63. package/src/server/conversation/codexNotificationQueue.js +21 -10
  64. package/src/server/conversation/codexObservationControl.js +105 -89
  65. package/src/server/conversation/codexOutput.js +84 -419
  66. package/src/server/conversation/codexProcess.js +29 -1053
  67. package/src/server/conversation/codexProcessLeader.js +1 -0
  68. package/src/server/conversation/codexPromptDispatch.js +241 -0
  69. package/src/server/conversation/codexProvider.js +147 -1807
  70. package/src/server/conversation/codexProviderAuth.js +348 -0
  71. package/src/server/conversation/codexProviderInvalidation.js +371 -0
  72. package/src/server/conversation/codexProviderInventories.js +437 -0
  73. package/src/server/conversation/codexProviderOwner.js +149 -957
  74. package/src/server/conversation/codexProviderRelease.js +465 -0
  75. package/src/server/conversation/codexProviderSelection.js +26 -15
  76. package/src/server/conversation/codexProviderThreads.js +698 -0
  77. package/src/server/conversation/codexProviderTurns.js +453 -0
  78. package/src/server/conversation/codexReasoning.js +32 -23
  79. package/src/server/conversation/codexRenewalCommands.js +18 -6
  80. package/src/server/conversation/codexRenewalThreads.js +7 -22
  81. package/src/server/conversation/codexResultSettlement.js +70 -88
  82. package/src/server/conversation/codexRunJournal.js +91 -97
  83. package/src/server/conversation/codexRuntime.js +426 -0
  84. package/src/server/conversation/codexRuntimeInventory.js +113 -0
  85. package/src/server/conversation/codexRuntimeLifecycle.js +39 -13
  86. package/src/server/conversation/codexRuntimeStart.js +431 -0
  87. package/src/server/conversation/codexRuntimeStop.js +324 -0
  88. package/src/server/conversation/codexSessionCleanup.js +25 -10
  89. package/src/server/conversation/codexSessionReconciliation.js +21 -7
  90. package/src/server/conversation/codexThreadReadiness.js +49 -52
  91. package/src/server/conversation/codexThreadSnapshot.js +210 -0
  92. package/src/server/conversation/codexThreadStatus.js +48 -62
  93. package/src/server/conversation/codexTurn.js +171 -55
  94. package/src/server/conversation/codexTurnActivity.js +210 -0
  95. package/src/server/conversation/codexTurnControl.js +44 -48
  96. package/src/server/conversation/codexTurnEndings.js +610 -0
  97. package/src/server/conversation/codexTurnRecovery.js +97 -848
  98. package/src/server/conversation/codexTurnState.js +138 -111
  99. package/src/server/conversation/completedResponses.js +227 -0
  100. package/src/server/conversation/configuration.js +2 -2
  101. package/src/server/conversation/continuity.js +43 -6
  102. package/src/server/conversation/conversationDelivery.js +394 -0
  103. package/src/server/conversation/conversationEntries.js +365 -0
  104. package/src/server/conversation/conversationEvents.js +192 -0
  105. package/src/server/conversation/conversationGoals.js +187 -0
  106. package/src/server/conversation/conversationHandles.js +237 -0
  107. package/src/server/conversation/conversationReads.js +206 -0
  108. package/src/server/conversation/conversationRefusals.js +58 -0
  109. package/src/server/conversation/conversationReplacement.js +254 -0
  110. package/src/server/conversation/conversationSending.js +273 -0
  111. package/src/server/conversation/conversationStopping.js +121 -0
  112. package/src/server/conversation/conversationTurnDelivery.js +413 -0
  113. package/src/server/conversation/conversationTurns.js +511 -0
  114. package/src/server/conversation/deliveryTiming.js +25 -3
  115. package/src/server/conversation/fileStorage.js +11 -6
  116. package/src/server/conversation/hookBridge.js +14 -3
  117. package/src/server/conversation/index.js +1 -1
  118. package/src/server/conversation/localExecution.js +82 -625
  119. package/src/server/conversation/nativeCommandCallbacks.js +10 -0
  120. package/src/server/conversation/nativeConversationLifecycle.js +379 -0
  121. package/src/server/conversation/nativeHistoryExport.js +27 -18
  122. package/src/server/conversation/normalize.js +9 -0
  123. package/src/server/conversation/openCodeClient.js +295 -366
  124. package/src/server/conversation/openCodeNativeHistory.js +141 -0
  125. package/src/server/conversation/openCodeNativeSession.js +79 -0
  126. package/src/server/conversation/openCodeProcess.js +109 -53
  127. package/src/server/conversation/openCodeProcessOwner.js +565 -0
  128. package/src/server/conversation/openCodeRuntime.js +111 -1814
  129. package/src/server/conversation/openCodeSessionTurns.js +547 -0
  130. package/src/server/conversation/openCodeTemporaryConversations.js +394 -0
  131. package/src/server/conversation/openCodeTools.js +28 -14
  132. package/src/server/conversation/openCodeTurn.js +80 -74
  133. package/src/server/conversation/openCodeTurnMonitor.js +388 -0
  134. package/src/server/conversation/processIdentity.js +582 -0
  135. package/src/server/conversation/provider.js +21 -6
  136. package/src/server/conversation/providers/api.js +268 -107
  137. package/src/server/conversation/providers/claude.js +29 -6
  138. package/src/server/conversation/providers/claudeDriver.js +325 -250
  139. package/src/server/conversation/providers/codex.js +69 -33
  140. package/src/server/conversation/providers/codexDriver.js +503 -1081
  141. package/src/server/conversation/providers/codexDriverConnection.js +331 -0
  142. package/src/server/conversation/providers/codexDriverTurns.js +464 -0
  143. package/src/server/conversation/providers/opencode.js +30 -4
  144. package/src/server/conversation/providers/opencodeDriver.js +321 -659
  145. package/src/server/conversation/providers/opencodeStandalone.js +475 -0
  146. package/src/server/conversation/providers/scoped.js +64 -19
  147. package/src/server/conversation/runtime.js +171 -2863
  148. package/src/server/conversation/storage.js +91 -50
  149. package/src/server/conversation/streams.js +15 -2
  150. package/src/server/conversation/structuredOutput.js +7 -11
  151. package/src/server/conversation/systemPrompt.js +13 -1
  152. package/src/server/conversation/tools.js +47 -15
  153. package/src/server/conversation/transcript.js +49 -20
  154. package/src/server/hosts/claudeTurn.js +2 -4
  155. package/src/server/hosts/codexConfiguration.js +6 -8
  156. package/src/server/hosts/codexEvents.js +4 -2
  157. package/src/server/hosts/codexProcess.js +1 -1
  158. package/src/server/hosts/codexProvider.js +11 -9
  159. package/src/server/hosts/codexTurn.js +3 -3
  160. package/src/server/hosts/openCodeClient.js +4 -2
  161. package/src/server/index.js +0 -2
  162. package/src/server/lib/aiConnectionClient.js +35 -25
  163. package/src/server/lib/ndjson.js +1 -0
  164. package/src/server/lib/providers/anthropicClient.js +15 -6
  165. package/src/server/lib/providers/common.js +10 -6
  166. package/src/server/lib/providers/openAiCompatibleClient.js +40 -23
  167. package/src/server/lib/serviceToolCatalog.js +29 -283
  168. package/src/server/lib/serviceToolEntries.js +270 -0
  169. package/src/server/types.js +78 -0
  170. package/src/shared/assistantResource.js +0 -23
  171. package/src/shared/conversation/answerChoiceSugar.js +2 -14
  172. package/src/shared/conversation/messageInputSugar.js +18 -0
  173. package/src/shared/conversation/numberedQuestionSugar.js +6 -16
  174. package/src/shared/conversation/pagination.js +1 -1
  175. package/src/shared/conversation/questions.js +1 -0
  176. package/src/shared/conversation/streaming.js +1 -0
  177. package/src/shared/index.js +0 -1
  178. package/src/testing/conversationStorageContract.js +20 -0
  179. package/test/aiConnectionClient.test.js +42 -1
  180. package/test/apiConversationDriver.test.js +217 -38
  181. package/test/assistantBoundedToolLoop.test.js +1 -1
  182. package/test/claudeConversationSession.test.js +15 -11
  183. package/test/claudeInstructionResume.test.js +1 -1
  184. package/test/claudeProcess.test.js +9 -9
  185. package/test/claudeTurn.test.js +21 -8
  186. package/test/codexAccountChange.test.js +5 -3
  187. package/test/codexConversationSession.test.js +22 -21
  188. package/test/codexEvents.test.js +3 -1
  189. package/test/codexHelperDeletion.test.js +3 -7
  190. package/test/codexIsolation.test.js +6 -6
  191. package/test/codexModelCatalog.test.js +6 -7
  192. package/test/codexProcess.test.js +3 -3
  193. package/test/codexThreadSelection.test.js +4 -1
  194. package/test/codexTurn.test.js +29 -20
  195. package/test/conversationApiFacilities.test.js +11 -9
  196. package/test/conversationAttachments.test.js +3 -3
  197. package/test/conversationConfiguration.test.js +4 -4
  198. package/test/conversationNativeStorage.test.js +11 -10
  199. package/test/conversationReplacement.test.js +13 -13
  200. package/test/conversationRuntime.test.js +9 -5
  201. package/test/conversationSession.test.js +78 -22
  202. package/test/conversationTools.test.js +47 -26
  203. package/test/fileConversationStorage.test.js +10 -3
  204. package/test/fixtures/codexHelperThreadLedger.js +3 -3
  205. package/test/frameworkActions.test.js +33 -18
  206. package/test/localConversationExecution.test.js +4 -4
  207. package/test/localExecution.test.js +1 -1
  208. package/test/openCodeConnectionChange.test.js +3 -2
  209. package/test/openCodeRuntime.test.js +8 -7
  210. package/test/openCodeTools.test.js +1 -1
  211. package/test/opencodeConversationSession.test.js +4 -3
  212. package/test/serviceToolCatalog.test.js +3 -8
  213. package/src/server/lib/assistantCompletion.js +0 -190
  214. package/src/server/lib/assistantToolLoop.js +0 -259
@@ -0,0 +1,365 @@
1
+ import { isDeepStrictEqual } from "node:util";
2
+ import { randomUUID } from "node:crypto";
3
+ import { normalizeConversationConfiguration } from "./configuration.js";
4
+ import { createReentrantConversationStorage } from "./storage.js";
5
+ import { createCodexConversationStore } from "./codexConversationStore.js";
6
+ import { conversationNativeBinding, hasUnfinishedConversationRewind } from "./runtimeStateUpgrade.js";
7
+ import { failure, stateErrorFields } from "./conversationRefusals.js";
8
+
9
+ /** @typedef {import("./provider.js").ConversationDriver} ConversationDriver */
10
+ /** @typedef {import("./conversationTurns.js").ActiveTurn} ActiveTurn */
11
+ /** @typedef {import("./conversationSending.js").ConversationRequest} ConversationRequest */
12
+
13
+ /**
14
+ * The conversation a host supplies for one id, instead of runtime-owned storage: a logical
15
+ * conversation with its own commands, a scoped native one, or a bound native one whose transcript,
16
+ * state and native owners stay the host's. conversationHandles' assertHostConversation checks it.
17
+ * @typedef {object} HostConversation
18
+ * @property {string} sessionId
19
+ * @property {string} namespace
20
+ * @property {string} engine
21
+ * @property {object} [commands] A logical conversation's own send, cancel, select and inspectDelivery.
22
+ * @property {object} [native] Its native facilities: owners, preparation, and `scoped` for a scoped one.
23
+ * @property {object} [transcript]
24
+ * @property {object} [state] Its native delivery and replacement state.
25
+ * @property {object} [selection] Writes its authorized assistant selection.
26
+ * @property {object} [applicationTools] Its opted-in application tools: their storage and context mapper.
27
+ */
28
+
29
+ /**
30
+ * The application tools a bound conversation opted into: where their receipts are saved, and how
31
+ * a call's context is mapped.
32
+ * @typedef {object} BoundApplicationTools
33
+ * @property {import("../types.js").ReentrantConversationStorage} storage
34
+ * @property {(context: object, call: object) => object | Promise<object>} prepareContext
35
+ */
36
+
37
+ /**
38
+ * One open conversation, shared by every handle opened on it. Only this module creates, binds and
39
+ * retires it; the modules that run its turns record their state on it.
40
+ * @typedef {object} ConversationEntry
41
+ * @property {string} id
42
+ * @property {string} key Its key among the open conversations: the host's namespace, or the id.
43
+ * @property {HostConversation | null} conversation The host's conversation, or null for one the runtime stores.
44
+ * @property {import("../types.js").ConversationHost | undefined} host The host scope it was opened on.
45
+ * @property {Promise<void>} initializing Settles once its state is checked and its provider is open.
46
+ * @property {Promise<unknown>} pending The end of its operation queue (see conversationEvents' serial).
47
+ * @property {Set<{ context: object, listener: (event: object) => unknown }>} listeners
48
+ * @property {Set<string>} cancelledMessages The newest messages cancelled here, refused if sent again.
49
+ * @property {string} engine
50
+ * @property {ConversationDriver} driver
51
+ * @property {any} provider The engine's provider, opened over the current binding.
52
+ * @property {object} bindingOwner Identifies the binding that current provider publications belong to.
53
+ * @property {string | null} segmentId The current runtime segment: a bound conversation's is its native thread.
54
+ * @property {BoundApplicationTools | null} applicationTools
55
+ * @property {ActiveTurn | null} active The turn it is running, if any.
56
+ * @property {ConversationRequest} [lastAcceptedRequest] A bound conversation's latest admitted request.
57
+ * @property {string} error The error a reader sees.
58
+ * @property {string} [phase] The phase subscribers were last told.
59
+ * @property {string} [nativePhase] The phase the native engine last reported.
60
+ * @property {object | null} [goal] The native goal, as last read or reported.
61
+ * @property {Error | null} [storageFailure] Progress that could not be saved; retrySave clears it.
62
+ * @property {Error | null} [executionFailure] Native cleanup that could not be confirmed; stop clears it.
63
+ * @property {string | null} [cleanupTurnId] The turn whose cleanup failed.
64
+ * @property {(() => Promise<object>) | null} [saveProgress] Saves the progress a storage failure kept.
65
+ * @property {boolean} [disposed]
66
+ */
67
+
68
+ /**
69
+ * A conversation's saved runtime state: the runtime's own record, or a bound conversation's
70
+ * projection of its host's state.
71
+ * @typedef {object} ConversationState
72
+ * @property {string} engine
73
+ * @property {object} configuration
74
+ * @property {string | null} segmentId
75
+ * @property {any} [binding]
76
+ * @property {any} [request] The request whose delivery is unconfirmed.
77
+ * @property {any} [replacement]
78
+ * @property {any} [nativeTurn]
79
+ * @property {unknown} [nativeResult]
80
+ * @property {string} [lastEngine]
81
+ * @property {any[]} [predecessors]
82
+ * @property {any} [continuity]
83
+ * @property {any[]} [continuityAttachments]
84
+ * @property {Record<string, string>} [seen]
85
+ */
86
+
87
+ /**
88
+ * The open conversations: an entry's creation, its saved state and engine provider, the bindings it
89
+ * changes to, and its retirement.
90
+ * @param {object} dependencies
91
+ * @param {import("../types.js").ReentrantConversationStorage | null} dependencies.storage
92
+ * @param {import("../types.js").ConversationTranscript | null} dependencies.transcript
93
+ * @param {import("../types.js").ConversationStreams} dependencies.streams
94
+ * @param {import("./provider.js").ConversationProviderFactory} dependencies.providers
95
+ * @param {import("./conversationEvents.js").ConversationEvents} dependencies.events
96
+ * @param {import("../types.js").ServiceToolCatalog | null} dependencies.catalog
97
+ * @param {string} dependencies.defaultEngine
98
+ * @param {string | undefined} dependencies.defaultIntegrationId
99
+ * @param {boolean} dependencies.persistCommentary
100
+ * @param {boolean} dependencies.completedEnvelope
101
+ */
102
+ function createConversationEntries({ storage, transcript, streams, providers, events, catalog, defaultEngine, defaultIntegrationId,
103
+ persistCommentary, completedEnvelope }) {
104
+ /** @type {Map<string, ConversationEntry>} */
105
+ const entries = new Map();
106
+
107
+ /** @param {string} key */
108
+ const find = key => entries.get(key);
109
+
110
+ const list = () => [...entries.values()];
111
+
112
+ /**
113
+ * The open entry of a conversation, created and initialized on its first open.
114
+ * @param {{ id: string, conversation: HostConversation | null, host: import("../types.js").ConversationHost | undefined,
115
+ * configuration: object | undefined, engine: string | undefined }} request
116
+ */
117
+ function acquire({ id, conversation, host, configuration, engine }) {
118
+ const key = conversation?.namespace || id;
119
+ let entry = entries.get(key);
120
+ if (!entry) {
121
+ entry = { id, key, conversation, host, pending: Promise.resolve(), active: null, listeners: new Set(), error: "",
122
+ cancelledMessages: new Set() };
123
+ entries.set(key, entry);
124
+ entry.initializing = initialize(entry, configuration, engine).catch(error => {
125
+ if (entries.get(key) === entry) entries.delete(key);
126
+ throw error;
127
+ });
128
+ }
129
+ return entry;
130
+ }
131
+
132
+ /**
133
+ * Opens the engine's provider over one binding. Its publications count only while `owner` is the
134
+ * entry's binding owner and, for a stored conversation, the segment is still current.
135
+ * @param {ConversationEntry} entry
136
+ * @param {ConversationDriver} driver
137
+ * @param {object} binding
138
+ * @param {string | null} segmentId
139
+ * @param {object} [owner]
140
+ */
141
+ async function openProvider(entry, driver, binding, segmentId, owner = (entry.bindingOwner = {})) {
142
+ const isCurrent = () => entry.bindingOwner === owner && (entry.conversation || entry.segmentId === segmentId);
143
+ let conversation;
144
+ if (entry.conversation || driver.canonicalTranscript) {
145
+ const store = entry.conversation ? null : createCodexConversationStore({ storage, scope: entry.id, segmentId, isCurrent, transcript, streams });
146
+ conversation = {
147
+ ...entry.conversation,
148
+ sessionId: entry.id,
149
+ persistCommentary,
150
+ runtime: entry.conversation?.runtime || { store, getSession: store.getSession },
151
+ publish: event => isCurrent() ? events.publish(entry, event) : undefined,
152
+ checkpoint: entry.conversation?.checkpoint || (input => isCurrent() ? entry.active?.nativeCheckpoint(input) : null)
153
+ };
154
+ }
155
+ return providers.open(driver, {
156
+ binding,
157
+ conversation,
158
+ async onFailure(error, { recovered = false } = {}) {
159
+ if (!isCurrent()) return;
160
+ if (recovered && entry.executionFailure !== error) return;
161
+ entry.executionFailure = recovered ? null : error;
162
+ entry.error = error.message;
163
+ await events.emit(entry, { type: "error", error: error.message, ...stateErrorFields(error.message) });
164
+ },
165
+ async writeBinding(value) {
166
+ if (entry.conversation) throw new Error("A bound native conversation writes identity through its original preparation owner.");
167
+ if (entry.bindingOwner !== owner) throw new Error("A retired provider cannot change a native binding.");
168
+ await storage.write(entry.id, async transaction => {
169
+ const metadata = await transaction.readMetadata();
170
+ if (entry.bindingOwner !== owner || metadata.runtime.segmentId !== segmentId) throw new Error("A retired native conversation cannot change the current binding.");
171
+ if (metadata.runtime.engine === "codex") {
172
+ const { codexAppServerRun: _staleRun, ...next } = value;
173
+ if (Object.hasOwn(metadata.runtime.binding, "codexAppServerRun")) {
174
+ next.codexAppServerRun = metadata.runtime.binding.codexAppServerRun;
175
+ }
176
+ metadata.runtime.binding = next;
177
+ } else metadata.runtime.binding = value;
178
+ await transaction.writeMetadata(metadata);
179
+ });
180
+ }
181
+ });
182
+ }
183
+
184
+ /**
185
+ * Makes a new entry usable: its saved runtime state is checked or created, and its engine's
186
+ * provider is opened over the current native binding.
187
+ * @param {ConversationEntry} entry
188
+ * @param {object | undefined} configuration
189
+ * @param {string | undefined} requestedEngine
190
+ */
191
+ async function initialize(entry, configuration, requestedEngine) {
192
+ if (entry.conversation) {
193
+ entry.applicationTools = boundApplicationTools(entry.conversation);
194
+ entry.engine = entry.conversation.engine;
195
+ if (requestedEngine && requestedEngine !== entry.engine) throw failure("The selected native engine changed.", "conversation_engine_changed", 409, { messageCode: "assistant.conversation.engineChanged" });
196
+ entry.driver = providers.createDriver(entry.engine, entry.host);
197
+ if (entry.conversation.commands) return;
198
+ if (entry.conversation.native?.scoped) {
199
+ entry.provider = await providers.open(entry.driver, { conversation: entry.conversation });
200
+ if (entry.disposed) throw failure("This conversation handle is disposed.", "conversation_handle_disposed", 409, { messageCode: "assistant.conversation.handleDisposed" });
201
+ return;
202
+ }
203
+ const state = await readState(entry);
204
+ if (entry.disposed) throw failure("This conversation handle is disposed.", "conversation_handle_disposed", 409, { messageCode: "assistant.conversation.handleDisposed" });
205
+ entry.provider = await openProvider(entry, entry.driver, state.binding, state.segmentId);
206
+ if (entry.disposed) throw failure("This conversation handle is disposed.", "conversation_handle_disposed", 409, { messageCode: "assistant.conversation.handleDisposed" });
207
+ return;
208
+ }
209
+ const binding = await storage.write(entry.id, async transaction => {
210
+ for (const method of ["readMetadata", "writeMetadata", "updateTurnMetadata", "listTurnIds", "readTurn", "nextTurnId", "hasMessage", "appendMessage", "replaceAssistant"]) {
211
+ // readable-check: allow method-probe - input validation: an application's storage adapter is checked against the runtime storage contract
212
+ if (typeof transaction[method] !== "function") throw new TypeError(`Conversation runtime storage requires transaction.${method}(). Run the runtime storage contract checks before using this adapter.`);
213
+ }
214
+ const metadata = await transaction.readMetadata();
215
+ entry.engine = metadata.runtime?.engine || requestedEngine || defaultEngine;
216
+ if (completedEnvelope && !["codex", "claude", "opencode"].includes(entry.engine)) {
217
+ throw failure("Completed envelopes require a runtime-owned native conversation.", "conversation_completed_envelopes_need_native_owner", 400, { messageCode: "assistant.conversation.completedEnvelopesNeedNativeOwner" });
218
+ }
219
+ if (configuration) configuration = normalizeConversationConfiguration(configuration, { engine: entry.engine, defaultIntegrationId });
220
+ const driver = entry.driver = providers.createDriver(entry.engine, entry.host);
221
+ if (metadata.runtime) {
222
+ if (metadata.runtime.version !== 3 || typeof metadata.runtime.lastEngine !== "string" || typeof metadata.runtime.segmentId !== "string" || !metadata.runtime.segmentId || !Array.isArray(metadata.runtime.predecessors)) {
223
+ throw failure("This conversation needs its offline runtime-state upgrade before it can be opened. Its saved requests were not replayed or changed.", "conversation_storage_version");
224
+ }
225
+ if (hasUnfinishedConversationRewind(metadata.runtime)) {
226
+ throw failure("This conversation has an unfinished Undo operation from an earlier runtime. Inspect its saved native and local history offline before opening it; no history was changed.", "conversation_rewind_inspection_before_open", 409, { messageCode: "assistant.conversation.rewindInspectionBeforeOpen" });
227
+ }
228
+ if (requestedEngine && requestedEngine !== entry.engine) throw failure("Use replace() to change this conversation's engine.", "conversation_use_replace_for_engine", 409, { messageCode: "assistant.conversation.useReplaceForEngine" });
229
+ driver.validateConfiguration(metadata.runtime.configuration);
230
+ if (configuration && !isDeepStrictEqual(configuration, metadata.runtime.configuration)) {
231
+ throw failure("Use configure() to change a saved conversation's settings.", "conversation_configuration_mismatch");
232
+ }
233
+ // Stored work is never permission to replay it after application restart.
234
+ if (metadata.runtime.request && driver.admissionBeforeDispatch) {
235
+ // API requests are dispatched only after the accepted user message and
236
+ // receipt are committed together. A remaining reservation was not sent.
237
+ delete metadata.runtime.request;
238
+ } else if (metadata.runtime.request) {
239
+ metadata.runtime.request.error = "The previous submission was interrupted. Check delivery before submitting it again.";
240
+ }
241
+ for (const id of await transaction.listTurnIds()) {
242
+ const turn = await transaction.readTurn(id);
243
+ if (turn.metadata?.runtime?.status === "running") {
244
+ await transaction.updateTurnMetadata(id, { runtime: { ...turn.metadata.runtime,
245
+ status: "interrupted", error: "The application restarted before this answer completed. The request was not replayed." } });
246
+ }
247
+ }
248
+ } else {
249
+ driver.validateConfiguration(configuration);
250
+ metadata.runtime = { version: 3, segmentId: randomUUID(), engine: entry.engine, configuration: structuredClone(configuration), predecessors: [], seen: {},
251
+ lastEngine: (await transaction.listTurnIds()).length ? "" : entry.engine };
252
+ if (driver.createBinding) metadata.runtime.binding = await driver.createBinding();
253
+ }
254
+ await transaction.writeMetadata(metadata);
255
+ entry.segmentId = metadata.runtime.segmentId;
256
+ return metadata.runtime.binding;
257
+ });
258
+ entry.provider = await openProvider(entry, entry.driver, binding, entry.segmentId);
259
+ }
260
+
261
+ /** @param {HostConversation} conversation */
262
+ function boundApplicationTools(conversation) {
263
+ const facilities = conversation.applicationTools;
264
+ if (facilities === undefined) return null;
265
+ // readable-check: allow method-probe - input validation: a host's bound application tools must bring their context mapper
266
+ if (!catalog || conversation.commands || conversation.native?.scoped || typeof facilities?.prepareContext !== "function") {
267
+ throw new TypeError("Bound application tools require a catalogue, an admitted conversation and a server-owned context mapper.");
268
+ }
269
+ return { storage: createReentrantConversationStorage(facilities.storage), prepareContext: facilities.prepareContext };
270
+ }
271
+
272
+ /**
273
+ * The saved state of a conversation. A bound native conversation keeps its original run,
274
+ * identity and delivery files, so its state is a read projection, never a runtime-v3 record or a
275
+ * backfill; reading it brings the entry's segment up to date with the host's native thread.
276
+ * @param {ConversationEntry} entry
277
+ * @param {object} [context]
278
+ * @returns {Promise<ConversationState>}
279
+ */
280
+ async function readState(entry, context) {
281
+ if (!entry.conversation) return (await storage.read(entry.id, transaction => transaction.readMetadata())).runtime;
282
+ const value = entry.conversation.native && entry.driver.readState
283
+ ? await entry.driver.readState({ conversation: entry.conversation, context })
284
+ : await entry.conversation.read(context);
285
+ entry.segmentId = value.threadId ? `${entry.engine}:${value.threadId}` : null;
286
+ const saved = value.delivery?.engines[entry.engine]?.pending;
287
+ // The saved receipt is the delivery proof, as in changeover recovery. A
288
+ // restart after native admission leaves only a stale attempt marker, which
289
+ // must not leave the conversation unconfirmed until another message is sent.
290
+ const pending = (saved?.attempted && !saved.inspectionOnly &&
291
+ await entry.conversation.transcript.hasMessage(saved.messageId)) ? null : saved;
292
+ return { engine: entry.engine, configuration: value.configuration, segmentId: entry.segmentId,
293
+ binding: { ...conversationNativeBinding(entry.engine, value.threadId),
294
+ ...(entry.engine === "codex" ? { codexAppServerRun: value.run } : {}) },
295
+ nativeTurn: value.turn, nativeResult: value.nativeResult,
296
+ lastEngine: value.delivery?.lastEngine ?? entry.engine,
297
+ replacement: ["preparing", "ready"].includes(value.delivery?.replacement?.status) ? value.delivery.replacement : null,
298
+ request: pending && { ...pending, text: pending.displayMessage, attachments: pending.displayAttachments || [] } };
299
+ }
300
+
301
+ /**
302
+ * A logical conversation's own read may report another engine; the entry follows it.
303
+ * @param {ConversationEntry} entry
304
+ * @param {string} engine
305
+ */
306
+ function followEngine(entry, engine) {
307
+ if (engine === entry.engine) return;
308
+ entry.engine = engine;
309
+ entry.driver = providers.createDriver(entry.engine, entry.host);
310
+ }
311
+
312
+ /**
313
+ * After a bound conversation's host replaced its native conversation, the entry binds the host's
314
+ * new conversation and opens its provider.
315
+ * @param {ConversationEntry} entry
316
+ * @param {object} context
317
+ */
318
+ async function rebindHostConversation(entry, context) {
319
+ const next = await entry.host.conversation({ id: entry.id, context });
320
+ entry.conversation = next;
321
+ entry.applicationTools = boundApplicationTools(next);
322
+ entry.engine = next.engine;
323
+ entry.driver = providers.createDriver(entry.engine, entry.host);
324
+ const current = await readState(entry);
325
+ entry.provider = await openProvider(entry, entry.driver, current.binding, current.segmentId);
326
+ }
327
+
328
+ /**
329
+ * After a stored conversation's replacement, the entry uses its successor's binding.
330
+ * @param {ConversationEntry} entry
331
+ * @param {{ engine: string, driver: ConversationDriver, provider: any, bindingOwner: object, segmentId: string }} binding
332
+ */
333
+ function adoptBinding(entry, { engine, driver, provider, bindingOwner, segmentId }) {
334
+ Object.assign(entry, { engine, driver, provider, bindingOwner, segmentId });
335
+ }
336
+
337
+ /**
338
+ * The entry leaves memory and its handles close. Live streams are kept per conversation id; a
339
+ * logical conversation streams through its own owner, so the stream under its id belongs to
340
+ * another entry with the same id, such as its session's Main conversation.
341
+ * @param {ConversationEntry} entry
342
+ */
343
+ function retire(entry) {
344
+ entry.disposed = true;
345
+ entry.listeners.clear();
346
+ if (!entry.conversation?.commands) streams.clear(entry.id);
347
+ if (entries.get(entry.key) === entry) entries.delete(entry.key);
348
+ }
349
+
350
+ return Object.freeze({
351
+ find,
352
+ list,
353
+ acquire,
354
+ openProvider,
355
+ readState,
356
+ followEngine,
357
+ rebindHostConversation,
358
+ adoptBinding,
359
+ retire
360
+ });
361
+ }
362
+
363
+ /** @typedef {ReturnType<typeof createConversationEntries>} ConversationEntries */
364
+
365
+ export { createConversationEntries };
@@ -0,0 +1,192 @@
1
+ import { isCompletedEnvelopeTurn } from "./transcript.js";
2
+ import { failure } from "./conversationRefusals.js";
3
+ import { submittedMessage } from "./conversationReads.js";
4
+
5
+ /** @typedef {import("./conversationEntries.js").ConversationEntry} ConversationEntry */
6
+
7
+ /**
8
+ * Runs one conversation entry's operations in order: each waits for the one before it.
9
+ * @param {ConversationEntry} entry
10
+ * @param {() => unknown} callback
11
+ */
12
+ function serial(entry, callback) {
13
+ const operation = entry.pending.then(callback);
14
+ entry.pending = operation.catch(() => {});
15
+ return operation;
16
+ }
17
+
18
+ /**
19
+ * Who may use an open conversation, and what its subscribers hear: authorization for each
20
+ * operation, events, the phase, and the publications of a provider or host.
21
+ * @param {object} dependencies
22
+ * @param {import("../types.js").ConversationAuthorize} dependencies.authorize
23
+ * @param {() => boolean} dependencies.isClosed Whether the runtime has closed.
24
+ * @param {import("../types.js").ReentrantConversationStorage | null} dependencies.storage
25
+ * @param {import("../types.js").ConversationStreams} dependencies.streams
26
+ * @param {boolean} dependencies.completedEnvelope
27
+ */
28
+ function createConversationEvents({ authorize, isClosed, storage, streams, completedEnvelope }) {
29
+ /**
30
+ * Send and its immediate admission share one authorization. Dispatch, receipt admission and
31
+ * every other operation authorize again.
32
+ * @param {object} context
33
+ * @param {string} id
34
+ * @param {string} operation
35
+ * @param {ConversationEntry} [entry]
36
+ * @param {Set<string>} [grant] The operations this call already authorized.
37
+ */
38
+ async function access(context, id, operation, entry, grant) {
39
+ if (isClosed() || entry?.disposed) throw failure("This conversation handle is closed.", "conversation_handle_closed", 409, { messageCode: "assistant.conversation.handleClosed" });
40
+ if (!grant?.has(operation)) {
41
+ if (!await authorize({ context, conversationId: id, operation })) {
42
+ throw failure("This conversation is not available to this identity.", "conversation_forbidden", 403);
43
+ }
44
+ grant?.add(operation);
45
+ }
46
+ if (isClosed() || entry?.disposed) throw failure("This conversation handle is closed.", "conversation_handle_closed", 409, { messageCode: "assistant.conversation.handleClosed" });
47
+ }
48
+
49
+ /**
50
+ * @param {ConversationEntry} entry
51
+ * @param {object} event
52
+ * @param {() => boolean} [isCurrent] Whether the event still belongs to the current turn.
53
+ */
54
+ async function emit(entry, event, isCurrent) {
55
+ const { completedEnvelope: _untrustedMarker, ...payload } = event;
56
+ if (payload.type === "message" && ["assistant", "commentary", "thinking"].includes(payload.role) && payload.text?.trim()) {
57
+ // Bound live streams deliberately omit private native turn identities.
58
+ // Their current admitted request owns them; historical rows retain IDs.
59
+ let request;
60
+ if (payload.turnId) {
61
+ request = [...(entry.active?.requests.values() || [])].find(request =>
62
+ !request.sealed && (request.turnId === payload.turnId || request.nativeTurnId === payload.turnId));
63
+ } else if (entry.conversation) {
64
+ const current = entry.active?.request;
65
+ if (current?.accepted && !current.sealed) request = current;
66
+ }
67
+ request?.deliveryTiming("first-response");
68
+ }
69
+ if (completedEnvelope && !entry.conversation) {
70
+ const request = payload.messageId && entry.active?.requests.get(payload.messageId) ||
71
+ payload.turnId && [...(entry.active?.requests.values() || [])].find(request => request.turnId === payload.turnId);
72
+ let marked = isCompletedEnvelopeTurn(payload.patch?.turn) ||
73
+ request?.completedEnvelope === true && request.input.origin === "application";
74
+ if (!marked && payload.turnId) {
75
+ try { marked = await storage.read(entry.id, async transaction => isCompletedEnvelopeTurn(await transaction.readTurn(payload.turnId))); }
76
+ catch { /* An unavailable receipt cannot establish presentation custody. */ }
77
+ }
78
+ if (!marked && payload.type === "message") marked =
79
+ [...(payload.streaming?.messages || []), ...(payload.streaming?.completedMessages || [])]
80
+ .some(message => message.messageId === payload.messageId && message.completedEnvelope === true && message.origin === "application");
81
+ if (marked) payload.completedEnvelope = true;
82
+ }
83
+ await Promise.all([...entry.listeners].map(async subscription => {
84
+ try { await access(subscription.context, entry.id, "subscribe", entry); }
85
+ catch { entry.listeners.delete(subscription); return; }
86
+ if (isCurrent && !isCurrent()) return;
87
+ // Presentation failures cannot interrupt persistence or provider work.
88
+ try { Promise.resolve(subscription.listener(structuredClone({ conversationId: entry.id, ...payload }))).catch(() => {}); }
89
+ catch { /* A failed observer does not own the conversation. */ }
90
+ }));
91
+ }
92
+
93
+ /**
94
+ * @param {ConversationEntry} entry
95
+ * @param {string} phase
96
+ * @param {string} [turnId]
97
+ */
98
+ async function setPhase(entry, phase, turnId) {
99
+ if (!["", "preparing", "working", "compacting", "retrying", "background"].includes(phase)) throw new Error("Invalid conversation phase from the engine adapter.");
100
+ if ((entry.phase || "") === phase) return;
101
+ entry.phase = phase;
102
+ await emit(entry, { type: "phase", phase, turnId });
103
+ }
104
+
105
+ /**
106
+ * A publication of the entry's provider or host.
107
+ * @param {ConversationEntry} entry
108
+ * @param {object} event
109
+ */
110
+ async function publish(entry, event) {
111
+ if (entry.disposed) return;
112
+ const publicationOwner = entry.bindingOwner;
113
+ const publicationSegment = entry.segmentId;
114
+ const publicationActive = entry.active;
115
+ // A native-backed bound conversation also uses configuration as a browser
116
+ // invalidation. It has no command facade, but owns the same subscriptions.
117
+ if (entry.conversation && (event.type === "configuration" || entry.conversation.commands && event.type === "phase")) {
118
+ return emit(entry, event);
119
+ }
120
+ const payload = event.payload || {};
121
+ if (Object.hasOwn(payload, "goal")) {
122
+ entry.goal = payload.goal;
123
+ await emit(entry, { type: "goal", goal: entry.goal });
124
+ } else if (event.goalChanged) {
125
+ await emit(entry, { type: "goal" });
126
+ }
127
+ const turn = payload.conversationLogPatch?.turn;
128
+ if (turn) {
129
+ await emit(entry, { type: "transcript", patch: payload.conversationLogPatch,
130
+ ...(payload.conversationStream ? { streaming: payload.conversationStream } : {}) });
131
+ const authored = submittedMessage(turn);
132
+ const request = authored && entry.active?.requests.get(authored.messageId);
133
+ // The original receipt already owns this row. Common admission is
134
+ // still resolved by the dispatch continuation outside its queue.
135
+ if (request) request.turnId = turn.turnId;
136
+ for (const message of turn.messages || []) {
137
+ if (!["assistant", "commentary", "thinking"].includes(message.role)) continue;
138
+ await emit(entry, { type: "message", turnId: turn.turnId, messageId: message.messageId,
139
+ ...(message.outputId ? { outputId: message.outputId } : {}),
140
+ ...(["user", "application"].includes(turn.metadata?.runtime?.origin) ? { origin: turn.metadata.runtime.origin } : {}),
141
+ role: message.role, text: message.text, at: message.at, status: "complete", persisted: true,
142
+ streaming: payload.conversationStream || streams.read(entry.id) });
143
+ }
144
+ }
145
+ if (payload.conversationStream) {
146
+ const snapshot = payload.conversationStream;
147
+ for (const message of snapshot.completedMessages || []) {
148
+ if (turn?.messages?.some(saved => saved.messageId === message.messageId ||
149
+ (message.outputId && saved.outputId === message.outputId))) continue;
150
+ const request = publicationActive && [...publicationActive.requests.values()].find(request => request.turnId === message.turnId);
151
+ const isCurrent = () => !entry.disposed && !request?.sealed && entry.active === publicationActive &&
152
+ entry.bindingOwner === publicationOwner && entry.segmentId === publicationSegment;
153
+ if (!request || !isCurrent()) continue;
154
+ if (!request.admissionPublished) {
155
+ request.progress.set(message.messageId, message);
156
+ continue;
157
+ }
158
+ await emit(entry, { type: "message", ...message, streaming: snapshot }, isCurrent);
159
+ }
160
+ for (const message of snapshot.messages) {
161
+ await emit(entry, { type: "message", ...message, streaming: snapshot });
162
+ }
163
+ if (!snapshot.messages.length) await emit(entry, { type: "message-complete", streaming: snapshot });
164
+ }
165
+ const nativeTurn = payload.agentSession?.turn;
166
+ if (nativeTurn) {
167
+ entry.nativePhase = nativeTurn.active
168
+ ? ["preparing", "compacting", "retrying", "background"].includes(nativeTurn.phase) ? nativeTurn.phase : "working"
169
+ : "";
170
+ const request = entry.active?.request;
171
+ if (!entry.active || request.accepted) {
172
+ if (request?.deliveryCommitted) {
173
+ try { await request.deliveryCommitted; }
174
+ catch { return; } // Admission owns this failure; native cleanup must still settle.
175
+ }
176
+ await setPhase(entry, entry.nativePhase, request?.turnId);
177
+ }
178
+ }
179
+ }
180
+
181
+ return Object.freeze({
182
+ access,
183
+ emit,
184
+ // readable-check: allow setter-seam - not a slot: setPhase records and announces a conversation's current phase
185
+ setPhase,
186
+ publish
187
+ });
188
+ }
189
+
190
+ /** @typedef {ReturnType<typeof createConversationEvents>} ConversationEvents */
191
+
192
+ export { createConversationEvents, serial };