More memory fixes
All checks were successful
Publish Library / Build NPM Project (push) Successful in 43s
Publish Library / Tag Version (push) Successful in 14s

This commit is contained in:
2026-07-29 22:11:09 -04:00
parent 14f6cdd313
commit 8dfcd06752
9 changed files with 426 additions and 376 deletions

View File

@@ -3,6 +3,8 @@ export * from './antrhopic';
export * from './audio';
export * from './llm';
export * from './memory';
export * from './memory-cache';
export * from './memory-graph';
export * from './open-ai';
export * from './provider';
export * from './tools';

View File

@@ -1,12 +1,13 @@
import {AbortablePromise, Ai} from './ai.ts';
import {Anthropic} from './antrhopic.ts';
import {MemoryCache} from './memory-cache.ts';
import {OpenAi} from './open-ai.ts';
import {LLMProvider} from './provider.ts';
import {AiTool, AiToolArg} from './tools.ts';
import {fileURLToPath} from 'url';
import {dirname, join} from 'path';
import {spawn} from 'node:child_process';
import {Memory, MemoryCache, MemoryManager} from './memory.ts';
import {Memory, MemoryManager} from './memory.ts';
export type AnthropicConfig = {proto: 'anthropic', token: string};
export type OpenAiConfig = {proto: 'openai', host?: string, token: string};
@@ -143,7 +144,7 @@ class LLM {
return {
prompt: `You have access to the following skill documents, use \`read_skill\` to access them:\n${list}`,
tools: [{
name: 'read_skill',
name: 'skill_read',
description: 'Read the full content of a skill/knowledge document',
args: {
name: {type: 'string', description: 'Exact skill name', required: true}
@@ -167,8 +168,14 @@ class LLM {
}
const m = options.model || this.defaultModel;
if(!this.models[m]) throw new Error(`Model does not exist: ${m}`);
let abort = () => {};
return Object.assign(new Promise<string>(async res => {
let request: AbortablePromise<string> | null = null;
let aborted = false;
const abort = () => {
aborted = true;
request?.abort?.();
};
const promise = (async () => {
let tools: AiTool[] = options.tools || this.ai.options.llm?.tools || [];
const prompts: string[] = [];
let history = options.history || [];
@@ -192,22 +199,27 @@ class LLM {
// Memory
if (options.memory) {
const mems = options.memory instanceof MemoryCache ? options.memory.memories : options.memory;
const relevant = await this.memoryManager.recollect(message, options.memory, 5);
prompts.unshift(`You have access to the following memory files:
${mems.map(m => `- ${m.name}: ${m.description}`).join('\n')}
${relevant.length ? `
Relevant memories have been preloaded:
${relevant.map(r => `
**${r.name}**
${r.description}
${r.content}
`).join('\n---\n')}
` : ''}`.trim());
tools.push(this.memoryManager.tools.read(options.memory));
if(mems.length) {
const relevant = await this.memoryManager.recollect(message, options.memory, 5);
prompts.unshift(`You have access to the following memory files:
${mems.map(m => `- ${m.name}: ${m.description}`).join('\n')}
${relevant.length ? `
Relevant memories have been preloaded:
${relevant.map(r => `
**${r.name}**
${r.description}
${r.content}
`).join('\n---\n')}
` : ''}`.trim());
tools.push(this.memoryManager.tools.read(options.memory));
}
}
if(aborted) throw Object.assign(new Error('Aborted'), {name: 'AbortError'});
prompts.unshift(options.system || this.ai.options.llm?.system || '');
const resp = await this.models[m].ask(message, {...options, tools, system: prompts.filter(Boolean).join('\n\n')});
request = this.models[m].ask(message, {...options, tools, system: prompts.filter(Boolean).join('\n\n')});
const resp = await request;
// Trim memory injections from history
if(options.memory) {
@@ -221,8 +233,10 @@ ${r.content}
if(options.history) options.history.splice(0, options.history.length, ...compressed);
}
return res(resp);
}), {abort});
return resp;
})();
return Object.assign(promise, {abort});
}
/**

59
src/memory-cache.ts Normal file
View File

@@ -0,0 +1,59 @@
import {KDPoint, KDTree} from './kd-tree.ts';
import {Memory, MemoryRef} from './memory.ts';
export class MemoryCache {
private tree: KDTree<MemoryRef>;
public memories: Memory[];
get length() { return this.memories.length; }
constructor(memories: Memory[]) {
this.memories = memories;
this.tree = this.buildTree();
}
private buildTree(): KDTree<MemoryRef> {
const embedded = this.memories.filter(m => m.embedding?.length);
if (!embedded.length) return new KDTree<MemoryRef>(0);
const dims = embedded[0].embedding.length;
const points: KDPoint<MemoryRef>[] = embedded.map(m => ({
vector: m.embedding,
payload: {name: m.name, description: m.description},
}));
return new KDTree<MemoryRef>(dims, 'cosine', points);
}
search(query: number[], limit: number): MemoryRef[] {
const results = this.tree.knn(query, limit);
return results.map(r => r.point.payload);
}
add(memory: Memory): void {
this.memories.push(memory);
this.rebuild();
}
update(memory: Memory): void {
const idx = this.memories.findIndex(m => m.name === memory.name);
if (idx !== -1) {
this.memories[idx] = memory;
} else {
this.memories.push(memory);
}
this.rebuild();
}
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.tree = this.buildTree();
}
}

69
src/memory-graph.ts Normal file
View File

@@ -0,0 +1,69 @@
import {MemoryCache} from './memory-cache.ts';
import {extractMetadata, Memory, MemoryNode} from './memory.ts';
export function buildMemoryGraph(memories: Memory[] | MemoryCache): MemoryNode[] {
const mems = memories instanceof MemoryCache ? memories.memories : memories;
const nameSet = new Set(mems.map(m => m.name));
const ghosts = new Set<string>();
const nodes: MemoryNode[] = mems.map(m => {
const {links, backlinks} = extractMetadata(m.content);
return {
name: m.name,
missing: false,
links,
backlinks,
};
});
for (const node of nodes) {
for (const link of node.links) {
if (!nameSet.has(link)) ghosts.add(link);
}
}
return [
...nodes,
...[...ghosts].map(name => ({
name,
missing: true,
links: [],
backlinks: nodes
.filter(n => n.links.includes(name))
.map(n => n.name),
}))
];
}
export function renderMemoryGraph(nodes) {
if (!nodes.length) return 'No memories yet.';
const groups = new Map();
for (const node of nodes) {
const [prefix, ...rest] = node.name.split('/');
const group = rest.length ? prefix : 'Root';
const label = rest.length ? rest.join('/') : node.name;
if (!groups.has(group)) groups.set(group, []);
groups.get(group).push({...node, label});
}
const ghostCount = nodes.filter(n => n.missing).length;
const lines = [`Memory Graph (${nodes.length} nodes, ${ghostCount} ghost${ghostCount === 1 ? '' : 's'})`, ''];
for (const group of [...groups.keys()].sort()) {
const items = groups.get(group).sort((a, b) => a.label.localeCompare(b.label));
lines.push(`${group}/`);
items.forEach((n, i) => {
const last = i === items.length - 1;
const branch = last ? '└─' : '├─';
const pad = last ? ' ' : '│ ';
const tag = n.missing ? ' (ghost)' : '';
lines.push(` ${branch} ${n.label}${tag}`);
if (n.links.length) lines.push(` ${pad}${n.links.join(', ')}`);
if (n.backlinks.length) lines.push(` ${pad}${n.backlinks.join(', ')}`);
});
lines.push('');
}
return lines.join('\n').trimEnd();
}

View File

@@ -1,6 +1,6 @@
import {LLMRequest, LLMMessage} from './llm.ts';
import {MemoryCache} from './memory-cache.ts';
import {AiTool} from './tools.ts';
import {KDTree, KDPoint} from './kd-tree.ts';
export type Memory = {
name: string;
@@ -9,15 +9,14 @@ export type Memory = {
embedding: number[];
}
type MemoryRef = {
export type MemoryRef = {
name: string;
description: string;
}
type FactBucket = {
export type FactBucket = {
subject: string;
facts: string[];
isNew: boolean;
}
export type MemoryNode = {
@@ -27,41 +26,8 @@ export type MemoryNode = {
backlinks: string[];
}
export function buildMemoryGraph(memories: Memory[] | MemoryCache): MemoryNode[] {
const mems = memories instanceof MemoryCache ? memories.memories : memories;
const nameSet = new Set(mems.map(m => m.name));
const ghosts = new Set<string>();
const nodes: MemoryNode[] = mems.map(m => {
const {links, backlinks} = extractMetadata(m.content);
return {
name: m.name,
missing: false,
links,
backlinks,
};
});
for (const node of nodes) {
for (const link of node.links) {
if (!nameSet.has(link)) ghosts.add(link);
}
}
return [
...nodes,
...[...ghosts].map(name => ({
name,
missing: true,
links: [],
backlinks: nodes
.filter(n => n.links.includes(name))
.map(n => n.name),
}))
];
}
function extractLinks(content: string): string[] {
if(!content) return [];
const matches = content.matchAll(/\[\[([^\]]+)\]\]/g);
return [...new Set([...matches].map(m => m[1].trim()))];
}
@@ -83,6 +49,15 @@ export function extractMetadata(content: string): {links: string[], backlinks: s
};
}
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++) {
@@ -108,103 +83,7 @@ function getWeekSunday(monday: string): string {
return d.toISOString().slice(0, 10);
}
function tagsFromName(name: string): string[] {
const prefix = name.split('/')[0];
return prefix ? [prefix.toLowerCase()] : [];
}
export function serializeMemory(mem: Memory, week?: {monday: string, sunday: string}): string {
return mem.content;
}
export function deserializeMemory(raw: string, embedding: number[] = []): Memory {
const match = raw.match(/^---\n([\s\S]*?)\n---\n\n?([\s\S]*)$/);
if (!match) {
return {name: '', description: '', content: raw.trim(), embedding};
}
const [, fm] = match;
const get = (key: string): string => {
const m = fm.match(new RegExp(`^${key}:\\s*(.+)$`, 'm'));
return m ? m[1].trim() : '';
};
return {
name: get('name'),
description: get('description'),
content: raw.trim(),
embedding,
};
}
export class MemoryCache {
private tree: KDTree<MemoryRef>;
public memories: Memory[];
private locks = new Map<string, Promise<void>>();
constructor(memories: Memory[]) {
this.memories = memories;
this.tree = this.buildTree();
}
private buildTree(): KDTree<MemoryRef> {
const embedded = this.memories.filter(m => m.embedding?.length);
if (!embedded.length) return new KDTree<MemoryRef>(0);
const dims = embedded[0].embedding.length;
const points: KDPoint<MemoryRef>[] = embedded.map(m => ({
vector: m.embedding,
payload: {name: m.name, description: m.description},
}));
return new KDTree<MemoryRef>(dims, 'cosine', points);
}
search(query: number[], limit: number): MemoryRef[] {
const results = this.tree.knn(query, limit);
return results.map(r => r.point.payload);
}
add(memory: Memory): void {
this.memories.push(memory);
this.rebuild();
}
update(memory: Memory): void {
const idx = this.memories.findIndex(m => m.name === memory.name);
if (idx !== -1) {
this.memories[idx] = memory;
} else {
this.memories.push(memory);
}
this.rebuild();
}
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.tree = this.buildTree();
}
lock<T>(name: string, fn: () => Promise<T>): Promise<T> {
const prev = this.locks.get(name) ?? Promise.resolve();
let resolveLock!: () => void;
const next = new Promise<void>(r => { resolveLock = r; });
this.locks.set(name, next);
const result = prev.then(fn).finally(resolveLock);
result.finally(() => {
if (this.locks.get(name) === next) this.locks.delete(name);
});
return result;
}
}
export class MemoryManager {
private pendingMemorizations = new Map<string, {
@@ -213,9 +92,15 @@ export class MemoryManager {
timestamp: number,
}>();
private queues = new Map<string, {
pending: string[],
request: {abort?: () => void} | null,
task: Promise<void>,
}>();
tools = {
read: (memories: Memory[] | MemoryCache): AiTool => ({
name: 'read_memory',
name: 'memory_recall',
description: 'Read the full content of a memory document',
args: {
name: {type: 'string', description: 'Exact memory name', required: true},
@@ -229,11 +114,10 @@ export class MemoryManager {
}),
forget: (memories: Memory[] | MemoryCache): AiTool => ({
name: 'forget_memory',
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},
reason: {type: 'string', description: 'Why this memory is being deleted', required: true},
name: {type: 'string', description: 'Exact memory name to forget', required: true}
},
fn: (args: any) => {
const result = this.forget(args.name, memories);
@@ -245,12 +129,8 @@ export class MemoryManager {
constructor(private llm: any) {}
private async createTempMemory(conversation: string): Promise<Memory> {
const [e] = await this.llm.embedding(conversation);
const timestamp = Date.now();
return {
name: `_temp_${timestamp}`,
description: 'Temporary memory - processing in background',
content: `---
const content = `---
name: _temp_${timestamp}
description: Temporary memory - processing in background
tags: [_temporary]
@@ -261,7 +141,12 @@ modified: ${new Date().toISOString()}
# Recent Conversation (Processing)
${conversation}`,
${conversation}`;
const [e] = await this.llm.embedding(content);
return {
name: `_temp_${timestamp}`,
description: 'Temporary memory - processing in background',
content,
embedding: e?.embedding || [],
};
}
@@ -302,17 +187,6 @@ ${conversation}`,
return scored.map(s => s.ref);
}
private createNode(name: string, memories: Memory[]): Memory {
const existing = memories.find(m => m.name === name);
if (existing) return existing;
return {
name,
description: '',
content: '',
embedding: [],
};
}
private listNodes(memories: Memory[]): MemoryRef[] {
return memories.map(m => ({name: m.name, description: m.description}));
}
@@ -361,7 +235,6 @@ ${conversation}`,
.map(h => `[${h.role}]: ${h.content}`).join('\n\n').trim();
if(!conversation) return [];
// Create and insert temp memory immediately
const trackingId = `${Date.now()}_${Math.random()}`;
const tempMemory = await this.createTempMemory(conversation);
const mem = memories instanceof MemoryCache ? memories.memories : memories;
@@ -374,65 +247,68 @@ ${conversation}`,
});
try {
await this._memorizeBackground(conversation, memories, options);
// Return the final memories (excluding temp ones)
await this._memorizeBackground(conversation, memories, options, tempMemory.name);
const finalMem = memories instanceof MemoryCache ? memories.memories : memories;
return finalMem.filter(m => !m.name.startsWith('_temp_'));
} catch (err) {
throw err;
} finally {
// Remove temp memory from the exact same memory array/cache
const pending = this.pendingMemorizations.get(trackingId);
if (pending) {
const cleanMem = pending.memories instanceof MemoryCache
? pending.memories.memories
: pending.memories;
const idx = cleanMem.findIndex(m => m.name === pending.tempMemoryName);
if (idx !== -1) {
cleanMem.splice(idx, 1);
}
if (pending.memories instanceof MemoryCache) {
pending.memories.rebuild();
}
if (idx !== -1) cleanMem.splice(idx, 1);
if (pending.memories instanceof MemoryCache) pending.memories.rebuild();
}
this.pendingMemorizations.delete(trackingId);
}
}
private async _memorizeBackground(conversation: string, memories: Memory[] | MemoryCache, options: LLMRequest): Promise<void> {
private async _memorizeBackground(conversation: string, memories: Memory[] | MemoryCache, options: LLMRequest, tempName: string): Promise<void> {
const mem = memories instanceof MemoryCache ? memories.memories : memories;
const monday = getWeekMonday();
const sunday = getWeekSunday(monday);
const buckets = await this.factAgent(conversation, mem, options, monday);
if(!buckets.length) return;
const runDocAgent = (node: Memory, bucket: FactBucket, embedding?: number[], week?: {monday: string, sunday: string}) => {
if (memories instanceof MemoryCache) {
return memories.lock(node.name, () => this.docAgent(node, bucket, mem, options, embedding, week));
const jobs = [...buckets].map(({subject, facts}) => {
let node = mem.find(m => m.name === subject);
if(!node) {
node = {name: subject, description: '', content: '', embedding: [],};
mem.push(node);
}
return this.docAgent(node, bucket, mem, options, embedding, week);
};
const week = subject.startsWith('Journal/') ? {monday, sunday} : undefined;
return this.enqueue(node, facts, mem, options, tempName, week);
});
await Promise.all(jobs);
}
await Promise.all(buckets.map(async bucket => {
let node = mem.find(m => m.name === bucket.subject && !m.name.startsWith('_temp_'));
let embedding: number[] | undefined;
/**
* Coalescing queue: if a doc is already compiling, abort the in-flight run, merge its
* facts with the new ones and restart. Never blocks a pending update, never drops facts.
*/
private enqueue(node: Memory, facts: string[], memories: Memory[] | MemoryCache, options: LLMRequest, tempName: string, week?: {monday: string, sunday: string}): Promise<void> {
const key = node.name;
const existing = this.queues.get(key);
if (existing) {
existing.pending.push(...facts);
existing.request?.abort?.();
return existing.task;
}
if (!node || bucket.isNew) {
const [e] = await this.llm.embedding(`${bucket.subject}\n${bucket.facts.join('\n')}`);
embedding = e?.embedding;
if (!node) {
node = this.createNode(bucket.subject, mem);
mem.push(node);
}
const entry: {pending: string[], request: {abort?: () => void} | null, task: Promise<void>} = {pending: [...facts], request: null, task: Promise.resolve()};
this.queues.set(key, entry);
const m = memories instanceof MemoryCache ? memories.memories : memories;
entry.task = (async () => {
while (entry.pending.length) {
const batch = dedupeFacts(entry.pending.splice(0, entry.pending.length));
const written = await this.docAgent(node, batch, m, options, tempName, week, entry);
if (!written) entry.pending.unshift(...batch);
}
const week = bucket.subject.startsWith('Journal/') ? {monday, sunday} : undefined;
await runDocAgent(node, bucket, embedding, week);
}));
if(memories instanceof MemoryCache)
memories.rebuild();
})().finally(() => {
this.queues.delete(key);
if(!this.queues.size && memories instanceof MemoryCache) memories.rebuild();
});
return entry.task;
}
private buildHeader(node: Memory, week?: {monday: string, sunday: string}, links: string[] = [], backlinks: string[] = []): string {
@@ -452,11 +328,7 @@ ${conversation}`,
}
private applyHeader(content: string, header: string): string {
const hasFrontmatter = content.trimStart().startsWith('---');
if (hasFrontmatter) {
return content.replace(/^---[\s\S]*?---\n?/, `${header}\n`);
}
return `${header}\n\n${content}`;
return `${header}\n\n${this.stripHeader(content)}`;
}
private updateFrontmatter(content: string, updates: {links?: string[], backlinks?: string[]}): string {
@@ -482,53 +354,53 @@ ${conversation}`,
}
private stripHeader(content: string): string {
return content.replace(/^---[\s\S]*?---\n?/, '').trimStart();
return content.replace(/^---[\s\S]*?\n---\n?/, '').trimStart();
}
private async docAgent(node: Memory, bucket: FactBucket, memories: Memory[], options: LLMRequest, precomputedEmbedding?: number[], week?: {monday: string, sunday: string}): Promise<void> {
private async docAgent(node: Memory, facts: string[], memories: Memory[], options: LLMRequest, tempName: string, week: {monday: string, sunday: string} | undefined, entry: {request: {abort?: () => void} | null}): Promise<boolean> {
const {links: oldLinks} = extractMetadata(node.content);
let finalContent = node.content;
await this.llm.ask(
`New facts to integrate:\n${bucket.facts.map(f => `- ${f}`).join('\n')}`,
{
model: options.model,
temperature: 0.3,
system: `You are a knowledge base editor. Integrate the provided facts into the document below.
const currentBody = this.stripHeader(node.content);
let update;
try {
for(let i = 0; i < 3 && !update?.content; i++) {
const request = this.llm.ask(`New Facts:\n${facts.map(f => `- ${f}`).join('\n')}`, {
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 in markdown, without the frontmatter block', required: true},
},
system: `You are a knowledge base editor. Rewrite the current document below so it incorporates the new facts.
Formatting rules:
- Use Obsidian-style markdown: # headings, **bold** for key terms, bullet lists for facts
- Use Obsidian-style markdown: # headings, **bold** to add emphasis, __italics__ for titles, terms, etc, bullet & numbered lists for grouped 1D data and tables for 2D data
- Link related concepts with [[WikiLink]] notation using full paths like [[People/Sarah]] or [[Projects/Website]]
- You may create links to nodes that don't exist yet if the concept is important
- Create links for specific entities (person, place, project, program) and abstract concepts (quantum mechanics, entropy) but skip generics (car, red, dog)
- Keep the document concise, factual, and human-readable
- Resolve any contradictions between old content and new facts (new facts win)
- Do not add filler, preamble, or AI commentary — just clean knowledge documents
- The document begins with a YAML frontmatter block (between --- markers) — do not remove or rewrite it, it is maintained automatically
${week ? '- This is a weekly journal entry. The frontmatter contains the week date range.\n' : ''}
- Resolve contradictions: the new facts always win — delete the outdated statement entirely, never keep both
- Later facts in the list override earlier ones
- Do not add frontmatter blocks, filler, preamble, or AI commentary
${week ? '- This is a weekly journal entry.\n' : ''}
All nodes:
${this.listNodes(memories).map(n => n.name).join(', ') || 'none'}
Current document:
\`\`\`markdown
${node.content || '(empty — this is a new document)'}
\`\`\``,
tools: [{
name: 'update_document',
description: 'Write the complete updated document content. Include everything after the frontmatter block — the frontmatter will be recalculated automatically.',
args: {
description: {type: 'string', description: 'One-line description of what this document covers, no formatting or emojis', required: true},
content: {type: 'string', description: 'Document body in markdown, without the frontmatter block', required: true},
},
fn: (args: any) => {
node.description = args.description;
finalContent = args.content;
return 'Saved';
},
}],
${currentBody}
\`\`\``}
);
entry.request = request;
update = await request;
}
);
} catch (err: any) {
if (err?.name === 'AbortError') return false;
throw err;
} finally {
entry.request = null;
}
const newLinks = extractLinks(finalContent).filter(l => l !== node.name);
if(!update?.content) return false;
const newLinks = extractLinks(update.content).filter(l => l !== node.name && l !== tempName);
const newLinkSet = new Set(newLinks);
const oldLinkSet = new Set(oldLinks);
@@ -558,61 +430,55 @@ ${node.content || '(empty — this is a new document)'}
}
const {backlinks} = extractMetadata(node.content);
const header = this.buildHeader(node, week, newLinks, backlinks);
node.content = this.applyHeader(finalContent, header);
if (precomputedEmbedding) {
node.embedding = precomputedEmbedding;
} else {
const embedInput = `${node.description}\n\n${this.stripHeader(node.content)}`.trim();
const [e] = await this.llm.embedding(embedInput);
if (e) node.embedding = e.embedding;
}
node.description = update.description;
node.content = this.applyHeader(update.content, this.buildHeader(node, week, newLinks, backlinks));
const [e] = await this.llm.embedding(node.content);
if(e) node.embedding = e.embedding;
return true;
}
private async factAgent(conversation: string, memories: Memory[], options: LLMRequest, weekKey: string): Promise<FactBucket[]> {
const buckets: FactBucket[] = [];
const buckets = new Map<string, string[]>();
await this.llm.ask(conversation, {
model: options.model,
temperature: 0.2,
system: `You are a fact extractor. Analyze this conversation and extract facts worth remembering long-term.
Rules:
- ONLY extract facts the USER explicitly stated about themselves, their work, or their projects
- ONLY extract current facts the USER explicitly stated about themselves, their work, or their projects
- ONLY extract decisions that were MADE during this conversation
- DO NOT extract anything the AI said, its capabilities, or meta-conversation about the AI
- DO NOT extract greetings, pleasantries, or generic exchanges
- DO NOT extract deltas or changes in facts; ONLY the end fact
- If nothing worth remembering was said, do not call any tools
When extracting facts, you MUST also decide the exact destination path:
- Use an existing node name if the facts clearly belong there
- All information primary about the user should go under "Personal/Subject" (e.g., Personal/Info, Personal/Todos)
- All information primary about the user should go under "Personal/..." (e.g., Personal/Info, Personal/Todos)
- When required, create a new path following collection/subject format (e.g., People/Sarah, Projects/Oxide)
- For journal entries, use "journal" (will auto-route to Journal/${weekKey})
- For journal entries, use "Journal"
Available nodes:
${this.listNodes(memories).map(n => `- ${n.name}: ${n.description}`).join('\n') || 'None yet.'}`,
- Journal
${this.listNodes(memories).filter(n => !n.name.includes('_temp_') && !n.name.includes('Journal')).map(n => `- ${n.name}: ${n.description}`).join('\n') || 'None yet.'}`,
tools: [{
name: 'extract_facts',
name: 'facts_extract',
description: 'Submit facts with their destination',
args: {
destination: {type: 'string', description: 'Exact existing node name OR new path (e.g. "People/Sarah", "Projects/Oxide")', required: true},
facts: {type: 'string', description: 'Comma-separated facts', required: true},
create_new: {type: 'boolean', description: 'True if this is a new node that doesn\'t exist yet', required: true},
},
fn: (args: any) => {
const subject = args.destination.trim().toLowerCase() === 'journal'
? `Journal/${weekKey}`
: args.destination;
buckets.push({
subject,
facts: args.facts.split(',').map((f: string) => f.trim()).filter(Boolean),
isNew: args.create_new,
});
: args.destination.trim();
const facts = buckets.get(subject) ?? [];
facts.push(...dedupeFacts(String(args.facts).split(',')));
buckets.set(subject, facts);
return 'Recorded';
},
}],
});
return buckets;
return buckets.entries().toArray().map(([subject, facts]) => ({subject, facts}));
}
}

View File

@@ -1,5 +1,5 @@
import {AbortablePromise} from './ai.ts';
import {LLMMessage, LLMRequest} from './llm.ts';
import {LLMRequest} from './llm.ts';
export abstract class LLMProvider {
abstract ask(message: string, options: LLMRequest): AbortablePromise<string>;

View File

@@ -133,7 +133,7 @@ export const ExecTool: AiTool = {
}
export const FetchTool: AiTool = {
name: 'fetch',
name: 'net_fetch',
description: 'Make HTTP request to URL',
args: {
url: {type: 'string', description: 'URL to fetch', required: true},
@@ -172,7 +172,7 @@ export const PythonTool: AiTool = {
}
export const ReadWebpageTool: AiTool = {
name: 'read_webpage',
name: 'net_read',
description: 'Extract clean content from webpages, or convert media/documents to accessible formats',
args: {
url: {type: 'string', description: 'URL to read', required: true},
@@ -276,7 +276,7 @@ export const ReadWebpageTool: AiTool = {
};
export const WebSearchTool: AiTool = {
name: 'web_search',
name: 'net_search',
description: 'Use duckduckgo (anonymous) to find find relevant online resources. Returns a list of URLs that works great with the `read_webpage` tool',
args: {
query: {type: 'string', description: 'Search string', required: true},
@@ -302,7 +302,7 @@ export const WebSearchTool: AiTool = {
}
export const WikipediaTool: AiTool = {
name: 'wikipedia_search',
name: 'get_wikipedia',
description: 'Search Wikipedia for matching articles',
args: {
query: {type: 'string', description: 'Search term or article title', required: true},