Compare commits

...
15 Commits
Author SHA1 Message Date
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
ztimson 85c01d3ef1 Added official file support
Publish Library / Build NPM Project (push) Successful in 30s
Publish Library / Tag Version (push) Successful in 10s
2026-08-17 15:50:48 -04:00
ztimson 5826573d5c Added official file support
Publish Library / Build NPM Project (push) Successful in 50s
Publish Library / Tag Version (push) Successful in 13s
2026-08-17 15:16:32 -04:00
8 changed files with 736 additions and 275 deletions
+6 -6
View File
@@ -1,19 +1,19 @@
{
"name": "@ztimson/ai-utils",
"version": "1.5.0",
"version": "1.6.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@ztimson/ai-utils",
"version": "1.5.0",
"version": "1.6.6",
"license": "MIT",
"dependencies": {
"@anthropic-ai/sdk": "^0.102.0",
"@huggingface/transformers": "^4.2.0",
"@tensorflow/tfjs": "^4.22.0",
"@ztimson/node-utils": "^1.0.7",
"@ztimson/utils": "^0.29.4",
"@ztimson/utils": "^0.30.8",
"cheerio": "^1.2.0",
"openai": "^6.42.0",
"pdf-parse": "^2.4.5",
@@ -1525,9 +1525,9 @@
"license": "MIT"
},
"node_modules/@ztimson/utils": {
"version": "0.29.7",
"resolved": "https://registry.npmjs.org/@ztimson/utils/-/utils-0.29.7.tgz",
"integrity": "sha512-cjQ9+RjC5X7gKNA/hJHDf7OtyYCa+5E0PDc76lIaATwNAxXCSx2IO9r2wHiHtZGV5bldjnFmw7aOV8Jmq7SgKQ==",
"version": "0.30.8",
"resolved": "https://registry.npmjs.org/@ztimson/utils/-/utils-0.30.8.tgz",
"integrity": "sha512-+vBjcinqckqMHkP95xWiQeQz2E7Q1oS0b+Odjp+F9rvQ4z0US4JdodjqhEaqh+RAO/yP77x4Eu0a04yBB4HNdw==",
"license": "MIT",
"dependencies": {
"var-persist": "^1.0.1"
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@ztimson/ai-utils",
"version": "1.6.0",
"version": "1.7.0",
"description": "AI Utility library",
"author": "Zak Timson",
"license": "MIT",
@@ -29,7 +29,7 @@
"@huggingface/transformers": "^4.2.0",
"@tensorflow/tfjs": "^4.22.0",
"@ztimson/node-utils": "^1.0.7",
"@ztimson/utils": "^0.29.4",
"@ztimson/utils": "^0.30.8",
"cheerio": "^1.2.0",
"openai": "^6.42.0",
"pdf-parse": "^2.4.5",
+1 -1
View File
@@ -4,7 +4,7 @@ import { Audio } from './audio.ts';
import {Vision} from './vision.ts';
export type AbortablePromise<T> = Promise<T> & {
abort: () => any
abort: (keep?: boolean) => any
};
export type AiOptions = {
+50
View File
@@ -13,6 +13,56 @@ export function extractLinks(content: string): string[] {
return [...new Set([...matches].map(m => m[1].trim()))];
}
/**
* Incrementally patch the graph for a set of changed memories, instead of
* re-scanning every document. Only the changed memories' own content is
* re-parsed for links; affected targets have their backlinks patched.
* Does NOT handle node deletion — full rebuildGraph() is still required
* when a memory is removed, since that needs a backlink sweep across
* everyone who might reference it.
*/
export function patchGraph(mems: Memory[], nodes: MemoryNode[], changed: Memory[]): MemoryNode[] {
const nameSet = new Set(mems.map(m => m.name));
const byName = new Map(nodes.map(n => [n.name, n]));
const ensureNode = (name: string): MemoryNode => {
let n = byName.get(name);
if (!n) {
n = {name, missing: !nameSet.has(name), links: [], backlinks: []};
byName.set(name, n);
}
return n;
};
for (const m of changed) {
const node = ensureNode(m.name);
node.missing = false; // real memory, promotes any pre-existing ghost entry
const oldLinks = m.links ?? [];
const newLinks = extractLinks(m.content).filter(l => l !== m.name);
for (const target of oldLinks.filter(l => !newLinks.includes(l))) {
const t = byName.get(target);
if (!t) continue;
t.backlinks = t.backlinks.filter(n => n !== m.name);
if (t.missing && !t.backlinks.length) byName.delete(target); // fully dereferenced ghost
}
for (const target of newLinks.filter(l => !oldLinks.includes(l))) {
const t = ensureNode(target);
if (!t.backlinks.includes(m.name)) t.backlinks.push(m.name);
}
m.links = newLinks;
node.links = newLinks;
}
for (const m of mems) {
const n = byName.get(m.name);
if (n) m.backlinks = n.backlinks;
}
return [...byName.values()];
}
export function rebuildGraph(memories: Memory[] | MemoryCache): MemoryNode[] {
const mems = memories instanceof MemoryCache ? memories.memories : memories;
const nameSet = new Set(mems.map(m => m.name));
+53 -12
View File
@@ -15,6 +15,7 @@ interface KDNode<T> {
axis: number;
left: KDNode<T> | null;
right: KDNode<T> | null;
deleted?: boolean;
}
// ─── Distance helpers ─────────────────────────────────────────────────────────
@@ -95,6 +96,7 @@ class BoundedMaxHeap<T> {
*
* Supports:
* - Insertion of labeled points
* - Lazy (tombstone) removal, physically purged on rebalance()
* - k-nearest-neighbor (KNN) search
* - Radius search (all points within a given distance)
* - Euclidean and cosine distance metrics
@@ -103,6 +105,7 @@ class BoundedMaxHeap<T> {
export class KDTree<T = unknown> {
private root: KDNode<T> | null = null;
private _size = 0;
private _tombstones = 0;
private readonly distanceFn: (a: number[], b: number[]) => number;
readonly dims: number;
@@ -129,9 +132,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; }
/** 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 ──────────────────────────────────────────────────────────────
/**
@@ -144,10 +153,36 @@ export class KDTree<T = unknown> {
this._size++;
}
// ── Removal ────────────────────────────────────────────────────────────────
/**
* Lazily remove all live points whose payload matches `predicate`.
* O(n) traversal, but avoids a full tree rebuild. Call `rebalance()`
* periodically (e.g. once tombstoneRatio crosses ~0.25) to reclaim space
* and restore optimal query depth.
* @returns number of points removed
*/
remove(predicate: (payload: T) => boolean): number {
let removed = 0;
const visit = (node: KDNode<T> | null): void => {
if (!node) return;
if (!node.deleted && predicate(node.point.payload)) {
node.deleted = true;
removed++;
}
visit(node.left);
visit(node.right);
};
visit(this.root);
this._size -= removed;
this._tombstones += removed;
return removed;
}
// ── KNN search ─────────────────────────────────────────────────────────────
/**
* Find the k nearest neighbors to `query`.
* Find the k nearest live neighbors to `query`.
* Returns results sorted by distance ascending.
*/
knn(query: number[], k: number): KNNResult<T>[] {
@@ -171,7 +206,7 @@ export class KDTree<T = unknown> {
// ── 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.
*/
radiusSearch(query: number[], radius: number): KNNResult<T>[] {
@@ -186,7 +221,7 @@ export class KDTree<T = unknown> {
// ── Conversion ─────────────────────────────────────────────────────────────
/** Collect all points in the tree (order not guaranteed). */
/** Collect all live points in the tree (order not guaranteed). */
toArray(): KDPoint<T>[] {
const out: KDPoint<T>[] = [];
this.collect(this.root, out);
@@ -194,12 +229,14 @@ export class KDTree<T = unknown> {
}
/**
* Rebuild the tree from its current points as a balanced tree.
* Useful after many individual insertions to restore O(log n) query time.
* Rebuild the tree from its current live points as a balanced tree.
* Physically purges tombstones and restores O(log n) query time.
*/
rebalance(): void {
const points = this.toArray();
this.root = points.length ? this.buildBalanced(points, 0) : null;
this._size = points.length;
this._tombstones = 0;
}
// ── Private: build ─────────────────────────────────────────────────────────
@@ -251,8 +288,10 @@ export class KDTree<T = unknown> {
): void {
if (node === null) return;
const dist = this.distanceFn(query, node.point.vector);
heap.push({ point: node.point, distance: dist });
if (!node.deleted) {
const dist = this.distanceFn(query, node.point.vector);
heap.push({ point: node.point, distance: dist });
}
const axis = node.axis;
const diff = query[axis] - node.point.vector[axis];
@@ -285,9 +324,11 @@ export class KDTree<T = unknown> {
): void {
if (node === null) return;
const dist = this.distanceFn(query, node.point.vector);
if (dist <= radius) {
results.push({ point: node.point, distance: dist });
if (!node.deleted) {
const dist = this.distanceFn(query, node.point.vector);
if (dist <= radius) {
results.push({ point: node.point, distance: dist });
}
}
const axis = node.axis;
@@ -310,7 +351,7 @@ export class KDTree<T = unknown> {
private collect(node: KDNode<T> | null, out: KDPoint<T>[]): void {
if (node === null) return;
out.push(node.point);
if (!node.deleted) out.push(node.point);
this.collect(node.left, out);
this.collect(node.right, out);
}
+69 -35
View File
@@ -19,6 +19,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 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 = {
name: string;
description?: string;
@@ -29,7 +36,7 @@ export type Agent = {
skills?: Skill[] | null;
tools?: AiTool[] | null;
mcp?: McpServer[] | null;
agents?: string[] | null;
agents?: AgentRef[] | null;
}
export type LLMFile = {
@@ -50,6 +57,8 @@ export type LLMMessage = {
role: 'assistant' | 'system' | 'user';
/** Message content */
content: string | any;
/** Files attached to request */
files?: LLMFile[];
/** Timestamp */
timestamp?: number;
/** Response duration in ms */
@@ -104,8 +113,8 @@ export type LLMRequest = {
skills?: Skill[];
/** MCP servers to connect and expose as tools */
mcp?: McpServer[];
/** Subagents exposed as delegatable/wrapped tools */
agents?: Agent[];
/** Subagents exposed as delegatable/wrapped tools, resolved lazily via their `fn` */
agents?: AgentRef[];
/** Attach files to request */
files?: LLMFile[];
/** @internal recursion guard for nested agent delegation */
@@ -241,10 +250,8 @@ class LLM {
} else if(isText) {
text = (await this.loadBuffer(file, true)).toString('utf-8');
} else {
text = `Unsupported file type: ${ext || mime}`;
text = typeof file.content === 'string' ? file.content : `[Binary file, unable to extract: ${name}]`;
}
// Cache result, skip re-extraction on future turns of the same conversation
file.content = text;
file.extracted = true;
delete file.path;
@@ -265,22 +272,21 @@ class LLM {
};
}
private setupAgent(agents: Agent[] = [], allAgents: Agent[], history: LLMMessage[], aborts: (() => void)[], depth = 0, delegateState: {resp: string | null}): AiTool[] {
return agents.map(a => {
const toolName = `${a.delegate ? '' : 'sub'}agent_${snakeCase(a.name)}`;
private setupAgent(stubs: AgentRef[] = [], history: LLMMessage[], aborts: ((keep?: boolean) => void)[], depth = 0, delegateState: {resp: string | null}): AiTool[] {
return stubs.map(stub => {
const toolName = `${stub.delegate ? '' : 'sub'}agent_${snakeCase(stub.name)}`;
return {
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>({
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},
}),
fn: async (args: any, stream: any, ai: any, id?: string) => {
if(depth >= MAX_AGENT_DEPTH) return 'Max agent delegation depth exceeded';
const nested = (a.agents || [])
.map(name => allAgents.find(x => x.name === name))
.filter((x): x is Agent => !!x && x.name !== a.name);
const a = await stub.fn();
if(!a) return `Agent "${stub.name}" could not be resolved`;
const q = a.delegate ? '' : `${args.instructions}${args.context ? `\n\n<context>${args.context}</context>` : ''}`;
@@ -297,7 +303,7 @@ ${a.system}`,
mcp: a.mcp || undefined,
skills: a.skills || undefined,
tools: a.tools || undefined,
agents: nested,
agents: a.agents || [],
_agentDepth: depth + 1,
} as any);
aborts.push(request.abort);
@@ -397,11 +403,13 @@ ${a.system}`,
if(!this.models[m]) throw new Error(`Model does not exist: ${m}`);
let request: AbortablePromise<string> | null = null;
let aborted = false;
const nestedAborts: (() => void)[] = [];
const abort = () => {
let keepOnAbort = true;
const nestedAborts: ((keep?: boolean) => void)[] = [];
const abort = (keep = true) => {
aborted = true;
request?.abort?.();
nestedAborts.forEach(a => a());
keepOnAbort = keep;
request?.abort?.(keep);
nestedAborts.forEach(a => a(keep));
};
let promise: any;
@@ -411,7 +419,24 @@ ${a.system}`,
let tools: AiTool[] = options.tools || this.ai.options.llm?.tools || [];
const prompts: string[] = [];
let history = options.history || [];
if(message) history.push({role: 'user', content: message, timestamp: Date.now()});
const historyStart = history.length;
const files = options.files || [];
if(message || files.length) history.push({role: 'user', content: message || '', timestamp: Date.now()});
// Accumulate streamed text so it can be committed to history if aborted mid-generation
let partialText = '';
const onStream = options.stream;
const stream = (chunk: {text?: string, tool?: string, done?: true}) => {
if(chunk.text) partialText += chunk.text;
return onStream?.(chunk);
};
/** Commit (keep) or discard this turn's progress on abort, then throw */
const abortNow = (): never => {
if(keepOnAbort) { if(partialText) history.push({role: 'assistant', content: partialText, timestamp: Date.now()}); }
else history.splice(historyStart, history.length - historyStart);
throw Object.assign(new Error('Aborted'), {name: 'AbortError'});
};
// MCP
const mcp = options.mcp || this.ai.options?.llm?.mcp;
@@ -432,7 +457,7 @@ ${a.system}`,
// Agents
const agents = options.agents || this.ai.options?.llm?.agents;
const delegateState: {resp: string | null} = {resp: null};
if(agents?.length) tools.push(...this.setupAgent(agents, agents, history, nestedAborts, options._agentDepth || 0, delegateState));
if(agents?.length) tools.push(...this.setupAgent(agents, history, nestedAborts, options._agentDepth || 0, delegateState));
// Memory
const mem = MemoryManager.normalize(options.memory);
@@ -440,8 +465,8 @@ ${a.system}`,
const mems = mem.memory instanceof MemoryCache ? mem.memory.memories : mem.memory;
if(mems.length) {
if(mem.inject) {
const pool = 15; // candidates considered, cheap since only refs are listed
const budget = mem.maxTokens ?? 2000; // actual content injected
const pool = 15;
const budget = mem.maxTokens ?? 2000;
const relevant = await this.memoryManager.recollect(message, mem.memory, pool);
let used = 0;
@@ -480,16 +505,18 @@ Linked: ${makeUnique([...r.links, ...r.backlinks]).join(', ')}
}
}
if(aborted) throw Object.assign(new Error('Aborted'), {name: 'AbortError'});
if(aborted) abortNow();
// Files
const files = options.files || [];
const lastMsg = history[history.length - 1];
const originalContent = lastMsg?.content;
if(files.length && lastMsg?.role === 'user') {
const {text, images} = await this.resolveFiles(files);
const merged = text ? `${originalContent}\n\n${text}` : originalContent;
lastMsg.content = images.length
if(files.length && lastMsg?.role === 'user') lastMsg.files = files;
const restores: {msg: LLMMessage, content: any}[] = [];
for(const msg of history) {
if(msg.role !== 'user' || !msg.files?.length) continue;
const {text, images} = await this.resolveFiles(msg.files);
if(!text && !images.length) continue;
restores.push({msg, content: msg.content});
const merged = text ? [msg.content, text].filter(Boolean).join('\n\n') : msg.content;
msg.content = images.length
? [...images.map(i => ({type: 'image', mime: i.mime, data: i.data})), {type: 'text', text: merged}]
: merged;
}
@@ -497,13 +524,20 @@ Linked: ${makeUnique([...r.links, ...r.backlinks]).join(', ')}
const toolTimings = new Map<string, {duration: number, tps: number}>();
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 || '');
request = this.models[m].ask('', {...options, tools, system: prompts.filter(Boolean).join('\n\n')});
let resp = await request;
request = this.models[m].ask('', {...options, tools, stream, system: prompts.filter(Boolean).join('\n\n')});
let resp: string;
try {
resp = await request;
} catch(err: any) {
if(aborted) return abortNow();
throw err;
}
if(files.length && lastMsg?.role === 'user') lastMsg.content = originalContent;
// Strip the file injection shim
restores.forEach(({msg, content}) => msg.content = content);
// Capture meta (duration / tps)
for(const h of history) {
+437 -178
View File
@@ -1,23 +1,21 @@
import {MemoryNode, rebuildGraph} from './helpers.ts';
import {MemoryNode, patchGraph, rebuildGraph} from './helpers.ts';
import {LLMRequest, LLMMessage} from './llm.ts';
import {AiTool} from './tools.ts';
import {KDPoint, KDTree} from './kd-tree.ts';
import {KDTree} from './kd-tree.ts';
const FACTS_HEADING = '## Facts';
const GENERIC_TEMPLATE = `# {{Title}}
## Summary
## Details
## Related`;
const FACT_SIMILARITY_THRESHOLD = 0.62;
const PENDING_HEADING = '## Pending';
const TODO_HEADING = '## Todo list';
const TREE_TOMBSTONE_LIMIT = 0.25;
const ALIAS_MATCH_THRESHOLD = 0.55;
export type Memory = {
name: string;
description: string;
content: string;
embedding: number[];
titleEmbedding?: number[];
bodyEmbeddings?: number[][];
links: string[];
backlinks: string[];
}
@@ -25,6 +23,7 @@ export type Memory = {
type MemoryRef = {
name: string;
description: string;
distance?: number;
}
type FactBucket = {
@@ -32,19 +31,32 @@ type FactBucket = {
facts: string[];
}
type MemoryTask = {
/** Exact node name / new persistent entity path this task belongs to, or '' for a personal task with no entity (goes to the journal) */
subject: string;
task: string;
done: boolean;
}
type FactAgentResult = {
buckets: FactBucket[];
journal: string;
tasks: MemoryTask[];
}
function dedupeFacts(facts: string[]): string[] {
const seen = new Map<string, string>();
for (const f of facts) {
for(const f of facts) {
const clean = f.trim();
if (clean) seen.set(clean.toLowerCase(), clean);
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];
for(let i = 0; i < a.length; i++) {
dot += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
@@ -55,18 +67,34 @@ function cosineDistance(a: number[], b: number[]): number {
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)}))
.map(m => ({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);
.slice(0, limit);
}
async function embedMemoryFields(node: Memory, llm: any): Promise<void> {
const body = stripHeader(node.content);
const [titleE] = await llm.embedding(node.name.split('/').pop() || node.name);
const [descE] = await llm.embedding(node.description || '');
const bodyChunks = body ? await llm.embedding(body) : [];
if(titleE) node.titleEmbedding = titleE.embedding;
if(descE) node.embedding = descE.embedding;
node.bodyEmbeddings = bodyChunks.map((c: any) => c.embedding).filter(Boolean);
}
export function stripHeader(content: string): string {
return content.replace(/^---[\s\S]*?\n---\n?/, '').trimStart();
}
/** True if a task has no persistent entity of its own and belongs in the journal instead. */
function isPersonalTask(t: MemoryTask): boolean {
const s = (t.subject ?? '').trim().toLowerCase();
return !s || s === 'journal' || s.startsWith('journal/');
}
export class MemoryCache {
private tree!: KDTree<MemoryRef>;
private indexed = new Map<string, number[]>();
public memories: Memory[];
public nodes: MemoryNode[] = [];
@@ -74,50 +102,62 @@ export class MemoryCache {
constructor(memories: Memory[]) {
this.memories = memories;
this.tree = new KDTree<MemoryRef>(0);
this.rebuild();
}
private buildTree(): KDTree<MemoryRef> {
const embedded = this.memories.filter(m => m.embedding?.length);
if (!embedded.length) return new KDTree<MemoryRef>(0);
private syncTree(): void {
const current = new Set(this.memories.map(m => m.name));
const dims = embedded[0].embedding.length;
const points: KDPoint<MemoryRef>[] = embedded.map(m => ({
vector: m.embedding,
payload: {name: m.name, description: m.description},
}));
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);
}
}
return new KDTree<MemoryRef>(dims, 'cosine', points);
for(const mem of this.memories) {
if(!mem.embedding?.length || this.indexed.has(mem.name)) continue;
if(this.tree.dims === 0) this.tree = new KDTree<MemoryRef>(mem.embedding.length, 'cosine');
if(mem.embedding.length !== this.tree.dims) continue; // guard against embedding model/dim drift
this.tree.insert({vector: mem.embedding, payload: {name: mem.name, description: mem.description}});
this.indexed.set(mem.name, mem.embedding);
}
if(this.tree.tombstoneRatio > TREE_TOMBSTONE_LIMIT) this.tree.rebalance();
}
search(query: number[], limit: number): MemoryRef[] {
if (!this.tree || this.tree.dims === 0) return [];
return this.tree.knn(query, limit).map(r => r.point.payload);
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();
this.rebuild([memory]);
}
update(memory: Memory): void {
const idx = this.memories.findIndex(m => m.name === memory.name);
if (idx !== -1) this.memories[idx] = memory;
const existing = this.memories.find(m => m.name === memory.name);
if(existing) Object.assign(existing, memory);
else this.memories.push(memory);
this.rebuild();
this.rebuild([existing ?? memory]);
}
remove(name: string): void {
const idx = this.memories.findIndex(m => m.name === name);
if (idx !== -1) {
if(idx !== -1) {
this.memories.splice(idx, 1);
this.rebuild();
}
}
rebuild(): void {
this.nodes = rebuildGraph(this.memories);
this.tree = this.buildTree();
rebuild(changed?: Memory[]): void {
this.nodes = (changed?.length && this.nodes.length)
? patchGraph(this.memories, this.nodes, changed)
: rebuildGraph(this.memories);
this.syncTree();
}
}
@@ -134,9 +174,9 @@ class MemoryAccessor {
return this.list.find(m => m.name === name);
}
commit(): MemoryNode[] {
if (this.cache) {
this.cache.rebuild();
commit(changed?: Memory[]): MemoryNode[] {
if(this.cache) {
this.cache.rebuild(changed);
return this.cache.nodes;
}
return rebuildGraph(this.list);
@@ -153,7 +193,7 @@ class MemoryAccessor {
forget(name: string): boolean {
const idx = this.list.findIndex(m => m.name === name);
if (idx === -1) return false;
if(idx === -1) return false;
this.list.splice(idx, 1);
this.commit();
return true;
@@ -161,11 +201,8 @@ class MemoryAccessor {
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;
}));
if(!missing.length) return 0;
await Promise.all(missing.map(node => embedMemoryFields(node, llm)));
this.commit();
return missing.length;
}
@@ -185,13 +222,13 @@ export type MemoryOptions = {
}
export class MemoryManager {
private recentlyTouched = new Map<string, number>();
private mergeLock: Promise<any> = Promise.resolve();
private queues = new Map<string, {
dirty: boolean,
request: {abort?: () => void} | null,
task: Promise<void>,
}>();
private recentlyTouched = new Map<string, number>();
tools = {
forget: (memories: Memory[] | MemoryCache): AiTool => ({
@@ -210,11 +247,11 @@ export class MemoryManager {
name: 'memory_recall',
description: 'Read the full content of a memory document',
args: {
name: {type: 'string', description: 'Exact memory name', required: true},
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';
const mem = new MemoryAccessor(memories).find(args.name);
if(!mem) return 'Document not found';
this.touch(mem.name);
return mem.content;
},
@@ -228,7 +265,7 @@ export class MemoryManager {
limit: {type: 'number', description: 'Number of memories to return', default: 1},
},
fn: async ({query, limit}) => {
const mem = await this.recollect(query, memories, 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(', ')}
@@ -242,68 +279,121 @@ ${m.content}
constructor(private llm: any) {}
static normalize(m?: Memory[] | MemoryCache | MemoryOptions) {
if (!m) return null;
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);
private stage(node: Memory, block: string): void {
if(!node.content) {
const title = node.name.split('/').pop() ?? node.name;
node.content = this.touchHeader(node, `# ${title}\n`);
}
const body = stripHeader(node.content);
const bullets = facts.map(f => `- ${f}`).join('\n');
const idx = body.indexOf(FACTS_HEADING);
const idx = body.indexOf(PENDING_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)}`;
? `${body.trimEnd()}\n\n${PENDING_HEADING}\n${block}\n`
: `${body.slice(0, idx + PENDING_HEADING.length)}\n${block}${body.slice(idx + PENDING_HEADING.length)}`;
node.content = this.touchHeader(node, newBody);
}
private ensureDoc(node: Memory): void {
if (node.content) return;
const title = node.name.split('/').pop() ?? node.name;
node.content = this.touchHeader(node, `# ${title}\n`);
private resolveSubject(subject: string, store: MemoryAccessor): string {
function normalize(name: string): string {
return name.trim().toLowerCase().replace(/\s+/g, ' ');
}
const trimmed = subject.trim();
const exact = store.find(trimmed);
if(exact) return exact.name;
const normalized = normalize(trimmed);
const caseInsensitive = store.list.find(m => normalize(m.name) === normalized);
if(caseInsensitive) return caseInsensitive.name;
const root = trimmed.split('/')[0];
const leaf = trimmed.split('/').slice(1).join('/') || trimmed;
const candidates = store.list.filter(m => m.name.split('/')[0] === root && m.name !== trimmed);
if(!candidates.length) return trimmed;
const leaves = candidates.map(m => m.name.split('/').slice(1).join('/') || m.name);
const probe = leaves.length > 1 ? leaves : [...leaves, ''];
const {max, similarities} = this.llm.fuzzyMatch(leaf, ...probe);
if(max >= ALIAS_MATCH_THRESHOLD) return candidates[similarities.indexOf(max)].name;
return trimmed;
}
private async factAgent(conversation: string, store: MemoryAccessor, options: LLMRequest, weekKey: string): Promise<FactBucket[]> {
private async factAgent(conversation: string, store: MemoryAccessor, options: LLMRequest): Promise<FactAgentResult> {
const ghosts = store.ghosts();
const response = await this.llm.ask(conversation, {
model: options.model,
temperature: 0.2,
system: `You are a fact extractor to build obsidian knowledge vaults.
Analyze this conversation and extract facts worth remembering long-term.
system: `Turn this conversation into a persistent memory file by extracting information into organized bullet points
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
Think of this like an Obsidian vault with a clear division of responsibility:
- The JOURNAL is a timeline. It answers "what happened, and when" and is the only place with a sense of time.
- ENTITY DOSSIERS are a wiki. They answer "what is currently true about this subject", with no sense of time — only current state.
- Never blur the two: a one-off event, conversation, or debugging session is a journal entry, not an entity, even if it's detailed.
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"
1. Journal Log
- A chronological, skimmable log of what actually happened: real discussions, decisions made, progress on projects, problems worked through
- This is NOT a transcript, and it is NOT a step-by-step record, its a compressed log of notable events & developments
- One line per development is usually enough: what was worked on and the outcome, not the blow-by-blow of how
- Skip small talk and trivial exchanges entirely. Skip anything that's purely a todo item (goes in Todo Tasks) or a durable fact about a subject (goes in Entity Dossiers)
2. Todo Tasks
- Extract concrete tasks the user says need to be done, should be done, or were completed
- Return the task text and whether it is still todo or is done
- A completed task should be marked done, not recreated as a new todo
- Only extract actionable tasks, not general goals or observations
- Assign each task a subject:
- If the task belongs to a persistent entity (a project, a class, etc.), use that entity's exact node name, or a new entity path if it doesn't exist yet
- If it's a personal/life task with no entity of its own (reach out to someone, reply to an email, pay a bill, etc.), leave subject as an empty string — it belongs in the journal, not a new document
3. Entity Dossiers
- Detailed dossiers with all information regarding a subject
- Record the final/end state, not intermediate changes
- Ignore assistant claims, guesses, greetings, or temporary details
- NEVER create a document for something that's only meaningful as a point in time — a single conversation, a one-off decision, a debugging session, a date. That's a journal entry, not an entity
- identify its HOME ENTITY:
- The HOME ENTITY name should always be a [abstract|pro]noun
- The grammatical subject/owner of the fact is the strongest clue
- Always preference an existing entity over creating a new one
- New child entities are appropriate only when they are themselves distinct persistent entities
- A document represents a persistent entity, not a topic, feature, bug, event, decision, setting, or conversation fragment
- Put project facts under the project they belong to, person facts under the person, etc
Example Entity Naming Convention:
- Projects/[Name]
- People/[Name]
- History/[Name]
- Science/[Name]
- [Subject]/[Name]
- Class/[Name]/[Chapter]
Use [[WikiLinks]] to express relationships between entities. NEVER create documents just to hold relationships
Keep journal material in the journal; don't turn journal events into entities unless they represent something persistent
Available nodes:
- Journal
${this.listNodes(store.list).filter(n => !n.name.includes('Journal')).map(n => `- ${n.name}: ${n.description}`).join('\n') || 'None yet.'}
${this.listNodes(store.list).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'},
},
journal: {type: 'string', description: 'Short day-to-day recap; empty if nothing happened.', required: false},
tasks: {
type: 'array', description: 'Concrete tasks mentioned or completed in the conversation.', required: false, items: {
type: 'object', items: {
subject: {type: 'string', description: 'Exact node name / new persistent entity path this task belongs to, or an empty string if this is a personal task with no entity of its own (those go in the journal)', required: true},
task: {type: 'string', description: 'Concise actionable task', required: true},
done: {type: 'boolean', description: 'Whether the task is completed', required: true},
},
}
},
buckets: {
type: 'array', description: 'Groups of facts to remember; empty array if nothing worth storing.', items: {
type: 'object', items: {
subject: {type: 'string', description: 'Exact node name or new persistent entity path', required: true},
facts: {type: 'array', description: 'Facts to store here', items: {type: 'string'}},
},
},
},
@@ -312,17 +402,20 @@ ${ghosts.length ? `${ghosts.map(g => `- ${g}: (Ghost)`).join('\n')}` : ''}`,
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 subject = 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}));
return {
buckets: buckets.entries().toArray().map(([subject, facts]) => ({subject, facts})),
journal: (response.journal ?? '').trim(),
tasks: response.tasks ?? [],
};
}
private getWeekMonday(date: Date = new Date()): string {
private 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;
@@ -330,14 +423,92 @@ ${ghosts.length ? `${ghosts.map(g => `- ${g}: (Ghost)`).join('\n')}` : ''}`,
return d.toISOString().slice(0, 10);
}
private journalDescription(journalName?: string): string {
const start = journalName?.split('/').pop() || this.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}`;
}
private getIncompleteTodos(content: string): string[] {
const body = stripHeader(content);
const match = body.match(/## Todo list\n([\s\S]*?)(?=\n## |$)/i);
if(!match) return [];
return match[1].split('\n')
.map(line => line.match(/^\s*-\s*\[([ xX])\]\s+(.+?)\s*$/))
.filter((m): m is RegExpMatchArray => !!m && m[1].toLowerCase() !== 'x')
.map(m => m[2].trim());
}
private listNodes(memories: Memory[]): MemoryRef[] {
return memories.map(m => ({name: m.name, description: m.description}));
}
private async mergeAgent(node: Memory, memories: Memory[] | MemoryCache, options: LLMRequest): Promise<Memory | null> {
function factSimilarity(a: Memory, b: Memory): number {
if(!a.bodyEmbeddings?.length || !b.bodyEmbeddings?.length) return 0;
let best = 0;
for(const av of a.bodyEmbeddings) {
for(const bv of b.bodyEmbeddings) best = Math.max(best, 1 - cosineDistance(av, bv));
}
return best;
}
if(!node.embedding?.length || node.name.startsWith('Journal/')) return null;
const store = new MemoryAccessor(memories);
const candidates = store.list
.filter(m => m.name !== node.name && !m.name.startsWith('Journal/'))
.filter(m => factSimilarity(node, m) >= FACT_SIMILARITY_THRESHOLD);
if(!candidates.length) return null;
const closest = candidates.sort((a, b) => factSimilarity(node, b) - factSimilarity(node, a))[0];
const result = await this.llm.ask('', {
model: options.model,
temperature: 0.3,
schema: {
aContent: {type: 'string', description: 'Updated document A body in markdown, without frontmatter.', required: true},
bContent: {type: 'string', description: 'Updated document B body in markdown, without frontmatter.', required: true},
},
system: `Maintain these two persistent knowledge-base documents like a wiki.
Do NOT merge, rename, or delete either document. Both represent entities that should remain independently addressable.
The documents were selected because their facts may overlap. Your job is to reconcile duplicated information and connect the documents:
- Decide which document is the HOME for each duplicated fact.
- Keep the authoritative copy in that home document.
- In the other document, replace the information with a short preamble and [[WikiLink]] to the home entity explaining the relationship.
- If the documents are distinct entities but merely related, keep their distinct facts and add useful [[WikiLinks]] between them.
- Do not delete useful entity-specific facts just because they are similar.
- Do not invent relationships or facts.
- Preserve useful history, technical specifics, structure, and existing [[WikiLinks]].
- Most current truth wins when facts conflict.
- Keep both documents concise and information-dense.
- No frontmatter, preamble, filler, or AI commentary.
Document A ("${node.name}"):
\`\`\`markdown
${stripHeader(node.content)}
\`\`\`
Document B ("${closest.name}"):
\`\`\`markdown
${stripHeader(closest.content)}
\`\`\``,
});
const a = store.find(node.name);
const b = store.find(closest.name);
if(!a || !b || !result?.aContent || !result?.bContent) return null;
a.content = this.touchHeader(a, result.aContent);
b.content = this.touchHeader(b, result.bContent);
await Promise.all([embedMemoryFields(a, this.llm), embedMemoryFields(b, this.llm)]);
return a;
}
private reconcile(node: Memory, memories: Memory[] | MemoryCache, options: LLMRequest): Promise<void> {
const key = node.name;
const existing = this.queues.get(key);
if (existing) {
if(existing) {
existing.dirty = true;
existing.request?.abort?.();
return existing.task;
@@ -345,81 +516,102 @@ ${ghosts.length ? `${ghosts.map(g => `- ${g}: (Ghost)`).join('\n')}` : ''}`,
const entry = {dirty: false, request: null, task: Promise.resolve()};
this.queues.set(key, entry);
const store = this.access(memories);
const store = new MemoryAccessor(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();
});
let current = node;
try {
do {
entry.dirty = false;
await this.docAgent(current, store.list, options, entry);
this.mergeLock = this.mergeLock.then(() => this.mergeAgent(current, memories, options));
const result = await this.mergeLock;
if(result) current = result;
} while(entry.dirty);
} finally {
store.commit([node]);
this.queues.delete(key);
}
})();
return entry.task;
}
private async docAgent(node: Memory, memories: Memory[], options: LLMRequest, entry: {request: {abort?: () => void} | null}): Promise<void> {
if(!memories.includes(node)) return;
const currentBody = stripHeader(node.content);
let update;
try {
for (let i = 0; i < 2 && !update?.content; i++) {
const request = this.llm.ask(currentBody, {
model: options.model,
temperature: 0.3,
schema: {
description: {type: 'string', description: 'One-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.
const journal = node.name.startsWith('Journal/');
const system = (journal
? `You maintain one persistent journal document
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.
Rewrite the ENTIRE journal, folding "## Pending" into the existing content removing the heading
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}
\`\`\`
Journal design:
- Preserve the chronological daily log
- Maintain a single \`## Todo list\` section for this entity: reconcile tasks semantically (merge equivalent tasks, remove duplicates, preserve incomplete tasks, check off completed ones), and keep it distinct from the narrative/fact sections
- Group information by day under a date heading
- Keep journal entries high level and concise: what was worked on and the outcome, not a step-by-step record of how — that detail lives in conversation history, not here
- Use [[WikiLinks]] for persistent entities; don't turn ordinary journal events into entities
- No frontmatter, preamble, filler, or AI commentary`
: `You maintain one persistent knowledge-base entity document
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
Rewrite the ENTIRE document, folding "## Pending" into the existing content. Remove the Pending section when finished.
Other nodes in the vault (link to these instead of duplicating their content):
Document design:
- The document represents one persistent entity. Keep information about that entity together and organized into sections
- Merge any pending information in, newest fact wins conflicts; remove redundant content
- Maintain a single \`## Todo list\` section for this entity: reconcile tasks semantically (merge equivalent tasks, remove duplicates, preserve incomplete tasks, check off completed ones), and keep it distinct from the narrative/fact sections
- Let the structure fit the entity; there is NO fixed template
- Add headings only when they meaningfully organize recurring information; don't create headings for one-off facts
- Keep the document concise and information-dense without removing useful technical specifics
- Current truth wins when facts conflict. Preserve older conflict as context, only when it adds useful meaning
- No frontmatter, preamble, filler, or AI commentary`) + `
Available nodes to link to:
${this.listNodes(memories).filter(n => n.name !== node.name).map(n => n.name).join(', ') || 'none'}
Current document:
\`\`\`markdown
${currentBody}
\`\`\``,
\`\`\``;
let update;
try {
for(let i = 0; i < 2 && !update?.content; i++) {
const request = this.llm.ask(currentBody, {
model: options.model,
temperature: 0.3,
schema: {
description: {type: 'string', description: 'One factual sentence describing the document\'s ENTIRE SUBJECT MATTER — for use as a search/merge fingerprint', required: true},
content: {type: 'string', description: 'Rewritten document body in markdown, without the frontmatter block', required: true},
},
system,
});
entry.request = request;
update = await request;
}
} catch (err: any) {
if (err?.name === 'AbortError') return;
} 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';
if(!update?.content) return;
node.description = node.name.startsWith('Journal/') ? this.journalDescription(node.name) : node.name !== 'People/User' ? update.description.replaceAll(/[\n:]/g, '') : '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;
await embedMemoryFields(node, this.llm);
}
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};
if(!match) return {fm: new Map(), body: content};
const fm = new Map<string, string>();
for (const line of match[1].split('\n')) {
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());
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]};
}
@@ -427,19 +619,19 @@ ${currentBody}
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('description', (node.name.startsWith('Journal/') ? this.journalDescription(node.name) : node.description) || 'Persistent memory document');
fm.set('modified', new Date().toISOString());
return this.writeFrontmatter(fm, body);
return this.writeFrontmatter(fm, stripHeader(body));
}
private writeFrontmatter(fm: Map<string, string>, body: string): string {
const lines = [...fm.entries()].map(([k, v]) => `${k}: ${v}`);
const lines = [...fm.entries()].map(([k, v]) => `${k}: ${JSON.stringify(String(v).replace(/\s+/g, ' ').trim())}`);
return `---\n${lines.join('\n')}\n---\n\n${body.trimStart()}`;
}
decay() {
for (const [name, ttl] of this.recentlyTouched) {
if (ttl <= 1) this.recentlyTouched.delete(name);
for(const [name, ttl] of this.recentlyTouched) {
if(ttl <= 1) this.recentlyTouched.delete(name);
else this.recentlyTouched.set(name, ttl - 1);
}
}
@@ -449,30 +641,43 @@ ${currentBody}
}
forget(name: string, memories: Memory[] | MemoryCache): boolean {
return this.access(memories).forget(name);
return new MemoryAccessor(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 [];
function rank(query: number[], candidates: Memory[], limit: number): Memory[] {
const scored = candidates.map(m => {
const titleSim = m.titleEmbedding?.length ? 1 - cosineDistance(query, m.titleEmbedding) : 0;
const descSim = m.embedding?.length ? 1 - cosineDistance(query, m.embedding) : 0;
const bodySim = m.bodyEmbeddings?.length
? Math.max(...m.bodyEmbeddings.map(b => 1 - cosineDistance(query, b)))
: 0;
return {memory: m, score: titleSim * 0.5 + descSim * 0.35 + bodySim * 0.15};
});
return scored.sort((a, b) => b.score - a.score).slice(0, limit).map(s => s.memory);
}
const store = new MemoryAccessor(memories);
if(!store.list.length) return [];
await store.backfillEmbeddings(this.llm);
const [e] = await this.llm.embedding(query);
if (!e) return [];
if(!e) return [];
const vectorResults = store.search(e.embedding, limit);
const found = new Set<string>(vectorResults.map(r => r.name));
const pool = store.search(e.embedding, Math.max(limit * 3, limit));
const poolMemories = pool.map(r => store.find(r.name)).filter((m): m is Memory => !!m);
const ranked = rank(e.embedding, poolMemories, limit);
const found = new Set<string>(ranked.map(m => m.name));
if (graphDepth > 0) {
if(graphDepth > 0) {
let frontier = [...found];
for (let depth = 0; depth < graphDepth && frontier.length; depth++) {
for(let depth = 0; depth < graphDepth && frontier.length; depth++) {
const next: string[] = [];
for (const name of frontier) {
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)) {
if(!node) continue;
for(const link of node.links) {
if(!found.has(link) && store.find(link)) {
found.add(link);
next.push(link);
}
@@ -482,42 +687,96 @@ ${currentBody}
}
}
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);
const rankedOrder = ranked.map(m => m.name);
const graphExpansions = [...found].filter(n => !rankedOrder.includes(n));
return [...rankedOrder, ...graphExpansions].map(n => store.find(n)!).filter(Boolean);
}
async memorize(history: LLMMessage[], memories: Memory[] | MemoryCache, options: LLMRequest): Promise<Memory[]> {
const conversation = history
.filter(h => h.role === 'user' || h.role === 'assistant')
.map(h => `[${h.role}]: ${h.content}`).join('\n\n').trim();
if (!conversation) return [];
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 store = new MemoryAccessor(memories);
const {buckets, journal, tasks} = await this.factAgent(conversation, store, options);
const touched: Memory[] = [];
for (const {subject, facts} of buckets) {
let node = store.find(subject);
if (!node) {
node = {name: subject, description: '', content: '', embedding: [], links: [], backlinks: []};
const personalTasks = tasks.filter(isPersonalTask);
const entityTasks = tasks.filter(t => !isPersonalTask(t));
if(journal || personalTasks.length) {
const journalName = `Journal/${this.getWeekStart()}`;
let jnode = store.find(journalName);
const isNew = !jnode;
if(!jnode) {
jnode = {
name: journalName,
description: this.journalDescription(),
content: '',
embedding: [],
links: [],
backlinks: [],
};
store.list.push(jnode);
}
const blocks: string[] = [];
if(journal) blocks.push(`### ${new Date().toISOString().slice(0, 10)}\n${journal}`);
if(isNew) {
const previousDate = new Date(`${this.getWeekStart()}T00:00:00Z`);
previousDate.setUTCDate(previousDate.getUTCDate() - 7);
const previous = store.find(`Journal/${previousDate.toISOString().slice(0, 10)}`);
if(previous) {
const todos = this.getIncompleteTodos(previous.content);
if(todos.length) blocks.push(`${TODO_HEADING}\n${todos.map(task => `- [ ] ${task}`).join('\n')}`);
}
}
if(personalTasks.length) blocks.push(`${TODO_HEADING}\n${personalTasks.map(task => `- [${task.done ? 'x' : ' '}] ${task.task}`).join('\n')}`);
if(blocks.length) this.stage(jnode, blocks.join('\n\n'));
touched.push(jnode);
}
const entityStaging = new Map<string, {facts: string[], tasks: MemoryTask[]}>();
for(const {subject, facts} of buckets) {
const resolved = this.resolveSubject(subject, store);
const entry = entityStaging.get(resolved) ?? {facts: [], tasks: []};
entry.facts.push(...facts);
entityStaging.set(resolved, entry);
}
for(const task of entityTasks) {
const resolved = this.resolveSubject(task.subject, store);
const entry = entityStaging.get(resolved) ?? {facts: [], tasks: []};
entry.tasks.push(task);
entityStaging.set(resolved, entry);
}
for(const [resolved, {facts, tasks: subjectTasks}] of entityStaging) {
let node = store.find(resolved);
if(!node) {
node = {name: resolved, description: 'Persistent memory document', 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);
const blocks: string[] = [];
if(facts.length) blocks.push(facts.map(f => `- ${f}`).join('\n'));
if(subjectTasks.length) blocks.push(`${TODO_HEADING}\n${subjectTasks.map(t => `- [${t.done ? 'x' : ' '}] ${t.task}`).join('\n')}`);
if(blocks.length) this.stage(node, blocks.join('\n\n'));
touched.push(node);
}
if (touched.length) {
store.commit();
await Promise.all(touched.map(async node => {
await embedMemoryFields(node, this.llm);
this.touch(node.name);
}));
if(touched.length) {
store.commit(touched);
(pending as any).content = `Saved to ${touched.map(n => `[[${n.name}]]`).join(', ')}`;
await Promise.all(touched.map(node => this.reconcile(node, memories, options).catch(() => {})));
Promise.all(touched.map(node => this.reconcile(node, memories, options).catch(() => {})));
} else {
(pending as any).content = 'Nothing worth remembering.';
}
@@ -526,9 +785,9 @@ ${currentBody}
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));
async reconcileAll(memories: Memory[] | MemoryCache, options: LLMRequest, scope: 'touched' | 'all' = 'touched'): Promise<void> {
const store = new MemoryAccessor(memories);
const targets = scope === 'all' ? store.list : store.list.filter(m => m.content.includes(PENDING_HEADING));
await Promise.all(targets.map(node => this.reconcile(node, memories, options)));
store.commit();
}
+118 -41
View File
@@ -36,21 +36,49 @@ export class OpenAi extends LLMProvider {
private toWire(history: LLMMessage[], system?: string): any[] {
const wire: any[] = [];
if(system) wire.push({role: 'system', content: system});
for(const h of history) {
if(h.role === 'tool') {
wire.push({
role: 'assistant',
content: null,
tool_calls: [{id: h.id, type: 'function', function: {name: h.name, arguments: JSON.stringify(h.args)}}],
}, {
role: 'tool',
tool_call_id: h.id,
content: h.error || h.content || '',
});
} else {
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({
role: 'assistant',
content: null,
tool_calls: calls
});
wire.push(...results);
i--;
}
return wire;
}
@@ -60,13 +88,12 @@ export class OpenAi extends LLMProvider {
if(!options.history) options.history = [];
const history = options.history;
if(message) history.push({role: 'user', content: message, timestamp: Date.now()});
const tools = options.tools || this.ai.options.llm?.tools || [];
const requestParams: any = {
model: options.model || this.model,
stream: !!options.stream,
max_completion_tokens: options.maxTokens || this.ai.options.llm?.maxTokens || undefined,
temperature: options.temperature || this.ai.options.llm?.temperature || undefined,
max_completion_tokens: options.maxTokens ?? this.ai.options.llm?.maxTokens,
temperature: options.temperature ?? this.ai.options.llm?.temperature,
tools: tools.map(t => ({
type: 'function',
function: {
@@ -74,8 +101,12 @@ export class OpenAi extends LLMProvider {
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]) : []
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])
: []
}
}
}))
@@ -83,60 +114,106 @@ 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}};
requestParams.response_format = {
type: 'json_schema',
json_schema: {name: 'response', strict: true, schema}
};
}
if(options.stream) requestParams.stream_options = {include_usage: true};
try {
let terminal = false;
let iteration = 0;
do {
iteration++;
requestParams.messages = this.toWire(history.filter(h => h.role !== 'system'), options.system);
const callStart = Date.now();
const resp: any = await this.tokenPool.run(token => this.getClient(token).chat.completions.create(requestParams)).catch(err => {
const resp: any = await this.tokenPool.run(token =>
this.getClient(token).chat.completions.create(requestParams)
).catch(err => {
err.message += `\n\nMessages:\n${JSON.stringify(requestParams.messages, null, 2)}`;
throw err;
});
let usage: any, msg: any = {content: '', tool_calls: []};
let usage: any;
let finishReason: string | undefined;
let msg: any = {content: '', tool_calls: []};
let streamedChars = 0;
if(options.stream) {
for await (const chunk of resp) {
if(controller.signal.aborted) break;
if(chunk.usage) usage = chunk.usage;
if(chunk.choices[0]?.delta?.content) {
msg.content += chunk.choices[0].delta.content;
options.stream({text: chunk.choices[0].delta.content});
}
if(chunk.choices[0]?.delta?.tool_calls) {
for(const deltaTC of chunk.choices[0].delta.tool_calls) {
const existing = msg.tool_calls.find((tc: any) => tc.index === deltaTC.index);
if(existing) {
let streamCompleted = false;
try {
for await (const chunk of resp) {
if(controller.signal.aborted) break;
if(chunk.usage) usage = chunk.usage;
const choice = chunk.choices?.[0];
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(choice?.delta?.tool_calls) {
for(const deltaTC of choice.delta.tool_calls) {
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.function?.name) existing.function.name = deltaTC.function.name;
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 {
usage = resp.usage;
finishReason = resp.choices[0].finish_reason;
msg = resp.choices[0].message;
}
const duration = Date.now() - callStart;
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 || [];
if(toolCalls.length && !controller.signal.aborted) {
if(msg.content?.trim()) history.push({role: 'assistant', content: msg.content.trim(), timestamp: Date.now(), duration, tps});
const entries = toolCalls.map((tc: any) => {
const entry: any = {role: 'tool', id: tc.id, name: tc.function.name, args: JSONAttemptParse(tc.function.arguments, {}), content: undefined, timestamp: Date.now()};
const entry: any = {
role: 'tool',
id: tc.id,
name: tc.function.name,
args: JSONAttemptParse(tc.function.arguments, {}),
content: undefined,
timestamp: Date.now()
};
history.push(entry);
return {tc, entry};
});
@@ -144,12 +221,13 @@ export class OpenAi extends LLMProvider {
await Promise.all(entries.map(async ({tc, entry}: any) => {
const tool = tools.find(findByProp('name', tc.function.name));
if(options.stream) options.stream({tool: tc.function.name});
if(!tool) { entry.error = 'Tool not found'; return; }
if(!tool) return entry.error = 'Tool not found';
try {
const toolStream = options.stream && ((chunk: any) => {
if(chunk.done) { terminal = true; return; }
if(chunk.done) return;
options.stream!(chunk);
});
const result = await tool.fn(entry.args, toolStream, this.ai, tc.id);
entry.content = typeof result === 'object' ? JSONSanitize(result) : result;
} catch(err: any) {
@@ -164,7 +242,6 @@ export class OpenAi extends LLMProvider {
} while(!terminal && !controller.signal.aborted);
if(options.stream) options.stream({done: true});
const turnStart = history.map(h => h.role).lastIndexOf('user');
const finalContent = history.slice(turnStart + 1).reduce((str, h) => h.role === 'assistant' ? str + (h.content || '') : str, '').trim();
res(options.schema ? JSONAttemptParse(finalContent, finalContent) : finalContent);