@jskit-ai/assistant-core 0.1.213 → 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.
- package/examples/conversation/package.json +5 -5
- package/fixtures/responsive-assistant/ConversationFixture.vue +7 -1
- package/package.json +5 -5
- package/src/client/conversation/AssistantConversationElement.vue +5 -1
- package/src/client/conversation/AssistantTranscript.vue +10 -1
- package/src/server/conversation/agentRun.js +38 -11
- package/src/server/conversation/claudeHistory.js +40 -5
- package/src/server/conversation/claudeProcess.js +16 -2
- package/src/server/conversation/claudeTurn.js +28 -8
- package/src/server/conversation/codexConfiguration.js +1 -1
- package/src/server/conversation/codexHistoryAdapter.js +11 -4
- package/src/server/conversation/codexNotificationQueue.js +11 -2
- package/src/server/conversation/codexObservationControl.js +8 -8
- package/src/server/conversation/codexOutput.js +20 -15
- package/src/server/conversation/codexProvider.js +40 -3
- package/src/server/conversation/codexProviderOwner.js +110 -1
- package/src/server/conversation/codexTurn.js +3 -0
- package/src/server/conversation/continuity.js +24 -6
- package/src/server/conversation/index.js +1 -1
- package/src/server/conversation/nativeHistoryExport.js +97 -0
- package/src/server/conversation/openCodeClient.js +47 -1
- package/src/server/conversation/openCodeRuntime.js +132 -24
- package/src/server/conversation/openCodeTurn.js +65 -24
- package/src/server/conversation/providers/api.js +7 -0
- package/src/server/conversation/providers/claudeDriver.js +52 -7
- package/src/server/conversation/providers/codex.js +19 -2
- package/src/server/conversation/providers/codexDriver.js +92 -14
- package/src/server/conversation/providers/opencodeDriver.js +31 -9
- package/src/server/conversation/runtime.js +418 -64
- package/src/server/conversation/runtimeStateUpgrade.js +77 -2
- package/src/server/conversation/streams.js +25 -5
- package/src/server/conversation/tools.js +2 -2
- package/src/server/conversation/transcript.js +23 -8
- package/src/server/hosts/claudeHistory.js +1 -0
- package/src/server/hosts/claudeProcess.js +1 -0
- package/src/server/hosts/codexProvider.js +3 -0
- package/src/server/hosts/nativeHistory.js +1 -0
- package/src/server/hosts/openCodeClient.js +1 -0
- package/src/server/hosts/openCodeTurn.js +1 -0
- package/src/server/index.js +1 -1
- package/src/server/lib/assistantToolLoop.js +99 -2
- package/test/aiConnectionClient.test.js +57 -0
- package/test/assistantBoundedToolLoop.test.js +332 -0
- package/test/assistantScroll.browser.test.js +151 -0
- package/test/claudeConversationSession.test.js +1329 -3
- package/test/claudeProcess.test.js +101 -0
- package/test/claudeTurn.test.js +31 -0
- package/test/codexConversationSession.test.js +1257 -12
- package/test/codexHistoryAdapter.test.js +74 -1
- package/test/codexTurn.test.js +282 -1
- package/test/conversation.test.js +83 -0
- package/test/conversationAgentRun.test.js +181 -0
- package/test/conversationRuntime.test.js +95 -0
- package/test/conversationRuntimeStateUpgrade.test.js +89 -0
- package/test/conversationStreaming.test.js +85 -0
- package/test/conversationTools.test.js +112 -1
- package/test/openCodeClient.test.js +186 -0
- package/test/openCodeRuntime.test.js +78 -0
- 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
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
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
|
-
|
|
651
|
-
|
|
652
|
-
|
|
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
|
|
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(-
|
|
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 ||
|
|
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,
|
|
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,
|