Compare commits

...

70 Commits
0.2.3 ... 1.2.2

Author SHA1 Message Date
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
abd290246c LLM ASR
All checks were successful
Publish Library / Build NPM Project (push) Successful in 43s
Publish Library / Tag Version (push) Successful in 13s
2026-02-22 09:29:31 -05:00
ca66e8e304 Improved whisper + pyannote, sentence diarization
All checks were successful
Publish Library / Build NPM Project (push) Successful in 49s
Publish Library / Tag Version (push) Successful in 7s
2026-02-21 14:16:20 -05:00
cec892563e Whisper ASR
All checks were successful
Publish Library / Build NPM Project (push) Successful in 33s
Publish Library / Tag Version (push) Successful in 5s
2026-02-21 01:03:25 -05:00
91066e070f WIP ASR
All checks were successful
Publish Library / Build NPM Project (push) Successful in 33s
Publish Library / Tag Version (push) Successful in 5s
2026-02-21 00:51:01 -05:00
a94b153c6d Fixed embedder autostart bug
All checks were successful
Publish Library / Build NPM Project (push) Successful in 36s
Publish Library / Tag Version (push) Successful in 5s
2026-02-21 00:30:38 -05:00
39537a4a8f Switching to processes and whisper.cpp to avoid transformers.js memory leaks
All checks were successful
Publish Library / Build NPM Project (push) Successful in 38s
Publish Library / Tag Version (push) Successful in 5s
2026-02-20 21:50:01 -05:00
790608f020 Queue OCR & ASR work
All checks were successful
Publish Library / Build NPM Project (push) Successful in 35s
Publish Library / Tag Version (push) Successful in 6s
2026-02-20 19:05:19 -05:00
473424ae23 segfault fix
All checks were successful
Publish Library / Build NPM Project (push) Successful in 33s
Publish Library / Tag Version (push) Successful in 6s
2026-02-20 17:31:49 -05:00
9b831f7d95 Better ASR IDing
All checks were successful
Publish Library / Build NPM Project (push) Successful in 34s
Publish Library / Tag Version (push) Successful in 5s
2026-02-20 16:55:25 -05:00
498b326e45 Bump 0.7.4
All checks were successful
Publish Library / Build NPM Project (push) Successful in 34s
Publish Library / Tag Version (push) Successful in 5s
2026-02-20 14:19:17 -05:00
56e4efec94 Use either python or python3 or diarization 2026-02-20 14:14:30 -05:00
a07f069ad0 One embedding at a time
All checks were successful
Publish Library / Build NPM Project (push) Successful in 27s
Publish Library / Tag Version (push) Successful in 7s
2026-02-19 22:58:53 -05:00
da15d299e6 parallel embedding cap
All checks were successful
Publish Library / Build NPM Project (push) Successful in 31s
Publish Library / Tag Version (push) Successful in 5s
2026-02-19 21:37:58 -05:00
7ef7c3f676 Cap speaker ID transcript length to 2000 tokens
All checks were successful
Publish Library / Build NPM Project (push) Successful in 34s
Publish Library / Tag Version (push) Successful in 6s
2026-02-14 09:48:12 -05:00
4143d00de7 Working speaker detection with advanced LLM identifying. Improved LLM json function
All checks were successful
Publish Library / Build NPM Project (push) Successful in 39s
Publish Library / Tag Version (push) Successful in 5s
2026-02-14 09:39:17 -05:00
0360f2493d Added hugging face token
All checks were successful
Publish Library / Build NPM Project (push) Successful in 31s
Publish Library / Tag Version (push) Successful in 5s
2026-02-12 22:15:57 -05:00
0172887877 audio worker fix
All checks were successful
Publish Library / Build NPM Project (push) Successful in 28s
Publish Library / Tag Version (push) Successful in 5s
2026-02-12 20:24:12 -05:00
8f89f5e3cf embedding worker fix
All checks were successful
Publish Library / Build NPM Project (push) Successful in 28s
Publish Library / Tag Version (push) Successful in 5s
2026-02-12 20:18:56 -05:00
5bd41f8c6a worker fix?
All checks were successful
Publish Library / Build NPM Project (push) Successful in 29s
Publish Library / Tag Version (push) Successful in 5s
2026-02-12 20:17:31 -05:00
e4399e1b7b Updataes?
All checks were successful
Publish Library / Build NPM Project (push) Successful in 26s
Publish Library / Tag Version (push) Successful in 5s
2026-02-12 20:14:00 -05:00
ad1ee48763 Use one-off workers to process requests without blocking
All checks were successful
Publish Library / Build NPM Project (push) Successful in 27s
Publish Library / Tag Version (push) Successful in 5s
2026-02-12 19:45:17 -05:00
3ed206923f Fix ASR
All checks were successful
Publish Library / Build NPM Project (push) Successful in 27s
Publish Library / Tag Version (push) Successful in 5s
2026-02-12 18:32:19 -05:00
22d5427e86 Fix ASR
All checks were successful
Publish Library / Build NPM Project (push) Successful in 28s
Publish Library / Tag Version (push) Successful in 5s
2026-02-12 17:49:33 -05:00
43b53164c0 Bump 0.6.3
All checks were successful
Publish Library / Build NPM Project (push) Successful in 29s
Publish Library / Tag Version (push) Successful in 4s
2026-02-12 17:24:15 -05:00
575fbac099 Fixed ASR
All checks were successful
Publish Library / Build NPM Project (push) Successful in 30s
Publish Library / Tag Version (push) Successful in 4s
2026-02-12 13:31:30 -05:00
46ae0f7913 expose diarization support checking function
All checks were successful
Publish Library / Build NPM Project (push) Successful in 25s
Publish Library / Tag Version (push) Successful in 5s
2026-02-12 11:55:29 -05:00
54730a2b9a Speaker diarization
All checks were successful
Publish Library / Build NPM Project (push) Successful in 31s
Publish Library / Tag Version (push) Successful in 5s
2026-02-12 11:26:11 -05:00
27506d20af Fix anthropic message history
All checks were successful
Publish Library / Build NPM Project (push) Successful in 30s
Publish Library / Tag Version (push) Successful in 5s
2026-02-11 22:45:30 -05:00
8c64129200 Removed log statement
All checks were successful
Publish Library / Build NPM Project (push) Successful in 27s
Publish Library / Tag Version (push) Successful in 5s
2026-02-11 21:58:39 -05:00
013aa942c0 Added save directory for embedder
All checks were successful
Publish Library / Build NPM Project (push) Successful in 33s
Publish Library / Tag Version (push) Successful in 4s
2026-02-11 21:45:54 -05:00
c8d5660b1a Enable quantized embedder for speed boost
All checks were successful
Publish Library / Build NPM Project (push) Successful in 23s
Publish Library / Tag Version (push) Successful in 5s
2026-02-11 20:28:14 -05:00
f2c66b0cb8 Updated default embedder
All checks were successful
Publish Library / Build NPM Project (push) Successful in 39s
Publish Library / Tag Version (push) Successful in 8s
2026-02-11 20:23:50 -05:00
cda7db4f45 Added memory system
All checks were successful
Publish Library / Build NPM Project (push) Successful in 30s
Publish Library / Tag Version (push) Successful in 5s
2026-02-08 19:52:02 -05:00
d71a6be120 Fixed timezones with date time tool
All checks were successful
Publish Library / Build NPM Project (push) Successful in 57s
Publish Library / Tag Version (push) Successful in 8s
2026-02-02 09:30:48 -05:00
7b57a0ded1 Updated LLM config and added read_webpage
All checks were successful
Publish Library / Build NPM Project (push) Successful in 46s
Publish Library / Tag Version (push) Successful in 6s
2026-02-01 13:16:08 -05:00
28904cddbe TTS
All checks were successful
Publish Library / Build NPM Project (push) Successful in 49s
Publish Library / Tag Version (push) Successful in 16s
2026-01-30 15:39:29 -05:00
d5bf1ec47e Pulled chunking out into its own exported function for easy access
All checks were successful
Publish Library / Build NPM Project (push) Successful in 41s
Publish Library / Tag Version (push) Successful in 7s
2026-01-30 10:38:51 -05:00
cb60a0b0c5 Moved embeddings to worker to prevent blocking
All checks were successful
Publish Library / Build NPM Project (push) Successful in 41s
Publish Library / Tag Version (push) Successful in 7s
2026-01-28 22:17:39 -05:00
1c59379c7d Set tesseract model
All checks were successful
Publish Library / Build NPM Project (push) Successful in 31s
Publish Library / Tag Version (push) Successful in 5s
2026-01-16 20:33:51 -05:00
6dce0e8954 Fixed tool calls
All checks were successful
Publish Library / Build NPM Project (push) Successful in 39s
Publish Library / Tag Version (push) Successful in 8s
2025-12-27 17:27:53 -05:00
18 changed files with 4053 additions and 2749 deletions

144
README.md
View File

@@ -3,7 +3,7 @@
<br /> <br />
<!-- Logo --> <!-- Logo -->
<img src="https://git.zakscode.com/repo-avatars/a90851ca730480ec37a5c0c2c4f1b4609eee5eadf806eaf16c83ac4cb7493aa9" alt="Logo" width="200" height="200"> <img alt="Logo" width="200" height="200" src="https://git.zakscode.com/repo-avatars/a82d423674763e7a0c1c945bdbb07e249b2bb786d3c9beae76d5b196a10f5c0f">
<!-- Title --> <!-- Title -->
### @ztimson/ai-utils ### @ztimson/ai-utils
@@ -53,13 +53,15 @@ A TypeScript library that provides a unified interface for working with multiple
- **Provider Abstraction**: Switch between AI providers without changing your code - **Provider Abstraction**: Switch between AI providers without changing your code
### Built With ### Built With
[![Anthropic](https://img.shields.io/badge/Anthropic-191919?style=for-the-badge&logo=anthropic&logoColor=white)](https://anthropic.com/) [![Anthropic](https://img.shields.io/badge/Anthropic-de7356?style=for-the-badge&logo=anthropic&logoColor=white)](https://anthropic.com/)
[![OpenAI](https://img.shields.io/badge/OpenAI-412991?style=for-the-badge&logo=openai&logoColor=white)](https://openai.com/) [![llama](https://img.shields.io/badge/llama.cpp-fff?style=for-the-badge&logo=ollama&logoColor=black)](https://github.com/ggml-org/llama.cpp)
[![Ollama](https://img.shields.io/badge/Ollama-000000?style=for-the-badge&logo=ollama&logoColor=white)](https://ollama.com/) [![OpenAI](https://img.shields.io/badge/OpenAI-000?style=for-the-badge&logo=openai-gym&logoColor=white)](https://openai.com/)
[![TensorFlow](https://img.shields.io/badge/TensorFlow-FF6F00?style=for-the-badge&logo=tensorflow&logoColor=white)](https://tensorflow.org/) [![Pyannote](https://img.shields.io/badge/Pyannote-458864?style=for-the-badge&logo=python&logoColor=white)](https://github.com/pyannote)
[![Tesseract](https://img.shields.io/badge/Tesseract-3C8FC7?style=for-the-badge&logo=tesseract&logoColor=white)](https://tesseract-ocr.github.io/) [![TensorFlow](https://img.shields.io/badge/TensorFlow-fff?style=for-the-badge&logo=tensorflow&logoColor=ff6f00)](https://tensorflow.org/)
[![Tesseract](https://img.shields.io/badge/Tesseract-B874B2?style=for-the-badge&logo=hack-the-box&logoColor=white)](https://tesseract-ocr.github.io/)
[![Transformers.js](https://img.shields.io/badge/Transformers.js-000?style=for-the-badge&logo=hugging-face&logoColor=yellow)](https://huggingface.co/docs/transformers.js/en/index)
[![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?style=for-the-badge&logo=typescript&logoColor=white)](https://typescriptlang.org/) [![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?style=for-the-badge&logo=typescript&logoColor=white)](https://typescriptlang.org/)
[![Whisper](https://img.shields.io/badge/Whisper-412991?style=for-the-badge&logo=openai&logoColor=white)](https://github.com/ggerganov/whisper.cpp) [![Whisper](https://img.shields.io/badge/Whisper.cpp-000?style=for-the-badge&logo=openai-gym&logoColor=white)](https://github.com/ggerganov/whisper.cpp)
## Setup ## Setup
@@ -75,6 +77,7 @@ A TypeScript library that provides a unified interface for working with multiple
#### Instructions #### Instructions
1. Install the package: `npm i @ztimson/ai-utils` 1. Install the package: `npm i @ztimson/ai-utils`
2. For speaker diarization: `pip install pyannote.audio`
</details> </details>
@@ -87,17 +90,138 @@ A TypeScript library that provides a unified interface for working with multiple
#### Prerequisites #### Prerequisites
- [Node.js](https://nodejs.org/en/download) - [Node.js](https://nodejs.org/en/download)
- _[Whisper.cpp](https://github.com/ggml-org/whisper.cpp/releases/tag) (ASR)_
- _[Pyannote](https://github.com/pyannote) (ASR Diarization):_ `pip install pyannote.audio`
#### Instructions #### Instructions
1. Install the dependencies: `npm i` 1. Install the dependencies: `npm i`
2. Build library: `npm build` 2. For speaker diarization: `pip install pyannote.audio`
3. Run unit tests: `npm test` 3. Build library: `npm build`
4. Run unit tests: `npm test`
</details> </details>
## Documentation ## 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 ## License

4402
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,22 +1,32 @@
import {LLM, LLMOptions} from './llm'; import * as os from 'node:os';
import LLM, {AnthropicConfig, OpenAiConfig, LLMRequest} from './llm';
import { Audio } from './audio.ts'; import { Audio } from './audio.ts';
import {Vision} from './vision.ts'; import {Vision} from './vision.ts';
export type AiOptions = LLMOptions & { export type AbortablePromise<T> = Promise<T> & {
whisper?: { abort: () => any
/** Whisper binary location */ };
binary: string;
/** Model: `ggml-base.en.bin` */ export type AiOptions = {
model: string; /** Token to pull diarization models from hugging face */
} hfToken?: string;
/** Path to models */ /** Path to models */
path: string; path?: string;
/** Whisper ASR model: ggml-tiny.en.bin, ggml-base.en.bin */
asr?: string;
/** Embedding model: all-MiniLM-L6-v2, bge-small-en-v1.5, bge-large-en-v1.5 */
embedder?: string;
/** Large language models, first is default */
llm?: Omit<LLMRequest, 'model'> & {
models: {[model: string]: AnthropicConfig | OpenAiConfig};
}
/** OCR model: eng, eng_best, eng_fast */
ocr?: string;
/** Whisper binary */
whisper?: string;
} }
export class Ai { export class Ai {
private downloads: {[key: string]: Promise<string>} = {};
private whisperModel!: string;
/** Audio processing AI */ /** Audio processing AI */
audio!: Audio; audio!: Audio;
/** Language processing AI */ /** Language processing AI */
@@ -25,6 +35,7 @@ export class Ai {
vision!: Vision; vision!: Vision;
constructor(public readonly options: AiOptions) { constructor(public readonly options: AiOptions) {
if(!options.path) options.path = os.tmpdir();
process.env.TRANSFORMERS_CACHE = options.path; process.env.TRANSFORMERS_CACHE = options.path;
this.audio = new Audio(this); this.audio = new Audio(this);
this.language = new LLM(this); this.language = new LLM(this);

View File

@@ -1,8 +1,9 @@
import {Anthropic as anthropic} from '@anthropic-ai/sdk'; import {Anthropic as anthropic} from '@anthropic-ai/sdk';
import {findByProp, objectMap, JSONSanitize, JSONAttemptParse, deepCopy} from '@ztimson/utils'; import {findByProp, objectMap, JSONSanitize, JSONAttemptParse} from '@ztimson/utils';
import {Ai} from './ai.ts'; import {AbortablePromise, Ai} from './ai.ts';
import {LLMMessage, LLMRequest} from './llm.ts'; import {LLMMessage, LLMRequest} from './llm.ts';
import {AbortablePromise, LLMProvider} from './provider.ts'; import {LLMProvider} from './provider.ts';
import {convertSchema} from './tools.ts';
export class Anthropic extends LLMProvider { export class Anthropic extends LLMProvider {
client!: anthropic; client!: anthropic;
@@ -13,25 +14,25 @@ export class Anthropic extends LLMProvider {
} }
private toStandard(history: any[]): LLMMessage[] { private toStandard(history: any[]): LLMMessage[] {
for(let i = 0; i < history.length; i++) { const timestamp = Date.now();
const orgI = i; const messages: LLMMessage[] = [];
if(typeof history[orgI].content != 'string') { for(let h of history) {
if(history[orgI].role == 'assistant') { if(typeof h.content == 'string') {
history[orgI].content.filter((c: any) => c.type =='tool_use').forEach((c: any) => { messages.push(<any>{timestamp, ...h});
i++; } else {
history.splice(i, 0, {role: 'tool', id: c.id, name: c.name, args: c.input, timestamp: Date.now()}); 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});
} else if(history[orgI].role == 'user') { h.content.forEach((c: any) => {
history[orgI].content.filter((c: any) => c.type =='tool_result').forEach((c: any) => { if(c.type == 'tool_use') {
const h = history.find((h: any) => h.id == c.tool_use_id); messages.push({timestamp, role: 'tool', id: c.id, name: c.name, args: c.input, content: undefined});
h[c.is_error ? 'error' : 'content'] = c.content; } 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;
}
}); });
} }
history[orgI].content = history[orgI].content.filter((c: any) => c.type == 'text').map((c: any) => c.text).join('\n\n');
} }
if(!history[orgI].timestamp) history[orgI].timestamp = Date.now(); return messages;
}
return history.filter(h => !!h.content);
} }
private fromStandard(history: LLMMessage[]): any[] { private fromStandard(history: LLMMessage[]): any[] {
@@ -48,18 +49,17 @@ export class Anthropic extends LLMProvider {
return history.map(({timestamp, ...h}) => h); return history.map(({timestamp, ...h}) => h);
} }
ask(message: string, options: LLMRequest = {}): AbortablePromise<LLMMessage[]> { ask(message: string, options: LLMRequest = {}): AbortablePromise<string | any> {
const controller = new AbortController(); const controller = new AbortController();
const response = new Promise<any>(async (res, rej) => { return Object.assign(new Promise<any>(async (res) => {
let history = this.fromStandard([...options.history || [], {role: 'user', content: message, timestamp: Date.now()}]); let history = this.fromStandard([...options.history || [], {role: 'user', content: message, timestamp: Date.now()}]);
const original = deepCopy(history); const tools = options.tools || this.ai.options.llm?.tools || [];
if(options.compress) history = await this.ai.language.compressHistory(<any>history, options.compress.max, options.compress.min, options);
const requestParams: any = { const requestParams: any = {
model: options.model || this.model, model: options.model || this.model,
max_tokens: options.max_tokens || this.ai.options.max_tokens || 4096, max_tokens: options.max_tokens || this.ai.options.llm?.max_tokens || 4096,
system: options.system || this.ai.options.system || '', system: options.system || this.ai.options.llm?.system || '',
temperature: options.temperature || this.ai.options.temperature || 0.7, temperature: options.temperature || this.ai.options.llm?.temperature || undefined,
tools: (options.tools || this.ai.options.tools || []).map(t => ({ tools: tools.map(t => ({
name: t.name, name: t.name,
description: t.description, description: t.description,
input_schema: { input_schema: {
@@ -73,10 +73,22 @@ export class Anthropic extends LLMProvider {
stream: !!options.stream, 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; let resp: any, isFirstMessage = true;
const assistantMessages: string[] = [];
do { do {
resp = await this.client.messages.create(requestParams); resp = await this.client.messages.create(requestParams).catch(err => {
err.message += `\n\nMessages:\n${JSON.stringify(history, null, 2)}`;
throw err;
});
// Streaming mode // Streaming mode
if(options.stream) { if(options.stream) {
@@ -112,13 +124,13 @@ export class Anthropic extends LLMProvider {
const toolCalls = resp.content.filter((c: any) => c.type === 'tool_use'); const toolCalls = resp.content.filter((c: any) => c.type === 'tool_use');
if(toolCalls.length && !controller.signal.aborted) { if(toolCalls.length && !controller.signal.aborted) {
history.push({role: 'assistant', content: resp.content}); history.push({role: 'assistant', content: resp.content});
original.push({role: 'assistant', content: resp.content});
const results = await Promise.all(toolCalls.map(async (toolCall: any) => { const results = await Promise.all(toolCalls.map(async (toolCall: any) => {
const tool = options.tools?.find(findByProp('name', toolCall.name)); 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'}; if(!tool) return {tool_use_id: toolCall.id, is_error: true, content: 'Tool not found'};
try { try {
const result = await tool.fn(toolCall.input, this.ai); 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) { } catch (err: any) {
return {type: 'tool_result', tool_use_id: toolCall.id, is_error: true, content: err?.message || err?.toString() || 'Unknown'}; return {type: 'tool_result', tool_use_id: toolCall.id, is_error: true, content: err?.message || err?.toString() || 'Unknown'};
} }
@@ -128,10 +140,16 @@ export class Anthropic extends LLMProvider {
} }
} while (!controller.signal.aborted && resp.content.some((c: any) => c.type === 'tool_use')); } while (!controller.signal.aborted && resp.content.some((c: any) => c.type === 'tool_use'));
if(options.stream) options.stream({done: true}); const textContent = resp.content.filter((c: any) => c.type == 'text').map((c: any) => c.text).join('\n\n');
res(this.toStandard([...history, {role: 'assistant', content: 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);
return Object.assign(response, {abort: () => controller.abort()}); if(options.stream) options.stream({done: true});
if(options.history) options.history.splice(0, options.history.length, ...history);
// 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,54 +1,267 @@
import {spawn} from 'node:child_process'; import {execSync, spawn} from 'node:child_process';
import {mkdtempSync} from 'node:fs';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import Path from 'node:path'; import {tmpdir} from 'node:os';
import {Ai} from './ai.ts'; import Path, {join} from 'node:path';
import {AbortablePromise, Ai} from './ai.ts';
export class Audio { export class Audio {
private downloads: {[key: string]: Promise<string>} = {}; private downloads: {[key: string]: Promise<string>} = {};
private pyannote!: string;
private whisperModel!: string; private whisperModel!: string;
constructor(private ai: Ai) { constructor(private ai: Ai) {
if(ai.options.whisper?.binary) { if(ai.options.whisper) {
this.whisperModel = ai.options.whisper?.model.endsWith('.bin') ? ai.options.whisper?.model : ai.options.whisper?.model + '.bin'; this.whisperModel = ai.options.asr || 'ggml-base.en.bin';
this.downloadAsrModel(); this.downloadAsrModel();
} }
this.pyannote = `
import sys
import json
import os
from pyannote.audio import Pipeline
os.environ['TORCH_HOME'] = r"${ai.options.path}"
pipeline = Pipeline.from_pretrained("pyannote/speaker-diarization-3.1", token="${ai.options.hfToken}")
output = pipeline(sys.argv[1])
segments = []
for turn, speaker in output.speaker_diarization:
segments.append({"start": turn.start, "end": turn.end, "speaker": speaker})
print(json.dumps(segments))
`;
} }
/** private async addPunctuation(timestampData: any, llm?: boolean, cadence = 150): Promise<string> {
* Convert audio to text using Auditory Speech Recognition const countSyllables = (word: string): number => {
* @param {string} path Path to audio word = word.toLowerCase().replace(/[^a-z]/g, '');
* @param model Whisper model if(word.length <= 3) return 1;
* @returns {Promise<any>} Extracted text const matches = word.match(/[aeiouy]+/g);
*/ let count = matches ? matches.length : 1;
asr(path: string, model: string = this.whisperModel): {abort: () => void, response: Promise<string | null>} { if(word.endsWith('e')) count--;
if(!this.ai.options.whisper?.binary) throw new Error('Whisper not configured'); return Math.max(1, count);
let abort: any = () => {}; };
const response = new Promise<string | null>((resolve, reject) => {
this.downloadAsrModel(model).then(m => { let result = '';
timestampData.transcription.filter((word, i) => {
let skip = false;
const prevWord = timestampData.transcription[i - 1];
const nextWord = timestampData.transcription[i + 1];
if(!word.text && nextWord) {
nextWord.offsets.from = word.offsets.from;
nextWord.timestamps.from = word.offsets.from;
} else if(word.text && word.text[0] != ' ' && prevWord) {
prevWord.offsets.to = word.offsets.to;
prevWord.timestamps.to = word.timestamps.to;
prevWord.text += word.text;
skip = true;
}
return !!word.text && !skip;
}).forEach((word: any) => {
const capital = /^[A-Z]/.test(word.text.trim());
const length = word.offsets.to - word.offsets.from;
const syllables = countSyllables(word.text.trim());
const expected = syllables * cadence;
if(capital && length > expected * 2 && word.text[0] == ' ') result += '.';
result += word.text;
});
if(!llm) return result.trim();
return this.ai.language.ask(result, {
system: 'Remove any misplaced punctuation from the following ASR transcript using the replace tool. Avoid modifying words unless there is an obvious typo',
temperature: 0.1,
tools: [{
name: 'replace',
description: 'Use find and replace to fix errors',
args: {
find: {type: 'string', description: 'Text to find', required: true},
replace: {type: 'string', description: 'Text to replace', required: true}
},
fn: (args) => result = result.replace(args.find, args.replace)
}]
}).then(() => result);
}
private async diarizeTranscript(timestampData: any, speakers: any[], llm: boolean): Promise<string> {
const speakerMap = new Map();
let speakerCount = 0;
speakers.forEach((seg: any) => {
if(!speakerMap.has(seg.speaker)) speakerMap.set(seg.speaker, ++speakerCount);
});
const punctuatedText = await this.addPunctuation(timestampData, llm);
const sentences = punctuatedText.match(/[^.!?]+[.!?]+/g) || [punctuatedText];
const words = timestampData.transcription.filter((w: any) => w.text.trim());
// Assign speaker to each sentence
const sentencesWithSpeakers = sentences.map(sentence => {
sentence = sentence.trim();
if(!sentence) return null;
const sentenceWords = sentence.toLowerCase().replace(/[^\w\s]/g, '').split(/\s+/);
const speakerWordCount = new Map<number, number>();
sentenceWords.forEach(sw => {
const word = words.find((w: any) => sw === w.text.trim().toLowerCase().replace(/[^\w]/g, ''));
if(!word) return;
const wordTime = word.offsets.from / 1000;
const speaker = speakers.find((seg: any) => wordTime >= seg.start && wordTime <= seg.end);
if(speaker) {
const spkNum = speakerMap.get(speaker.speaker);
speakerWordCount.set(spkNum, (speakerWordCount.get(spkNum) || 0) + 1);
}
});
let bestSpeaker = 1;
let maxWords = 0;
speakerWordCount.forEach((count, speaker) => {
if(count > maxWords) {
maxWords = count;
bestSpeaker = speaker;
}
});
return {speaker: bestSpeaker, text: sentence};
}).filter(s => s !== null);
// Merge adjacent sentences from same speaker
const merged: Array<{speaker: number, text: string}> = [];
sentencesWithSpeakers.forEach(item => {
const last = merged[merged.length - 1];
if(last && last.speaker === item.speaker) {
last.text += ' ' + item.text;
} else {
merged.push({...item});
}
});
let transcript = merged.map(item => `[Speaker ${item.speaker}]: ${item.text}`).join('\n').trim();
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)];
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}]`);
}}
]
});
return transcript;
}
private runAsr(file: string, opts: {model?: string, diarization?: boolean} = {}): AbortablePromise<any> {
let proc: any;
const p = new Promise<any>((resolve, reject) => {
this.downloadAsrModel(opts.model).then(m => {
if(opts.diarization) {
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']}
);
proc.on('error', (err: Error) => reject(err));
proc.on('close', async (code: number) => {
if(code === 0) {
output = await fs.readFile(output + '.json', 'utf-8');
fs.rm(output + '.json').catch(() => { });
try { resolve(JSON.parse(output)); }
catch(e) { reject(new Error('Failed to parse whisper JSON')); }
} else {
reject(new Error(`Exit code ${code}`));
}
});
} else {
let output = ''; let output = '';
const proc = spawn(<string>this.ai.options.whisper?.binary, ['-nt', '-np', '-m', m, '-f', path], {stdio: ['ignore', 'pipe', 'ignore']}); proc = spawn(<string>this.ai.options.whisper, ['-m', m, '-f', file, '-np', '-nt']);
abort = () => proc.kill('SIGTERM');
proc.on('error', (err: Error) => reject(err)); proc.on('error', (err: Error) => reject(err));
proc.stdout.on('data', (data: Buffer) => output += data.toString()); proc.stdout.on('data', (data: Buffer) => output += data.toString());
proc.on('close', (code: number) => { proc.on('close', async (code: number) => {
if(code === 0) resolve(output.trim() || null); if(code === 0) {
else reject(new Error(`Exit code ${code}`)); resolve(output.trim() || null);
} else {
reject(new Error(`Exit code ${code}`));
}
});
}
}); });
}); });
}); return <any>Object.assign(p, {abort: () => proc?.kill('SIGTERM')});
return {response, abort}; }
private runDiarization(file: string): AbortablePromise<any> {
let aborted = false, abort = () => { aborted = true; };
const checkPython = (cmd: string) => {
return new Promise<boolean>((resolve) => {
const proc = spawn(cmd, ['-W', 'ignore', '-c', 'import pyannote.audio']);
proc.on('close', (code: number) => resolve(code === 0));
proc.on('error', () => resolve(false));
});
};
const p = Promise.all<any>([
checkPython('python'),
checkPython('python3'),
]).then(<any>(async ([p, p3]: [boolean, boolean]) => {
if(aborted) return;
if(!p && !p3) throw new Error('Pyannote is not installed: pip install pyannote.audio');
const binary = p3 ? 'python3' : 'python';
return new Promise((resolve, reject) => {
if(aborted) return;
let output = '';
const proc = spawn(binary, ['-W', 'ignore', '-c', this.pyannote, file]);
proc.stdout.on('data', (data: Buffer) => output += data.toString());
proc.stderr.on('data', (data: Buffer) => console.error(data.toString()));
proc.on('close', (code: number) => {
if(code === 0) {
try { resolve(JSON.parse(output)); }
catch (err) { reject(new Error('Failed to parse diarization output')); }
} else {
reject(new Error(`Python process exited with code ${code}`));
}
});
proc.on('error', reject);
abort = () => proc.kill('SIGTERM');
});
}));
return <any>Object.assign(p, {abort});
}
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 "${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});
const timestamps = this.runAsr(tmp, {model: options.model, diarization: true});
const diarization = this.runDiarization(tmp);
let aborted = false, abort = () => {
aborted = true;
timestamps.abort();
diarization.abort();
clean();
};
const response = Promise.allSettled([timestamps, diarization]).then(async ([ts, d]) => {
if(ts.status == 'rejected') throw new Error('Whisper.cpp timestamps:\n' + ts.reason);
if(d.status == 'rejected') throw new Error('Pyannote:\n' + d.reason);
if(aborted || !options.diarization) return ts.value;
return this.diarizeTranscript(ts.value, d.value, options.diarization == 'llm');
}).finally(() => clean());
return <any>Object.assign(response, {abort});
} }
/**
* Downloads the specified Whisper model if it is not already present locally.
*
* @param {string} model Whisper model that will be downloaded
* @return {Promise<string>} Absolute path to model file, resolves once downloaded
*/
async downloadAsrModel(model: string = this.whisperModel): Promise<string> { async downloadAsrModel(model: string = this.whisperModel): Promise<string> {
if(!this.ai.options.whisper?.binary) throw new Error('Whisper not configured'); if(!this.ai.options.whisper) throw new Error('Whisper not configured');
if(!model.endsWith('.bin')) model += '.bin'; if(!model.endsWith('.bin')) model += '.bin';
const p = Path.join(this.ai.options.path, model); const p = Path.join(<string>this.ai.options.path, model);
if(await fs.stat(p).then(() => true).catch(() => false)) return p; if(await fs.stat(p).then(() => true).catch(() => false)) return p;
if(!!this.downloads[model]) return this.downloads[model]; if(!!this.downloads[model]) return this.downloads[model];
this.downloads[model] = fetch(`https://huggingface.co/ggerganov/whisper.cpp/resolve/main/${model}`) this.downloads[model] = fetch(`https://huggingface.co/ggerganov/whisper.cpp/resolve/main/${model}`)

13
src/embedder.ts Normal file
View File

@@ -0,0 +1,13 @@
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, {cache_dir: modelDir});
const output = await embedder(text, { pooling: 'mean', normalize: true });
const embedding = Array.from(output.data);
process.stdout.write(JSON.stringify({embedding}));
process.exit();
});

View File

@@ -1,4 +1,9 @@
export * from './ai'; export * from './ai';
export * from './antrhopic'; export * from './antrhopic';
export * from './audio';
export * from './llm'; export * from './llm';
export * from './memory';
export * from './open-ai';
export * from './provider';
export * from './tools'; export * from './tools';
export * from './vision';

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,12 +1,15 @@
import {pipeline} from '@xenova/transformers'; import {AbortablePromise, Ai} from './ai.ts';
import {JSONAttemptParse} from '@ztimson/utils';
import {Ai} from './ai.ts';
import {Anthropic} from './antrhopic.ts'; import {Anthropic} from './antrhopic.ts';
import {Ollama} from './ollama.ts';
import {OpenAi} from './open-ai.ts'; import {OpenAi} from './open-ai.ts';
import {AbortablePromise, LLMProvider} from './provider.ts'; import {LLMProvider} from './provider.ts';
import {AiTool} from './tools.ts'; import {AiTool, AiToolArg} from './tools.ts';
import * as tf from '@tensorflow/tfjs'; 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 OpenAiConfig = {proto: 'openai', host?: string, token: string};
export type LLMMessage = { export type LLMMessage = {
/** Message originator */ /** Message originator */
@@ -27,38 +30,14 @@ export type LLMMessage = {
/** Tool result */ /** Tool result */
content: undefined | string; content: undefined | string;
/** Tool error */ /** Tool error */
error: undefined | string; error?: undefined | string;
/** Timestamp */ /** Timestamp */
timestamp?: number; timestamp?: number;
} }
export type LLMOptions = {
/** Anthropic settings */
anthropic?: {
/** API Token */
token: string;
/** Default model */
model: string;
},
/** Ollama settings */
ollama?: {
/** connection URL */
host: string;
/** Default model */
model: string;
},
/** Open AI settings */
openAi?: {
/** API Token */
token: string;
/** Default model */
model: string;
},
/** Default provider & model */
model: string | [string, string];
} & Omit<LLMRequest, 'model'>;
export type LLMRequest = { export type LLMRequest = {
/** Return a parsed JSON object that matches the schema */
schema?: AiToolArg;
/** System prompt */ /** System prompt */
system?: string; system?: string;
/** Message history */ /** Message history */
@@ -70,54 +49,195 @@ export type LLMRequest = {
/** Available tools */ /** Available tools */
tools?: AiTool[]; tools?: AiTool[];
/** LLM model */ /** LLM model */
model?: string | [string, string]; model?: string;
/** Stream response */ /** Stream response */
stream?: (chunk: {text?: string, done?: true}) => any; stream?: (chunk: {text?: string, tool?: string, done?: true}) => any;
/** Compress old messages in the chat to free up context */ /** Compress old messages in the chat to free up context */
compress?: { compress?: {max: number; min: number};
/** Trigger chat compression once context exceeds the token count */ /** User's memory documents - RAG injected automatically each turn */
max: number; memory?: Memory[] | MemoryCache;
/** Compress chat until context size smaller than */ /** Model to use for memory operations */
min: number 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 class LLM { export type McpServer = {
private embedModel: any; /** MCP server name for humans */
private providers: {[key: string]: LLMProvider} = {}; 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} = {};
constructor(public readonly ai: Ai) { constructor(public readonly ai: Ai) {
this.embedModel = pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2'); if(!ai.options.llm?.models) return;
if(ai.options.anthropic?.token) this.providers.anthropic = new Anthropic(this.ai, ai.options.anthropic.token, ai.options.anthropic.model); Object.entries(ai.options.llm.models).forEach(([model, config]) => {
if(ai.options.ollama?.host) this.providers.ollama = new Ollama(this.ai, ai.options.ollama.host, ai.options.ollama.model); if(!this.defaultModel) this.defaultModel = model;
if(ai.options.openAi?.token) this.providers.openAi = new OpenAi(this.ai, ai.options.openAi.token, ai.options.openAi.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.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}`;
}
}]
}
}
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 => {
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) {
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));
}
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'));
}
// 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});
} }
/** /**
* Chat with LLM * Digest full conversation history into memory documents.
* @param {string} message Question * Call on session end to persist the conversation.
* @param {LLMRequest} options Configuration options and chat history
* @returns {{abort: () => void, response: Promise<LLMMessage[]>}} Function to abort response and chat history
*/ */
ask(message: string, options: LLMRequest = {}): AbortablePromise<LLMMessage[]> { async updateMemory(history: LLMMessage[], memories: Memory[] | MemoryCache, options: LLMRequest = {}): Promise<void> {
let model: any = [null, null]; await this.memoryManager.memorize(history, memories, {model: this.defaultModel, ...options});
if(options.model) {
if(typeof options.model == 'object') model = options.model;
else model = [options.model, (<any>this.ai.options)[options.model]?.model];
}
if(!options.model || model[1] == null) {
if(typeof this.ai.options.model == 'object') model = this.ai.options.model;
else model = [this.ai.options.model, (<any>this.ai.options)[this.ai.options.model]?.model];
}
if(!model[0] || !model[1]) throw new Error(`Unknown LLM provider or model: ${model[0]} / ${model[1]}`);
return this.providers[model[0]].ask(message, {...options, model: model[1]});
} }
/** /**
* Compress chat history to reduce context size * Compress chat history to reduce context size
* @param {LLMMessage[]} history Chatlog that will be compressed * @param {LLMMessage[]} history Chatlog that will be compressed
* @param max Trigger compression once context is larger than max * @param max Trigger compression once context is larger than max
* @param min Summarize until context size is less than min * @param min Leave messages less than the token minimum, summarize the rest
* @param {LLMRequest} options LLM options * @param {LLMRequest} options LLM options
* @returns {Promise<LLMMessage[]>} New chat history will summary at index 0 * @returns {Promise<LLMMessage[]>} New chat history will summary at index 0
*/ */
@@ -130,12 +250,23 @@ export class LLM {
else break; else break;
} }
if(history.length <= keep) return history; if(history.length <= keep) return history;
const recent = keep == 0 ? [] : history.slice(-keep), 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'); process = (keep == 0 ? history : history.slice(0, -keep)).filter(h => h.role === 'assistant' || h.role === 'user');
const summary = await this.summarize(process.map(m => `${m.role}: ${m.content}`).join('\n\n'), 250, options);
return [{role: 'assistant', content: `Conversation 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 h;
} }
/**
* Compare the difference between embeddings (calculates the angle between two vectors)
* @param {number[]} v1 First embedding / vector comparison
* @param {number[]} v2 Second embedding / vector for comparison
* @returns {number} Similarity values 0-1: 0 = unique, 1 = identical
*/
cosineSimilarity(v1: number[], v2: number[]): number { cosineSimilarity(v1: number[], v2: number[]): number {
if (v1.length !== v2.length) throw new Error('Vectors must be same length'); if (v1.length !== v2.length) throw new Error('Vectors must be same length');
let dotProduct = 0, normA = 0, normB = 0; let dotProduct = 0, normA = 0, normB = 0;
@@ -148,55 +279,92 @@ export class LLM {
return denominator === 0 ? 0 : dotProduct / denominator; return denominator === 0 ? 0 : dotProduct / denominator;
} }
embedding(target: object | string, maxTokens = 500, overlapTokens = 50) { /**
* Chunk text into parts for AI digestion
* @param {object | string} target Item that will be chunked (objects get converted)
* @param {number} maxTokens Chunking size. More = better context, less = more specific (Search by paragraphs or lines)
* @param {number} overlapTokens Includes previous X tokens to provide continuity to AI (In addition to max tokens)
* @returns {string[]} Chunked strings
*/
chunk(target: object | string, maxTokens = 500, overlapTokens = 50): string[] {
const objString = (obj: any, path = ''): string[] => { const objString = (obj: any, path = ''): string[] => {
if(obj === null || obj === undefined) return []; if(!obj) return [];
return Object.entries(obj).flatMap(([key, value]) => { return Object.entries(obj).flatMap(([key, value]) => {
const p = path ? `${path}${isNaN(+key) ? `.${key}` : `[${key}]`}` : key; const p = path ? `${path}${isNaN(+key) ? `.${key}` : `[${key}]`}` : key;
if(typeof value === 'object' && value !== null && !Array.isArray(value)) return objString(value, p); if(typeof value === 'object' && !Array.isArray(value)) return objString(value, p);
const valueStr = Array.isArray(value) ? value.join(', ') : String(value); return `${p}: ${Array.isArray(value) ? value.join(', ') : value}`;
return `${p}: ${valueStr}`; });
};
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;) {
let text = '', j = i;
while(j < tokens.length) {
const next = text + (text ? ' ' : '') + tokens[j];
if(this.estimateTokens(next.replace(/\s*\n\s*/g, '\n')) > maxTokens && text) break;
text = next;
j++;
}
const clean = text.replace(/\s*\n\s*/g, '\n').trim();
if(clean) chunks.push(clean);
i = Math.max(j - overlapTokens, j === i ? i + 1 : j);
}
return chunks;
}
/**
* Create a vector representation of a string
* @param {object | string} target Item that will be embedded (objects get converted)
* @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<{index: number, embedding: number[], text: string, tokens: number}[]> {
let {maxTokens = 500, overlapTokens = 50} = opts;
let aborted = false;
const abort = () => { aborted = true; };
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,
this.ai.options?.embedder || 'bge-small-en-v1.5'
];
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) => {
if(aborted) return reject(new Error('Aborted'));
if(code === 0) {
try {
const result = JSON.parse(output);
resolve(result.embedding);
} catch(err) {
reject(err);
}
} else {
reject(new Error(`Embedder process exited with code ${code}`));
}
});
proc.on('error', reject);
}); });
}; };
const embed = async (text: string): Promise<number[]> => { const p = (async () => {
const model = await this.embedModel; const chunks = this.chunk(target, maxTokens, overlapTokens), results: any[] = [];
const output = await model(text, {pooling: 'mean', normalize: true}); for(let i = 0; i < chunks.length; i++) {
return Array.from(output.data); if(aborted) break;
}; const text = chunks[i];
const embedding = await embed(text);
// Tokenize results.push({index: i, embedding, text, tokens: this.estimateTokens(text)});
const lines = typeof target === 'object' ? objString(target) : target.split('\n');
const tokens = lines.flatMap(line => [...line.split(/\s+/).filter(w => w.trim()), '\n']);
// Chunk
const chunks: string[] = [];
let start = 0;
while (start < tokens.length) {
let end = start;
let text = '';
// Build chunk
while (end < tokens.length) {
const nextToken = tokens[end];
const testText = text + (text ? ' ' : '') + nextToken;
const testTokens = this.estimateTokens(testText.replace(/\s*\n\s*/g, '\n'));
if (testTokens > maxTokens && text) break;
text = testText;
end++;
} }
// Save chunk return results;
const cleanText = text.replace(/\s*\n\s*/g, '\n').trim(); })();
if(cleanText) chunks.push(cleanText); return <any>Object.assign(p, {abort});
start = end - overlapTokens;
if (start <= end - tokens.length + end) start = end; // Safety: prevent infinite loop
}
return Promise.all(chunks.map(async (text, index) => ({
index,
embedding: await embed(text),
text,
tokens: this.estimateTokens(text),
})));
} }
/** /**
@@ -211,7 +379,7 @@ export class LLM {
/** /**
* Compare the difference between two strings using tensor math * Compare the difference between two strings using tensor math
* @param target Text that will checked * @param target Text that will be checked
* @param {string} searchTerms Multiple search terms to check against target * @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 * @returns {{avg: number, max: number, similarities: number[]}} Similarity values 0-1: 0 = unique, 1 = identical
*/ */
@@ -222,34 +390,66 @@ export class LLM {
(char.charCodeAt(0) * (index + 1)) % dimensions / dimensions).slice(0, dimensions); (char.charCodeAt(0) * (index + 1)) % dimensions / dimensions).slice(0, dimensions);
} }
const v = vector(target); const v = vector(target);
const similarities = searchTerms.map(t => vector(t)).map(refVector => this.cosineSimilarity(v, refVector)) 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 {avg: similarities.reduce((acc, s) => acc + s, 0) / similarities.length, max: Math.max(...similarities), similarities};
}
/**
* Ask a question with JSON response
* @param {string} message Question
* @param {LLMRequest} options Configuration options and chat history
* @returns {Promise<{} | {} | RegExpExecArray | null>}
*/
async json(message: string, options?: LLMRequest) {
let resp = await this.ask(message, {
system: 'Respond using a JSON blob',
...options
});
if(!resp?.[0]?.content) return {};
return JSONAttemptParse(new RegExp('\{[\s\S]*\}').exec(resp[0].content), {});
} }
/** /**
* Create a summary of some text * Create a summary of some text
* @param {string} text Text to summarize * @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 * @param options LLM request options
* @returns {Promise<string>} Summary * @returns {Promise<string>} Summary
*/ */
summarize(text: string, tokens: number, options?: LLMRequest): Promise<string | null> { async summarize(text: string, length: number = 500, options?: LLMRequest): Promise<string | null> {
return this.ask(text, {system: `Generate a brief summary <= ${tokens} tokens. Output nothing else`, temperature: 0.3, ...options}) 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`;
.then(history => <string>history.pop()?.content || null); 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] ?? '';
} }
} }
export default LLM;

420
src/memory.ts Normal file
View File

@@ -0,0 +1,420 @@
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[];
}
// In memory.ts - replace findGhostNodes with this:
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>();
// Collect all ghost references
for (const m of mems) {
for (const link of m.links) {
if (!nameSet.has(link)) ghosts.add(link);
}
}
// Build node list: real nodes + ghost nodes
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));
// Graph expansion
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) return;
const buckets = await this.factAgent(conversation, mem, options);
if(!buckets.length) return;
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);
}));
// Rebuild indexes
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;
await this.llm.ask(
`New facts to integrate:\n${bucket.facts.map(f => `- ${f}`).join('\n')}`,
{
model: options.model,
temperature: 0.3,
system: `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 — only link things that are genuinely related
- 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)'}
\`\`\``,
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[] = [];
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
Group facts by subject. For each group call \`extract_facts\` once.
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: 'Subject matter facts regard', 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.
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', 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: 'Canonical name for the new node', 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

@@ -1,117 +0,0 @@
import {findByProp, objectMap, JSONSanitize, JSONAttemptParse} from '@ztimson/utils';
import {Ai} from './ai.ts';
import {LLMMessage, LLMRequest} from './llm.ts';
import {AbortablePromise, LLMProvider} from './provider.ts';
import {Ollama as ollama} from 'ollama';
export class Ollama extends LLMProvider {
client!: ollama;
constructor(public readonly ai: Ai, public host: string, public model: string) {
super();
this.client = new ollama({host});
}
private toStandard(history: any[]): LLMMessage[] {
for(let i = 0; i < history.length; i++) {
if(history[i].role == 'assistant' && history[i].tool_calls) {
if(history[i].content) delete history[i].tool_calls;
else {
history.splice(i, 1);
i--;
}
} else if(history[i].role == 'tool') {
const error = history[i].content.startsWith('{"error":');
history[i] = {role: 'tool', name: history[i].tool_name, args: history[i].args, [error ? 'error' : 'content']: history[i].content, timestamp: history[i].timestamp};
}
if(!history[i]?.timestamp) history[i].timestamp = Date.now();
}
return history;
}
private fromStandard(history: LLMMessage[]): any[] {
return history.map((h: any) => {
const {timestamp, ...rest} = h;
if(h.role != 'tool') return rest;
return {role: 'tool', tool_name: h.name, content: h.error || h.content}
});
}
ask(message: string, options: LLMRequest = {}): AbortablePromise<LLMMessage[]> {
const controller = new AbortController();
const response = new Promise<any>(async (res, rej) => {
let system = options.system || this.ai.options.system;
let history = this.fromStandard([...options.history || [], {role: 'user', content: message, timestamp: Date.now()}]);
if(history[0].roll == 'system') {
if(!system) system = history.shift();
else history.shift();
}
if(options.compress) history = await this.ai.language.compressHistory(<any>history, options.compress.max, options.compress.min);
if(options.system) history.unshift({role: 'system', content: system})
const requestParams: any = {
model: options.model || this.model,
messages: history,
stream: !!options.stream,
signal: controller.signal,
options: {
temperature: options.temperature || this.ai.options.temperature || 0.7,
num_predict: options.max_tokens || this.ai.options.max_tokens || 4096,
},
tools: (options.tools || this.ai.options.tools || []).map(t => ({
type: 'function',
function: {
name: t.name,
description: t.description,
parameters: {
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]) : []
}
}
}))
}
let resp: any, isFirstMessage = true;
do {
resp = await this.client.chat(requestParams);
if(options.stream) {
if(!isFirstMessage) options.stream({text: '\n\n'});
else isFirstMessage = false;
resp.message = {role: 'assistant', content: '', tool_calls: []};
for await (const chunk of resp) {
if(controller.signal.aborted) break;
if(chunk.message?.content) {
resp.message.content += chunk.message.content;
options.stream({text: chunk.message.content});
}
if(chunk.message?.tool_calls) resp.message.tool_calls = chunk.message.tool_calls;
if(chunk.done) break;
}
}
if(resp.message?.tool_calls?.length && !controller.signal.aborted) {
history.push(resp.message);
const results = await Promise.all(resp.message.tool_calls.map(async (toolCall: any) => {
const tool = (options.tools || this.ai.options.tools)?.find(findByProp('name', toolCall.function.name));
if(!tool) return {role: 'tool', tool_name: toolCall.function.name, content: '{"error": "Tool not found"}'};
const args = typeof toolCall.function.arguments === 'string' ? JSONAttemptParse(toolCall.function.arguments, {}) : toolCall.function.arguments;
try {
const result = await tool.fn(args, this.ai);
return {role: 'tool', tool_name: toolCall.function.name, args, content: JSONSanitize(result)};
} catch (err: any) {
return {role: 'tool', tool_name: toolCall.function.name, args, content: JSONSanitize({error: err?.message || err?.toString() || 'Unknown'})};
}
}));
history.push(...results);
requestParams.messages = history;
}
} while (!controller.signal.aborted && resp.message?.tool_calls?.length);
if(options.stream) options.stream({done: true});
res(this.toStandard([...history, {role: 'assistant', content: resp.message?.content}]));
});
return Object.assign(response, {abort: () => controller.abort()});
}
}

View File

@@ -1,15 +1,19 @@
import {OpenAI as openAI} from 'openai'; import {OpenAI as openAI} from 'openai';
import {findByProp, objectMap, JSONSanitize, JSONAttemptParse} from '@ztimson/utils'; import {findByProp, objectMap, JSONSanitize, JSONAttemptParse, clean} from '@ztimson/utils';
import {Ai} from './ai.ts'; import {AbortablePromise, Ai} from './ai.ts';
import {LLMMessage, LLMRequest} from './llm.ts'; import {LLMMessage, LLMRequest} from './llm.ts';
import {AbortablePromise, LLMProvider} from './provider.ts'; import {LLMProvider} from './provider.ts';
import {convertSchema} from './tools.ts';
export class OpenAi extends LLMProvider { export class OpenAi extends LLMProvider {
client!: openAI; client!: openAI;
constructor(public readonly ai: Ai, public readonly apiToken: string, public model: string) { constructor(public readonly ai: Ai, public readonly host: string | null, public readonly token: string, public model: string) {
super(); super();
this.client = new openAI({apiKey: apiToken}); this.client = new openAI(clean({
baseURL: host,
apiKey: token || (host ? 'ignored' : undefined)
}));
} }
private toStandard(history: any[]): LLMMessage[] { private toStandard(history: any[]): LLMMessage[] {
@@ -61,19 +65,22 @@ export class OpenAi extends LLMProvider {
}, [] as any[]); }, [] as any[]);
} }
ask(message: string, options: LLMRequest = {}): AbortablePromise<LLMMessage[]> { ask(message: string, options: LLMRequest = {}): AbortablePromise<string | any> {
const controller = new AbortController(); const controller = new AbortController();
const response = new Promise<any>(async (res, rej) => { 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()}]); let history = this.fromStandard([...options.history || [], {role: 'user', content: message, timestamp: Date.now()}]);
if(options.compress) history = await this.ai.language.compressHistory(<any>history, options.compress.max, options.compress.min, options); const tools = options.tools || this.ai.options.llm?.tools || [];
const requestParams: any = { const requestParams: any = {
model: options.model || this.model, model: options.model || this.model,
messages: history, messages: history,
stream: !!options.stream, stream: !!options.stream,
max_tokens: options.max_tokens || this.ai.options.max_tokens || 4096, max_completion_tokens: options.max_tokens || this.ai.options.llm?.max_tokens || undefined,
temperature: options.temperature || this.ai.options.temperature || 0.7, temperature: options.temperature || this.ai.options.llm?.temperature || undefined,
tools: (options.tools || this.ai.options.tools || []).map(t => ({ tools: tools.map(t => ({
type: 'function', type: 'function',
function: { function: {
name: t.name, name: t.name,
@@ -87,35 +94,75 @@ 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; let resp: any, isFirstMessage = true;
do { do {
resp = await this.client.chat.completions.create(requestParams); resp = await this.client.chat.completions.create(requestParams).catch(err => {
err.message += `\n\nMessages:\n${JSON.stringify(history, null, 2)}`;
throw err;
});
if(options.stream) { if(options.stream) {
if(!isFirstMessage) options.stream({text: '\n\n'}); if(!isFirstMessage) options.stream({text: '\n\n'});
else isFirstMessage = false; else isFirstMessage = false;
resp.choices = [{message: {content: '', tool_calls: []}}]; resp.choices = [{message: {role: 'assistant', content: '', tool_calls: []}}];
for await (const chunk of resp) { for await (const chunk of resp) {
if(controller.signal.aborted) break; if(controller.signal.aborted) break;
if(chunk.choices[0].delta.content) { if(chunk.choices[0].delta.content) {
resp.choices[0].message.content += chunk.choices[0].delta.content; resp.choices[0].message.content += chunk.choices[0].delta.content;
options.stream({text: 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) {
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 || []; const toolCalls = resp.choices[0].message.tool_calls || [];
if(toolCalls.length && !controller.signal.aborted) { if(toolCalls.length && !controller.signal.aborted) {
history.push(resp.choices[0].message); history.push(resp.choices[0].message);
const results = await Promise.all(toolCalls.map(async (toolCall: any) => { const results = await Promise.all(toolCalls.map(async (toolCall: any) => {
const tool = options.tools?.find(findByProp('name', toolCall.function.name)); 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(!tool) return {role: 'tool', tool_call_id: toolCall.id, content: '{"error": "Tool not found"}'};
try { try {
const args = JSONAttemptParse(toolCall.function.arguments, {}); const args = JSONAttemptParse(toolCall.function.arguments, {});
const result = await tool.fn(args, this.ai); 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) { } catch (err: any) {
return {role: 'tool', tool_call_id: toolCall.id, content: JSONSanitize({error: err?.message || err?.toString() || 'Unknown'})}; return {role: 'tool', tool_call_id: toolCall.id, content: JSONSanitize({error: err?.message || err?.toString() || 'Unknown'})};
} }
@@ -125,9 +172,16 @@ export class OpenAi extends LLMProvider {
} }
} while (!controller.signal.aborted && resp.choices?.[0]?.message?.tool_calls?.length); } while (!controller.signal.aborted && resp.choices?.[0]?.message?.tool_calls?.length);
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.stream) options.stream({done: true});
res(this.toStandard([...history, {role: 'assistant', content: resp.choices[0].message.content || ''}])); if(options.history) options.history.splice(0, options.history.length, ...history);
});
return Object.assign(response, {abort: () => controller.abort()}); // 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,7 +1,6 @@
import {LLMMessage, LLMOptions, LLMRequest} from './llm.ts'; import {AbortablePromise} from './ai.ts';
import {LLMMessage, LLMRequest} from './llm.ts';
export type AbortablePromise<T> = Promise<T> & {abort: () => void};
export abstract class LLMProvider { export abstract class LLMProvider {
abstract ask(message: string, options: LLMRequest): AbortablePromise<LLMMessage[]>; abstract ask(message: string, options: LLMRequest): AbortablePromise<string>;
} }

View File

@@ -1,6 +1,16 @@
import {$, $Sync} from '@ztimson/node-utils'; import * as cheerio from 'cheerio';
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 {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]: { export type AiToolArg = {[key: string]: {
/** Argument type */ /** Argument type */
@@ -31,40 +41,95 @@ export type AiTool = {
/** Tool arguments */ /** Tool arguments */
args?: AiToolArg, args?: AiToolArg,
/** Callback function */ /** Callback function */
fn: (args: any, ai: Ai) => any | Promise<any>, 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 = { export const CliTool: AiTool = {
name: 'cli', name: 'cli',
description: 'Use the command line interface, returns any output', description: 'Use the command line interface, returns any output',
args: {command: {type: 'string', description: 'Command to run', required: true}}, 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 = { export const DateTimeTool: AiTool = {
name: 'get_datetime', name: 'get_datetime',
description: 'Get current date and time', description: 'Get local date / time',
args: {}, args: {},
fn: async () => new Date().toISOString() fn: async () => new Date().toString()
}
export const DateTimeUTCTool: AiTool = {
name: 'get_datetime_utc',
description: 'Get current UTC date / time',
args: {},
fn: async () => new Date().toUTCString()
} }
export const ExecTool: AiTool = { export const ExecTool: AiTool = {
name: 'exec', name: 'exec',
description: 'Run code/scripts', description: 'Run code/scripts',
args: { 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} code: {type: 'string', description: 'Code to execute', required: true}
}, },
fn: async (args, ai) => { fn: async (args, stream, ai) => {
try { try {
switch(args.type) { switch(args.language) {
case 'bash': case 'cli':
return await CliTool.fn({command: args.code}, ai); return await CliTool.fn({command: args.code}, stream, ai);
case 'node': case 'node':
return await JSTool.fn({code: args.code}, ai); return await JSTool.fn({code: args.code}, stream, ai);
case 'python': { case 'python':
return await PythonTool.fn({code: args.code}, ai); return await PythonTool.fn({code: args.code}, stream, ai);
} default:
throw new Error(`Unsupported language: ${args.language}`);
} }
} catch(err: any) { } catch(err: any) {
return {error: err?.message || err.toString()}; return {error: err?.message || err.toString()};
@@ -96,9 +161,9 @@ export const JSTool: AiTool = {
code: {type: 'string', description: 'CommonJS javascript', required: true} code: {type: 'string', description: 'CommonJS javascript', required: true}
}, },
fn: async (args: {code: string}) => { fn: async (args: {code: string}) => {
const console = consoleInterceptor(null); const c = consoleInterceptor(null);
const resp = await Fn<any>({console}, args.code, true).catch((err: any) => console.output.error.push(err)); const resp = await Fn<any>({console: c}, args.code, true).catch((err: any) => c.output.error.push(err));
return {...console.output, return: resp, stdout: undefined, stderr: undefined}; return {...c.output, return: resp, stdout: undefined, stderr: undefined};
} }
} }
@@ -111,9 +176,113 @@ export const PythonTool: AiTool = {
fn: async (args: {code: string}) => ({result: $Sync`python -c "${args.code}"`}) fn: async (args: {code: string}) => ({result: $Sync`python -c "${args.code}"`})
} }
export const SearchTool: AiTool = { export const ReadWebpageTool: AiTool = {
name: 'search', name: 'read_webpage',
description: 'Use a search engine to find relevant URLs, should be changed with fetch to scrape sources', description: 'Extract clean content from webpages, or convert media/documents to accessible formats',
args: {
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; 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, 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 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) {
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) {
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',
description: 'Use duckduckgo (anonymous) to find find relevant online resources. Returns a list of URLs that works great with the `read_webpage` tool',
args: { args: {
query: {type: 'string', description: 'Search string', required: true}, query: {type: 'string', description: 'Search string', required: true},
length: {type: 'string', description: 'Number of results to return', default: 5}, length: {type: 'string', description: 'Number of results to return', default: 5},
@@ -123,7 +292,7 @@ export const SearchTool: AiTool = {
length: number; length: number;
}) => { }) => {
const html = await fetch(`https://html.duckduckgo.com/html/?q=${encodeURIComponent(args.query)}`, { 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()); }).then(resp => resp.text());
let match, regex = /<a .*?href="(.+?)".+?<\/a>/g; let match, regex = /<a .*?href="(.+?)".+?<\/a>/g;
const results = new ASet<string>(); const results = new ASet<string>();
@@ -136,3 +305,91 @@ export const SearchTool: AiTool = {
return results; 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

@@ -1,25 +1,42 @@
import {createWorker} from 'tesseract.js'; import {createWorker} from 'tesseract.js';
import {Ai} from './ai.ts'; import {AbortablePromise, Ai} from './ai.ts';
export class Vision { export class Vision {
constructor(private ai: Ai) { } constructor(private ai: Ai) {}
/** /**
* Convert image to text using Optical Character Recognition * Convert image to text using Optical Character Recognition
* @param {string} path Path to image * @param {string} path Path to image
* @returns {{abort: Function, response: Promise<string | null>}} Abort function & Promise of extracted text * @returns {AbortablePromise<string | null>} Promise of extracted text with abort method
*/ */
ocr(path: string): {abort: () => void, response: Promise<string | null>} { ocr(path: string): AbortablePromise<string | null> {
let worker: any; let worker: any;
return { let reject: (err: any) => void;
abort: () => { worker?.terminate(); },
response: new Promise(async res => { const handler = (err: Error) => {
worker = await createWorker('eng', 1, {cachePath: this.ai.options.path}); if(err.stack?.includes('tesseract.js')) {
const {data} = await worker.recognize(path); process.off('uncaughtException', handler);
await worker.terminate(); reject?.(err);
res(data.text.trim() || null); return;
})
} }
throw err;
};
process.on('uncaughtException', handler);
const p = (async () => {
worker = await createWorker(this.ai.options.ocr || 'eng', 2, {cachePath: this.ai.options.path});
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", "target": "ESNext",
"useDefineForClassFields": true, "useDefineForClassFields": true,
"module": "ESNext", "module": "ESNext",
"lib": ["ESNext"], "lib": [
"ESNext",
"dom"
],
"skipLibCheck": true, "skipLibCheck": true,
/* Bundler mode */ /* Bundler mode */
@@ -15,6 +18,7 @@
"noEmit": true, "noEmit": true,
/* Linting */ /* Linting */
"strict": true "strict": true,
"noImplicitAny": false
} }
} }

View File

@@ -4,9 +4,15 @@ import dts from 'vite-plugin-dts';
export default defineConfig({ export default defineConfig({
build: { build: {
lib: { lib: {
entry: './src/index.ts', entry: {
index: './src/index.ts',
embedder: './src/embedder.ts',
},
name: 'utils', name: 'utils',
fileName: (format) => (format === 'es' ? 'index.mjs' : 'index.js'), fileName: (format, entryName) => {
if (entryName === 'embedder') return 'embedder.js';
return format === 'es' ? 'index.mjs' : 'index.js';
},
}, },
ssr: true, ssr: true,
emptyOutDir: true, emptyOutDir: true,