Compare commits

..

31 Commits
0.8.1 ... 1.2.3

Author SHA1 Message Date
8229e02a52 Improved memory management
All checks were successful
Publish Library / Build NPM Project (push) Successful in 44s
Publish Library / Tag Version (push) Successful in 11s
2026-07-27 14:25:24 -04:00
a6fb8ae828 New memory system
All checks were successful
Publish Library / Build NPM Project (push) Successful in 1m5s
Publish Library / Tag Version (push) Successful in 11s
2026-07-27 03:59:39 -04:00
d1230bcaad Updated wiki tool
All checks were successful
Publish Library / Build NPM Project (push) Successful in 55s
Publish Library / Tag Version (push) Successful in 14s
2026-07-26 12:18:57 -04:00
2d49c9aa80 Removed redundant llama protocol (Use openai)
All checks were successful
Publish Library / Build NPM Project (push) Successful in 44s
Publish Library / Tag Version (push) Successful in 13s
2026-07-11 19:33:02 -04:00
9a39f00f94 Diarization fix
All checks were successful
Publish Library / Build NPM Project (push) Successful in 44s
Publish Library / Tag Version (push) Successful in 13s
2026-07-11 18:36:24 -04:00
436757daad Added new json output support
Some checks failed
Publish Library / Build NPM Project (push) Failing after 1m2s
Publish Library / Tag Version (push) Has been skipped
2026-07-11 18:27:55 -04:00
69b3297bb3 Proper error handling for OCR
All checks were successful
Publish Library / Build NPM Project (push) Successful in 1m25s
Publish Library / Tag Version (push) Successful in 10s
2026-06-09 11:21:12 -04:00
710c6ce52c Proper error handling for OCR
Some checks failed
Publish Library / Tag Version (push) Has been cancelled
Publish Library / Build NPM Project (push) Has been cancelled
2026-06-09 11:20:50 -04:00
4ac3036000 Proper error handling for OCR
All checks were successful
Publish Library / Build NPM Project (push) Successful in 43s
Publish Library / Tag Version (push) Successful in 11s
2026-06-09 09:41:09 -04:00
3121d542d4 OCR
All checks were successful
Publish Library / Build NPM Project (push) Successful in 1m4s
Publish Library / Tag Version (push) Successful in 17s
2026-06-09 08:29:46 -04:00
51ab8f2538 Memory / history fixes
All checks were successful
Publish Library / Build NPM Project (push) Successful in 52s
Publish Library / Tag Version (push) Successful in 14s
2026-06-07 21:35:26 -04:00
7dd3307a07 Update LLM models at runtime
All checks were successful
Publish Library / Build NPM Project (push) Successful in 39s
Publish Library / Tag Version (push) Successful in 15s
2026-06-07 15:50:54 -04:00
209d3b120b Export memory types
All checks were successful
Publish Library / Build NPM Project (push) Successful in 1m7s
Publish Library / Tag Version (push) Successful in 13s
2026-06-07 13:06:45 -04:00
0b1c25dfda Added MCP, Hybrid Memories and Skill support
All checks were successful
Publish Library / Build NPM Project (push) Successful in 56s
Publish Library / Tag Version (push) Successful in 16s
2026-06-06 22:02:19 -04:00
af6522ad88 Bump 0.9.0
All checks were successful
Publish Library / Build NPM Project (push) Successful in 26s
Publish Library / Tag Version (push) Successful in 9s
2026-03-29 23:01:30 -04:00
ee7b85301b * Fixed llm response object (double encoding)
All checks were successful
Publish Library / Build NPM Project (push) Successful in 25s
Publish Library / Tag Version (push) Successful in 12s
+ added wikitools
+ Improved webpage reading tool
2026-03-29 23:00:40 -04:00
d2e711fbf2 Added wikipedia tools
All checks were successful
Publish Library / Build NPM Project (push) Successful in 1m5s
Publish Library / Tag Version (push) Successful in 11s
2026-03-29 21:50:26 -04:00
596e99daa7 Use word count for summary (more predictable)
All checks were successful
Publish Library / Build NPM Project (push) Successful in 55s
Publish Library / Tag Version (push) Successful in 33s
2026-03-26 13:10:46 -04:00
eda4eed87d Added JSON / Summary LLM safeguard
All checks were successful
Publish Library / Build NPM Project (push) Successful in 41s
Publish Library / Tag Version (push) Successful in 21s
2026-03-26 12:50:52 -04:00
7f88c2d1d0 Added JSON / Summary LLM safeguard
All checks were successful
Publish Library / Build NPM Project (push) Successful in 1m17s
Publish Library / Tag Version (push) Successful in 13s
2026-03-26 12:33:50 -04:00
5eae84f6cf Added JSON / Summary LLM safeguard
All checks were successful
Publish Library / Build NPM Project (push) Successful in 1m1s
Publish Library / Tag Version (push) Successful in 14s
2026-03-26 12:24:20 -04:00
52a3e73484 Improved read_webpage tool
All checks were successful
Publish Library / Build NPM Project (push) Successful in 57s
Publish Library / Tag Version (push) Successful in 14s
2026-03-21 14:34:24 -04:00
ccb1bdf043 Added Non-UTC version of date/time tool
All checks were successful
Publish Library / Build NPM Project (push) Successful in 54s
Publish Library / Tag Version (push) Successful in 6s
2026-03-13 18:55:38 -04:00
b814ea8b28 Improved memory recall results
All checks were successful
Publish Library / Build NPM Project (push) Successful in 42s
Publish Library / Tag Version (push) Successful in 10s
2026-03-03 00:26:00 -05:00
06dda88dbc Removed log statements
All checks were successful
Publish Library / Build NPM Project (push) Successful in 36s
Publish Library / Tag Version (push) Successful in 10s
2026-03-02 14:00:58 -05:00
5d34652d46 Fixed CLI tool
All checks were successful
Publish Library / Build NPM Project (push) Successful in 39s
Publish Library / Tag Version (push) Successful in 10s
2026-03-01 18:11:25 -05:00
6454548364 Fixed CLI tool
All checks were successful
Publish Library / Build NPM Project (push) Successful in 41s
Publish Library / Tag Version (push) Successful in 9s
2026-03-01 17:18:30 -05:00
936317f2f2 Better memory de-duplication
All checks were successful
Publish Library / Build NPM Project (push) Successful in 37s
Publish Library / Tag Version (push) Successful in 10s
2026-03-01 00:11:17 -05:00
cfde2ac4d3 Fixed open AI tool call streaming!
All checks were successful
Publish Library / Build NPM Project (push) Successful in 42s
Publish Library / Tag Version (push) Successful in 8s
2026-02-27 13:11:41 -05:00
e4ba89d3db Open ai tool call history fix?
All checks were successful
Publish Library / Build NPM Project (push) Successful in 35s
Publish Library / Tag Version (push) Successful in 29s
2026-02-27 13:00:49 -05:00
71a7e2a904 Better RAG memory
All checks were successful
Publish Library / Build NPM Project (push) Successful in 50s
Publish Library / Tag Version (push) Successful in 9s
2026-02-27 12:32:27 -05:00
15 changed files with 3327 additions and 1827 deletions

120
README.md
View File

@@ -103,7 +103,125 @@ A TypeScript library that provides a unified interface for working with multiple
## Documentation
[Available Here](https://ai-utils.docs.zakscode.com/)
### Setup
```javascript
const ai = new Ai({
path: '/ai-models',
// Setup audio
whisper: '/path/to/binary', // Required for ASR
hfToken: '...', // Required for diarization
asr: 'ggml-base.en.bin', // Override default ASR model
// Setup LLM
embedder: 'bge-small-en-v1.5', // Override default embedder model
llm: {
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,
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},
'gpt-4o': {proto: 'openai', token: process.env.OPENAI_TOKEN},
'llama3': {proto: 'ollama', host: 'http://localhost:11434'},
},
mcp: [
{name: 'files', url: 'https://mcp.example.com', token: process.env.MCP_TOKEN}
],
skills: [
{name: 'Tone of voice', description: 'Brand writing guidelines', content: '# Tone of Voice\n\nAlways be concise and friendly...'}
],
tools: [{
name: 'Marco?',
description: 'Where is marco polo?',
args: {
shout: {type: 'boolean', default: 'Shout into the void?', description: false, required: false}
},
fn: (args: any, stream: LLMRequest['stream'], ai: Ai) => {
const {shout} = args;
return shout ? 'Polo!' : 'Polo';
}
}],
},
// Setup Vision
ocr: 'eng' // Override default OCR model
});
```
### Audio
```javascript
// Crate audio transcript
const text = await ai.audio.asr('./path/to/audio.mp3');
console.log(text);
// Break transcript into speakers
const text = await ai.audio.asr('./path/to/audio.mp3', {diarization: true});
console.log(text);
// Break transcript into named speakers
const text = await ai.audio.asr('./path/to/audio.mp3', {diarization: 'llm'});
console.log(text);
```
### Language
```javascript
const history = [], memory = [];
// Wait for entire response
const text = await ai.language.ask('My favorite color is blue, whats yours?', {history, memory});
console.log(text);
// Stream response
const chunks = '';
await ai.language.ask('Write me a poem', {
history, memory,
stream: chunk => chunks += chunk,
});
console.log(chunks);
// Manually compile history into memories at end of conversation
// Happens automatically when coverstaions are compressed
await ai.language.updateMemory(history, memory);
// Summarize text
const summary = await ai.language.summarize(longText, 200);
// Code response (no conversation or extra BS)
const code = await ai.language.code('Write a fibonacci function');
// Structured JSON response
const data = await ai.language.json('Extract the name and age', `{
"name": "string",
"age": "number"
}`, {system: 'Extract from user input'});
```
#### Premade LLM Tools:
- `cli`: Run a shell command, returns its output
- `get_datetime`: Returns local date/time
- `get_datetime_utc`: Returns current UTC date/time
- `exec`: Execute code in cli, node, or python
- `fetch`: Make HTTP requests (GET/POST/PUT/DELETE)
- `exec_javascript`: Execute CommonJS JavaScript
- `exec_python`: Execute Python via python -c
- `read_webpage`: Scrape & clean content from a URL, handles HTML, JSON, CSV, media, PDFs etc.
- `web_search`: Anonymous DuckDuckGo search, returns a list of URLs
- `wikipedia_lookup`: Fetch a Wikipedia article (intro or full)
- `wikipedia_search`: Search Wikipedia and return matching articles
- `get_weather`: Fetch current weather + forecast for a location (just built!)
### Vision
```javascript
// Extract text from image
const text = await ai.vision.ocr('./path/to/image.png');
console.log(text);
```
## License

3381
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "@ztimson/ai-utils",
"version": "0.8.1",
"version": "1.2.3",
"description": "AI Utility library",
"author": "Zak Timson",
"license": "MIT",
@@ -25,21 +25,21 @@
"watch": "npx vite build --watch"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.78.0",
"@anthropic-ai/sdk": "^0.102.0",
"@tensorflow/tfjs": "^4.22.0",
"@xenova/transformers": "^2.17.2",
"@huggingface/transformers": "^4.2.0",
"@ztimson/node-utils": "^1.0.7",
"@ztimson/utils": "^0.28.13",
"@ztimson/utils": "^0.29.4",
"cheerio": "^1.2.0",
"openai": "^6.22.0",
"openai": "^6.42.0",
"tesseract.js": "^7.0.0"
},
"devDependencies": {
"@types/node": "^24.8.1",
"@types/node": "^24.13.1",
"typedoc": "^0.26.7",
"typescript": "^5.3.3",
"vite": "^7.2.7",
"vite-plugin-dts": "^4.5.3"
"typescript": "^5.6.3",
"vite": "^8.0.16",
"vite-plugin-dts": "^5.0.2"
},
"files": [
"dist"

View File

@@ -1,5 +1,5 @@
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';
@@ -8,7 +8,7 @@ export type AbortablePromise<T> = Promise<T> & {
};
export type AiOptions = {
/** Token to pull models from hugging face */
/** Token to pull diarization models from hugging face */
hfToken?: string;
/** Path to models */
path?: string;
@@ -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;

View File

@@ -3,6 +3,7 @@ import {findByProp, objectMap, JSONSanitize, JSONAttemptParse} from '@ztimson/ut
import {AbortablePromise, Ai} from './ai.ts';
import {LLMMessage, LLMRequest} from './llm.ts';
import {LLMProvider} from './provider.ts';
import {convertSchema} from './tools.ts';
export class Anthropic extends LLMProvider {
client!: anthropic;
@@ -48,7 +49,7 @@ export class Anthropic extends LLMProvider {
return history.map(({timestamp, ...h}) => h);
}
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()}]);
@@ -57,7 +58,7 @@ export class Anthropic extends LLMProvider {
model: options.model || this.model,
max_tokens: options.max_tokens || this.ai.options.llm?.max_tokens || 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,
@@ -72,6 +73,16 @@ export class Anthropic extends LLMProvider {
stream: !!options.stream,
};
// Add structured output support
if(options.schema) {
requestParams.output_config = {
format: {
type: 'json_schema',
schema: convertSchema(options.schema)
}
};
}
let resp: any, isFirstMessage = true;
do {
resp = await this.client.messages.create(requestParams).catch(err => {
@@ -119,7 +130,7 @@ export class Anthropic extends LLMProvider {
if(!tool) return {tool_use_id: toolCall.id, is_error: true, content: 'Tool not found'};
try {
const result = await tool.fn(toolCall.input, options?.stream, this.ai);
return {type: 'tool_result', tool_use_id: toolCall.id, content: JSONSanitize(result)};
return {type: 'tool_result', tool_use_id: toolCall.id, 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'};
}
@@ -128,12 +139,17 @@ export class Anthropic extends LLMProvider {
requestParams.messages = history;
}
} 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')});
const textContent = resp.content.filter((c: any) => c.type == 'text').map((c: any) => c.text).join('\n\n');
history.push({role: 'assistant', content: textContent});
history = this.toStandard(history);
if(options.stream) options.stream({done: true});
if(options.history) options.history.splice(0, options.history.length, ...history);
res(history.at(-1)?.content);
// Return parsed JSON if schema provided
const finalContent = history.at(-1)?.content;
res(options.schema ? JSONAttemptParse(finalContent, finalContent) : finalContent);
}), {abort: () => controller.abort()});
}
}

View File

@@ -2,7 +2,6 @@ import {execSync, spawn} from 'node:child_process';
import {mkdtempSync} from 'node:fs';
import fs from 'node:fs/promises';
import {tmpdir} from 'node:os';
import * as path from 'node:path';
import Path, {join} from 'node:path';
import {AbortablePromise, Ai} from './ai.ts';
@@ -142,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;
}
@@ -155,7 +161,7 @@ print(json.dumps(segments))
const p = new Promise<any>((resolve, reject) => {
this.downloadAsrModel(opts.model).then(m => {
if(opts.diarization) {
let output = path.join(path.dirname(file), 'transcript');
let output = join(Path.dirname(file), 'transcript');
proc = spawn(<string>this.ai.options.whisper,
['-m', m, '-f', file, '-np', '-ml', '1', '-oj', '-of', output],
{stdio: ['ignore', 'ignore', 'pipe']}
@@ -226,11 +232,11 @@ print(json.dumps(segments))
return <any>Object.assign(p, {abort});
}
asr(file: string, options: { model?: string; diarization?: boolean | 'llm' } = {}): AbortablePromise<string | null> {
asr(path: string, options: { model?: string; diarization?: boolean | 'llm' } = {}): AbortablePromise<string | null> {
if(!this.ai.options.whisper) throw new Error('Whisper not configured');
const tmp = join(mkdtempSync(join(tmpdir(), 'audio-')), 'converted.wav');
execSync(`ffmpeg -i "${file}" -ar 16000 -ac 1 -f wav "${tmp}"`, { stdio: 'ignore' });
execSync(`ffmpeg -i "${path}" -ar 16000 -ac 1 -f wav "${tmp}"`, { stdio: 'ignore' });
const clean = () => fs.rm(Path.dirname(tmp), {recursive: true, force: true}).catch(() => {});
if(!options.diarization) return this.runAsr(tmp, {model: options.model});

View File

@@ -1,13 +1,13 @@
import { pipeline } from '@xenova/transformers';
import { pipeline } from '@huggingface/transformers';
const [modelDir, model] = process.argv.slice(2);
let text = '';
process.stdin.on('data', chunk => text += chunk);
process.stdin.on('end', async () => {
const embedder = await pipeline('feature-extraction', 'Xenova/' + model, {quantized: true, cache_dir: modelDir});
const embedder = await pipeline('feature-extraction', 'Xenova/' + model, {cache_dir: modelDir});
const output = await embedder(text, { pooling: 'mean', normalize: true });
const embedding = Array.from(output.data);
console.log(JSON.stringify({embedding}));
process.stdout.write(JSON.stringify({embedding}));
process.exit();
});

View File

@@ -2,6 +2,7 @@ export * from './ai';
export * from './antrhopic';
export * from './audio';
export * from './llm';
export * from './memory';
export * from './open-ai';
export * from './provider';
export * from './tools';

334
src/kd-tree.ts Normal file
View File

@@ -0,0 +1,334 @@
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;
}
// ─── 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
* - 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 readonly dims: number;
private readonly distanceFn: (a: number[], b: number[]) => 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 points stored in the tree. */
get size(): number { return this._size; }
// ── 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++;
}
// ── KNN search ─────────────────────────────────────────────────────────────
/**
* Find the k nearest 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 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 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 points as a balanced tree.
* Useful after many individual insertions to restore O(log n) query time.
*/
rebalance(): void {
const points = this.toArray();
this.root = points.length ? this.buildBalanced(points, 0) : null;
}
// ── 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;
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;
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;
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);
}
}

View File

@@ -1,15 +1,14 @@
import {JSONAttemptParse} 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, MemoryCache, MemoryManager} from './memory.ts';
export type AnthropicConfig = {proto: 'anthropic', token: string};
export type OllamaConfig = {proto: 'ollama', host: string};
export type OpenAiConfig = {proto: 'openai', host?: string, token: string};
export type LLMMessage = {
@@ -36,19 +35,9 @@ export type LLMMessage = {
timestamp?: number;
}
/** Background information the AI will be fed */
export type LLMMemory = {
/** What entity is this fact about */
owner: string;
/** The information that will be remembered */
fact: string;
/** Owner and fact embedding vector */
embeddings: [number[], number[]];
/** Creation time */
timestamp: Date;
}
export type LLMRequest = {
/** Return a parsed JSON object that matches the schema */
schema?: AiToolArg;
/** System prompt */
system?: string;
/** Message history */
@@ -64,17 +53,39 @@ export type LLMRequest = {
/** Stream response */
stream?: (chunk: {text?: string, tool?: string, done?: true}) => any;
/** Compress old messages in the chat to free up context */
compress?: {
/** Trigger chat compression once context exceeds the token count */
max: number;
/** Compress chat until context size smaller than */
min: number
},
/** Background information the AI will be fed */
memory?: LLMMemory[],
compress?: {max: number; min: number};
/** User's memory documents - RAG injected automatically each turn */
memory?: Memory[] | MemoryCache;
/** 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[];
}
export type McpServer = {
/** MCP server name for humans */
name: string;
/** Host URL */
host: string;
/** Server access token */
token?: string;
}
export type Skill = {
/** Name of skill for humans */
name: string;
/** Description LLM will use to decide to learn a skill */
description: string;
/** Skill instructions */
content: string;
}
class LLM {
private memoryManager!: MemoryManager;
defaultModel!: string;
models: {[model: string]: LLMProvider} = {};
@@ -83,94 +94,143 @@ 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 setupMcp(servers: McpServer[] = []): Promise<{prompt: string, tools: AiTool[]}> {
if(!servers?.length) return {prompt: '', tools: []};
const allTools: AiTool[] = [];
await Promise.all(servers.map(async server => {
const res = await fetch(`${server.host}/tools`, {headers: server.token ? {Authorization: `Bearer ${server.token}`} : {}});
const mcp: any = await res.json();
if(!mcp?.tools) return;
for(const t of mcp.tools) {
const args: Record<string, any> = {};
if(t.inputSchema?.properties) {
for(const [key, val] of Object.entries<any>(t.inputSchema.properties)) {
args[key] = {type: val.type || 'string', description: val.description || '', required: t.inputSchema.required?.includes(key)};
}
}
allTools.push({
name: `${server.name}_${t.name}`,
description: t.description || '',
args,
fn: async (a: any) => {
const r = await fetch(`${server.host}/tools/call`, {
method: 'POST',
headers: {'Content-Type': 'application/json', ...(server.token ? {Authorization: `Bearer ${server.token}`} : {})},
body: JSON.stringify({name: t.name, arguments: a})
});
const data: any = await r.json();
return data?.content?.[0]?.text ?? JSON.stringify(data);
}
});
}
}));
const list = allTools.map(t => `- ${t.name}: ${t.description}`).join('\n');
return {
prompt: `You have access to the following MCP tools:\n${list}`,
tools: allTools
};
}
private setupSkills(skills: Skill[] = []): {prompt: string, tools: AiTool[]} {
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}`,
tools: [{
name: 'read_skill',
description: 'Read the full content of a skill/knowledge document',
args: {
name: {type: 'string', description: 'Exact skill name', required: true}
},
fn: (args: any) => {
const skill = skills.find(s => s.name === args.name);
if(!skill) return `Skill not found. Available:\n${list}`;
return `# ${skill.name}\n${skill.content}`;
}
}]
}
}
/**
* Chat with LLM
* @param {string} message Question
* @param {LLMRequest} options Configuration options and chat history
* @returns {{abort: () => void, response: Promise<string>}} Function to abort response and chat history
*/
ask(message: string, options: LLMRequest = {}): AbortablePromise<string> {
options = <any>{
system: '',
...this.ai.options.llm,
models: undefined,
history: [],
...options,
}
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 => {
if(!options.history) options.history = [];
// If memories were passed, find any relivant ones and add a tool for ADHOC lookups
let tools: AiTool[] = options.tools || this.ai.options.llm?.tools || [];
const prompts: string[] = [];
let history = options.history || [];
// MCP
const mcp = options.mcp || this.ai.options?.llm?.mcp;
if(mcp?.length) {
const m = await this.setupMcp(mcp);
prompts.unshift(m.prompt);
tools.push(...m.tools);
}
// Skills
const skills = options.skills || this.ai.options?.llm?.skills;
if(skills?.length) {
const s = this.setupSkills(skills);
prompts.unshift(s.prompt);
tools.push(...s.tools);
}
// Memory
if (options.memory) {
options.system = (options.system || '') + '\nYou have passive persistent memory never make any mention of your memory capabilities and what you can/cannot remember\n';
const search = async (query?: string | null, subject?: string | null, limit = 50) => {
const [o, q] = await Promise.all([
subject ? this.embedding(subject) : Promise.resolve(null),
query ? this.embedding(query) : Promise.resolve(null),
]);
return (options.memory || [])
.map(m => ({...m, score: o ? this.cosineSimilarity(m.embeddings[0], o[0].embedding) : 1}))
.filter((m: any) => m.score >= 0.8)
.map((m: any) => ({...m, score: q ? this.cosineSimilarity(m.embeddings[1], q[0].embedding) : m.score}))
.filter((m: any) => m.score >= 0.2)
.toSorted((a: any, b: any) => a.score - b.score)
.slice(0, limit);
const mems = options.memory instanceof MemoryCache ? options.memory.memories : options.memory;
const relevant = await this.memoryManager.recollect(message, options.memory, 5);
prompts.unshift(`You have access to the following memory files:
${mems.map(m => `- ${m.name}: ${m.description}`).join('\n')}
${relevant.length ? `
Relevant memories have been preloaded:
${relevant.map(r => `
**${r.name}**
${r.description}
${r.content}
`).join('\n---\n')}
` : ''}`.trim());
tools.push(this.memoryManager.tools.read(options.memory));
}
const relevant = await search(message);
if(relevant.length) options.history.push({role: 'assistant', content: 'Things I remembered:\n' + relevant.map(m => `${m.owner}: ${m.fact}`).join('\n')});
options.tools = [...options.tools || [], {
name: 'read_memory',
description: 'Check your long-term memory for more information',
args: {
subject: {type: 'string', description: 'Find information by a subject topic, can be used with or without query argument'},
query: {type: 'string', description: 'Search memory based on a query, can be used with or without subject argument'},
limit: {type: 'number', description: 'Result limit, default 5'},
},
fn: (args) => {
if(!args.subject && !args.query) throw new Error('Either a subject or query argument is required');
return search(args.query, args.subject, args.limit || 5);
}
}];
}
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')});
// Ask
const resp = await this.models[m].ask(message, options);
// Remove any memory calls
// Trim memory injections from history
if(options.memory) {
const i = options.history?.findIndex((h: any) => h.role == 'assistant' && h.content.startsWith('Things I remembered:'));
if(i != null && i >= 0) options.history?.splice(i, 1);
history.splice(0, history.length, ...history.filter(h => h.role !== 'tool' || h.name !== 'recall'));
}
// Handle compression and memory extraction
if(options.compress || options.memory) {
let compressed: any = null;
if(options.compress) {
compressed = await this.ai.language.compressHistory(options.history, options.compress.max, options.compress.min, options);
options.history.splice(0, options.history.length, ...compressed.history);
} else {
const i = options.history?.findLastIndex(m => m.role == 'user') ?? -1;
compressed = await this.ai.language.compressHistory(i != -1 ? options.history.slice(i) : options.history, 0, 0, options);
}
if(options.memory) {
const updated = options.memory
.filter(m => !compressed.memory.some(m2 => this.cosineSimilarity(m.embeddings[1], m2.embeddings[1]) > 0.8))
.concat(compressed.memory);
options.memory.splice(0, options.memory.length, ...updated);
}
// Auto-memorize before compressing
if(options.compress && this.estimateTokens(history) >= options.compress.max) {
if(options.memory) await this.memoryManager.memorize(history, options.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});
}
async code(message: string, options?: LLMRequest): Promise<any> {
const resp = await this.ask(message, {...options, system: [
options?.system,
'Return your response in a code block'
].filter(t => !!t).join(('\n'))});
const codeBlock = /```(?:.+)?\s*([\s\S]*?)```/.exec(resp);
return codeBlock ? codeBlock[1].trim() : null;
/**
* Digest full conversation history into memory documents.
* Call on session end to persist the conversation.
*/
async updateMemory(history: LLMMessage[], memories: Memory[] | MemoryCache, options: LLMRequest = {}): Promise<void> {
await this.memoryManager.memorize(history, memories, {model: this.defaultModel, ...options});
}
/**
@@ -181,32 +241,24 @@ class LLM {
* @param {LLMRequest} options LLM options
* @returns {Promise<LLMMessage[]>} New chat history will summary at index 0
*/
async compressHistory(history: LLMMessage[], max: number, min: number, options?: LLMRequest): Promise<{history: LLMMessage[], memory: LLMMemory[]}> {
if(this.estimateTokens(history) < max) return {history, memory: []};
async compressHistory(history: LLMMessage[], max: number, min: number, options?: LLMRequest): Promise<LLMMessage[]> {
if(this.estimateTokens(history) < max) return history;
let keep = 0, tokens = 0;
for(let m of history.toReversed()) {
tokens += this.estimateTokens(m.content);
if(tokens < min) keep++;
else break;
}
if(history.length <= keep) return {history, memory: []};
if(history.length <= keep) return history;
const system = history[0].role == 'system' ? history[0] : null,
recent = keep == 0 ? [] : history.slice(-keep),
process = (keep == 0 ? history : history.slice(0, -keep)).filter(h => h.role === 'assistant' || h.role === 'user');
const summary: any = await this.json(process.map(m => `${m.role}: ${m.content}`).join('\n\n'), '{summary: string, facts: [[subject, fact]]}', {
system: 'Create the smallest summary possible, no more than 500 tokens. Create a list of NEW facts (split by subject [pro]noun and fact) about what you learned from this conversation that you didn\'t already know or get from a tool call or system prompt. Focus only on new information about people, topics, or facts. Avoid generating facts about the AI.',
model: options?.model,
temperature: options?.temperature || 0.3
});
const timestamp = new Date();
const memory = await Promise.all((summary?.facts || [])?.map(async ([owner, fact]: [string, string]) => {
const e = await Promise.all([this.embedding(owner), this.embedding(`${owner}: ${fact}`)]);
return {owner, fact, embeddings: [e[0][0].embedding, e[1][0].embedding], timestamp};
}));
const h = [{role: 'assistant', content: `Conversation Summary: ${summary?.summary}`, timestamp: Date.now()}, ...recent];
const summary: any = await this.summarize(process.map(m => `[${m.role}]: ${m.content}`).join('\n\n'), 500, options);
const d = Date.now();
const h = [{role: <any>'tool', name: 'summary', id: `summary_` + d, args: {}, content: `Conversation Summary: ${summary?.summary}`, timestamp: d}, ...recent];
if(system) h.splice(0, 0, system);
return {history: <any>h, memory};
return h;
}
/**
@@ -243,7 +295,7 @@ class LLM {
return `${p}: ${Array.isArray(value) ? value.join(', ') : value}`;
});
};
const lines = typeof target === 'object' ? objString(target) : target.split('\n');
const lines = typeof target === 'object' ? objString(target) : target.toString().split('\n');
const tokens = lines.flatMap(l => [...l.split(/\s+/).filter(Boolean), '\n']);
const chunks: string[] = [];
for(let i = 0; i < tokens.length;) {
@@ -267,7 +319,7 @@ class LLM {
* @param {maxTokens?: number, overlapTokens?: number} opts Options for embedding such as chunk sizes
* @returns {Promise<Awaited<{index: number, embedding: number[], text: string, tokens: number}>[]>} Chunked embeddings
*/
embedding(target: object | string, opts: {maxTokens?: number, overlapTokens?: number} = {}): AbortablePromise<any[]> {
embedding(target: object | string, opts: {maxTokens?: number, overlapTokens?: number} = {}): AbortablePromise<{index: number, embedding: number[], text: string, tokens: number}[]> {
let {maxTokens = 500, overlapTokens = 50} = opts;
let aborted = false;
const abort = () => { aborted = true; };
@@ -275,7 +327,6 @@ class LLM {
const embed = (text: string): Promise<number[]> => {
return new Promise((resolve, reject) => {
if(aborted) return reject(new Error('Aborted'));
const args: string[] = [
join(dirname(fileURLToPath(import.meta.url)), 'embedder.js'),
<string>this.ai.options.path,
@@ -284,7 +335,6 @@ class LLM {
const proc = spawn('node', args, {stdio: ['pipe', 'pipe', 'ignore']});
proc.stdin.write(text);
proc.stdin.end();
let output = '';
proc.stdout.on('data', (data: Buffer) => output += data.toString());
proc.on('close', (code: number) => {
@@ -294,7 +344,7 @@ class LLM {
const result = JSON.parse(output);
resolve(result.embedding);
} catch(err) {
reject(new Error('Failed to parse embedding output'));
reject(err);
}
} else {
reject(new Error(`Embedder process exited with code ${code}`));
@@ -314,7 +364,7 @@ class LLM {
}
return results;
})();
return Object.assign(p, { abort });
return <any>Object.assign(p, {abort});
}
/**
@@ -340,34 +390,65 @@ class LLM {
(char.charCodeAt(0) * (index + 1)) % dimensions / dimensions).slice(0, dimensions);
}
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}
}
/**
* 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>}
*/
async json(text: string, schema: string, options?: LLMRequest): Promise<any> {
const code = await this.code(text, {...options, system: [
options?.system,
`Only respond using JSON matching this schema:\n\`\`\`json\n${schema}\n\`\`\``
].filter(t => !!t).join('\n')});
return code ? JSONAttemptParse(code, {}) : null;
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};
}
/**
* Create a summary of some text
* @param {string} text Text to summarize
* @param {number} tokens Max number of tokens
* @param {number} length Max number of words
* @param options LLM request options
* @returns {Promise<string>} Summary
*/
summarize(text: string, tokens: number, options?: LLMRequest): Promise<string | null> {
return this.ask(text, {system: `Generate a brief summary <= ${tokens} tokens. Output nothing else`, temperature: 0.3, ...options});
async summarize(text: string, length: number = 500, options?: LLMRequest): Promise<string | null> {
let system = `Your job is to summarize the users message using tool calls. Call the \`submit\` tool at least once with the shortest summary possible that's <= ${length} words. The tool call will respond with the token count. Responses 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 summary',
args: {summary: {type: 'string', description: 'Text summarization', required: true}},
fn: (args) => {
if(!args.summary) return 'No summary provided';
const count = args.summary.split(' ').length;
if(count > length) return `Too long: ${length} words`;
done = true;
resolve(args.summary || null);
return `Saved: ${length} words`;
}
}, ...(options?.tools || [])],
});
if(!done) reject(`AI failed to create summary:\n${resp}`);
});
}
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 == 'openai') this.models[name] = new OpenAi(this.ai, config.host || null, config.token, name);
if(setDefault || !this.defaultModel) this.defaultModel = name;
}
removeModel(name: string) {
delete this.models[name];
if(this.defaultModel === name) {
this.defaultModel = Object.keys(this.models)[0] ?? '';
}
}
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 == 'openai') this.models[model] = new OpenAi(this.ai, config.host || null, config.token, model);
});
this.defaultModel = Object.keys(this.models)[0] ?? '';
}
}

501
src/memory.ts Normal file
View File

@@ -0,0 +1,501 @@
import {LLMRequest, LLMMessage} from './llm.ts';
import {AiTool} from './tools.ts';
import {KDTree, KDPoint} from './kd-tree.ts';
export type Memory = {
name: string;
description: string;
content: string;
embedding: number[];
links: string[];
backlinks: string[];
}
type MemoryRef = {
name: string;
description: string;
}
type FactBucket = {
subject: string;
facts: string[];
}
export type MemoryNode = {
name: string;
missing: boolean;
links: string[];
backlinks: string[];
}
export function buildMemoryGraph(memories: Memory[] | MemoryCache): MemoryNode[] {
const mems = memories instanceof MemoryCache ? memories.memories : memories;
const nameSet = new Set(mems.map(m => m.name));
const ghosts = new Set<string>();
for (const m of mems) {
for (const link of m.links) {
if (!nameSet.has(link)) ghosts.add(link);
}
}
return [
...mems.map(m => ({
name: m.name,
missing: false,
links: m.links,
backlinks: m.backlinks,
})),
...[...ghosts].map(name => ({
name,
missing: true,
links: [],
backlinks: mems
.filter(m => m.links.includes(name))
.map(m => m.name),
}))
];
}
function extractLinks(content: string): string[] {
const matches = content.matchAll(/\[\[([^\]]+)\]\]/g);
return [...new Set([...matches].map(m => m[1].trim()))];
}
function rebuildBacklinks(memories: Memory[]): void {
for (const m of memories) m.backlinks = [];
for (const m of memories) {
for (const link of m.links) {
const target = memories.find(t => t.name === link);
if (target) target.backlinks.push(m.name);
}
}
}
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;
}
export class MemoryCache {
private tree: KDTree<MemoryRef>;
public memories: Memory[];
constructor(memories: Memory[]) {
this.memories = memories;
this.tree = this.buildTree();
}
private buildTree(): KDTree<MemoryRef> {
const embedded = this.memories.filter(m => m.embedding?.length);
if(!embedded.length) return new KDTree<MemoryRef>(0);
const dims = embedded[0].embedding.length;
const points: KDPoint<MemoryRef>[] = embedded.map(m => ({
vector: m.embedding,
payload: {name: m.name, description: m.description},
}));
return new KDTree<MemoryRef>(dims, 'cosine', points);
}
search(query: number[], limit: number): MemoryRef[] {
const results = this.tree.knn(query, limit);
return results.map(r => r.point.payload);
}
add(memory: Memory): void {
this.memories.push(memory);
this.rebuild();
}
update(memory: Memory): void {
const idx = this.memories.findIndex(m => m.name === memory.name);
if (idx !== -1) {
this.memories[idx] = memory;
} else {
this.memories.push(memory);
}
this.rebuild();
}
rebuild(): void {
this.tree = this.buildTree();
}
rebuildLinks(): void {
rebuildBacklinks(this.memories);
}
}
export class MemoryManager {
tools = {
read: (memories: Memory[] | MemoryCache): AiTool => ({
name: 'read_memory',
description: 'Read the full content of a memory document',
args: {
name: {type: 'string', description: 'Exact memory name', required: true},
},
fn:(args: any) => {
const mems = memories instanceof MemoryCache ? memories.memories : memories;
const mem = mems.find(m => m.name === args.name);
if(!mem) return 'Document not found';
return this.formatMemory(mem);
}
}),
};
constructor(private llm: any) {}
private cosineSearch(query: number[], memories: Memory[], limit: number): MemoryRef[] {
const scored = memories
.filter(m => m.embedding?.length)
.map(m => ({
ref: {name: m.name, description: m.description},
distance: cosineDistance(query, m.embedding)
}))
.sort((a, b) => a.distance - b.distance)
.slice(0, limit);
return scored.map(s => s.ref);
}
private createNode(name: string, memories: Memory[]): Memory {
const existing = memories.find(m => m.name === name);
if(existing) return existing;
return {
name,
description: '',
content: '',
embedding: [],
links: [],
backlinks: [],
};
}
private formatMemory(mem: Memory): string {
return [
`# ${mem.name}`,
mem.description ? `> ${mem.description}` : '',
mem.links.length ? `**Links:** ${mem.links.map(l => `[[${l}]]`).join(', ')}` : '',
mem.backlinks.length ? `**Referenced by:** ${mem.backlinks.map(l => `[[${l}]]`).join(', ')}` : '',
'',
mem.content,
].filter(l => l !== undefined).join('\n');
}
private listNodes(memories: Memory[]): MemoryRef[] {
return memories.map(m => ({name: m.name, description: m.description}));
}
async recollect(query: string, memories: Memory[] | MemoryCache, limit = 5, graphDepth = 1): Promise<Memory[]> {
const mem: Memory[] = memories instanceof MemoryCache ? memories.memories : memories;
if(!mem.length) return [];
const [e] = await this.llm.embedding(query);
if(!e) return [];
let vectorResults: MemoryRef[];
if(memories instanceof MemoryCache) vectorResults = memories.search(e.embedding, limit);
else vectorResults = this.cosineSearch(e.embedding, mem, limit);
const found = new Set<string>(vectorResults.map(r => r.name));
if(graphDepth > 0) {
const frontier = [...found];
for(let depth = 0; depth < graphDepth; depth++) {
const next: string[] = [];
for(const name of frontier) {
const node = mem.find(m => m.name === name);
if(!node) continue;
for(const link of node.links) {
if(!found.has(link) && mem.find(m => m.name === link)) {
found.add(link);
next.push(link);
}
}
}
frontier.splice(0, frontier.length, ...next);
if(!frontier.length) break;
}
}
const vectorOrder = vectorResults.map(r => r.name);
const graphExpansions = [...found].filter(n => !vectorOrder.includes(n));
const ordered = [...vectorOrder, ...graphExpansions];
return ordered.map(n => mem.find(m => m.name === n)!).filter(Boolean);
}
async memorize(history: LLMMessage[], memories: Memory[] | MemoryCache, options: LLMRequest): Promise<void> {
const mem = memories instanceof MemoryCache ? memories.memories : memories;
const conversation = history
.filter(h => h.role === 'user' || h.role === 'assistant')
.map(h => `[${h.role}]: ${h.content}`).join('\n\n').trim();
if(conversation) {
const buckets = await this.factAgent(conversation, mem, options);
if(buckets.length) {
await Promise.all(buckets.map(async bucket => {
const node = await this.organizingAgent(bucket, mem, options);
if(!mem.find(m => m.name === node.name)) mem.push(node);
await this.docAgent(node, bucket, mem, options);
}));
}
}
// Auto-compress old journals
const weekAgo = Date.now() - (7 * 24 * 60 * 60 * 1000);
const oldDailies = mem.filter(m => {
const journal = /^Journal\/(\d{4}-\d{2}-\d{2}$)/.exec(m.name);
return journal && new Date(journal[1]).getTime() < weekAgo;
});
if(oldDailies.length) {
const byMonth = new Map<string, Memory[]>();
for(const daily of oldDailies) {
const match = daily.name.match(/^Journal\/(\d{4}-\d{2})-\d{2}$/);
if(!match) continue;
const monthKey = match[1];
if(!byMonth.has(monthKey)) byMonth.set(monthKey, []);
byMonth.get(monthKey)!.push(daily);
}
for(const [monthKey, entries] of byMonth) {
const monthlyPath = `Journal/${monthKey}`;
let monthly = mem.find(m => m.name === monthlyPath);
if(!monthly) {
monthly = this.createNode(monthlyPath, mem);
mem.push(monthly);
}
const bucket: FactBucket = {
subject: monthlyPath,
facts: entries.flatMap(e => e.content.split('\n').filter(line => line.trim())),
};
await this.docAgent(monthly, bucket, mem, options);
for(const daily of entries) {
const idx = mem.indexOf(daily);
if(idx !== -1) mem.splice(idx, 1);
}
}
}
if (memories instanceof MemoryCache) {
memories.rebuildLinks();
memories.rebuild();
} else {
rebuildBacklinks(mem);
}
}
private async docAgent(node: Memory, bucket: FactBucket, memories: Memory[], options: LLMRequest): Promise<void> {
let finalContent = node.content;
const isJournalCompression = node.name.match(/^Journal\/\d{4}-\d{2}$/);
const systemPrompt = isJournalCompression
? `You are a journal compressor. Condense the daily entries below into a monthly summary.
Format:
# ${node.name}
## Themes
(Recurring topics, moods, patterns)
## Key Events
(Important moments, decisions, milestones)
## Notable Conversations
(Significant discussions or revelations)
Rules:
- Use [[WikiLinks]] to reference permanent notes using full paths like [[People/Sarah]] or [[Projects/Website]]
- Keep it concise but preserve emotional/temporal context
- Discard filler but keep things the user vented about or cared about
- If a fact belongs in a permanent note, link to it instead of duplicating
Current monthly summary:
\`\`\`markdown
${node.content || '(empty — first compression for this month)'}
\`\`\``
: `You are a knowledge base editor. Integrate the provided facts into the document below.
Formatting rules:
- Use Obsidian-style markdown: # headings, **bold** for key terms, bullet lists for facts
- Link related concepts with [[WikiLink]] notation using full paths like [[People/Sarah]] or [[Projects/Website]]
- You may create links to nodes that don't exist yet if the concept is important
- Keep the document concise, factual, and human-readable
- Resolve any contradictions between old content and new facts (new facts win)
- Do not add filler, preamble, or AI commentary — just clean knowledge documents
All nodes:
${this.listNodes(memories).map(n => n.name).join(', ') || 'none'}
Current document:
\`\`\`markdown
${node.content || '(empty — this is a new document)'}
\`\`\``;
await this.llm.ask(
`New facts to integrate:\n${bucket.facts.map(f => `- ${f}`).join('\n')}`,
{
model: options.model,
temperature: 0.3,
system: systemPrompt,
tools: [{
name: 'update_document',
description: 'Write the complete updated document content',
args: {
description: {type: 'string', description: 'One-line description of what this document covers, no formatting or emojis', required: true},
content: {type: 'string', description: 'Fully updated document in markdown', required: true},
},
fn:(args: any) => {
node.description = args.description;
finalContent = args.content;
return 'Saved';
}
}]
}
);
node.content = finalContent;
node.links = extractLinks(finalContent);
const needsEmbed = !node.embedding?.length || node.description !== memories.find(m => m.name === node.name)?.description;
if (needsEmbed) {
const [e] = await this.llm.embedding(node.description);
if (e) node.embedding = e.embedding;
}
}
private async factAgent(conversation: string, memories: Memory[], options: LLMRequest): Promise<FactBucket[]> {
const buckets: FactBucket[] = [];
const today = new Date().toISOString().split('T')[0];
await this.llm.ask(conversation, {
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, their work, or their projects
- ONLY extract decisions that were MADE during this conversation
- DO NOT extract anything the AI said, its capabilities, or meta-conversation about the AI
- DO NOT extract greetings, pleasantries, or generic exchanges
- If nothing worth remembering was said, do not call any tools
**Organizational patterns:**
- Journal entries use paths like: Journal/${today}
- People use paths like: People/Name
- Projects use paths like: Projects/Name
- Personal info uses paths like: Personal/Goals, Personal/Tasks, etc.
- General knowledge uses paths like: Biology/Topic, History/Topic, etc.
Learn from existing nodes and follow the same pattern when extracting.
Group facts by subject. For each group call \`extract_facts\` once with the FULL PATH.
Known nodes (name: description):
${this.listNodes(memories).map(n => `- ${n.name}: ${n.description}`).join('\n') || 'None yet.'}`,
tools: [{
name: 'extract_facts',
description: 'Submit a group of related facts for a specific subject',
args: {
subject: {type: 'string', description: 'Full path for the subject (e.g., "Journal/2025-01-27", "People/Sarah", "Projects/Website")', required: true},
facts: {type: 'string', description: 'Comma-separated list of extracted facts', required: true},
},
fn: (args: any) => {
buckets.push({
subject: args.subject,
facts: args.facts.split(',').map((f: string) => f.trim()).filter(Boolean),
});
return 'Recorded';
}
}]
});
return buckets;
}
private async organizingAgent(bucket: FactBucket, memories: Memory[], options: LLMRequest): Promise<Memory> {
let candidates = this.listNodes(memories);
let attempts = 0;
const maxAttempts = 3;
while (attempts++ < maxAttempts) {
let home = '', mode: string | null = null;
const resp = await this.llm.ask(`Subject: ${bucket.subject}\n\nFacts:\n${bucket.facts.map(f => `- ${f}`).join('\n')}`, {
model: options.model,
temperature: 0.1,
system: `You are a knowledge organizer. Your job is to find the correct home for the supplied facts.
1. Review the facts and the node list below. Pick the most likely match or decide if a new node is needed.
2. If you picked an existing node, use \`read\` to verify it's the right place.
- After reading, call either \`confirm\` (correct node) or \`mismatched\` (wrong node).
3. If none of the nodes match, call \`create\` to make a new node.
**Organizational patterns:**
- Journal entries: Journal/YYYY-MM-DD
- People: People/Name
- Projects: Projects/Name
- Personal: Personal/Goals, Personal/Tasks, etc.
- Knowledge: Biology/Topic, History/Topic, etc.
Available nodes:
${candidates.map(n => `- ${n.name}: ${n.description}`).join('\n') || 'None — create a new node.'}`,
tools: [{
name: 'read',
description: 'Read a node file to verify it is the right home for these facts',
args: {name: {type: 'string', description: 'Exact node name (full path)', required: true}},
fn: ({name}) => {
const mem = memories.find(m => m.name === name);
if (!mem) return 'Node not found';
home = name;
return this.formatMemory(mem);
}
}, {
name: 'confirm',
description: 'Confirm this is the correct node for the facts',
args: {},
fn: () => {
mode = 'success';
resp.abort();
}
}, {
name: 'mismatched',
description: 'This is not the node you are looking for',
args: {},
fn: () => {
mode = 'failed';
resp.abort();
}
}, {
name: 'create',
description: 'No existing node fits — create a new one',
args: {
name: {type: 'string', description: 'Full path for the new node (e.g., "People/Sarah", "Journal/2025-01-27")', required: true}
},
fn: ({name}) => {
home = name;
mode = 'create';
resp.abort();
}
}]
});
if(mode === 'create') {
return this.createNode(home, memories);
} else if (mode === 'failed') {
candidates = candidates.filter(c => c.name !== home);
if(!candidates.length) return this.createNode(bucket.subject, memories);
} else if (mode === 'success') {
const existing = memories.find(m => m.name === home);
return existing || this.createNode(home, memories);
}
}
return this.createNode(bucket.subject, memories);
}
}

View File

@@ -3,6 +3,7 @@ import {findByProp, objectMap, JSONSanitize, JSONAttemptParse, clean} from '@zti
import {AbortablePromise, Ai} from './ai.ts';
import {LLMMessage, LLMRequest} from './llm.ts';
import {LLMProvider} from './provider.ts';
import {convertSchema} from './tools.ts';
export class OpenAi extends LLMProvider {
client!: openAI;
@@ -11,7 +12,7 @@ export class OpenAi extends LLMProvider {
super();
this.client = new openAI(clean({
baseURL: host,
apiKey: token
apiKey: token || (host ? 'ignored' : undefined)
}));
}
@@ -64,18 +65,21 @@ export class OpenAi extends LLMProvider {
}, [] as any[]);
}
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 && options.history?.[0]?.role != 'system') options.history?.splice(0, 0, {role: 'system', content: options.system, timestamp: Date.now()});
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()}]);
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.max_tokens || this.ai.options.llm?.max_tokens || undefined,
temperature: options.temperature || this.ai.options.llm?.temperature || undefined,
tools: tools.map(t => ({
type: 'function',
function: {
@@ -90,6 +94,18 @@ export class OpenAi extends LLMProvider {
}))
};
if(options.schema) {
const schema = convertSchema(options.schema);
requestParams.response_format = {
type: 'json_schema',
json_schema: {
name: 'response',
strict: true,
schema
}
};
}
let resp: any, isFirstMessage = true;
do {
resp = await this.client.chat.completions.create(requestParams).catch(err => {
@@ -100,19 +116,42 @@ export class OpenAi extends LLMProvider {
if(options.stream) {
if(!isFirstMessage) options.stream({text: '\n\n'});
else isFirstMessage = false;
resp.choices = [{message: {content: '', tool_calls: []}}];
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;
options.stream({text: chunk.choices[0].delta.content});
}
if(chunk.choices[0].delta.tool_calls) {
resp.choices[0].message.tool_calls = 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);
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;
}
} else {
resp.choices[0].message.tool_calls.push({
index: deltaTC.index,
id: deltaTC.id || '',
type: deltaTC.type || 'function',
function: {
name: deltaTC.function?.name || '',
arguments: deltaTC.function?.arguments || ''
}
});
}
}
}
}
}
if(resp.error) throw new Error(resp.error);
const toolCalls = resp.choices[0].message.tool_calls || [];
if(toolCalls.length && !controller.signal.aborted) {
history.push(resp.choices[0].message);
@@ -123,7 +162,7 @@ export class OpenAi extends LLMProvider {
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: JSONSanitize(result)};
return {role: 'tool', tool_call_id: toolCall.id, 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'})};
}
@@ -132,12 +171,17 @@ export class OpenAi extends LLMProvider {
requestParams.messages = history;
}
} while (!controller.signal.aborted && resp.choices?.[0]?.message?.tool_calls?.length);
history.push({role: 'assistant', content: resp.choices[0].message.content || ''});
const textContent = resp.choices[0].message.content?.trim() || '';
history.push({role: 'assistant', content: textContent});
history = this.toStandard(history);
if(options.stream) options.stream({done: true});
if(options.history) options.history.splice(0, options.history.length, ...history);
res(history.at(-1)?.content);
// Return parsed JSON if schema provided
const finalContent = history.at(-1)?.content;
res(options.schema ? JSONAttemptParse(finalContent, finalContent) : finalContent);
}), {abort: () => controller.abort()});
}
}

View File

@@ -1,9 +1,17 @@
import * as cheerio from 'cheerio';
import {$, $Sync} from '@ztimson/node-utils';
import {ASet, consoleInterceptor, Http, fn as Fn} from '@ztimson/utils';
import {$Sync} from '@ztimson/node-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';
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)';
const getShell = () => {
if(os.platform() == 'win32') return 'cmd';
return $Sync`echo $SHELL`?.split('/').pop() || 'bash';
}
export type AiToolArg = {[key: string]: {
/** Argument type */
type: 'array' | 'boolean' | 'number' | 'object' | 'string',
@@ -36,37 +44,87 @@ export type AiTool = {
fn: (args: any, stream: LLMRequest['stream'], ai: Ai) => any | Promise<any>,
};
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 CliTool: 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}) => $`${args.command}`
fn: (args: {command: string}) => $Sync`${args.command}`
}
export const DateTimeTool: AiTool = {
name: 'get_datetime',
description: 'Get current UTC date / time',
args: {},
fn: async () => new Date().toUTCString()
description: 'Get local/UTC date/time',
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 ExecTool: AiTool = {
name: 'exec',
description: 'Run code/scripts',
args: {
language: {type: 'string', description: 'Execution language', enum: ['cli', 'node', 'python'], required: true},
language: {type: 'string', description: `Execution language (CLI: ${getShell()})`, enum: ['cli', 'node', 'python'], required: true},
code: {type: 'string', description: 'Code to execute', required: true}
},
fn: async (args, stream, ai) => {
try {
switch(args.type) {
case 'bash':
switch(args.language) {
case 'cli':
return await CliTool.fn({command: args.code}, stream, ai);
case 'node':
return await JSTool.fn({code: args.code}, stream, ai);
case 'python': {
case 'python':
return await PythonTool.fn({code: args.code}, stream, ai);
}
default:
throw new Error(`Unsupported language: ${args.language}`);
}
} catch(err: any) {
return {error: err?.message || err.toString()};
@@ -98,14 +156,14 @@ export const JSTool: AiTool = {
code: {type: 'string', description: 'CommonJS javascript', required: true}
},
fn: async (args: {code: string}) => {
const console = consoleInterceptor(null);
const resp = await Fn<any>({console}, args.code, true).catch((err: any) => console.output.error.push(err));
return {...console.output, return: resp, stdout: undefined, stderr: undefined};
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 PythonTool: AiTool = {
name: 'exec_javascript',
name: 'exec_python',
description: 'Execute commonjs javascript',
args: {
code: {type: 'string', description: 'CommonJS javascript', required: true}
@@ -115,37 +173,107 @@ export const PythonTool: AiTool = {
export const ReadWebpageTool: AiTool = {
name: 'read_webpage',
description: 'Extract clean, structured content from a webpage. Use after web_search to read specific URLs',
description: 'Extract clean content from webpages, or convert media/documents to accessible formats',
args: {
url: {type: 'string', description: 'URL to extract content from', required: true},
focus: {type: 'string', description: 'Optional: What aspect to focus on (e.g., "pricing", "features", "contact info")'}
url: {type: 'string', description: 'URL to read', required: true},
mimeRegex: {type: 'string', description: 'Optional regex to filter MIME types (e.g., "^image/", "text/")'}
},
fn: async (args: {url: string; focus?: string}) => {
const html = await fetch(args.url, {headers: {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"}})
.then(r => r.text()).catch(err => {throw new Error(`Failed to fetch: ${err.message}`)});
fn: async (args: {url: string; mimeRegex?: string}) => {
const ua = 'AiTools-Webpage/1.0';
const maxSize = 10 * 1024 * 1024;
const response = await fetch(args.url, {
headers: {
'User-Agent': ua,
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.5'
},
redirect: 'follow'
}).catch(err => {throw new Error(`Failed to fetch: ${err.message}`)});
const contentType = response.headers.get('content-type') || '';
const mimeType = contentType.split(';')[0].trim().toLowerCase();
if(args.mimeRegex && !new RegExp(args.mimeRegex, 'i').test(mimeType)) {
return `❌ MIME type rejected: ${mimeType} (filter: ${args.mimeRegex})`;
}
if(mimeType.match(/^(image|audio|video)\//)) {
const buffer = await response.arrayBuffer();
if(buffer.byteLength > maxSize) {
return `❌ File too large: ${(buffer.byteLength / 1024 / 1024).toFixed(1)}MB (max 10MB)\nType: ${mimeType}`;
}
const base64 = Buffer.from(buffer).toString('base64');
return `## Media File\n**Type:** ${mimeType}\n**Size:** ${(buffer.byteLength / 1024).toFixed(1)}KB\n**Data URL:** \`data:${mimeType};base64,${base64.slice(0, 100)}...\``;
}
if(mimeType.match(/^text\/(plain|csv|xml)/) || args.url.match(/\.(txt|csv|xml|md|yaml|yml)$/i)) {
const text = await response.text();
const truncated = text.length > 50000 ? text.slice(0, 50000) : text;
return `## Text File\n**Type:** ${mimeType}\n**URL:** ${args.url}\n\n${truncated}`;
}
if(mimeType.match(/application\/(json|xml|csv)/)) {
const text = await response.text();
const truncated = text.length > 50000 ? text.slice(0, 50000) : text;
return `## Structured Data\n**Type:** ${mimeType}\n**URL:** ${args.url}\n\n\`\`\`\n${truncated}\n\`\`\``;
}
if(mimeType === 'application/pdf' || (mimeType.startsWith('application/') && !mimeType.includes('html'))) {
const buffer = await response.arrayBuffer();
if(buffer.byteLength > maxSize) {
return `❌ File too large: ${(buffer.byteLength / 1024 / 1024).toFixed(1)}MB (max 10MB)\nType: ${mimeType}`;
}
const base64 = Buffer.from(buffer).toString('base64');
return `## Binary File\n**Type:** ${mimeType}\n**Size:** ${(buffer.byteLength / 1024).toFixed(1)}KB\n**Data URL:** \`data:${mimeType};base64,${base64.slice(0, 100)}...\``;
}
// HTML
const html = await response.text();
const $ = cheerio.load(html);
$('script, style, nav, footer, header, aside, iframe, noscript, [role="navigation"], [role="banner"], .ad, .ads, .cookie, .popup').remove();
const metadata = {
title: $('meta[property="og:title"]').attr('content') || $('title').text() || '',
description: $('meta[name="description"]').attr('content') || $('meta[property="og:description"]').attr('content') || '',
};
$('script, style, nav, footer, header, aside, iframe, noscript, svg').remove();
$('[role="navigation"], [role="banner"], [role="complementary"]').remove();
$('[aria-hidden="true"], [hidden], .visually-hidden, .sr-only, .screen-reader-text').remove();
$('.ad, .ads, .advertisement, .cookie, .popup, .modal, .sidebar, .related, .comments, .social-share').remove();
$('button, [class*="share"], [class*="follow"], [class*="social"]').remove();
const title = $('meta[property="og:title"]').attr('content') || $('title').text().trim() || '';
const description = $('meta[name="description"]').attr('content') || $('meta[property="og:description"]').attr('content') || '';
const author = $('meta[name="author"]').attr('content') || '';
let content = '';
const contentSelectors = ['article', 'main', '[role="main"]', '.content', '.post', '.entry', 'body'];
for (const selector of contentSelectors) {
const el = $(selector).first();
const selectors = ['article', 'main', '[role="main"]', '.content', '.post-content', '.entry-content', '.article-content'];
for(const sel of selectors) {
const el = $(sel).first();
if(el.length && el.text().trim().length > 200) {
content = el.text();
const paragraphs: string[] = [];
el.find('p').each((_, p) => {
const text = $(p).text().trim();
if(text.length > 80) paragraphs.push(text);
});
if(paragraphs.length > 2) {
content = paragraphs.join('\n\n');
break;
}
}
if (!content) content = $('body').text();
content = content.replace(/\s+/g, ' ').trim().slice(0, 8000);
}
return {url: args.url, title: metadata.title.trim(), description: metadata.description.trim(), content, focus: args.focus};
if(!content) {
const paragraphs: string[] = [];
$('body p').each((_, p) => {
const text = $(p).text().trim();
if(text.length > 80) paragraphs.push(text);
});
content = paragraphs.slice(0, 30).join('\n\n');
}
// Decode escaped newlines and clean
const parts = [`## ${title || 'Webpage'}`];
if(description) parts.push(`_${description}_`);
if(author) parts.push(`👤 ${author}`);
parts.push(`🔗 ${args.url}\n`);
parts.push(content);
return decodeHtml(parts.join('\n\n').replaceAll(/\n{3,}/g, '\n\n'));
}
};
export const WebSearchTool: AiTool = {
name: 'web_search',
@@ -159,7 +287,7 @@ export const WebSearchTool: AiTool = {
length: number;
}) => {
const html = await fetch(`https://html.duckduckgo.com/html/?q=${encodeURIComponent(args.query)}`, {
headers: {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", "Accept-Language": "en-US,en;q=0.9"}
headers: {"User-Agent": UA, "Accept-Language": "en-US,en;q=0.9"}
}).then(resp => resp.text());
let match, regex = /<a .*?href="(.+?)".+?<\/a>/g;
const results = new ASet<string>();
@@ -172,3 +300,91 @@ export const WebSearchTool: AiTool = {
return results;
}
}
export const WikipediaTool: AiTool = {
name: 'wikipedia_search',
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'}
},
fn: async (args: {query: string, mode: 'search' | 'summary' | 'full'}) => {
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)';
class WikipediaClient {
async get(url: string) {
const resp = await fetch(url, {headers: {'User-Agent': UA}});
return resp.json();
}
api(params: any) {
const qs = new URLSearchParams({...params, format: 'json', utf8: '1'}).toString();
return this.get(`https://en.wikipedia.org/w/api.php?${qs}`);
}
clean(text: string) {
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();
if(args.mode == 'search') return wiki.search(args.query);
return wiki.lookup(args.query, args.mode || 'summary');
}
};

View File

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

View File

@@ -4,7 +4,10 @@
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ESNext"],
"lib": [
"ESNext",
"dom"
],
"skipLibCheck": true,
/* Bundler mode */