Dax Raad 9 месяцев назад
Родитель
Сommit
d0d67029f4
11 измененных файлов с 351 добавлено и 75 удалено
  1. 7 4
      js/bun.lock
  2. 1 1
      js/opencode.jsonc
  3. 3 2
      js/package.json
  4. 52 0
      js/src/app/config.ts
  5. 23 11
      js/src/app/index.ts
  6. 0 42
      js/src/config/config.ts
  7. 1 0
      js/src/id/id.ts
  8. 17 1
      js/src/index.ts
  9. 59 0
      js/src/llm/llm.ts
  10. 175 1
      js/src/session/session.ts
  11. 13 13
      js/src/storage/storage.ts

+ 7 - 4
js/bun.lock

@@ -4,22 +4,25 @@
     "": {
     "": {
       "name": "js",
       "name": "js",
       "dependencies": {
       "dependencies": {
+        "@ai-sdk/anthropic": "^2.0.0-alpha.2",
         "@flystorage/file-storage": "^1.1.0",
         "@flystorage/file-storage": "^1.1.0",
         "@flystorage/local-fs": "^1.1.0",
         "@flystorage/local-fs": "^1.1.0",
         "ai": "^5.0.0-alpha.2",
         "ai": "^5.0.0-alpha.2",
-        "ulid": "^3.0.0",
-        "zod": "^3.24.4",
+        "ulid": "3.0.0",
+        "zod": "^3.25.0-beta.20250518T002810",
       },
       },
       "devDependencies": {
       "devDependencies": {
         "@tsconfig/bun": "^1.0.7",
         "@tsconfig/bun": "^1.0.7",
         "@types/bun": "latest",
         "@types/bun": "latest",
       },
       },
       "peerDependencies": {
       "peerDependencies": {
-        "typescript": "^5",
+        "typescript": "5",
       },
       },
     },
     },
   },
   },
   "packages": {
   "packages": {
+    "@ai-sdk/anthropic": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "2.0.0-alpha.2", "@ai-sdk/provider-utils": "3.0.0-alpha.2" }, "peerDependencies": { "zod": "^3.24.0" } }, "sha512-ynD9RHZBAoLKUh8wqmS0EROSZQGSYZkVoF5Y0ZgMQfyVkeDXTl3PCXfc0jqwBTjNk6OOHza6BD5hCZDSjh0Sqg=="],
+
     "@ai-sdk/provider": ["@ai-sdk/[email protected]", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-jgRpHhpKmXnUEp41xUZyqJ8VPF9gS6W7SP2iYRaM9jaq66edcg6gTYOJLqM+nSU2tXYfkzfoBGGRvtl9ijH/VQ=="],
     "@ai-sdk/provider": ["@ai-sdk/[email protected]", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-jgRpHhpKmXnUEp41xUZyqJ8VPF9gS6W7SP2iYRaM9jaq66edcg6gTYOJLqM+nSU2tXYfkzfoBGGRvtl9ijH/VQ=="],
 
 
     "@ai-sdk/provider-utils": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "2.0.0-alpha.2", "@standard-schema/spec": "^1.0.0", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-oTlF6UlVitSdVPQv0e+kAkZmbuunJAUYdVEh7ZRvoti+kY/T4vOT6p22X0xTaWgl0+MI1igAT+c83j7tCMuo2w=="],
     "@ai-sdk/provider-utils": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "2.0.0-alpha.2", "@standard-schema/spec": "^1.0.0", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-oTlF6UlVitSdVPQv0e+kAkZmbuunJAUYdVEh7ZRvoti+kY/T4vOT6p22X0xTaWgl0+MI1igAT+c83j7tCMuo2w=="],
@@ -78,7 +81,7 @@
 
 
     "undici-types": ["[email protected]", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
     "undici-types": ["[email protected]", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
 
 
-    "zod": ["[email protected]4.4", "", {}, "sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg=="],
+    "zod": ["[email protected]5.0-beta.20250518T002810", "", {}, "sha512-3/aIqMbUXG9EjTelJkDcWd+izJP5MxFgQEMSYI8n41pwYhRDYYxy2dnbkgfNcnLbFZ9uByZn9XXqHTh05QHqSQ=="],
 
 
     "zod-to-json-schema": ["[email protected]", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g=="],
     "zod-to-json-schema": ["[email protected]", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g=="],
   }
   }

+ 1 - 1
js/opencode.jsonc

@@ -1,3 +1,3 @@
 {
 {
-  "lol": "jsonc"
+  "providers": {}
 }
 }

+ 3 - 2
js/package.json

@@ -10,10 +10,11 @@
     "typescript": "5"
     "typescript": "5"
   },
   },
   "dependencies": {
   "dependencies": {
+    "@ai-sdk/anthropic": "^2.0.0-alpha.2",
     "@flystorage/file-storage": "^1.1.0",
     "@flystorage/file-storage": "^1.1.0",
     "@flystorage/local-fs": "^1.1.0",
     "@flystorage/local-fs": "^1.1.0",
-    "ai": "5.0.0-alpha.2",
+    "ai": "^5.0.0-alpha.2",
     "ulid": "3.0.0",
     "ulid": "3.0.0",
-    "zod": "3.24.4"
+    "zod": "^3.25.0-beta.20250518T002810"
   }
   }
 }
 }

+ 52 - 0
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;
+  }
+}

+ 23 - 11
js/src/app/index.ts

@@ -2,6 +2,7 @@ import fs from "fs/promises";
 import { AppPath } from "./path";
 import { AppPath } from "./path";
 import { Log } from "../util/log";
 import { Log } from "../util/log";
 import { Context } from "../util/context";
 import { Context } from "../util/context";
+import { Config } from "./config";
 
 
 export namespace App {
 export namespace App {
   const log = Log.create({ service: "app" });
   const log = Log.create({ service: "app" });
@@ -13,29 +14,40 @@ export namespace App {
   export async function create(input: { directory: string }) {
   export async function create(input: { directory: string }) {
     log.info("creating");
     log.info("creating");
 
 
+    const config = await Config.load(input.directory);
+
     const dataDir = AppPath.data(input.directory);
     const dataDir = AppPath.data(input.directory);
     await fs.mkdir(dataDir, { recursive: true });
     await fs.mkdir(dataDir, { recursive: true });
     log.info("created", { path: dataDir });
     log.info("created", { path: dataDir });
 
 
     const services = new Map<any, any>();
     const services = new Map<any, any>();
 
 
-    return {
+    const result = {
+      get services() {
+        return services;
+      },
+      get config() {
+        return config;
+      },
       get root() {
       get root() {
         return input.directory;
         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() {
   export async function use() {

+ 0 - 42
js/src/config/config.ts

@@ -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();
-  }
-}

+ 1 - 0
js/src/id/id.ts

@@ -4,6 +4,7 @@ import { z } from "zod";
 export namespace Identifier {
 export namespace Identifier {
   const prefixes = {
   const prefixes = {
     session: "ses",
     session: "ses",
+    message: "msg",
   } as const;
   } as const;
 
 
   export function create(
   export function create(

+ 17 - 1
js/src/index.ts

@@ -2,12 +2,28 @@ import { App } from "./app";
 import process from "node:process";
 import process from "node:process";
 import { RPC } from "./server/server";
 import { RPC } from "./server/server";
 import { Session } from "./session/session";
 import { Session } from "./session/session";
+import { Identifier } from "./id/id";
 
 
 const app = await App.create({
 const app = await App.create({
   directory: process.cwd(),
   directory: process.cwd(),
 });
 });
 
 
 App.provide(app, async () => {
 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();
   const rpc = RPC.listen();
 });
 });

+ 59 - 0
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);
+  }
+}

+ 175 - 1
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 { Identifier } from "../id/id";
+import { LLM } from "../llm/llm";
 import { Storage } from "../storage/storage";
 import { Storage } from "../storage/storage";
 import { Log } from "../util/log";
 import { Log } from "../util/log";
+import {
+  convertToModelMessages,
+  streamText,
+  tool,
+  type TextUIPart,
+  type ToolInvocationUIPart,
+  type UIMessage,
+} from "ai";
 
 
 export namespace Session {
 export namespace Session {
   const log = Log.create({ service: "session" });
   const log = Log.create({ service: "session" });
@@ -10,13 +22,175 @@ export namespace Session {
     title: string;
     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() {
   export async function create() {
     const result: Info = {
     const result: Info = {
       id: Identifier.create("session"),
       id: Identifier.create("session"),
       title: "New Session - " + new Date().toISOString(),
       title: "New Session - " + new Date().toISOString(),
     };
     };
     log.info("created", result);
     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;
     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,
+          });
+      }
+    }
+  }
 }
 }

+ 13 - 13
js/src/storage/storage.ts

@@ -8,19 +8,17 @@ import { AppPath } from "../app/path";
 export namespace Storage {
 export namespace Storage {
   const log = Log.create({ service: "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) {
   function expose<T extends keyof FileStorage>(key: T) {
     const fn = FileStorage.prototype[key];
     const fn = FileStorage.prototype[key];
@@ -36,4 +34,6 @@ export namespace Storage {
 
 
   export const write = expose("write");
   export const write = expose("write");
   export const read = expose("read");
   export const read = expose("read");
+  export const list = expose("list");
+  export const readToString = expose("readToString");
 }
 }