Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4203cb34ef | |||
| d42c240362 | |||
| c1a16096ae | |||
| ff0ee0b60e | |||
| 0a6f1e4d62 | |||
| 08a351e028 | |||
| 85c01d3ef1 | |||
| 5826573d5c | |||
| 797a40a566 | |||
| 7308927a3c | |||
| 04f038ba65 | |||
| d42f58d710 | |||
| 878a8794ee | |||
| 3f1289d993 | |||
| 077f75cdd9 | |||
| 566d84fd7a | |||
| 4230b534fc | |||
| 119f8472f2 | |||
| 9c04e58c63 | |||
| 7fbb42c26a | |||
| be08db8e2c | |||
| 497f051c62 | |||
| 62fbe73b22 | |||
| d53b1c6328 | |||
| 89619e211e | |||
| afc6653364 | |||
| 68e72445a2 | |||
| 1aa6cdf329 | |||
| d022a5ef4d | |||
| a1d438a20a | |||
| 52a9e3aaa4 | |||
| a7aec4ee29 | |||
| dda2d4c2a3 | |||
| 58e0e488e4 | |||
| 8dfcd06752 | |||
| 14f6cdd313 | |||
| 73d6ee0f2a | |||
| bee4085666 | |||
| 3b5c71de7c | |||
| 8229e02a52 | |||
| a6fb8ae828 | |||
| d1230bcaad | |||
| 2d49c9aa80 | |||
| 9a39f00f94 | |||
| 436757daad | |||
| 69b3297bb3 | |||
| 710c6ce52c | |||
| 4ac3036000 | |||
| 3121d542d4 |
@@ -119,7 +119,7 @@ const ai = new Ai({
|
||||
system: 'You are a helpful assistant.',
|
||||
compress: {max: 90_000, min: 50_000}, // Compress chat history to min tokens when max is reached
|
||||
temperature: 0.8,
|
||||
max_tokens: 100_000,
|
||||
maxTokens: 100_000,
|
||||
memoryModel: 'gpt-4o', // Cheap model for managing memories in background, defaults to current model
|
||||
models: {
|
||||
'claude-3-5-sonnet': {proto: 'anthropic', token: process.env.ANTHROPIC_TOKEN},
|
||||
@@ -186,7 +186,7 @@ console.log(chunks);
|
||||
|
||||
// Manually compile history into memories at end of conversation
|
||||
// Happens automatically when coverstaions are compressed
|
||||
await ai.language.updateMemory(history, memory);
|
||||
await ai.language.memorize(history, memory);
|
||||
|
||||
// Summarize text
|
||||
const summary = await ai.language.summarize(longText, 200);
|
||||
|
||||
25
main.mjs
25
main.mjs
@@ -1,25 +0,0 @@
|
||||
import {Ai} from './dist/index.mjs';
|
||||
|
||||
const ai = new Ai({
|
||||
path: './',
|
||||
llm: {
|
||||
system: 'You are a testbed for developing an AI library',
|
||||
models: {
|
||||
'qwen/qwen3.5-9b': {proto: 'openai', host: 'http://127.0.0.1:1234/v1'}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const skills = [{
|
||||
name: 'Momentum',
|
||||
description: 'Learn how to use the Momentum API',
|
||||
content: 'You can initialize it with: new Momentum(url);'
|
||||
}];
|
||||
|
||||
const history = [], memory = [];
|
||||
await ai.language.ask('My favorite color is red', {history, memory});
|
||||
await ai.language.updateMemory(history, memory);
|
||||
|
||||
history.splice(0, history.length);
|
||||
console.log(await ai.language.ask('Whats my favorite color?', {history, memory}));
|
||||
console.log(history);
|
||||
724
package-lock.json
generated
724
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ztimson/ai-utils",
|
||||
"version": "1.0.3",
|
||||
"version": "1.6.8",
|
||||
"description": "AI Utility library",
|
||||
"author": "Zak Timson",
|
||||
"license": "MIT",
|
||||
@@ -26,12 +26,13 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.102.0",
|
||||
"@tensorflow/tfjs": "^4.22.0",
|
||||
"@huggingface/transformers": "^4.2.0",
|
||||
"@tensorflow/tfjs": "^4.22.0",
|
||||
"@ztimson/node-utils": "^1.0.7",
|
||||
"@ztimson/utils": "^0.29.4",
|
||||
"@ztimson/utils": "^0.30.8",
|
||||
"cheerio": "^1.2.0",
|
||||
"openai": "^6.42.0",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"tesseract.js": "^7.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import * as os from 'node:os';
|
||||
import LLM, {AnthropicConfig, OllamaConfig, OpenAiConfig, LLMRequest} from './llm';
|
||||
import LLM, {AnthropicConfig, OpenAiConfig, LLMRequest} from './llm';
|
||||
import { Audio } from './audio.ts';
|
||||
import {Vision} from './vision.ts';
|
||||
|
||||
export type AbortablePromise<T> = Promise<T> & {
|
||||
abort: () => any
|
||||
abort: (keep?: boolean) => any
|
||||
};
|
||||
|
||||
export type AiOptions = {
|
||||
@@ -18,7 +18,7 @@ export type AiOptions = {
|
||||
embedder?: string;
|
||||
/** Large language models, first is default */
|
||||
llm?: Omit<LLMRequest, 'model'> & {
|
||||
models: {[model: string]: AnthropicConfig | OllamaConfig | OpenAiConfig};
|
||||
models: {[model: string]: AnthropicConfig | OpenAiConfig};
|
||||
}
|
||||
/** OCR model: eng, eng_best, eng_fast */
|
||||
ocr?: string;
|
||||
|
||||
174
src/antrhopic.ts
174
src/antrhopic.ts
@@ -1,63 +1,65 @@
|
||||
import {Anthropic as anthropic} from '@anthropic-ai/sdk';
|
||||
import {findByProp, objectMap, JSONSanitize, JSONAttemptParse} from '@ztimson/utils';
|
||||
import {findByProp, objectMap, JSONSanitize, JSONAttemptParse, makeArray} from '@ztimson/utils';
|
||||
import {AbortablePromise, Ai} from './ai.ts';
|
||||
import {LLMMessage, LLMRequest} from './llm.ts';
|
||||
import {LLMProvider} from './provider.ts';
|
||||
import {TokenPool} from './token-pool.ts';
|
||||
import {convertSchema} from './tools.ts';
|
||||
|
||||
export class Anthropic extends LLMProvider {
|
||||
client!: anthropic;
|
||||
private clients = new Map<string, anthropic>();
|
||||
tokenPool!: TokenPool;
|
||||
|
||||
constructor(public readonly ai: Ai, public readonly apiToken: string, public model: string) {
|
||||
constructor(public readonly ai: Ai, public readonly apiToken: string | string[], public model: string) {
|
||||
super();
|
||||
this.client = new anthropic({apiKey: apiToken});
|
||||
this.tokenPool = new TokenPool(...makeArray(apiToken).filter(Boolean));
|
||||
}
|
||||
|
||||
private toStandard(history: any[]): LLMMessage[] {
|
||||
const timestamp = Date.now();
|
||||
const messages: LLMMessage[] = [];
|
||||
for(let h of history) {
|
||||
if(typeof h.content == 'string') {
|
||||
messages.push(<any>{timestamp, ...h});
|
||||
} else {
|
||||
const textContent = h.content?.filter((c: any) => c.type == 'text').map((c: any) => c.text).join('\n\n');
|
||||
if(textContent) messages.push({timestamp, role: h.role, content: textContent});
|
||||
h.content.forEach((c: any) => {
|
||||
if(c.type == 'tool_use') {
|
||||
messages.push({timestamp, role: 'tool', id: c.id, name: c.name, args: c.input, content: undefined});
|
||||
} else if(c.type == 'tool_result') {
|
||||
const m: any = messages.findLast(m => (<any>m).id == c.tool_use_id);
|
||||
if(m) m[c.is_error ? 'error' : 'content'] = c.content;
|
||||
private getClient(token: string): anthropic {
|
||||
let client = this.clients.get(token);
|
||||
if(!client) {
|
||||
client = new anthropic({apiKey: token});
|
||||
this.clients.set(token, client);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
return messages;
|
||||
return client;
|
||||
}
|
||||
|
||||
private fromStandard(history: LLMMessage[]): any[] {
|
||||
for(let i = 0; i < history.length; i++) {
|
||||
if(history[i].role == 'tool') {
|
||||
const h: any = history[i];
|
||||
history.splice(i, 1,
|
||||
private toWireContent(content: any): any {
|
||||
if(!Array.isArray(content)) return content;
|
||||
return content.map(c => c.type === 'image'
|
||||
? {type: 'image', source: {type: 'base64', media_type: c.mime, data: c.data}}
|
||||
: {type: 'text', text: c.text});
|
||||
}
|
||||
|
||||
/** Convert standard history -> Anthropic wire format */
|
||||
private toWire(history: LLMMessage[]): any[] {
|
||||
const wire: any[] = [];
|
||||
for(const h of history) {
|
||||
if(h.role === 'tool') {
|
||||
wire.push(
|
||||
{role: 'assistant', content: [{type: 'tool_use', id: h.id, name: h.name, input: h.args}]},
|
||||
{role: 'user', content: [{type: 'tool_result', tool_use_id: h.id, is_error: !!h.error, content: h.error || h.content}]}
|
||||
)
|
||||
i++;
|
||||
{role: 'user', content: [{type: 'tool_result', tool_use_id: h.id, is_error: !!h.error, content: h.error || h.content || ''}]}
|
||||
);
|
||||
} else {
|
||||
wire.push({role: h.role, content: this.toWireContent(h.content)});
|
||||
}
|
||||
}
|
||||
return history.map(({timestamp, ...h}) => h);
|
||||
return wire;
|
||||
}
|
||||
|
||||
ask(message: string, options: LLMRequest = {}): AbortablePromise<string> {
|
||||
ask(message: string, options: LLMRequest = {}): AbortablePromise<string | any> {
|
||||
const controller = new AbortController();
|
||||
return Object.assign(new Promise<any>(async (res) => {
|
||||
let history = this.fromStandard([...options.history || [], {role: 'user', content: message, timestamp: Date.now()}]);
|
||||
return Object.assign(new Promise<any>(async (res, rej) => {
|
||||
if(!options.history) options.history = [];
|
||||
const history = options.history;
|
||||
if(message) history.push({role: 'user', content: message, timestamp: Date.now()});
|
||||
|
||||
const tools = options.tools || this.ai.options.llm?.tools || [];
|
||||
const requestParams: any = {
|
||||
model: options.model || this.model,
|
||||
max_tokens: options.max_tokens || this.ai.options.llm?.max_tokens || 4096,
|
||||
max_tokens: options.maxTokens || this.ai.options.llm?.maxTokens || 4096,
|
||||
system: options.system || this.ai.options.llm?.system || '',
|
||||
temperature: options.temperature || this.ai.options.llm?.temperature || 0.7,
|
||||
temperature: options.temperature || this.ai.options.llm?.temperature || undefined,
|
||||
tools: tools.map(t => ({
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
@@ -65,75 +67,97 @@ export class Anthropic extends LLMProvider {
|
||||
type: 'object',
|
||||
properties: t.args ? objectMap(t.args, (key, value) => ({...value, required: undefined})) : {},
|
||||
required: t.args ? Object.entries(t.args).filter(t => t[1].required).map(t => t[0]) : []
|
||||
},
|
||||
fn: undefined
|
||||
}
|
||||
})),
|
||||
messages: history,
|
||||
stream: !!options.stream,
|
||||
};
|
||||
|
||||
let resp: any, isFirstMessage = true;
|
||||
if(options.schema) {
|
||||
requestParams.output_config = {format: {type: 'json_schema', schema: convertSchema(options.schema)}};
|
||||
}
|
||||
|
||||
try {
|
||||
let terminal = false;
|
||||
do {
|
||||
resp = await this.client.messages.create(requestParams).catch(err => {
|
||||
err.message += `\n\nMessages:\n${JSON.stringify(history, null, 2)}`;
|
||||
requestParams.messages = this.toWire(history.filter(h => h.role !== 'system'));
|
||||
|
||||
const callStart = Date.now();
|
||||
const resp: any = await this.tokenPool.run(token => this.getClient(token).messages.create(requestParams)).catch(err => {
|
||||
err.message += `\n\nMessages:\n${JSON.stringify(requestParams.messages, null, 2)}`;
|
||||
throw err;
|
||||
});
|
||||
|
||||
// Streaming mode
|
||||
let usage: any, content: any[] = [];
|
||||
if(options.stream) {
|
||||
if(!isFirstMessage) options.stream({text: '\n\n'});
|
||||
else isFirstMessage = false;
|
||||
resp.content = [];
|
||||
for await (const chunk of resp) {
|
||||
if(controller.signal.aborted) break;
|
||||
if(chunk.type === 'content_block_start') {
|
||||
if(chunk.content_block.type === 'text') {
|
||||
resp.content.push({type: 'text', text: ''});
|
||||
} else if(chunk.content_block.type === 'tool_use') {
|
||||
resp.content.push({type: 'tool_use', id: chunk.content_block.id, name: chunk.content_block.name, input: <any>''});
|
||||
}
|
||||
if(chunk.content_block.type === 'text') content.push({type: 'text', text: ''});
|
||||
else if(chunk.content_block.type === 'tool_use') content.push({type: 'tool_use', id: chunk.content_block.id, name: chunk.content_block.name, input: ''});
|
||||
} else if(chunk.type === 'content_block_delta') {
|
||||
if(chunk.delta.type === 'text_delta') {
|
||||
const text = chunk.delta.text;
|
||||
resp.content.at(-1).text += text;
|
||||
options.stream({text});
|
||||
content.at(-1).text += chunk.delta.text;
|
||||
options.stream({text: chunk.delta.text});
|
||||
} else if(chunk.delta.type === 'input_json_delta') {
|
||||
resp.content.at(-1).input += chunk.delta.partial_json;
|
||||
content.at(-1).input += chunk.delta.partial_json;
|
||||
}
|
||||
} else if(chunk.type === 'content_block_stop') {
|
||||
const last = resp.content.at(-1);
|
||||
if(last.input != null) last.input = last.input ? JSONAttemptParse(last.input, {}) : {};
|
||||
const last = content.at(-1);
|
||||
if(last?.type === 'tool_use') last.input = last.input ? JSONAttemptParse(last.input, {}) : {};
|
||||
} else if(chunk.type === 'message_delta') {
|
||||
if(chunk.usage) usage = chunk.usage;
|
||||
} else if(chunk.type === 'message_stop') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
usage = resp.usage;
|
||||
content = resp.content;
|
||||
}
|
||||
const duration = Date.now() - callStart;
|
||||
const tps = usage?.output_tokens && duration > 0 ? usage.output_tokens / (duration / 1000) : 0;
|
||||
|
||||
// Run tools
|
||||
const toolCalls = resp.content.filter((c: any) => c.type === 'tool_use');
|
||||
const toolCalls = content.filter((c: any) => c.type === 'tool_use');
|
||||
if(toolCalls.length && !controller.signal.aborted) {
|
||||
history.push({role: 'assistant', content: resp.content});
|
||||
const results = await Promise.all(toolCalls.map(async (toolCall: any) => {
|
||||
const tool = tools.find(findByProp('name', toolCall.name));
|
||||
if(options.stream) options.stream({tool: toolCall.name});
|
||||
if(!tool) return {tool_use_id: toolCall.id, is_error: true, content: 'Tool not found'};
|
||||
const text = content.filter((c: any) => c.type === 'text').map((c: any) => c.text).join('\n\n').trim();
|
||||
if(text) history.push({role: 'assistant', content: text, timestamp: Date.now(), duration, tps});
|
||||
|
||||
const entries = toolCalls.map((tc: any) => {
|
||||
const entry: any = {role: 'tool', id: tc.id, name: tc.name, args: tc.input, content: undefined, timestamp: Date.now()};
|
||||
history.push(entry);
|
||||
return {tc, entry};
|
||||
});
|
||||
|
||||
await Promise.all(entries.map(async ({tc, entry}: any) => {
|
||||
const tool = tools.find(findByProp('name', tc.name));
|
||||
if(options.stream) options.stream({tool: tc.name});
|
||||
if(!tool) { entry.error = 'Tool not found'; return; }
|
||||
try {
|
||||
const result = await tool.fn(toolCall.input, options?.stream, this.ai);
|
||||
return {type: 'tool_result', tool_use_id: toolCall.id, content: typeof result == 'object' ? JSONSanitize(result) : result};
|
||||
const toolStream = options.stream && ((chunk: any) => {
|
||||
if(chunk.done) { terminal = true; return; }
|
||||
options.stream!(chunk);
|
||||
});
|
||||
const result = await tool.fn(entry.args, toolStream, this.ai, tc.id);
|
||||
entry.content = typeof result === 'object' ? JSONSanitize(result) : result;
|
||||
} catch(err: any) {
|
||||
return {type: 'tool_result', tool_use_id: toolCall.id, is_error: true, content: err?.message || err?.toString() || 'Unknown'};
|
||||
entry.error = err?.message || err?.toString() || 'Unknown';
|
||||
}
|
||||
}));
|
||||
history.push({role: 'user', content: results});
|
||||
requestParams.messages = history;
|
||||
} else {
|
||||
terminal = true;
|
||||
const text = content.filter((c: any) => c.type === 'text').map((c: any) => c.text).join('\n\n').trim();
|
||||
if(text) history.push({role: 'assistant', content: text, timestamp: Date.now(), duration, tps});
|
||||
}
|
||||
} while (!controller.signal.aborted && resp.content.some((c: any) => c.type === 'tool_use'));
|
||||
history.push({role: 'assistant', content: resp.content.filter((c: any) => c.type == 'text').map((c: any) => c.text).join('\n\n')});
|
||||
history = this.toStandard(history);
|
||||
} while(!terminal && !controller.signal.aborted);
|
||||
|
||||
if(options.stream) options.stream({done: true});
|
||||
if(options.history) options.history.splice(0, options.history.length, ...history);
|
||||
res(history.at(-1)?.content);
|
||||
|
||||
const turnStart = history.map(h => h.role).lastIndexOf('user');
|
||||
const finalContent = history.slice(turnStart + 1).reduce((str, h) => h.role === 'assistant' ? str + (h.content || '') : str, '').trim();
|
||||
res(options.schema ? JSONAttemptParse(finalContent, finalContent) : finalContent);
|
||||
} catch(err) {
|
||||
rej(err);
|
||||
}
|
||||
}), {abort: () => controller.abort()});
|
||||
}
|
||||
}
|
||||
|
||||
13
src/audio.ts
13
src/audio.ts
@@ -141,11 +141,18 @@ print(json.dumps(segments))
|
||||
if(!llm) return transcript;
|
||||
let chunks = this.ai.language.chunk(transcript, 500, 0);
|
||||
if(chunks.length > 4) chunks = [...chunks.slice(0, 3), <string>chunks.at(-1)];
|
||||
const names = await this.ai.language.json(chunks.join('\n'), '{1: "Detected Name", 2: "Second Name"}', {
|
||||
system: 'Use the following transcript to identify speakers. Only identify speakers you are positive about, dont mention speakers you are unsure about in your response',
|
||||
await this.ai.language.ask(chunks.join('\n'), {
|
||||
system: 'Read the following transcript and attempt to identify every speaker. For every positively identified speaker, call the \`identify\` tool with the speaker\'s ID number & the identified name exactly once.',
|
||||
temperature: 0.1,
|
||||
tools: [
|
||||
{name: 'identify', description: 'Identify a speaker', args: {
|
||||
speaker: {type: 'number', description: 'Speaker number', required: true},
|
||||
name: {type: 'string', description: 'Inferred name', required: true},
|
||||
}, fn: ({speaker, name}) => {
|
||||
transcript = transcript.replaceAll(`[Speaker ${speaker}]`, `[${name}]`);
|
||||
}}
|
||||
]
|
||||
});
|
||||
Object.entries(names).forEach(([speaker, name]) => transcript = transcript.replaceAll(`[Speaker ${speaker}]`, `[${name}]`));
|
||||
return transcript;
|
||||
}
|
||||
|
||||
|
||||
135
src/helpers.ts
Normal file
135
src/helpers.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import {Memory, MemoryCache} from './memory.ts';
|
||||
|
||||
export type MemoryNode = {
|
||||
name: string;
|
||||
missing: boolean;
|
||||
links: string[];
|
||||
backlinks: string[];
|
||||
}
|
||||
|
||||
export function extractLinks(content: string): string[] {
|
||||
if (!content) return [];
|
||||
const matches = content.matchAll(/\[\[([^\]|]+)(?:\|[^\]]*)?\]\]/g);
|
||||
return [...new Set([...matches].map(m => m[1].trim()))];
|
||||
}
|
||||
|
||||
/**
|
||||
* Incrementally patch the graph for a set of changed memories, instead of
|
||||
* re-scanning every document. Only the changed memories' own content is
|
||||
* re-parsed for links; affected targets have their backlinks patched.
|
||||
* Does NOT handle node deletion — full rebuildGraph() is still required
|
||||
* when a memory is removed, since that needs a backlink sweep across
|
||||
* everyone who might reference it.
|
||||
*/
|
||||
export function patchGraph(mems: Memory[], nodes: MemoryNode[], changed: Memory[]): MemoryNode[] {
|
||||
const nameSet = new Set(mems.map(m => m.name));
|
||||
const byName = new Map(nodes.map(n => [n.name, n]));
|
||||
|
||||
const ensureNode = (name: string): MemoryNode => {
|
||||
let n = byName.get(name);
|
||||
if (!n) {
|
||||
n = {name, missing: !nameSet.has(name), links: [], backlinks: []};
|
||||
byName.set(name, n);
|
||||
}
|
||||
return n;
|
||||
};
|
||||
|
||||
for (const m of changed) {
|
||||
const node = ensureNode(m.name);
|
||||
node.missing = false; // real memory, promotes any pre-existing ghost entry
|
||||
const oldLinks = m.links ?? [];
|
||||
const newLinks = extractLinks(m.content).filter(l => l !== m.name);
|
||||
|
||||
for (const target of oldLinks.filter(l => !newLinks.includes(l))) {
|
||||
const t = byName.get(target);
|
||||
if (!t) continue;
|
||||
t.backlinks = t.backlinks.filter(n => n !== m.name);
|
||||
if (t.missing && !t.backlinks.length) byName.delete(target); // fully dereferenced ghost
|
||||
}
|
||||
for (const target of newLinks.filter(l => !oldLinks.includes(l))) {
|
||||
const t = ensureNode(target);
|
||||
if (!t.backlinks.includes(m.name)) t.backlinks.push(m.name);
|
||||
}
|
||||
|
||||
m.links = newLinks;
|
||||
node.links = newLinks;
|
||||
}
|
||||
|
||||
for (const m of mems) {
|
||||
const n = byName.get(m.name);
|
||||
if (n) m.backlinks = n.backlinks;
|
||||
}
|
||||
|
||||
return [...byName.values()];
|
||||
}
|
||||
|
||||
export function rebuildGraph(memories: Memory[] | MemoryCache): MemoryNode[] {
|
||||
const mems = memories instanceof MemoryCache ? memories.memories : memories;
|
||||
const nameSet = new Set(mems.map(m => m.name));
|
||||
|
||||
for (const m of mems) m.links = extractLinks(m.content).filter(l => l !== m.name);
|
||||
for (const m of mems) m.backlinks = [];
|
||||
for (const m of mems) {
|
||||
for (const link of m.links) {
|
||||
const target = mems.find(t => t.name === link);
|
||||
if (target) target.backlinks.push(m.name);
|
||||
}
|
||||
}
|
||||
|
||||
const nodes: MemoryNode[] = mems.map(m => ({
|
||||
name: m.name,
|
||||
missing: false,
|
||||
links: m.links,
|
||||
backlinks: m.backlinks,
|
||||
}));
|
||||
|
||||
const ghosts = new Set<string>();
|
||||
for (const node of nodes) {
|
||||
for (const link of node.links) {
|
||||
if (!nameSet.has(link)) ghosts.add(link);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
...nodes,
|
||||
...[...ghosts].map(name => ({
|
||||
name,
|
||||
missing: true,
|
||||
links: [],
|
||||
backlinks: nodes.filter(n => n.links.includes(name)).map(n => n.name),
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
export function renderMemoryGraph(nodes: MemoryNode[]): string {
|
||||
if (!nodes.length) return 'No memories yet.';
|
||||
|
||||
const groups = new Map<string, (MemoryNode & {label: string})[]>();
|
||||
for (const node of nodes) {
|
||||
const [prefix, ...rest] = node.name.split('/');
|
||||
const group = rest.length ? prefix : 'Root';
|
||||
const label = rest.length ? rest.join('/') : node.name;
|
||||
if (!groups.has(group)) groups.set(group, []);
|
||||
groups.get(group)!.push({...node, label});
|
||||
}
|
||||
|
||||
const ghostCount = nodes.filter(n => n.missing).length;
|
||||
const lines = [`Memory Graph (${nodes.length} nodes, ${ghostCount} ghost${ghostCount === 1 ? '' : 's'})`, ''];
|
||||
|
||||
for (const group of [...groups.keys()].sort()) {
|
||||
const items = groups.get(group)!.sort((a, b) => a.label.localeCompare(b.label));
|
||||
lines.push(`${group}/`);
|
||||
items.forEach((n, i) => {
|
||||
const last = i === items.length - 1;
|
||||
const branch = last ? '└─' : '├─';
|
||||
const pad = last ? ' ' : '│ ';
|
||||
const tag = n.missing ? ' (ghost)' : '';
|
||||
lines.push(` ${branch} ${n.label}${tag}`);
|
||||
if (n.links.length) lines.push(` ${pad} → ${n.links.join(', ')}`);
|
||||
if (n.backlinks.length) lines.push(` ${pad} ← ${n.backlinks.join(', ')}`);
|
||||
});
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
return lines.join('\n').trimEnd();
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
export * from './ai';
|
||||
export * from './antrhopic';
|
||||
export * from './audio';
|
||||
export * from './helpers';
|
||||
export * from './llm';
|
||||
export * from './memory';
|
||||
export * from './open-ai';
|
||||
export * from './provider';
|
||||
export * from './token-pool'
|
||||
export * from './tools';
|
||||
export * from './vision';
|
||||
|
||||
376
src/kd-tree.ts
Normal file
376
src/kd-tree.ts
Normal file
@@ -0,0 +1,376 @@
|
||||
export type DistanceMetric = "euclidean" | "cosine";
|
||||
|
||||
export interface KDPoint<T = unknown> {
|
||||
vector: number[];
|
||||
payload: T;
|
||||
}
|
||||
|
||||
export interface KNNResult<T = unknown> {
|
||||
point: KDPoint<T>;
|
||||
distance: number;
|
||||
}
|
||||
|
||||
interface KDNode<T> {
|
||||
point: KDPoint<T>;
|
||||
axis: number;
|
||||
left: KDNode<T> | null;
|
||||
right: KDNode<T> | null;
|
||||
deleted?: boolean;
|
||||
}
|
||||
|
||||
// ─── Distance helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
function euclidean(a: number[], b: number[]): number {
|
||||
let sum = 0;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
const d = a[i] - b[i];
|
||||
sum += d * d;
|
||||
}
|
||||
return Math.sqrt(sum);
|
||||
}
|
||||
|
||||
function cosine(a: number[], b: number[]): number {
|
||||
let dot = 0, normA = 0, normB = 0;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
dot += a[i] * b[i];
|
||||
normA += a[i] * a[i];
|
||||
normB += b[i] * b[i];
|
||||
}
|
||||
const denom = Math.sqrt(normA) * Math.sqrt(normB);
|
||||
return denom === 0 ? 1 : 1 - dot / denom; // distance = 1 - similarity
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the k closest candidates in memory, evicts the furthest when full
|
||||
*/
|
||||
class BoundedMaxHeap<T> {
|
||||
private heap: KNNResult<T>[] = [];
|
||||
|
||||
constructor(private readonly k: number) {}
|
||||
|
||||
get size(): number { return this.heap.length; }
|
||||
|
||||
get worstDistance(): number {
|
||||
return this.heap.length < this.k ? Infinity : this.heap[0].distance;
|
||||
}
|
||||
|
||||
push(item: KNNResult<T>): void {
|
||||
if (this.heap.length < this.k) {
|
||||
this.heap.push(item);
|
||||
this.bubbleUp(this.heap.length - 1);
|
||||
} else if (item.distance < this.heap[0].distance) {
|
||||
this.heap[0] = item;
|
||||
this.sinkDown(0);
|
||||
}
|
||||
}
|
||||
|
||||
toSortedArray(): KNNResult<T>[] {
|
||||
return [...this.heap].sort((a, b) => a.distance - b.distance);
|
||||
}
|
||||
|
||||
private bubbleUp(i: number): void {
|
||||
while (i > 0) {
|
||||
const parent = (i - 1) >> 1;
|
||||
if (this.heap[parent].distance >= this.heap[i].distance) break;
|
||||
[this.heap[parent], this.heap[i]] = [this.heap[i], this.heap[parent]];
|
||||
i = parent;
|
||||
}
|
||||
}
|
||||
|
||||
private sinkDown(i: number): void {
|
||||
const n = this.heap.length;
|
||||
while (true) {
|
||||
let largest = i;
|
||||
const l = 2 * i + 1, r = 2 * i + 2;
|
||||
if (l < n && this.heap[l].distance > this.heap[largest].distance) largest = l;
|
||||
if (r < n && this.heap[r].distance > this.heap[largest].distance) largest = r;
|
||||
if (largest === i) break;
|
||||
[this.heap[largest], this.heap[i]] = [this.heap[i], this.heap[largest]];
|
||||
i = largest;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* K-D Tree for efficient nearest-neighbor search over high-dimensional vectors / embeddings.
|
||||
*
|
||||
* Supports:
|
||||
* - Insertion of labeled points
|
||||
* - Lazy (tombstone) removal, physically purged on rebalance()
|
||||
* - k-nearest-neighbor (KNN) search
|
||||
* - Radius search (all points within a given distance)
|
||||
* - Euclidean and cosine distance metrics
|
||||
* - Bulk construction (balanced tree) for best query performance
|
||||
*/
|
||||
export class KDTree<T = unknown> {
|
||||
private root: KDNode<T> | null = null;
|
||||
private _size = 0;
|
||||
private _tombstones = 0;
|
||||
private readonly distanceFn: (a: number[], b: number[]) => number;
|
||||
|
||||
readonly dims: number;
|
||||
|
||||
/**
|
||||
* @param dims Dimensionality of all vectors (must be consistent).
|
||||
* @param metric Distance metric to use. Default: "euclidean".
|
||||
* @param points Optional initial set of points. Builds a balanced tree
|
||||
* in O(n log² n) — prefer this over inserting one-by-one
|
||||
* when you have a large corpus.
|
||||
*/
|
||||
constructor(
|
||||
dims: number,
|
||||
metric: DistanceMetric = "euclidean",
|
||||
points?: KDPoint<T>[]
|
||||
) {
|
||||
this.dims = dims;
|
||||
this.distanceFn = metric === "cosine" ? cosine : euclidean;
|
||||
|
||||
if (points && points.length > 0) {
|
||||
this.validateAll(points);
|
||||
this.root = this.buildBalanced([...points], 0);
|
||||
this._size = points.length;
|
||||
}
|
||||
}
|
||||
|
||||
/** Total number of live points stored in the tree (excludes tombstoned). */
|
||||
get size(): number { return this._size; }
|
||||
|
||||
/** Fraction of physical nodes that are tombstoned (pending removal on next rebalance). */
|
||||
get tombstoneRatio(): number {
|
||||
const total = this._size + this._tombstones;
|
||||
return total ? this._tombstones / total : 0;
|
||||
}
|
||||
|
||||
// ── Insertion ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Insert a single point. O(log n) average, O(n) worst case on skewed data.
|
||||
* For bulk loading prefer passing points to the constructor.
|
||||
*/
|
||||
insert(point: KDPoint<T>): void {
|
||||
this.validate(point);
|
||||
this.root = this.insertNode(this.root, point, 0);
|
||||
this._size++;
|
||||
}
|
||||
|
||||
// ── Removal ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Lazily remove all live points whose payload matches `predicate`.
|
||||
* O(n) traversal, but avoids a full tree rebuild. Call `rebalance()`
|
||||
* periodically (e.g. once tombstoneRatio crosses ~0.25) to reclaim space
|
||||
* and restore optimal query depth.
|
||||
* @returns number of points removed
|
||||
*/
|
||||
remove(predicate: (payload: T) => boolean): number {
|
||||
let removed = 0;
|
||||
const visit = (node: KDNode<T> | null): void => {
|
||||
if (!node) return;
|
||||
if (!node.deleted && predicate(node.point.payload)) {
|
||||
node.deleted = true;
|
||||
removed++;
|
||||
}
|
||||
visit(node.left);
|
||||
visit(node.right);
|
||||
};
|
||||
visit(this.root);
|
||||
this._size -= removed;
|
||||
this._tombstones += removed;
|
||||
return removed;
|
||||
}
|
||||
|
||||
// ── KNN search ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Find the k nearest live neighbors to `query`.
|
||||
* Returns results sorted by distance ascending.
|
||||
*/
|
||||
knn(query: number[], k: number): KNNResult<T>[] {
|
||||
if (k <= 0) throw new RangeError("k must be a positive integer");
|
||||
this.validateVector(query);
|
||||
|
||||
const heap = new BoundedMaxHeap<T>(k);
|
||||
this.searchKNN(this.root, query, k, heap, 0);
|
||||
return heap.toSortedArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Nearest single neighbor. Convenience wrapper around knn(query, 1).
|
||||
* Returns null if the tree is empty.
|
||||
*/
|
||||
nearest(query: number[]): KNNResult<T> | null {
|
||||
const results = this.knn(query, 1);
|
||||
return results[0] ?? null;
|
||||
}
|
||||
|
||||
// ── Radius search ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Return all live points whose distance to `query` is ≤ `radius`,
|
||||
* sorted by distance ascending.
|
||||
*/
|
||||
radiusSearch(query: number[], radius: number): KNNResult<T>[] {
|
||||
if (radius < 0) throw new RangeError("radius must be non-negative");
|
||||
this.validateVector(query);
|
||||
|
||||
const results: KNNResult<T>[] = [];
|
||||
this.searchRadius(this.root, query, radius, results, 0);
|
||||
results.sort((a, b) => a.distance - b.distance);
|
||||
return results;
|
||||
}
|
||||
|
||||
// ── Conversion ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Collect all live points in the tree (order not guaranteed). */
|
||||
toArray(): KDPoint<T>[] {
|
||||
const out: KDPoint<T>[] = [];
|
||||
this.collect(this.root, out);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the tree from its current live points as a balanced tree.
|
||||
* Physically purges tombstones and restores O(log n) query time.
|
||||
*/
|
||||
rebalance(): void {
|
||||
const points = this.toArray();
|
||||
this.root = points.length ? this.buildBalanced(points, 0) : null;
|
||||
this._size = points.length;
|
||||
this._tombstones = 0;
|
||||
}
|
||||
|
||||
// ── Private: build ─────────────────────────────────────────────────────────
|
||||
|
||||
private buildBalanced(points: KDPoint<T>[], depth: number): KDNode<T> {
|
||||
const axis = depth % this.dims;
|
||||
points.sort((a, b) => a.vector[axis] - b.vector[axis]);
|
||||
|
||||
const mid = Math.floor(points.length / 2);
|
||||
return {
|
||||
point: points[mid],
|
||||
axis,
|
||||
left: points.slice(0, mid).length
|
||||
? this.buildBalanced(points.slice(0, mid), depth + 1)
|
||||
: null,
|
||||
right: points.slice(mid + 1).length
|
||||
? this.buildBalanced(points.slice(mid + 1), depth + 1)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Private: insert ────────────────────────────────────────────────────────
|
||||
|
||||
private insertNode(
|
||||
node: KDNode<T> | null,
|
||||
point: KDPoint<T>,
|
||||
depth: number
|
||||
): KDNode<T> {
|
||||
if (node === null) {
|
||||
return { point, axis: depth % this.dims, left: null, right: null };
|
||||
}
|
||||
const axis = depth % this.dims;
|
||||
if (point.vector[axis] < node.point.vector[axis]) {
|
||||
node.left = this.insertNode(node.left, point, depth + 1);
|
||||
} else {
|
||||
node.right = this.insertNode(node.right, point, depth + 1);
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
// ── Private: KNN traversal ─────────────────────────────────────────────────
|
||||
|
||||
private searchKNN(
|
||||
node: KDNode<T> | null,
|
||||
query: number[],
|
||||
k: number,
|
||||
heap: BoundedMaxHeap<T>,
|
||||
depth: number
|
||||
): void {
|
||||
if (node === null) return;
|
||||
|
||||
if (!node.deleted) {
|
||||
const dist = this.distanceFn(query, node.point.vector);
|
||||
heap.push({ point: node.point, distance: dist });
|
||||
}
|
||||
|
||||
const axis = node.axis;
|
||||
const diff = query[axis] - node.point.vector[axis];
|
||||
const [near, far] = diff <= 0
|
||||
? [node.left, node.right]
|
||||
: [node.right, node.left];
|
||||
|
||||
this.searchKNN(near, query, k, heap, depth + 1);
|
||||
|
||||
// Only explore the far side if it could contain a closer point.
|
||||
// For cosine distance we can't prune by axis gap alone, so always explore.
|
||||
const shouldExplore =
|
||||
this.distanceFn === cosine
|
||||
? true
|
||||
: Math.abs(diff) < heap.worstDistance;
|
||||
|
||||
if (shouldExplore) {
|
||||
this.searchKNN(far, query, k, heap, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Private: radius traversal ──────────────────────────────────────────────
|
||||
|
||||
private searchRadius(
|
||||
node: KDNode<T> | null,
|
||||
query: number[],
|
||||
radius: number,
|
||||
results: KNNResult<T>[],
|
||||
depth: number
|
||||
): void {
|
||||
if (node === null) return;
|
||||
|
||||
if (!node.deleted) {
|
||||
const dist = this.distanceFn(query, node.point.vector);
|
||||
if (dist <= radius) {
|
||||
results.push({ point: node.point, distance: dist });
|
||||
}
|
||||
}
|
||||
|
||||
const axis = node.axis;
|
||||
const diff = query[axis] - node.point.vector[axis];
|
||||
const [near, far] = diff <= 0
|
||||
? [node.left, node.right]
|
||||
: [node.right, node.left];
|
||||
|
||||
this.searchRadius(near, query, radius, results, depth + 1);
|
||||
|
||||
const shouldExplore =
|
||||
this.distanceFn === cosine ? true : Math.abs(diff) <= radius;
|
||||
|
||||
if (shouldExplore) {
|
||||
this.searchRadius(far, query, radius, results, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Private: collect ───────────────────────────────────────────────────────
|
||||
|
||||
private collect(node: KDNode<T> | null, out: KDPoint<T>[]): void {
|
||||
if (node === null) return;
|
||||
if (!node.deleted) out.push(node.point);
|
||||
this.collect(node.left, out);
|
||||
this.collect(node.right, out);
|
||||
}
|
||||
|
||||
// ── Private: validation ────────────────────────────────────────────────────
|
||||
|
||||
private validateVector(v: number[]): void {
|
||||
if (v.length !== this.dims) {
|
||||
throw new TypeError(
|
||||
`Vector length ${v.length} does not match tree dimensionality ${this.dims}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private validate(point: KDPoint<T>): void {
|
||||
this.validateVector(point.vector);
|
||||
}
|
||||
|
||||
private validateAll(points: KDPoint<T>[]): void {
|
||||
for (const p of points) this.validate(p);
|
||||
}
|
||||
}
|
||||
489
src/llm.ts
489
src/llm.ts
@@ -1,24 +1,63 @@
|
||||
import {clean, makeUnique, snakeCase} from '@ztimson/utils';
|
||||
import {AbortablePromise, Ai} from './ai.ts';
|
||||
import {Anthropic} from './antrhopic.ts';
|
||||
import {OpenAi} from './open-ai.ts';
|
||||
import {LLMProvider} from './provider.ts';
|
||||
import {AiTool} from './tools.ts';
|
||||
import {AiTool, AiToolArg} from './tools.ts';
|
||||
import {fileURLToPath} from 'url';
|
||||
import {dirname, join} from 'path';
|
||||
import {spawn} from 'node:child_process';
|
||||
import {Memory, MemoryManager} from './memory.ts';
|
||||
import {Memory, MemoryCache, MemoryManager, MemoryOptions, stripHeader} from './memory.ts';
|
||||
import {mkdtempSync} from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import {tmpdir} from 'node:os';
|
||||
import {dirname, join, basename, extname} from 'path';
|
||||
import { PDFParse } from 'pdf-parse';
|
||||
|
||||
export type AnthropicConfig = {proto: 'anthropic', token: string};
|
||||
export type OllamaConfig = {proto: 'ollama', host: string};
|
||||
export type OpenAiConfig = {proto: 'openai', host?: string, token: string};
|
||||
const MAX_AGENT_DEPTH = 5;
|
||||
const PDF_OCR_PAGE_THRESHOLD = 12; // above this many pages, OCR scanned pages instead of feeding images to the model
|
||||
|
||||
export type AnthropicConfig = {proto: 'anthropic', token: string | string[]};
|
||||
export type OpenAiConfig = {proto: 'openai', host?: string, token: string | string[]};
|
||||
|
||||
export type Agent = {
|
||||
name: string;
|
||||
description?: string;
|
||||
model?: string | null;
|
||||
temperature?: number;
|
||||
system: string;
|
||||
delegate?: boolean;
|
||||
skills?: Skill[] | null;
|
||||
tools?: AiTool[] | null;
|
||||
mcp?: McpServer[] | null;
|
||||
agents?: string[] | null;
|
||||
}
|
||||
|
||||
export type LLMFile = {
|
||||
/** Path to file on disk */
|
||||
path?: string;
|
||||
/** File content: raw text, base64-encoded binary, or a Buffer */
|
||||
content?: string | Buffer;
|
||||
/** Original filename, used to infer type from extension */
|
||||
name?: string;
|
||||
/** Mime type override, inferred from extension if omitted */
|
||||
mime?: string;
|
||||
/** @internal set once extraction has run, skips re-processing next turn */
|
||||
extracted?: boolean;
|
||||
};
|
||||
|
||||
export type LLMMessage = {
|
||||
/** Message originator */
|
||||
role: 'assistant' | 'system' | 'user';
|
||||
/** Message content */
|
||||
content: string | any;
|
||||
/** Files attached to request */
|
||||
files?: LLMFile[];
|
||||
/** Timestamp */
|
||||
timestamp?: number;
|
||||
/** Response duration in ms */
|
||||
duration?: number;
|
||||
/** Tokens per second */
|
||||
tps?: number;
|
||||
} | {
|
||||
/** Tool call */
|
||||
role: 'tool';
|
||||
@@ -34,15 +73,21 @@ export type LLMMessage = {
|
||||
error?: undefined | string;
|
||||
/** Timestamp */
|
||||
timestamp?: number;
|
||||
/** Response duration in ms */
|
||||
duration?: number;
|
||||
/** Tokens per second */
|
||||
tps?: number;
|
||||
}
|
||||
|
||||
export type LLMRequest = {
|
||||
/** Return a parsed JSON object that matches the schema */
|
||||
schema?: AiToolArg;
|
||||
/** System prompt */
|
||||
system?: string;
|
||||
/** Message history */
|
||||
history?: LLMMessage[];
|
||||
/** Max tokens for request */
|
||||
max_tokens?: number;
|
||||
maxTokens?: number;
|
||||
/** 0 = Rigid Logic, 1 = Balanced, 2 = Hyper Creative **/
|
||||
temperature?: number;
|
||||
/** Available tools */
|
||||
@@ -54,13 +99,19 @@ export type LLMRequest = {
|
||||
/** Compress old messages in the chat to free up context */
|
||||
compress?: {max: number; min: number};
|
||||
/** User's memory documents - RAG injected automatically each turn */
|
||||
memory?: Memory[];
|
||||
memory?: Memory[] | MemoryCache | MemoryOptions;
|
||||
/** Model to use for memory operations */
|
||||
memoryModel?: string;
|
||||
/** Skill documents the AI can browse and read on demand */
|
||||
skills?: Skill[];
|
||||
/** MCP servers to connect and expose as tools */
|
||||
mcp?: McpServer[];
|
||||
/** Subagents exposed as delegatable/wrapped tools */
|
||||
agents?: Agent[];
|
||||
/** Attach files to request */
|
||||
files?: LLMFile[];
|
||||
/** @internal recursion guard for nested agent delegation */
|
||||
_agentDepth?: number;
|
||||
}
|
||||
|
||||
export type McpServer = {
|
||||
@@ -81,8 +132,12 @@ export type Skill = {
|
||||
content: string;
|
||||
}
|
||||
|
||||
|
||||
class LLM {
|
||||
private static AUDIO_EXT = ['wav','mp3','m4a','flac','ogg','aac','wma'];
|
||||
private static IMAGE_EXT = ['png','jpg','jpeg','bmp','gif','tiff','webp'];
|
||||
private static TEXT_EXT = ['txt','md','csv','json','xml','html','js','ts','py','yaml','yml','log'];
|
||||
private static PDF_EXT = ['pdf'];
|
||||
|
||||
private memoryManager!: MemoryManager;
|
||||
|
||||
defaultModel!: string;
|
||||
@@ -93,12 +148,171 @@ class LLM {
|
||||
Object.entries(ai.options.llm.models).forEach(([model, config]) => {
|
||||
if(!this.defaultModel) this.defaultModel = model;
|
||||
if(config.proto == 'anthropic') this.models[model] = new Anthropic(this.ai, config.token, model);
|
||||
else if(config.proto == 'ollama') this.models[model] = new OpenAi(this.ai, config.host, 'not-needed', model);
|
||||
else if(config.proto == 'openai') this.models[model] = new OpenAi(this.ai, config.host || null, config.token, model);
|
||||
});
|
||||
this.memoryManager = new MemoryManager(this);
|
||||
}
|
||||
|
||||
private async loadBuffer(file: LLMFile, asText: boolean): Promise<Buffer> {
|
||||
if(file.path) return fs.readFile(file.path);
|
||||
if(Buffer.isBuffer(file.content)) return file.content;
|
||||
if(typeof file.content === 'string') return Buffer.from(file.content, asText ? 'utf-8' : 'base64');
|
||||
throw new Error('No path or content provided');
|
||||
}
|
||||
|
||||
private async writeTemp(name: string, buffer: Buffer): Promise<string> {
|
||||
const path = join(mkdtempSync(join(tmpdir(), 'ai-file-')), name);
|
||||
await fs.writeFile(path, buffer);
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract text from a PDF. Pages with no text layer (scanned/image-only) are handled as either:
|
||||
* - Rendered to images and returned alongside the text so the (vision-capable) model can read them directly
|
||||
* - OCR'd via Tesseract when the doc is too large to reasonably pass as images
|
||||
*/
|
||||
private async resolvePdf(buffer: Buffer): Promise<{text: string, images: {mime: string, data: string}[]}> {
|
||||
const parser = new PDFParse({data: buffer});
|
||||
try {
|
||||
const {text, pages} = await parser.getText();
|
||||
const scanned = (pages || []).filter(p => !p.text?.trim());
|
||||
if(!scanned.length) return {text: text.trim() || '[Empty PDF]', images: []};
|
||||
const total = pages.length;
|
||||
const pageNums = scanned.map(p => p.num);
|
||||
const {pages: shots} = await parser.getScreenshot({partial: pageNums});
|
||||
if(total <= PDF_OCR_PAGE_THRESHOLD) {
|
||||
return {
|
||||
text: text.trim(),
|
||||
images: shots.map(s => ({mime: 'image/png', data: Buffer.from(s.data).toString('base64')}))
|
||||
};
|
||||
}
|
||||
const ocrText = await Promise.all(shots.map(async (s, i) => {
|
||||
const path = await this.writeTemp(`page-${pageNums[i]}.png`, Buffer.from(s.data));
|
||||
try {
|
||||
return await this.ai.vision.ocr(path) || '';
|
||||
} finally {
|
||||
fs.rm(dirname(path), {recursive: true, force: true}).catch(() => {});
|
||||
}
|
||||
}));
|
||||
return {text: [text.trim(), ...ocrText].filter(Boolean).join('\n\n'), images: []};
|
||||
} finally {
|
||||
await parser.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveFile(file: LLMFile): Promise<{text?: string, images?: {mime: string, data: string}[]}> {
|
||||
const name = file.name || (file.path ? basename(file.path) : 'file');
|
||||
|
||||
// Already resolved on a previous turn, reuse cached text
|
||||
if(file.extracted) return {text: `<file name="${name}">\n${file.content}\n</file>`};
|
||||
|
||||
const ext = extname(name).slice(1).toLowerCase();
|
||||
const mime = file.mime || '';
|
||||
const isAudio = mime.startsWith('audio/') || LLM.AUDIO_EXT.includes(ext);
|
||||
const isImage = mime.startsWith('image/') || LLM.IMAGE_EXT.includes(ext);
|
||||
const isPdf = mime === 'application/pdf' || LLM.PDF_EXT.includes(ext);
|
||||
const isText = mime.startsWith('text/') || LLM.TEXT_EXT.includes(ext);
|
||||
|
||||
let tmpDir: string | null = null;
|
||||
try {
|
||||
if(isImage) {
|
||||
const data = (await this.loadBuffer(file, false)).toString('base64');
|
||||
return {images: [{mime: mime || `image/${ext === 'jpg' ? 'jpeg' : ext}`, data}]};
|
||||
}
|
||||
|
||||
if(isPdf) {
|
||||
const {text, images} = await this.resolvePdf(await this.loadBuffer(file, false));
|
||||
// Only cache/skip re-processing when we didn't need to hand off images (OCR'd or fully text-based)
|
||||
if(!images.length) {
|
||||
file.content = text;
|
||||
file.extracted = true;
|
||||
delete file.path;
|
||||
}
|
||||
return {text: `<file name="${name}">\n${text || '[Scanned PDF - see attached page images]'}\n</file>`, images};
|
||||
}
|
||||
|
||||
let text: string;
|
||||
if(isAudio) {
|
||||
let path = file.path;
|
||||
if(!path) {
|
||||
const buffer = await this.loadBuffer(file, false);
|
||||
path = await this.writeTemp(name, buffer);
|
||||
tmpDir = dirname(path);
|
||||
}
|
||||
text = await this.ai.audio.asr(path) || '';
|
||||
} else if(isText) {
|
||||
text = (await this.loadBuffer(file, true)).toString('utf-8');
|
||||
} else {
|
||||
text = typeof file.content === 'string' ? file.content : `[Binary file, unable to extract: ${name}]`;
|
||||
}
|
||||
file.content = text;
|
||||
file.extracted = true;
|
||||
delete file.path;
|
||||
|
||||
return {text: `<file name="${name}">\n${text}\n</file>`};
|
||||
} catch(err: any) {
|
||||
return {text: `<file name="${name}">Failed to process: ${err.message}</file>`};
|
||||
} finally {
|
||||
if(tmpDir) fs.rm(tmpDir, {recursive: true, force: true}).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveFiles(files: LLMFile[]): Promise<{text: string, images: {mime: string, data: string}[]}> {
|
||||
const resolved = await Promise.all(files.map(f => this.resolveFile(f)));
|
||||
return {
|
||||
text: resolved.filter(r => r.text).map(r => r.text).join('\n\n'),
|
||||
images: resolved.flatMap(r => r.images || [])
|
||||
};
|
||||
}
|
||||
|
||||
private setupAgent(agents: Agent[] = [], allAgents: Agent[], history: LLMMessage[], aborts: ((keep?: boolean) => void)[], depth = 0, delegateState: {resp: string | null}): AiTool[] {
|
||||
return agents.map(a => {
|
||||
const toolName = `${a.delegate ? '' : 'sub'}agent_${snakeCase(a.name)}`;
|
||||
return {
|
||||
name: toolName,
|
||||
description: `${a.delegate ? 'Delegate to ' : ''}Subagent: ${a.description || a.name}`,
|
||||
args: clean<any>({
|
||||
context: !a.delegate ? {type: 'string', description: 'Summary of related messages, samples, files, etc...', required: true} : undefined,
|
||||
instructions: {type: 'string', description: 'Detailed instructions for subagent to complete', required: true},
|
||||
}),
|
||||
fn: async (args: any, stream: any, ai: any, id?: string) => {
|
||||
if(depth >= MAX_AGENT_DEPTH) return 'Max agent delegation depth exceeded';
|
||||
|
||||
const nested = (a.agents || [])
|
||||
.map(name => allAgents.find(x => x.name === name))
|
||||
.filter((x): x is Agent => !!x && x.name !== a.name);
|
||||
|
||||
const q = a.delegate ? '' : `${args.instructions}${args.context ? `\n\n<context>${args.context}</context>` : ''}`;
|
||||
|
||||
const request = this.ask(q, {
|
||||
system: `You are a specialized subagent being called from an orchestrator
|
||||
${a.delegate ? 'Your output streams directly to the user for the remainder of this turn. You are mid conversation' : 'You are wrapped in a tool call that will be analysis by an LLM'}
|
||||
Dispense with greetings and focus on your instructions using available tools and returning only the final result unless specifically instructed to converse
|
||||
|
||||
${a.system}`,
|
||||
model: a.model || undefined,
|
||||
temperature: a.temperature,
|
||||
stream: a.delegate ? stream : undefined,
|
||||
history: a.delegate ? history : [],
|
||||
mcp: a.mcp || undefined,
|
||||
skills: a.skills || undefined,
|
||||
tools: a.tools || undefined,
|
||||
agents: nested,
|
||||
_agentDepth: depth + 1,
|
||||
} as any);
|
||||
aborts.push(request.abort);
|
||||
const resp = await request;
|
||||
|
||||
if(a.delegate) {
|
||||
delegateState.resp = resp;
|
||||
return '';
|
||||
}
|
||||
return resp;
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private async setupMcp(servers: McpServer[] = []): Promise<{prompt: string, tools: AiTool[]}> {
|
||||
if(!servers?.length) return {prompt: '', tools: []};
|
||||
const allTools: AiTool[] = [];
|
||||
@@ -132,7 +346,7 @@ class LLM {
|
||||
|
||||
const list = allTools.map(t => `- ${t.name}: ${t.description}`).join('\n');
|
||||
return {
|
||||
prompt: `You have access to the following MCP tools:\n${list}`,
|
||||
prompt: `## MCP\nYou have access to the following MCP tools:\n${list}`,
|
||||
tools: allTools
|
||||
};
|
||||
}
|
||||
@@ -141,9 +355,9 @@ class LLM {
|
||||
if(!skills?.length) return {prompt: '', tools: []};
|
||||
const list = skills.map(s => `- ${s.name}: ${s.description}`).join('\n');
|
||||
return {
|
||||
prompt: `You have access to the following skill documents, use \`read_skill\` to access them:\n${list}`,
|
||||
prompt: `## Skills\nYou have access to the following skill documents, whenever there is overlap between a question and a skill file, use \`skill_read\` to get instructions and background knowledge:\n${list}`,
|
||||
tools: [{
|
||||
name: 'read_skill',
|
||||
name: 'skill_read',
|
||||
description: 'Read the full content of a skill/knowledge document',
|
||||
args: {
|
||||
name: {type: 'string', description: 'Exact skill name', required: true}
|
||||
@@ -157,10 +371,23 @@ class LLM {
|
||||
}
|
||||
}
|
||||
|
||||
private wrapToolTiming(tools: AiTool[], timings: Map<string, {duration: number, tps: number}>): AiTool[] {
|
||||
return tools.map(t => ({
|
||||
...t,
|
||||
fn: async (args: any, stream: any, ai: any, id?: string) => {
|
||||
const start = Date.now();
|
||||
const result = await t.fn(args, stream, ai, id);
|
||||
const duration = Date.now() - start;
|
||||
const tps = duration > 0 ? this.estimateTokens(result) / (duration / 1000) : 0;
|
||||
if(id) timings.set(id, {duration, tps});
|
||||
return result;
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
ask(message: string, options: LLMRequest = {}): AbortablePromise<string> {
|
||||
options = <any>{
|
||||
system: '',
|
||||
temperature: 0.8,
|
||||
...this.ai.options.llm,
|
||||
models: undefined,
|
||||
history: [],
|
||||
@@ -168,11 +395,42 @@ class LLM {
|
||||
}
|
||||
const m = options.model || this.defaultModel;
|
||||
if(!this.models[m]) throw new Error(`Model does not exist: ${m}`);
|
||||
let abort = () => {};
|
||||
return Object.assign(new Promise<string>(async res => {
|
||||
let request: AbortablePromise<string> | null = null;
|
||||
let aborted = false;
|
||||
let keepOnAbort = true;
|
||||
const nestedAborts: ((keep?: boolean) => void)[] = [];
|
||||
const abort = (keep = true) => {
|
||||
aborted = true;
|
||||
keepOnAbort = keep;
|
||||
request?.abort?.(keep);
|
||||
nestedAborts.forEach(a => a(keep));
|
||||
};
|
||||
|
||||
let promise: any;
|
||||
const requestStart = Date.now();
|
||||
|
||||
promise = (async () => {
|
||||
let tools: AiTool[] = options.tools || this.ai.options.llm?.tools || [];
|
||||
const prompts: string[] = [];
|
||||
let history = options.history || [];
|
||||
const historyStart = history.length;
|
||||
const files = options.files || [];
|
||||
if(message || files.length) history.push({role: 'user', content: message || '', timestamp: Date.now()});
|
||||
|
||||
// Accumulate streamed text so it can be committed to history if aborted mid-generation
|
||||
let partialText = '';
|
||||
const onStream = options.stream;
|
||||
const stream = (chunk: {text?: string, tool?: string, done?: true}) => {
|
||||
if(chunk.text) partialText += chunk.text;
|
||||
return onStream?.(chunk);
|
||||
};
|
||||
|
||||
/** Commit (keep) or discard this turn's progress on abort, then throw */
|
||||
const abortNow = (): never => {
|
||||
if(keepOnAbort) { if(partialText) history.push({role: 'assistant', content: partialText, timestamp: Date.now()}); }
|
||||
else history.splice(historyStart, history.length - historyStart);
|
||||
throw Object.assign(new Error('Aborted'), {name: 'AbortError'});
|
||||
};
|
||||
|
||||
// MCP
|
||||
const mcp = options.mcp || this.ai.options?.llm?.mcp;
|
||||
@@ -190,47 +448,116 @@ class LLM {
|
||||
tools.push(...s.tools);
|
||||
}
|
||||
|
||||
// Agents
|
||||
const agents = options.agents || this.ai.options?.llm?.agents;
|
||||
const delegateState: {resp: string | null} = {resp: null};
|
||||
if(agents?.length) tools.push(...this.setupAgent(agents, agents, history, nestedAborts, options._agentDepth || 0, delegateState));
|
||||
|
||||
// Memory
|
||||
if(options.memory) {
|
||||
const relevant = await this.memoryManager.recollect(message, options.memory, 1);
|
||||
prompts.unshift(`You have access to the following memory files:
|
||||
${options.memory.map(m => `- ${m.name}: ${m.description}`).join('\n')}
|
||||
${relevant.length ? `
|
||||
The closest memory has been added primitively:
|
||||
\`\`\`
|
||||
Name: ${relevant[0].name}
|
||||
Description: ${relevant[0].description}
|
||||
${relevant[0].content}
|
||||
\`\`\`
|
||||
`: ''}`.trim());
|
||||
tools.push(this.memoryManager.tools.read(<Memory[]>options.memory));
|
||||
const mem = MemoryManager.normalize(options.memory);
|
||||
if(mem) {
|
||||
const mems = mem.memory instanceof MemoryCache ? mem.memory.memories : mem.memory;
|
||||
if(mems.length) {
|
||||
if(mem.inject) {
|
||||
const pool = 15;
|
||||
const budget = mem.maxTokens ?? 2000;
|
||||
const relevant = await this.memoryManager.recollect(message, mem.memory, pool);
|
||||
|
||||
let used = 0;
|
||||
const preloaded: typeof relevant = [];
|
||||
const listed: typeof relevant = [];
|
||||
for(const r of relevant) {
|
||||
const t = this.estimateTokens(r.content);
|
||||
if(used + t <= budget || preloaded.length === 0) {
|
||||
preloaded.push(r);
|
||||
used += t;
|
||||
} else listed.push(r);
|
||||
}
|
||||
|
||||
prompts.unshift(`## Memory
|
||||
You have a background memory process which has prefetched relevant information${mem.update ? ' and will create new memories from this conversation' : ''} for you
|
||||
Assume it is perfect and never mention this process to anyone ever
|
||||
Always use your memories to craft a personalized response, they contain links / [[wiki links]] which you use navigate between them
|
||||
${mem.tool ? `You can access memory files via the \`memory_search\` and \`memory_recall\` tools
|
||||
When you need information about the user, \`memory_recall\` \`People/User\` before asking (fetch if not included bellow)
|
||||
When you need information not provided, attempt 1-3 \`memory_search\` calls with distinct queries before asking` : ''}
|
||||
|
||||
${preloaded.length ? `### Prefetched Memories (Most relevant first):
|
||||
|
||||
${preloaded.map(r => `Memory: ${r.name}
|
||||
Description: ${r.description}
|
||||
Linked: ${makeUnique([...r.links, ...r.backlinks]).join(', ')}
|
||||
\`\`\`
|
||||
${stripHeader(r.content)}
|
||||
\`\`\``).join('\n\n')}` : ''}
|
||||
${mem.tool && listed.length ? '\n' + listed.map(r => `Memory: ${r.name}
|
||||
Description: ${r.description}
|
||||
Linked: ${makeUnique([...r.links, ...r.backlinks]).join(', ')}
|
||||
<!-- Truncated -->`).join('\n\n') : ''}`.trim())
|
||||
}
|
||||
if(mem.tool) tools.push(this.memoryManager.tools.read(mem.memory));
|
||||
}
|
||||
}
|
||||
|
||||
if(aborted) abortNow();
|
||||
|
||||
const lastMsg = history[history.length - 1];
|
||||
if(files.length && lastMsg?.role === 'user') lastMsg.files = files;
|
||||
const restores: {msg: LLMMessage, content: any}[] = [];
|
||||
for(const msg of history) {
|
||||
if(msg.role !== 'user' || !msg.files?.length) continue;
|
||||
const {text, images} = await this.resolveFiles(msg.files);
|
||||
if(!text && !images.length) continue;
|
||||
restores.push({msg, content: msg.content});
|
||||
const merged = text ? [msg.content, text].filter(Boolean).join('\n\n') : msg.content;
|
||||
msg.content = images.length
|
||||
? [...images.map(i => ({type: 'image', mime: i.mime, data: i.data})), {type: 'text', text: merged}]
|
||||
: merged;
|
||||
}
|
||||
|
||||
const toolTimings = new Map<string, {duration: number, tps: number}>();
|
||||
tools = this.wrapToolTiming(tools, toolTimings);
|
||||
|
||||
if(aborted) abortNow();
|
||||
|
||||
prompts.unshift(options.system || this.ai.options.llm?.system || '');
|
||||
const resp = await this.models[m].ask(message, {...options, tools, system: prompts.filter(Boolean).join('\n\n')});
|
||||
|
||||
// Trim memory injections from history
|
||||
if(options.memory) {
|
||||
history.splice(0, history.length, ...history.filter(h => h.role !== 'tool' || h.name !== 'recall'));
|
||||
request = this.models[m].ask('', {...options, tools, stream, system: prompts.filter(Boolean).join('\n\n')});
|
||||
let resp: string;
|
||||
try {
|
||||
resp = await request;
|
||||
} catch(err: any) {
|
||||
if(aborted) return abortNow();
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Auto-memorize before compressing
|
||||
// Strip the file injection shim
|
||||
restores.forEach(({msg, content}) => msg.content = content);
|
||||
|
||||
// Capture meta (duration / tps)
|
||||
for(const h of history) {
|
||||
if(h.role === 'tool' && toolTimings.has(h.id)) Object.assign(h, toolTimings.get(h.id));
|
||||
}
|
||||
|
||||
if(typeof resp === 'string' && !resp.trim() && delegateState.resp !== null) resp = delegateState.resp;
|
||||
|
||||
if(mem?.tool) history.splice(0, history.length, ...history.filter(h => h.role !== 'tool' || h.name !== 'memory_recall'));
|
||||
if(options.compress && this.estimateTokens(history) >= options.compress.max) {
|
||||
if(options.memory) await this.memoryManager.memorize(history, options.memory, options);
|
||||
if(mem?.update) await this.memoryManager.memorize(history, mem.memory, {model: options.memoryModel || this.defaultModel, ...options});
|
||||
const compressed = await this.compressHistory(history, options.compress.max, options.compress.min, options);
|
||||
if(options.history) options.history.splice(0, options.history.length, ...compressed);
|
||||
}
|
||||
|
||||
return res(resp);
|
||||
}), {abort});
|
||||
}
|
||||
const requestDuration = Date.now() - requestStart;
|
||||
const totalTokens = history
|
||||
.filter((h: any) => h.role === 'assistant' && h.duration && h.tps)
|
||||
.reduce((sum: number, h: any) => sum + h.tps * (h.duration / 1000), 0);
|
||||
const requestTps = requestDuration > 0 ? totalTokens / (requestDuration / 1000) : 0;
|
||||
Object.assign(promise, {duration: requestDuration, tps: requestTps});
|
||||
|
||||
/**
|
||||
* Digest full conversation history into memory documents.
|
||||
* Call on session end to persist the conversation.
|
||||
*/
|
||||
async updateMemory(history: LLMMessage[], memories: Memory[], options: LLMRequest = {}): Promise<void> {
|
||||
await this.memoryManager.memorize(history, memories, {model: this.defaultModel, ...options});
|
||||
return resp;
|
||||
})();
|
||||
|
||||
return Object.assign(promise, {abort});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -383,49 +710,41 @@ ${relevant[0].content}
|
||||
* @param {string} searchTerms Multiple search terms to check against target
|
||||
* @returns {{avg: number, max: number, similarities: number[]}} Similarity values 0-1: 0 = unique, 1 = identical
|
||||
*/
|
||||
fuzzyMatch(target: string, ...searchTerms: string[]) {
|
||||
fuzzyMatch(target, ...searchTerms) {
|
||||
if (searchTerms.length < 2) throw new Error('Requires at least 2 strings to compare');
|
||||
const vector = (text: string, dimensions: number = 10): number[] => {
|
||||
return text.toLowerCase().split('').map((char, index) =>
|
||||
(char.charCodeAt(0) * (index + 1)) % dimensions / dimensions).slice(0, dimensions);
|
||||
const levenshtein = (a, b) => {
|
||||
const m = a.length, n = b.length;
|
||||
if (!m) return n;
|
||||
if (!n) return m;
|
||||
const dp = Array.from({length: m + 1}, (_, i) => [i, ...Array(n).fill(0)]);
|
||||
for (let j = 0; j <= n; j++) dp[0][j] = j;
|
||||
for (let i = 1; i <= m; i++) {
|
||||
for (let j = 1; j <= n; j++) {
|
||||
dp[i][j] = a[i - 1] === b[j - 1]
|
||||
? dp[i - 1][j - 1]
|
||||
: 1 + Math.min(dp[i - 1][j - 1], dp[i - 1][j], dp[i][j - 1]);
|
||||
}
|
||||
const v = vector(target);
|
||||
const similarities = searchTerms.map(t => vector(t)).map(refVector => this.cosineSimilarity(v, refVector));
|
||||
return {avg: similarities.reduce((acc, s) => acc + s, 0) / similarities.length, max: Math.max(...similarities), similarities};
|
||||
}
|
||||
return dp[m][n];
|
||||
};
|
||||
const similarity = (a, b) => {
|
||||
a = a.toLowerCase(); b = b.toLowerCase();
|
||||
return 1 - levenshtein(a, b) / Math.max(a.length, b.length, 1);
|
||||
};
|
||||
const similarities = searchTerms.map(t => similarity(target, t));
|
||||
return {
|
||||
avg: similarities.reduce((acc, s) => acc + s, 0) / similarities.length,
|
||||
max: Math.max(...similarities),
|
||||
similarities
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask a question with JSON response
|
||||
* @param {string} text Text to process
|
||||
* @param {string} schema JSON schema the AI should match
|
||||
* @param {LLMRequest} options Configuration options and chat history
|
||||
* @returns {Promise<{} | {} | RegExpExecArray | null>}
|
||||
* Digest full conversation history into memory documents.
|
||||
* Call on session end to persist the conversation.
|
||||
*/
|
||||
async json(text: string, schema: string, options?: LLMRequest): Promise<any> {
|
||||
let system = `Your job is to convert input to JSON using tool calls. Call the \`submit\` tool at least once with JSON matching this schema:\n\`\`\`json\n${schema}\n\`\`\`\n\nResponses are ignored`;
|
||||
if(options?.system) system += '\n\n' + options.system;
|
||||
return new Promise(async (resolve, reject) => {
|
||||
let done = false;
|
||||
const resp = await this.ask(text, {
|
||||
temperature: 0.3,
|
||||
...options,
|
||||
system,
|
||||
tools: [{
|
||||
name: 'submit',
|
||||
description: 'Submit JSON',
|
||||
args: {json: {type: 'string', description: 'Javascript parsable JSON string', required: true}},
|
||||
fn: (args) => {
|
||||
try {
|
||||
const json = JSON.parse(args.json);
|
||||
resolve(json);
|
||||
done = true;
|
||||
} catch { return 'Invalid JSON'; }
|
||||
return 'Saved';
|
||||
}
|
||||
}, ...(options?.tools || [])],
|
||||
});
|
||||
if(!done) reject(`AI failed to create JSON:\n${resp}`);
|
||||
});
|
||||
async memorize(history: LLMMessage[], memories: Memory[] | MemoryCache, options: LLMRequest = {}): Promise<Memory[]> {
|
||||
return this.memoryManager.memorize(history, memories, {model: this.defaultModel, ...options});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -462,9 +781,8 @@ ${relevant[0].content}
|
||||
});
|
||||
}
|
||||
|
||||
addModel(name: string, config: AnthropicConfig | OllamaConfig | OpenAiConfig, setDefault = false) {
|
||||
addModel(name: string, config: AnthropicConfig | OpenAiConfig, setDefault = false) {
|
||||
if(config.proto == 'anthropic') this.models[name] = new Anthropic(this.ai, config.token, name);
|
||||
else if(config.proto == 'ollama') this.models[name] = new OpenAi(this.ai, config.host, 'not-needed', name);
|
||||
else if(config.proto == 'openai') this.models[name] = new OpenAi(this.ai, config.host || null, config.token, name);
|
||||
if(setDefault || !this.defaultModel) this.defaultModel = name;
|
||||
}
|
||||
@@ -476,12 +794,11 @@ ${relevant[0].content}
|
||||
}
|
||||
}
|
||||
|
||||
setModels(models: {[model: string]: AnthropicConfig | OllamaConfig | OpenAiConfig}, replace = true) {
|
||||
setModels(models: {[model: string]: AnthropicConfig | OpenAiConfig}, replace = true) {
|
||||
if(replace) this.models = {};
|
||||
Object.entries(models).forEach(([model, config]) => {
|
||||
if(!this.defaultModel) this.defaultModel = model;
|
||||
if(config.proto == 'anthropic') this.models[model] = new Anthropic(this.ai, config.token, model);
|
||||
else if(config.proto == 'ollama') this.models[model] = new OpenAi(this.ai, config.host, 'not-needed', model);
|
||||
else if(config.proto == 'openai') this.models[model] = new OpenAi(this.ai, config.host || null, config.token, model);
|
||||
});
|
||||
this.defaultModel = Object.keys(this.models)[0] ?? '';
|
||||
|
||||
819
src/memory.ts
819
src/memory.ts
@@ -1,177 +1,732 @@
|
||||
// memory.ts
|
||||
import {MemoryNode, patchGraph, rebuildGraph} from './helpers.ts';
|
||||
import {LLMRequest, LLMMessage} from './llm.ts';
|
||||
import {AiTool} from './tools.ts';
|
||||
import {KDTree} from './kd-tree.ts';
|
||||
import {escapeRegex} from '@ztimson/utils';
|
||||
|
||||
const MERGE_THRESHOLD = 0.12;
|
||||
const PENDING_HEADING = '## Pending';
|
||||
const TREE_TOMBSTONE_LIMIT = 0.25;
|
||||
const ALIAS_MATCH_THRESHOLD = 0.55;
|
||||
const GENERIC_TEMPLATE = `# {{Title}}
|
||||
|
||||
## Summary
|
||||
|
||||
## Details
|
||||
|
||||
## Related`;
|
||||
|
||||
/** Background information the AI will be fed as a knowledge document */
|
||||
export type Memory = {
|
||||
/** Memory subject */
|
||||
name: string;
|
||||
/** Short description of what this document contains - used for RAG retrieval */
|
||||
description: string;
|
||||
/** Full markdown content of the document */
|
||||
content: string;
|
||||
/** Embedding vector of the description - used for similarity search */
|
||||
/** Description embedding — indexed in the KD tree, used for merge/ANN candidate lookup */
|
||||
embedding: number[];
|
||||
/** Title-only embedding, weighted heaviest during recall ranking */
|
||||
titleEmbedding?: number[];
|
||||
/** Chunked body embeddings, best-chunk match used during recall ranking */
|
||||
bodyEmbeddings?: number[][];
|
||||
links: string[];
|
||||
backlinks: string[];
|
||||
}
|
||||
|
||||
export type MemoryCollection = {
|
||||
/** Memory subject */
|
||||
type MemoryRef = {
|
||||
name: string;
|
||||
/** Short description - required if isNew */
|
||||
description?: string;
|
||||
/** Extracted facts to merge */
|
||||
description: string;
|
||||
/** Cosine distance from the query, present when returned from a search */
|
||||
distance?: number;
|
||||
}
|
||||
|
||||
type FactBucket = {
|
||||
subject: string;
|
||||
facts: string[];
|
||||
}
|
||||
|
||||
type FactAgentResult = {
|
||||
buckets: FactBucket[];
|
||||
journal: string;
|
||||
}
|
||||
|
||||
function dedupeFacts(facts: string[]): string[] {
|
||||
const seen = new Map<string, string>();
|
||||
for (const f of facts) {
|
||||
const clean = f.trim();
|
||||
if (clean) seen.set(clean.toLowerCase(), clean);
|
||||
}
|
||||
return [...seen.values()];
|
||||
}
|
||||
|
||||
function cosineDistance(a: number[], b: number[]): number {
|
||||
let dot = 0, normA = 0, normB = 0;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
dot += a[i] * b[i];
|
||||
normA += a[i] * a[i];
|
||||
normB += b[i] * b[i];
|
||||
}
|
||||
const denom = Math.sqrt(normA) * Math.sqrt(normB);
|
||||
return denom === 0 ? 1 : 1 - dot / denom;
|
||||
}
|
||||
|
||||
function cosineSearch(query: number[], memories: Memory[], limit: number): MemoryRef[] {
|
||||
return memories
|
||||
.filter(m => m.embedding?.length)
|
||||
.map(m => ({name: m.name, description: m.description, distance: cosineDistance(query, m.embedding)}))
|
||||
.sort((a, b) => a.distance - b.distance)
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
/** Re-embed a node's title / description / body fields. Description embedding stays the KD-tree index key. */
|
||||
async function embedMemoryFields(node: Memory, llm: any): Promise<void> {
|
||||
const body = stripHeader(node.content);
|
||||
const [titleE] = await llm.embedding(node.name.split('/').pop() || node.name);
|
||||
const [descE] = await llm.embedding(node.description || '');
|
||||
const bodyChunks = body ? await llm.embedding(body) : [];
|
||||
if (titleE) node.titleEmbedding = titleE.embedding;
|
||||
if (descE) node.embedding = descE.embedding;
|
||||
node.bodyEmbeddings = bodyChunks.map((c: any) => c.embedding).filter(Boolean);
|
||||
}
|
||||
|
||||
export function stripHeader(content: string): string {
|
||||
return content.replace(/^---[\s\S]*?\n---\n?/, '').trimStart();
|
||||
}
|
||||
|
||||
export class MemoryCache {
|
||||
private tree!: KDTree<MemoryRef>;
|
||||
/** Tracks which memories are currently indexed in the tree, keyed by name -> embedding reference */
|
||||
private indexed = new Map<string, number[]>();
|
||||
public memories: Memory[];
|
||||
public nodes: MemoryNode[] = [];
|
||||
|
||||
get length() { return this.memories.length; }
|
||||
|
||||
constructor(memories: Memory[]) {
|
||||
this.memories = memories;
|
||||
this.tree = new KDTree<MemoryRef>(0);
|
||||
this.rebuild();
|
||||
}
|
||||
|
||||
/** Incrementally sync the KD tree against `this.memories` instead of rebuilding from scratch */
|
||||
private syncTree(): void {
|
||||
const current = new Set(this.memories.map(m => m.name));
|
||||
|
||||
for (const [name, emb] of [...this.indexed]) {
|
||||
const mem = this.memories.find(m => m.name === name);
|
||||
if (!mem || !current.has(name) || mem.embedding !== emb) {
|
||||
this.tree.remove(p => p.name === name);
|
||||
this.indexed.delete(name);
|
||||
}
|
||||
}
|
||||
|
||||
for (const mem of this.memories) {
|
||||
if (!mem.embedding?.length || this.indexed.has(mem.name)) continue;
|
||||
if (this.tree.dims === 0) this.tree = new KDTree<MemoryRef>(mem.embedding.length, 'cosine');
|
||||
if (mem.embedding.length !== this.tree.dims) continue; // guard against embedding model/dim drift
|
||||
this.tree.insert({vector: mem.embedding, payload: {name: mem.name, description: mem.description}});
|
||||
this.indexed.set(mem.name, mem.embedding);
|
||||
}
|
||||
|
||||
if (this.tree.tombstoneRatio > TREE_TOMBSTONE_LIMIT) this.tree.rebalance();
|
||||
}
|
||||
|
||||
search(query: number[], limit: number): MemoryRef[] {
|
||||
if (!this.tree || this.tree.dims === 0) return [];
|
||||
return this.tree.knn(query, limit).map(r => ({...r.point.payload, distance: r.distance}));
|
||||
}
|
||||
|
||||
add(memory: Memory): void {
|
||||
this.memories.push(memory);
|
||||
this.rebuild([memory]);
|
||||
}
|
||||
|
||||
update(memory: Memory): void {
|
||||
const existing = this.memories.find(m => m.name === memory.name);
|
||||
if (existing) Object.assign(existing, memory);
|
||||
else this.memories.push(memory);
|
||||
this.rebuild([existing ?? memory]);
|
||||
}
|
||||
|
||||
remove(name: string): void {
|
||||
const idx = this.memories.findIndex(m => m.name === name);
|
||||
if (idx !== -1) {
|
||||
this.memories.splice(idx, 1);
|
||||
this.rebuild();
|
||||
}
|
||||
}
|
||||
|
||||
rebuild(changed?: Memory[]): void {
|
||||
this.nodes = (changed?.length && this.nodes.length)
|
||||
? patchGraph(this.memories, this.nodes, changed)
|
||||
: rebuildGraph(this.memories);
|
||||
this.syncTree();
|
||||
}
|
||||
}
|
||||
|
||||
class MemoryAccessor {
|
||||
readonly list: Memory[];
|
||||
private readonly cache: MemoryCache | null;
|
||||
|
||||
constructor(memories: Memory[] | MemoryCache) {
|
||||
this.cache = memories instanceof MemoryCache ? memories : null;
|
||||
this.list = this.cache ? this.cache.memories : <Memory[]>memories;
|
||||
}
|
||||
|
||||
find(name: string): Memory | undefined {
|
||||
return this.list.find(m => m.name === name);
|
||||
}
|
||||
|
||||
commit(changed?: Memory[]): MemoryNode[] {
|
||||
if (this.cache) {
|
||||
this.cache.rebuild(changed);
|
||||
return this.cache.nodes;
|
||||
}
|
||||
return rebuildGraph(this.list);
|
||||
}
|
||||
|
||||
ghosts(): string[] {
|
||||
const nodes = this.cache ? this.cache.nodes : rebuildGraph(this.list);
|
||||
return nodes.filter(n => n.missing).map(n => n.name);
|
||||
}
|
||||
|
||||
/** Cache path uses the KD tree's knn(); raw-array path (no cache available) falls back to a linear cosine scan */
|
||||
search(vector: number[], limit: number): MemoryRef[] {
|
||||
return this.cache ? this.cache.search(vector, limit) : cosineSearch(vector, this.list, limit);
|
||||
}
|
||||
|
||||
forget(name: string): boolean {
|
||||
const idx = this.list.findIndex(m => m.name === name);
|
||||
if (idx === -1) return false;
|
||||
this.list.splice(idx, 1);
|
||||
this.commit();
|
||||
return true;
|
||||
}
|
||||
|
||||
async backfillEmbeddings(llm: any): Promise<number> {
|
||||
const missing = this.list.filter(m => !m.embedding?.length);
|
||||
if (!missing.length) return 0;
|
||||
await Promise.all(missing.map(node => embedMemoryFields(node, llm)));
|
||||
this.commit();
|
||||
return missing.length;
|
||||
}
|
||||
}
|
||||
|
||||
export type MemoryOptions = {
|
||||
/** Memory object */
|
||||
memory: Memory[] | MemoryCache;
|
||||
/** Inject N memories into the system prompt */
|
||||
inject?: boolean;
|
||||
/** expose recall tool to LLM */
|
||||
tool?: boolean;
|
||||
/** Update memory on compression */
|
||||
update?: boolean;
|
||||
/** Max context size of memories to inject to each call (removed immediately after use) */
|
||||
maxTokens?: number;
|
||||
}
|
||||
|
||||
export class MemoryManager {
|
||||
private mergeLock: Promise<any> = Promise.resolve();
|
||||
private queues = new Map<string, {
|
||||
dirty: boolean,
|
||||
request: {abort?: () => void} | null,
|
||||
task: Promise<void>,
|
||||
}>();
|
||||
private recentlyTouched = new Map<string, number>();
|
||||
|
||||
tools = {
|
||||
edit: (memory: Memory): AiTool => ({
|
||||
name: 'edit_memory',
|
||||
description: 'Edit a memory. Omit start/end to append. Pass start only to replace from that line on (Note line 0 = first line of content / line AFTER description). Pass start+end to replace a specific range. start=0 replaces the whole document. Returns updated document',
|
||||
forget: (memories: Memory[] | MemoryCache): AiTool => ({
|
||||
name: 'memory_forget',
|
||||
description: 'Permanently delete a memory document and clean up all references to it',
|
||||
args: {
|
||||
content: {type: 'string', description: 'New content', required: true},
|
||||
start: {type: 'number', description: 'First line to replace (0-indexed, inclusive). Omit to append.'},
|
||||
end: {type: 'number', description: 'Last line to replace (0-indexed, inclusive). Omit to replace from start to end of doc.'},
|
||||
name: {type: 'string', description: 'Exact memory name to forget', required: true}
|
||||
},
|
||||
fn: (args: any) => {
|
||||
const lines = memory.content ? memory.content.split('\n') : [];
|
||||
const newLines = args.content.split('\n');
|
||||
if(args.start === undefined) lines.push(...newLines);
|
||||
else if(args.end === undefined) lines.splice(args.start, lines.length - args.start, ...newLines);
|
||||
else lines.splice(args.start, args.end - args.start + 1, ...newLines);
|
||||
memory.content = lines.join('\n');
|
||||
return memory.content;
|
||||
}
|
||||
const result = this.forget(args.name, memories);
|
||||
return result ? `Forgotten: ${args.name}` : `Not found: ${args.name}`;
|
||||
},
|
||||
}),
|
||||
extract: (pools: MemoryCollection[]): AiTool => ({
|
||||
name: 'extract_facts',
|
||||
description: 'Extract a list of facts to group into a single memory',
|
||||
args: {
|
||||
name: {type: 'string', description: 'Exact name of an existing memory, or a new name if none fits ([pro]nouns only)', required: true},
|
||||
description: {type: 'string', description: 'One sentence description of the memory subject', required: true},
|
||||
facts: {type: 'string', description: 'Comma separated list of extracted facts', required: true},
|
||||
},
|
||||
fn: (args: any) => {
|
||||
pools.push({
|
||||
name: args.name,
|
||||
description: args.description,
|
||||
facts: args.facts.split(',').map((f: string) => f.trim()).filter(Boolean),
|
||||
});
|
||||
return 'Success';
|
||||
}}),
|
||||
read: (memories: Memory[]): AiTool => ({
|
||||
name: 'read_memory',
|
||||
description: 'Read entire memory',
|
||||
|
||||
read: (memories: Memory[] | MemoryCache): AiTool => ({
|
||||
name: 'memory_recall',
|
||||
description: 'Read the full content of a memory document',
|
||||
args: {
|
||||
name: {type: 'string', description: 'Exact memory name', required: true},
|
||||
},
|
||||
fn: (args: any) => {
|
||||
const mem = memories.find(m => m.name === args.name);
|
||||
const mem = this.access(memories).find(args.name);
|
||||
if (!mem) return 'Document not found';
|
||||
return `Name: ${mem.name}\nDescription: ${mem.description}\n\n${mem.content}`;
|
||||
}
|
||||
this.touch(mem.name);
|
||||
return mem.content;
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
constructor(private llm: any, private model?: string) {}
|
||||
|
||||
/**
|
||||
* Extracts facts from conversation and groups them into individual memories
|
||||
* @param {string} conversation Full conversation formatted as [role]: content
|
||||
* @param {Memory[]} memories The user's memory documents
|
||||
* @param {LLMRequest} options LLM options
|
||||
* @returns {Promise<MemoryCollection[]>} Fact pools grouped by target document
|
||||
*/
|
||||
private async extract(conversation: string, memories: Memory[], options: LLMRequest): Promise<MemoryCollection[]> {
|
||||
const existingDocs = memories.map(m => `Name: ${m.name}\nDescription: ${m.description}`).join('\n\n');
|
||||
const pools: MemoryCollection[] = [];
|
||||
await this.llm.ask(conversation, {
|
||||
model: this.model || options.model,
|
||||
temperature: 0.2,
|
||||
system: `You are a fact extractor. Analyze this conversation and extract facts worth remembering long term.
|
||||
Rules:
|
||||
- ONLY extract facts the USER explicitly stated about themselves or their business
|
||||
- ONLY extract decisions that were MADE during this conversation
|
||||
- DO NOT extract anything the AI said, its name, capabilities, or how it introduced itself
|
||||
- DO NOT extract greetings, pleasantries or generic exchanges
|
||||
- If nothing worth remembering was said, call NO tools
|
||||
|
||||
For each fact decide whether it belongs in an existing document or needs a new one, then call the \`extract_facts\` tool.
|
||||
|
||||
Existing documents:\n${existingDocs || 'None yet.'}`,
|
||||
tools: [this.tools.extract(pools)]
|
||||
});
|
||||
return pools;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bot 2 - Editor: merges a pool of facts into a specific document using surgical line-based edits.
|
||||
* Receives full document content and uses read + amend tools to make precise edits.
|
||||
* @param {MemoryCollection} newMem The fact pool to merge
|
||||
* @param {Memory[]} memories The user's memory documents
|
||||
* @param {LLMRequest} options LLM options
|
||||
*/
|
||||
private async edit(newMem: MemoryCollection, memories: Memory[], options: LLMRequest): Promise<void> {
|
||||
const existing = memories.find(m => m.name === newMem.name);
|
||||
const mem: Memory = existing || {name: newMem.name, description: newMem.description || '', content: '', embedding: []};
|
||||
const isNew = !existing;
|
||||
|
||||
await this.llm.ask(newMem.facts.map(f => `- ${f}`).join('\n'),
|
||||
{
|
||||
model: this.model || options.model,
|
||||
temperature: 0.2,
|
||||
system: `You are a document editor. Merge the users list of facts into the following document using the \`edit_memory\` tool; call it as many times as necessary:
|
||||
search: (memories: Memory[] | MemoryCache): AiTool => ({
|
||||
name: 'memory_search',
|
||||
description: 'Use embeddings to find the MOST relevant memories, even if NOT relevant',
|
||||
args: {
|
||||
query: {type: 'string', description: 'What to look for in the memories', required: true},
|
||||
limit: {type: 'number', description: 'Number of memories to return', default: 1},
|
||||
},
|
||||
fn: async ({query, limit}) => {
|
||||
const mem = await this.recollect(query, memories, limit)
|
||||
return mem.map(m => `Memory: ${m.name}
|
||||
Description: ${m.description}
|
||||
Links: ${[...m.links, ...m.backlinks].join(', ')}
|
||||
\`\`\`
|
||||
${mem.content}
|
||||
${m.content}
|
||||
\`\`\``).join('\n\n');
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
constructor(private llm: any) {}
|
||||
|
||||
static normalize(m?: Memory[] | MemoryCache | MemoryOptions) {
|
||||
if (!m) return null;
|
||||
const raw = m instanceof MemoryCache || Array.isArray(m);
|
||||
return raw ? {memory: <Memory[] | MemoryCache>m, inject: true, tool: true, update: true} : {inject: true, tool: true, update: true, ...m};
|
||||
}
|
||||
|
||||
private access(memories: Memory[] | MemoryCache): MemoryAccessor {
|
||||
return new MemoryAccessor(memories);
|
||||
}
|
||||
|
||||
private stage(node: Memory, block: string): void {
|
||||
this.ensureDoc(node);
|
||||
const body = stripHeader(node.content);
|
||||
const idx = body.indexOf(PENDING_HEADING);
|
||||
const newBody = idx === -1
|
||||
? `${body.trimEnd()}\n\n${PENDING_HEADING}\n${block}\n`
|
||||
: `${body.slice(0, idx + PENDING_HEADING.length)}\n${block}${body.slice(idx + PENDING_HEADING.length)}`;
|
||||
node.content = this.touchHeader(node, newBody);
|
||||
}
|
||||
|
||||
private ensureDoc(node: Memory): void {
|
||||
if (node.content) return;
|
||||
const title = node.name.split('/').pop() ?? node.name;
|
||||
node.content = this.touchHeader(node, `# ${title}\n`);
|
||||
}
|
||||
|
||||
private sanitizeDescription(text: string): string {
|
||||
return (text ?? '').replace(/\s+/g, ' ').trim().slice(0, 240);
|
||||
}
|
||||
|
||||
private relink(memories: Memory[], from: string, to: string): void {
|
||||
const pattern = new RegExp(`\\[\\[${escapeRegex(from)}\\]\\]`, 'g');
|
||||
for (const m of memories) if (pattern.test(m.content)) m.content = m.content.replace(pattern, `[[${to}]]`);
|
||||
}
|
||||
|
||||
private normalizeLeaf(name: string): string {
|
||||
return name.trim().toLowerCase().replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a fact-agent proposed subject to an existing node when it's an alias/rename of one.
|
||||
* Exact match is checked first (cheap, and covers the common case since node names are
|
||||
* already normalized at creation time). Only falls through to fuzzy alias matching against
|
||||
* same-root candidates when there's no existing hit — i.e. only on likely-new-doc creation.
|
||||
*/
|
||||
private resolveSubject(subject: string, store: MemoryAccessor): string {
|
||||
const trimmed = subject.trim();
|
||||
const exact = store.find(trimmed);
|
||||
if (exact) return exact.name;
|
||||
|
||||
const normalized = this.normalizeLeaf(trimmed);
|
||||
const caseInsensitive = store.list.find(m => this.normalizeLeaf(m.name) === normalized);
|
||||
if (caseInsensitive) return caseInsensitive.name;
|
||||
|
||||
const root = trimmed.split('/')[0];
|
||||
const leaf = trimmed.split('/').slice(1).join('/') || trimmed;
|
||||
const candidates = store.list.filter(m => m.name.split('/')[0] === root && m.name !== trimmed);
|
||||
if (!candidates.length) return trimmed;
|
||||
|
||||
// fuzzyMatch requires >=2 terms; pad with an empty string when there's only one candidate
|
||||
const leaves = candidates.map(m => m.name.split('/').slice(1).join('/') || m.name);
|
||||
const probe = leaves.length > 1 ? leaves : [...leaves, ''];
|
||||
const {max, similarities} = this.llm.fuzzyMatch(leaf, ...probe);
|
||||
if (max >= ALIAS_MATCH_THRESHOLD) return candidates[similarities.indexOf(max)].name;
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private async factAgent(conversation: string, store: MemoryAccessor, options: LLMRequest): Promise<FactAgentResult> {
|
||||
const ghosts = store.ghosts();
|
||||
|
||||
const response = await this.llm.ask(conversation, {
|
||||
model: options.model,
|
||||
temperature: 0.2,
|
||||
system: `You are a fact extractor for Obsidian-style knowledge vaults. Analyze the conversation and produce:
|
||||
|
||||
1. Journal recap (single paragraph)
|
||||
- "Captains Log" style record keeping
|
||||
- What was discussed/worked on, decisions, user's events/state/mood, general context
|
||||
- Leave empty only for trivial/empty exchanges/small talk
|
||||
|
||||
2. Fact buckets
|
||||
- ONLY facts the USER explicitly stated about themselves, their work, projects, or decisions made during this conversation
|
||||
- NEVER extract greetings, pleasantries, or anything the assistant itself said
|
||||
- Extract the final/end state, not deltas
|
||||
|
||||
Path assignment (entity) rules:
|
||||
- Use the owning entity of the fact (even if implied): "New bug on project 51 -> Projects/51"
|
||||
- When multiple facts relate to the same entity, pick a primary owner and wikilink related entities
|
||||
- Reuse existing entities when the owner already has a node
|
||||
- Always group under consistent entity roots (always plural):
|
||||
- Projects/[Name] for all initiatives
|
||||
- People/[Name] for all individuals
|
||||
- History/[Name] for all historical figures/events
|
||||
- Science/[Name] for all scientific concepts
|
||||
- Child entities nest under their parent entity:
|
||||
- Projects/51/Memory System, Projects/51/Bug-XYZ, not Bugs/51
|
||||
- Science/AI/Model-X, not Model-X/AI
|
||||
|
||||
Wikilink rules:
|
||||
- Use [[WikiLinks]] to connect related entities (e.g., [[Projects/51]], [[People/Robert]])
|
||||
- Only link specific, existing or implied entity paths — skip generic terms
|
||||
- Don't over-link: each link should add clarity or context, not noise
|
||||
|
||||
Available nodes:
|
||||
${this.listNodes(store.list).map(n => `- ${n.name}: ${n.description}`).join('\n') || 'None yet.'}
|
||||
${ghosts.length ? `${ghosts.map(g => `- ${g}: (Ghost)`).join('\n')}` : ''}`,
|
||||
schema: {
|
||||
journal: {type: 'string', description: 'Short day-to-day recap; empty if nothing happened.', required: false},
|
||||
buckets: {type: 'array', description: 'Groups of facts to remember; empty array if nothing worth storing.', items: {
|
||||
type: 'object', items: {
|
||||
subject: {type: 'string', description: 'Exact node name or new path (e.g. "People/Sarah", "Projects/Oxide")', required: true},
|
||||
facts: {type: 'array', description: 'Facts to store here', items: {type: 'string'}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const buckets = new Map<string, string[]>();
|
||||
for (const bucket of response.buckets ?? []) {
|
||||
const subject = bucket.subject.trim();
|
||||
const facts = buckets.get(subject) ?? [];
|
||||
facts.push(...dedupeFacts(bucket.facts));
|
||||
buckets.set(subject, facts);
|
||||
}
|
||||
|
||||
return {
|
||||
buckets: buckets.entries().toArray().map(([subject, facts]) => ({subject, facts})),
|
||||
journal: (response.journal ?? '').trim(),
|
||||
};
|
||||
}
|
||||
|
||||
private getWeekMonday(date: Date = new Date()): string {
|
||||
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
|
||||
const day = d.getUTCDay();
|
||||
const diff = day === 0 ? -6 : 1 - day;
|
||||
d.setUTCDate(d.getUTCDate() + diff);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
private listNodes(memories: Memory[]): MemoryRef[] {
|
||||
return memories.map(m => ({name: m.name, description: m.description}));
|
||||
}
|
||||
|
||||
/** Finds the closest merge candidate via the KD tree's knn() instead of a manual O(n) cosine scan */
|
||||
private async checkMerge(node: Memory, memories: Memory[] | MemoryCache, options: LLMRequest, threshold = MERGE_THRESHOLD): Promise<Memory | null> {
|
||||
if (!node.embedding?.length || node.name.startsWith('Journal/')) return null;
|
||||
const store = this.access(memories);
|
||||
|
||||
const candidate = store.search(node.embedding, 5)
|
||||
.find(r => r.name !== node.name && !r.name.startsWith('Journal/') && r.distance !== undefined && r.distance <= threshold);
|
||||
if (!candidate) return null;
|
||||
const closest = store.find(candidate.name);
|
||||
if (!closest) return null;
|
||||
|
||||
const result = await this.mergeAgent(node, closest, options);
|
||||
const merged: Memory = {name: result.name, description: this.sanitizeDescription(result.description), content: '', embedding: [], links: [], backlinks: []};
|
||||
merged.content = this.touchHeader(merged, result.content);
|
||||
await embedMemoryFields(merged, this.llm);
|
||||
|
||||
this.relink(store.list, node.name, merged.name);
|
||||
this.relink(store.list, closest.name, merged.name);
|
||||
|
||||
this.queues.get(closest.name)?.request?.abort?.();
|
||||
this.queues.delete(closest.name);
|
||||
|
||||
store.forget(node.name);
|
||||
store.forget(closest.name);
|
||||
store.list.push(merged);
|
||||
store.commit();
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
private reconcile(node: Memory, memories: Memory[] | MemoryCache, options: LLMRequest): Promise<void> {
|
||||
const key = node.name;
|
||||
const existing = this.queues.get(key);
|
||||
if (existing) {
|
||||
existing.dirty = true;
|
||||
existing.request?.abort?.();
|
||||
return existing.task;
|
||||
}
|
||||
|
||||
const entry = {dirty: false, request: null, task: Promise.resolve()};
|
||||
this.queues.set(key, entry);
|
||||
const store = this.access(memories);
|
||||
entry.task = (async () => {
|
||||
let current = node, merged = false;
|
||||
try {
|
||||
do {
|
||||
entry.dirty = false;
|
||||
await this.docAgent(current, store.list, options, entry);
|
||||
this.mergeLock = this.mergeLock.then(() => this.checkMerge(current, memories, options));
|
||||
const result = await this.mergeLock;
|
||||
if (result) { current = result; merged = true; }
|
||||
} while (entry.dirty);
|
||||
} finally {
|
||||
store.commit(merged ? undefined : [node]);
|
||||
this.queues.delete(key);
|
||||
}
|
||||
})();
|
||||
return entry.task;
|
||||
}
|
||||
|
||||
private async docAgent(node: Memory, memories: Memory[], options: LLMRequest, entry: {request: {abort?: () => void} | null}): Promise<void> {
|
||||
if(!memories.includes(node)) return;
|
||||
const currentBody = stripHeader(node.content);
|
||||
let update;
|
||||
try {
|
||||
for (let i = 0; i < 2 && !update?.content; i++) {
|
||||
const request = this.llm.ask(currentBody, {
|
||||
model: options.model,
|
||||
temperature: 0.3,
|
||||
schema: {
|
||||
description: {type: 'string', description: 'One factual sentence describing the document\'s ENTIRE SUBJECT MATTER — for use as a search/merge fingerprint', required: true},
|
||||
content: {type: 'string', description: 'Rewritten document body in markdown, without the frontmatter block', required: true},
|
||||
},
|
||||
system: `You are a knowledge base editor maintaining one Obsidian-style document.
|
||||
|
||||
If it has a "## Pending" section, fold all new material into the appropriate part, resolve overlap, then remove the section entirely. If no section, just tidy per the rules below.
|
||||
|
||||
Use this loose structure, adapting headings to what the content needs:
|
||||
\`\`\`markdown
|
||||
${GENERIC_TEMPLATE}
|
||||
\`\`\`
|
||||
|
||||
Rules:
|
||||
- Contradictions: "## Pending" holds the newest information — bias toward it. Fold it in as the standing fact and drop the outdated statement, unless the old context adds meaningful nuance (e.g. "previously X, now Y"). This document should read as a source of truth, not an audit log
|
||||
- Journals (Journal/...): keep entries as a chronological timeline; clean up grammar within entries but never delete history
|
||||
- Use Obsidian markdown: # headings, **bold**, bullet/numbered lists, tables for 2D data
|
||||
- Link specific entities and concepts with [[WikiLink]] (e.g., [[Projects/KiwixServer]]); skip generics
|
||||
- Keep concise, factual, human-readable
|
||||
- NO frontmatter, filler, preamble, or AI commentary
|
||||
|
||||
Available nodes to link to (don't duplicate their content):
|
||||
${this.listNodes(memories).filter(n => n.name !== node.name).map(n => n.name).join(', ') || 'none'}
|
||||
|
||||
Current document:
|
||||
\`\`\`markdown
|
||||
${currentBody}
|
||||
\`\`\``,
|
||||
tools: [this.tools.edit(mem)]
|
||||
});
|
||||
entry.request = request;
|
||||
update = await request;
|
||||
}
|
||||
);
|
||||
|
||||
if(isNew || mem.description !== existing?.description) {
|
||||
const e = await this.llm.embedding(mem.description);
|
||||
mem.embedding = e?.[0]?.embedding;
|
||||
} catch (err: any) {
|
||||
if (err?.name === 'AbortError') return;
|
||||
throw err;
|
||||
} finally {
|
||||
entry.request = null;
|
||||
}
|
||||
|
||||
if(isNew) memories.push(mem);
|
||||
else {
|
||||
const idx = memories.findIndex(m => m.name === newMem.name);
|
||||
if(idx >= 0) memories[idx] = mem;
|
||||
if (!update?.content) return;
|
||||
node.description = node.name !== 'People/User' ? this.sanitizeDescription(update.description) : 'All information about the current user';
|
||||
node.content = this.touchHeader(node, update.content);
|
||||
await embedMemoryFields(node, this.llm);
|
||||
}
|
||||
|
||||
private async mergeAgent(a: Memory, b: Memory, options: LLMRequest): Promise<{name: string, description: string, content: string}> {
|
||||
const modifiedOf = (m: Memory) => this.parseFrontmatter(m.content).fm.get('modified') || 'unknown';
|
||||
|
||||
return this.llm.ask('', {
|
||||
model: options.model,
|
||||
temperature: 0.3,
|
||||
schema: {
|
||||
name: {type: 'string', description: 'New path for the merged doc, collection/subject format (e.g. Projects/Oxide) — only reuse an old title if it\'s genuinely the best fit', required: true},
|
||||
description: {type: 'string', description: 'One factual sentence describing the merged document\'s subject matter', required: true},
|
||||
content: {type: 'string', description: 'Fully reconciled body in markdown, without frontmatter', required: true},
|
||||
},
|
||||
system: `You are a knowledge base editor merging two overlapping Obsidian documents into one.
|
||||
|
||||
Structure loosely:
|
||||
\`\`\`markdown
|
||||
${GENERIC_TEMPLATE}
|
||||
\`\`\`
|
||||
|
||||
Combine both documents, resolve duplication. On contradictions, bias toward whichever document was modified more recently; drop the outdated statement unless the old context adds meaningful nuance.
|
||||
|
||||
Document A ("${a.name}", last modified ${modifiedOf(a)}):
|
||||
\`\`\`markdown
|
||||
${stripHeader(a.content)}
|
||||
\`\`\`
|
||||
|
||||
Document B ("${b.name}", last modified ${modifiedOf(b)}):
|
||||
\`\`\`markdown
|
||||
${stripHeader(b.content)}
|
||||
\`\`\``,
|
||||
});
|
||||
}
|
||||
|
||||
private parseFrontmatter(content: string): {fm: Map<string, string>, body: string} {
|
||||
const match = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
||||
if (!match) return {fm: new Map(), body: content};
|
||||
const fm = new Map<string, string>();
|
||||
for (const line of match[1].split('\n')) {
|
||||
const i = line.indexOf(':');
|
||||
if (i === -1) continue;
|
||||
const key = line.slice(0, i).trim();
|
||||
const raw = line.slice(i + 1).trim();
|
||||
let value = raw;
|
||||
try { value = JSON.parse(raw); } catch { /* legacy unquoted value, keep raw */ }
|
||||
fm.set(key, value);
|
||||
}
|
||||
return {fm, body: match[2]};
|
||||
}
|
||||
|
||||
/**
|
||||
* Find relevant memory documents for a query using description embeddings
|
||||
* @param {string} query The query to search against
|
||||
* @param {Memory[]} memories The user's memory documents
|
||||
* @param {number} limit Max number of results to return
|
||||
* @returns {Promise<Memory[]>} The most relevant memory documents
|
||||
* Writes the code-owned frontmatter block. `body` is passed through stripHeader() first so a
|
||||
* model that ignores instructions and hallucinates its own `---` block can never corrupt or
|
||||
* duplicate the real frontmatter — the LLM only ever gets to influence the body.
|
||||
*/
|
||||
async recollect(query: string, memories: Memory[], limit = 5): Promise<Memory[]> {
|
||||
private touchHeader(node: Memory, body: string): string {
|
||||
const {fm} = this.parseFrontmatter(node.content);
|
||||
fm.set('name', node.name);
|
||||
fm.set('description', node.description || '');
|
||||
fm.set('modified', new Date().toISOString());
|
||||
return this.writeFrontmatter(fm, stripHeader(body));
|
||||
}
|
||||
|
||||
private writeFrontmatter(fm: Map<string, string>, body: string): string {
|
||||
const lines = [...fm.entries()].map(([k, v]) => `${k}: ${JSON.stringify(String(v).replace(/\s+/g, ' ').trim())}`);
|
||||
return `---\n${lines.join('\n')}\n---\n\n${body.trimStart()}`;
|
||||
}
|
||||
|
||||
decay() {
|
||||
for (const [name, ttl] of this.recentlyTouched) {
|
||||
if (ttl <= 1) this.recentlyTouched.delete(name);
|
||||
else this.recentlyTouched.set(name, ttl - 1);
|
||||
}
|
||||
}
|
||||
|
||||
touch(name: string, ttl = 2) {
|
||||
this.recentlyTouched.set(name, ttl);
|
||||
}
|
||||
|
||||
forget(name: string, memories: Memory[] | MemoryCache): boolean {
|
||||
return this.access(memories).forget(name);
|
||||
}
|
||||
|
||||
/** Ranks a candidate pool by weighted title/description/body similarity against the query embedding */
|
||||
private rankByFields(query: number[], candidates: Memory[], limit: number): Memory[] {
|
||||
const scored = candidates.map(m => {
|
||||
const titleSim = m.titleEmbedding?.length ? 1 - cosineDistance(query, m.titleEmbedding) : 0;
|
||||
const descSim = m.embedding?.length ? 1 - cosineDistance(query, m.embedding) : 0;
|
||||
const bodySim = m.bodyEmbeddings?.length
|
||||
? Math.max(...m.bodyEmbeddings.map(b => 1 - cosineDistance(query, b)))
|
||||
: 0;
|
||||
return {memory: m, score: titleSim * 0.5 + descSim * 0.35 + bodySim * 0.15};
|
||||
});
|
||||
return scored.sort((a, b) => b.score - a.score).slice(0, limit).map(s => s.memory);
|
||||
}
|
||||
|
||||
async recollect(query: string, memories: Memory[] | MemoryCache, limit = 5, graphDepth = 1): Promise<Memory[]> {
|
||||
const store = this.access(memories);
|
||||
if (!store.list.length) return [];
|
||||
|
||||
await store.backfillEmbeddings(this.llm);
|
||||
|
||||
const [e] = await this.llm.embedding(query);
|
||||
return memories
|
||||
.filter(m => m.embedding?.length)
|
||||
.map(m => ({...m, score: this.llm.cosineSimilarity(m.embedding, e.embedding)}))
|
||||
.toSorted((a: any, b: any) => b.score - a.score)
|
||||
.slice(0, limit);
|
||||
if (!e) return [];
|
||||
|
||||
// Description embedding is the cheap ANN index key; pull a wider pool then re-rank by field weight
|
||||
const pool = store.search(e.embedding, Math.max(limit * 3, limit));
|
||||
const poolMemories = pool.map(r => store.find(r.name)).filter((m): m is Memory => !!m);
|
||||
const ranked = this.rankByFields(e.embedding, poolMemories, limit);
|
||||
const found = new Set<string>(ranked.map(m => m.name));
|
||||
|
||||
if (graphDepth > 0) {
|
||||
let frontier = [...found];
|
||||
for (let depth = 0; depth < graphDepth && frontier.length; depth++) {
|
||||
const next: string[] = [];
|
||||
for (const name of frontier) {
|
||||
const node = store.find(name);
|
||||
if (!node) continue;
|
||||
for (const link of node.links) {
|
||||
if (!found.has(link) && store.find(link)) {
|
||||
found.add(link);
|
||||
next.push(link);
|
||||
}
|
||||
}
|
||||
}
|
||||
frontier = next;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Two-stage memory pipeline: classify facts from conversation history then surgically merge them into documents.
|
||||
* Bot 1 (classify) extracts and groups facts cheaply. Bot 2 (edit) runs per-document in parallel with full content access.
|
||||
* @param {LLMMessage[]} history Full conversation history to digest
|
||||
* @param {Memory[]} memories The user's memory documents — mutated in place
|
||||
* @param {LLMRequest} options LLM options
|
||||
*/
|
||||
async memorize(history: LLMMessage[], memories: Memory[], options: LLMRequest): Promise<void> {
|
||||
const rankedOrder = ranked.map(m => m.name);
|
||||
const graphExpansions = [...found].filter(n => !rankedOrder.includes(n));
|
||||
return [...rankedOrder, ...graphExpansions].map(n => store.find(n)!).filter(Boolean);
|
||||
}
|
||||
|
||||
async memorize(history: LLMMessage[], memories: Memory[] | MemoryCache, options: LLMRequest): Promise<Memory[]> {
|
||||
const conversation = history
|
||||
.filter(h => h.role === 'user' || h.role === 'assistant')
|
||||
.map(h => `[${h.role}]: ${h.content}`)
|
||||
.join('\n\n');
|
||||
if(!conversation.trim()) return;
|
||||
const pools = await this.extract(conversation, memories, options);
|
||||
if(!pools.length) return;
|
||||
await Promise.all(pools.map(pool => this.edit(pool, memories, options)));
|
||||
.map(h => `[${h.role}]: ${h.content}`).join('\n\n').trim();
|
||||
if (!conversation) return [];
|
||||
|
||||
const uid = `${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
||||
const pending = {role: 'tool', name: 'memory_process', id: uid, content: conversation} as unknown as LLMMessage;
|
||||
history.push(pending);
|
||||
|
||||
const store = this.access(memories);
|
||||
const {buckets, journal} = await this.factAgent(conversation, store, options);
|
||||
const touched: Memory[] = [];
|
||||
|
||||
if (journal) {
|
||||
const journalName = `Journal/${this.getWeekMonday()}`;
|
||||
let jnode = store.find(journalName);
|
||||
if (!jnode) {
|
||||
jnode = {name: journalName, description: '', content: '', embedding: [], links: [], backlinks: []};
|
||||
store.list.push(jnode);
|
||||
}
|
||||
this.stage(jnode, `### ${new Date().toISOString().slice(0, 10)}\n${journal}`);
|
||||
touched.push(jnode);
|
||||
}
|
||||
|
||||
for (const {subject, facts} of buckets) {
|
||||
const resolved = this.resolveSubject(subject, store);
|
||||
let node = store.find(resolved);
|
||||
if (!node) {
|
||||
node = {name: resolved, description: '', content: '', embedding: [], links: [], backlinks: []};
|
||||
store.list.push(node);
|
||||
}
|
||||
this.stage(node, facts.map(f => `- ${f}`).join('\n'));
|
||||
touched.push(node);
|
||||
}
|
||||
|
||||
await Promise.all(touched.map(async node => {
|
||||
await embedMemoryFields(node, this.llm);
|
||||
this.touch(node.name);
|
||||
}));
|
||||
|
||||
if (touched.length) {
|
||||
store.commit(touched);
|
||||
(pending as any).content = `Saved to ${touched.map(n => `[[${n.name}]]`).join(', ')}`;
|
||||
Promise.all(touched.map(node => this.reconcile(node, memories, options).catch(() => {})));
|
||||
} else {
|
||||
(pending as any).content = 'Nothing worth remembering.';
|
||||
}
|
||||
|
||||
(touched as any).uid = uid;
|
||||
return touched;
|
||||
}
|
||||
|
||||
async reconcileAll(memories: Memory[] | MemoryCache, options: LLMRequest, scope: 'touched' | 'all' = 'touched'): Promise<void> {
|
||||
const store = this.access(memories);
|
||||
const targets = scope === 'all' ? store.list : store.list.filter(m => m.content.includes(PENDING_HEADING));
|
||||
await Promise.all(targets.map(node => this.reconcile(node, memories, options)));
|
||||
store.commit();
|
||||
}
|
||||
}
|
||||
|
||||
189
src/open-ai.ts
189
src/open-ai.ts
@@ -1,84 +1,72 @@
|
||||
import {OpenAI as openAI} from 'openai';
|
||||
import {findByProp, objectMap, JSONSanitize, JSONAttemptParse, clean} from '@ztimson/utils';
|
||||
import {findByProp, objectMap, JSONSanitize, JSONAttemptParse, clean, makeArray} from '@ztimson/utils';
|
||||
import {AbortablePromise, Ai} from './ai.ts';
|
||||
import {LLMMessage, LLMRequest} from './llm.ts';
|
||||
import {LLMProvider} from './provider.ts';
|
||||
import {TokenPool} from './token-pool.ts';
|
||||
import {convertSchema} from './tools.ts';
|
||||
|
||||
export class OpenAi extends LLMProvider {
|
||||
client!: openAI;
|
||||
tokenPool!: TokenPool;
|
||||
private clients = new Map<string, openAI>();
|
||||
|
||||
constructor(public readonly ai: Ai, public readonly host: string | null, public readonly token: string, public model: string) {
|
||||
constructor(public readonly ai: Ai, public readonly host: string | null, public readonly token: string | string[], public model: string) {
|
||||
super();
|
||||
this.client = new openAI(clean({
|
||||
baseURL: host,
|
||||
apiKey: token || host ? 'ignored' : undefined
|
||||
}));
|
||||
const tokens = makeArray(token).filter(Boolean);
|
||||
this.tokenPool = new TokenPool(...(tokens.length ? tokens : [host ? 'ignored' : '']));
|
||||
}
|
||||
|
||||
private toStandard(history: any[]): LLMMessage[] {
|
||||
for(let i = 0; i < history.length; i++) {
|
||||
const h = history[i];
|
||||
if(h.role === 'assistant' && h.tool_calls) {
|
||||
const tools = h.tool_calls.map((tc: any) => ({
|
||||
role: 'tool',
|
||||
id: tc.id,
|
||||
name: tc.function.name,
|
||||
args: JSONAttemptParse(tc.function.arguments, {}),
|
||||
timestamp: h.timestamp
|
||||
}));
|
||||
history.splice(i, 1, ...tools);
|
||||
i += tools.length - 1;
|
||||
} else if(h.role === 'tool' && h.content) {
|
||||
const record = history.find(h2 => h.tool_call_id == h2.id);
|
||||
if(record) {
|
||||
if(h.content.includes('"error":')) record.error = h.content;
|
||||
else record.content = h.content;
|
||||
private getClient(token: string): openAI {
|
||||
let client = this.clients.get(token);
|
||||
if(!client) {
|
||||
client = new openAI(clean({baseURL: this.host, apiKey: token || undefined}));
|
||||
this.clients.set(token, client);
|
||||
}
|
||||
history.splice(i, 1);
|
||||
i--;
|
||||
}
|
||||
if(!history[i]?.timestamp) history[i].timestamp = Date.now();
|
||||
}
|
||||
return history;
|
||||
return client;
|
||||
}
|
||||
|
||||
private fromStandard(history: LLMMessage[]): any[] {
|
||||
return history.reduce((result, h) => {
|
||||
private toWireContent(content: any): any {
|
||||
if(!Array.isArray(content)) return content;
|
||||
return content.map(c => c.type === 'image'
|
||||
? {type: 'image_url', image_url: {url: `data:${c.mime};base64,${c.data}`}}
|
||||
: {type: 'text', text: c.text});
|
||||
}
|
||||
|
||||
/** Convert standard history -> OpenAI wire format */
|
||||
private toWire(history: LLMMessage[], system?: string): any[] {
|
||||
const wire: any[] = [];
|
||||
if(system) wire.push({role: 'system', content: system});
|
||||
for(const h of history) {
|
||||
if(h.role === 'tool') {
|
||||
result.push({
|
||||
wire.push({
|
||||
role: 'assistant',
|
||||
content: null,
|
||||
tool_calls: [{id: h.id, type: 'function', function: {name: h.name, arguments: JSON.stringify(h.args)}}],
|
||||
refusal: null,
|
||||
annotations: []
|
||||
}, {
|
||||
role: 'tool',
|
||||
tool_call_id: h.id,
|
||||
content: h.error || h.content
|
||||
content: h.error || h.content || '',
|
||||
});
|
||||
} else {
|
||||
const {timestamp, ...rest} = h;
|
||||
result.push(rest);
|
||||
wire.push({role: h.role, content: this.toWireContent(h.content)});
|
||||
}
|
||||
return result;
|
||||
}, [] as any[]);
|
||||
}
|
||||
return wire;
|
||||
}
|
||||
|
||||
ask(message: string, options: LLMRequest = {}): AbortablePromise<string> {
|
||||
ask(message: string, options: LLMRequest = {}): AbortablePromise<string | any> {
|
||||
const controller = new AbortController();
|
||||
return Object.assign(new Promise<any>(async (res, rej) => {
|
||||
if(options.system) {
|
||||
if(options.history?.[0]?.role != 'system') options.history?.splice(0, 0, {role: 'system', content: options.system, timestamp: Date.now()});
|
||||
else options.history[0].content = options.system;
|
||||
}
|
||||
let history = this.fromStandard([...options.history || [], {role: 'user', content: message, timestamp: Date.now()}]);
|
||||
if(!options.history) options.history = [];
|
||||
const history = options.history;
|
||||
if(message) history.push({role: 'user', content: message, timestamp: Date.now()});
|
||||
|
||||
const tools = options.tools || this.ai.options.llm?.tools || [];
|
||||
const requestParams: any = {
|
||||
model: options.model || this.model,
|
||||
messages: history,
|
||||
stream: !!options.stream,
|
||||
max_tokens: options.max_tokens || this.ai.options.llm?.max_tokens || 4096,
|
||||
temperature: options.temperature || this.ai.options.llm?.temperature || 0.7,
|
||||
max_completion_tokens: options.maxTokens || this.ai.options.llm?.maxTokens || undefined,
|
||||
temperature: options.temperature || this.ai.options.llm?.temperature || undefined,
|
||||
tools: tools.map(t => ({
|
||||
type: 'function',
|
||||
function: {
|
||||
@@ -93,77 +81,96 @@ export class OpenAi extends LLMProvider {
|
||||
}))
|
||||
};
|
||||
|
||||
let resp: any, isFirstMessage = true;
|
||||
if(options.schema) {
|
||||
const schema = convertSchema(options.schema);
|
||||
requestParams.response_format = {type: 'json_schema', json_schema: {name: 'response', strict: true, schema}};
|
||||
}
|
||||
if(options.stream) requestParams.stream_options = {include_usage: true};
|
||||
|
||||
try {
|
||||
let terminal = false;
|
||||
do {
|
||||
resp = await this.client.chat.completions.create(requestParams).catch(err => {
|
||||
err.message += `\n\nMessages:\n${JSON.stringify(history, null, 2)}`;
|
||||
requestParams.messages = this.toWire(history.filter(h => h.role !== 'system'), options.system);
|
||||
|
||||
const callStart = Date.now();
|
||||
const resp: any = await this.tokenPool.run(token => this.getClient(token).chat.completions.create(requestParams)).catch(err => {
|
||||
err.message += `\n\nMessages:\n${JSON.stringify(requestParams.messages, null, 2)}`;
|
||||
throw err;
|
||||
});
|
||||
|
||||
let usage: any, msg: any = {content: '', tool_calls: []};
|
||||
if(options.stream) {
|
||||
if(!isFirstMessage) options.stream({text: '\n\n'});
|
||||
else isFirstMessage = false;
|
||||
resp.choices = [{message: {role: 'assistant', content: '', tool_calls: []}}];
|
||||
for await (const chunk of resp) {
|
||||
if(controller.signal.aborted) break;
|
||||
if(chunk.choices[0].delta.content) {
|
||||
resp.choices[0].message.content += chunk.choices[0].delta.content;
|
||||
if(chunk.usage) usage = chunk.usage;
|
||||
if(chunk.choices[0]?.delta?.content) {
|
||||
msg.content += chunk.choices[0].delta.content;
|
||||
options.stream({text: chunk.choices[0].delta.content});
|
||||
}
|
||||
|
||||
if(chunk.choices[0].delta.tool_calls) {
|
||||
if(chunk.choices[0]?.delta?.tool_calls) {
|
||||
for(const deltaTC of chunk.choices[0].delta.tool_calls) {
|
||||
const existing = resp.choices[0].message.tool_calls.find(tc => tc.index === deltaTC.index);
|
||||
const existing = msg.tool_calls.find((tc: any) => tc.index === deltaTC.index);
|
||||
if(existing) {
|
||||
if(deltaTC.id) existing.id = deltaTC.id;
|
||||
if(deltaTC.type) existing.type = deltaTC.type;
|
||||
if(deltaTC.function) {
|
||||
if(!existing.function) existing.function = {};
|
||||
if(deltaTC.function.name) existing.function.name = deltaTC.function.name;
|
||||
if(deltaTC.function.arguments) existing.function.arguments = (existing.function.arguments || '') + deltaTC.function.arguments;
|
||||
}
|
||||
if(deltaTC.function?.name) existing.function.name = deltaTC.function.name;
|
||||
if(deltaTC.function?.arguments) existing.function.arguments += deltaTC.function.arguments;
|
||||
} else {
|
||||
resp.choices[0].message.tool_calls.push({
|
||||
msg.tool_calls.push({
|
||||
index: deltaTC.index,
|
||||
id: deltaTC.id || '',
|
||||
type: deltaTC.type || 'function',
|
||||
function: {
|
||||
name: deltaTC.function?.name || '',
|
||||
arguments: deltaTC.function?.arguments || ''
|
||||
}
|
||||
function: {name: deltaTC.function?.name || '', arguments: deltaTC.function?.arguments || ''}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
usage = resp.usage;
|
||||
msg = resp.choices[0].message;
|
||||
}
|
||||
const duration = Date.now() - callStart;
|
||||
const tps = usage?.completion_tokens && duration > 0 ? usage.completion_tokens / (duration / 1000) : 0;
|
||||
|
||||
if(resp.error) throw new Error(resp.error);
|
||||
const toolCalls = resp.choices[0].message.tool_calls || [];
|
||||
const toolCalls = msg.tool_calls || [];
|
||||
if(toolCalls.length && !controller.signal.aborted) {
|
||||
history.push(resp.choices[0].message);
|
||||
const results = await Promise.all(toolCalls.map(async (toolCall: any) => {
|
||||
const tool = tools?.find(findByProp('name', toolCall.function.name));
|
||||
if(options.stream) options.stream({tool: toolCall.function.name});
|
||||
if(!tool) return {role: 'tool', tool_call_id: toolCall.id, content: '{"error": "Tool not found"}'};
|
||||
if(msg.content?.trim()) history.push({role: 'assistant', content: msg.content.trim(), timestamp: Date.now(), duration, tps});
|
||||
|
||||
const entries = toolCalls.map((tc: any) => {
|
||||
const entry: any = {role: 'tool', id: tc.id, name: tc.function.name, args: JSONAttemptParse(tc.function.arguments, {}), content: undefined, timestamp: Date.now()};
|
||||
history.push(entry);
|
||||
return {tc, entry};
|
||||
});
|
||||
|
||||
await Promise.all(entries.map(async ({tc, entry}: any) => {
|
||||
const tool = tools.find(findByProp('name', tc.function.name));
|
||||
if(options.stream) options.stream({tool: tc.function.name});
|
||||
if(!tool) { entry.error = 'Tool not found'; return; }
|
||||
try {
|
||||
const args = JSONAttemptParse(toolCall.function.arguments, {});
|
||||
const result = await tool.fn(args, options.stream, this.ai);
|
||||
return {role: 'tool', tool_call_id: toolCall.id, content: typeof result == 'object' ? JSONSanitize(result) : result};
|
||||
const toolStream = options.stream && ((chunk: any) => {
|
||||
if(chunk.done) { terminal = true; return; }
|
||||
options.stream!(chunk);
|
||||
});
|
||||
const result = await tool.fn(entry.args, toolStream, this.ai, tc.id);
|
||||
entry.content = typeof result === 'object' ? JSONSanitize(result) : result;
|
||||
} catch(err: any) {
|
||||
return {role: 'tool', tool_call_id: toolCall.id, content: JSONSanitize({error: err?.message || err?.toString() || 'Unknown'})};
|
||||
entry.error = err?.message || err?.toString() || 'Unknown';
|
||||
}
|
||||
}));
|
||||
history.push(...results);
|
||||
requestParams.messages = history;
|
||||
} else {
|
||||
terminal = true;
|
||||
const text = (msg.content || '').trim();
|
||||
if(text) history.push({role: 'assistant', content: text, timestamp: Date.now(), duration, tps});
|
||||
}
|
||||
} while (!controller.signal.aborted && resp.choices?.[0]?.message?.tool_calls?.length);
|
||||
history.push({role: 'assistant', content: resp.choices[0].message.content.trim() || ''});
|
||||
history = this.toStandard(history);
|
||||
} while(!terminal && !controller.signal.aborted);
|
||||
|
||||
if(options.stream) options.stream({done: true});
|
||||
if(options.history) options.history.splice(0, options.history.length, ...history);
|
||||
res(history.at(-1)?.content);
|
||||
|
||||
const turnStart = history.map(h => h.role).lastIndexOf('user');
|
||||
const finalContent = history.slice(turnStart + 1).reduce((str, h) => h.role === 'assistant' ? str + (h.content || '') : str, '').trim();
|
||||
res(options.schema ? JSONAttemptParse(finalContent, finalContent) : finalContent);
|
||||
} catch(err) {
|
||||
rej(err);
|
||||
}
|
||||
}), {abort: () => controller.abort()});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {AbortablePromise} from './ai.ts';
|
||||
import {LLMMessage, LLMRequest} from './llm.ts';
|
||||
import {LLMRequest} from './llm.ts';
|
||||
|
||||
export abstract class LLMProvider {
|
||||
abstract ask(message: string, options: LLMRequest): AbortablePromise<string>;
|
||||
|
||||
65
src/token-pool.ts
Normal file
65
src/token-pool.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
const DEFAULT_COOLDOWN = 15 * 60 * 1000;
|
||||
|
||||
type TokenState = {
|
||||
token: string;
|
||||
cooldownUntil: number; // 0 = available now
|
||||
lastError?: {code: number, message: string};
|
||||
};
|
||||
|
||||
export class TokenPoolExhaustedError extends Error {
|
||||
constructor(public tokens: Record<string, {code: number, message: string}>) {
|
||||
super(`All tokens exhausted:\n${Object.entries(tokens).map(([t, e]) => `${t}: [${e.code}] ${e.message}`).join('\n')}`);
|
||||
this.name = 'TokenPoolExhaustedError';
|
||||
}
|
||||
}
|
||||
|
||||
export class TokenPool {
|
||||
private states: TokenState[];
|
||||
|
||||
constructor(...tokens: string[]) {
|
||||
this.states = tokens.map(token => ({token, cooldownUntil: 0}));
|
||||
}
|
||||
|
||||
private preview(token: string): string {
|
||||
return token.length <= 8 ? '****' : `${token.slice(0, 4)}...${token.slice(-4)}`;
|
||||
}
|
||||
|
||||
/** Anthropic & OpenAI SDKs both attach `status` to thrown errors */
|
||||
private statusCode(err: any): number {
|
||||
return err?.status ?? err?.response?.status ?? err?.statusCode;
|
||||
}
|
||||
|
||||
private retryAfter(err: any): number {
|
||||
const headers = err?.headers || err?.response?.headers;
|
||||
const raw = headers?.get?.('retry-after') ?? headers?.['retry-after'];
|
||||
if(raw) {
|
||||
const seconds = Number(raw);
|
||||
if(!isNaN(seconds)) return Date.now() + seconds * 1000;
|
||||
const date = new Date(raw).getTime();
|
||||
if(!isNaN(date)) return date;
|
||||
}
|
||||
return Date.now() + DEFAULT_COOLDOWN;
|
||||
}
|
||||
|
||||
async run<T>(fn: (token: string) => Promise<T>): Promise<T> {
|
||||
const now = Date.now();
|
||||
for(const state of this.states) {
|
||||
if(state.cooldownUntil > now) continue;
|
||||
try {
|
||||
const result = await fn(state.token);
|
||||
state.cooldownUntil = 0;
|
||||
state.lastError = undefined;
|
||||
return result;
|
||||
} catch(err: any) {
|
||||
const code = this.statusCode(err);
|
||||
if(![401, 403, 429].includes(code)) throw err;
|
||||
state.cooldownUntil = code === 429 ? this.retryAfter(err) : Date.now() + DEFAULT_COOLDOWN;
|
||||
state.lastError = {code, message: err?.message || 'Unknown error'};
|
||||
}
|
||||
}
|
||||
|
||||
const failures: Record<string, {code: number, message: string}> = {};
|
||||
this.states.forEach(s => { if(s.lastError) failures[this.preview(s.token)] = s.lastError; });
|
||||
throw new TokenPoolExhaustedError(failures);
|
||||
}
|
||||
}
|
||||
720
src/tools.ts
720
src/tools.ts
@@ -1,6 +1,6 @@
|
||||
import * as cheerio from 'cheerio';
|
||||
import {$Sync} from '@ztimson/node-utils';
|
||||
import {ASet, consoleInterceptor, Http, fn as Fn, decodeHtml} from '@ztimson/utils';
|
||||
import {ASet, consoleInterceptor, Http, fn as Fn, decodeHtml, objectMap} from '@ztimson/utils';
|
||||
import * as os from 'node:os';
|
||||
import {Ai} from './ai.ts';
|
||||
import {LLMRequest} from './llm.ts';
|
||||
@@ -41,28 +41,83 @@ export type AiTool = {
|
||||
/** Tool arguments */
|
||||
args?: AiToolArg,
|
||||
/** Callback function */
|
||||
fn: (args: any, stream: LLMRequest['stream'], ai: Ai) => any | Promise<any>,
|
||||
fn: (args: any, stream: LLMRequest['stream'], ai: Ai, toolId?: string) => any | Promise<any>,
|
||||
};
|
||||
|
||||
export const CliTool: AiTool = {
|
||||
export function convertSchema(schema: any): any {
|
||||
if(!schema) return null;
|
||||
|
||||
const convertProp = (prop: any): any => {
|
||||
const converted: any = {
|
||||
type: prop.type || 'string',
|
||||
};
|
||||
|
||||
if(prop.description) converted.description = prop.description;
|
||||
if(prop.default !== undefined) converted.default = prop.default;
|
||||
if(prop.enum) converted.enum = prop.enum;
|
||||
if(prop.pattern) converted.pattern = prop.pattern;
|
||||
|
||||
// Handle array items
|
||||
if(prop.type === 'array' && prop.items) {
|
||||
converted.items = convertProp(prop.items);
|
||||
}
|
||||
|
||||
// Handle object properties
|
||||
if(prop.type === 'object' && prop.items) {
|
||||
converted.properties = objectMap(prop.items, (key, value) => convertProp(value));
|
||||
const required = Object.entries(prop.items).filter(([_, v]: any) => v.required).map(([k]) => k);
|
||||
if(required.length) converted.required = required;
|
||||
converted.additionalProperties = false;
|
||||
}
|
||||
|
||||
// Handle min/max based on type
|
||||
if(prop.min !== undefined) {
|
||||
if(prop.type === 'string' || prop.type === 'array') converted.minLength = prop.min;
|
||||
else converted.minimum = prop.min;
|
||||
}
|
||||
if(prop.max !== undefined) {
|
||||
if(prop.type === 'string' || prop.type === 'array') converted.maxLength = prop.max;
|
||||
else converted.maximum = prop.max;
|
||||
}
|
||||
|
||||
return converted;
|
||||
};
|
||||
|
||||
return {
|
||||
type: 'object',
|
||||
properties: objectMap(schema, (key, value) => convertProp(value)),
|
||||
required: Object.entries(schema).filter(([_, v]: any) => v.required).map(([k]) => k),
|
||||
additionalProperties: false
|
||||
};
|
||||
}
|
||||
|
||||
export const ExecCliTool: AiTool = {
|
||||
name: 'cli',
|
||||
description: 'Use the command line interface, returns any output',
|
||||
args: {command: {type: 'string', description: 'Command to run', required: true}},
|
||||
fn: (args: {command: string}) => $Sync`${args.command}`
|
||||
}
|
||||
|
||||
export const DateTimeTool: AiTool = {
|
||||
name: 'get_datetime',
|
||||
description: 'Get local date / time',
|
||||
args: {},
|
||||
fn: async () => new Date().toString()
|
||||
export const ExecJSTool: AiTool = {
|
||||
name: 'exec_javascript',
|
||||
description: 'Execute commonjs javascript',
|
||||
args: {
|
||||
code: {type: 'string', description: 'CommonJS javascript', required: true}
|
||||
},
|
||||
fn: async (args: {code: string}) => {
|
||||
const c = consoleInterceptor(null);
|
||||
const resp = await Fn<any>({console: c}, args.code, true).catch((err: any) => c.output.error.push(err));
|
||||
return {...c.output, return: resp, stdout: undefined, stderr: undefined};
|
||||
}
|
||||
}
|
||||
|
||||
export const DateTimeUTCTool: AiTool = {
|
||||
name: 'get_datetime_utc',
|
||||
description: 'Get current UTC date / time',
|
||||
args: {},
|
||||
fn: async () => new Date().toUTCString()
|
||||
export const ExecPythonTool: AiTool = {
|
||||
name: 'exec_python',
|
||||
description: 'Execute commonjs javascript',
|
||||
args: {
|
||||
code: {type: 'string', description: 'CommonJS javascript', required: true}
|
||||
},
|
||||
fn: async (args: {code: string}) => ({result: $Sync`python -c "${args.code}"`})
|
||||
}
|
||||
|
||||
export const ExecTool: AiTool = {
|
||||
@@ -76,11 +131,11 @@ export const ExecTool: AiTool = {
|
||||
try {
|
||||
switch(args.language) {
|
||||
case 'cli':
|
||||
return await CliTool.fn({command: args.code}, stream, ai);
|
||||
return await ExecCliTool.fn({command: args.code}, stream, ai);
|
||||
case 'node':
|
||||
return await JSTool.fn({code: args.code}, stream, ai);
|
||||
return await ExecJSTool.fn({code: args.code}, stream, ai);
|
||||
case 'python':
|
||||
return await PythonTool.fn({code: args.code}, stream, ai);
|
||||
return await ExecPythonTool.fn({code: args.code}, stream, ai);
|
||||
default:
|
||||
throw new Error(`Unsupported language: ${args.language}`);
|
||||
}
|
||||
@@ -90,8 +145,483 @@ export const ExecTool: AiTool = {
|
||||
}
|
||||
}
|
||||
|
||||
export const FetchTool: AiTool = {
|
||||
name: 'fetch',
|
||||
export const FsDeleteTool = (whitelist: null | string[] = null): AiTool => {
|
||||
return {
|
||||
name: 'fs_delete',
|
||||
description: 'Delete a file or directory',
|
||||
args: {
|
||||
path: {type: 'string', description: 'Path to file or directory', required: true},
|
||||
recursive: {type: 'boolean', description: 'Delete all children', required: false}
|
||||
},
|
||||
fn: async ({path, recursive = false}) => {
|
||||
const {existsSync, rmSync} = await import('fs');
|
||||
const normalizePath = p => p.replace(/\\/g, '/');
|
||||
|
||||
path = normalizePath(path);
|
||||
if(whitelist && !whitelist.some(p => path.startsWith(p))) return {error: 'Permission denied'};
|
||||
if(!existsSync(path)) return {error: 'Path does not exist'};
|
||||
|
||||
rmSync(path, {recursive, force: true});
|
||||
return {success: true, path};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const FsMoveTool = (whitelist: null | string[] = null): AiTool => {
|
||||
return {
|
||||
name: 'fs_move',
|
||||
description: 'Move or rename a file or directory',
|
||||
args: {
|
||||
source: {type: 'string', description: 'Path to source file or directory', required: true},
|
||||
destination: {type: 'string', description: 'Path to destination file or directory', required: true}
|
||||
},
|
||||
fn: async ({source, destination}) => {
|
||||
const {existsSync, renameSync} = await import('fs');
|
||||
const normalizePath = p => p.replace(/\\/g, '/');
|
||||
|
||||
source = normalizePath(source);
|
||||
destination = normalizePath(destination);
|
||||
if(whitelist && !whitelist.some(p => source.startsWith(p) && destination.startsWith(p))) return {error: 'Permission denied'};
|
||||
|
||||
if(!existsSync(source)) return {error: 'Source path does not exist'};
|
||||
if(existsSync(destination)) return {error: 'Destination path already exists'};
|
||||
|
||||
renameSync(source, destination);
|
||||
return {success: true, source, destination};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const FsReadTool = (whitelist: null | string[] = null): AiTool => {
|
||||
return {
|
||||
name: 'fs_read',
|
||||
description: 'Read the contents of a provided path. Works with files and directories',
|
||||
args: {path: {type: 'string', description: 'Path to file or directory', required: true}},
|
||||
fn: async ({path}) => {
|
||||
const {existsSync, lstatSync, readdirSync, readFileSync} = await import('fs');
|
||||
const {join} = await import('path');
|
||||
const normalizePath = p => p.replace(/\\/g, '/');
|
||||
|
||||
path = normalizePath(path);
|
||||
if(whitelist && !whitelist.some(p => path.startsWith(p))) return {error: 'Permission denied'};
|
||||
|
||||
if(!existsSync(path)) return {error: 'Path does not exist'};
|
||||
const stats = lstatSync(path);
|
||||
if(stats.isDirectory()) {
|
||||
const children = readdirSync(path).map(name => {
|
||||
const childPath = normalizePath(join(path, name));
|
||||
const childStats = lstatSync(childPath);
|
||||
return {name, type: childStats.isDirectory() ? 'directory' : 'file', size: childStats.size};
|
||||
});
|
||||
return {type: 'directory', children};
|
||||
}
|
||||
const content = readFileSync(path, 'utf-8');
|
||||
return {type: 'file', content};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const FsSearchTool = (whitelist: null | string[] = null): AiTool => {
|
||||
return {
|
||||
name: 'fs_search',
|
||||
description: 'Scan a directory for matching glob patterns (e.g. "**/*.js", "src/**/*.test.ts")',
|
||||
args: {
|
||||
pattern: {type: 'string', description: 'Glob pattern to match against paths', required: true},
|
||||
root: {type: 'string', description: 'Directory to search from', required: false, default: '.'}
|
||||
},
|
||||
fn: async ({pattern, root = '.'}) => {
|
||||
const {existsSync, lstatSync, readdirSync} = await import('fs');
|
||||
const {join, relative} = await import('path');
|
||||
const normalizePath = p => p.replace(/\\/g, '/');
|
||||
|
||||
root = normalizePath(root);
|
||||
if(!existsSync(root)) return {error: 'Root path does not exist'};
|
||||
if(!lstatSync(root).isDirectory()) return {error: 'Root path is not a directory'};
|
||||
|
||||
if(whitelist && !whitelist.some(p => root.startsWith(p))) return {error: 'Permission denied'};
|
||||
|
||||
const globToRegex = (glob) => {
|
||||
let re = '';
|
||||
for(let i = 0; i < glob.length; i++) {
|
||||
const c = glob[i];
|
||||
if(c === '*') {
|
||||
if(glob[i + 1] === '*') {
|
||||
const isSlash = glob[i + 2] === '/';
|
||||
re += '.*';
|
||||
i += isSlash ? 2 : 1;
|
||||
} else {
|
||||
re += '[^/]*';
|
||||
}
|
||||
} else if(c === '?') {
|
||||
re += '[^/]';
|
||||
} else if('.+^$(){}|[]\\'.includes(c)) {
|
||||
re += '\\' + c;
|
||||
} else {
|
||||
re += c;
|
||||
}
|
||||
}
|
||||
return new RegExp('^' + re + '$');
|
||||
};
|
||||
const regex = globToRegex(pattern);
|
||||
|
||||
const results: any = [];
|
||||
const walk = (dir) => {
|
||||
for(const name of readdirSync(dir)) {
|
||||
const fullPath = normalizePath(join(dir, name));
|
||||
const stats = lstatSync(fullPath);
|
||||
const relPath = normalizePath(relative(root, fullPath));
|
||||
if(regex.test(relPath)) {
|
||||
results.push({path: relPath, type: stats.isDirectory() ? 'directory' : 'file', size: stats.size});
|
||||
}
|
||||
if(stats.isDirectory()) walk(fullPath);
|
||||
}
|
||||
};
|
||||
walk(root);
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const FsWriteTool = (whitelist: null | string[] = null): AiTool => {
|
||||
return {
|
||||
name: 'fs_write',
|
||||
description: 'Create a directory, write content to a file or preform a find & replace',
|
||||
args: {
|
||||
path: {type: 'string', description: 'Path to file or directory', required: true},
|
||||
content: {type: 'string', description: 'Content to write or replace (Omit to create a directory)'},
|
||||
find: {type: 'string', description: 'Text or regex pattern to match (regex must match pattern: "/pattern/g")'}
|
||||
},
|
||||
fn: async ({path, content, find}) => {
|
||||
const {existsSync, mkdirSync, readFileSync, writeFileSync} = await import('fs');
|
||||
const {dirname} = await import('path');
|
||||
const normalizePath = p => p.replace(/\\/g, '/');
|
||||
|
||||
path = normalizePath(path);
|
||||
if(whitelist && !whitelist.some(p => path.startsWith(p))) return {error: 'Permission denied'};
|
||||
|
||||
if(content === undefined) {
|
||||
mkdirSync(path, {recursive: true});
|
||||
return {success: true, type: 'directory', path};
|
||||
}
|
||||
|
||||
const dir = normalizePath(dirname(path));
|
||||
if(!existsSync(dir)) mkdirSync(dir, {recursive: true});
|
||||
|
||||
if(find && existsSync(path)) {
|
||||
const existing = readFileSync(path, 'utf-8');
|
||||
const regexMatch = find.match(/^\/(.+)\/([gimuy]*)$/);
|
||||
const pattern = regexMatch ? new RegExp(regexMatch[1], regexMatch[2]) : find;
|
||||
|
||||
if(!existing.match(pattern)) return {error: 'Find pattern not found in file'};
|
||||
|
||||
const updated = existing.replace(pattern, content);
|
||||
writeFileSync(path, updated, 'utf-8');
|
||||
return {success: true, type: 'file', path, replaced: true, content: updated};
|
||||
}
|
||||
|
||||
writeFileSync(path, content, 'utf-8');
|
||||
return {success: true, type: 'file', path, content};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const GetPathsTool: AiTool = {
|
||||
name: 'get_paths',
|
||||
description: 'Get the current working directory, and paths to the users home directory',
|
||||
fn: async () => {
|
||||
return {
|
||||
home: os.homedir(),
|
||||
cwd: process.cwd()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const GetDatetimeTool: AiTool = {
|
||||
name: 'get_datetime',
|
||||
description: 'Get local/UTC timestamp',
|
||||
args: {
|
||||
timezone: {type: 'string', description: 'Which timezone to return, defaults to local', enum: ['local', 'utc'], default: 'local'}
|
||||
},
|
||||
fn: ({timezone}) => new Date()[timezone === 'local' ? 'toString' : 'toUTCString']()
|
||||
}
|
||||
|
||||
export const GetDevice: AiTool = {
|
||||
name: 'get_device',
|
||||
description: 'Get comprehensive system information including hostname, specs, load, storage, and network status',
|
||||
args: {},
|
||||
fn: async () => {
|
||||
const platform = os.platform();
|
||||
const hostname = os.hostname();
|
||||
|
||||
// CPU Info
|
||||
const cpus = os.cpus();
|
||||
const cpuModel = cpus[0].model;
|
||||
const cpuCores = cpus.length;
|
||||
|
||||
// Memory Info
|
||||
const totalMem: any = (os.totalmem() / 1024 / 1024 / 1024).toFixed(2);
|
||||
const freeMem: any = (os.freemem() / 1024 / 1024 / 1024).toFixed(2);
|
||||
const usedMem: any = (totalMem - freeMem).toFixed(2);
|
||||
const memUsage: any = ((usedMem / totalMem) * 100).toFixed(1);
|
||||
|
||||
// Load Average (not available on Windows)
|
||||
const loadAvg = platform === 'win32' ? ['N/A', 'N/A', 'N/A'] : os.loadavg().map(l => l.toFixed(2));
|
||||
|
||||
// Storage Usage
|
||||
let storage = {};
|
||||
if(platform === 'win32') {
|
||||
const ps = $Sync`powershell "Get-PSDrive C | Select-Object Used,Free | ConvertTo-Json"`.trim();
|
||||
const drive = JSON.parse(ps);
|
||||
const used: any = (drive.Used / 1024 / 1024 / 1024).toFixed(2);
|
||||
const free: any = (drive.Free / 1024 / 1024 / 1024).toFixed(2);
|
||||
const total: any = (parseFloat(used) + parseFloat(free)).toFixed(2);
|
||||
const usage: any = ((used / total) * 100).toFixed(1);
|
||||
storage = {
|
||||
filesystem: 'C:',
|
||||
size: `${total} GB`,
|
||||
used: `${used} GB`,
|
||||
available: `${free} GB`,
|
||||
usage: `${usage}%`
|
||||
};
|
||||
} else {
|
||||
const df = $Sync`df -h / | tail -1`.trim();
|
||||
const s = df.split(/\s+/);
|
||||
storage = {
|
||||
filesystem: s[0],
|
||||
size: s[1],
|
||||
used: s[2],
|
||||
available: s[3],
|
||||
usage: s[4]
|
||||
};
|
||||
}
|
||||
|
||||
// Network Status
|
||||
const interfaces = os.networkInterfaces();
|
||||
const activeIfaces = Object.entries(interfaces)
|
||||
.filter(([name]) => name !== 'lo' && !name.includes('Loopback'))
|
||||
.map(([name, addrs]) => {
|
||||
const ipv4 = addrs?.find(a => a.family === 'IPv4');
|
||||
return ipv4 ? {name, ip: ipv4.address} : null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
// Internet connectivity check
|
||||
let internet = false;
|
||||
try {
|
||||
if(platform === 'win32') {
|
||||
$Sync`powershell "Test-Connection -ComputerName 8.8.8.8 -Count 1 -Quiet"`;
|
||||
} else {
|
||||
$Sync`ping -c 1 -W 2 8.8.8.8 > /dev/null 2>&1`;
|
||||
}
|
||||
internet = true;
|
||||
} catch {}
|
||||
|
||||
// Uptime
|
||||
const uptime = os.uptime();
|
||||
const days = Math.floor(uptime / 86400);
|
||||
const hours = Math.floor((uptime % 86400) / 3600);
|
||||
const minutes = Math.floor((uptime % 3600) / 60);
|
||||
|
||||
return {
|
||||
hostname,
|
||||
cpu: {
|
||||
model: cpuModel,
|
||||
cores: cpuCores
|
||||
},
|
||||
memory: {
|
||||
total: `${totalMem} GB`,
|
||||
used: `${usedMem} GB`,
|
||||
free: `${freeMem} GB`,
|
||||
usage: `${memUsage}%`
|
||||
},
|
||||
load: {
|
||||
'1min': loadAvg[0],
|
||||
'5min': loadAvg[1],
|
||||
'15min': loadAvg[2]
|
||||
},
|
||||
storage,
|
||||
network: {
|
||||
interfaces: activeIfaces,
|
||||
internet: internet ? 'connected' : 'disconnected'
|
||||
},
|
||||
uptime: `${days}d ${hours}h ${minutes}m`,
|
||||
platform: `${os.type()} ${os.release()}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const GetWikipediaTool: AiTool = {
|
||||
name: 'get_wikipedia',
|
||||
description: 'Search Wikipedia for matching articles',
|
||||
args: {
|
||||
query: {type: 'string', description: 'Search term or article title', required: true},
|
||||
mode: {type: 'string', description: 'search - look for articles, summary - intro of first found article (default), full - complete first found article', enum: ['search', 'summary', 'full'], default: 'summary'},
|
||||
ua: {type: 'string', description: 'User Agent'},
|
||||
},
|
||||
fn: async ({query, mode, ua}) => {
|
||||
class WikipediaClient {
|
||||
useragent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)';
|
||||
|
||||
constructor(useragent: string) {
|
||||
this.useragent = useragent;
|
||||
}
|
||||
|
||||
async get(url) {
|
||||
const resp = await fetch(url, {headers: {'User-Agent': this.useragent}});
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
api(params) {
|
||||
const qs = new URLSearchParams({...params, format: 'json', utf8: '1'}).toString();
|
||||
return this.get(`https://en.wikipedia.org/w/api.php?${qs}`);
|
||||
}
|
||||
|
||||
clean(text) {
|
||||
const cutoffs = ['== See also ==', '== References ==', '== Bibliography ==', '== External links =='];
|
||||
for (const marker of cutoffs) {
|
||||
const idx = text.indexOf(marker);
|
||||
if (idx !== -1) text = text.slice(0, idx);
|
||||
}
|
||||
|
||||
return text
|
||||
.replace(/^={4}\s*(.+?)\s*={4}$/gm, '#### $1')
|
||||
.replace(/^={3}\s*(.+?)\s*={3}$/gm, '### $1')
|
||||
.replace(/^={2}\s*(.+?)\s*={2}$/gm, '## $1')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.replace(/ {2,}/g, ' ')
|
||||
.replace(/\[\d+]/g, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
async searchTitles(query: string, limit = 6) {
|
||||
const data = await this.api({action: 'query', list: 'search', srsearch: query, srlimit: limit, srprop: 'snippet'});
|
||||
return data.query?.search || [];
|
||||
}
|
||||
|
||||
async fetchExtract(title: string, introOnly = false) {
|
||||
const params: any = {action: 'query', prop: 'extracts', titles: title, explaintext: 1, redirects: 1};
|
||||
if(introOnly) params.exintro = 1;
|
||||
const data = await this.api(params);
|
||||
const page: any = Object.values(data.query?.pages || {})[0];
|
||||
return this.clean(page?.extract || '');
|
||||
}
|
||||
|
||||
pageUrl(title: string) {
|
||||
return `https://en.wikipedia.org/wiki/${encodeURIComponent(title.replace(/ /g, '_'))}`;
|
||||
}
|
||||
|
||||
stripHtml(text: string) {
|
||||
return text.replace(/<[^>]+>/g, '');
|
||||
}
|
||||
|
||||
async lookup(query: string, detail = 'summary') {
|
||||
const results = await this.searchTitles(query, 6);
|
||||
if(!results.length) return `❌ No Wikipedia articles found for "${query}"`;
|
||||
const title = results[0].title;
|
||||
const url = this.pageUrl(title);
|
||||
const introOnly = detail !== 'full';
|
||||
const content = await this.fetchExtract(title, introOnly);
|
||||
return `## ${title}\n🔗 ${url}\n\n${content}`;
|
||||
}
|
||||
|
||||
async search(query: string) {
|
||||
const results = await this.searchTitles(query, 8);
|
||||
if(!results.length) return `❌ No results for "${query}"`;
|
||||
const lines = [`### Search results for "${query}"\n`];
|
||||
for(let i = 0; i < results.length; i++) {
|
||||
const r = results[i];
|
||||
const snippet = this.stripHtml(r.snippet || '').trim();
|
||||
lines.push(`**${i + 1}. ${r.title}**\n${snippet}\n${this.pageUrl(r.title)}`);
|
||||
}
|
||||
return lines.join('\n\n');
|
||||
}
|
||||
}
|
||||
|
||||
const wiki = new WikipediaClient(ua);
|
||||
if(mode === 'search') return wiki.search(query);
|
||||
return wiki.lookup(query, mode || 'summary');
|
||||
}
|
||||
};
|
||||
|
||||
export const GeoCodeTool: AiTool = {
|
||||
name: 'geo_code',
|
||||
description: 'Converts coordinates to address OR vice versa',
|
||||
args: {
|
||||
query: {type: 'string', description: 'Search query - coordinates (lat,lon) or address string', required: true},
|
||||
},
|
||||
fn: async ({query}) => {
|
||||
const coordinates = /(-?\d+(?:\.\d+)?).*?,.*?(-?\d+(?:\.\d+)?)/.exec(query);
|
||||
if(coordinates) { // Geolocate
|
||||
const url = `https://nominatim.openstreetmap.org/reverse?format=json&lat=${encodeURIComponent(coordinates[1])}&lon=${encodeURIComponent(coordinates[2])}`;
|
||||
const response = await fetch(url, {headers: {'User-Agent': 'OpenSight/1.0', 'Accept-Language': 'en'}});
|
||||
const data = await response.json();
|
||||
if(data.display_name) return {address: data.display_name, mode: 'geolocate'};
|
||||
} else { // Geocode
|
||||
const url = `https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}`;
|
||||
const response = await fetch(url, {headers: {'User-Agent': 'OpenSight/1.0'}});
|
||||
const data = await response.json();
|
||||
if(data[0]) return {latitude: parseFloat(data[0].lat), longitude: parseFloat(data[0].lon), mode: 'geocode'};
|
||||
}
|
||||
return {error: 'Not found'};
|
||||
},
|
||||
}
|
||||
|
||||
export const GeoWeatherTool: AiTool = {
|
||||
name: 'geo_weather',
|
||||
description: 'Gets weather and air quality info for a location and time',
|
||||
args: {
|
||||
query: {type: 'string', description: 'Location - address or place name', required: true},
|
||||
day: {type: 'string', description: 'Date to retrieve (YYYY-MM-DD), defaults to today'},
|
||||
},
|
||||
fn: async ({query, day}) => {
|
||||
day = day || new Date().toISOString().slice(0, 10);
|
||||
|
||||
const geoUrl = `https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}`;
|
||||
const geoResponse = await fetch(geoUrl, {headers: {'User-Agent': 'OpenSight/1.0'}});
|
||||
const geoData = await geoResponse.json();
|
||||
if(!geoData[0]) return {error: 'Location not found'};
|
||||
|
||||
const lat = parseFloat(geoData[0].lat);
|
||||
const lon = parseFloat(geoData[0].lon);
|
||||
|
||||
const weatherUrl = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}&start_date=${day}&end_date=${day}&daily=weathercode,temperature_2m_max,temperature_2m_min,apparent_temperature_max,apparent_temperature_min,precipitation_sum,precipitation_probability_max,windspeed_10m_max,winddirection_10m_dominant,uv_index_max,sunrise,sunset&timezone=auto`;
|
||||
const airUrl = `https://air-quality-api.open-meteo.com/v1/air-quality?latitude=${lat}&longitude=${lon}&start_date=${day}&end_date=${day}&hourly=us_aqi,european_aqi,pm10,pm2_5&timezone=auto`;
|
||||
|
||||
const [weatherResponse, airResponse] = await Promise.all([fetch(weatherUrl), fetch(airUrl)]);
|
||||
const weatherData = await weatherResponse.json();
|
||||
const airData = await airResponse.json();
|
||||
|
||||
const avg = arr => (arr && arr.length) ? arr.reduce((a, b) => a + b, 0) / arr.length : null;
|
||||
|
||||
return {
|
||||
location: geoData[0].display_name,
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
elevation: weatherData.elevation,
|
||||
date: day,
|
||||
weatherCode: weatherData.daily?.weathercode?.[0],
|
||||
tempMax: weatherData.daily?.temperature_2m_max?.[0],
|
||||
tempMin: weatherData.daily?.temperature_2m_min?.[0],
|
||||
feelsLikeMax: weatherData.daily?.apparent_temperature_max?.[0],
|
||||
feelsLikeMin: weatherData.daily?.apparent_temperature_min?.[0],
|
||||
precipitation: weatherData.daily?.precipitation_sum?.[0],
|
||||
precipitationChance: weatherData.daily?.precipitation_probability_max?.[0],
|
||||
windSpeedMax: weatherData.daily?.windspeed_10m_max?.[0],
|
||||
windDirection: weatherData.daily?.winddirection_10m_dominant?.[0],
|
||||
uvIndexMax: weatherData.daily?.uv_index_max?.[0],
|
||||
sunrise: weatherData.daily?.sunrise?.[0],
|
||||
sunset: weatherData.daily?.sunset?.[0],
|
||||
usAqi: avg(airData.hourly?.us_aqi),
|
||||
europeanAqi: avg(airData.hourly?.european_aqi),
|
||||
pm10: avg(airData.hourly?.pm10),
|
||||
pm2_5: avg(airData.hourly?.pm2_5),
|
||||
};
|
||||
},
|
||||
}
|
||||
|
||||
export const WebFetchTool: AiTool = {
|
||||
name: 'web_fetch',
|
||||
description: 'Make HTTP request to URL',
|
||||
args: {
|
||||
url: {type: 'string', description: 'URL to fetch', required: true},
|
||||
@@ -107,30 +637,59 @@ export const FetchTool: AiTool = {
|
||||
}) => new Http({url: args.url, headers: args.headers}).request({method: args.method || 'GET', body: args.body})
|
||||
}
|
||||
|
||||
export const JSTool: AiTool = {
|
||||
name: 'exec_javascript',
|
||||
description: 'Execute commonjs javascript',
|
||||
export const WebFlareSolverTool = (host: string) => {
|
||||
return {
|
||||
name: 'web_flaresolverr',
|
||||
description: 'Use a flaresolverr proxy to bypass cloudflare bot detection',
|
||||
args: {
|
||||
code: {type: 'string', description: 'CommonJS javascript', required: true}
|
||||
url: {type: 'string', description: 'URL to fetch', required: true},
|
||||
cmd: {type: 'string', description: 'Flaresolverr cmd', enum: ['request.get', 'request.post'], default: 'request.get'},
|
||||
maxTimeout: {type: 'number', description: 'Fetch time limit', default: 60_000},
|
||||
postData: {type: 'object', description: 'Data to send during request.post requests'},
|
||||
},
|
||||
fn: async (args: {code: string}) => {
|
||||
const c = consoleInterceptor(null);
|
||||
const resp = await Fn<any>({console: c}, args.code, true).catch((err: any) => c.output.error.push(err));
|
||||
return {...c.output, return: resp, stdout: undefined, stderr: undefined};
|
||||
fn: async ({url, cmd, maxTimeout, postData}) => {
|
||||
function toFormUrlEncoded(obj, prefix = '') {
|
||||
const pairs: any = [];
|
||||
for (const key in obj) {
|
||||
if (!obj.hasOwnProperty(key)) continue;
|
||||
|
||||
const value = obj[key];
|
||||
const encodedKey = prefix
|
||||
? `${prefix}[${encodeURIComponent(key)}]`
|
||||
: encodeURIComponent(key);
|
||||
|
||||
if (value === null || value === undefined) {
|
||||
pairs.push(`${encodedKey}=`);
|
||||
} else if (typeof value === 'object' && !Array.isArray(value)) {
|
||||
pairs.push(toFormUrlEncoded(value, encodedKey));
|
||||
} else if (Array.isArray(value)) {
|
||||
value.forEach(item => {
|
||||
pairs.push(`${encodedKey}[]=${encodeURIComponent(item)}`);
|
||||
});
|
||||
} else {
|
||||
pairs.push(`${encodedKey}=${encodeURIComponent(value)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export const PythonTool: AiTool = {
|
||||
name: 'exec_javascript',
|
||||
description: 'Execute commonjs javascript',
|
||||
args: {
|
||||
code: {type: 'string', description: 'CommonJS javascript', required: true}
|
||||
},
|
||||
fn: async (args: {code: string}) => ({result: $Sync`python -c "${args.code}"`})
|
||||
return pairs.join('&');
|
||||
}
|
||||
|
||||
export const ReadWebpageTool: AiTool = {
|
||||
name: 'read_webpage',
|
||||
const res = await fetch(host + '/v1', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({cmd, url, maxTimeout, postData: postData ? toFormUrlEncoded(postData) : undefined}),
|
||||
});
|
||||
|
||||
if(!res.ok) throw new Error(`FlareSolverr HTTP error: ${res.status} ${res.statusText}`);
|
||||
const data = await res.json();
|
||||
if(data.status !== 'ok') throw new Error(`FlareSolverr error: ${data.message ?? data.status}`);
|
||||
return data.solution.response;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const WebReadTool: AiTool = {
|
||||
name: 'web_read',
|
||||
description: 'Extract clean content from webpages, or convert media/documents to accessible formats',
|
||||
args: {
|
||||
url: {type: 'string', description: 'URL to read', required: true},
|
||||
@@ -258,94 +817,3 @@ export const WebSearchTool: AiTool = {
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
class WikipediaClient {
|
||||
private async get(url: string): Promise<any> {
|
||||
const resp = await fetch(url, {headers: {'User-Agent': UA}});
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
private api(params: Record<string, any>): Promise<any> {
|
||||
const qs = new URLSearchParams({...params, format: 'json', utf8: '1'}).toString();
|
||||
return this.get(`https://en.wikipedia.org/w/api.php?${qs}`);
|
||||
}
|
||||
|
||||
private clean(text: string): string {
|
||||
return text.replace(/\n{3,}/g, '\n\n').replace(/ {2,}/g, ' ').replace(/\[\d+\]/g, '').trim();
|
||||
}
|
||||
|
||||
private truncate(text: string, max: number): string {
|
||||
if(text.length <= max) return text;
|
||||
const cut = text.slice(0, max);
|
||||
const lastPara = cut.lastIndexOf('\n\n');
|
||||
return lastPara > max * 0.7 ? cut.slice(0, lastPara) : cut;
|
||||
}
|
||||
|
||||
private async searchTitles(query: string, limit = 6): Promise<any[]> {
|
||||
const data = await this.api({action: 'query', list: 'search', srsearch: query, srlimit: limit, srprop: 'snippet'});
|
||||
return data.query?.search || [];
|
||||
}
|
||||
|
||||
private async fetchExtract(title: string, intro = false): Promise<string> {
|
||||
const params: any = {action: 'query', prop: 'extracts', titles: title, explaintext: 1, redirects: 1};
|
||||
if(intro) params.exintro = 1;
|
||||
const data = await this.api(params);
|
||||
const page = Object.values(data.query?.pages || {})[0] as any;
|
||||
return this.clean(page?.extract || '');
|
||||
}
|
||||
|
||||
private pageUrl(title: string): string {
|
||||
return `https://en.wikipedia.org/wiki/${encodeURIComponent(title.replace(/ /g, '_'))}`;
|
||||
}
|
||||
|
||||
private stripHtml(text: string): string {
|
||||
return text.replace(/<[^>]+>/g, '');
|
||||
}
|
||||
|
||||
async lookup(query: string, detail: 'intro' | 'full' = 'intro'): Promise<string> {
|
||||
const results = await this.searchTitles(query, 6);
|
||||
if(!results.length) return `❌ No Wikipedia articles found for "${query}"`;
|
||||
const title = results[0].title;
|
||||
const url = this.pageUrl(title);
|
||||
const content = await this.fetchExtract(title, detail === 'intro');
|
||||
const text = this.truncate(content, detail === 'intro' ? 2000 : 8000);
|
||||
return `## ${title}\n🔗 ${url}\n\n${text}`;
|
||||
}
|
||||
|
||||
async search(query: string): Promise<string> {
|
||||
const results = await this.searchTitles(query, 8);
|
||||
if(!results.length) return `❌ No results for "${query}"`;
|
||||
const lines = [`### Search results for "${query}"\n`];
|
||||
for(let i = 0; i < results.length; i++) {
|
||||
const r = results[i];
|
||||
const snippet = this.truncate(this.stripHtml(r.snippet || ''), 150);
|
||||
lines.push(`**${i + 1}. ${r.title}**\n${snippet}\n${this.pageUrl(r.title)}`);
|
||||
}
|
||||
return lines.join('\n\n');
|
||||
}
|
||||
}
|
||||
|
||||
export const WikipediaLookupTool: AiTool = {
|
||||
name: 'wikipedia_lookup',
|
||||
description: 'Get Wikipedia article content',
|
||||
args: {
|
||||
query: {type: 'string', description: 'Topic or article title', required: true},
|
||||
detail: {type: 'string', description: 'Content level: "intro" (summary, default) or "full" (complete article)', enum: ['intro', 'full'], default: 'intro'}
|
||||
},
|
||||
fn: async (args: {query: string; detail?: 'intro' | 'full'}) => {
|
||||
const wiki = new WikipediaClient();
|
||||
return wiki.lookup(args.query, args.detail || 'intro');
|
||||
}
|
||||
};
|
||||
|
||||
export const WikipediaSearchTool: AiTool = {
|
||||
name: 'wikipedia_search',
|
||||
description: 'Search Wikipedia for matching articles',
|
||||
args: {
|
||||
query: {type: 'string', description: 'Search terms', required: true}
|
||||
},
|
||||
fn: async (args: {query: string}) => {
|
||||
const wiki = new WikipediaClient();
|
||||
return wiki.search(args.query);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -12,12 +12,31 @@ export class Vision {
|
||||
*/
|
||||
ocr(path: string): AbortablePromise<string | null> {
|
||||
let worker: any;
|
||||
const p = new Promise<string | null>(async res => {
|
||||
let reject: (err: any) => void;
|
||||
|
||||
const handler = (err: Error) => {
|
||||
if(err.stack?.includes('tesseract.js')) {
|
||||
process.off('uncaughtException', handler);
|
||||
reject?.(err);
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
};
|
||||
process.on('uncaughtException', handler);
|
||||
|
||||
const p = (async () => {
|
||||
worker = await createWorker(this.ai.options.ocr || 'eng', 2, {cachePath: this.ai.options.path});
|
||||
const {data} = await worker.recognize(path);
|
||||
await worker.terminate();
|
||||
res(data.text.trim() || null);
|
||||
}).finally(() => worker?.terminate());
|
||||
return await new Promise<string | null>((res, rej) => {
|
||||
reject = rej;
|
||||
worker.recognize(path)
|
||||
.then(({data}: any) => res(data.text.trim() || null))
|
||||
.catch(rej);
|
||||
});
|
||||
})().finally(() => {
|
||||
process.off('uncaughtException', handler);
|
||||
worker?.terminate();
|
||||
});
|
||||
|
||||
return Object.assign(p, {abort: () => worker?.terminate()});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
"target": "ESNext",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ESNext"],
|
||||
"lib": [
|
||||
"ESNext",
|
||||
"dom"
|
||||
],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
|
||||
Reference in New Issue
Block a user