Compare commits

...
16 Commits
Author SHA1 Message Date
ztimson 2921b208da More memory optimizations
Publish Library / Build NPM Project (push) Successful in 1m6s
Publish Library / Tag Version (push) Successful in 14s
2026-09-25 00:33:20 -04:00
ztimson b5aec246ac Memory refinement WIP 2026-09-24 14:25:06 -04:00
ztimson 2d6debad86 Memorization prompt tightening
Publish Library / Build NPM Project (push) Successful in 44s
Publish Library / Tag Version (push) Successful in 13s
2026-09-20 11:27:01 -04:00
ztimson 6bed8f20b5 Recursive agents update
Publish Library / Build NPM Project (push) Successful in 36s
Publish Library / Tag Version (push) Successful in 7s
2026-09-20 00:43:52 -04:00
ztimson dc45a99b04 Bump 1.6.13
Publish Library / Build NPM Project (push) Successful in 41s
Publish Library / Tag Version (push) Successful in 14s
2026-09-19 19:30:06 -04:00
ztimson 263a65c192 Fix open-ai early termination & memory improvements
Publish Library / Build NPM Project (push) Successful in 48s
Publish Library / Tag Version (push) Successful in 7s
2026-09-19 19:27:00 -04:00
ztimson 1e8c7c6662 Fix open-ai early termination
Publish Library / Build NPM Project (push) Successful in 1m47s
Publish Library / Tag Version (push) Successful in 8s
2026-09-19 13:22:48 -04:00
ztimson 1f1a4662d4 Entity based notes
Publish Library / Build NPM Project (push) Successful in 41s
Publish Library / Tag Version (push) Successful in 14s
2026-09-18 22:37:05 -04:00
ztimson ee4147e24e Fixed opanai early termination from tool calls
Publish Library / Build NPM Project (push) Successful in 46s
Publish Library / Tag Version (push) Successful in 15s
2026-09-18 16:07:58 -04:00
ztimson d29c0ca389 Fixed opanai early termination from tool calls
Publish Library / Build NPM Project (push) Successful in 55s
Publish Library / Tag Version (push) Successful in 9s
2026-09-18 02:15:02 -04:00
ztimson 4203cb34ef Better fact organization
Publish Library / Build NPM Project (push) Successful in 40s
Publish Library / Tag Version (push) Successful in 10s
2026-09-14 12:22:49 -04:00
ztimson d42c240362 Memorization optimziations
Publish Library / Build NPM Project (push) Successful in 1m2s
Publish Library / Tag Version (push) Successful in 10s
2026-08-31 12:38:40 -04:00
ztimson c1a16096ae Keep message progress on abort
Publish Library / Build NPM Project (push) Successful in 43s
Publish Library / Tag Version (push) Successful in 14s
2026-08-29 21:18:28 -04:00
ztimson ff0ee0b60e Patched memory merging
Publish Library / Build NPM Project (push) Successful in 45s
Publish Library / Tag Version (push) Successful in 10s
2026-08-28 16:48:46 -04:00
ztimson 0a6f1e4d62 Refined memory management prompts
Publish Library / Build NPM Project (push) Successful in 1m18s
Publish Library / Tag Version (push) Successful in 20s
2026-08-25 10:03:36 -04:00
ztimson 08a351e028 Better memory management
Publish Library / Build NPM Project (push) Successful in 59s
Publish Library / Tag Version (push) Successful in 11s
2026-08-24 14:42:10 -04:00
12 changed files with 902 additions and 671 deletions
+6 -6
View File
@@ -1,19 +1,19 @@
{ {
"name": "@ztimson/ai-utils", "name": "@ztimson/ai-utils",
"version": "1.5.0", "version": "1.6.6",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@ztimson/ai-utils", "name": "@ztimson/ai-utils",
"version": "1.5.0", "version": "1.6.6",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.102.0", "@anthropic-ai/sdk": "^0.102.0",
"@huggingface/transformers": "^4.2.0", "@huggingface/transformers": "^4.2.0",
"@tensorflow/tfjs": "^4.22.0", "@tensorflow/tfjs": "^4.22.0",
"@ztimson/node-utils": "^1.0.7", "@ztimson/node-utils": "^1.0.7",
"@ztimson/utils": "^0.29.4", "@ztimson/utils": "^0.30.8",
"cheerio": "^1.2.0", "cheerio": "^1.2.0",
"openai": "^6.42.0", "openai": "^6.42.0",
"pdf-parse": "^2.4.5", "pdf-parse": "^2.4.5",
@@ -1525,9 +1525,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@ztimson/utils": { "node_modules/@ztimson/utils": {
"version": "0.29.7", "version": "0.30.8",
"resolved": "https://registry.npmjs.org/@ztimson/utils/-/utils-0.29.7.tgz", "resolved": "https://registry.npmjs.org/@ztimson/utils/-/utils-0.30.8.tgz",
"integrity": "sha512-cjQ9+RjC5X7gKNA/hJHDf7OtyYCa+5E0PDc76lIaATwNAxXCSx2IO9r2wHiHtZGV5bldjnFmw7aOV8Jmq7SgKQ==", "integrity": "sha512-+vBjcinqckqMHkP95xWiQeQz2E7Q1oS0b+Odjp+F9rvQ4z0US4JdodjqhEaqh+RAO/yP77x4Eu0a04yBB4HNdw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"var-persist": "^1.0.1" "var-persist": "^1.0.1"
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@ztimson/ai-utils", "name": "@ztimson/ai-utils",
"version": "1.6.2", "version": "1.7.2",
"description": "AI Utility library", "description": "AI Utility library",
"author": "Zak Timson", "author": "Zak Timson",
"license": "MIT", "license": "MIT",
@@ -29,7 +29,7 @@
"@huggingface/transformers": "^4.2.0", "@huggingface/transformers": "^4.2.0",
"@tensorflow/tfjs": "^4.22.0", "@tensorflow/tfjs": "^4.22.0",
"@ztimson/node-utils": "^1.0.7", "@ztimson/node-utils": "^1.0.7",
"@ztimson/utils": "^0.29.4", "@ztimson/utils": "^0.30.8",
"cheerio": "^1.2.0", "cheerio": "^1.2.0",
"openai": "^6.42.0", "openai": "^6.42.0",
"pdf-parse": "^2.4.5", "pdf-parse": "^2.4.5",
+1 -1
View File
@@ -4,7 +4,7 @@ import { Audio } from './audio.ts';
import {Vision} from './vision.ts'; import {Vision} from './vision.ts';
export type AbortablePromise<T> = Promise<T> & { export type AbortablePromise<T> = Promise<T> & {
abort: () => any abort: (keep?: boolean) => any
}; };
export type AiOptions = { export type AiOptions = {
+5 -2
View File
@@ -1,11 +1,14 @@
export * from './ai'; export * from './ai';
export * from './antrhopic'; export * from './antrhopic';
export * from './audio'; export * from './audio';
export * from './helpers';
export * from './llm'; export * from './llm';
export * from './memory'; export * from './memory/graph';
export * from './memory/kd-tree';
export * from './memory/memory';
export * from './memory/memory-state';
export * from './open-ai'; export * from './open-ai';
export * from './provider'; export * from './provider';
export * from './token-pool' export * from './token-pool'
export * from './tools'; export * from './tools';
export * from './vision'; export * from './vision';
export * from './utils';
+57 -43
View File
@@ -1,17 +1,19 @@
import {clean, makeUnique, snakeCase} from '@ztimson/utils'; import {clean, makeUnique, snakeCase} from '@ztimson/utils';
import {AbortablePromise, Ai} from './ai.ts'; import {AbortablePromise, Ai} from './ai.ts';
import {Anthropic} from './antrhopic.ts'; import {Anthropic} from './antrhopic.ts';
import {MemoryCache} from './memory/memory-state.ts';
import {Memory, MemoryManager, MemoryOptions} from './memory/memory.ts';
import {OpenAi} from './open-ai.ts'; import {OpenAi} from './open-ai.ts';
import {LLMProvider} from './provider.ts'; import {LLMProvider} from './provider.ts';
import {AiTool, AiToolArg} from './tools.ts'; import {AiTool, AiToolArg} from './tools.ts';
import {fileURLToPath} from 'url'; import {fileURLToPath} from 'url';
import {spawn} from 'node:child_process'; import {spawn} from 'node:child_process';
import {Memory, MemoryCache, MemoryManager, MemoryOptions, stripHeader} from './memory.ts';
import {mkdtempSync} from 'node:fs'; import {mkdtempSync} from 'node:fs';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import {tmpdir} from 'node:os'; import {tmpdir} from 'node:os';
import {dirname, join, basename, extname} from 'path'; import {dirname, join, basename, extname} from 'path';
import { PDFParse } from 'pdf-parse'; import { PDFParse } from 'pdf-parse';
import {stripHeader} from './utils.ts';
const MAX_AGENT_DEPTH = 5; const MAX_AGENT_DEPTH = 5;
const PDF_OCR_PAGE_THRESHOLD = 12; // above this many pages, OCR scanned pages instead of feeding images to the model const PDF_OCR_PAGE_THRESHOLD = 12; // above this many pages, OCR scanned pages instead of feeding images to the model
@@ -19,6 +21,13 @@ const PDF_OCR_PAGE_THRESHOLD = 12; // above this many pages, OCR scanned pages i
export type AnthropicConfig = {proto: 'anthropic', token: string | string[]}; export type AnthropicConfig = {proto: 'anthropic', token: string | string[]};
export type OpenAiConfig = {proto: 'openai', host?: string, token: string | string[]}; export type OpenAiConfig = {proto: 'openai', host?: string, token: string | string[]};
export type AgentRef = {
name: string;
description?: string;
delegate?: boolean;
fn: () => Agent | null | Promise<Agent | null>;
}
export type Agent = { export type Agent = {
name: string; name: string;
description?: string; description?: string;
@@ -29,7 +38,7 @@ export type Agent = {
skills?: Skill[] | null; skills?: Skill[] | null;
tools?: AiTool[] | null; tools?: AiTool[] | null;
mcp?: McpServer[] | null; mcp?: McpServer[] | null;
agents?: string[] | null; agents?: AgentRef[] | null;
} }
export type LLMFile = { export type LLMFile = {
@@ -106,8 +115,8 @@ export type LLMRequest = {
skills?: Skill[]; skills?: Skill[];
/** MCP servers to connect and expose as tools */ /** MCP servers to connect and expose as tools */
mcp?: McpServer[]; mcp?: McpServer[];
/** Subagents exposed as delegatable/wrapped tools */ /** Subagents exposed as delegatable/wrapped tools, resolved lazily via their `fn` */
agents?: Agent[]; agents?: AgentRef[];
/** Attach files to request */ /** Attach files to request */
files?: LLMFile[]; files?: LLMFile[];
/** @internal recursion guard for nested agent delegation */ /** @internal recursion guard for nested agent delegation */
@@ -265,22 +274,21 @@ class LLM {
}; };
} }
private setupAgent(agents: Agent[] = [], allAgents: Agent[], history: LLMMessage[], aborts: (() => void)[], depth = 0, delegateState: {resp: string | null}): AiTool[] { private setupAgent(stubs: AgentRef[] = [], history: LLMMessage[], aborts: ((keep?: boolean) => void)[], depth = 0, delegateState: {resp: string | null}): AiTool[] {
return agents.map(a => { return stubs.map(stub => {
const toolName = `${a.delegate ? '' : 'sub'}agent_${snakeCase(a.name)}`; const toolName = `${stub.delegate ? '' : 'sub'}agent_${snakeCase(stub.name)}`;
return { return {
name: toolName, name: toolName,
description: `${a.delegate ? 'Delegate to ' : ''}Subagent: ${a.description || a.name}`, description: `${stub.delegate ? 'Delegate to ' : ''}Subagent: ${stub.description || stub.name}`,
args: clean<any>({ args: clean<any>({
context: !a.delegate ? {type: 'string', description: 'Summary of related messages, samples, files, etc...', required: true} : undefined, context: !stub.delegate ? {type: 'string', description: 'Summary of related messages, samples, files, etc...', required: true} : undefined,
instructions: {type: 'string', description: 'Detailed instructions for subagent to complete', required: true}, instructions: {type: 'string', description: 'Detailed instructions for subagent to complete', required: true},
}), }),
fn: async (args: any, stream: any, ai: any, id?: string) => { fn: async (args: any, stream: any, ai: any, id?: string) => {
if(depth >= MAX_AGENT_DEPTH) return 'Max agent delegation depth exceeded'; if(depth >= MAX_AGENT_DEPTH) return 'Max agent delegation depth exceeded';
const nested = (a.agents || []) const a = await stub.fn();
.map(name => allAgents.find(x => x.name === name)) if(!a) return `Agent "${stub.name}" could not be resolved`;
.filter((x): x is Agent => !!x && x.name !== a.name);
const q = a.delegate ? '' : `${args.instructions}${args.context ? `\n\n<context>${args.context}</context>` : ''}`; const q = a.delegate ? '' : `${args.instructions}${args.context ? `\n\n<context>${args.context}</context>` : ''}`;
@@ -297,7 +305,7 @@ ${a.system}`,
mcp: a.mcp || undefined, mcp: a.mcp || undefined,
skills: a.skills || undefined, skills: a.skills || undefined,
tools: a.tools || undefined, tools: a.tools || undefined,
agents: nested, agents: a.agents || [],
_agentDepth: depth + 1, _agentDepth: depth + 1,
} as any); } as any);
aborts.push(request.abort); aborts.push(request.abort);
@@ -397,11 +405,13 @@ ${a.system}`,
if(!this.models[m]) throw new Error(`Model does not exist: ${m}`); if(!this.models[m]) throw new Error(`Model does not exist: ${m}`);
let request: AbortablePromise<string> | null = null; let request: AbortablePromise<string> | null = null;
let aborted = false; let aborted = false;
const nestedAborts: (() => void)[] = []; let keepOnAbort = true;
const abort = () => { const nestedAborts: ((keep?: boolean) => void)[] = [];
const abort = (keep = true) => {
aborted = true; aborted = true;
request?.abort?.(); keepOnAbort = keep;
nestedAborts.forEach(a => a()); request?.abort?.(keep);
nestedAborts.forEach(a => a(keep));
}; };
let promise: any; let promise: any;
@@ -411,9 +421,25 @@ ${a.system}`,
let tools: AiTool[] = options.tools || this.ai.options.llm?.tools || []; let tools: AiTool[] = options.tools || this.ai.options.llm?.tools || [];
const prompts: string[] = []; const prompts: string[] = [];
let history = options.history || []; let history = options.history || [];
const historyStart = history.length;
const files = options.files || []; const files = options.files || [];
if(message || files.length) history.push({role: 'user', content: message || '', timestamp: Date.now()}); if(message || files.length) history.push({role: 'user', content: message || '', timestamp: Date.now()});
// Accumulate streamed text so it can be committed to history if aborted mid-generation
let partialText = '';
const onStream = options.stream;
const stream = (chunk: {text?: string, tool?: string, done?: true}) => {
if(chunk.text) partialText += chunk.text;
return onStream?.(chunk);
};
/** Commit (keep) or discard this turn's progress on abort, then throw */
const abortNow = (): never => {
if(keepOnAbort) { if(partialText) history.push({role: 'assistant', content: partialText, timestamp: Date.now()}); }
else history.splice(historyStart, history.length - historyStart);
throw Object.assign(new Error('Aborted'), {name: 'AbortError'});
};
// MCP // MCP
const mcp = options.mcp || this.ai.options?.llm?.mcp; const mcp = options.mcp || this.ai.options?.llm?.mcp;
if(mcp?.length) { if(mcp?.length) {
@@ -433,7 +459,7 @@ ${a.system}`,
// Agents // Agents
const agents = options.agents || this.ai.options?.llm?.agents; const agents = options.agents || this.ai.options?.llm?.agents;
const delegateState: {resp: string | null} = {resp: null}; const delegateState: {resp: string | null} = {resp: null};
if(agents?.length) tools.push(...this.setupAgent(agents, agents, history, nestedAborts, options._agentDepth || 0, delegateState)); if(agents?.length) tools.push(...this.setupAgent(agents, history, nestedAborts, options._agentDepth || 0, delegateState));
// Memory // Memory
const mem = MemoryManager.normalize(options.memory); const mem = MemoryManager.normalize(options.memory);
@@ -441,8 +467,8 @@ ${a.system}`,
const mems = mem.memory instanceof MemoryCache ? mem.memory.memories : mem.memory; const mems = mem.memory instanceof MemoryCache ? mem.memory.memories : mem.memory;
if(mems.length) { if(mems.length) {
if(mem.inject) { if(mem.inject) {
const pool = 15; // candidates considered, cheap since only refs are listed const pool = 15;
const budget = mem.maxTokens ?? 2000; // actual content injected const budget = mem.maxTokens ?? 2000;
const relevant = await this.memoryManager.recollect(message, mem.memory, pool); const relevant = await this.memoryManager.recollect(message, mem.memory, pool);
let used = 0; let used = 0;
@@ -477,11 +503,11 @@ Description: ${r.description}
Linked: ${makeUnique([...r.links, ...r.backlinks]).join(', ')} Linked: ${makeUnique([...r.links, ...r.backlinks]).join(', ')}
<!-- Truncated -->`).join('\n\n') : ''}`.trim()) <!-- Truncated -->`).join('\n\n') : ''}`.trim())
} }
if(mem.tool) tools.push(this.memoryManager.tools.read(mem.memory)); if(mem.tool) tools.push(...this.memoryManager.tools.read(mem.memory));
} }
} }
if(aborted) throw Object.assign(new Error('Aborted'), {name: 'AbortError'}); if(aborted) abortNow();
const lastMsg = history[history.length - 1]; const lastMsg = history[history.length - 1];
if(files.length && lastMsg?.role === 'user') lastMsg.files = files; if(files.length && lastMsg?.role === 'user') lastMsg.files = files;
@@ -500,11 +526,17 @@ Linked: ${makeUnique([...r.links, ...r.backlinks]).join(', ')}
const toolTimings = new Map<string, {duration: number, tps: number}>(); const toolTimings = new Map<string, {duration: number, tps: number}>();
tools = this.wrapToolTiming(tools, toolTimings); tools = this.wrapToolTiming(tools, toolTimings);
if(aborted) throw Object.assign(new Error('Aborted'), {name: 'AbortError'}); if(aborted) abortNow();
prompts.unshift(options.system || this.ai.options.llm?.system || ''); prompts.unshift(options.system || this.ai.options.llm?.system || '');
request = this.models[m].ask('', {...options, tools, system: prompts.filter(Boolean).join('\n\n')}); request = this.models[m].ask('', {...options, tools, stream, system: prompts.filter(Boolean).join('\n\n')});
let resp = await request; let resp: string;
try {
resp = await request;
} catch(err: any) {
if(aborted) return abortNow();
throw err;
}
// Strip the file injection shim // Strip the file injection shim
restores.forEach(({msg, content}) => msg.content = content); restores.forEach(({msg, content}) => msg.content = content);
@@ -564,24 +596,6 @@ Linked: ${makeUnique([...r.links, ...r.backlinks]).join(', ')}
return h; 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 {
if (v1.length !== v2.length) throw new Error('Vectors must be same length');
let dotProduct = 0, normA = 0, normB = 0;
for (let i = 0; i < v1.length; i++) {
dotProduct += v1[i] * v2[i];
normA += v1[i] * v1[i];
normB += v2[i] * v2[i];
}
const denominator = Math.sqrt(normA) * Math.sqrt(normB);
return denominator === 0 ? 0 : dotProduct / denominator;
}
/** /**
* Chunk text into parts for AI digestion * Chunk text into parts for AI digestion
* @param {object | string} target Item that will be chunked (objects get converted) * @param {object | string} target Item that will be chunked (objects get converted)
-535
View File
@@ -1,535 +0,0 @@
import {MemoryNode, rebuildGraph} from './helpers.ts';
import {LLMRequest, LLMMessage} from './llm.ts';
import {AiTool} from './tools.ts';
import {KDPoint, KDTree} from './kd-tree.ts';
const FACTS_HEADING = '## Facts';
const GENERIC_TEMPLATE = `# {{Title}}
## Summary
## Details
## Related`;
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[];
}
function dedupeFacts(facts: string[]): string[] {
const seen = new Map<string, string>();
for (const f of facts) {
const clean = f.trim();
if (clean) seen.set(clean.toLowerCase(), clean);
}
return [...seen.values()];
}
function cosineDistance(a: number[], b: number[]): number {
let dot = 0, normA = 0, normB = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
const denom = Math.sqrt(normA) * Math.sqrt(normB);
return denom === 0 ? 1 : 1 - dot / denom;
}
function cosineSearch(query: number[], memories: Memory[], limit: number): MemoryRef[] {
return memories
.filter(m => m.embedding?.length)
.map(m => ({ref: {name: m.name, description: m.description}, distance: cosineDistance(query, m.embedding)}))
.sort((a, b) => a.distance - b.distance)
.slice(0, limit)
.map(s => s.ref);
}
export function stripHeader(content: string): string {
return content.replace(/^---[\s\S]*?\n---\n?/, '').trimStart();
}
export class MemoryCache {
private tree!: KDTree<MemoryRef>;
public memories: Memory[];
public nodes: MemoryNode[] = [];
get length() { return this.memories.length; }
constructor(memories: Memory[]) {
this.memories = memories;
this.rebuild();
}
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[] {
if (!this.tree || this.tree.dims === 0) return [];
return this.tree.knn(query, limit).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();
}
remove(name: string): void {
const idx = this.memories.findIndex(m => m.name === name);
if (idx !== -1) {
this.memories.splice(idx, 1);
this.rebuild();
}
}
rebuild(): void {
this.nodes = rebuildGraph(this.memories);
this.tree = this.buildTree();
}
}
class MemoryAccessor {
readonly list: Memory[];
private readonly cache: MemoryCache | null;
constructor(memories: Memory[] | MemoryCache) {
this.cache = memories instanceof MemoryCache ? memories : null;
this.list = this.cache ? this.cache.memories : <Memory[]>memories;
}
find(name: string): Memory | undefined {
return this.list.find(m => m.name === name);
}
commit(): MemoryNode[] {
if (this.cache) {
this.cache.rebuild();
return this.cache.nodes;
}
return rebuildGraph(this.list);
}
ghosts(): string[] {
const nodes = this.cache ? this.cache.nodes : rebuildGraph(this.list);
return nodes.filter(n => n.missing).map(n => n.name);
}
search(vector: number[], limit: number): MemoryRef[] {
return this.cache ? this.cache.search(vector, limit) : cosineSearch(vector, this.list, limit);
}
forget(name: string): boolean {
const idx = this.list.findIndex(m => m.name === name);
if (idx === -1) return false;
this.list.splice(idx, 1);
this.commit();
return true;
}
async backfillEmbeddings(llm: any): Promise<number> {
const missing = this.list.filter(m => !m.embedding?.length);
if (!missing.length) return 0;
await Promise.all(missing.map(async node => {
const [e] = await llm.embedding(node.content);
if (e) node.embedding = e.embedding;
}));
this.commit();
return missing.length;
}
}
export type MemoryOptions = {
/** Memory object */
memory: Memory[] | MemoryCache;
/** Inject N memories into the system prompt */
inject?: boolean;
/** expose recall tool to LLM */
tool?: boolean;
/** Update memory on compression */
update?: boolean;
/** Max context size of memories to inject to each call (removed immediately after use) */
maxTokens?: number;
}
export class MemoryManager {
private recentlyTouched = new Map<string, number>();
private queues = new Map<string, {
dirty: boolean,
request: {abort?: () => void} | null,
task: Promise<void>,
}>();
tools = {
forget: (memories: Memory[] | MemoryCache): AiTool => ({
name: 'memory_forget',
description: 'Permanently delete a memory document and clean up all references to it',
args: {
name: {type: 'string', description: 'Exact memory name to forget', required: true}
},
fn: (args: any) => {
const result = this.forget(args.name, memories);
return result ? `Forgotten: ${args.name}` : `Not found: ${args.name}`;
},
}),
read: (memories: Memory[] | MemoryCache): AiTool => ({
name: 'memory_recall',
description: 'Read the full content of a memory document',
args: {
name: {type: 'string', description: 'Exact memory name', required: true},
},
fn: (args: any) => {
const mem = this.access(memories).find(args.name);
if (!mem) return 'Document not found';
this.touch(mem.name);
return mem.content;
},
}),
search: (memories: Memory[] | MemoryCache): AiTool => ({
name: 'memory_search',
description: 'Use embeddings to find the MOST relevant memories, even if NOT relevant',
args: {
query: {type: 'string', description: 'What to look for in the memories', required: true},
limit: {type: 'number', description: 'Number of memories to return', default: 1},
},
fn: async ({query, limit}) => {
const mem = await this.recollect(query, memories, limit)
return mem.map(m => `Memory: ${m.name}
Description: ${m.description}
Links: ${[...m.links, ...m.backlinks].join(', ')}
\`\`\`
${m.content}
\`\`\``).join('\n\n');
},
}),
};
constructor(private llm: any) {}
static normalize(m?: Memory[] | MemoryCache | MemoryOptions) {
if (!m) return null;
const raw = m instanceof MemoryCache || Array.isArray(m);
return raw ? {memory: <Memory[] | MemoryCache>m, inject: true, tool: true, update: true} : {inject: true, tool: true, update: true, ...m};
}
private access(memories: Memory[] | MemoryCache): MemoryAccessor {
return new MemoryAccessor(memories);
}
private appendFacts(node: Memory, facts: string[]): void {
this.ensureDoc(node);
const body = stripHeader(node.content);
const bullets = facts.map(f => `- ${f}`).join('\n');
const idx = body.indexOf(FACTS_HEADING);
const newBody = idx === -1
? `${body.trimEnd()}\n\n${FACTS_HEADING}\n${bullets}\n`
: `${body.slice(0, idx + FACTS_HEADING.length)}\n${bullets}${body.slice(idx + FACTS_HEADING.length)}`;
node.content = this.touchHeader(node, newBody);
}
private ensureDoc(node: Memory): void {
if (node.content) return;
const title = node.name.split('/').pop() ?? node.name;
node.content = this.touchHeader(node, `# ${title}\n`);
}
private async factAgent(conversation: string, store: MemoryAccessor, options: LLMRequest, weekKey: string): Promise<FactBucket[]> {
const ghosts = store.ghosts();
const response = await this.llm.ask(conversation, {
model: options.model,
temperature: 0.2,
system: `You are a fact extractor to build obsidian knowledge vaults.
Analyze this conversation and extract facts worth remembering long-term.
Rules:
- Always extract facts that the user explicitly told you to remember
- ONLY extract current facts the USER explicitly stated about themselves, their work, projects or decisions that were MADE during this conversation
- DO NOT extract greetings, pleasantries, or generic exchanges
- DO NOT extract deltas or changes in facts; ONLY the end fact
- DO NOT extract anything the AI/assistant itself said
- If nothing worth remembering was said, return an empty buckets array
When extracting facts, you MUST also decide the exact destination path:
- Reuse node names (including ghost) as much as possible IF the facts belongs there
- All information primarily about the user should go under "People/User"
- When required, create a new path following collection/subject format (e.g., People/Sarah, Projects/Oxide) — you are not limited to any fixed list of collections, use whatever fits
- For journal entries, use "Journal"
Available nodes:
- Journal
${this.listNodes(store.list).filter(n => !n.name.includes('Journal')).map(n => `- ${n.name}: ${n.description}`).join('\n') || 'None yet.'}
${ghosts.length ? `${ghosts.map(g => `- ${g}: (Ghost)`).join('\n')}` : ''}`,
schema: {
buckets: {type: 'array', description: 'Groups of facts to remember, each assigned to a different node. Return an empty array if there is nothing worth storing in an obsidian vault', items: {
type: 'object', items: {
subject: {type: 'string', description: 'Exact existing node name OR new path (e.g. "People/Sarah", "Projects/Oxide"), or "Journal"', required: true},
facts: {
type: 'array',
description: 'Facts to store at this destination',
items: {type: 'string', description: 'A single fact'},
},
},
},
},
},
});
const buckets = new Map<string, string[]>();
for(const bucket of response.buckets ?? []) {
const subject = bucket.subject.trim().toLowerCase() === 'journal'
? `Journal/${weekKey}` : bucket.subject.trim();
const facts = buckets.get(subject) ?? [];
facts.push(...dedupeFacts(bucket.facts));
buckets.set(subject, facts);
}
return buckets.entries().toArray().map(([subject, facts]) => ({subject, facts}));
}
private getWeekMonday(date: Date = new Date()): string {
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
const day = d.getUTCDay();
const diff = day === 0 ? -6 : 1 - day;
d.setUTCDate(d.getUTCDate() + diff);
return d.toISOString().slice(0, 10);
}
private listNodes(memories: Memory[]): MemoryRef[] {
return memories.map(m => ({name: m.name, description: m.description}));
}
private reconcile(node: Memory, memories: Memory[] | MemoryCache, options: LLMRequest): Promise<void> {
const key = node.name;
const existing = this.queues.get(key);
if (existing) {
existing.dirty = true;
existing.request?.abort?.();
return existing.task;
}
const entry = {dirty: false, request: null, task: Promise.resolve()};
this.queues.set(key, entry);
const store = this.access(memories);
entry.task = (async () => {
do {
entry.dirty = false;
await this.docAgent(node, store.list, options, entry);
} while (entry.dirty);
})().finally(() => {
this.queues.delete(key);
store.commit();
});
return entry.task;
}
private async docAgent(node: Memory, memories: Memory[], options: LLMRequest, entry: {request: {abort?: () => void} | null}): Promise<void> {
const currentBody = stripHeader(node.content);
let update;
try {
for (let i = 0; i < 2 && !update?.content; i++) {
const request = this.llm.ask(currentBody, {
model: options.model,
temperature: 0.3,
schema: {
description: {type: 'string', description: 'One-line description of what this document covers, no formatting or emojis', required: true},
content: {type: 'string', description: 'Rewritten document body in markdown, without the frontmatter block', required: true},
},
system: `You are a knowledge base editor maintaining one document in an Obsidian-style vault.
If the document has a "${FACTS_HEADING}" section, integrate every bullet under it into the appropriate part of the document, then remove the "${FACTS_HEADING}" section entirely. If there is no such section, just tidy the document per the rules below.
Structure: follow this generic shape loosely, adapting section names/order to what the content actually needs (e.g. journal-style docs may want a timeline instead of "Details"):
\`\`\`markdown
${GENERIC_TEMPLATE}
\`\`\`
Formatting rules:
- Use Obsidian-style markdown: # headings, **bold** for emphasis, bullet & numbered lists for grouped 1D data, tables for 2D data
- Link related concepts with [[WikiLink]] notation using full paths like [[People/Sarah]] or [[Projects/Website]]
- Create links for specific entities (person, place, project, program) and abstract concepts, but skip generics (car, red, dog)
- Keep the document concise, factual, and human-readable
- Resolve contradictions: newer facts always win — delete the outdated statement entirely, never keep both
- Do not add frontmatter blocks, filler, preamble, or AI commentary
Other nodes in the vault (link to these instead of duplicating their content):
${this.listNodes(memories).filter(n => n.name !== node.name).map(n => n.name).join(', ') || 'none'}
Current document:
\`\`\`markdown
${currentBody}
\`\`\``,
});
entry.request = request;
update = await request;
}
} catch (err: any) {
if (err?.name === 'AbortError') return;
throw err;
} finally {
entry.request = null;
}
if (!update?.content) return;
node.description = node.name !== 'People/User' ? update.description : 'All information about the current user';
node.content = this.touchHeader(node, update.content);
const [e] = await this.llm.embedding(node.content);
if (e) node.embedding = e.embedding;
}
private parseFrontmatter(content: string): {fm: Map<string, string>, body: string} {
const match = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
if (!match) return {fm: new Map(), body: content};
const fm = new Map<string, string>();
for (const line of match[1].split('\n')) {
const i = line.indexOf(':');
if (i === -1) continue;
fm.set(line.slice(0, i).trim(), line.slice(i + 1).trim());
}
return {fm, body: match[2]};
}
private touchHeader(node: Memory, body: string): string {
const {fm} = this.parseFrontmatter(node.content);
fm.set('name', node.name);
fm.set('description', node.description || '');
fm.set('modified', new Date().toISOString());
return this.writeFrontmatter(fm, body);
}
private writeFrontmatter(fm: Map<string, string>, body: string): string {
const lines = [...fm.entries()].map(([k, v]) => `${k}: ${v}`);
return `---\n${lines.join('\n')}\n---\n\n${body.trimStart()}`;
}
decay() {
for (const [name, ttl] of this.recentlyTouched) {
if (ttl <= 1) this.recentlyTouched.delete(name);
else this.recentlyTouched.set(name, ttl - 1);
}
}
touch(name: string, ttl = 2) {
this.recentlyTouched.set(name, ttl);
}
forget(name: string, memories: Memory[] | MemoryCache): boolean {
return this.access(memories).forget(name);
}
async recollect(query: string, memories: Memory[] | MemoryCache, limit = 5, graphDepth = 1): Promise<Memory[]> {
const store = this.access(memories);
if (!store.list.length) return [];
await store.backfillEmbeddings(this.llm);
const [e] = await this.llm.embedding(query);
if (!e) return [];
const vectorResults = store.search(e.embedding, limit);
const found = new Set<string>(vectorResults.map(r => r.name));
if (graphDepth > 0) {
let frontier = [...found];
for (let depth = 0; depth < graphDepth && frontier.length; depth++) {
const next: string[] = [];
for (const name of frontier) {
const node = store.find(name);
if (!node) continue;
for (const link of node.links) {
if (!found.has(link) && store.find(link)) {
found.add(link);
next.push(link);
}
}
}
frontier = next;
}
}
const vectorOrder = vectorResults.map(r => r.name);
const graphExpansions = [...found].filter(n => !vectorOrder.includes(n));
return [...vectorOrder, ...graphExpansions].map(n => store.find(n)!).filter(Boolean);
}
async memorize(history: LLMMessage[], memories: Memory[] | MemoryCache, options: LLMRequest): Promise<Memory[]> {
const conversation = history
.filter(h => h.role === 'user' || h.role === 'assistant')
.map(h => `[${h.role}]: ${h.content}`).join('\n\n').trim();
if (!conversation) return [];
const uid = `${Date.now()}_${Math.random().toString(36).slice(2)}`;
const pending = {role: 'tool', name: 'memory_process', id: uid, content: conversation} as unknown as LLMMessage;
history.push(pending);
const store = this.access(memories);
const buckets = await this.factAgent(conversation, store, options, this.getWeekMonday());
const touched: Memory[] = [];
for (const {subject, facts} of buckets) {
let node = store.find(subject);
if (!node) {
node = {name: subject, description: '', content: '', embedding: [], links: [], backlinks: []};
store.list.push(node);
}
this.appendFacts(node, facts);
const [e] = await this.llm.embedding(node.content);
if (e) node.embedding = e.embedding;
this.touch(node.name);
touched.push(node);
}
if (touched.length) {
store.commit();
(pending as any).content = `Saved to ${touched.map(n => `[[${n.name}]]`).join(', ')}`;
await Promise.all(touched.map(node => this.reconcile(node, memories, options).catch(() => {})));
} else {
(pending as any).content = 'Nothing worth remembering.';
}
(touched as any).uid = uid;
return touched;
}
async reconcileVault(memories: Memory[] | MemoryCache, options: LLMRequest, scope: 'touched' | 'all' = 'touched'): Promise<void> {
const store = this.access(memories);
const targets = scope === 'all' ? store.list : store.list.filter(m => m.content.includes(FACTS_HEADING));
await Promise.all(targets.map(node => this.reconcile(node, memories, options)));
store.commit();
}
}
+44 -1
View File
@@ -1,4 +1,5 @@
import {Memory, MemoryCache} from './memory.ts'; import {MemoryCache} from './memory-state.ts';
import type {Memory} from './memory.ts';
export type MemoryNode = { export type MemoryNode = {
name: string; name: string;
@@ -13,6 +14,48 @@ export function extractLinks(content: string): string[] {
return [...new Set([...matches].map(m => m[1].trim()))]; return [...new Set([...matches].map(m => m[1].trim()))];
} }
export function patchGraph(mems: Memory[], nodes: MemoryNode[], changed: Memory[]): MemoryNode[] {
const nameSet = new Set(mems.map(m => m.name));
const byName = new Map(nodes.map(n => [n.name, n]));
const ensureNode = (name: string): MemoryNode => {
let n = byName.get(name);
if (!n) {
n = {name, missing: !nameSet.has(name), links: [], backlinks: []};
byName.set(name, n);
}
return n;
};
for (const m of changed) {
const node = ensureNode(m.name);
node.missing = false; // real memory, promotes any pre-existing ghost entry
const oldLinks = m.links ?? [];
const newLinks = extractLinks(m.content).filter(l => l !== m.name);
for (const target of oldLinks.filter(l => !newLinks.includes(l))) {
const t = byName.get(target);
if (!t) continue;
t.backlinks = t.backlinks.filter(n => n !== m.name);
if (t.missing && !t.backlinks.length) byName.delete(target); // fully dereferenced ghost
}
for (const target of newLinks.filter(l => !oldLinks.includes(l))) {
const t = ensureNode(target);
if (!t.backlinks.includes(m.name)) t.backlinks.push(m.name);
}
m.links = newLinks;
node.links = newLinks;
}
for (const m of mems) {
const n = byName.get(m.name);
if (n) m.backlinks = n.backlinks;
}
return [...byName.values()];
}
export function rebuildGraph(memories: Memory[] | MemoryCache): MemoryNode[] { export function rebuildGraph(memories: Memory[] | MemoryCache): MemoryNode[] {
const mems = memories instanceof MemoryCache ? memories.memories : memories; const mems = memories instanceof MemoryCache ? memories.memories : memories;
const nameSet = new Set(mems.map(m => m.name)); const nameSet = new Set(mems.map(m => m.name));
+53 -35
View File
@@ -1,3 +1,5 @@
import {cosineDistance, euclideanDistance} from '../utils.ts';
export type DistanceMetric = "euclidean" | "cosine"; export type DistanceMetric = "euclidean" | "cosine";
export interface KDPoint<T = unknown> { export interface KDPoint<T = unknown> {
@@ -15,28 +17,7 @@ interface KDNode<T> {
axis: number; axis: number;
left: KDNode<T> | null; left: KDNode<T> | null;
right: KDNode<T> | null; right: KDNode<T> | null;
} deleted?: boolean;
// ─── Distance helpers ─────────────────────────────────────────────────────────
function euclidean(a: number[], b: number[]): number {
let sum = 0;
for (let i = 0; i < a.length; i++) {
const d = a[i] - b[i];
sum += d * d;
}
return Math.sqrt(sum);
}
function cosine(a: number[], b: number[]): number {
let dot = 0, normA = 0, normB = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
const denom = Math.sqrt(normA) * Math.sqrt(normB);
return denom === 0 ? 1 : 1 - dot / denom; // distance = 1 - similarity
} }
/** /**
@@ -95,6 +76,7 @@ class BoundedMaxHeap<T> {
* *
* Supports: * Supports:
* - Insertion of labeled points * - Insertion of labeled points
* - Lazy (tombstone) removal, physically purged on rebalance()
* - k-nearest-neighbor (KNN) search * - k-nearest-neighbor (KNN) search
* - Radius search (all points within a given distance) * - Radius search (all points within a given distance)
* - Euclidean and cosine distance metrics * - Euclidean and cosine distance metrics
@@ -103,6 +85,7 @@ class BoundedMaxHeap<T> {
export class KDTree<T = unknown> { export class KDTree<T = unknown> {
private root: KDNode<T> | null = null; private root: KDNode<T> | null = null;
private _size = 0; private _size = 0;
private _tombstones = 0;
private readonly distanceFn: (a: number[], b: number[]) => number; private readonly distanceFn: (a: number[], b: number[]) => number;
readonly dims: number; readonly dims: number;
@@ -120,7 +103,7 @@ export class KDTree<T = unknown> {
points?: KDPoint<T>[] points?: KDPoint<T>[]
) { ) {
this.dims = dims; this.dims = dims;
this.distanceFn = metric === "cosine" ? cosine : euclidean; this.distanceFn = metric === "cosine" ? cosineDistance : euclideanDistance;
if (points && points.length > 0) { if (points && points.length > 0) {
this.validateAll(points); this.validateAll(points);
@@ -129,9 +112,15 @@ export class KDTree<T = unknown> {
} }
} }
/** Total number of points stored in the tree. */ /** Total number of live points stored in the tree (excludes tombstoned). */
get size(): number { return this._size; } get size(): number { return this._size; }
/** Fraction of physical nodes that are tombstoned (pending removal on next rebalance). */
get tombstoneRatio(): number {
const total = this._size + this._tombstones;
return total ? this._tombstones / total : 0;
}
// ── Insertion ────────────────────────────────────────────────────────────── // ── Insertion ──────────────────────────────────────────────────────────────
/** /**
@@ -144,10 +133,36 @@ export class KDTree<T = unknown> {
this._size++; this._size++;
} }
// ── Removal ────────────────────────────────────────────────────────────────
/**
* Lazily remove all live points whose payload matches `predicate`.
* O(n) traversal, but avoids a full tree rebuild. Call `rebalance()`
* periodically (e.g. once tombstoneRatio crosses ~0.25) to reclaim space
* and restore optimal query depth.
* @returns number of points removed
*/
remove(predicate: (payload: T) => boolean): number {
let removed = 0;
const visit = (node: KDNode<T> | null): void => {
if (!node) return;
if (!node.deleted && predicate(node.point.payload)) {
node.deleted = true;
removed++;
}
visit(node.left);
visit(node.right);
};
visit(this.root);
this._size -= removed;
this._tombstones += removed;
return removed;
}
// ── KNN search ───────────────────────────────────────────────────────────── // ── KNN search ─────────────────────────────────────────────────────────────
/** /**
* Find the k nearest neighbors to `query`. * Find the k nearest live neighbors to `query`.
* Returns results sorted by distance ascending. * Returns results sorted by distance ascending.
*/ */
knn(query: number[], k: number): KNNResult<T>[] { knn(query: number[], k: number): KNNResult<T>[] {
@@ -171,7 +186,7 @@ export class KDTree<T = unknown> {
// ── Radius search ────────────────────────────────────────────────────────── // ── Radius search ──────────────────────────────────────────────────────────
/** /**
* Return all points whose distance to `query` is ≤ `radius`, * Return all live points whose distance to `query` is ≤ `radius`,
* sorted by distance ascending. * sorted by distance ascending.
*/ */
radiusSearch(query: number[], radius: number): KNNResult<T>[] { radiusSearch(query: number[], radius: number): KNNResult<T>[] {
@@ -186,7 +201,7 @@ export class KDTree<T = unknown> {
// ── Conversion ───────────────────────────────────────────────────────────── // ── Conversion ─────────────────────────────────────────────────────────────
/** Collect all points in the tree (order not guaranteed). */ /** Collect all live points in the tree (order not guaranteed). */
toArray(): KDPoint<T>[] { toArray(): KDPoint<T>[] {
const out: KDPoint<T>[] = []; const out: KDPoint<T>[] = [];
this.collect(this.root, out); this.collect(this.root, out);
@@ -194,12 +209,14 @@ export class KDTree<T = unknown> {
} }
/** /**
* Rebuild the tree from its current points as a balanced tree. * Rebuild the tree from its current live points as a balanced tree.
* Useful after many individual insertions to restore O(log n) query time. * Physically purges tombstones and restores O(log n) query time.
*/ */
rebalance(): void { rebalance(): void {
const points = this.toArray(); const points = this.toArray();
this.root = points.length ? this.buildBalanced(points, 0) : null; this.root = points.length ? this.buildBalanced(points, 0) : null;
this._size = points.length;
this._tombstones = 0;
} }
// ── Private: build ───────────────────────────────────────────────────────── // ── Private: build ─────────────────────────────────────────────────────────
@@ -251,8 +268,10 @@ export class KDTree<T = unknown> {
): void { ): void {
if (node === null) return; if (node === null) return;
if (!node.deleted) {
const dist = this.distanceFn(query, node.point.vector); const dist = this.distanceFn(query, node.point.vector);
heap.push({ point: node.point, distance: dist }); heap.push({ point: node.point, distance: dist });
}
const axis = node.axis; const axis = node.axis;
const diff = query[axis] - node.point.vector[axis]; const diff = query[axis] - node.point.vector[axis];
@@ -261,11 +280,8 @@ export class KDTree<T = unknown> {
: [node.right, node.left]; : [node.right, node.left];
this.searchKNN(near, query, k, heap, depth + 1); 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 = const shouldExplore =
this.distanceFn === cosine this.distanceFn === cosineDistance
? true ? true
: Math.abs(diff) < heap.worstDistance; : Math.abs(diff) < heap.worstDistance;
@@ -285,10 +301,12 @@ export class KDTree<T = unknown> {
): void { ): void {
if (node === null) return; if (node === null) return;
if (!node.deleted) {
const dist = this.distanceFn(query, node.point.vector); const dist = this.distanceFn(query, node.point.vector);
if (dist <= radius) { if (dist <= radius) {
results.push({ point: node.point, distance: dist }); results.push({ point: node.point, distance: dist });
} }
}
const axis = node.axis; const axis = node.axis;
const diff = query[axis] - node.point.vector[axis]; const diff = query[axis] - node.point.vector[axis];
@@ -299,7 +317,7 @@ export class KDTree<T = unknown> {
this.searchRadius(near, query, radius, results, depth + 1); this.searchRadius(near, query, radius, results, depth + 1);
const shouldExplore = const shouldExplore =
this.distanceFn === cosine ? true : Math.abs(diff) <= radius; this.distanceFn === cosineDistance ? true : Math.abs(diff) <= radius;
if (shouldExplore) { if (shouldExplore) {
this.searchRadius(far, query, radius, results, depth + 1); this.searchRadius(far, query, radius, results, depth + 1);
@@ -310,7 +328,7 @@ export class KDTree<T = unknown> {
private collect(node: KDNode<T> | null, out: KDPoint<T>[]): void { private collect(node: KDNode<T> | null, out: KDPoint<T>[]): void {
if (node === null) return; if (node === null) return;
out.push(node.point); if (!node.deleted) out.push(node.point);
this.collect(node.left, out); this.collect(node.left, out);
this.collect(node.right, out); this.collect(node.right, out);
} }
+181
View File
@@ -0,0 +1,181 @@
import {MemoryNode, patchGraph, rebuildGraph} from './graph.ts';
import {KDTree} from './kd-tree.ts';
import type {Memory, MemoryRef, MemoryStore} from './memory.ts';
import {cosineDistance, embedMemoryFields} from '../utils.ts';
const TREE_TOMBSTONE_LIMIT = 0.25;
export function memoryStore(memories: MemoryStore): {
list: Memory[];
cache: MemoryCache | null;
find: (name: string) => Memory | undefined;
ghosts: () => string[];
search: (vector: number[], limit: number) => MemoryRef[];
forget: (name: string) => boolean;
rebuild: (changed?: Memory[]) => MemoryNode[];
backfillEmbeddings: (llm: any) => Promise<number>;
} {
if(memories instanceof MemoryCache) {
return {
list: memories.memories,
cache: memories,
find: name => memories.find(name),
ghosts: () => memories.ghosts(),
search: (vector, limit) => memories.search(vector, limit),
forget: name => memories.remove(name),
rebuild: changed => memories.rebuild(changed),
backfillEmbeddings: llm => memories.backfillEmbeddings(llm),
};
}
return {
list: memories,
cache: null,
find: name => memories.find(m => m.name === name),
ghosts: () => rebuildGraph(memories).filter(n => n.missing).map(n => n.name),
search: (vector, limit) => memories
.filter(m => m.embedding?.length)
.map(m => ({
name: m.name,
description: m.description,
distance: cosineDistance(vector, m.embedding),
}))
.sort((a, b) => a.distance - b.distance)
.slice(0, limit),
forget: name => {
const idx = memories.findIndex(m => m.name === name);
if(idx === -1) return false;
memories.splice(idx, 1);
return true;
},
rebuild: changed => rebuildGraph(memories),
backfillEmbeddings: async llm => {
const missing = memories.filter(m => !m.embedding?.length);
if(!missing.length) return 0;
await Promise.all(missing.map(async node => {
await embedMemoryFields(node, llm);
}));
return missing.length;
},
};
}
export class MemoryCache {
private tree!: KDTree<MemoryRef>;
private indexed = new Map<string, number[]>();
public memories: Memory[];
public nodes: MemoryNode[] = [];
get length() {
return this.memories.length;
}
constructor(memories: Memory[]) {
this.memories = memories;
this.tree = new KDTree<MemoryRef>(0);
this.rebuild();
}
find(name: string): Memory | undefined {
return this.memories.find(m => m.name === name);
}
private syncTree(): void {
const current = new Set(this.memories.map(m => m.name));
for(const [name, emb] of [...this.indexed]) {
const mem = this.memories.find(m => m.name === name);
if(!mem || !current.has(name) || mem.embedding !== emb) {
this.tree.remove(p => p.name === name);
this.indexed.delete(name);
}
}
for(const mem of this.memories) {
if(!mem.embedding?.length || this.indexed.has(mem.name)) continue;
if(this.tree.dims === 0) {
this.tree = new KDTree<MemoryRef>(mem.embedding.length, 'cosine');
}
if(mem.embedding.length !== this.tree.dims) continue;
this.tree.insert({
vector: mem.embedding,
payload: {
name: mem.name,
description: mem.description,
},
});
this.indexed.set(mem.name, mem.embedding);
}
if(this.tree.tombstoneRatio > TREE_TOMBSTONE_LIMIT) {
this.tree.rebalance();
}
}
search(query: number[], limit: number): MemoryRef[] {
if(!this.tree || this.tree.dims === 0) return [];
return this.tree.knn(query, limit).map(r => ({
...r.point.payload,
distance: r.distance,
}));
}
add(memory: Memory): void {
this.memories.push(memory);
this.rebuild([memory]);
}
update(memory: Memory): void {
const existing = this.find(memory.name);
if(existing) Object.assign(existing, memory);
else this.memories.push(memory);
this.rebuild([existing ?? memory]);
}
remove(name: string): boolean {
const idx = this.memories.findIndex(m => m.name === name);
if(idx === -1) return false;
this.memories.splice(idx, 1);
this.rebuild();
return true;
}
ghosts(): string[] {
return this.nodes.filter(n => n.missing).map(n => n.name);
}
rebuild(changed?: Memory[]): MemoryNode[] {
this.nodes = changed?.length && this.nodes.length
? patchGraph(this.memories, this.nodes, changed)
: rebuildGraph(this.memories);
this.syncTree();
return this.nodes;
}
commit(changed?: Memory[]): MemoryNode[] {
return this.rebuild(changed);
}
async backfillEmbeddings(llm: any): Promise<number> {
const missing = this.memories.filter(m => !m.embedding?.length);
if(!missing.length) return 0;
await Promise.all(missing.map(node => embedMemoryFields(node, llm)));
this.commit(missing);
return missing.length;
}
}
+348
View File
@@ -0,0 +1,348 @@
import {AiTool} from '../tools.ts';
import type {LLMMessage, LLMRequest} from '../llm.ts';
import {MemoryCache, memoryStore} from './memory-state.ts';
import {cosineDistance, embedMemoryFields, stripHeader, updateMemory} from '../utils.ts';
const FACT_SIMILARITY_THRESHOLD = 0.62;
const DUPLICATE_THRESHOLD = 0.68;
const PROTECTED_MEMORIES = ['People/User'];
const COLLECTION_WORDS = ['project', 'projects', 'people', 'person', 'managed', 'guides', 'guide', 'research', 'class', 'classes'];
export type Memory = {
name: string;
description: string;
content: string;
embedding: number[];
titleEmbedding?: number[];
bodyEmbeddings?: number[][];
links: string[];
backlinks: string[];
}
export type MemoryRef = {
name: string;
description: string;
distance?: number;
}
export type MemoryOptions = {
memory: Memory[] | MemoryCache;
inject?: boolean;
tool?: boolean;
update?: boolean;
maxTokens?: number;
}
export type MemoryStore = Memory[] | MemoryCache;
/** Create an empty memory shell. */
function emptyNode(name: string, description = ''): Memory {
return {name, description, content: `# ${name.split('/').pop()}\n`, embedding: [], links: [], backlinks: []};
}
function renderNode(node: Memory): string {
return `### ${node.name}
Description: ${node.description}
Links: ${[...node.links, ...node.backlinks].join(', ') || 'none'}
\`\`\`markdown
${node.content}
\`\`\``;
}
function factSimilarity(a: Memory, b: Memory): number {
return !a.bodyEmbeddings?.length || !b.bodyEmbeddings?.length ? 0 : Math.max(...a.bodyEmbeddings.flatMap(av => b.bodyEmbeddings!.map(bv => 1 - cosineDistance(av, bv))));
}
function words(text: string): string[] {
return [...new Set(text.toLowerCase().replace(/[[\]()/_-]/g, ' ').replace(/[^a-z0-9\s]/g, '').split(/\s+/).filter(w => w && !COLLECTION_WORDS.includes(w)))];
}
function jaccard(a: string[], b: string[]): number {
const bs = new Set(b), hit = a.filter(x => bs.has(x)).length, total = new Set([...a, ...b]).size;
return total ? hit / total : 0;
}
function duplicateScore(a: Memory, b: Memory): number {
const name = Math.max(
jaccard(words(a.name), words(b.name)),
jaccard(words(a.name.split('/').pop() || a.name), words(b.name.split('/').pop() || b.name)),
);
const desc = jaccard(words(a.description), words(b.description));
const body = factSimilarity(a, b);
const emb = a.embedding?.length && b.embedding?.length && a.embedding.length === b.embedding.length ? 1 - cosineDistance(a.embedding, b.embedding) : 0;
return Math.max(body, name * 0.9 + desc * 0.06 + emb * 0.04, emb * 0.55 + name * 0.35 + desc * 0.1);
}
function homeScore(node: Memory): number {
return (PROTECTED_MEMORIES.includes(node.name) ? 1e9 : 0)
+ (node.name.includes('/') ? 4 : 0)
+ (node.description && node.description !== 'Persistent memory document' ? 1 : 0)
+ Math.min(stripHeader(node.content).length / 1000, 5);
}
function pickMerge(a: Memory, b: Memory, touched: Set<string>): [drop: Memory, home: Memory] {
const as = homeScore(a), bs = homeScore(b);
if(touched.has(a.name) && !touched.has(b.name)) return as > bs + 2 ? [b, a] : [a, b];
if(touched.has(b.name) && !touched.has(a.name)) return bs > as + 2 ? [a, b] : [b, a];
return as <= bs ? [a, b] : [b, a];
}
/** Build memory tools and memory index text. */
export function memoryTools(llm: any, memories: MemoryStore): {tools: AiTool[]; list: string} {
const store = memoryStore(memories);
const names = new Map<string, string>();
for(const node of store.list)
if(!names.has(node.name)) names.set(node.name, `${node.name} - ${node.description}`);
for(const name of store.ghosts())
if(!names.has(name)) names.set(name, `${name} - ghost node`);
return {
list: [...names.values()].join('\n'),
tools: [
{
name: 'memory_search',
description: 'Semantically search memories for most relevant',
args: {
query: {type: 'string', description: 'Search query', required: true},
limit: {type: 'number', description: 'Maximum results, default 5', default: 5},
},
fn: async ({query, limit = 5}) => {
if(!query?.trim()) return 'Search query is required.';
const [chunk] = await llm.embedding(query, {maxTokens: 8000, overlapTokens: 0});
if(!chunk?.embedding) return 'Failed to create embedding from query';
const results = store.search(chunk.embedding, limit).map(ref => store.find(ref.name)).filter((node): node is Memory => !!node);
return results.length ? results.map(renderNode).join('\n\n---\n\n') : 'No relevant memories found.';
},
},
{
name: 'memory_read',
description: 'Read an entire memory document by name',
args: {name: {type: 'string', description: 'Exact document name', required: true}},
fn: async ({name}) => {
const node = store.find(name);
return node ? renderNode(node) : store.ghosts().includes(name) ? `"${name}" is a ghost node with no document of its own.` : `Not found: "${name}".`;
},
},
{
name: 'memory_delete',
description: 'Delete a duplicate or merged memory',
args: {name: {type: 'string', description: 'Exact document name', required: true}},
fn: async ({name}) => {
store.forget(name);
return `Removed: ${name}`;
},
},
{
name: 'memory_write',
description: 'Create or replace a memory document.',
args: {
name: {type: 'string', description: 'Document name following the entity naming convention.', required: true},
description: {type: 'string', description: 'One factual sentence describing the entire document subject', required: true},
content: {type: 'string', description: 'Complete Markdown document body, including the # title', required: true},
},
fn: async (args: any) => {
const name = String(args.name || '').trim();
if(!name) return 'A document name is required.';
const description = String(args.description || '').trim();
if(!description) return 'A document description is required.';
const content = String(args.content || '').trim();
if(!content) return 'Document content is required.';
let node = store.find(name);
if(!node) {
node = emptyNode(name, description);
if(store.cache) store.cache.add(node);
else store.list.push(node);
}
node.description = name === 'People/User' ? 'All information about the current user' : description.replace(/\s+/g, ' ').trim();
node.content = updateMemory(node, content);
await embedMemoryFields(node, llm);
store.cache?.commit([node]);
return `Updated ${name}`;
},
},
],
};
}
export class MemoryManager {
private memorized = new WeakMap<LLMMessage[], LLMMessage>();
constructor(private llm: any) {}
static normalize(memory?: Memory[] | MemoryCache | MemoryOptions): MemoryOptions | null {
if(!memory) return null;
if(Array.isArray(memory) || memory instanceof MemoryCache) return {memory, inject: true, tool: false, update: false};
if(typeof memory === 'object' && 'memory' in memory) return {inject: true, tool: false, update: false, ...memory};
return null;
}
private memorySystem(list: string): string {
return `You maintain notes written in markdown used for memories from recent conversations using your tools.
Only preserve durable information worth remembering established by the USER.
Do not store assistant guesses, speculation, suggestions, commentary, temporary state, or details that are not worth remembering.
## Rules
- ALWAYS READ a target memory before changing it, \`memory_write\` does a full replace, it DOES NOT append!
- Memories should contain the final state, not deltas
- New conversational context is authoritative when it contracts existing information; reconcile it
- Only remove information when stale, contradicted or duplicated; always preserve existing information, formatting and keep related information together
- Only merge memories when two or more nodes are clearly about the same thing; only split a memory when it is clearly about two distinct subjects
- Use [[WikiLinks]] liberally to record aliases and relationships between entities, even ones without pages yet (ghost nodes)
- Use headings, subheadings, lists, tables and other markdown formatting to make documents clean
- Maintain a \`## Todo List\` of checkboxes AS THE FIRST SUBHEADING when an entity has tasks
- Only create todo items for USER tasks, not AI work
- Only store each in one place, no duplicates
- Use \`People/User\` for personal tasks or as a fallback
## Naming
- Every fact should be grouped with the owning entity
- Always follow the naming convention \`Collection/(Pro)Noun\`
- Facts about the user belong under People/User
- Reuse existing memories when they are clearly the same entity including aliases and ghost references.
- Only create deeper paths when there is a real parent/child entity relationship: \`School/Class/Chapter\`
Valid Examples:
- People/User
- People/John Smith
- Projects/Momentum
- Projects/Momentum/Marketing
- Research/Object Recognition
- Guides/HAM Radio SOP
## Workflow
1. Create groups of durable information and todos based on the owning entity & naming rules above
2. For each group:
1. Read the existing memory(s)
2. Merge the information & todos based on the rules above
3. Write the entire patched document
Available memories:
${list || 'No memory documents exist yet.'}`;
}
private touchedNames(history: LLMMessage[]): string[] {
return [...new Set(history
.filter((h: any) => h.role === 'tool' && h.name === 'memory_write' && !h.error)
.map((h: any) => String(h.args?.name || h.content?.match(/^Updated (.+)$/)?.[1] || '').trim())
.filter(Boolean))];
}
private async backfillEmbeddings(store: ReturnType<typeof memoryStore>): Promise<void> {
const missing = store.list.filter(m => !m.embedding?.length || !m.titleEmbedding?.length || !m.bodyEmbeddings?.length);
await Promise.all(missing.map(m => embedMemoryFields(m, this.llm)));
store.cache?.commit(missing);
}
private closestDuplicate(node: Memory, store: ReturnType<typeof memoryStore>): Memory | null {
return store.list
.filter(m => m.name !== node.name && !m.name.startsWith('Journal/') && !node.name.startsWith('Journal/'))
.map(m => ({node: m, score: duplicateScore(node, m)}))
.filter(x => x.score >= DUPLICATE_THRESHOLD || factSimilarity(node, x.node) >= FACT_SIMILARITY_THRESHOLD)
.sort((a, b) => b.score - a.score)[0]?.node || null;
}
private async rehomeDeleted(drop: Memory, home: Memory, memories: MemoryStore, options: LLMRequest): Promise<void> {
const store = memoryStore(memories);
const backup = structuredClone(drop);
store.forget(drop.name);
try {
const memory = memoryTools(this.llm, memories);
await this.llm.ask(`A duplicate memory document was removed automatically.
Deleted document:
${renderNode(backup)}
Closest surviving home:
${renderNode(home)}
Reinsert every durable unique fact, useful relationship, alias, and user todo from the deleted document into the best remaining memory document.
Usually this should be "${home.name}", but use another existing memory if it is a better home.
Read before writing. Write full replacement documents only.
Do NOT recreate "${backup.name}" unless the deletion was wrong and it is clearly a distinct persistent entity.`, {
model: options.memoryModel || options.model,
temperature: 0.2,
maxTokens: options.maxTokens,
tools: memory.tools,
history: [],
system: this.memorySystem(memory.list),
});
} catch(err) {
if(!store.find(backup.name)) store.cache ? store.cache.add(backup) : store.list.push(backup);
throw err;
} finally {
store.cache?.commit(store.list);
}
}
private async reconcileSimilar(history: LLMMessage[], memories: MemoryStore, options: LLMRequest): Promise<void> {
const store = memoryStore(memories);
const touched = new Set(this.touchedNames(history));
const targets = store.list.filter(m => touched.has(m.name) || [...touched].some(t => duplicateScore(m, store.find(t) || m) >= DUPLICATE_THRESHOLD));
const deleted = new Set<string>();
if(!targets.length) return;
await this.backfillEmbeddings(store);
for(const node of targets) {
if(!store.find(node.name) || deleted.has(node.name) || PROTECTED_MEMORIES.includes(node.name)) continue;
const closest = this.closestDuplicate(node, store);
if(!closest) continue;
const [drop, home] = pickMerge(node, closest, touched);
if(deleted.has(drop.name) || PROTECTED_MEMORIES.includes(drop.name)) continue;
deleted.add(drop.name);
await this.rehomeDeleted(drop, home, memories, options);
await this.backfillEmbeddings(store);
}
}
async recollect(query: string, memory: MemoryStore, limit = 15): Promise<Memory[]> {
const store = memoryStore(memory);
if(!store.list.length || !query?.trim()) return [];
const [chunk] = await this.llm.embedding(query, {maxTokens: 8000, overlapTokens: 0});
return !chunk?.embedding ? [] : store.search(chunk.embedding, limit).map(ref => store.find(ref.name)).filter((m: Memory | undefined): m is Memory => !!m);
}
get tools(): {read: (memory: MemoryStore) => AiTool[]} {
return {read: (memory: MemoryStore) => memoryTools(this.llm, memory).tools};
}
async memorize(history: LLMMessage[], memories: Memory[] | MemoryCache, options: LLMRequest = {},): Promise<Memory[]> {
const store = memoryStore(memories);
const previous = this.memorized.get(history);
let start = 0;
if(previous) {
const index = history.indexOf(previous);
if(index >= 0) start = index + 1;
}
const turns = history.slice(start).filter((h: any) => h.role === 'user' || h.role === 'assistant');
const conversation = turns.map((h: any) => `[${h.role}]: ${h.content}`).join('\n\n').trim();
if(!conversation) return store.list;
const memory = memoryTools(this.llm, memories);
const memoryHistory: LLMMessage[] = [];
await this.llm.ask(conversation, {
model: options.memoryModel || options.model,
temperature: 0.2,
maxTokens: options.maxTokens,
tools: memory.tools,
history: memoryHistory,
system: this.memorySystem(memory.list),
});
await this.reconcileSimilar(memoryHistory, memories, options);
const lastTurn = turns.at(-1);
if(lastTurn) this.memorized.set(history, lastTurn);
return store.list;
}
}
+112 -35
View File
@@ -36,21 +36,49 @@ export class OpenAi extends LLMProvider {
private toWire(history: LLMMessage[], system?: string): any[] { private toWire(history: LLMMessage[], system?: string): any[] {
const wire: any[] = []; const wire: any[] = [];
if(system) wire.push({role: 'system', content: system}); if(system) wire.push({role: 'system', content: system});
for(const h of history) {
if(h.role === 'tool') { for(let i = 0; i < history.length; i++) {
const h = history[i];
if(h.role !== 'tool') {
wire.push({role: h.role, content: this.toWireContent(h.content)});
continue;
}
const calls: any[] = [];
const results: any[] = [];
while(i < history.length && history[i].role === 'tool') {
const tool: any = history[i];
calls.push({
id: tool.id,
type: 'function',
function: {
name: tool.name,
arguments: JSON.stringify(tool.args || {})
}
});
results.push({
role: 'tool',
tool_call_id: tool.id,
content: tool.error || tool.content || ''
});
i++;
}
wire.push({ wire.push({
role: 'assistant', role: 'assistant',
content: null, content: null,
tool_calls: [{id: h.id, type: 'function', function: {name: h.name, arguments: JSON.stringify(h.args)}}], tool_calls: calls
}, {
role: 'tool',
tool_call_id: h.id,
content: h.error || h.content || '',
}); });
} else {
wire.push({role: h.role, content: this.toWireContent(h.content)}); wire.push(...results);
} i--;
} }
return wire; return wire;
} }
@@ -60,13 +88,12 @@ export class OpenAi extends LLMProvider {
if(!options.history) options.history = []; if(!options.history) options.history = [];
const history = options.history; const history = options.history;
if(message) history.push({role: 'user', content: message, timestamp: Date.now()}); if(message) history.push({role: 'user', content: message, timestamp: Date.now()});
const tools = options.tools || this.ai.options.llm?.tools || []; 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,
stream: !!options.stream, stream: !!options.stream,
max_completion_tokens: options.maxTokens || this.ai.options.llm?.maxTokens || undefined, max_completion_tokens: options.maxTokens ?? this.ai.options.llm?.maxTokens,
temperature: options.temperature || this.ai.options.llm?.temperature || undefined, temperature: options.temperature ?? this.ai.options.llm?.temperature,
tools: tools.map(t => ({ tools: tools.map(t => ({
type: 'function', type: 'function',
function: { function: {
@@ -74,8 +101,12 @@ export class OpenAi extends LLMProvider {
description: t.description, description: t.description,
parameters: { parameters: {
type: 'object', type: 'object',
properties: t.args ? objectMap(t.args, (key, value) => ({...value, required: undefined})) : {}, properties: t.args
required: t.args ? Object.entries(t.args).filter(t => t[1].required).map(t => t[0]) : [] ? objectMap(t.args, (key, value) => ({...value, required: undefined}))
: {},
required: t.args
? Object.entries(t.args).filter(t => t[1].required).map(t => t[0])
: []
} }
} }
})) }))
@@ -83,60 +114,106 @@ export class OpenAi extends LLMProvider {
if(options.schema) { if(options.schema) {
const schema = convertSchema(options.schema); const schema = convertSchema(options.schema);
requestParams.response_format = {type: 'json_schema', json_schema: {name: 'response', strict: true, schema}}; requestParams.response_format = {
type: 'json_schema',
json_schema: {name: 'response', strict: true, schema}
};
} }
if(options.stream) requestParams.stream_options = {include_usage: true}; if(options.stream) requestParams.stream_options = {include_usage: true};
try { try {
let terminal = false; let terminal = false;
let iteration = 0;
do { do {
iteration++;
requestParams.messages = this.toWire(history.filter(h => h.role !== 'system'), options.system); requestParams.messages = this.toWire(history.filter(h => h.role !== 'system'), options.system);
const callStart = Date.now(); const callStart = Date.now();
const resp: any = await this.tokenPool.run(token => this.getClient(token).chat.completions.create(requestParams)).catch(err => { const resp: any = await this.tokenPool.run(token =>
this.getClient(token).chat.completions.create(requestParams)
).catch(err => {
err.message += `\n\nMessages:\n${JSON.stringify(requestParams.messages, null, 2)}`; err.message += `\n\nMessages:\n${JSON.stringify(requestParams.messages, null, 2)}`;
throw err; throw err;
}); });
let usage: any, msg: any = {content: '', tool_calls: []}; let usage: any;
let finishReason: string | undefined;
let msg: any = {content: '', tool_calls: []};
let streamedChars = 0;
if(options.stream) { if(options.stream) {
let streamCompleted = false;
try {
for await (const chunk of resp) { for await (const chunk of resp) {
if(controller.signal.aborted) break; if(controller.signal.aborted) break;
if(chunk.usage) usage = chunk.usage; if(chunk.usage) usage = chunk.usage;
if(chunk.choices[0]?.delta?.content) {
msg.content += chunk.choices[0].delta.content; const choice = chunk.choices?.[0];
options.stream({text: chunk.choices[0].delta.content}); if(choice?.finish_reason) finishReason = choice.finish_reason;
if(choice?.delta?.content) {
msg.content += choice.delta.content;
streamedChars += choice.delta.content.length;
options.stream({text: choice.delta.content});
} }
if(chunk.choices[0]?.delta?.tool_calls) {
for(const deltaTC of chunk.choices[0].delta.tool_calls) { if(choice?.delta?.tool_calls) {
const existing = msg.tool_calls.find((tc: any) => tc.index === deltaTC.index); for(const deltaTC of choice.delta.tool_calls) {
if(existing) { const index = deltaTC.index ?? msg.tool_calls.length;
let existing = msg.tool_calls.find((tc: any) => tc.index === index);
if(!existing) {
existing = {index, id: '', function: {name: '', arguments: ''}};
msg.tool_calls.push(existing);
}
if(deltaTC.id) existing.id = deltaTC.id; if(deltaTC.id) existing.id = deltaTC.id;
if(deltaTC.function?.name) existing.function.name = deltaTC.function.name; if(deltaTC.function?.name) existing.function.name = deltaTC.function.name;
if(deltaTC.function?.arguments) existing.function.arguments += deltaTC.function.arguments; if(deltaTC.function?.arguments) existing.function.arguments += deltaTC.function.arguments;
} else {
msg.tool_calls.push({
index: deltaTC.index,
id: deltaTC.id || '',
function: {name: deltaTC.function?.name || '', arguments: deltaTC.function?.arguments || ''}
});
} }
} }
} }
streamCompleted = true;
} catch(err) {
if(!controller.signal.aborted) throw err;
} }
if(streamCompleted && !finishReason) finishReason = msg.tool_calls.length ? 'tool_calls' : 'stop';
} else { } else {
usage = resp.usage; usage = resp.usage;
finishReason = resp.choices[0].finish_reason;
msg = resp.choices[0].message; msg = resp.choices[0].message;
} }
const duration = Date.now() - callStart; const duration = Date.now() - callStart;
const tps = usage?.completion_tokens && duration > 0 ? usage.completion_tokens / (duration / 1000) : 0; const tps = usage?.completion_tokens && duration > 0 ? usage.completion_tokens / (duration / 1000) : 0;
if(finishReason === 'length' && !controller.signal.aborted) {
if(msg.content?.trim()) history.push({role: 'assistant', content: msg.content.trim(), timestamp: Date.now(), duration, tps});
throw new Error(`[OpenAI] Response hit token limit before completing`);
}
if(!finishReason && !controller.signal.aborted) {
throw new Error('[OpenAI] Completion ended without a usable response');
}
const toolCalls = msg.tool_calls || []; const toolCalls = msg.tool_calls || [];
if(toolCalls.length && !controller.signal.aborted) { if(toolCalls.length && !controller.signal.aborted) {
if(msg.content?.trim()) history.push({role: 'assistant', content: msg.content.trim(), timestamp: Date.now(), duration, tps}); if(msg.content?.trim()) history.push({role: 'assistant', content: msg.content.trim(), timestamp: Date.now(), duration, tps});
const entries = toolCalls.map((tc: any) => { const entries = toolCalls.map((tc: any) => {
const entry: any = {role: 'tool', id: tc.id, name: tc.function.name, args: JSONAttemptParse(tc.function.arguments, {}), content: undefined, timestamp: Date.now()}; const entry: any = {
role: 'tool',
id: tc.id,
name: tc.function.name,
args: JSONAttemptParse(tc.function.arguments, {}),
content: undefined,
timestamp: Date.now()
};
history.push(entry); history.push(entry);
return {tc, entry}; return {tc, entry};
}); });
@@ -144,12 +221,13 @@ export class OpenAi extends LLMProvider {
await Promise.all(entries.map(async ({tc, entry}: any) => { await Promise.all(entries.map(async ({tc, entry}: any) => {
const tool = tools.find(findByProp('name', tc.function.name)); const tool = tools.find(findByProp('name', tc.function.name));
if(options.stream) options.stream({tool: tc.function.name}); if(options.stream) options.stream({tool: tc.function.name});
if(!tool) { entry.error = 'Tool not found'; return; } if(!tool) return entry.error = 'Tool not found';
try { try {
const toolStream = options.stream && ((chunk: any) => { const toolStream = options.stream && ((chunk: any) => {
if(chunk.done) { terminal = true; return; } if(chunk.done) return;
options.stream!(chunk); options.stream!(chunk);
}); });
const result = await tool.fn(entry.args, toolStream, this.ai, tc.id); const result = await tool.fn(entry.args, toolStream, this.ai, tc.id);
entry.content = typeof result === 'object' ? JSONSanitize(result) : result; entry.content = typeof result === 'object' ? JSONSanitize(result) : result;
} catch(err: any) { } catch(err: any) {
@@ -164,7 +242,6 @@ export class OpenAi extends LLMProvider {
} while(!terminal && !controller.signal.aborted); } while(!terminal && !controller.signal.aborted);
if(options.stream) options.stream({done: true}); if(options.stream) options.stream({done: true});
const turnStart = history.map(h => h.role).lastIndexOf('user'); const turnStart = history.map(h => h.role).lastIndexOf('user');
const finalContent = history.slice(turnStart + 1).reduce((str, h) => h.role === 'assistant' ? str + (h.content || '') : str, '').trim(); const finalContent = history.slice(turnStart + 1).reduce((str, h) => h.role === 'assistant' ? str + (h.content || '') : str, '').trim();
res(options.schema ? JSONAttemptParse(finalContent, finalContent) : finalContent); res(options.schema ? JSONAttemptParse(finalContent, finalContent) : finalContent);
+82
View File
@@ -0,0 +1,82 @@
import {Memory} from './memory/memory.ts';
export 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 async function embedMemoryFields(node: Memory, llm: any): Promise<void> {
const body = stripHeader(node.content);
const [titleE] = await llm.embedding(node.name.split('/').pop() || node.name);
const [descE] = await llm.embedding(node.description || '');
const bodyChunks = body ? await llm.embedding(body) : [];
if(titleE) node.titleEmbedding = titleE.embedding;
if(descE) node.embedding = descE.embedding;
node.bodyEmbeddings = bodyChunks.map((c: any) => c.embedding).filter(Boolean);
}
export function euclideanDistance(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);
}
export function getWeekStart(date: Date = new Date()): string {
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
const day = d.getUTCDay();
const diff = day === 0 ? -6 : 1 - day;
d.setUTCDate(d.getUTCDate() + diff);
return d.toISOString().slice(0, 10);
}
export function journalDescription(journalName?: string): string {
const start = journalName?.split('/').pop() || getWeekStart();
const d = new Date(`${start}T00:00:00Z`);
d.setUTCDate(d.getUTCDate() + 6);
const end = d.toISOString().slice(0, 10);
return `Log from ${start} - ${end}`;
}
function parseFrontmatter(content: string): {fm: Map<string, string>, body: string} {
const match = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
if(!match) return {fm: new Map(), body: content};
const fm = new Map<string, string>();
for(const line of match[1].split('\n')) {
const i = line.indexOf(':');
if(i === -1) continue;
const key = line.slice(0, i).trim();
const raw = line.slice(i + 1).trim();
let value = raw;
try { value = JSON.parse(raw); } catch { }
fm.set(key, value);
}
return {fm, body: match[2]};
}
export function writeFrontmatter(fm: Map<string, string>, body: string): string {
const lines = [...fm.entries()].map(([k, v]) =>
`${k}: ${JSON.stringify(String(v).replace(/\s+/g, ' ').trim())}`);
return `---\n${lines.join('\n')}\n---\n\n${body.trimStart()}`;
}
export function stripHeader(content: string): string {
return content.replace(/^---[\s\S]*?\n---\n?/, '').trimStart();
}
export function updateMemory(node: Memory, body: string): string {
const {fm} = parseFrontmatter(node.content);
fm.set('name', node.name);
fm.set('description', (node.name.startsWith('Journal/') ? journalDescription(node.name) : node.description)
|| 'Persistent memory document');
fm.set('modified', new Date().toISOString());
return writeFrontmatter(fm, stripHeader(body));
}