summaryrefslogtreecommitdiffhomepage
path: root/js/src
diff options
context:
space:
mode:
authorDax Raad <[email protected]>2025-05-18 02:43:01 -0400
committerDax Raad <[email protected]>2025-05-26 12:40:17 -0400
commitd0d67029f4baad7389b5ba072379c2ff44a22dc4 (patch)
treecb81e86662c14c20687bf5bac488dda911a9855e /js/src
parenta34d020bc6b252e842f042d935c7a0e6444460cf (diff)
downloadopencode-d0d67029f4baad7389b5ba072379c2ff44a22dc4.tar.gz
opencode-d0d67029f4baad7389b5ba072379c2ff44a22dc4.zip
process
Diffstat (limited to 'js/src')
-rw-r--r--js/src/app/config.ts52
-rw-r--r--js/src/app/index.ts34
-rw-r--r--js/src/config/config.ts42
-rw-r--r--js/src/id/id.ts1
-rw-r--r--js/src/index.ts18
-rw-r--r--js/src/llm/llm.ts59
-rw-r--r--js/src/session/session.ts176
-rw-r--r--js/src/storage/storage.ts26
8 files changed, 340 insertions, 68 deletions
diff --git a/js/src/app/config.ts b/js/src/app/config.ts
new file mode 100644
index 000000000..84960db61
--- /dev/null
+++ b/js/src/app/config.ts
@@ -0,0 +1,52 @@
+import path from "node:path";
+import { Log } from "../util/log";
+import { z } from "zod/v4";
+
+export namespace Config {
+ const log = Log.create({ service: "config" });
+
+ export const Info = z
+ .object({
+ providers: z
+ .object({
+ anthropic: z
+ .object({
+ apiKey: z.string().optional(),
+ headers: z.record(z.string(), z.string()).optional(),
+ baseURL: z.string().optional(),
+ })
+ .strict()
+ .optional(),
+ })
+ .strict()
+ .optional(),
+ })
+ .strict();
+
+ export type Info = z.output<typeof Info>;
+
+ export async function load(directory: string) {
+ let result: Info = {};
+ for (const file of ["opencode.jsonc", "opencode.json"]) {
+ const resolved = path.join(directory, file);
+ log.info("searching", { path: resolved });
+ try {
+ result = await import(path.join(directory, file)).then((mod) =>
+ Info.parse(mod.default),
+ );
+ log.info("found", { path: resolved });
+ break;
+ } catch (e) {
+ if (e instanceof z.ZodError) {
+ for (const issue of e.issues) {
+ log.info(issue.message);
+ }
+ throw e;
+ }
+ continue;
+ }
+ }
+ log.info("loaded", result);
+ return result;
+ }
+}
diff --git a/js/src/app/index.ts b/js/src/app/index.ts
index 1c063ebd3..7f5a44f30 100644
--- a/js/src/app/index.ts
+++ b/js/src/app/index.ts
@@ -2,6 +2,7 @@ import fs from "fs/promises";
import { AppPath } from "./path";
import { Log } from "../util/log";
import { Context } from "../util/context";
+import { Config } from "./config";
export namespace App {
const log = Log.create({ service: "app" });
@@ -13,29 +14,40 @@ export namespace App {
export async function create(input: { directory: string }) {
log.info("creating");
+ const config = await Config.load(input.directory);
+
const dataDir = AppPath.data(input.directory);
await fs.mkdir(dataDir, { recursive: true });
log.info("created", { path: dataDir });
const services = new Map<any, any>();
- return {
+ const result = {
+ get services() {
+ return services;
+ },
+ get config() {
+ return config;
+ },
get root() {
return input.directory;
},
- service<T extends () => any>(service: any, init: T) {
- if (!services.has(service)) {
- log.info("registering service", { name: service });
- services.set(service, init());
- }
- return services.get(service) as ReturnType<T>;
- },
+ service<T extends (app: any) => any>(service: any, init: T) {},
};
+
+ return result;
}
- export function service<T extends () => any>(key: any, init: T) {
- const app = ctx.use();
- return app.service(key, init);
+ export function state<T extends (app: Info) => any>(key: any, init: T) {
+ return () => {
+ const app = ctx.use();
+ const services = app.services;
+ if (!services.has(key)) {
+ log.info("registering service", { name: key });
+ services.set(key, init(app));
+ }
+ return services.get(key) as ReturnType<T>;
+ };
}
export async function use() {
diff --git a/js/src/config/config.ts b/js/src/config/config.ts
deleted file mode 100644
index d76b1a842..000000000
--- a/js/src/config/config.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-import path from "node:path";
-import { Log } from "../util/log";
-import { App } from "../app";
-
-export namespace Config {
- const log = Log.create({ service: "config" });
-
- // TODO: this should be zod
- export interface Info {
- mcp: any; // TODO
- lsp: any; // TODO
- }
-
- function state() {
- return App.service("config", async () => {
- const app = await App.use();
- let result: Info = {
- mcp: {},
- lsp: {},
- };
- for (const file of ["opencode.jsonc", "opencode.json"]) {
- const resolved = path.join(app.root, file);
- log.info("searching", { path: resolved });
- try {
- result = await import(path.join(app.root, file)).then(
- (mod) => mod.default,
- );
- log.info("found", { path: resolved });
- break;
- } catch (e) {
- continue;
- }
- }
- log.info("loaded", result);
- return result;
- });
- }
-
- function get() {
- return state();
- }
-}
diff --git a/js/src/id/id.ts b/js/src/id/id.ts
index a63716041..5ad2fb91d 100644
--- a/js/src/id/id.ts
+++ b/js/src/id/id.ts
@@ -4,6 +4,7 @@ import { z } from "zod";
export namespace Identifier {
const prefixes = {
session: "ses",
+ message: "msg",
} as const;
export function create(
diff --git a/js/src/index.ts b/js/src/index.ts
index 9ccbfda6c..959d1182a 100644
--- a/js/src/index.ts
+++ b/js/src/index.ts
@@ -2,12 +2,28 @@ import { App } from "./app";
import process from "node:process";
import { RPC } from "./server/server";
import { Session } from "./session/session";
+import { Identifier } from "./id/id";
const app = await App.create({
directory: process.cwd(),
});
App.provide(app, async () => {
- const session = await Session.create();
+ const sessionID = await Session.list()
+ [Symbol.asyncIterator]()
+ .next()
+ .then((v) => v.value ?? Session.create().then((s) => s.id));
+
+ await Session.chat(sessionID, {
+ role: "user",
+ id: Identifier.create("message"),
+ parts: [
+ {
+ type: "text",
+ text: "Hey how are you? try to use tools",
+ },
+ ],
+ });
+
const rpc = RPC.listen();
});
diff --git a/js/src/llm/llm.ts b/js/src/llm/llm.ts
new file mode 100644
index 000000000..0ae3bbd87
--- /dev/null
+++ b/js/src/llm/llm.ts
@@ -0,0 +1,59 @@
+import { App } from "../app";
+import { Log } from "../util/log";
+
+import { createAnthropic } from "@ai-sdk/anthropic";
+import type { LanguageModel, Provider } from "ai";
+import { generateText, NoSuchModelError } from "ai";
+
+export namespace LLM {
+ const log = Log.create({ service: "llm" });
+
+ export class ModelNotFoundError extends Error {
+ constructor(public readonly model: string) {
+ super();
+ }
+ }
+
+ const state = App.state("llm", async (app) => {
+ const providers: Provider[] = [];
+
+ if (process.env["ANTHROPIC_API_KEY"] || app.config.providers?.anthropic) {
+ log.info("loaded anthropic");
+ const provider = createAnthropic({
+ apiKey: app.config.providers?.anthropic?.apiKey,
+ baseURL: app.config.providers?.anthropic?.baseURL,
+ headers: app.config.providers?.anthropic?.headers,
+ });
+ providers.push(provider);
+ }
+
+ return {
+ models: new Map<string, LanguageModel>(),
+ providers,
+ };
+ });
+
+ export async function providers() {
+ return state().then((state) => state.providers);
+ }
+
+ export async function findModel(model: string) {
+ const s = await state();
+ if (s.models.has(model)) {
+ return s.models.get(model)!;
+ }
+ log.info("loading", { model });
+ for (const provider of s.providers) {
+ try {
+ const match = provider.languageModel(model);
+ log.info("found", { model });
+ s.models.set(model, match);
+ return match;
+ } catch (e) {
+ if (e instanceof NoSuchModelError) continue;
+ throw e;
+ }
+ }
+ throw new ModelNotFoundError(model);
+ }
+}
diff --git a/js/src/session/session.ts b/js/src/session/session.ts
index 0925584de..4ad40f32a 100644
--- a/js/src/session/session.ts
+++ b/js/src/session/session.ts
@@ -1,6 +1,18 @@
+import path from "path";
+import { z } from "zod";
+import { App } from "../app/";
import { Identifier } from "../id/id";
+import { LLM } from "../llm/llm";
import { Storage } from "../storage/storage";
import { Log } from "../util/log";
+import {
+ convertToModelMessages,
+ streamText,
+ tool,
+ type TextUIPart,
+ type ToolInvocationUIPart,
+ type UIMessage,
+} from "ai";
export namespace Session {
const log = Log.create({ service: "session" });
@@ -10,13 +22,175 @@ export namespace Session {
title: string;
}
+ const state = App.state("session", () => {
+ const sessions = new Map<string, Info>();
+ const messages = new Map<string, UIMessage[]>();
+
+ return {
+ sessions,
+ messages,
+ };
+ });
+
export async function create() {
const result: Info = {
id: Identifier.create("session"),
title: "New Session - " + new Date().toISOString(),
};
log.info("created", result);
- await Storage.write("session/info/" + result.id, JSON.stringify(result));
+ await Storage.write(
+ "session/info/" + result.id + ".json",
+ JSON.stringify(result),
+ );
+ state().sessions.set(result.id, result);
return result;
}
+
+ export async function get(id: string) {
+ const result = state().sessions.get(id);
+ if (result) {
+ return result;
+ }
+ const read = JSON.parse(await Storage.readToString("session/info/" + id));
+ state().sessions.set(id, read);
+ return read;
+ }
+
+ export async function messages(sessionID: string) {
+ const result = state().messages.get(sessionID);
+ if (result) {
+ return result;
+ }
+ const read = JSON.parse(
+ await Storage.readToString(
+ "session/message/" + sessionID + ".json",
+ ).catch(() => "[]"),
+ );
+ state().messages.set(sessionID, read);
+ return read;
+ }
+
+ export async function* list() {
+ try {
+ const result = await Storage.list("session/info");
+ for await (const item of result) {
+ yield path.basename(item.path, ".json");
+ }
+ } catch {
+ return;
+ }
+ }
+
+ export async function chat(sessionID: string, msg: UIMessage) {
+ const l = log.clone().tag("session", sessionID);
+ l.info("chatting");
+ const msgs = (await messages(sessionID)) ?? [
+ {
+ id: Identifier.create("message"),
+ role: "system",
+ parts: [
+ {
+ type: "text",
+ text: "You are a helpful assistant called opencode",
+ },
+ ],
+ } as UIMessage,
+ ];
+ msgs.push(msg);
+ state().messages.set(sessionID, msgs);
+ async function write() {
+ return Storage.write(
+ "session/message/" + sessionID + ".json",
+ JSON.stringify(msgs),
+ );
+ }
+ await write();
+
+ const model = await LLM.findModel("claude-3-7-sonnet-20250219");
+ const result = streamText({
+ messages: convertToModelMessages(msgs),
+ temperature: 0,
+ tools: {
+ test: tool({
+ id: "opencode.test" as const,
+ parameters: z.object({
+ feeling: z.string(),
+ }),
+ execute: async () => {
+ return `Hello`;
+ },
+ description: "call this tool to get a greeting",
+ }),
+ },
+ model,
+ });
+ const next: UIMessage = {
+ id: Identifier.create("message"),
+ role: "assistant",
+ parts: [],
+ };
+ msgs.push(next);
+ let text: TextUIPart | undefined;
+ const reader = result.toUIMessageStream().getReader();
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ l.info("part", value);
+ switch (value.type) {
+ case "start":
+ break;
+ case "start-step":
+ next.parts.push({
+ type: "step-start",
+ });
+ break;
+ case "text":
+ if (!text) {
+ text = value;
+ next.parts.push(value);
+ break;
+ }
+ text.text += value.text;
+ break;
+
+ case "tool-call":
+ next.parts.push({
+ type: "tool-invocation",
+ toolInvocation: {
+ state: "call",
+ ...value,
+ },
+ });
+ break;
+
+ case "tool-result":
+ const match = next.parts.find(
+ (p) =>
+ p.type === "tool-invocation" &&
+ p.toolInvocation.toolCallId === value.toolCallId,
+ ) as ToolInvocationUIPart | undefined;
+ if (match) {
+ match.toolInvocation = {
+ ...match.toolInvocation,
+ state: "result",
+ result: value.result,
+ };
+ await write();
+ }
+ break;
+
+ case "finish":
+ await write();
+ break;
+ case "finish-step":
+ await write();
+ break;
+
+ default:
+ l.info("unhandled", {
+ type: value.type,
+ });
+ }
+ }
+ }
}
diff --git a/js/src/storage/storage.ts b/js/src/storage/storage.ts
index b84e089cc..19e8dc06f 100644
--- a/js/src/storage/storage.ts
+++ b/js/src/storage/storage.ts
@@ -8,19 +8,17 @@ import { AppPath } from "../app/path";
export namespace Storage {
const log = Log.create({ service: "storage" });
- function state() {
- return App.service("storage", async () => {
- const app = await App.use();
- const storageDir = AppPath.storage(app.root);
- await fs.mkdir(storageDir, { recursive: true });
- const storage = new FileStorage(new LocalStorageAdapter(storageDir));
- await storage.write("test", "test");
- log.info("created", { path: storageDir });
- return {
- storage,
- };
- });
- }
+ const state = App.state("storage", async () => {
+ const app = await App.use();
+ const storageDir = AppPath.storage(app.root);
+ await fs.mkdir(storageDir, { recursive: true });
+ const storage = new FileStorage(new LocalStorageAdapter(storageDir));
+ await storage.write("test", "test");
+ log.info("created", { path: storageDir });
+ return {
+ storage,
+ };
+ });
function expose<T extends keyof FileStorage>(key: T) {
const fn = FileStorage.prototype[key];
@@ -36,4 +34,6 @@ export namespace Storage {
export const write = expose("write");
export const read = expose("read");
+ export const list = expose("list");
+ export const readToString = expose("readToString");
}