Makelore 2.0 initial clean snapshot
This commit is contained in:
341
electron/opencode/client.ts
Normal file
341
electron/opencode/client.ts
Normal file
@@ -0,0 +1,341 @@
|
||||
import { realpathSync } from 'node:fs';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
export interface DecorateRequestInput {
|
||||
baseUrl: string;
|
||||
path: string;
|
||||
directory: string;
|
||||
}
|
||||
|
||||
export interface DecoratedRequest {
|
||||
url: string;
|
||||
headers: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface OpencodeClientOptions {
|
||||
baseUrl: string;
|
||||
directory: string;
|
||||
fetchImpl?: typeof fetch;
|
||||
}
|
||||
|
||||
export interface OpencodeModelReference {
|
||||
providerID: string;
|
||||
modelID: string;
|
||||
}
|
||||
|
||||
export interface OpencodeFilePartInput {
|
||||
type: 'file';
|
||||
mime: string;
|
||||
filename?: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface OpencodeCommandInfo {
|
||||
name: string;
|
||||
description?: string;
|
||||
agent?: string;
|
||||
model?: string;
|
||||
source?: 'command' | 'mcp' | 'skill';
|
||||
subtask?: boolean;
|
||||
hints: string[];
|
||||
template?: unknown;
|
||||
}
|
||||
|
||||
export interface ForkOpencodeSessionInput {
|
||||
messageID?: string;
|
||||
}
|
||||
|
||||
export interface SummarizeOpencodeSessionInput {
|
||||
providerID: string;
|
||||
modelID: string;
|
||||
}
|
||||
|
||||
export interface OpencodeCommandTextPartInput {
|
||||
type: 'text';
|
||||
text: string;
|
||||
synthetic: true;
|
||||
}
|
||||
|
||||
export type OpencodeCommandPartInput =
|
||||
| OpencodeCommandTextPartInput
|
||||
| OpencodeFilePartInput;
|
||||
|
||||
export interface ExecuteOpencodeSessionCommandInput {
|
||||
command: string;
|
||||
arguments: string;
|
||||
agent?: string;
|
||||
model?: string;
|
||||
variant?: string;
|
||||
parts?: OpencodeCommandPartInput[];
|
||||
}
|
||||
|
||||
export interface SendOpencodeSessionMessageInput {
|
||||
text: string;
|
||||
system?: string;
|
||||
agent?: string;
|
||||
files?: OpencodeFilePartInput[];
|
||||
model?: OpencodeModelReference;
|
||||
}
|
||||
|
||||
export interface FindOpencodeFilesOptions {
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface UpdateOpencodeSessionInput {
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface OpencodeSkillInfo {
|
||||
name: string;
|
||||
description?: string;
|
||||
location: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface RevertOpencodeSessionMessageInput {
|
||||
messageID: string;
|
||||
partID?: string;
|
||||
}
|
||||
|
||||
export type OpencodePermissionReply = 'once' | 'always' | 'reject';
|
||||
|
||||
type OpencodeTextPartInput = { type: 'text'; text: string };
|
||||
type OpencodeMessagePartInput = OpencodeTextPartInput | OpencodeFilePartInput;
|
||||
type OpencodeTextPartsPayload = {
|
||||
parts: OpencodeMessagePartInput[];
|
||||
model?: OpencodeModelReference;
|
||||
system?: string;
|
||||
agent?: string;
|
||||
};
|
||||
|
||||
function buildTextPartsPayload(input: SendOpencodeSessionMessageInput) {
|
||||
const payload: OpencodeTextPartsPayload = {
|
||||
parts: [
|
||||
{
|
||||
type: 'text',
|
||||
text: input.text,
|
||||
},
|
||||
...(input.files ?? []),
|
||||
],
|
||||
};
|
||||
if (input.model) {
|
||||
payload.model = input.model;
|
||||
}
|
||||
if (input.system) {
|
||||
payload.system = input.system;
|
||||
}
|
||||
if (input.agent) {
|
||||
payload.agent = input.agent;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function summarizePromptPayload(sessionID: string, payload: OpencodeTextPartsPayload) {
|
||||
const fileParts = payload.parts.filter((part): part is OpencodeFilePartInput => part.type === 'file');
|
||||
const textParts = payload.parts.filter((part): part is OpencodeTextPartInput => part.type === 'text');
|
||||
return {
|
||||
sessionID,
|
||||
model: payload.model ? `${payload.model.providerID}/${payload.model.modelID}` : null,
|
||||
textLength: textParts.reduce((sum, part) => sum + part.text.length, 0),
|
||||
partCount: payload.parts.length,
|
||||
filePartCount: fileParts.length,
|
||||
fileMimes: fileParts.map((part) => part.mime),
|
||||
};
|
||||
}
|
||||
|
||||
type ResolveRealPath = (directory: string) => string;
|
||||
|
||||
export function canonicalizeOpencodeDirectory(
|
||||
directory: string,
|
||||
resolveRealPath: ResolveRealPath = realpathSync.native,
|
||||
): string {
|
||||
try {
|
||||
return resolveRealPath(directory);
|
||||
} catch {
|
||||
return directory;
|
||||
}
|
||||
}
|
||||
|
||||
export function decorateOpencodeRequest(
|
||||
input: DecorateRequestInput,
|
||||
canonicalizeDirectory: (directory: string) => string = canonicalizeOpencodeDirectory,
|
||||
): DecoratedRequest {
|
||||
const url = new URL(input.path, input.baseUrl);
|
||||
const directory = canonicalizeDirectory(input.directory);
|
||||
const encodedDirectory = encodeURIComponent(directory);
|
||||
if (!url.searchParams.has('directory')) {
|
||||
url.searchParams.set('directory', directory);
|
||||
}
|
||||
|
||||
return {
|
||||
url: url.toString(),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-opencode-directory': encodedDirectory,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function parseJsonResponse<T>(response: Response): Promise<T> {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Runtime request failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
if (response.status === 204) return undefined as T;
|
||||
return await response.json() as T;
|
||||
}
|
||||
|
||||
export function createOpencodeClient(options: OpencodeClientOptions) {
|
||||
const fetchImpl = options.fetchImpl ?? fetch;
|
||||
const request = async <T>(path: string, init?: RequestInit): Promise<T> => {
|
||||
const decorated = decorateOpencodeRequest({
|
||||
baseUrl: options.baseUrl,
|
||||
path,
|
||||
directory: options.directory,
|
||||
});
|
||||
const requestUrl = new URL(decorated.url);
|
||||
logger.info('[opencode-client] Forwarding request to runtime', {
|
||||
method: init?.method ?? 'GET',
|
||||
pathname: requestUrl.pathname,
|
||||
});
|
||||
|
||||
return await parseJsonResponse<T>(await fetchImpl(decorated.url, {
|
||||
...init,
|
||||
headers: {
|
||||
...decorated.headers,
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const findFilesPath = (query: string, options?: FindOpencodeFilesOptions) => {
|
||||
const params = new URLSearchParams({ query });
|
||||
if (typeof options?.limit === 'number') {
|
||||
params.set('limit', String(options.limit));
|
||||
}
|
||||
return `/find/file?${params.toString()}`;
|
||||
};
|
||||
|
||||
const fileContentPath = (filePath: string) => {
|
||||
const params = new URLSearchParams({ path: filePath });
|
||||
return `/file/content?${params.toString()}`;
|
||||
};
|
||||
|
||||
const searchTextPath = (pattern: string) => {
|
||||
const params = new URLSearchParams({ pattern });
|
||||
return `/find?${params.toString()}`;
|
||||
};
|
||||
|
||||
const opencodeCommandMethods = {
|
||||
listCommands: (): Promise<OpencodeCommandInfo[]> =>
|
||||
request<OpencodeCommandInfo[]>('/command'),
|
||||
forkSession: (
|
||||
sessionID: string,
|
||||
payload: ForkOpencodeSessionInput = {},
|
||||
): Promise<unknown> =>
|
||||
request<unknown>(`/session/${encodeURIComponent(sessionID)}/fork`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
shareSession: (sessionID: string): Promise<unknown> =>
|
||||
request<unknown>(`/session/${encodeURIComponent(sessionID)}/share`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
unshareSession: (sessionID: string): Promise<unknown> =>
|
||||
request<unknown>(`/session/${encodeURIComponent(sessionID)}/share`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
summarizeSession: (
|
||||
sessionID: string,
|
||||
payload: SummarizeOpencodeSessionInput,
|
||||
): Promise<boolean> =>
|
||||
request<boolean>(`/session/${encodeURIComponent(sessionID)}/summarize`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
executeSessionCommand: (
|
||||
sessionID: string,
|
||||
payload: ExecuteOpencodeSessionCommandInput,
|
||||
): Promise<unknown> =>
|
||||
request<unknown>(`/session/${encodeURIComponent(sessionID)}/command`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
};
|
||||
|
||||
return {
|
||||
...opencodeCommandMethods,
|
||||
listSkills: () => request<OpencodeSkillInfo[]>('/skill'),
|
||||
listSessions: () => request<unknown[]>('/session'),
|
||||
createSession: (payload: Record<string, unknown>) => request<unknown>('/session', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
updateSession: (sessionID: string, payload: UpdateOpencodeSessionInput) =>
|
||||
request<unknown>(`/session/${encodeURIComponent(sessionID)}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
deleteSession: (sessionID: string) =>
|
||||
request<void>(`/session/${encodeURIComponent(sessionID)}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
abortSession: (sessionID: string) =>
|
||||
request<void>(`/session/${encodeURIComponent(sessionID)}/abort`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
getSessionDiff: (sessionID: string) =>
|
||||
request<unknown[]>(`/session/${encodeURIComponent(sessionID)}/diff`),
|
||||
revertSessionMessage: (sessionID: string, payload: RevertOpencodeSessionMessageInput) =>
|
||||
request<unknown>(`/session/${encodeURIComponent(sessionID)}/revert`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
unrevertSession: (sessionID: string) =>
|
||||
request<unknown>(`/session/${encodeURIComponent(sessionID)}/unrevert`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
getSessionTodos: (sessionID: string) =>
|
||||
request<unknown[]>(`/session/${encodeURIComponent(sessionID)}/todo`),
|
||||
getSessionStatuses: () => request<Record<string, unknown>>('/session/status'),
|
||||
listQuestions: () => request<unknown[]>('/question'),
|
||||
replyQuestion: (requestID: string, answers: string[][]) =>
|
||||
request<void>(`/question/${encodeURIComponent(requestID)}/reply`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ answers }),
|
||||
}),
|
||||
rejectQuestion: (requestID: string) =>
|
||||
request<void>(`/question/${encodeURIComponent(requestID)}/reject`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
getConfig: () => request<Record<string, unknown>>('/config'),
|
||||
listPermissions: () => request<unknown[]>('/permission'),
|
||||
replyPermission: (requestID: string, reply: OpencodePermissionReply, message?: string) =>
|
||||
request<void>(`/permission/${encodeURIComponent(requestID)}/reply`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
reply,
|
||||
...(message ? { message } : {}),
|
||||
}),
|
||||
}),
|
||||
getSessionMessages: (sessionID: string) =>
|
||||
request<unknown[]>(`/session/${encodeURIComponent(sessionID)}/message`),
|
||||
sendSessionMessage: (sessionID: string, payload: SendOpencodeSessionMessageInput) =>
|
||||
request<unknown>(`/session/${encodeURIComponent(sessionID)}/message`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(buildTextPartsPayload(payload)),
|
||||
}),
|
||||
promptSessionAsync: (sessionID: string, payload: SendOpencodeSessionMessageInput) => {
|
||||
const requestPayload = buildTextPartsPayload(payload);
|
||||
logger.info('[opencode-client] Sending prompt_async payload', summarizePromptPayload(sessionID, requestPayload));
|
||||
return request<void>(`/session/${encodeURIComponent(sessionID)}/prompt_async`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestPayload),
|
||||
});
|
||||
},
|
||||
getFileStatuses: () => request<unknown[]>('/file/status'),
|
||||
findFiles: (query: string, options?: FindOpencodeFilesOptions) =>
|
||||
request<unknown[]>(findFilesPath(query, options)),
|
||||
searchText: (pattern: string) => request<unknown[]>(searchTextPath(pattern)),
|
||||
getFileContent: (filePath: string) => request<unknown>(fileContentPath(filePath)),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user