@jskit-ai/assistant-core 0.1.212 → 0.1.214

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 (60) hide show
  1. package/examples/conversation/package.json +5 -5
  2. package/fixtures/responsive-assistant/ConversationFixture.vue +7 -1
  3. package/package.json +5 -5
  4. package/src/client/conversation/AssistantConversationElement.vue +5 -1
  5. package/src/client/conversation/AssistantTranscript.vue +10 -1
  6. package/src/server/conversation/agentRun.js +38 -11
  7. package/src/server/conversation/claudeHistory.js +40 -5
  8. package/src/server/conversation/claudeProcess.js +28 -4
  9. package/src/server/conversation/claudeTools.js +11 -2
  10. package/src/server/conversation/claudeTurn.js +213 -23
  11. package/src/server/conversation/codexConfiguration.js +1 -1
  12. package/src/server/conversation/codexHistoryAdapter.js +11 -4
  13. package/src/server/conversation/codexNotificationQueue.js +11 -2
  14. package/src/server/conversation/codexObservationControl.js +8 -8
  15. package/src/server/conversation/codexOutput.js +20 -15
  16. package/src/server/conversation/codexProvider.js +40 -3
  17. package/src/server/conversation/codexProviderOwner.js +110 -1
  18. package/src/server/conversation/codexTurn.js +3 -0
  19. package/src/server/conversation/continuity.js +24 -6
  20. package/src/server/conversation/index.js +1 -1
  21. package/src/server/conversation/nativeHistoryExport.js +97 -0
  22. package/src/server/conversation/openCodeClient.js +47 -1
  23. package/src/server/conversation/openCodeRuntime.js +183 -28
  24. package/src/server/conversation/openCodeTurn.js +65 -24
  25. package/src/server/conversation/providers/api.js +7 -0
  26. package/src/server/conversation/providers/claudeDriver.js +52 -7
  27. package/src/server/conversation/providers/codex.js +19 -2
  28. package/src/server/conversation/providers/codexDriver.js +92 -14
  29. package/src/server/conversation/providers/opencodeDriver.js +31 -9
  30. package/src/server/conversation/runtime.js +424 -65
  31. package/src/server/conversation/runtimeStateUpgrade.js +77 -2
  32. package/src/server/conversation/streams.js +25 -5
  33. package/src/server/conversation/tools.js +2 -2
  34. package/src/server/conversation/transcript.js +23 -8
  35. package/src/server/hosts/claudeHistory.js +1 -0
  36. package/src/server/hosts/claudeProcess.js +1 -0
  37. package/src/server/hosts/codexProvider.js +3 -0
  38. package/src/server/hosts/nativeHistory.js +1 -0
  39. package/src/server/hosts/openCodeClient.js +1 -0
  40. package/src/server/hosts/openCodeTurn.js +1 -0
  41. package/src/server/index.js +1 -1
  42. package/src/server/lib/assistantToolLoop.js +99 -2
  43. package/test/aiConnectionClient.test.js +57 -0
  44. package/test/assistantBoundedToolLoop.test.js +332 -0
  45. package/test/assistantScroll.browser.test.js +151 -0
  46. package/test/claudeConversationSession.test.js +1417 -3
  47. package/test/claudeProcess.test.js +175 -0
  48. package/test/claudeTurn.test.js +466 -0
  49. package/test/codexConversationSession.test.js +1257 -12
  50. package/test/codexHistoryAdapter.test.js +74 -1
  51. package/test/codexTurn.test.js +282 -1
  52. package/test/conversation.test.js +83 -0
  53. package/test/conversationAgentRun.test.js +181 -0
  54. package/test/conversationRuntime.test.js +95 -0
  55. package/test/conversationRuntimeStateUpgrade.test.js +89 -0
  56. package/test/conversationStreaming.test.js +85 -0
  57. package/test/conversationTools.test.js +165 -1
  58. package/test/openCodeClient.test.js +186 -0
  59. package/test/openCodeRuntime.test.js +372 -0
  60. package/test/opencodeConversationSession.test.js +637 -5
@@ -37,6 +37,7 @@ export function createCodexAppServerOutput({
37
37
  debugLog,
38
38
  debugError,
39
39
  liveProgressMaxLength,
40
+ persistCommentary = true,
40
41
  storeReadError,
41
42
  messageMetadata,
42
43
  snapshotRecoveryItemLimit
@@ -639,24 +640,28 @@ export function createCodexAppServerOutput({
639
640
  const writer = role === "commentary"
640
641
  ? store.writeConversationCommentaryMessage
641
642
  : store.writeConversationThinkingMessage;
642
- written = await writer.call(store, normalizedSessionId, {
643
- ...(streamMessageId ? { outputId: streamMessageId } : {}),
644
- messageId: codexAppServerConversationMessageId(
645
- normalizedThreadId,
646
- codexAppServerNotificationTurnId(notification) || turn.turnId,
647
- role,
643
+ if (role !== "commentary" || persistCommentary) {
644
+ written = await writer.call(store, normalizedSessionId, {
645
+ ...(streamMessageId ? { outputId: streamMessageId } : {}),
646
+ messageId: codexAppServerConversationMessageId(
647
+ normalizedThreadId,
648
+ codexAppServerNotificationTurnId(notification) || turn.turnId,
649
+ role,
650
+ text
651
+ ),
652
+ requireOpenTurn: false,
653
+ nativeIdentity: { threadId: normalizedThreadId, turnId: codexAppServerNotificationTurnId(notification) || turn.turnId },
648
654
  text
649
- ),
650
- requireOpenTurn: false,
651
- nativeIdentity: { threadId: normalizedThreadId, turnId: codexAppServerNotificationTurnId(notification) || turn.turnId },
652
- text
653
- });
654
- if (!written) {
655
- codexAppServerLiveProgressItems.delete(key);
656
- codexAppServerLiveProgressFingerprints.delete(fingerprintKey);
655
+ });
656
+ if (!written) {
657
+ codexAppServerLiveProgressItems.delete(key);
658
+ codexAppServerLiveProgressFingerprints.delete(fingerprintKey);
659
+ }
657
660
  }
658
661
  const payload = {
659
- conversationStream: store.completeConversationStreamMessage(normalizedSessionId, streamMessageId)
662
+ conversationStream: await store.completeConversationStreamMessage(normalizedSessionId, streamMessageId, {
663
+ text, role, nativeIdentity: { threadId: normalizedThreadId, turnId: codexAppServerNotificationTurnId(notification) || turn.turnId }
664
+ })
660
665
  };
661
666
  if (written) {
662
667
  payload.conversationLogPatch = { turn: written, type: "upsert-turn" };
@@ -16,7 +16,7 @@ import {
16
16
  import { prepareCodexHistory } from "./codexHistoryAdapter.js";
17
17
  import { createCodexConversationAdapter } from "./providers/codex.js";
18
18
  import { exportCodexNativeHistory } from "./codexNativeHistoryExport.js";
19
- import { retireNativeConversation } from "./nativeHistoryExport.js";
19
+ import { retireNativeConversation, readStoppedNativeDatabase } from "./nativeHistoryExport.js";
20
20
  import { isPlainObject, normalizeText as normalizeAgentText } from "./normalize.js";
21
21
  import {
22
22
  codexAppServerProviderThreadTurns,
@@ -135,6 +135,41 @@ async function retireCodexConversationHistory(provider, binding, {
135
135
  remove: () => provider.deleteThread(binding.conversationId) });
136
136
  }
137
137
 
138
+ // Native Codex0.159.1 keeps goals in its separate goals_1.sqlite store.
139
+ // Return its original SQL fields; the host owns stopped conversion eligibility,
140
+ // including rejecting active/blocked/limited goals and resolving SQLite home.
141
+ async function readCodexNativeGoal(databasePath, { threadId, signal } = {}) {
142
+ if (!/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/u.test(threadId || "") ||
143
+ path.basename(databasePath || "") !== "goals_1.sqlite") {
144
+ throw new TypeError("Codex goal inspection requires its exact thread ID and supported goals_1.sqlite path.");
145
+ }
146
+ return readStoppedNativeDatabase(databasePath, database => {
147
+ const columns = database.prepare("PRAGMA table_info(thread_goals)").all();
148
+ const text = ["thread_id", "goal_id", "objective", "status"];
149
+ const integers = ["token_budget", "tokens_used", "time_used_seconds", "created_at_ms", "updated_at_ms"];
150
+ if (!columns.some(column => column.name === "thread_id" && column.pk === 1) ||
151
+ text.some(name => !columns.some(column => column.name === name && column.type === "TEXT")) ||
152
+ integers.some(name => !columns.some(column => column.name === name && column.type === "INTEGER"))) {
153
+ throw new Error("Codex's saved native goal schema is unsupported; no history was changed.");
154
+ }
155
+ const rows = database.prepare(`SELECT thread_id, goal_id, objective, status, token_budget,
156
+ tokens_used, time_used_seconds, created_at_ms, updated_at_ms
157
+ FROM thread_goals WHERE thread_id = ? LIMIT 2`).all(threadId);
158
+ if (!rows.length) return null;
159
+ const row = rows[0];
160
+ if (rows.length !== 1 || row.thread_id !== threadId ||
161
+ !/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/u.test(row.goal_id || "") ||
162
+ typeof row.objective !== "string" ||
163
+ !["active", "paused", "blocked", "usage_limited", "budget_limited", "complete"].includes(row.status) ||
164
+ row.token_budget !== null && (!Number.isSafeInteger(row.token_budget) || row.token_budget < 0) ||
165
+ ["tokens_used", "time_used_seconds", "created_at_ms", "updated_at_ms"].some(key =>
166
+ !Number.isSafeInteger(row[key]) || row[key] < 0)) {
167
+ throw new Error("Codex's saved native goal is unsupported or ambiguous; no history was changed.");
168
+ }
169
+ return row;
170
+ }, { signal });
171
+ }
172
+
138
173
  async function codexAppServerThreadHasReadableHistory(provider = null, threadId = "") {
139
174
  const normalizedThreadId = normalizeAgentText(threadId);
140
175
  if (!normalizedThreadId || typeof provider?.readThread !== "function") {
@@ -2025,7 +2060,9 @@ export class CodexAppServerAgentProvider {
2025
2060
  throw new TypeError("Codex tool dispatch requires its exact thread and handler.");
2026
2061
  }
2027
2062
  if (this.threadRequestHandlers.has(threadId)) throw new Error("This Codex thread already has an application tool owner.");
2028
- const registration = { handler };
2063
+ const runInContext = this.options.runInContext;
2064
+ const registration = { handler: typeof runInContext === "function"
2065
+ ? request => runInContext(() => handler(request)) : handler };
2029
2066
  this.threadRequestHandlers.set(threadId, registration);
2030
2067
  const client = this.client;
2031
2068
  client?.setRequestHandler?.(request => this.handleServerRequest(request, client));
@@ -2768,7 +2805,7 @@ export {
2768
2805
  codexAppServerThreadHasReadableHistory, deleteCodexAppServerThread, deleteCodexAppServerHelperThread,
2769
2806
  inspectCodexAppServerMessageAdmission,
2770
2807
  codexCliResumeCommand, codexLocalImageInput, codexTextInput, codexTurnInput, shellQuote,
2771
- exportCodexNativeHistory, retireCodexConversationHistory
2808
+ exportCodexNativeHistory, retireCodexConversationHistory, readCodexNativeGoal
2772
2809
  };
2773
2810
  export {
2774
2811
  createCodexAppServerProviderOwner,
@@ -231,6 +231,7 @@ export function createCodexAppServerProviderOwner({
231
231
  create: onObservationLost => {
232
232
  const parameters = {
233
233
  ...prepared.parameters,
234
+ runInContext: prepared.runInContext,
234
235
  onClose() { unsubscribeControls?.(); },
235
236
  beforeResumeThread(threadId) {
236
237
  return prepared.runInContext(async () => {
@@ -561,12 +562,120 @@ export function createCodexAppServerProviderOwner({
561
562
  });
562
563
  }
563
564
 
565
+ async function invalidateProviderThreads(modelProviderId, includeOwned, toolHomeSource) {
566
+ const groups = new Map();
567
+ const inventory = [...providerRecords()];
568
+ if (includeOwned) for (const record of codexAppServerOwnedRuntimes.values()) {
569
+ if (!inventory.some(candidate => candidate.provider === record.provider)) inventory.push(record);
570
+ }
571
+ for (const record of inventory) {
572
+ if (!inScope(record)) continue;
573
+ const key = codexAppServerOwnedRuntimeKey(record.providerKey, record.providerOptions);
574
+ if (!groups.has(key)) groups.set(key, []);
575
+ groups.get(key).push(record);
576
+ }
577
+ const results = [], failed = [];
578
+ for (const [runtimeKey, peers] of groups) {
579
+ try {
580
+ await withRuntimeLifecycle(runtimeKey, async () => {
581
+ const available = peers.filter(record => record.provider.client?.isOpen());
582
+ if (!available.length) {
583
+ if (peers.some(record => record.provider.runtime || record.provider.runtimeStopOwner ||
584
+ codexAppServerOwnedRuntimes.get(runtimeKey)?.provider === record.provider)) {
585
+ throw new Error("Codex's existing control connection is unavailable. Restore it before changing this provider connection.");
586
+ }
587
+ return;
588
+ }
589
+ const request = (record, method, params) => {
590
+ const client = record.provider.client;
591
+ if (!client?.isOpen()) throw new Error("Codex's control connection closed during provider retirement. Restore it and retry.");
592
+ return client.request(method, params, { signal: AbortSignal.timeout(5_000) });
593
+ };
594
+ const selectedHome = normalizeText(toolHomeSource);
595
+ const dedicated = Boolean(selectedHome && peers.some(record =>
596
+ normalizeText(record.providerOptions.toolHomeSource) === selectedHome));
597
+ if (dedicated && peers.some(record => normalizeText(record.providerOptions.toolHomeSource) !== selectedHome)) {
598
+ throw new Error("Codex's provider credential home has other native consumers. Close its native terminal/helper and retry; unrelated runtimes remain running.");
599
+ }
600
+ const inspector = available[0], candidates = new Set(), seen = new Set();
601
+ let cursor;
602
+ do {
603
+ const page = await request(inspector, "thread/loaded/list", { limit: 100, ...(cursor ? { cursor } : {}) });
604
+ if (!Array.isArray(page?.data)) throw new Error("Codex returned an invalid loaded-thread inventory. Restore its connection before retrying.");
605
+ for (const id of page.data) {
606
+ if (typeof id !== "string" || !id) throw new Error("Codex returned an invalid loaded-thread identity.");
607
+ candidates.add(id);
608
+ }
609
+ cursor = page.nextCursor;
610
+ if (cursor && (typeof cursor !== "string" || seen.has(cursor) || seen.size >= 100)) throw new Error("Codex's loaded-thread inventory did not finish.");
611
+ if (cursor) seen.add(cursor);
612
+ } while (cursor);
613
+ for (const { provider } of peers) for (const [id, binding] of provider.threadEnvironments || []) {
614
+ if (dedicated || binding.params?.modelProvider === modelProviderId) candidates.add(id);
615
+ }
616
+ const threadIds = [];
617
+ for (const id of candidates) {
618
+ const { thread } = await request(inspector, "thread/read", { threadId: id, includeTurns: false });
619
+ if (!thread || thread.id !== id || typeof thread.modelProvider !== "string" || !thread.modelProvider ||
620
+ !(typeof thread.status === "string" ? thread.status : thread.status?.type)) {
621
+ throw new Error("Codex returned an untrustworthy thread/provider inventory. Restore its connection before changing credentials.");
622
+ }
623
+ if (dedicated && thread.modelProvider !== modelProviderId) {
624
+ throw new Error("Codex's provider credential home retains another provider thread. Close its native terminal/helper and retry; unrelated threads remain running.");
625
+ }
626
+ if (thread.modelProvider === modelProviderId) threadIds.push(id);
627
+ }
628
+ if (!threadIds.length && !dedicated) return;
629
+ const error = Object.assign(new Error("This Codex provider connection changed. Work is stopped; use Send or Resume after reconnecting."), {
630
+ code: "codex_provider_connection_changed"
631
+ });
632
+ for (const { provider } of peers) for (const threadId of threadIds) provider.conversationRuntime?.invalidateThread(threadId);
633
+ await Promise.allSettled(peers.flatMap(({ provider }) => threadIds
634
+ .map(id => provider.threadEnvironmentTasks?.get(id)).filter(Boolean)));
635
+ // The same observation journal retains selected stop ownership. Peers
636
+ // with other threads must keep their admission and active work intact.
637
+ for (const record of available) await record.observation?.prepare({ provider: record.provider, error, threadIds });
638
+ for (const threadId of threadIds) {
639
+ await inspector.provider.stopThreadForObservationLoss(threadId);
640
+ for (const record of available) {
641
+ const detached = await request(record, "thread/unsubscribe", { threadId });
642
+ if (!["unsubscribed", "notSubscribed", "notLoaded"].includes(detached?.status)) throw new Error("Codex did not confirm selected-thread detachment.");
643
+ }
644
+ const { thread } = await request(inspector, "thread/read", { threadId, includeTurns: false });
645
+ if ((typeof thread?.status === "string" ? thread.status : thread?.status?.type) !== "notLoaded") {
646
+ throw new Error("Close the native Codex terminal using this provider, then retry changing its connection. Another subscriber retained the selected thread; the shared server and other conversations remain running.");
647
+ }
648
+ }
649
+ if (dedicated) {
650
+ // A curated default also lives in this exact isolated helper's
651
+ // native config cache. Reuse verified retirement only after proving
652
+ // every loaded/cached thread belongs to the selected provider.
653
+ const owned = codexAppServerOwnedRuntimes.get(runtimeKey);
654
+ if (!owned || !peers.some(record => record.provider === owned.provider)) {
655
+ throw new Error("Codex's dedicated provider runtime ownership cannot be verified. Restore its control connection and retry.");
656
+ }
657
+ await owned.scope.stopOwnedRuntime(owned, { cached: false, preserveProcessExitProof: true, requireVerifiedExit: true });
658
+ }
659
+ for (const record of available) await record.observation?.complete({ provider: record.provider, error, threadIds, sharedStop: dedicated });
660
+ if (dedicated) for (const record of peers) record.scope.closeProvider(record.providerKey);
661
+ results.push({ runtimeKey, threadIds, stopped: true, sharedProcessRetained: !dedicated,
662
+ ...(dedicated ? { dedicatedRuntimeRetired: true } : {}) });
663
+ });
664
+ } catch (error) {
665
+ failed.push({ runtimeKey, error: error.message, retryable: true });
666
+ }
667
+ }
668
+ return { ok: failed.length === 0, failed, results, providerCount: results.length, stopped: results.reduce((sum, result) => sum + result.threadIds.length, 0) };
669
+ }
670
+
564
671
  async function invalidateRuntimes({
565
672
  includeOwned = false,
566
673
  requireVerifiedExit = false,
567
674
  stopOwnedRuntimes = false,
568
- toolHomeSource = ""
675
+ toolHomeSource = "",
676
+ modelProviderId = ""
569
677
  } = {}) {
678
+ if (modelProviderId) return withLifecycle(() => invalidateProviderThreads(modelProviderId, includeOwned, toolHomeSource));
570
679
  codexAppServerChatModelCatalog = null;
571
680
  const normalizedToolHomeSource = normalizeText(toolHomeSource);
572
681
  const ownedRecordsByProvider = new Map(
@@ -93,6 +93,7 @@ export function createCodexAppServerRunOwner({
93
93
  idlePublishPayload = null,
94
94
  checkpoint = async () => null,
95
95
  messageMetadata = {},
96
+ persistCommentary = true,
96
97
  deliveryStateMetadataKey = "assistant_delivery",
97
98
  hasRuntime = () => true,
98
99
  recoverAdmission = null,
@@ -117,6 +118,7 @@ export function createCodexAppServerRunOwner({
117
118
  interruptFailedCode = "codex_turn_interrupt_failed",
118
119
  errorPrefix = ""
119
120
  } = {}) {
121
+ if (typeof persistCommentary !== "boolean") throw new TypeError("Invalid Codex commentary persistence policy.");
120
122
  if (!Number.isSafeInteger(finalizingGraceMs) || finalizingGraceMs < 1) {
121
123
  throw new TypeError("Invalid Codex finalizing grace.");
122
124
  }
@@ -195,6 +197,7 @@ export function createCodexAppServerRunOwner({
195
197
  debugLog,
196
198
  debugError,
197
199
  liveProgressMaxLength,
200
+ persistCommentary,
198
201
  storeReadError,
199
202
  messageMetadata,
200
203
  snapshotRecoveryItemLimit
@@ -1,4 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { isCompletedEnvelopeTurn } from "./transcript.js";
2
3
 
3
4
  // A final answer keeps its identity when an application edits its visible text.
4
5
  export function conversationMessageIdentity(turnId, message) {
@@ -15,7 +16,13 @@ export function conversationMessageVersion(message, { includeData = false } = {}
15
16
 
16
17
  /** Keep native delivery and its history cursor in the same durable record. */
17
18
  export function createConversationChangeover({ state, transcript, agent, identity, presentation = {}, log = () => {},
18
- captureContext = false, applicationMessages = false }) {
19
+ captureContext = false, applicationMessages = false, maximumInitialMessages = 30,
20
+ maximumInitialMessageCharacters = Infinity }) {
21
+ if (!Number.isSafeInteger(maximumInitialMessages) || maximumInitialMessages < 0 ||
22
+ maximumInitialMessageCharacters !== Infinity &&
23
+ (!Number.isSafeInteger(maximumInitialMessageCharacters) || maximumInitialMessageCharacters < 1)) {
24
+ throw new TypeError("Initial native history requires a non-negative message limit and a positive text limit.");
25
+ }
19
26
  const {
20
27
  label = "Conversation changeover",
21
28
  applicationName = "the application",
@@ -100,7 +107,13 @@ export function createConversationChangeover({ state, transcript, agent, identit
100
107
  code: unconfirmedCode, messageId, threadId, error: unconfirmedMessage };
101
108
  }
102
109
 
103
- async function send({ engineId, messages, input, turnMetadata }) {
110
+ async function send({ engineId, messages, input, turnMetadata, completedEnvelope = false, excludedMessageIds = [] }) {
111
+ if (typeof completedEnvelope !== "boolean" || !Array.isArray(excludedMessageIds) ||
112
+ excludedMessageIds.some(id => typeof id !== "string" || !id.trim() || id.length > 128) ||
113
+ new Set(excludedMessageIds).size !== excludedMessageIds.length || excludedMessageIds.length && !completedEnvelope) {
114
+ throw new TypeError("Native history exclusions require a tracked completed envelope and exact message IDs.");
115
+ }
116
+ const excluded = new Set(excludedMessageIds);
104
117
  const value = await readState({ engineId, messages });
105
118
  const binding = value.engines[engineId] ||= { seen: {} };
106
119
  rememberReplies(value, engineId, messages);
@@ -165,17 +178,19 @@ export function createConversationChangeover({ state, transcript, agent, identit
165
178
  const snapshot = Object.fromEntries(messages.map((message) => [message.id, message.version]));
166
179
  const returning = Object.keys(binding.seen).length > 0;
167
180
  const changed = messages.filter((message) => binding.seen[message.id] !== message.version);
181
+ const renderChanged = changed.filter(message => !excluded.has(message.messageId));
168
182
  const deleted = Object.keys(binding.seen).filter((id) => !Object.hasOwn(snapshot, id));
169
183
  const switched = value.lastEngine !== engineId;
170
184
  // A new native conversation gets the recent bubbles. A returning one gets
171
185
  // every missed/edited bubble, even if the edit is older than that window.
172
- const catchup = returning ? changed : messages.slice(-30);
186
+ const catchup = (returning ? changed : maximumInitialMessages ? messages.slice(-maximumInitialMessages) : [])
187
+ .filter(message => !excluded.has(message.messageId));
173
188
  let pending = binding.pending;
174
189
  if (pending && pending.messageId !== input.messageId) {
175
190
  delete binding.pending;
176
191
  pending = null;
177
192
  }
178
- if (!pending && (switched || changed.length || deleted.length)) {
193
+ if (!pending && (switched || renderChanged.length || deleted.length)) {
179
194
  const handover = value.replacement?.engineId === engineId && value.replacement.status === "ready"
180
195
  ? value.replacement.handover : "";
181
196
  const preamble = [
@@ -187,7 +202,10 @@ export function createConversationChangeover({ state, transcript, agent, identit
187
202
  ...(handover ? ["This is a fresh native context. Earlier native history remains separate. The saved continuity briefing follows:",
188
203
  JSON.stringify({ handover })] : []),
189
204
  JSON.stringify({ messages: catchup.map(({ version, originalVersion, receipt, ...message }) => ({
190
- ...message, ...(binding.seen[message.id] ? { corrected: true } : {})
205
+ ...message,
206
+ ...(!returning && typeof message.text === "string" && message.text.length > maximumInitialMessageCharacters
207
+ ? { text: message.text.slice(0, maximumInitialMessageCharacters) } : {}),
208
+ ...(binding.seen[message.id] ? { corrected: true } : {})
191
209
  })), removedMessageIds: deleted }),
192
210
  `[End ${label}]`,
193
211
  "",
@@ -247,7 +265,7 @@ export function createConversationChangeover({ state, transcript, agent, identit
247
265
  }
248
266
 
249
267
  function writtenMessages(history, native = false) {
250
- return history.flatMap(turn => (turn.messages || []).filter(message => message.role !== "thinking")
268
+ return history.filter(turn => !isCompletedEnvelopeTurn(turn)).flatMap(turn => (turn.messages || []).filter(message => message.role !== "thinking")
251
269
  .map(message => ({ id: conversationMessageIdentity(turn.turnId, message),
252
270
  role: message.role === "system" && turn.metadata?.runtime?.origin === "application" ? "application" : message.role,
253
271
  messageId: message.messageId, text: message.text,
@@ -1,5 +1,5 @@
1
1
  export { createConversationTranscript } from "./transcript.js";
2
- export { conversationMessageIdentity, conversationMessageVersion, createConversationChangeover } from "./continuity.js";
2
+ export { conversationMessageIdentity, conversationMessageVersion, conversationHistoryVersions, createConversationChangeover, conversationRequestText } from "./continuity.js";
3
3
  export { createMemoryConversationStorage } from "./memoryStorage.js";
4
4
  export { createConversationStorage, createReentrantConversationStorage,
5
5
  createConversationOperationLease, beginConversationOperation, finishConversationOperation } from "./storage.js";
@@ -1,6 +1,103 @@
1
1
  import path from "node:path";
2
+ import { chmod, copyFile, lstat, mkdtemp, open, realpath, rm } from "node:fs/promises";
3
+ import { createReadStream } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { pathToFileURL } from "node:url";
2
6
  import { createHash } from "node:crypto";
3
7
 
8
+ // Providers retain their grammar; stopped file custody belongs to this owner.
9
+ // Immutable reads require no journal. A valid stopped WAL is read from private
10
+ // copies so SQLite can build its own SHM without touching native storage.
11
+ export async function readStoppedNativeDatabase(databasePath, read, { signal } = {}) {
12
+ if (!path.isAbsolute(databasePath || "") || typeof read !== "function") {
13
+ throw new TypeError("Native inspection requires an absolute database path and reader.");
14
+ }
15
+ const journalError = () => new Error("Native storage still has a journal sidecar that cannot be inspected safely. Keep its original storage owner stopped; inspection does not checkpoint or remove it.");
16
+ const inspect = async () => {
17
+ const files = {};
18
+ for (const suffix of ["", "-wal", "-shm", "-journal"]) {
19
+ signal?.throwIfAborted();
20
+ const file = `${databasePath}${suffix}`;
21
+ let info;
22
+ try { info = await lstat(file); }
23
+ catch (error) { if (suffix && error.code === "ENOENT") continue; throw error; }
24
+ if (!info.isFile() || info.isSymbolicLink() || await realpath(file) !== file) {
25
+ throw new Error("Native storage must be a regular file at its original canonical path.");
26
+ }
27
+ files[suffix] = info;
28
+ }
29
+ if (files["-journal"] || Boolean(files["-wal"]) !== Boolean(files["-shm"])) throw journalError();
30
+ return files;
31
+ };
32
+ const before = await inspect();
33
+ const fingerprint = async file => {
34
+ const hash = createHash("sha256");
35
+ try {
36
+ for await (const chunk of createReadStream(file, { signal })) hash.update(chunk);
37
+ return hash.digest("hex");
38
+ } catch (error) {
39
+ if (signal?.aborted) throw signal.reason;
40
+ throw error;
41
+ }
42
+ };
43
+ const unchanged = async (digests = null) => {
44
+ const after = await inspect();
45
+ if (JSON.stringify(Object.keys(before)) !== JSON.stringify(Object.keys(after)) ||
46
+ Object.keys(before).some(suffix => ["dev", "ino", "size", "mtimeMs", "ctimeMs", "mode"].some(key => before[suffix][key] !== after[suffix][key]))) {
47
+ throw new Error("Native storage changed during inspection. Keep all of its writers stopped before retrying.");
48
+ }
49
+ if (digests) for (const suffix of Object.keys(before)) {
50
+ if (await fingerprint(`${databasePath}${suffix}`) !== digests[suffix]) {
51
+ throw new Error("Native storage changed during inspection. Keep all of its writers stopped before retrying.");
52
+ }
53
+ }
54
+ };
55
+ const { DatabaseSync } = await import("node:sqlite");
56
+ let directory;
57
+ let database;
58
+ let result;
59
+ let digests;
60
+ try {
61
+ signal?.throwIfAborted();
62
+ let target = `${pathToFileURL(databasePath).href}?mode=ro&immutable=1`;
63
+ if (before["-wal"]) {
64
+ // Eligibility only: SQLite owns WAL frames, checksums and committed pages.
65
+ const header = Buffer.alloc(32);
66
+ const wal = await open(`${databasePath}-wal`, "r");
67
+ let bytesRead;
68
+ try { ({ bytesRead } = await wal.read(header, 0, header.length, 0)); }
69
+ finally { await wal.close(); }
70
+ const magic = header.readUInt32BE(0);
71
+ const pageSize = header.readUInt32BE(8);
72
+ if (bytesRead !== 32 || ![0x377f0682, 0x377f0683].includes(magic) || header.readUInt32BE(4) !== 3007000 ||
73
+ pageSize < 512 || pageSize > 65536 || (pageSize & (pageSize - 1)) !== 0 || before["-shm"].size < 32768) throw journalError();
74
+ digests = {};
75
+ for (const suffix of Object.keys(before)) digests[suffix] = await fingerprint(`${databasePath}${suffix}`);
76
+ await unchanged(digests);
77
+ directory = await mkdtemp(path.join(tmpdir(), "assistant-native-snapshot-"));
78
+ const snapshot = path.join(directory, "native.db");
79
+ for (const suffix of ["", "-wal"]) {
80
+ signal?.throwIfAborted();
81
+ await copyFile(`${databasePath}${suffix}`, `${snapshot}${suffix}`);
82
+ await chmod(`${snapshot}${suffix}`, 0o600);
83
+ if (await fingerprint(`${snapshot}${suffix}`) !== digests[suffix]) throw new Error("Native storage changed while copying its stopped snapshot.");
84
+ }
85
+ await unchanged(digests);
86
+ target = `${pathToFileURL(snapshot).href}?mode=ro`;
87
+ }
88
+ database = new DatabaseSync(target, { readOnly: true });
89
+ database.exec("PRAGMA query_only=ON; BEGIN");
90
+ result = await read(database);
91
+ signal?.throwIfAborted();
92
+ database.exec("ROLLBACK");
93
+ await unchanged(digests);
94
+ } finally {
95
+ try { database?.close(); }
96
+ finally { if (directory) await rm(directory, { recursive: true, force: true }); }
97
+ }
98
+ return result;
99
+ }
100
+
4
101
  export function canonicalNativeHistoryJson(value) {
5
102
  return JSON.stringify(value, (_key, entry) => entry && typeof entry === "object" && !Array.isArray(entry)
6
103
  ? Object.fromEntries(Object.keys(entry).sort().map((key) => [key, entry[key]])) : entry);
@@ -1,7 +1,7 @@
1
1
  import { pathToFileURL } from "node:url";
2
2
  import { Buffer } from "node:buffer";
3
3
  import path from "node:path";
4
- import { createNativeHistoryExport, retireNativeConversation } from "./nativeHistoryExport.js";
4
+ import { createNativeHistoryExport, readStoppedNativeDatabase, retireNativeConversation } from "./nativeHistoryExport.js";
5
5
 
6
6
  const OPENCODE_RESPONSE_LIMIT_BYTES = 2 * 1024 * 1024;
7
7
  const OPENCODE_CATALOG_LIMIT_BYTES = 32 * 1024 * 1024;
@@ -122,6 +122,51 @@ function normalizedMessageRows(value = null) {
122
122
  });
123
123
  }
124
124
 
125
+ /** Inspect stopped native storage without starting OpenCode or changing its files. */
126
+ async function readOpenCodeConversationDatabase({ databasePath, conversationId, onMessage, signal } = {}) {
127
+ if (!path.isAbsolute(databasePath || "") || !/^ses_[a-zA-Z0-9_]{1,256}$/u.test(conversationId || "") ||
128
+ typeof onMessage !== "function") throw new TypeError("OpenCode inspection requires its exact database, conversation and message reader.");
129
+ const unavailable = () => new Error("OpenCode native storage could not be inspected completely. Keep its writers stopped and inspect its original database before retrying.");
130
+ try {
131
+ return await readStoppedNativeDatabase(databasePath, async database => {
132
+ const session = database.prepare("SELECT * FROM session WHERE id = ?").get(conversationId);
133
+ if (!session || session.id !== conversationId || !path.isAbsolute(session.directory || "")) throw unavailable();
134
+ if (database.prepare("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = 'session_input'").get() &&
135
+ database.prepare("SELECT 1 FROM session_input WHERE session_id = ? AND promoted_seq IS NULL LIMIT 1").get(conversationId)) {
136
+ throw new Error("OpenCode has an unpromoted native request. Inspect it before restoring continuity; no request was repeated.");
137
+ }
138
+ const output = createNativeHistoryExport(async ({ message }) => onMessage(normalizedMessageRows([message])[0]), { signal });
139
+ const parts = database.prepare("SELECT id, message_id, session_id, data FROM part WHERE message_id = ? ORDER BY id");
140
+ for (const row of database.prepare("SELECT id, session_id, data FROM message WHERE session_id = ? ORDER BY time_created, id").iterate(conversationId)) {
141
+ signal?.throwIfAborted();
142
+ if (!/^msg_[a-zA-Z0-9_]{1,256}$/u.test(row.id)) throw unavailable();
143
+ let bytes = Buffer.byteLength(row.data);
144
+ if (bytes > 64 * 1024 * 1024) throw unavailable();
145
+ const data = JSON.parse(row.data);
146
+ if (!data || typeof data !== "object" || Array.isArray(data) || !["user", "assistant"].includes(data.role)) throw unavailable();
147
+ const content = [];
148
+ for (const part of parts.iterate(row.id)) {
149
+ signal?.throwIfAborted();
150
+ bytes += Buffer.byteLength(part.data);
151
+ if (bytes > 64 * 1024 * 1024 || part.session_id !== conversationId || part.message_id !== row.id ||
152
+ !/^prt_[a-zA-Z0-9_]{1,256}$/u.test(part.id)) throw unavailable();
153
+ const value = JSON.parse(part.data);
154
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw unavailable();
155
+ content.push({ ...value, id: part.id, sessionID: part.session_id, messageID: part.message_id });
156
+ }
157
+ // Same original MessageV2 storage projection: native columns own IDs.
158
+ await output.emit({ type: "message", message: { info: { ...data, id: row.id, sessionID: row.session_id }, parts: content }, text: [] });
159
+ }
160
+ return { session, ...output.complete() };
161
+ }, { signal });
162
+ } catch (error) {
163
+ if (signal?.aborted) throw signal.reason;
164
+ if (String(error?.message || "").startsWith("OpenCode ") ||
165
+ String(error?.message || "").startsWith("Native storage ")) throw error;
166
+ throw unavailable();
167
+ }
168
+ }
169
+
125
170
  function providerCapabilityMedia(value = null) {
126
171
  const source = record(value);
127
172
  return Object.fromEntries([
@@ -710,6 +755,7 @@ async function retireOpenCodeConversationHistory(storageClient, controlClient, b
710
755
  }
711
756
 
712
757
  export {
758
+ readOpenCodeConversationDatabase,
713
759
  retireOpenCodeConversationHistory,
714
760
  openCodeAssistantMessageText,
715
761
  OPENCODE_RESPONSE_LIMIT_BYTES,