@jskit-ai/assistant-core 0.1.199 → 0.1.201
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/README.md +8 -1
- package/examples/conversation/package.json +5 -5
- package/fixtures/responsive-assistant/App.vue +4 -1
- package/package.json +99 -36
- package/src/client/conversation/AssistantConversationElement.vue +27 -4
- package/src/client/conversation/AssistantConversationStatus.vue +24 -0
- package/src/client/conversation/AssistantGoalControl.vue +8 -5
- package/src/client/conversation/AssistantTranscript.vue +10 -3
- package/src/client/conversation/index.js +2 -0
- package/src/client/conversation/messageDelivery.js +115 -32
- package/src/client/conversation/retainedConversation.js +32 -0
- package/src/client/conversation/useAssistantQuestions.js +76 -0
- package/src/client/lib/assistantApi.js +72 -2
- package/src/server/conversation/agentRun.js +222 -0
- package/src/server/conversation/attachments.js +93 -0
- package/src/server/conversation/claudeClient.js +221 -0
- package/src/server/conversation/claudeHistory.js +256 -0
- package/src/server/conversation/claudeProcess.js +327 -0
- package/src/server/conversation/claudeTools.js +42 -0
- package/src/server/conversation/claudeTurn.js +1082 -0
- package/src/server/conversation/codexConfiguration.js +1183 -0
- package/src/server/conversation/codexConversations.js +1082 -0
- package/src/server/conversation/codexDetachedTurn.js +332 -0
- package/src/server/conversation/codexEvents.js +194 -1
- package/src/server/conversation/codexGoalCommands.js +124 -0
- package/src/server/conversation/codexHelperExecution.js +306 -0
- package/src/server/conversation/codexHelperThreadLedger.js +804 -0
- package/src/server/conversation/codexHelperThreadRecovery.js +395 -0
- package/src/server/conversation/codexHelperThreads.js +638 -0
- package/src/server/conversation/codexHistoryAdapter.js +369 -0
- package/src/server/conversation/codexMessageCommands.js +663 -0
- package/src/server/conversation/codexMessageDelivery.js +211 -0
- package/src/server/conversation/codexNativeHistoryExport.js +95 -0
- package/src/server/conversation/codexNotificationObserver.js +482 -0
- package/src/server/conversation/codexNotificationQueue.js +103 -0
- package/src/server/conversation/codexObservationControl.js +174 -0
- package/src/server/conversation/codexOutput.js +931 -0
- package/src/server/conversation/codexProcess.js +1559 -0
- package/src/server/conversation/codexProcessLeader.js +14 -0
- package/src/server/conversation/codexProvider.js +2710 -0
- package/src/server/conversation/codexProviderOwner.js +1058 -0
- package/src/server/conversation/codexProviderSelection.js +218 -0
- package/src/server/conversation/codexReasoning.js +314 -0
- package/src/server/conversation/codexRenewalCommands.js +290 -0
- package/src/server/conversation/codexResultSettlement.js +409 -0
- package/src/server/conversation/codexRunJournal.js +658 -0
- package/src/server/conversation/codexRuntimeLifecycle.js +288 -0
- package/src/server/conversation/codexSessionCleanup.js +96 -0
- package/src/server/conversation/codexSessionReconciliation.js +284 -0
- package/src/server/conversation/codexThreadReadiness.js +397 -0
- package/src/server/conversation/codexThreadStatus.js +354 -0
- package/src/server/conversation/codexTurn.js +701 -255
- package/src/server/conversation/codexTurnControl.js +317 -0
- package/src/server/conversation/codexTurnRecovery.js +990 -0
- package/src/server/conversation/codexTurnState.js +549 -0
- package/src/server/conversation/commandHook.js +18 -0
- package/src/server/conversation/commandWrapper.js +37 -0
- package/src/server/conversation/configuration.js +43 -0
- package/src/server/conversation/continuity.js +289 -0
- package/src/server/conversation/fileStorage.js +55 -0
- package/src/server/conversation/hookBridge.js +19 -0
- package/src/server/conversation/index.js +13 -0
- package/src/server/conversation/localExecution.js +665 -0
- package/src/server/conversation/memoryStorage.js +7 -72
- package/src/server/conversation/nativeHistoryExport.js +90 -0
- package/src/server/conversation/nativeStorage.js +23 -0
- package/src/server/conversation/openCodeClient.js +209 -19
- package/src/server/conversation/openCodePlugin.js +84 -0
- package/src/server/conversation/openCodeProcess.js +476 -0
- package/src/server/conversation/openCodeRuntime.js +1578 -0
- package/src/server/conversation/openCodeTools.js +60 -0
- package/src/server/conversation/openCodeTurn.js +557 -0
- package/src/server/conversation/provider.js +30 -0
- package/src/server/conversation/providers/api.js +136 -0
- package/src/server/conversation/providers/claude.js +63 -0
- package/src/server/conversation/providers/claudeDriver.js +425 -0
- package/src/server/conversation/providers/codex.js +248 -0
- package/src/server/conversation/providers/codexDriver.js +1078 -0
- package/src/server/conversation/providers/opencode.js +49 -0
- package/src/server/conversation/providers/opencodeDriver.js +650 -0
- package/src/server/conversation/providers/scoped.js +312 -0
- package/src/server/conversation/runtime.js +2220 -0
- package/src/server/conversation/runtimeStateUpgrade.js +125 -0
- package/src/server/conversation/storage.js +184 -0
- package/src/server/conversation/streams.js +7 -2
- package/src/server/conversation/structuredOutput.js +240 -0
- package/src/server/conversation/systemPrompt.js +54 -0
- package/src/server/conversation/tools.js +84 -0
- package/src/server/conversation/transcript.js +5 -4
- package/src/server/hosts/claudeHistory.js +5 -0
- package/src/server/hosts/claudeProcess.js +11 -0
- package/src/server/hosts/claudeTurn.js +4 -0
- package/src/server/hosts/codexConfiguration.js +20 -0
- package/src/server/hosts/codexEvents.js +13 -0
- package/src/server/hosts/codexProcess.js +12 -0
- package/src/server/hosts/codexProvider.js +23 -0
- package/src/server/hosts/codexTurn.js +20 -0
- package/src/server/hosts/nativeHistory.js +3 -0
- package/src/server/hosts/openCodeClient.js +3 -0
- package/src/server/hosts/openCodeProcess.js +23 -0
- package/src/server/hosts/openCodeTurn.js +7 -0
- package/src/server/index.js +2 -0
- package/src/server/lib/aiConnectionClient.js +13 -6
- package/src/server/lib/assistantCompletion.js +190 -0
- package/src/server/lib/assistantToolLoop.js +314 -0
- package/src/server/lib/serviceToolCatalog.js +25 -10
- package/src/shared/assistantResponseText.js +29 -0
- package/src/shared/conversation/attachments.js +19 -0
- package/src/shared/conversation/index.js +3 -0
- package/src/shared/conversation/pagination.js +78 -0
- package/src/shared/conversation/patches.js +150 -0
- package/src/shared/conversation/questions.js +48 -0
- package/src/shared/conversation/streaming.js +7 -4
- package/src/shared/index.js +2 -0
- package/src/shared/nativeProviders.js +45 -0
- package/src/testing/conversationStorageContract.js +31 -1
- package/src/testing/nativeCodex.js +11 -0
- package/src/testing/nativeOpenCode.js +3 -0
- package/test/aiConnectionClient.test.js +2 -0
- package/test/apiConversationDriver.test.js +73 -0
- package/test/assistantApiSurfaceHeader.test.js +85 -0
- package/test/assistantBoundedToolLoop.test.js +285 -0
- package/test/assistantScroll.browser.test.js +16 -0
- package/test/claudeClient.test.js +189 -0
- package/test/claudeConversationSession.test.js +1173 -0
- package/test/claudeInstructionResume.test.js +82 -0
- package/test/claudeProcess.test.js +204 -0
- package/test/claudeTurn.test.js +253 -0
- package/test/codexConversationSession.test.js +2122 -0
- package/test/codexGoal.test.js +37 -0
- package/test/codexHelperDeletion.test.js +163 -0
- package/test/codexHelperThreadLedger.test.js +494 -0
- package/test/codexHistoryAdapter.test.js +541 -0
- package/test/codexInteractiveArguments.test.js +30 -0
- package/test/codexIsolation.test.js +756 -0
- package/test/codexModelCatalog.test.js +362 -0
- package/test/codexNativeHistoryExport.test.js +81 -0
- package/test/codexThreadSelection.test.js +136 -0
- package/test/codexTurn.test.js +2158 -0
- package/test/conversation.test.js +110 -0
- package/test/conversationAgentRun.test.js +326 -0
- package/test/conversationApiFacilities.test.js +90 -0
- package/test/conversationAttachments.test.js +238 -0
- package/test/conversationCommandWrapper.test.js +48 -0
- package/test/conversationConfiguration.test.js +174 -0
- package/test/conversationNativeStorage.test.js +25 -0
- package/test/conversationReplacement.test.js +172 -0
- package/test/conversationRuntime.test.js +241 -0
- package/test/conversationRuntimeStateUpgrade.test.js +144 -0
- package/test/conversationSession.test.js +1080 -0
- package/test/conversationStreaming.test.js +68 -0
- package/test/conversationSystemPrompt.test.js +84 -0
- package/test/conversationTools.test.js +416 -0
- package/test/fileConversationStorage.test.js +73 -0
- package/test/fixtures/codexHelperThreadLedger.js +39 -0
- package/test/localConversationExecution.test.js +90 -0
- package/test/messageDelivery.test.js +234 -1
- package/test/nativeConversationRetirement.test.js +215 -0
- package/test/openCodeClient.test.js +515 -0
- package/test/openCodeProcess.test.js +177 -0
- package/test/openCodeRuntime.test.js +322 -0
- package/test/openCodeTools.test.js +287 -0
- package/test/opencodeConversationSession.test.js +789 -0
- package/test/serviceToolCatalog.test.js +24 -0
|
@@ -1,76 +1,11 @@
|
|
|
1
|
-
|
|
1
|
+
import { createConversationStorage } from "./storage.js";
|
|
2
|
+
|
|
3
|
+
/** Transient storage: share one instance across the application's conversations. */
|
|
2
4
|
export function createMemoryConversationStorage() {
|
|
3
5
|
const conversations = new Map();
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
return scope;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
function transaction(turns) {
|
|
12
|
-
return {
|
|
13
|
-
async listTurnIds() { return [...turns.keys()]; },
|
|
14
|
-
async nextTurnId() { return String(Math.max(0, ...[...turns.keys()].map(Number).filter(Number.isSafeInteger)) + 1).padStart(6, "0"); },
|
|
15
|
-
async hasMessage(id) {
|
|
16
|
-
return Boolean(id) && [...turns.values()].some((turn) => turn.messages.some((message) => message.messageId === id));
|
|
17
|
-
},
|
|
18
|
-
async readTurn(id) {
|
|
19
|
-
const turn = turns.get(id);
|
|
20
|
-
if (!turn) return null;
|
|
21
|
-
const find = (role) => turn.messages.find((message) => message.role === role) || null;
|
|
22
|
-
const thinking = turn.messages.filter((message) => message.role === "thinking");
|
|
23
|
-
const commentary = turn.messages.filter((message) => message.role === "commentary");
|
|
24
|
-
const activity = [...thinking, ...commentary].sort((a, b) => a.at.localeCompare(b.at));
|
|
25
|
-
const system = find("system"), user = find("user"), assistant = find("assistant");
|
|
26
|
-
return structuredClone({
|
|
27
|
-
turnId: id, user, assistant, thinking, commentary,
|
|
28
|
-
...(system ? { system } : {}), ...(turn.metadata ? { metadata: turn.metadata } : {}),
|
|
29
|
-
messages: [system, user, ...activity, assistant].filter(Boolean)
|
|
30
|
-
});
|
|
31
|
-
},
|
|
32
|
-
async appendMessage(id, { turnMetadata, ...message }) {
|
|
33
|
-
const turn = turns.get(id) || { messages: [] };
|
|
34
|
-
if (turnMetadata) turn.metadata = structuredClone(turnMetadata);
|
|
35
|
-
turn.messages.push(structuredClone(message));
|
|
36
|
-
turns.set(id, turn);
|
|
37
|
-
},
|
|
38
|
-
async replaceAssistant(id, message) {
|
|
39
|
-
if (!id) throw new TypeError("Assistant replacement requires a turn id.");
|
|
40
|
-
const turn = turns.get(id) || { messages: [] };
|
|
41
|
-
const previous = turn.messages.find((entry) => entry.role === "assistant");
|
|
42
|
-
turn.messages = turn.messages.filter((entry) => entry.role !== "assistant");
|
|
43
|
-
turn.messages.push({ ...previous, ...structuredClone(message), at: previous?.at || message.at });
|
|
44
|
-
turns.set(id, turn);
|
|
45
|
-
}
|
|
46
|
-
};
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
async function write(scope, callback, remove = false) {
|
|
50
|
-
const id = key(scope);
|
|
51
|
-
const previous = pending.get(id) || Promise.resolve();
|
|
52
|
-
const operation = previous.then(async () => {
|
|
53
|
-
const draft = structuredClone(conversations.get(id) || new Map());
|
|
54
|
-
const result = await callback(transaction(draft));
|
|
55
|
-
if (remove) conversations.delete(id);
|
|
56
|
-
else conversations.set(id, draft);
|
|
57
|
-
return result;
|
|
58
|
-
});
|
|
59
|
-
const settled = operation.then(() => undefined, () => undefined);
|
|
60
|
-
pending.set(id, settled);
|
|
61
|
-
try { return await operation; }
|
|
62
|
-
finally { if (pending.get(id) === settled) pending.delete(id); }
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
return Object.freeze({
|
|
66
|
-
write,
|
|
67
|
-
async read(scope, callback) {
|
|
68
|
-
const id = key(scope);
|
|
69
|
-
await pending.get(id);
|
|
70
|
-
return callback(transaction(structuredClone(conversations.get(id) || new Map())));
|
|
71
|
-
},
|
|
72
|
-
async deleteConversation(scope) {
|
|
73
|
-
await write(scope, async () => {}, true);
|
|
74
|
-
}
|
|
6
|
+
return createConversationStorage({
|
|
7
|
+
readRecord: (id) => conversations.get(id) || { turns: new Map(), metadata: {} },
|
|
8
|
+
writeRecord: (id, record) => { conversations.set(id, record); },
|
|
9
|
+
deleteRecord: (id) => { conversations.delete(id); }
|
|
75
10
|
});
|
|
76
11
|
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
|
|
4
|
+
export function canonicalNativeHistoryJson(value) {
|
|
5
|
+
return JSON.stringify(value, (_key, entry) => entry && typeof entry === "object" && !Array.isArray(entry)
|
|
6
|
+
? Object.fromEntries(Object.keys(entry).sort().map((key) => [key, entry[key]])) : entry);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// Providers own record projection; this small sink owns the common byte bound,
|
|
10
|
+
// callback backpressure and exact content revision used before retirement.
|
|
11
|
+
export function createNativeHistoryExport(onRecord, { signal, maxBytes = 2 * 1024 ** 3 } = {}) {
|
|
12
|
+
if (typeof onRecord !== "function") throw new TypeError("Native export requires a record callback.");
|
|
13
|
+
const digest = createHash("sha256");
|
|
14
|
+
let bytes = 0;
|
|
15
|
+
let recordCount = 0;
|
|
16
|
+
return {
|
|
17
|
+
async emit(record) {
|
|
18
|
+
signal?.throwIfAborted();
|
|
19
|
+
const serialized = `${canonicalNativeHistoryJson(record)}\n`;
|
|
20
|
+
bytes += Buffer.byteLength(serialized);
|
|
21
|
+
if (bytes > maxBytes) throw new Error("Native export exceeded its byte limit; history was not retired.");
|
|
22
|
+
digest.update(serialized);
|
|
23
|
+
recordCount++;
|
|
24
|
+
// An admitted host callback may still publish under the archive lock.
|
|
25
|
+
// Cancellation must await its completion; the host owns cancelling its IO.
|
|
26
|
+
await onRecord(record);
|
|
27
|
+
signal?.throwIfAborted();
|
|
28
|
+
},
|
|
29
|
+
complete(extra = {}) {
|
|
30
|
+
signal?.throwIfAborted();
|
|
31
|
+
return { revision: digest.digest("hex"), bytes, recordCount, ...extra };
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Shared by the three native owners. The host must durably preserve customer
|
|
37
|
+
// history, check references across projects and exclude external CLI writers.
|
|
38
|
+
// This callback is trusted server code, never request data or a retention rule.
|
|
39
|
+
export async function retireNativeConversation({ binding, inspect, remove, beforeDelete, readConversation, exportConversation }) {
|
|
40
|
+
if (typeof beforeDelete !== "function") throw new TypeError("Native retirement requires a host preservation callback.");
|
|
41
|
+
if (!binding?.conversationId || !path.isAbsolute(binding.workdir || "")) {
|
|
42
|
+
throw new TypeError("Native retirement requires an exact conversation and absolute native directory.");
|
|
43
|
+
}
|
|
44
|
+
const before = await inspect();
|
|
45
|
+
if (before.length === 0) return { ok: true, alreadyAbsent: true, conversationIds: [] };
|
|
46
|
+
const snapshot = JSON.stringify(before);
|
|
47
|
+
const ids = new Set(before.map((entry) => entry.conversationId));
|
|
48
|
+
const inventory = {
|
|
49
|
+
binding: structuredClone(binding),
|
|
50
|
+
conversations: structuredClone(before)
|
|
51
|
+
};
|
|
52
|
+
if (readConversation) {
|
|
53
|
+
inventory.readConversation = (id) => {
|
|
54
|
+
if (!ids.has(id)) throw new Error("Cannot export a conversation outside the inspected native family.");
|
|
55
|
+
return readConversation(id);
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
const exported = new Map();
|
|
59
|
+
let preserving = true;
|
|
60
|
+
if (exportConversation) {
|
|
61
|
+
inventory.exportConversation = async (id, onRecord) => {
|
|
62
|
+
if (!preserving || !ids.has(id)) throw new Error("Cannot export a conversation outside the inspected preservation scope.");
|
|
63
|
+
const result = await exportConversation(id, onRecord);
|
|
64
|
+
if (!/^[a-f0-9]{64}$/u.test(result?.revision || "")) throw new Error("Native export did not confirm a complete history revision.");
|
|
65
|
+
exported.set(id, result.revision);
|
|
66
|
+
return result;
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
let proof;
|
|
70
|
+
try { proof = await beforeDelete(inventory); }
|
|
71
|
+
finally { preserving = false; }
|
|
72
|
+
if (proof?.preserved !== true || proof?.exclusive !== true) {
|
|
73
|
+
throw new Error("The host did not confirm preserved history and exclusive ownership of every native conversation.");
|
|
74
|
+
}
|
|
75
|
+
if (exportConversation) {
|
|
76
|
+
if (exported.size !== ids.size) throw new Error("Preserve the complete native export of every inspected conversation before retirement.");
|
|
77
|
+
for (const id of ids) {
|
|
78
|
+
if ((await exportConversation(id, async () => {})).revision !== exported.get(id)) {
|
|
79
|
+
throw new Error("Native history changed during preservation. Export and preserve it again before retirement.");
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (JSON.stringify(await inspect()) !== snapshot) {
|
|
84
|
+
throw new Error("Native history changed during preservation. Inspect and preserve it again before retirement.");
|
|
85
|
+
}
|
|
86
|
+
await remove(before);
|
|
87
|
+
if ((await inspect()).length !== 0) throw new Error("Native deletion was not confirmed; retain the cleanup receipt and retry.");
|
|
88
|
+
return { ok: true, alreadyAbsent: false, conversationIds: before.map((entry) => entry.conversationId) };
|
|
89
|
+
}
|
|
90
|
+
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
const policies = Object.freeze({
|
|
2
|
+
// Codex's deletion transaction locks writers and rejects rollout references
|
|
3
|
+
// outside the deletion family, including another live fork.
|
|
4
|
+
codex: Object.freeze({ requiresWriterExclusion: false }),
|
|
5
|
+
claude: Object.freeze({
|
|
6
|
+
requiresWriterExclusion: true,
|
|
7
|
+
allowsManagedServer: false,
|
|
8
|
+
matchesProcess: ({ executable, commandLine }) =>
|
|
9
|
+
/(?:^|[\0/])claude(?:\0|$)|@anthropic-ai\/claude-code\//u.test(`${executable || ""}\0${commandLine}`)
|
|
10
|
+
}),
|
|
11
|
+
opencode: Object.freeze({
|
|
12
|
+
requiresWriterExclusion: true,
|
|
13
|
+
allowsManagedServer: true,
|
|
14
|
+
matchesProcess: ({ executable, commandLine }) =>
|
|
15
|
+
/(?:^|[\0/])opencode(?:\0|$)/u.test(`${executable || ""}\0${commandLine}`)
|
|
16
|
+
})
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
/** The host owns process inventory, identity proof and the exclusion lock. */
|
|
20
|
+
export function nativeConversationStoragePolicy(engineId) {
|
|
21
|
+
if (!Object.hasOwn(policies, engineId)) throw new TypeError("Unsupported native storage writer owner.");
|
|
22
|
+
return policies[engineId];
|
|
23
|
+
}
|
|
@@ -1,6 +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
5
|
|
|
5
6
|
const OPENCODE_RESPONSE_LIMIT_BYTES = 2 * 1024 * 1024;
|
|
6
7
|
const OPENCODE_CATALOG_LIMIT_BYTES = 32 * 1024 * 1024;
|
|
@@ -210,14 +211,21 @@ function stablePromptBody(input = {}) {
|
|
|
210
211
|
const modelID = text(model.modelID || model.id);
|
|
211
212
|
const providerID = text(model.providerID);
|
|
212
213
|
const prompt = typeof input?.prompt?.text === "string" ? input.prompt.text : "";
|
|
213
|
-
|
|
214
|
-
|
|
214
|
+
const content = (input.prompt?.content || []).map(part => {
|
|
215
|
+
if (part.type === "text" && typeof part.text === "string") return { type: "text", text: part.text };
|
|
216
|
+
if (part.type === "image" && part.image instanceof Uint8Array && ["image/png", "image/jpeg", "image/webp", "image/gif"].includes(part.mediaType)) {
|
|
217
|
+
return { type: "file", mime: part.mediaType, url: `data:${part.mediaType};base64,${Buffer.from(part.image).toString("base64")}` };
|
|
218
|
+
}
|
|
219
|
+
throw new TypeError("OpenCode prompt content must contain text or authorized image bytes.");
|
|
220
|
+
});
|
|
221
|
+
if (!prompt.trim() && !content.length) {
|
|
222
|
+
throw new TypeError("OpenCode prompt requests require text or attachment content.");
|
|
215
223
|
}
|
|
216
224
|
return {
|
|
217
225
|
...(text(input.agent) ? { agent: text(input.agent) } : {}),
|
|
218
226
|
...(text(input.id) ? { messageID: text(input.id) } : {}),
|
|
219
227
|
...(modelID && providerID ? { model: { modelID, providerID } } : {}),
|
|
220
|
-
parts: [{ text: prompt, type: "text" }, ...(input.attachments || [])
|
|
228
|
+
parts: [...(prompt ? [{ text: prompt, type: "text" }] : []), ...content, ...(input.attachments || [])
|
|
221
229
|
.filter((attachment) => attachment.contentType?.startsWith("image/"))
|
|
222
230
|
.map((attachment) => ({
|
|
223
231
|
type: "file", mime: attachment.contentType, filename: attachment.fileName,
|
|
@@ -266,39 +274,67 @@ function createOpenCodeServerClient({
|
|
|
266
274
|
const authorization = `Basic ${Buffer.from(`${text(username) || "opencode"}:${String(password)}`).toString("base64")}`;
|
|
267
275
|
const scopedDirectory = text(directory);
|
|
268
276
|
|
|
269
|
-
function requestHeaders(accept = "application/json", body = undefined) {
|
|
277
|
+
function requestHeaders(accept = "application/json", body = undefined, directory = scopedDirectory) {
|
|
270
278
|
return {
|
|
271
279
|
accept,
|
|
272
280
|
authorization,
|
|
273
|
-
...(
|
|
281
|
+
...(directory ? { "x-opencode-directory": directory } : {}),
|
|
274
282
|
...(body === undefined ? {} : { "content-type": "application/json" })
|
|
275
283
|
};
|
|
276
284
|
}
|
|
277
285
|
|
|
278
|
-
async function
|
|
286
|
+
async function requestResponse(method = "GET", requestPath = "/", {
|
|
279
287
|
body,
|
|
288
|
+
directory = scopedDirectory,
|
|
280
289
|
limitBytes = OPENCODE_RESPONSE_LIMIT_BYTES,
|
|
281
290
|
signal
|
|
282
291
|
} = {}) {
|
|
283
292
|
const response = await fetchImpl(new URL(requestPath, origin), {
|
|
284
293
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
285
|
-
headers: requestHeaders("application/json", body),
|
|
294
|
+
headers: requestHeaders("application/json", body, directory),
|
|
286
295
|
method,
|
|
287
296
|
signal
|
|
288
297
|
});
|
|
289
|
-
|
|
290
|
-
? ""
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
298
|
+
try {
|
|
299
|
+
const source = response.status === 204 ? "" : await readBoundedResponse(response, limitBytes);
|
|
300
|
+
if (!response.ok) {
|
|
301
|
+
const payload = parsedJson(source);
|
|
302
|
+
throw openCodeServerError(responseErrorMessage(payload), {
|
|
303
|
+
body: payload, method, path: requestPath, status: response.status
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
return { source, headers: response.headers };
|
|
307
|
+
} finally { await response.body?.cancel().catch(() => {}); }
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async function request(method, requestPath, options) {
|
|
311
|
+
return parsedJson((await requestResponse(method, requestPath, options)).source);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async function requestStorageResponse(route, { signal, maxBytes = 4 * 1024 * 1024, body } = {}) {
|
|
315
|
+
// The first storage request can initialize the saved directory after the
|
|
316
|
+
// global health endpoint is ready. Allow the normal startup deadline.
|
|
317
|
+
const deadline = AbortSignal.timeout(30_000);
|
|
318
|
+
const boundedSignal = signal ? AbortSignal.any([signal, deadline]) : deadline;
|
|
319
|
+
boundedSignal.throwIfAborted();
|
|
320
|
+
const { source, headers } = await requestResponse(body === undefined ? "GET" : "PATCH", route, {
|
|
321
|
+
body, signal: boundedSignal, limitBytes: maxBytes, directory: ""
|
|
322
|
+
});
|
|
323
|
+
boundedSignal.throwIfAborted();
|
|
324
|
+
return { data: source ? JSON.parse(source) : null, headers };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async function storageInventory(route, options) {
|
|
328
|
+
const { data: rows } = await requestStorageResponse(route, options);
|
|
329
|
+
if (!Array.isArray(rows) || rows.length > 1000 || rows.some((row) => !/^ses_[a-zA-Z0-9]+$/u.test(row?.id))) {
|
|
330
|
+
throw new Error("OpenCode returned an incomplete or invalid storage inventory.");
|
|
300
331
|
}
|
|
301
|
-
return
|
|
332
|
+
return rows;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function storageConversationPath(conversationId) {
|
|
336
|
+
if (!/^ses_[a-zA-Z0-9]+$/u.test(conversationId)) throw new TypeError("Invalid OpenCode conversation id.");
|
|
337
|
+
return `/session/${encodeURIComponent(conversationId)}`;
|
|
302
338
|
}
|
|
303
339
|
|
|
304
340
|
async function *events(sessionId = "", { onReady = null, signal } = {}) {
|
|
@@ -394,6 +430,58 @@ function createOpenCodeServerClient({
|
|
|
394
430
|
}
|
|
395
431
|
|
|
396
432
|
return Object.freeze({
|
|
433
|
+
async allowConversationAttachments(conversationId, attachments) {
|
|
434
|
+
const route = storageConversationPath(conversationId);
|
|
435
|
+
const { data: session } = await requestStorageResponse(route);
|
|
436
|
+
if (session?.id !== conversationId) throw new Error("OpenCode returned another conversation.");
|
|
437
|
+
const permission = [...(session.permission || [])];
|
|
438
|
+
const previousLength = permission.length;
|
|
439
|
+
for (const attachment of attachments) {
|
|
440
|
+
const pattern = path.join(path.dirname(attachment.path), "*").replaceAll("\\", "/");
|
|
441
|
+
const rule = permission.findLast((item) => item.permission === "external_directory" && item.pattern === pattern);
|
|
442
|
+
if (rule?.action !== "allow") permission.push({ permission: "external_directory", pattern, action: "allow" });
|
|
443
|
+
}
|
|
444
|
+
if (permission.length !== previousLength) {
|
|
445
|
+
await requestStorageResponse(route, { body: { permission } });
|
|
446
|
+
}
|
|
447
|
+
},
|
|
448
|
+
async listConversationChildren(conversationId, { signal } = {}) {
|
|
449
|
+
const children = await storageInventory(`${storageConversationPath(conversationId)}/children`, { signal });
|
|
450
|
+
if (children.some((child) => child.parentID !== conversationId)) {
|
|
451
|
+
throw new Error("OpenCode returned an incomplete or invalid child inventory.");
|
|
452
|
+
}
|
|
453
|
+
return children;
|
|
454
|
+
},
|
|
455
|
+
async listConversationsForDirectory(directory, { signal } = {}) {
|
|
456
|
+
if (!path.isAbsolute(directory || "")) throw new TypeError("OpenCode inventory requires an absolute native directory.");
|
|
457
|
+
// Use the global persisted inventory: the project-scoped /session
|
|
458
|
+
// listing loses its Git project identity once archived source is gone.
|
|
459
|
+
// Request one beyond our limit to detect a truncated inventory.
|
|
460
|
+
const rows = await storageInventory(`/experimental/session?${new URLSearchParams({ directory, limit: "1001" })}`, { signal });
|
|
461
|
+
if (rows.some((row) => typeof row.directory !== "string")) throw new Error("OpenCode inventory has no native directory.");
|
|
462
|
+
return rows.filter((row) => row.directory === directory);
|
|
463
|
+
},
|
|
464
|
+
async readConversationStorage(conversationId, { signal } = {}) {
|
|
465
|
+
// The native storage record does not resolve a live source workspace.
|
|
466
|
+
const { data } = await requestStorageResponse(storageConversationPath(conversationId), { signal });
|
|
467
|
+
if (data?.id !== conversationId || !path.isAbsolute(data.directory || "")) {
|
|
468
|
+
throw new Error("OpenCode returned an invalid native conversation record.");
|
|
469
|
+
}
|
|
470
|
+
return data;
|
|
471
|
+
},
|
|
472
|
+
async readConversationStoragePage(conversationId, { before = "", signal } = {}) {
|
|
473
|
+
const route = `${storageConversationPath(conversationId)}/message`;
|
|
474
|
+
if (typeof before !== "string" || before.length > 8192) throw new TypeError("Invalid OpenCode storage cursor.");
|
|
475
|
+
const query = new URLSearchParams({ limit: "1", ...(before ? { before } : {}) });
|
|
476
|
+
const { data, headers } = await requestStorageResponse(`${route}?${query}`, {
|
|
477
|
+
signal, maxBytes: 64 * 1024 * 1024
|
|
478
|
+
});
|
|
479
|
+
const nextCursor = headers.get("x-next-cursor");
|
|
480
|
+
if (!nextCursor && /rel="next"/u.test(headers.get("link") || "")) {
|
|
481
|
+
throw new Error("OpenCode omitted its native history continuation cursor.");
|
|
482
|
+
}
|
|
483
|
+
return { data, nextCursor };
|
|
484
|
+
},
|
|
397
485
|
async agents({ directory = "", signal } = {}) {
|
|
398
486
|
return request("GET", `/agent${queryString({ directory })}`, {
|
|
399
487
|
limitBytes: OPENCODE_CATALOG_LIMIT_BYTES,
|
|
@@ -438,6 +526,21 @@ function createOpenCodeServerClient({
|
|
|
438
526
|
async health({ signal } = {}) {
|
|
439
527
|
return request("GET", "/global/health", { signal });
|
|
440
528
|
},
|
|
529
|
+
async prepareDirectory({ signal } = {}) {
|
|
530
|
+
// Global health and the v2 session routes do not initialize the directory
|
|
531
|
+
// used by prompt/abort. Complete that native startup before exposing a
|
|
532
|
+
// conversation whose first Stop would otherwise wait for plugin installs.
|
|
533
|
+
const deadline = AbortSignal.timeout(30_000);
|
|
534
|
+
const boundedSignal = signal ? AbortSignal.any([signal, deadline]) : deadline;
|
|
535
|
+
boundedSignal.throwIfAborted();
|
|
536
|
+
const result = await request("GET", "/path", { signal: boundedSignal });
|
|
537
|
+
boundedSignal.throwIfAborted();
|
|
538
|
+
if (!text(result?.directory)) {
|
|
539
|
+
throw openCodeServerError("OpenCode did not confirm its conversation directory is ready.", {
|
|
540
|
+
code: "assistant_opencode_directory_unavailable", method: "GET", path: "/path"
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
},
|
|
441
544
|
async interrupt(sessionId = "", { signal } = {}) {
|
|
442
545
|
return request("POST", stableSessionPath(sessionId, "/abort"), { signal });
|
|
443
546
|
},
|
|
@@ -520,7 +623,94 @@ function openCodeAssistantMessageText(message = {}) {
|
|
|
520
623
|
return [...new Set(values)].join("\n\n");
|
|
521
624
|
}
|
|
522
625
|
|
|
626
|
+
// Native preservation uses the storage API independently of the saved worktree.
|
|
627
|
+
// Status still comes from the exact directory-scoped control client.
|
|
628
|
+
async function inspectOpenCodeConversationFamily(storageClient, controlClient, binding, { signal } = {}) {
|
|
629
|
+
const queue = [binding.conversationId];
|
|
630
|
+
const seen = new Set();
|
|
631
|
+
const records = [];
|
|
632
|
+
for (let index = 0; index < queue.length; index += 1) {
|
|
633
|
+
signal?.throwIfAborted();
|
|
634
|
+
const conversationId = queue[index];
|
|
635
|
+
if (seen.has(conversationId) || queue.length > 1000) throw new Error("OpenCode native family is cyclic or exceeds its inventory limit.");
|
|
636
|
+
seen.add(conversationId);
|
|
637
|
+
let native;
|
|
638
|
+
try { native = await storageClient.readConversationStorage(conversationId, { signal }); }
|
|
639
|
+
catch (error) { if (error.statusCode === 404 && index === 0) return []; throw error; }
|
|
640
|
+
if (native?.id !== conversationId || native?.directory !== binding.workdir ||
|
|
641
|
+
(await controlClient.sessionStatus(conversationId, { signal })).type !== "idle") {
|
|
642
|
+
throw new Error("OpenCode retirement requires an idle native family in the exact saved directory.");
|
|
643
|
+
}
|
|
644
|
+
records.push({ conversationId, workdir: native.directory,
|
|
645
|
+
...(Number.isFinite(native.time?.created) ? { createdAt: new Date(native.time.created).toISOString() } : {}),
|
|
646
|
+
...(Number.isFinite(native.time?.updated) ? { updatedAt: new Date(native.time.updated).toISOString() } : {}) });
|
|
647
|
+
const children = await storageClient.listConversationChildren(conversationId, { signal });
|
|
648
|
+
for (const child of children) {
|
|
649
|
+
if (child.directory !== binding.workdir) throw new Error("OpenCode child directory differs from its saved owner.");
|
|
650
|
+
queue.push(child.id);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
return records.sort((left, right) => left.conversationId.localeCompare(right.conversationId));
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
async function exportOpenCodeNativeHistory(storageClient, controlClient, id, onRecord, { signal: inputSignal } = {}) {
|
|
657
|
+
const deadline = AbortSignal.timeout(300_000);
|
|
658
|
+
const signal = inputSignal ? AbortSignal.any([inputSignal, deadline]) : deadline;
|
|
659
|
+
const output = createNativeHistoryExport(onRecord, { signal });
|
|
660
|
+
const info = await storageClient.readConversationStorage(id, { signal });
|
|
661
|
+
if (info?.id !== id || (await controlClient.sessionStatus(id, { signal })).type !== "idle") {
|
|
662
|
+
throw new Error("OpenCode native export requires the exact idle conversation.");
|
|
663
|
+
}
|
|
664
|
+
await output.emit({ type: "thread", thread: info, text: [] });
|
|
665
|
+
const messageIds = new Set();
|
|
666
|
+
const cursors = new Set();
|
|
667
|
+
let before = "";
|
|
668
|
+
let pages = 0;
|
|
669
|
+
while (true) {
|
|
670
|
+
signal.throwIfAborted();
|
|
671
|
+
if (++pages > 20_000) throw new Error("OpenCode native export exceeded its page limit; history was not retired.");
|
|
672
|
+
const response = await storageClient.readConversationStoragePage(id, { before, signal });
|
|
673
|
+
if (!Array.isArray(response?.data) || response.data.length > 1 ||
|
|
674
|
+
(response.nextCursor !== null && (typeof response.nextCursor !== "string" || !response.nextCursor || response.nextCursor.length > 8192)) ||
|
|
675
|
+
(response.data.length === 0 && response.nextCursor !== null)) {
|
|
676
|
+
throw new Error("OpenCode returned an incomplete or invalid native message page.");
|
|
677
|
+
}
|
|
678
|
+
for (const message of response.data) {
|
|
679
|
+
const info = message?.info;
|
|
680
|
+
if (!/^msg_[a-zA-Z0-9_]{1,256}$/u.test(info?.id) || info.sessionID !== id ||
|
|
681
|
+
!["user", "assistant"].includes(info.role) || !Array.isArray(message.parts) || messageIds.has(info.id)) {
|
|
682
|
+
throw new Error("OpenCode returned an invalid or duplicate native message.");
|
|
683
|
+
}
|
|
684
|
+
messageIds.add(info.id);
|
|
685
|
+
const content = openCodeAssistantMessageText({ content: message.parts });
|
|
686
|
+
await output.emit({ type: "message", message, text: content ? [{ role: info.role, text: content,
|
|
687
|
+
branchId: id, messageId: info.id,
|
|
688
|
+
...(info.parentID ? { parentMessageId: info.parentID } : {}),
|
|
689
|
+
...(Number.isFinite(info.time?.created) ? { createdAt: new Date(info.time.created).toISOString() } : {}),
|
|
690
|
+
...(Number.isFinite(info.time?.completed) ? { completedAt: new Date(info.time.completed).toISOString() } : {}),
|
|
691
|
+
...(info.modelID || info.model?.modelID ? { modelId: info.modelID || info.model.modelID } : {}),
|
|
692
|
+
...(info.providerID || info.model?.providerID ? { modelProviderId: info.providerID || info.model.providerID } : {}),
|
|
693
|
+
...(info.agent ? { agent: info.agent } : {}) }] : [] });
|
|
694
|
+
}
|
|
695
|
+
if (response.nextCursor === null) break;
|
|
696
|
+
if (cursors.has(response.nextCursor)) throw new Error("OpenCode repeated a native history cursor.");
|
|
697
|
+
cursors.add(response.nextCursor);
|
|
698
|
+
before = response.nextCursor;
|
|
699
|
+
}
|
|
700
|
+
return output.complete();
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/** Retire the exact native family after the host's durable preservation. */
|
|
704
|
+
async function retireOpenCodeConversationHistory(storageClient, controlClient, binding, options = {}) {
|
|
705
|
+
const inspect = () => inspectOpenCodeConversationFamily(storageClient, controlClient, binding, { signal: options.signal });
|
|
706
|
+
return retireNativeConversation({ binding, inspect, beforeDelete: options.beforeDelete,
|
|
707
|
+
readConversation: async (id) => ({ info: await storageClient.readConversationStorage(id), messages: (await controlClient.messages(id)).data }),
|
|
708
|
+
exportConversation: (id, onRecord) => exportOpenCodeNativeHistory(storageClient, controlClient, id, onRecord, { signal: options.signal }),
|
|
709
|
+
remove: () => controlClient.deleteSession(binding.conversationId) });
|
|
710
|
+
}
|
|
711
|
+
|
|
523
712
|
export {
|
|
713
|
+
retireOpenCodeConversationHistory,
|
|
524
714
|
openCodeAssistantMessageText,
|
|
525
715
|
OPENCODE_RESPONSE_LIMIT_BYTES,
|
|
526
716
|
createOpenCodeServerClient,
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { createOpenCodeConversationAdapter } from "./providers/opencode.js";
|
|
2
|
+
import { wrapNativeCommand } from "./commandWrapper.js";
|
|
3
|
+
import { readOpenCodeEnvironments, openCodeEnvironmentForSession, openCodeEnvironmentForDirectory,
|
|
4
|
+
assertOpenCodeModelProvider, limitOpenCodeModelOutput } from "./openCodeRuntime.js";
|
|
5
|
+
|
|
6
|
+
// Runs in OpenCode. The host writes this private binding before every dispatch;
|
|
7
|
+
// the native system hook also rereads it after automatic compaction.
|
|
8
|
+
export default async function conversationInstructions({ client, registryPath = process.env.JSKIT_OPENCODE_ENV_REGISTRY,
|
|
9
|
+
resolveHostInstructions } = {}) {
|
|
10
|
+
const schemas = JSON.parse(process.env.JSKIT_OPENCODE_TOOL_SCHEMAS || "[]");
|
|
11
|
+
const definitions = new Map(schemas.map(({ function: definition }) => [definition.name, definition]));
|
|
12
|
+
const environments = () => readOpenCodeEnvironments(registryPath);
|
|
13
|
+
async function readBinding(id) {
|
|
14
|
+
const binding = await openCodeEnvironmentForSession(await environments(), id, client);
|
|
15
|
+
if (!binding) throw new Error("This native conversation has no instruction owner.");
|
|
16
|
+
return binding;
|
|
17
|
+
}
|
|
18
|
+
const adapter = createOpenCodeConversationAdapter({
|
|
19
|
+
async resolveInstructions(id) {
|
|
20
|
+
const { conversation } = await readBinding(id);
|
|
21
|
+
if (!conversation) return null;
|
|
22
|
+
return { identity: conversation.systemPrompt, placement: "replace", read: () => conversation.systemPrompt };
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
const hostInstructions = resolveHostInstructions ? createOpenCodeConversationAdapter({
|
|
26
|
+
resolveInstructions: async id => (await resolveHostInstructions(id))?.instructions
|
|
27
|
+
}) : null;
|
|
28
|
+
return {
|
|
29
|
+
"experimental.chat.system.transform": hostInstructions ? async (input, output) => {
|
|
30
|
+
const selected = await resolveHostInstructions(input.sessionID || input.sessionId);
|
|
31
|
+
return selected?.conversation
|
|
32
|
+
? adapter.transformSystem(input, output)
|
|
33
|
+
: hostInstructions.transformSystem(input, output);
|
|
34
|
+
} : adapter.transformSystem,
|
|
35
|
+
event: hostInstructions ? event => { hostInstructions.event(event); adapter.event(event); } : adapter.event,
|
|
36
|
+
"chat.params": async (input, output) => {
|
|
37
|
+
const binding = await readBinding(input.sessionID);
|
|
38
|
+
assertOpenCodeModelProvider(binding, input);
|
|
39
|
+
limitOpenCodeModelOutput(input, output);
|
|
40
|
+
},
|
|
41
|
+
"tool.execute.before": async ({ tool, sessionID }, output) => {
|
|
42
|
+
const { conversation } = await readBinding(sessionID);
|
|
43
|
+
const allowed = definitions.has(tool)
|
|
44
|
+
? conversation?.tools?.schemas.some(({ function: definition }) => definition.name === tool)
|
|
45
|
+
: conversation?.nativeTools;
|
|
46
|
+
if (!allowed) {
|
|
47
|
+
throw new Error("This conversation has no authorization for that native tool.");
|
|
48
|
+
}
|
|
49
|
+
if (tool === "bash" && conversation.commandWrapper) {
|
|
50
|
+
output.args.command = wrapNativeCommand(conversation.commandWrapper, output.args.command);
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
"shell.env": async ({ sessionID, cwd }, output) => {
|
|
54
|
+
const binding = sessionID ? await readBinding(sessionID) : openCodeEnvironmentForDirectory(await environments(), cwd);
|
|
55
|
+
if (binding?.conversation?.nativeTools) {
|
|
56
|
+
Object.assign(output.env, binding.env);
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
// OpenCode 1.18.31 accepts JSON Schema properties from plugins. Preserve the
|
|
60
|
+
// original required/optional fields through its native definition hook.
|
|
61
|
+
"tool.definition": ({ toolID }, output) => {
|
|
62
|
+
const definition = definitions.get(toolID);
|
|
63
|
+
if (definition) output.jsonSchema = definition.parameters;
|
|
64
|
+
},
|
|
65
|
+
tool: Object.fromEntries([...definitions].map(([name, definition]) => [name, {
|
|
66
|
+
description: definition.description, args: definition.parameters.properties,
|
|
67
|
+
async execute(input, context) {
|
|
68
|
+
if (!context.callID || !context.sessionID || !context.messageID) {
|
|
69
|
+
throw new Error("OpenCode did not supply an application tool identity.");
|
|
70
|
+
}
|
|
71
|
+
const connection = (await readBinding(context.sessionID)).conversation?.tools;
|
|
72
|
+
if (!connection?.schemas.some(({ function: definition }) => definition.name === name)) {
|
|
73
|
+
throw new Error("This conversation has no authorization for that native tool.");
|
|
74
|
+
}
|
|
75
|
+
const response = await fetch(connection.url, { method: "POST", signal: context.abort,
|
|
76
|
+
headers: { authorization: `Bearer ${connection.token}`, "content-type": "application/json" },
|
|
77
|
+
body: JSON.stringify({ sessionId: context.sessionID, messageId: context.messageID, id: context.callID, name, input }) });
|
|
78
|
+
const result = await response.json();
|
|
79
|
+
if (!response.ok) throw new Error(result.error || "The application tool request failed.");
|
|
80
|
+
return JSON.stringify(result);
|
|
81
|
+
}
|
|
82
|
+
}]))
|
|
83
|
+
};
|
|
84
|
+
}
|