summaryrefslogtreecommitdiff
path: root/packages/ai/src
diff options
context:
space:
mode:
authorpolwex <polwex@sortug.com>2025-11-23 01:12:53 +0700
committerpolwex <polwex@sortug.com>2025-11-23 01:12:53 +0700
commitcb1b56f5a0eddbf77446f415f2beda57c8305f85 (patch)
treed333ca5c143063af8ee1b2f9e2d1d25f8ef2007c /packages/ai/src
wut
Diffstat (limited to 'packages/ai/src')
-rw-r--r--packages/ai/src/cache.ts150
-rw-r--r--packages/ai/src/claude.ts173
-rw-r--r--packages/ai/src/gemini.ts199
-rw-r--r--packages/ai/src/gemini2.ts149
-rw-r--r--packages/ai/src/generic.ts204
-rw-r--r--packages/ai/src/genericnew.ts169
-rw-r--r--packages/ai/src/logic/constants.ts3
-rw-r--r--packages/ai/src/nlp/index.ts7
-rw-r--r--packages/ai/src/nlp/nlp.ts208
-rw-r--r--packages/ai/src/nlp/ocr.ts18
-rw-r--r--packages/ai/src/nlp/spacy.ts79
-rw-r--r--packages/ai/src/nlp/stanza.ts210
-rw-r--r--packages/ai/src/nlp/types.ts50
-rw-r--r--packages/ai/src/openai-responses.ts186
-rw-r--r--packages/ai/src/openai.ts260
-rw-r--r--packages/ai/src/openai_tools.ts66
-rw-r--r--packages/ai/src/prompts.ts14
-rw-r--r--packages/ai/src/tts/eleven.ts20
-rw-r--r--packages/ai/src/tts/minimax.ts107
-rw-r--r--packages/ai/src/tts/output.mp3bin0 -> 1020960 bytes
-rw-r--r--packages/ai/src/types/index.ts56
-rw-r--r--packages/ai/src/types/mtproto.ts0
22 files changed, 2328 insertions, 0 deletions
diff --git a/packages/ai/src/cache.ts b/packages/ai/src/cache.ts
new file mode 100644
index 0000000..5d59163
--- /dev/null
+++ b/packages/ai/src/cache.ts
@@ -0,0 +1,150 @@
+// memoize.ts (Bun-compatible, no Node Buffers)
+import { mkdir } from "node:fs/promises";
+import path from "node:path";
+
+type MemoOpts<V> = {
+ ttlMs?: number; // time-to-live for entries
+ maxEntries?: number; // cap; oldest (LRU) evicted
+ persistDir?: string; // set to enable disk cache (e.g. ".cache/memo")
+ keyFn?: (...args: any[]) => string; // custom key if you need it
+ cacheErrors?: boolean; // default false
+};
+
+type Entry<V> = {
+ v: V;
+ exp: number | null;
+ at: number; // last hit (LRU)
+};
+
+const enc = new TextEncoder();
+const dec = new TextDecoder();
+
+const stableStringify = (x: any): string => {
+ const seen = new WeakSet();
+ const S = (v: any): any => {
+ if (v && typeof v === "object") {
+ if (seen.has(v)) return "[Circular]";
+ seen.add(v);
+ if (Array.isArray(v)) return v.map(S);
+ return Object.fromEntries(
+ Object.keys(v)
+ .sort()
+ .map((k) => [k, S(v[k])]),
+ );
+ }
+ if (typeof v === "function") return `[Function:${v.name || "anon"}]`;
+ if (typeof v === "undefined") return "__undefined__";
+ return v;
+ };
+ return JSON.stringify(S(x));
+};
+
+async function sha256Hex(s: string) {
+ const h = await crypto.subtle.digest("SHA-256", enc.encode(s));
+ return Array.from(new Uint8Array(h))
+ .map((b) => b.toString(16).padStart(2, "0"))
+ .join("");
+}
+
+function now() {
+ return Date.now();
+}
+
+export function memoize<
+ F extends (...args: any[]) => any,
+ V = Awaited<ReturnType<F>>,
+>(fn: F, opts: MemoOpts<V> = {}): F {
+ const ttl = opts.ttlMs ?? 0;
+ const max = opts.maxEntries ?? 0;
+ const dir = opts.persistDir ? path.resolve(opts.persistDir) : null;
+
+ const mem = new Map<string, Entry<V>>();
+ const inflight = new Map<string, Promise<V>>();
+
+ async function keyOf(args: any[]): Promise<string> {
+ const base = opts.keyFn ? opts.keyFn(...args) : stableStringify(args);
+ return dir ? await sha256Hex(base) : base; // hash when persisting (safe filename)
+ }
+
+ async function readDisk(k: string): Promise<Entry<V> | null> {
+ if (!dir) throw new Error("no dir!");
+ const f = Bun.file(path.join(dir, `${k}.json`));
+ if (!(await f.exists())) return null;
+ try {
+ const obj = JSON.parse(await f.text());
+ return obj as Entry<V>;
+ } catch {
+ return null;
+ }
+ }
+
+ async function writeDisk(k: string, e: Entry<V>) {
+ if (!dir) throw new Error("no dir!");
+ await Bun.write(path.join(dir, `${k}.json`), JSON.stringify(e));
+ }
+
+ function evictLRU() {
+ if (!max || mem.size <= max) return;
+ const arr = [...mem.entries()].sort((a, b) => a[1].at - b[1].at);
+ for (let i = 0; i < mem.size - max; i++) mem.delete(arr[i][0]);
+ }
+
+ async function getOrCall(args: any[]): Promise<V> {
+ const k = await keyOf(args);
+ const t = now();
+
+ // in-flight coalescing
+ if (inflight.has(k)) return inflight.get(k)!;
+
+ // memory hit
+ const m = mem.get(k);
+ if (m && (!m.exp || t < m.exp)) {
+ m.at = t;
+ return m.v;
+ }
+
+ // disk hit
+ const d = await readDisk(k);
+ if (d && (!d.exp || t < d.exp)) {
+ d.at = t;
+ mem.set(k, d);
+ evictLRU();
+ return d.v;
+ }
+
+ // miss → call underlying
+ const call = (async () => {
+ try {
+ const r = fn.apply(undefined, args);
+ const v: V = r instanceof Promise ? await r : (r as V);
+ const e: Entry<V> = { v, exp: ttl ? t + ttl : null, at: t };
+ mem.set(k, e);
+ evictLRU();
+ await writeDisk(k, e);
+ return v;
+ } catch (err) {
+ if (opts.cacheErrors) {
+ const e: Entry<any> = { v: err, exp: ttl ? t + ttl : null, at: t };
+ mem.set(k, e);
+ await writeDisk(k, e as Entry<V>);
+ }
+ throw err;
+ } finally {
+ inflight.delete(k);
+ }
+ })();
+
+ inflight.set(k, call);
+ return call;
+ }
+
+ // Wrap preserving arity & `this` for methods
+ const wrapped = function (this: any, ...args: any[]) {
+ const maybe = getOrCall(args).then((v) => v);
+ // If original fn is sync (per your signature), unwrap to sync only when it's truly sync.
+ // We can detect by calling without awaiting once—dangerous—so be conservative:
+ return maybe as unknown as ReturnType<F>;
+ } as any as F;
+
+ return wrapped;
+}
diff --git a/packages/ai/src/claude.ts b/packages/ai/src/claude.ts
new file mode 100644
index 0000000..a411030
--- /dev/null
+++ b/packages/ai/src/claude.ts
@@ -0,0 +1,173 @@
+import Claude from "@anthropic-ai/sdk";
+import { RESPONSE_LENGTH } from "./logic/constants";
+import type { AIModelAPI, ChatMessage, InputToken } from "./types";
+import { BOOKWORM_SYS } from "./prompts";
+import type { AsyncRes } from "@sortug/lib";
+import type {
+ ImageBlockParam,
+ MessageCreateParamsStreaming,
+ TextBlockParam,
+} from "@anthropic-ai/sdk/resources";
+
+type Message = Claude.Messages.MessageParam;
+
+export default class ClaudeAPI implements AIModelAPI {
+ private model: string = "claude-opus-4-20250514";
+ tokenizer: (text: string) => number;
+ maxTokens: number;
+ // model: string = "claude-3-5-sonnet-20241022";
+ constructor(
+ model: string,
+ maxTokens = 200_000,
+ tokenizer: (text: string) => number = (text) => text.length / 3,
+ ) {
+ this.maxTokens = maxTokens;
+ this.tokenizer = tokenizer;
+ if (model) this.model = model;
+ }
+ public setModel(model: string) {
+ this.model = model;
+ }
+ private mapMessages(input: ChatMessage[]): Message[] {
+ return input.map((m) => {
+ const role = m.author === "claude" ? "assistant" : "user";
+ return { role, content: m.text };
+ });
+ }
+ private buildInput(tokens: InputToken[]): Message[] {
+ // can do base64 for images too
+ const content = tokens.map((t) => {
+ const content =
+ "text" in t
+ ? ({ type: "text", text: t.text } as TextBlockParam)
+ : "img" in t
+ ? ({
+ type: "image",
+ source: { type: "url", url: t.img },
+ } as ImageBlockParam)
+ : ({ type: "text", text: "oy vey" } as TextBlockParam);
+ return content;
+ });
+
+ return [{ role: "user", content }];
+ }
+
+ // https://docs.anthropic.com/en/api/messages-examples#vision
+ public async send(input: string | InputToken[], sys?: string) {
+ const msgs: Message[] =
+ typeof input === "string"
+ ? [{ role: "user", content: input }]
+ : this.buildInput(input);
+ const truncated = this.truncateHistory(msgs);
+ const res = await this.apiCall(truncated, sys);
+ return res;
+ }
+
+ public async sendDoc(data: string) {
+ const sys = BOOKWORM_SYS;
+ const msg: Message = {
+ role: "user",
+ content: [
+ {
+ type: "document",
+ source: { type: "base64", data, media_type: "application/pdf" },
+ },
+ {
+ type: "text",
+ text: "Please analyze this according to your system prompt. Be thorough.",
+ },
+ ],
+ };
+ const res = await this.apiCall([msg], sys);
+ return res;
+ }
+
+ public async stream(
+ input: string | InputToken[],
+ handle: (c: any) => void,
+ sys?: string,
+ ) {
+ const msgs: Message[] =
+ typeof input === "string"
+ ? [{ role: "user", content: input }]
+ : this.buildInput(input);
+ const truncated = this.truncateHistory(msgs);
+ await this.apiCallStream(truncated, handle, sys);
+ }
+
+ private truncateHistory(messages: Message[]): Message[] {
+ const totalTokens = messages.reduce((total, message) => {
+ return total + this.tokenizer(message.content as string);
+ }, 0);
+ while (totalTokens > this.maxTokens && messages.length > 1) {
+ messages.splice(0, 1);
+ }
+ return messages;
+ }
+
+ // TODO
+ // https://docs.anthropic.com/en/api/messages-examples#putting-words-in-claudes-mouth
+ private async apiCall(
+ messages: Message[],
+ system?: string,
+ ): Promise<AsyncRes<string>> {
+ try {
+ const claud = new Claude();
+ const params = {
+ model: this.model,
+ max_tokens: RESPONSE_LENGTH,
+ messages,
+ };
+ const res = await claud.messages.create(
+ system ? { ...params, system } : params,
+ );
+ const resm: string = res.content.reduce((acc: string, item) => {
+ if (item.type === "tool_use") return acc;
+ else if (item.type === "text") return `${acc}\n${item.text}`;
+ else return acc;
+ }, "");
+ // const resm = res.content.reduce((acc: string[], item) => {
+ // if (item.type === "tool_use") return acc;
+ // else if (item.type === "text") return [...acc, item.text]
+ // else return acc;
+ // }, []);
+ return { ok: resm };
+ } catch (e) {
+ console.log(e, "error in claude api");
+ return { error: `${e}` };
+ }
+ }
+
+ private async apiCallStream(
+ messages: Message[],
+ handle: (c: any) => void,
+ system?: string,
+ ): Promise<void> {
+ try {
+ const claud = new Claude();
+ const params = {
+ model: this.model,
+ max_tokens: RESPONSE_LENGTH,
+ messages,
+ stream: true as true,
+ };
+ const fparams: MessageCreateParamsStreaming = system
+ ? { ...params, system }
+ : params;
+ const stream = await claud.messages.create(fparams);
+
+ for await (const part of stream) {
+ if (part.type === "message_start") continue;
+ if (part.type === "content_block_start") continue;
+ if (part.type === "content_block_delta") {
+ console.log("delta", part.delta);
+ const delta: any = part.delta;
+ handle(delta.text);
+ }
+ }
+ } catch (e) {
+ console.log(e, "error in claude api");
+ handle(`Error streaming Claude, ${e}`);
+ }
+ }
+}
diff --git a/packages/ai/src/gemini.ts b/packages/ai/src/gemini.ts
new file mode 100644
index 0000000..d8010b0
--- /dev/null
+++ b/packages/ai/src/gemini.ts
@@ -0,0 +1,199 @@
+// import mime from "mime-types";
+import {
+ Chat,
+ createPartFromBase64,
+ createPartFromUri,
+ createUserContent,
+ GoogleGenAI,
+ type Content,
+ type ContentListUnion,
+ type GeneratedImage,
+ type GeneratedVideo,
+ type Part,
+} from "@google/genai";
+import type { AIModelAPI, InputToken } from "./types";
+import type { AsyncRes, Result } from "@sortug/lib";
+
+export default class GeminiAPI implements AIModelAPI {
+ tokenizer: (text: string) => number;
+ maxTokens: number;
+ private model: string;
+ api: GoogleGenAI;
+ chats: Map<string, Chat> = new Map<string, Chat>();
+
+ constructor(
+ model?: string,
+ maxTokens = 200_000,
+ tokenizer: (text: string) => number = (text) => text.length / 3,
+ ) {
+ this.maxTokens = maxTokens;
+ this.tokenizer = tokenizer;
+
+ const gem = new GoogleGenAI({ apiKey: Bun.env["GEMINI_API_KEY"]! });
+ this.api = gem;
+ this.model = model || "gemini-2.5-pro";
+ }
+
+ // input data in gemini gets pretty involved
+ //
+ // data
+ // Union type
+ // data can be only one of the following:
+ // text
+ // string
+ // Inline text.
+
+ // inlineData
+ // object (Blob)
+ // Inline media bytes.
+
+ // functionCall
+ // object (FunctionCall)
+ // A predicted FunctionCall returned from the model that contains a string representing the FunctionDeclaration.name with the arguments and their values.
+
+ // functionResponse
+ // object (FunctionResponse)
+ // The result output of a FunctionCall that contains a string representing the FunctionDeclaration.name and a structured JSON object containing any output from the function is used as context to the model.
+
+ // fileData
+ // object (FileData)
+ // URI based data.
+
+ // executableCode
+ // object (ExecutableCode)
+ // Code generated by the model that is meant to be executed.
+
+ // codeExecutionResult
+ // object (CodeExecutionResult)
+ // Result of executing the ExecutableCode.
+
+ // metadata
+ // Union type
+ public setModel(model: string) {
+ this.model = model;
+ }
+ private contentFromImage(imageString: string): Result<Part> {
+ // TODO
+ // const mimeType = mime.lookup(imageString);
+ const mimeType = "";
+ if (!mimeType) return { error: "no mimetype" };
+ const url = URL.parse(imageString);
+ if (url) {
+ const part = createPartFromUri(imageString, mimeType);
+ return { ok: part };
+ } else return { ok: createPartFromBase64(imageString, mimeType) };
+ }
+ async inlineImage(imageURI: URL): AsyncRes<Part> {
+ try {
+ const imgdata = await fetch(imageURI);
+ const imageArrayBuffer = await imgdata.arrayBuffer();
+ const base64ImageData = Buffer.from(imageArrayBuffer).toString("base64");
+ const mimeType = imgdata.headers.get("content-type") || "image/jpeg";
+ return { ok: { inlineData: { mimeType, data: base64ImageData } } };
+ } catch (e) {
+ return { error: `${e}` };
+ }
+ }
+ public buildInput(tokens: InputToken[]): Result<Content> {
+ try {
+ const input = createUserContent(
+ tokens.map((t) => {
+ if ("text" in t) return t.text;
+ if ("img" in t) {
+ const imagePart = this.contentFromImage(t.img);
+ if ("error" in imagePart) throw new Error("image failed");
+ else return imagePart.ok;
+ }
+ return "oy vey";
+ }),
+ );
+ return { ok: input };
+ } catch (e) {
+ return { error: `${e}` };
+ }
+ }
+
+ async send(
+ input: string | InputToken[],
+ systemPrompt?: string,
+ ): AsyncRes<string> {
+ let contents: ContentListUnion;
+ if (typeof input === "string") contents = input;
+ else {
+ const built = this.buildInput(input);
+ if ("error" in built) return built;
+ else contents = built.ok;
+ }
+ try {
+ const opts = {
+ model: this.model,
+ contents,
+ };
+ const fopts = systemPrompt
+ ? { ...opts, config: { systemInstruction: systemPrompt } }
+ : opts;
+ const response = await this.api.models.generateContent(fopts);
+ if (!response.text) return { error: "no text in response" };
+ return { ok: response.text };
+ } catch (e) {
+ return { error: `${e}` };
+ }
+ }
+ async stream(
+ input: string | InputToken[],
+ handler: (s: string) => void,
+ systemPrompt?: string,
+ ) {
+ let contents: ContentListUnion;
+ if (typeof input === "string") contents = input;
+ else {
+ const built = this.buildInput(input);
+ if ("error" in built) return built;
+ else contents = built.ok;
+ }
+ const opts = {
+ model: this.model,
+ contents,
+ };
+ const fopts = systemPrompt
+ ? { ...opts, config: { systemInstruction: systemPrompt } }
+ : opts;
+ const response = await this.api.models.generateContentStream(fopts);
+ for await (const chunk of response) {
+ handler(chunk.text || "");
+ }
+ }
+
+ async makeImage(prompt: string): AsyncRes<GeneratedImage[]> {
+ try {
+ const response = await this.api.models.generateImages({
+ model: this.model,
+ prompt,
+ });
+ // TODO if empty or undefined return error
+ return { ok: response.generatedImages || [] };
+ } catch (e) {
+ return { error: `${e}` };
+ }
+ }
+ async makeVideo({
+ prompt,
+ image,
+ }: {
+ prompt?: string;
+ image?: string;
+ }): AsyncRes<GeneratedVideo[]> {
+ try {
+ const response = await this.api.models.generateVideos({
+ model: this.model,
+ prompt,
+ });
+ // TODO if empty or undefined return error
+ return { ok: response.response?.generatedVideos || [] };
+ } catch (e) {
+ return { error: `${e}` };
+ }
+ }
+}
+// TODO how to use caches
+// https://ai.google.dev/api/caching
diff --git a/packages/ai/src/gemini2.ts b/packages/ai/src/gemini2.ts
new file mode 100644
index 0000000..0b7c0da
--- /dev/null
+++ b/packages/ai/src/gemini2.ts
@@ -0,0 +1,149 @@
+import {
+ GenerativeModel,
+ GoogleGenerativeAI,
+ type Content,
+ type GenerateContentResult,
+} from "@google/generative-ai";
+import { RESPONSE_LENGTH } from "./logic/constants";
+import type {
+ AIModelAPI,
+ ChatMessage,
+ OChoice,
+ OChunk,
+ OMessage,
+} from "./types";
+import type { AsyncRes } from "@sortug/lib";
+
+export default class GeminiAPI implements AIModelAPI {
+ tokenizer: (text: string) => number;
+ maxTokens: number;
+ private model: GenerativeModel;
+
+ constructor(
+ maxTokens = 200_000,
+ tokenizer: (text: string) => number = (text) => text.length / 3,
+ model?: string,
+ ) {
+ this.maxTokens = maxTokens;
+ this.tokenizer = tokenizer;
+
+ const gem = new GoogleGenerativeAI(Bun.env["GEMINI_API_KEY"]!);
+ this.model = gem.getGenerativeModel({
+ // model: model || "gemini-2.0-flash-exp",
+ model: model || "gemini-2.5-pro-preview-05-06 ",
+ generationConfig: { maxOutputTokens: RESPONSE_LENGTH },
+ });
+ }
+
+ public setModel(model: string) {
+ const gem = new GoogleGenerativeAI(Bun.env["GEMINI_API_KEY"]!);
+ this.model = gem.getGenerativeModel({
+ model,
+ generationConfig: { maxOutputTokens: RESPONSE_LENGTH },
+ });
+ }
+ private mapMessages(input: ChatMessage[]): Content[] {
+ return input.map((m) => ({
+ role: m.author === "gemini" ? "model" : "user",
+ parts: [{ text: m.text }],
+ }));
+ }
+
+ private mapMessagesR1(input: ChatMessage[]): Content[] {
+ return input.reduce((acc: Content[], m, i) => {
+ const prev = acc[i - 1];
+ const role = m.author === "gemini" ? "model" : "user";
+ const msg = { role, parts: [{ text: m.text }] };
+ if (prev?.role === role) acc[i - 1] = msg;
+ else acc = [...acc, msg];
+ return acc;
+ }, []);
+ }
+
+ private async apiCall(
+ messages: Content[],
+ isR1: boolean = false,
+ ): Promise<AsyncRes<string[]>> {
+ try {
+ const chat = this.model.startChat({ history: messages });
+ const res = await chat.sendMessage("");
+ return { ok: [res.response.text()] };
+ } catch (e) {
+ console.log(e, "error in gemini api");
+ return { error: `${e}` };
+ }
+ }
+
+ private async apiCallStream(
+ messages: Content[],
+ handle: (c: any) => void,
+ isR1: boolean = false,
+ ): Promise<void> {
+ try {
+ const chat = this.model.startChat({ history: messages });
+ const res = await chat.sendMessage("");
+ // for await (const chunk of res.stream()) {
+ // handle(chunk.text());
+ // }
+ } catch (e) {
+ console.log(e, "error in gemini api");
+ handle(`Error streaming Gemini, ${e}`);
+ }
+ }
+
+ public async send(sys: string, input: ChatMessage[]) {
+ console.log({ sys, input });
+ this.model.systemInstruction = { role: "system", parts: [{ text: sys }] };
+ const messages = this.mapMessages(input);
+ const truncated = this.truncateHistory(messages);
+ const res = await this.apiCall(truncated);
+ return res;
+ }
+
+ public async sendR1(input: ChatMessage[]) {
+ const messages = this.mapMessagesR1(input);
+ const truncated = this.truncateHistory(messages);
+ const res = await this.apiCall(truncated, true);
+ return res;
+ }
+
+ public async stream(
+ sys: string,
+ input: ChatMessage[],
+ handle: (c: any) => void,
+ ) {
+ this.model.systemInstruction = { role: "system", parts: [{ text: sys }] };
+ const messages = this.mapMessages(input);
+ const truncated = this.truncateHistory(messages);
+ await this.apiCallStream(truncated, handle);
+ }
+
+ public async streamR1(input: ChatMessage[], handle: (c: any) => void) {
+ const messages = this.mapMessagesR1(input);
+ const truncated = this.truncateHistory(messages);
+ await this.apiCallStream(truncated, handle, true);
+ }
+
+ public async sendDoc(data: ArrayBuffer, mimeType: string, prompt: string) {
+ const res = await this.model.generateContent([
+ {
+ inlineData: {
+ data: Buffer.from(data).toString("base64"),
+ mimeType,
+ },
+ },
+ prompt,
+ ]);
+ return res;
+ }
+
+ private truncateHistory(messages: Content[]): Content[] {
+ const totalTokens = messages.reduce((total, message) => {
+ return total + this.tokenizer(message.parts[0].text || "");
+ }, 0);
+ while (totalTokens > this.maxTokens && messages.length > 1) {
+ messages.splice(0, 1);
+ }
+ return messages;
+ }
+}
diff --git a/packages/ai/src/generic.ts b/packages/ai/src/generic.ts
new file mode 100644
index 0000000..8c41f19
--- /dev/null
+++ b/packages/ai/src/generic.ts
@@ -0,0 +1,204 @@
+import OpenAI from "openai";
+import { MAX_TOKENS, RESPONSE_LENGTH } from "./logic/constants";
+import type { AIModelAPI, ChatMessage, InputToken } from "./types";
+import type { AsyncRes } from "@sortug/lib";
+import type { ChatCompletionContentPart } from "openai/resources";
+import { memoize } from "./cache";
+import type { ChatCompletionCreateParamsNonStreaming } from "groq-sdk/src/resources/chat/completions.js";
+
+type OChoice = OpenAI.Chat.Completions.ChatCompletion.Choice;
+type Message = OpenAI.Chat.Completions.ChatCompletionUserMessageParam;
+type Params = OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming;
+type OMessage = OpenAI.Chat.Completions.ChatCompletionMessageParam;
+
+type Props = {
+ baseURL: string;
+ apiKey: string;
+ model?: string;
+ maxTokens?: number;
+ tokenizer?: (text: string) => number;
+ allowBrowser?: boolean;
+};
+export default class OpenAIAPI implements AIModelAPI {
+ private cachedCreate!: (
+ args: Params,
+ ) => Promise<OpenAI.Chat.Completions.ChatCompletion>;
+
+ private apiKey;
+ private baseURL;
+ private api;
+ maxTokens: number = MAX_TOKENS;
+ tokenizer: (text: string) => number = (text) => text.length / 3;
+ model;
+
+ constructor(props: Props) {
+ this.apiKey = props.apiKey;
+ this.baseURL = props.baseURL;
+ this.api = new OpenAI({
+ baseURL: this.baseURL,
+ apiKey: this.apiKey,
+ dangerouslyAllowBrowser: props.allowBrowser || false,
+ });
+ this.model = props.model || "";
+ if (props.maxTokens) this.maxTokens = props.maxTokens;
+ if (props.tokenizer) this.tokenizer = props.tokenizer;
+
+ const boundCreate = this.api.chat.completions.create.bind(
+ this.api.chat.completions,
+ );
+
+ this.cachedCreate = memoize(boundCreate, {
+ ttlMs: 2 * 60 * 60 * 1000, // 2h
+ maxEntries: 5000,
+ persistDir: "./cache/memo",
+ // stable key for the call
+ keyFn: (args) => {
+ // args is the single object param to .create(...)
+ const {
+ model,
+ messages,
+ max_tokens,
+ temperature,
+ top_p,
+ frequency_penalty,
+ presence_penalty,
+ stop,
+ } = args as Params;
+ // stringify messages deterministically (role+content only)
+ const msg = (messages as any[])
+ .map((m) => ({ role: m.role, content: m.content }))
+ .slice(0, 200); // guard size if you want
+ return JSON.stringify({
+ model,
+ msg,
+ max_tokens,
+ temperature,
+ top_p,
+ frequency_penalty,
+ presence_penalty,
+ stop,
+ });
+ },
+ });
+ }
+ public setModel(model: string) {
+ this.model = model;
+ }
+ private mapMessages(input: ChatMessage[]): Message[] {
+ return input.map((m) => {
+ return { role: m.author as any, content: m.text, name: m.author };
+ });
+ }
+ private buildInput(tokens: InputToken[]): Message[] {
+ const content: ChatCompletionContentPart[] = tokens.map((t) => {
+ if ("text" in t) return { type: "text", text: t.text };
+ if ("img" in t) return { type: "image_url", image_url: { url: t.img } };
+ else return { type: "text", text: "oy vey" };
+ });
+ return [{ role: "user", content }];
+ }
+
+ public async send(
+ input: string | InputToken[],
+ sys?: string,
+ ): AsyncRes<string> {
+ const messages: Message[] =
+ typeof input === "string"
+ ? [{ role: "user" as const, content: input }]
+ : this.buildInput(input);
+ // const messages = this.mapMessages(input);
+ const allMessages: OMessage[] = sys
+ ? [{ role: "system", content: sys }, ...messages]
+ : messages;
+ const truncated = this.truncateHistory(allMessages);
+ const res = await this.apiCall(truncated);
+ if ("error" in res) return res;
+ else {
+ try {
+ // TODO type this properly
+ const choices: OChoice[] = res.ok;
+ const resText = choices.reduce((acc, item) => {
+ return `${acc}\n${item.message.content || ""}`;
+ }, "");
+ return { ok: resText };
+ } catch (e) {
+ return { error: `${e}` };
+ }
+ }
+ }
+
+ public async stream(
+ input: string | InputToken[],
+ handle: (c: string) => void,
+ sys?: string,
+ ) {
+ const messages: Message[] =
+ typeof input === "string"
+ ? [{ role: "user" as const, content: input }]
+ : this.buildInput(input);
+ // const messages = this.mapMessages(input);
+ const allMessages: OMessage[] = sys
+ ? [{ role: "system", content: sys }, ...messages]
+ : messages;
+ const truncated = this.truncateHistory(allMessages);
+ await this.apiCallStream(truncated, handle);
+ }
+
+ private truncateHistory(messages: OMessage[]): OMessage[] {
+ const totalTokens = messages.reduce((total, message) => {
+ return total + this.tokenizer(message.content as string);
+ }, 0);
+ while (totalTokens > this.maxTokens && messages.length > 1) {
+ // Always keep the system message if it exists
+ const startIndex = messages[0].role === "system" ? 1 : 0;
+ messages.splice(startIndex, 1);
+ }
+ return messages;
+ }
+
+ // TODO custom temperature?
+ private async apiCall(messages: OMessage[]): AsyncRes<OChoice[]> {
+ // console.log({ messages }, "at the very end");
+ try {
+ const completion = await this.cachedCreate({
+ // temperature: 1.3,
+ model: this.model,
+ messages,
+ max_tokens: RESPONSE_LENGTH,
+ });
+ if (!completion) return { error: "null response from openai" };
+ return { ok: completion.choices };
+ } catch (e) {
+ console.log(e, "error in openai api");
+ return { error: `${e}` };
+ }
+ }
+
+ private async apiCallStream(
+ messages: OMessage[],
+ handle: (c: string) => void,
+ ): Promise<void> {
+ try {
+ const stream = await this.api.chat.completions.create({
+ temperature: 1.3,
+ model: this.model,
+ messages,
+ max_tokens: RESPONSE_LENGTH,
+ stream: true,
+ });
+
+ for await (const chunk of stream) {
+ for (const choice of chunk.choices) {
+ console.log({ choice });
+ if (!choice.delta) continue;
+ const cont = choice.delta.content;
+ if (!cont) continue;
+ handle(cont);
+ }
+ }
+ } catch (e) {
+ console.log(e, "error in openai api");
+ handle(`Error streaming OpenAI, ${e}`);
+ }
+ }
+}
diff --git a/packages/ai/src/genericnew.ts b/packages/ai/src/genericnew.ts
new file mode 100644
index 0000000..b8b4e94
--- /dev/null
+++ b/packages/ai/src/genericnew.ts
@@ -0,0 +1,169 @@
+import OpenAI from "openai";
+import { MAX_TOKENS, RESPONSE_LENGTH } from "./logic/constants";
+import type { AIModelAPI, InputToken } from "./types";
+import type { AsyncRes } from "@sortug/lib";
+import type {
+ ResponseCreateParamsBase,
+ ResponseCreateParamsNonStreaming,
+ ResponseCreateParamsStreaming,
+ ResponseInput,
+} from "openai/resources/responses/responses.mjs";
+
+type Props = {
+ baseURL: string;
+ apiKey: string | undefined;
+ model?: string;
+ maxTokens?: number;
+ tokenizer?: (text: string) => number;
+};
+export default class OpenAIAPI implements AIModelAPI {
+ private apiKey;
+ private baseURL;
+ private api;
+ maxTokens: number = MAX_TOKENS;
+ tokenizer: (text: string) => number = (text) => text.length / 3;
+ model;
+
+ constructor(props: Props) {
+ if (!props.apiKey) throw new Error("NO API KEY");
+ console.log({ props });
+ this.apiKey = props.apiKey;
+ this.baseURL = props.baseURL;
+ this.api = new OpenAI({ baseURL: this.baseURL, apiKey: this.apiKey });
+ this.model = props.model || "";
+ if (props.maxTokens) this.maxTokens = props.maxTokens;
+ if (props.tokenizer) this.tokenizer = props.tokenizer;
+ }
+ public setModel(model: string) {
+ this.model = model;
+ }
+
+ public buildInput(tokens: InputToken[]): ResponseInput {
+ return [
+ {
+ role: "user",
+ content: tokens.map((t) =>
+ "text" in t
+ ? { type: "input_text", text: t.text }
+ : "img" in t
+ ? { type: "input_image", image_url: t.img, detail: "auto" }
+ : { type: "input_text", text: "oy vey" },
+ ),
+ },
+ ];
+ }
+
+ // OpenAI SDK has three kinds ReponseInputContent: text image and file
+ // images can be URLs or base64 dataurl thingies
+ //
+ public async send(
+ inpt: string | InputToken[],
+ sys?: string,
+ ): AsyncRes<string> {
+ const input = typeof inpt === "string" ? inpt : this.buildInput(inpt);
+ const params = sys ? { instructions: sys, input } : { input };
+ const res = await this.apiCall(params);
+ if ("error" in res) return res;
+ else {
+ try {
+ return { ok: res.ok.output_text };
+ } catch (e) {
+ return { error: `${e}` };
+ }
+ }
+ }
+
+ public async stream(
+ inpt: string | InputToken[],
+ handle: (c: string) => void,
+ sys?: string,
+ ) {
+ const input = typeof inpt === "string" ? inpt : this.buildInput(inpt);
+ const params = sys ? { instructions: sys, input } : { input };
+ await this.apiCallStream(params, handle);
+ }
+
+ // TODO custom temperature?
+ private async apiCall(
+ params: ResponseCreateParamsNonStreaming,
+ ): AsyncRes<OpenAI.Responses.Response> {
+ try {
+ const res = await this.api.responses.create({
+ ...params,
+ // temperature: 1.3,
+ model: params.model || this.model,
+ input: params.input,
+ max_output_tokens: params.max_output_tokens || RESPONSE_LENGTH,
+ stream: false,
+ });
+ // TODO damn there's a lot of stuff here
+ return { ok: res };
+ } catch (e) {
+ console.log(e, "error in openai api");
+ return { error: `${e}` };
+ }
+ }
+
+ private async apiCallStream(
+ params: ResponseCreateParamsBase,
+ handler: (c: string) => void,
+ ) {
+ // temperature: 1.3,
+ const pms: ResponseCreateParamsStreaming = {
+ ...params,
+ stream: true,
+ model: params.model || this.model,
+ input: params.input,
+ max_output_tokens: params.max_output_tokens || RESPONSE_LENGTH,
+ };
+ try {
+ const stream = await this.api.responses.create(pms);
+ for await (const event of stream) {
+ console.log(event);
+ switch (event.type) {
+ // TODO deal with audio and whatever
+ case "response.output_text.delta":
+ handler(event.delta);
+ break;
+ case "response.completed":
+ break;
+ default:
+ break;
+ }
+ // if (event.type === "response.completed")
+ // wtf how do we use this
+ }
+ } catch (e) {
+ console.log(e, "error in openai api");
+ return { error: `${e}` };
+ }
+ }
+
+ // private async apiCallStream(
+ // messages: Message[],
+ // handle: (c: string) => void,
+ // ): Promise<void> {
+ // try {
+ // const stream = await this.api.chat.completions.create({
+ // temperature: 1.3,
+ // model: this.model,
+ // messages,
+ // max_tokens: RESPONSE_LENGTH,
+ // stream: true,
+ // });
+
+ // for await (const chunk of stream) {
+ // for (const choice of chunk.choices) {
+ // console.log({ choice });
+ // if (!choice.delta) continue;
+ // const cont = choice.delta.content;
+ // if (!cont) continue;
+ // handle(cont);
+ // }
+ // }
+ // } catch (e) {
+ // console.log(e, "error in openai api");
+ // handle(`Error streaming OpenAI, ${e}`);
+ // }
+ // }
+}
diff --git a/packages/ai/src/logic/constants.ts b/packages/ai/src/logic/constants.ts
new file mode 100644
index 0000000..170477d
--- /dev/null
+++ b/packages/ai/src/logic/constants.ts
@@ -0,0 +1,3 @@
+// export const RESPONSE_LENGTH = 1024;
+export const RESPONSE_LENGTH = 256;
+export const MAX_TOKENS = 64_000;
diff --git a/packages/ai/src/nlp/index.ts b/packages/ai/src/nlp/index.ts
new file mode 100644
index 0000000..ebed586
--- /dev/null
+++ b/packages/ai/src/nlp/index.ts
@@ -0,0 +1,7 @@
+import * as Spacy from "./spacy";
+import * as Stanza from "./stanza";
+import * as ISO from "./iso";
+import { ocr } from "./ocr";
+import type * as Types from "./types";
+export * from "./nlp";
+export { ISO, ocr, Stanza, Spacy, type Types };
diff --git a/packages/ai/src/nlp/nlp.ts b/packages/ai/src/nlp/nlp.ts
new file mode 100644
index 0000000..3b1e3a7
--- /dev/null
+++ b/packages/ai/src/nlp/nlp.ts
@@ -0,0 +1,208 @@
+export const isPunctuation = (text: string): boolean => {
+ // Common punctuation characters
+ const punctuationRegex = /^[.,;:!?()[\]{}'"«»""''…-]+$/;
+ return punctuationRegex.test(text);
+};
+
+// Get color for different syntactic categories
+export function getColorForType(type: string): string {
+ const colors: Record<string, string> = {
+ // Phrasal categories
+ S: "#6495ED", // Sentence - cornflower blue
+ NP: "#FF7F50", // Noun Phrase - coral
+ VP: "#32CD32", // Verb Phrase - lime green
+ PP: "#9370DB", // Prepositional Phrase - medium purple
+ ADJP: "#FFD700", // Adjective Phrase - gold
+ ADVP: "#FF69B4", // Adverb Phrase - hot pink
+
+ // Part-of-speech tags
+ NN: "#FFA07A", // Noun - light salmon
+ NNS: "#FFA07A", // Plural Noun - light salmon
+ NNP: "#FFA07A", // Proper Noun - light salmon
+ VB: "#90EE90", // Verb - light green
+ VBP: "#90EE90", // Present tense verb - light green
+ VBG: "#90EE90", // Gerund verb - light green
+ VBZ: "#90EE90", // 3rd person singular present verb - light green
+ VBD: "#90EE90", // Past tense verb - light green
+ VBN: "#90EE90", // Past participle verb - light green
+ JJ: "#F0E68C", // Adjective - khaki
+ RB: "#DDA0DD", // Adverb - plum
+ IN: "#87CEFA", // Preposition - light sky blue
+ DT: "#D3D3D3", // Determiner - light gray
+ PRP: "#D8BFD8", // Personal pronoun - thistle
+ CC: "#A9A9A9", // Coordinating conjunction - dark gray
+
+ // Default
+ ROOT: "#000000", // Root - black
+ LEAF: "#666666", // Leaf nodes - dark gray
+ };
+
+ return colors[type] || "#666666";
+}
+
+// Get a description for node types
+export function getDescription(type: string): string {
+ const descriptions: Record<string, string> = {
+ S: "Sentence",
+ SBAR: "Subordinating conjunction clause",
+ SBARQ: "Direct question",
+ SINV: "Declarative sentence with subject-aux inversion",
+ SQ: "Subconstituent of SBARQ excluding wh-word",
+ WHADVP: "wh-adverb phrase",
+ WHNP: "wh-nounphrase",
+ WHPP: "wh-prepositional phrase",
+ WDT: "wh-determiner",
+ WP: "wh-pronoun",
+ WRB: "wh-adverb",
+ WP$: "possesive wh-pronoun",
+ MD: "modal",
+ X: "Unknown",
+ NP: "Noun Phrase",
+ VP: "Verb Phrase",
+ PP: "Prepositional Phrase",
+ ADJP: "Adjective Phrase",
+ ADVP: "Adverb Phrase",
+ LS: "List item market",
+ SYM: "Symbol",
+ NN: "Noun",
+ NNS: "Plural Noun",
+ NNP: "Proper Noun",
+ NNPS: "Proper Noun, Plural",
+ VB: "Verb (base form)",
+ VBP: "Verb (present tense)",
+ VBG: "Verb (gerund/present participle)",
+ VBZ: "Verb (3rd person singular present)",
+ VBD: "Verb (past tense)",
+ VBN: "Verb (past participle)",
+ JJ: "Adjective",
+ JJR: "Adjective, comparative",
+ JJS: "Adjective, superlative",
+ EX: "Existential there",
+ RB: "Adverb",
+ RBR: "Adverb, comparative",
+ RBS: "Adverb, superlative",
+ RP: "Particle",
+ IN: "Preposition",
+ TO: "to",
+ DT: "Determiner",
+ PDT: "Predeterminer",
+ PRP: "Personal Pronoun",
+ PP$: "Possesive Pronoun",
+ PRP$: "Possesive Pronoun",
+ POS: "Possesive ending",
+ FW: "Foreign Word",
+ CC: "Coordinating Conjunction",
+ CD: "Cardinal number",
+ UH: "interjection",
+ ROOT: "Root Node",
+ CLR: "figurative motion",
+ FRAG: "fragment",
+ ":": "Colon/Semicolon",
+ ",": "Comma",
+ ".": "Period",
+ };
+
+ return descriptions[type] || type;
+}
+
+// https://universaldependencies.org/u/dep/xcomp.htmlexport
+
+export function unpackDeprel(type: string): string {
+ const descriptions: Record<string, string> = {
+ nsubj: "nominal subject",
+ obj: "object",
+ iobj: "indirect object",
+ csubj: "clausal subject",
+ ccomp: "clausal complement",
+ xcomp: "open clausal complement",
+ obl: "oblique nominal",
+ vocative: "vocative",
+ expl: "expletive",
+ dislocated: "dislocated",
+ nmod: "nominal modifier",
+ appos: "appositional modifier",
+ nummod: "numeric modifier",
+ advcl: "adverbial clause modifier",
+ acl: "admonimal clause",
+ advmod: "adverbial modifier",
+ discourse: "dicourse element",
+ aux: "auxiliary",
+ cop: "copula",
+ mark: "marker",
+ amod: "adjectival modifier",
+ det: "determiner",
+ clf: "classifier",
+ case: "case marker",
+ conj: "conjunction",
+ cc: "coordinating conjunction",
+ fixed: "fixed multiword expression",
+ flat: "flat expression",
+ list: "list",
+ parataxis: "parataxis",
+ compound: "compound",
+ orphan: "orphan",
+ goeswith: "goes with",
+ reparandum: "overriden disfluency",
+ punct: "punctuation",
+ root: "root",
+ dep: "unspecified dependency",
+ };
+ const res = descriptions[type];
+ if (!res) console.log("tag not found!!", type);
+
+ return res || type;
+}
+
+export function deprelColors(type: string): string {
+ const colors: Record<string, string> = {
+ // Phrasal categories
+ s: "#6495ED", // Sentence - cornflower blue
+ nsubj: "#6495ED", // Sentence - cornflower blue
+ root: "#FFD700", // Adjective Phrase - gold
+ p: "#FFD700", // Adjective Phrase - gold
+ NP: "#FF7F50", // Noun Phrase - coral
+ VP: "#32CD32", // Verb Phrase - lime green
+ PP: "#9370DB", // Prepositional Phrase - medium purple
+ ADVP: "#FF69B4", // Adverb Phrase - hot pink
+
+ // Part-of-speech tags
+ NN: "#FFA07A", // Noun - light salmon
+ NNS: "#FFA07A", // Plural Noun - light salmon
+ NNP: "#FFA07A", // Proper Noun - light salmon
+ VB: "#90EE90", // Verb - light green
+ VBP: "#90EE90", // Present tense verb - light green
+ VBG: "#90EE90", // Gerund verb - light green
+ VBZ: "#90EE90", // 3rd person singular present verb - light green
+ VBD: "#90EE90", // Past tense verb - light green
+ VBN: "#90EE90", // Past participle verb - light green
+ JJ: "#F0E68C", // Adjective - khaki
+ RB: "#DDA0DD", // Adverb - plum
+ IN: "#87CEFA", // Preposition - light sky blue
+ DT: "#D3D3D3", // Determiner - light gray
+ PRP: "#D8BFD8", // Personal pronoun - thistle
+ CC: "#A9A9A9", // Coordinating conjunction - dark gray
+
+ // Default
+ ROOT: "#000000", // Root - black
+ LEAF: "#666666", // Leaf nodes - dark gray
+ };
+
+ return colors[type] || "#666666";
+}
+export function unpackPos(pos: string): string {
+ const map: Record<string, string> = {
+ adj: "adjective",
+ adv: "adverb",
+ adv_phrase: "adverbial phrase",
+ combining_form: "combining form",
+ conj: "conjunction",
+ det: "determinant",
+ intj: "interjection",
+ num: "number",
+ prep: "preposition",
+ prep_phrase: "prepositional phrase",
+ pron: "pronoun",
+ punct: "punctuation",
+ };
+ return map[pos] || pos;
+}
diff --git a/packages/ai/src/nlp/ocr.ts b/packages/ai/src/nlp/ocr.ts
new file mode 100644
index 0000000..d495a8b
--- /dev/null
+++ b/packages/ai/src/nlp/ocr.ts
@@ -0,0 +1,18 @@
+import type { AsyncRes } from "@sortug/lib";
+
+export async function ocr(formData: FormData): AsyncRes<string[]> {
+ const endpoint = "http://localhost:8102/ocr";
+
+ const opts = {
+ method: "POST",
+ body: formData,
+ headers: { "X-API-KEY": Bun.env.SORTUG_NLP_API_KEY! },
+ };
+ try {
+ const res = await fetch(endpoint, opts);
+ const j = await res.json();
+ return { ok: j };
+ } catch (e) {
+ return { error: `${e}` };
+ }
+}
diff --git a/packages/ai/src/nlp/spacy.ts b/packages/ai/src/nlp/spacy.ts
new file mode 100644
index 0000000..829c77e
--- /dev/null
+++ b/packages/ai/src/nlp/spacy.ts
@@ -0,0 +1,79 @@
+import type { AsyncRes, Result } from "@sortug/lib";
+import { detectLang } from "./iso";
+const ENDPOINT = "http://localhost:8102";
+
+export async function run(text: string, langg?: string): AsyncRes<SpacyRes> {
+ try {
+ const lang = langg ? langg : detectLang(text);
+ const body = JSON.stringify({ string: text, lang });
+ const opts = {
+ headers: {
+ "Content-type": "application/json",
+ "X-API-KEY": Bun.env.SORTUG_NLP_API_KEY!,
+ },
+ method: "POST",
+ body,
+ };
+ const res = await fetch(ENDPOINT + "/spacy", opts);
+ const j = await res.json();
+ console.log("spacy", j);
+ return { ok: j };
+ } catch (e) {
+ return { error: `${e}` };
+ }
+}
+
+export type SpacyResBig = {
+ doc: {
+ text: string;
+ ents: any[];
+ sents: Array<{ start: number; end: number }>;
+ tokens: Token[];
+ };
+ segs: Sentence[];
+};
+export type SpacyRes = {
+ input: string;
+ segments: Sentence[];
+};
+export type Sentence = {
+ text: string;
+ start: number;
+ end: number;
+ root: Token;
+ subj: Token;
+ arcs: Arc[];
+ words: Word[];
+};
+export type Arc = {
+ start: number;
+ end: number;
+ label: string; // deprel label
+ dir: string;
+};
+export type Token = {
+ id: number;
+ head: number;
+ start: number;
+ end: number;
+ dep: string;
+ lemma: string;
+ morph: string;
+ pos: string;
+ tag: string;
+ text: string;
+};
+
+export interface Word extends Token {
+ ancestors: number[];
+ children: [];
+ n_lefts: number;
+ n_rights: number;
+ left_edge: number;
+ right_edge: number;
+ morph_map: Record<string, string>;
+}
+
+export function isChild(w: Word, topId: number): boolean {
+ return w.id === topId || w.ancestors.includes(topId);
+}
diff --git a/packages/ai/src/nlp/stanza.ts b/packages/ai/src/nlp/stanza.ts
new file mode 100644
index 0000000..90fa1fc
--- /dev/null
+++ b/packages/ai/src/nlp/stanza.ts
@@ -0,0 +1,210 @@
+import type { AsyncRes, Result } from "@sortug/lib";
+import { detectLang } from "./iso";
+
+const ENDPOINT = "http://localhost:8102";
+export async function segmenter(
+ text: string,
+ langg?: string,
+): AsyncRes<StanzaRes> {
+ try {
+ const lang = langg ? langg : detectLang(text);
+ const body = JSON.stringify({ lang, string: text });
+ const opts = {
+ headers: {
+ "Content-type": "application/json",
+ "X-API-KEY": Bun.env.SORTUG_NLP_API_KEY!,
+ },
+ method: "POST",
+ body,
+ };
+ const res = await fetch(ENDPOINT + "/stanza", opts);
+ const j = await res.json();
+ return { ok: j };
+ } catch (e) {
+ return { error: `${e}` };
+ }
+}
+export async function idLang(text: string) {
+ try {
+ const body = JSON.stringify({ string: text });
+ const opts = {
+ headers: {
+ "Content-type": "application/json",
+ "X-API-KEY": Bun.env.SORTUG_NLP_API_KEY!,
+ },
+ method: "POST",
+ body,
+ };
+ const res = await fetch(ENDPOINT + "/detect-lang", opts);
+ const j = await res.json();
+ return { ok: j };
+ } catch (e) {
+ return { error: `${e}` };
+ }
+}
+export type StanzaRes = { input: string; segments: Sentence[] };
+export type Sentence = {
+ text: string;
+ sentiment: number;
+ constituency: TreeNode;
+ constring: string;
+ dependencies: Dependency[];
+ entities: Entity[];
+ tokens: Token[];
+ words: Word[];
+};
+export type TreeNode = {
+ label: string;
+ children: TreeNode[];
+};
+export type Dependency = Array<[Word, string, Word]>;
+export type Word = {
+ id: number;
+ text: string;
+ lemma: string;
+ upos: string;
+ xpos: string;
+ feats: string;
+ head: number;
+ deprel: string;
+ start_char: number;
+ end_char: number;
+};
+export type Token = {
+ id: [number, number];
+ text: string;
+ misc: string;
+ words: Word[];
+ start_char: number;
+ end_char: number;
+ ner: string;
+};
+export type Entity = {
+ text: string;
+ misc: string;
+ start_char: number;
+ end_char: number;
+ type: string;
+};
+
+// mine
+export type Clause = {
+ words: Word[];
+ dependency: Dependency;
+ text: string;
+};
+// "amod",
+// {
+// "id": 1,
+// "text": "Stony",
+// "lemma": "Stony",
+// "upos": "ADJ",
+// "xpos": "NNP",
+// "feats": "Degree=Pos",
+// "head": 3,
+// "deprel": "amod",
+// "start_char": 0,
+// "end_char": 5
+// }
+//
+//
+
+export interface ParsedGrammar {
+ predicateCore: number;
+ subjectCore: number | null;
+ tree: Record<number, number[]>;
+ wordMap: WordMap;
+ words: BigWord[];
+}
+export interface BigWord extends Word {
+ ancestry: number[];
+ component: "s" | "p" | "u";
+}
+export type ComputedDependency = {
+ word: BigWord;
+ children: ComputedDependency[];
+};
+export type WordMap = Record<number, Word>;
+
+export function buildTreeFromWords(words: Word[]): Result<ParsedGrammar> {
+ const roots = words.filter((w) => w.deprel === "root");
+ if (roots.length > 1) {
+ console.log("roots", roots);
+ return { error: "too many roots" };
+ } else if (roots.length === 0) {
+ return { error: "no roots" };
+ } else {
+ const root = roots[0];
+ const wordmap = words.reduce((acc: WordMap, item) => {
+ acc[item.id] = item;
+ return acc;
+ }, {});
+ return { ok: parseFurther(words, wordmap, root) };
+ }
+}
+function parseFurther(
+ words: Word[],
+ wordMap: WordMap,
+ root: Word,
+): ParsedGrammar {
+ const predicateCore = root.id;
+ let subjectCore: number | null = null;
+ const tree: Record<number, number[]> = {};
+ const bigwords: BigWord[] = [];
+ const getAncestry = (parent: Word): number[] => {
+ const kids = tree[parent.head] || [];
+ tree[parent.head] = [...kids, parent.id];
+ if (parent.deprel === "nsubj") subjectCore = parent.id;
+
+ console.log("getting ancestry " + parent.id, parent.text);
+ const grandpa = wordMap[parent.head];
+ if (!grandpa) return [parent.id];
+ else return [parent.id, ...getAncestry(grandpa)];
+ };
+ let idx = 0;
+ for (const w of words) {
+ if (w.deprel === "punct") {
+ const prev = words[idx - 1];
+ if (!prev) continue;
+ prev.text += w.text;
+ continue;
+ }
+ const parent = wordMap[w.head];
+ if (!parent) tree[w.id] = [];
+ const ancestry = !parent ? [] : getAncestry(parent);
+ const component =
+ subjectCore && (w.id === subjectCore || ancestry.includes(subjectCore))
+ ? "s"
+ : w.id === predicateCore || ancestry.includes(root.id)
+ ? "p"
+ : "u";
+ const bw: BigWord = { ...w, component, ancestry };
+ wordMap[w.id] = bw;
+ bigwords.push(bw);
+ idx++;
+ }
+ const pg: ParsedGrammar = {
+ predicateCore,
+ subjectCore,
+ wordMap,
+ tree,
+ words: bigwords,
+ };
+ return pg;
+}
+
+export function oneDescendant(node: TreeNode): boolean {
+ if (node.children.length !== 1) return false;
+ else {
+ const child = node.children[0];
+ return child.children.length === 0;
+ }
+}
+
+// function findChildren(wordmap: WordMap, word: Word): ComputedDependency {
+// const children = words.filter((w) => w.head === head.id);
+// return {
+// word: head,
+// children: children.map((c) => findChildren(words, c)),
+// };
+// }
diff --git a/packages/ai/src/nlp/types.ts b/packages/ai/src/nlp/types.ts
new file mode 100644
index 0000000..605a637
--- /dev/null
+++ b/packages/ai/src/nlp/types.ts
@@ -0,0 +1,50 @@
+export type ViewLevel =
+ | "text"
+ | "paragraph"
+ | "sentence"
+ | "clause"
+ | "word"
+ | "syllable"
+ | "phoneme";
+export interface ViewState {
+ level: ViewLevel;
+ pIndex: number | null;
+ sIndex: number | null;
+ cIndex: number | null;
+ wIndex: number | null;
+ yIndex: number | null;
+ fIndex: number | null;
+}
+
+export interface ViewProps {
+ idx: number;
+ rawText: string;
+ context: Context;
+}
+export type Context = {
+ parentText: string;
+ segmented: string[];
+ idx: number;
+};
+
+export type WordData = {
+ confidence: number;
+ frequency: number | null;
+ id: number;
+ ipa: Array<{ ipa: string; tags: string[] }>;
+ spelling: string;
+ type: ExpressionType;
+ syllables: number;
+ lang: string;
+ prosody: any;
+ senses: Sense[];
+};
+export type ExpressionType = "word" | "expression" | "syllable";
+export type Sense = {
+ etymology: string;
+ pos: string;
+ forms: Array<{ form: string; tags: string[] }>;
+ related: any;
+ senses: Array<{ glosses: string[]; links: Array<[string, string]> }>;
+};
+export type LoadingStatus = "pending" | "loading" | "success" | "error";
diff --git a/packages/ai/src/openai-responses.ts b/packages/ai/src/openai-responses.ts
new file mode 100644
index 0000000..63a08cc
--- /dev/null
+++ b/packages/ai/src/openai-responses.ts
@@ -0,0 +1,186 @@
+import OpenAI from "openai";
+import { MAX_TOKENS, RESPONSE_LENGTH } from "./logic/constants";
+import type { AIModelAPI, ChatMessage, InputToken } from "./types";
+import type { AsyncRes } from "@sortug/lib";
+import type {
+ ResponseContent,
+ ResponseInput,
+ ResponseInputContent,
+ ResponseInputItem,
+ ResponseOutputItem,
+ ResponseOutputMessage,
+} from "openai/resources/responses/responses";
+import type { ResponseCreateAndStreamParams } from "openai/lib/responses/ResponseStream";
+import { memoize } from "./cache";
+
+type Params = OpenAI.Responses.ResponseCreateParamsNonStreaming;
+type Props = {
+ baseURL: string;
+ apiKey: string;
+ model?: string;
+ maxTokens?: number;
+ tokenizer?: (text: string) => number;
+ allowBrowser?: boolean;
+};
+export default class OpenAIAPI implements AIModelAPI {
+ private cachedCreate!: (args: Params) => Promise<OpenAI.Responses.Response>;
+
+ private apiKey;
+ private baseURL;
+ private api;
+ maxTokens: number = MAX_TOKENS;
+ tokenizer: (text: string) => number = (text) => text.length / 3;
+ model;
+
+ constructor(props: Props) {
+ this.apiKey = props.apiKey;
+ this.baseURL = props.baseURL;
+ this.api = new OpenAI({
+ baseURL: this.baseURL,
+ apiKey: this.apiKey,
+ dangerouslyAllowBrowser: props.allowBrowser || false,
+ });
+ this.model = props.model || "";
+ if (props.maxTokens) this.maxTokens = props.maxTokens;
+ if (props.tokenizer) this.tokenizer = props.tokenizer;
+
+ const boundCreate = this.api.responses.create.bind(this.api.responses);
+
+ this.cachedCreate = memoize(boundCreate, {
+ ttlMs: 2 * 60 * 60 * 1000, // 2h
+ maxEntries: 5000,
+ persistDir: "./cache/memo",
+ // stable key for the call
+ keyFn: (args) => {
+ // args is the single object param to .create(...)
+ const { model, input, max_output_tokens, temperature, top_p } =
+ args as Params;
+ // stringify messages deterministically (role+content only)
+ return JSON.stringify({
+ model,
+ input,
+ max_output_tokens,
+ temperature,
+ top_p,
+ });
+ },
+ });
+ }
+ public setModel(model: string) {
+ this.model = model;
+ }
+ // response input items are text, image, file, conversation state or function cals
+ private buildInput(tokens: InputToken[]): ResponseInputItem[] {
+ const content: ResponseInputContent[] = tokens.map((t) => {
+ if ("text" in t) return { type: "input_text" as const, text: t.text };
+ // image_url or file_id
+ else if ("img" in t)
+ return {
+ type: "input_image" as const,
+ image_url: t.img,
+ detail: "auto",
+ };
+ // file_data or file_id or file_url or filename
+ else if ("file" in t)
+ return { type: "input_file" as const, file_data: t.file.file_data };
+ // TODO obviously
+ else return { type: "input_text" as const, text: "oy vey" };
+ });
+ // role can be user, developer, or system
+ return [{ role: "user" as const, content }];
+ }
+
+ public async send(
+ userInput: string | InputToken[],
+ sys?: string,
+ ): AsyncRes<string> {
+ const input: string | ResponseInput =
+ typeof userInput === "string" ? userInput : this.buildInput(userInput);
+ // const messages = this.mapMessages(input);
+ const res = await this.apiCall({ instructions: sys, input });
+ if ("error" in res) return res;
+ else {
+ try {
+ // TODO type this properly
+ const resText = res.ok.reduce((acc, item) => {
+ if (item.type === "message" && item.status === "completed") {
+ const outputText = this.getOutputText(item.content);
+ return `${acc}\n${outputText}`;
+ }
+ // TODO else
+ return acc;
+ }, "");
+ return { ok: resText };
+ } catch (e) {
+ return { error: `${e}` };
+ }
+ }
+ }
+ getOutputText(content: ResponseOutputMessage["content"]): string {
+ let text = "";
+ for (const c of content) {
+ if (c.type === "refusal") text += `\nRefused to respond: ${c.refusal}\n`;
+ else text += `\n${c.text}\n`;
+ }
+ return text;
+ }
+
+ public async stream(
+ userInput: string | InputToken[],
+ handle: (c: string) => void,
+ sys?: string,
+ ) {
+ const input: string | ResponseInput =
+ typeof userInput === "string" ? userInput : this.buildInput(userInput);
+ await this.apiCallStream({ instructions: sys, input }, handle);
+ }
+
+ // TODO custom temperature?dune exec -- ./test/test_nock.exe --verbose
+ private async apiCall(
+ params: OpenAI.Responses.ResponseCreateParamsNonStreaming,
+ ): AsyncRes<ResponseOutputItem[]> {
+ // console.log({ messages }, "at the very end");
+ try {
+ const response = await this.cachedCreate({
+ ...params,
+ model: this.model,
+ // max_output_tokens: RESPONSE_LENGTH,
+ });
+ if (response.status !== "completed")
+ return {
+ error:
+ response.incomplete_details?.reason || response.status || "error",
+ };
+
+ return { ok: response.output };
+ } catch (e) {
+ console.log(e, "error in openai api");
+ return { error: `${e}` };
+ }
+ }
+
+ private async apiCallStream(
+ params: ResponseCreateAndStreamParams,
+ handle: (c: string) => void,
+ ): Promise<void> {
+ try {
+ const stream = await this.api.responses.create({
+ // temperature: 1.3,
+ ...params,
+ stream: true,
+ model: this.model,
+ max_output_tokens: RESPONSE_LENGTH,
+ });
+
+ for await (const chunk of stream) {
+ console.log("stream reponse", chunk);
+ if (chunk.type === "response.output_text.done") handle(chunk.text);
+ // TODO else
+ }
+ } catch (e) {
+ console.log(e, "error in openai api");
+ // TODO
+ // handle(`Error streaming OpenAI, ${e}`);
+ }
+ }
+}
diff --git a/packages/ai/src/openai.ts b/packages/ai/src/openai.ts
new file mode 100644
index 0000000..bd1dca1
--- /dev/null
+++ b/packages/ai/src/openai.ts
@@ -0,0 +1,260 @@
+import fs from "fs";
+import OpenAI from "openai";
+import { RESPONSE_LENGTH } from "./logic/constants";
+import type { ChatMessage, OChoice, OChunk, OMessage } from "./types";
+import type { AsyncRes, Result } from "@sortug/lib";
+import OpenAIToolUse from "./openai_tools";
+import type { FileObject } from "openai/src/resources/files.js";
+
+type Message = OpenAI.Chat.Completions.ChatCompletionMessageParam;
+
+type Props = {
+ maxTokens?: number;
+ baseURL?: string;
+ apiKey?: string;
+ tokenizer?: (text: string) => number;
+};
+export default class Conversation {
+ private maxTokens: number = 128_000;
+ private apiKey: string = Bun.env["OPENAI_API_KEY"] || "";
+ private baseURL: string = "https://api.openai.com/v1";
+ private tokenizer: (text: string) => number = (text) => text.length / 3;
+ openai;
+ private model: string = "gpt-4.1";
+
+ constructor(props: Props) {
+ if (props.apiKey) this.apiKey = props.apiKey;
+ if (props.baseURL) this.baseURL = props.baseURL;
+ this.openai = new OpenAI({ baseURL: this.baseURL, apiKey: this.apiKey });
+ if (props.maxTokens) this.maxTokens = props.maxTokens;
+ if (props.tokenizer) this.tokenizer = props.tokenizer;
+ }
+ public setModel(model: string) {
+ this.model = model;
+ }
+ private mapMessages(input: ChatMessage[]): Message[] {
+ return input.map((m) => {
+ const role = m.author === "openai" ? "assistant" : "user";
+ return { role, content: m.text, name: m.author };
+ });
+ }
+
+ private mapMessagesR1(input: ChatMessage[]): Message[] {
+ return input.reduce((acc: Message[], m, i) => {
+ const prev = acc[i - 1];
+ const role = m.author === "openai" ? "assistant" : "user";
+ const msg: Message = { role, content: m.text, name: m.author };
+ if (prev?.role === role) acc[i - 1] = msg;
+ else acc = [...acc, msg];
+ return acc;
+ }, []);
+ }
+
+ public async send(sys: string, input: ChatMessage[]): AsyncRes<OChoice[]> {
+ const messages = this.mapMessages(input);
+ const sysMsg: Message = { role: "system", content: sys };
+ const allMessages = [sysMsg, ...messages];
+ const truncated = this.truncateHistory(allMessages);
+ const res = await this.apiCall(truncated);
+ return res;
+ }
+
+ public async sendR1(input: ChatMessage[]): AsyncRes<OChoice[]> {
+ const messages = this.mapMessagesR1(input);
+ const truncated = this.truncateHistory(messages);
+ const res = await this.apiCall(truncated);
+ return res;
+ }
+
+ public async stream(
+ sys: string,
+ input: ChatMessage[],
+ handle: (c: any) => void,
+ ) {
+ const messages = this.mapMessages(input);
+ const sysMsg: Message = { role: "system", content: sys };
+ const allMessages = [sysMsg, ...messages];
+ const truncated = this.truncateHistory(allMessages);
+ await this.apiCallStream(truncated, handle);
+ }
+
+ public async streamR1(input: ChatMessage[], handle: (c: any) => void) {
+ const messages = this.mapMessagesR1(input);
+ const truncated = this.truncateHistory(messages);
+ await this.apiCallStream(truncated, handle);
+ }
+
+ private truncateHistory(messages: Message[]): Message[] {
+ const totalTokens = messages.reduce((total, message) => {
+ return total + this.tokenizer(message.content as string);
+ }, 0);
+ while (totalTokens > this.maxTokens && messages.length > 1) {
+ // Always keep the system message if it exists
+ const startIndex = messages[0].role === "system" ? 1 : 0;
+ messages.splice(startIndex, 1);
+ }
+ return messages;
+ }
+
+ private async apiCall(messages: Message[]): AsyncRes<OChoice[]> {
+ try {
+ const completion = await this.openai.chat.completions.create({
+ temperature: 1.3,
+ model: this.model,
+ messages,
+ max_tokens: RESPONSE_LENGTH,
+ });
+ if (!completion) return { error: "null response from openai" };
+ return { ok: completion.choices };
+ } catch (e) {
+ console.log(e, "error in openai api");
+ return { error: `${e}` };
+ }
+ }
+
+ private async apiCallStream(
+ messages: Message[],
+ handle: (c: string) => void,
+ ): Promise<void> {
+ try {
+ const stream = await this.openai.chat.completions.create({
+ temperature: 1.3,
+ model: this.model,
+ messages,
+ max_tokens: RESPONSE_LENGTH,
+ stream: true,
+ });
+
+ for await (const chunk of stream) {
+ for (const choice of chunk.choices) {
+ console.log({ choice });
+ if (!choice.delta) continue;
+ const cont = choice.delta.content;
+ if (!cont) continue;
+ handle(cont);
+ }
+ }
+ } catch (e) {
+ console.log(e, "error in openai api");
+ handle(`Error streaming OpenAI, ${e}`);
+ }
+ }
+
+ // assistant
+ async assistant() {
+ const assistant = await this.openai.beta.assistants.create({
+ name: "Literature professor",
+ instructions:
+ "You are a professor of literature. Use your knowledge to analyze large pieces of text and answer questions from your users.",
+ model: this.model,
+ tools: [{ type: "file_search" }],
+ temperature: 0.7,
+ response_format: { type: "text" },
+ });
+ const vector_store = await this.openai.beta.vectorStores.create({
+ name: "docs",
+ });
+ const tool_resources = {
+ file_search: { vector_store_ids: [vector_store.id] },
+ };
+ const tant = await this.openai.beta.assistants.update(assistant.id, {
+ tool_resources,
+ });
+ const thread = await this.openai.beta.threads.create();
+ const msg = await this.openai.beta.threads.messages.create(thread.id, {
+ role: "user",
+ content:
+ "Greetings, pleasure to meet. Let's get started if you don't mind",
+ });
+ const run = await this.openai.beta.threads.runs.create(thread.id, {
+ assistant_id: assistant.id,
+ instructions: "be nice",
+ });
+ while (run.status === "in_progress") {
+ console.log({ run });
+ }
+ }
+ async lookatFile(fo: FileObject) {
+ const tant = await this.openai.beta.assistants.create({
+ name: "Literature professor",
+ instructions:
+ "You are a professor of literature. Use your knowledge to analyze large pieces of text and answer questions from your users.",
+ model: this.model,
+ tools: [{ type: "file_search" }],
+ temperature: 0.7,
+ response_format: { type: "text" },
+ });
+ const thread = await this.openai.beta.threads.create();
+ await this.openai.beta.threads.messages.create(thread.id, {
+ role: "user",
+ content:
+ "Greetings, pleasure to meet. Let's get started if you don't mind. Look at this file and summarize its contents",
+ attachments: [{ file_id: fo.id, tools: [{ type: "file_search" }] }],
+ });
+ const run = await this.openai.beta.threads.runs.createAndPoll(thread.id, {
+ assistant_id: tant.id,
+ });
+ console.log({ run });
+ const msgs = await this.openai.beta.threads.messages.list(run.thread_id);
+ console.log({ msgs });
+ for (let m of msgs.data) {
+ console.log(m, "message on thread");
+ }
+ }
+
+ async uploadFile(res: Response) {
+ // const ff = fs.createReadStream("./lol")
+ const file = await this.openai.files.create({
+ file: res,
+ purpose: "assistants",
+ });
+ console.log({ file }, "uploaded");
+ return file;
+
+ // {
+ // "id": "file-abc123",
+ // "object": "file",
+ // "bytes": 120000,
+ // "created_at": 1677610602,
+ // "filename": "mydata.jsonl",
+ // "purpose": "fine-tune",
+ // }
+ }
+
+ // async analyzeFile(){
+ // const huh = await this.openai.beta.vectorStores.files.uploadAndPoll()
+ // }
+
+ // mcp
+
+ async mcp() {
+ const res = await fetch("http://localhost:8900/list");
+ const list = await res.json();
+ this.tryTools(list);
+ }
+
+ async tryTools(tools: OpenAI.Chat.Completions.ChatCompletionTool[]) {
+ const messages: Message[] = [
+ { role: "user", content: "What's on my twitter timeline right now?" },
+ ];
+ const completion = await this.openai.chat.completions.create({
+ model: "gpt-4o-2024-11-20",
+ messages,
+ tools,
+ });
+ if (!completion) return { error: "null response from openai" };
+
+ for (let choice of completion.choices) {
+ console.log({ choice });
+ if (choice.message.tool_calls) {
+ const instance = new OpenAIToolUse(
+ this.openai,
+ "gpt-4o-2024-11-20",
+ tools,
+ choice.message,
+ choice.message.tool_calls,
+ );
+ }
+ }
+ }
+}
diff --git a/packages/ai/src/openai_tools.ts b/packages/ai/src/openai_tools.ts
new file mode 100644
index 0000000..feb2e4a
--- /dev/null
+++ b/packages/ai/src/openai_tools.ts
@@ -0,0 +1,66 @@
+import type OpenAI from "openai";
+import type { Result } from "./types";
+type ToolCall = OpenAI.Chat.Completions.ChatCompletionMessageToolCall;
+
+type Tool = OpenAI.Chat.Completions.ChatCompletionTool;
+type ToolMsg = OpenAI.Chat.Completions.ChatCompletionToolMessageParam;
+
+type Message = OpenAI.Chat.Completions.ChatCompletionMessage;
+
+export default class OpenAIToolUse {
+ api;
+ model;
+ socket;
+ tools;
+ message;
+ calls;
+ res: ToolMsg | null = null;
+ constructor(
+ api: OpenAI,
+ model: string,
+ tools: Tool[],
+ message: Message,
+ calls: ToolCall[],
+ ) {
+ this.api = api;
+ this.model = model;
+ this.socket = new WebSocket("http://localhost:8900");
+ this.tools = tools;
+ this.message = message;
+ this.calls = calls;
+ for (let c of calls) {
+ console.log({ c });
+ }
+ this.wsHandlers();
+ }
+ wsHandlers() {
+ this.socket.addEventListener("open", (_data) => {
+ this.handleToolCalls();
+ });
+ this.socket.addEventListener("message", (ev) => {
+ const j = JSON.parse(ev.data);
+ if ("functionRes" in j) this.handleRes(j.functionRes);
+ });
+ }
+ handleToolCalls() {
+ for (let c of this.calls) this.socket.send(JSON.stringify({ call: c }));
+ }
+ async handleRes(res: Result<ToolMsg>) {
+ if ("error" in res) {
+ console.log("TODO");
+ return;
+ }
+ this.res = res.ok;
+ const messages = [this.message, res.ok];
+ console.log({ messages }, "almost there");
+ const completion = await this.api.chat.completions.create({
+ model: this.model,
+ messages,
+ tools: this.tools,
+ });
+ console.log({ completion });
+ for (let choice of completion.choices) {
+ console.log({ choice });
+ }
+ }
+}
diff --git a/packages/ai/src/prompts.ts b/packages/ai/src/prompts.ts
new file mode 100644
index 0000000..60e8c0d
--- /dev/null
+++ b/packages/ai/src/prompts.ts
@@ -0,0 +1,14 @@
+export const yagoSys =
+ "You are a helpful assistant of humans engaged in high stakes work. We call you Yagobot. Your user's name will appear in the 'name' field of this message. Please be brief but intelligent in your answers. Be civil but not overly polite, always tell the truth even if inconvenient. Address your user by his name.";
+
+export const biaSys =
+ "You are Yagobot, an extremely helpful assistant in charge of attending to a new mother to all her needs. Her name is Bia and she would like you to address her in both Thai and English at all times. Her husband will show up now and then, he's cool too.";
+
+export const GUEST_SYS =
+ "You are Yagobot, a helpful assistant with vast knowledge of everything there is to now in several languages. You are responding to a guest user now, be polite, but friendly and brief. Get to the point and strive to be both cool and useful. Respond in the language in which you were addressed.";
+
+export const LEO_SYS = `You are Yagobot, a super advanced tutor AI to help the children of foreign elites with the best education in the world. You are talking to Leo, a precocious mixed-race Japanese 11 year old. His Japanese name is 黎雄. He can't speak English well but he can understand a bit. Please respond to him the same content thrice: in English first, then English but in IPA phonetic noting, then in Japanese. Try to be proactive and ask him questions yourself if you see he isn't talking much.`;
+
+export const SAYURI_SYS = `You are Yagobot, a super advanced tutor AI to help the children of foreign elites with the best education in the world. You are talking to Sayuri, a lovely mixed-race Japanese 9 year old. Her Japanese name is 紗悠里. She can't speak English well but she can understand a bit. Please respond to her the same content thrice: in English first, then English but in IPA phonetic noting, then in Japanese. Try to be proactive and ask him questions yourself if you see she isn't talking much.`;
+
+export const BOOKWORM_SYS = `You are a professor of literature. Use your knowledge to analyze large pieces of text and answer questions from your users.`;
diff --git a/packages/ai/src/tts/eleven.ts b/packages/ai/src/tts/eleven.ts
new file mode 100644
index 0000000..c870b11
--- /dev/null
+++ b/packages/ai/src/tts/eleven.ts
@@ -0,0 +1,20 @@
+import { ElevenLabsClient, play } from "@elevenlabs/elevenlabs-js";
+
+const elevenlabs = new ElevenLabsClient({
+ apiKey: Bun.env.ELEVEN_KEY!, // Defaults to process.env.ELEVENLABS_API_KEY
+});
+
+const models = await elevenlabs.models.list();
+for (const model of models) {
+ const langs = model.languages || [];
+ for (const lang of langs) {
+ if (lang.name === "Thai") console.log(model.modelId);
+ }
+}
+// ONLY eleven_v3 has Thai!
+// const audio = await elevenlabs.textToSpeech.convert("Xb7hH8MSUJpSbSDYk0k2", {
+// text: "Hello! 你好! Hola! नमस्ते! Bonjour! こんにちは! مرحبا! 안녕하세요! Ciao! Cześć! Привіт! வணக்கம்!",
+// modelId: "eleven_multilingual_v2",
+// });
+
+// await play(audio);
diff --git a/packages/ai/src/tts/minimax.ts b/packages/ai/src/tts/minimax.ts
new file mode 100644
index 0000000..4421f94
--- /dev/null
+++ b/packages/ai/src/tts/minimax.ts
@@ -0,0 +1,107 @@
+// https://platform.minimax.io/docs/api-reference/speech-t2a-async-create/
+//
+//
+//
+
+const text = `สำนักข่าวต่างประเทศรายงานเมื่อ 18 พ.ย. 2568 ว่า เจ้าหน้าที่กู้ภัยของประเทศชิลี กำลังดำเนินการค้นหากลุ่มนักท่องเที่ยวที่สูญหายไปในพายุหิมะรุนแรงซึ่งเกิดขึ้นที่ อุทยานแห่งชาติ “ตอร์เรส เดล ไพเน” ในภูมิภาคปาตาโกเนีย ทางตอนใต้ของชิลี หลังพายุทำให้มีผู้เสียชีวิตแล้วอย่างน้อย 5 ศพ`;
+// const text = `So I start using it for my project and after about 20 mins - oh, no. Out of credits.
+// I didn't even get to try a single Gemini 3 prompt. I was out of credits before my first had completed. I guess I've burned through the free tier in some other app but the error message gave me no clues. As far as I can tell there's no link to give Google my money in the app. Maybe they think they have enough.
+
+// After switching to gpt-oss:120b it did some things quite well, and the annotation feature in the plan doc is really nice. It has potential but I suspect it's suffering from Google's typical problem that it's only really been tested on Googlers.`;
+const model = "speech-2.6-hd";
+const voice1 = "Thai_male_1_sample8";
+const voice2 = "Thai_male_2_sample2";
+const voice3 = "Thai_female_1_sample1";
+const voice4 = "Thai_female_2_sample2";
+const params = {
+ model,
+ language_boost: "auto",
+ voice_setting: { voice_id: voice1, speed: 1, vol: 1, pitch: 1 },
+ pronunciation_dct: { tone: ["lol, lmao"] },
+ audio_setting: {
+ audio_sample_rate: 32000,
+ bitrate: 128_000,
+ format: "mp3",
+ channel: 2,
+ },
+ voice_modify: {
+ pitch: 0,
+ intensity: 0,
+ timbre: 0,
+ sound_Effects: "spacious_echo",
+ },
+};
+
+async function getVoices() {
+ const endpoint = "/get_voice";
+ const body = { voice_type: "all" };
+ return await post(endpoint, body);
+}
+async function tts() {
+ const endpoint = "/t2a_v2";
+ const body = { text, stream: false, ...params };
+ return await post(endpoint, body);
+}
+async function ws() {
+ const url = "wss://api.minimax.io/ws/v1/t2a_v2";
+ const event = "task_start";
+
+ const headers = {
+ Authorization: `Bearer ${Bun.env.MINIMAX_API_KEY!}`,
+ };
+ const socket = new WebSocket(url, { headers });
+ const body = { event, ...params };
+ const body2 = { event: "task_continue", text };
+ socket.send(JSON.stringify(body));
+ // const event = "task_continue";
+ // const event = "task_finish";
+}
+async function tts_async() {
+ const body = {
+ text,
+ ...params,
+ };
+ return await post("/t2a_async_v2", body);
+}
+async function post(path: string, body: any) {
+ const url = "https://api.minimax.io/v1" + path;
+ const options = {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${Bun.env.MINIMAX_API_KEY2!}`,
+ },
+ body: JSON.stringify(body),
+ };
+
+ try {
+ const response = await fetch(url, options);
+ const data = await response.json();
+ return data;
+ } catch (error) {
+ console.error(error);
+ }
+}
+async function get(path: string) {
+ const url = "https://api.minimax.io/v1" + path;
+ const options = {
+ headers: {
+ Authorization: `Bearer ${Bun.env.MINIMAX_API_KEY!}`,
+ },
+ };
+
+ try {
+ const response = await fetch(url, options);
+ const data = await response.json();
+ console.log(data);
+ } catch (error) {
+ console.error(error);
+ }
+}
+
+import fs from "node:fs";
+const res = await tts();
+const audio = res.data.audio;
+const audioBuffer = Buffer.from(audio, "hex");
+const filename = "output.mp3";
+fs.writeFileSync(filename, audioBuffer);
diff --git a/packages/ai/src/tts/output.mp3 b/packages/ai/src/tts/output.mp3
new file mode 100644
index 0000000..9f22e3a
--- /dev/null
+++ b/packages/ai/src/tts/output.mp3
Binary files differ
diff --git a/packages/ai/src/types/index.ts b/packages/ai/src/types/index.ts
new file mode 100644
index 0000000..24db77b
--- /dev/null
+++ b/packages/ai/src/types/index.ts
@@ -0,0 +1,56 @@
+import type { ResponseInputFile } from "openai/resources/responses/responses.js";
+import type { AsyncRes } from "@sortug/lib";
+export type ChatMessage = {
+ author: string;
+ text: string;
+ sent: number;
+ reasoning?: string;
+};
+
+export type InputToken =
+ | { text: string }
+ | { img: string }
+ | { file: ResponseInputFile }
+ | { tools: ToolUseInput[] };
+export type ToolUseInput = any; // TODO
+// me
+export type RequestOptions = {
+ textOutput: boolean;
+};
+export const defaultOptions: RequestOptions = {
+ textOutput: true,
+};
+// openai
+export type ContentType = { text: string } | { audio: Response };
+
+export interface AIModelAPI {
+ setModel: (model: string) => void;
+ tokenizer: (text: string) => number;
+ maxTokens: number;
+
+ send: (
+ input: string | InputToken[],
+ systemPrompt?: string,
+ ) => AsyncRes<string>;
+ stream: (
+ input: string | InputToken[],
+ handler: (data: string) => void,
+ systemPrompt?: string,
+ ) => void;
+}
+
+export type LLMChoice =
+ | { gemini: string }
+ | { claude: string }
+ | { chatgpt: string }
+ | { grok: string }
+ | { deepseek: string }
+ | { kimi: string }
+ | {
+ openai: {
+ url: string;
+ apiKey: string;
+ model: string;
+ allowBrowser?: boolean;
+ };
+ };
diff --git a/packages/ai/src/types/mtproto.ts b/packages/ai/src/types/mtproto.ts
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/packages/ai/src/types/mtproto.ts