Compare commits

..

3 Commits
1.2.3 ... 1.2.5

Author SHA1 Message Date
73d6ee0f2a Personal file memory organization instructions
All checks were successful
Publish Library / Build NPM Project (push) Successful in 45s
Publish Library / Tag Version (push) Successful in 12s
2026-07-27 22:39:06 -04:00
bee4085666 updatememory awaits full result
Some checks failed
Publish Library / Tag Version (push) Has been cancelled
Publish Library / Build NPM Project (push) Has been cancelled
2026-07-27 22:34:36 -04:00
3b5c71de7c Improved memory management
All checks were successful
Publish Library / Build NPM Project (push) Successful in 40s
Publish Library / Tag Version (push) Successful in 14s
2026-07-27 20:10:09 -04:00
3 changed files with 371 additions and 254 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "@ztimson/ai-utils", "name": "@ztimson/ai-utils",
"version": "1.2.3", "version": "1.2.5",
"description": "AI Utility library", "description": "AI Utility library",
"author": "Zak Timson", "author": "Zak Timson",
"license": "MIT", "license": "MIT",

View File

@@ -229,8 +229,8 @@ ${r.content}
* Digest full conversation history into memory documents. * Digest full conversation history into memory documents.
* Call on session end to persist the conversation. * Call on session end to persist the conversation.
*/ */
async updateMemory(history: LLMMessage[], memories: Memory[] | MemoryCache, options: LLMRequest = {}): Promise<void> { async updateMemory(history: LLMMessage[], memories: Memory[] | MemoryCache, options: LLMRequest = {}): Promise<Memory[]> {
await this.memoryManager.memorize(history, memories, {model: this.defaultModel, ...options}); return this.memoryManager.memorize(history, memories, {model: this.defaultModel, ...options});
} }
/** /**

View File

@@ -7,8 +7,6 @@ export type Memory = {
description: string; description: string;
content: string; content: string;
embedding: number[]; embedding: number[];
links: string[];
backlinks: string[];
} }
type MemoryRef = { type MemoryRef = {
@@ -19,6 +17,7 @@ type MemoryRef = {
type FactBucket = { type FactBucket = {
subject: string; subject: string;
facts: string[]; facts: string[];
isNew: boolean;
} }
export type MemoryNode = { export type MemoryNode = {
@@ -33,26 +32,31 @@ export function buildMemoryGraph(memories: Memory[] | MemoryCache): MemoryNode[]
const nameSet = new Set(mems.map(m => m.name)); const nameSet = new Set(mems.map(m => m.name));
const ghosts = new Set<string>(); const ghosts = new Set<string>();
for (const m of mems) { const nodes: MemoryNode[] = mems.map(m => {
for (const link of m.links) { 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); if (!nameSet.has(link)) ghosts.add(link);
} }
} }
return [ return [
...mems.map(m => ({ ...nodes,
name: m.name,
missing: false,
links: m.links,
backlinks: m.backlinks,
})),
...[...ghosts].map(name => ({ ...[...ghosts].map(name => ({
name, name,
missing: true, missing: true,
links: [], links: [],
backlinks: mems backlinks: nodes
.filter(m => m.links.includes(name)) .filter(n => n.links.includes(name))
.map(m => m.name), .map(n => n.name),
})) }))
]; ];
} }
@@ -62,14 +66,21 @@ function extractLinks(content: string): string[] {
return [...new Set([...matches].map(m => m[1].trim()))]; return [...new Set([...matches].map(m => m[1].trim()))];
} }
function rebuildBacklinks(memories: Memory[]): void { export function extractMetadata(content: string): {links: string[], backlinks: string[]} {
for (const m of memories) m.backlinks = []; const match = content.match(/^---\n([\s\S]*?)\n---/);
for (const m of memories) { if (!match) return {links: [], backlinks: []};
for (const link of m.links) {
const target = memories.find(t => t.name === link); const fm = match[1];
if (target) target.backlinks.push(m.name); const getList = (key: string): string[] => {
} const m = fm.match(new RegExp(`^${key}:\\s*\\[(.*)\\]$`, 'm'));
} if (!m || !m[1].trim()) return [];
return m[1].split(',').map(s => s.trim().replace(/^"|"$/g, '')).filter(Boolean);
};
return {
links: getList('links'),
backlinks: getList('backlinks'),
};
} }
function cosineDistance(a: number[], b: number[]): number { function cosineDistance(a: number[], b: number[]): number {
@@ -83,9 +94,53 @@ function cosineDistance(a: number[], b: number[]): number {
return denom === 0 ? 1 : 1 - dot / denom; return denom === 0 ? 1 : 1 - dot / denom;
} }
function getWeekMonday(date: Date = new Date()): string {
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
const day = d.getUTCDay();
const diff = day === 0 ? -6 : 1 - day;
d.setUTCDate(d.getUTCDate() + diff);
return d.toISOString().slice(0, 10);
}
function getWeekSunday(monday: string): string {
const d = new Date(`${monday}T00:00:00Z`);
d.setUTCDate(d.getUTCDate() + 6);
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 { export class MemoryCache {
private tree: KDTree<MemoryRef>; private tree: KDTree<MemoryRef>;
public memories: Memory[]; public memories: Memory[];
private locks = new Map<string, Promise<void>>();
constructor(memories: Memory[]) { constructor(memories: Memory[]) {
this.memories = memories; this.memories = memories;
@@ -94,7 +149,7 @@ export class MemoryCache {
private buildTree(): KDTree<MemoryRef> { private buildTree(): KDTree<MemoryRef> {
const embedded = this.memories.filter(m => m.embedding?.length); const embedded = this.memories.filter(m => m.embedding?.length);
if(!embedded.length) return new KDTree<MemoryRef>(0); if (!embedded.length) return new KDTree<MemoryRef>(0);
const dims = embedded[0].embedding.length; const dims = embedded[0].embedding.length;
const points: KDPoint<MemoryRef>[] = embedded.map(m => ({ const points: KDPoint<MemoryRef>[] = embedded.map(m => ({
@@ -125,16 +180,38 @@ export class MemoryCache {
this.rebuild(); 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 { rebuild(): void {
this.tree = this.buildTree(); this.tree = this.buildTree();
} }
rebuildLinks(): void { lock<T>(name: string, fn: () => Promise<T>): Promise<T> {
rebuildBacklinks(this.memories); 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 { export class MemoryManager {
private pendingMemorizations = new Map<string, {
memories: Memory[] | MemoryCache,
tempMemoryName: string,
timestamp: number,
}>();
tools = { tools = {
read: (memories: Memory[] | MemoryCache): AiTool => ({ read: (memories: Memory[] | MemoryCache): AiTool => ({
@@ -143,23 +220,82 @@ export class MemoryManager {
args: { args: {
name: {type: 'string', description: 'Exact memory name', required: true}, name: {type: 'string', description: 'Exact memory name', required: true},
}, },
fn:(args: any) => { fn: (args: any) => {
const mems = memories instanceof MemoryCache ? memories.memories : memories; const mems = memories instanceof MemoryCache ? memories.memories : memories;
const mem = mems.find(m => m.name === args.name); const mem = mems.find(m => m.name === args.name);
if(!mem) return 'Document not found'; if (!mem) return 'Document not found';
return this.formatMemory(mem); return mem.content;
} },
}),
forget: (memories: Memory[] | MemoryCache): AiTool => ({
name: 'forget_memory',
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},
},
fn: (args: any) => {
const result = this.forget(args.name, memories);
return result ? `Forgotten: ${args.name}` : `Not found: ${args.name}`;
},
}), }),
}; };
constructor(private llm: any) {} 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: `---
name: _temp_${timestamp}
description: Temporary memory - processing in background
tags: [_temporary]
links: []
backlinks: []
modified: ${new Date().toISOString()}
---
# Recent Conversation (Processing)
${conversation}`,
embedding: e?.embedding || [],
};
}
forget(name: string, memories: Memory[] | MemoryCache): boolean {
const mem = memories instanceof MemoryCache ? memories.memories : memories;
const idx = mem.findIndex(m => m.name === name);
if (idx === -1) return false;
for (const node of mem) {
const {links, backlinks} = extractMetadata(node.content);
const newBacklinks = backlinks.filter(b => b !== name);
const newLinks = links.filter(l => l !== name);
if (newBacklinks.length !== backlinks.length || newLinks.length !== links.length) {
node.content = this.updateFrontmatter(node.content, {
links: newLinks,
backlinks: newBacklinks,
});
}
}
mem.splice(idx, 1);
if (memories instanceof MemoryCache) memories.rebuild();
return true;
}
private cosineSearch(query: number[], memories: Memory[], limit: number): MemoryRef[] { private cosineSearch(query: number[], memories: Memory[], limit: number): MemoryRef[] {
const scored = memories const scored = memories
.filter(m => m.embedding?.length) .filter(m => m.embedding?.length)
.map(m => ({ .map(m => ({
ref: {name: m.name, description: m.description}, ref: {name: m.name, description: m.description},
distance: cosineDistance(query, m.embedding) distance: cosineDistance(query, m.embedding),
})) }))
.sort((a, b) => a.distance - b.distance) .sort((a, b) => a.distance - b.distance)
.slice(0, limit); .slice(0, limit);
@@ -168,59 +304,48 @@ export class MemoryManager {
private createNode(name: string, memories: Memory[]): Memory { private createNode(name: string, memories: Memory[]): Memory {
const existing = memories.find(m => m.name === name); const existing = memories.find(m => m.name === name);
if(existing) return existing; if (existing) return existing;
return { return {
name, name,
description: '', description: '',
content: '', content: '',
embedding: [], embedding: [],
links: [],
backlinks: [],
}; };
} }
private formatMemory(mem: Memory): string {
return [
`# ${mem.name}`,
mem.description ? `> ${mem.description}` : '',
mem.links.length ? `**Links:** ${mem.links.map(l => `[[${l}]]`).join(', ')}` : '',
mem.backlinks.length ? `**Referenced by:** ${mem.backlinks.map(l => `[[${l}]]`).join(', ')}` : '',
'',
mem.content,
].filter(l => l !== undefined).join('\n');
}
private listNodes(memories: Memory[]): MemoryRef[] { private listNodes(memories: Memory[]): MemoryRef[] {
return memories.map(m => ({name: m.name, description: m.description})); return memories.map(m => ({name: m.name, description: m.description}));
} }
async recollect(query: string, memories: Memory[] | MemoryCache, limit = 5, graphDepth = 1): Promise<Memory[]> { async recollect(query: string, memories: Memory[] | MemoryCache, limit = 5, graphDepth = 1): Promise<Memory[]> {
const mem: Memory[] = memories instanceof MemoryCache ? memories.memories : memories; const mem: Memory[] = memories instanceof MemoryCache ? memories.memories : memories;
if(!mem.length) return []; if (!mem.length) return [];
const [e] = await this.llm.embedding(query); const [e] = await this.llm.embedding(query);
if(!e) return []; if (!e) return [];
let vectorResults: MemoryRef[]; let vectorResults: MemoryRef[];
if(memories instanceof MemoryCache) vectorResults = memories.search(e.embedding, limit); if (memories instanceof MemoryCache) vectorResults = memories.search(e.embedding, limit);
else vectorResults = this.cosineSearch(e.embedding, mem, limit); else vectorResults = this.cosineSearch(e.embedding, mem, limit);
const found = new Set<string>(vectorResults.map(r => r.name)); const found = new Set<string>(vectorResults.map(r => r.name));
if(graphDepth > 0) { if (graphDepth > 0) {
const frontier = [...found]; const frontier = [...found];
for(let depth = 0; depth < graphDepth; depth++) { for (let depth = 0; depth < graphDepth; depth++) {
const next: string[] = []; const next: string[] = [];
for(const name of frontier) { for (const name of frontier) {
const node = mem.find(m => m.name === name); const node = mem.find(m => m.name === name);
if(!node) continue; if (!node) continue;
for(const link of node.links) { const {links} = extractMetadata(node.content);
if(!found.has(link) && mem.find(m => m.name === link)) { for (const link of links) {
if (!found.has(link) && mem.find(m => m.name === link)) {
found.add(link); found.add(link);
next.push(link); next.push(link);
} }
} }
} }
frontier.splice(0, frontier.length, ...next); frontier.splice(0, frontier.length, ...next);
if(!frontier.length) break; if (!frontier.length) break;
} }
} }
@@ -230,98 +355,146 @@ export class MemoryManager {
return ordered.map(n => mem.find(m => m.name === n)!).filter(Boolean); return ordered.map(n => mem.find(m => m.name === n)!).filter(Boolean);
} }
async memorize(history: LLMMessage[], memories: Memory[] | MemoryCache, options: LLMRequest): Promise<void> { async memorize(history: LLMMessage[], memories: Memory[] | MemoryCache, options: LLMRequest): Promise<Memory[]> {
const mem = memories instanceof MemoryCache ? memories.memories : memories;
const conversation = history const conversation = history
.filter(h => h.role === 'user' || h.role === 'assistant') .filter(h => h.role === 'user' || h.role === 'assistant')
.map(h => `[${h.role}]: ${h.content}`).join('\n\n').trim(); .map(h => `[${h.role}]: ${h.content}`).join('\n\n').trim();
if(!conversation) return [];
if(conversation) { // Create and insert temp memory immediately
const buckets = await this.factAgent(conversation, mem, options); const trackingId = `${Date.now()}_${Math.random()}`;
if(buckets.length) { const tempMemory = await this.createTempMemory(conversation);
await Promise.all(buckets.map(async bucket => { const mem = memories instanceof MemoryCache ? memories.memories : memories;
const node = await this.organizingAgent(bucket, mem, options); mem.push(tempMemory);
if(!mem.find(m => m.name === node.name)) mem.push(node); if (memories instanceof MemoryCache) memories.rebuild();
await this.docAgent(node, bucket, mem, options); this.pendingMemorizations.set(trackingId, {
})); memories,
} tempMemoryName: tempMemory.name,
} timestamp: Date.now(),
// Auto-compress old journals
const weekAgo = Date.now() - (7 * 24 * 60 * 60 * 1000);
const oldDailies = mem.filter(m => {
const journal = /^Journal\/(\d{4}-\d{2}-\d{2}$)/.exec(m.name);
return journal && new Date(journal[1]).getTime() < weekAgo;
}); });
if(oldDailies.length) { try {
const byMonth = new Map<string, Memory[]>(); await this._memorizeBackground(conversation, memories, options);
for(const daily of oldDailies) { // Return the final memories (excluding temp ones)
const match = daily.name.match(/^Journal\/(\d{4}-\d{2})-\d{2}$/); const finalMem = memories instanceof MemoryCache ? memories.memories : memories;
if(!match) continue; return finalMem.filter(m => !m.name.startsWith('_temp_'));
const monthKey = match[1]; } catch (err) {
if(!byMonth.has(monthKey)) byMonth.set(monthKey, []); throw err;
byMonth.get(monthKey)!.push(daily); } finally {
} // Remove temp memory from the exact same memory array/cache
const pending = this.pendingMemorizations.get(trackingId);
for(const [monthKey, entries] of byMonth) { if (pending) {
const monthlyPath = `Journal/${monthKey}`; const cleanMem = pending.memories instanceof MemoryCache
let monthly = mem.find(m => m.name === monthlyPath); ? pending.memories.memories
if(!monthly) { : pending.memories;
monthly = this.createNode(monthlyPath, mem); const idx = cleanMem.findIndex(m => m.name === pending.tempMemoryName);
mem.push(monthly); if (idx !== -1) {
cleanMem.splice(idx, 1);
} }
if (pending.memories instanceof MemoryCache) {
const bucket: FactBucket = { pending.memories.rebuild();
subject: monthlyPath,
facts: entries.flatMap(e => e.content.split('\n').filter(line => line.trim())),
};
await this.docAgent(monthly, bucket, mem, options);
for(const daily of entries) {
const idx = mem.indexOf(daily);
if(idx !== -1) mem.splice(idx, 1);
} }
} }
} this.pendingMemorizations.delete(trackingId);
if (memories instanceof MemoryCache) {
memories.rebuildLinks();
memories.rebuild();
} else {
rebuildBacklinks(mem);
} }
} }
private async docAgent(node: Memory, bucket: FactBucket, memories: Memory[], options: LLMRequest): Promise<void> { private async _memorizeBackground(conversation: string, memories: Memory[] | MemoryCache, options: LLMRequest): 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));
}
return this.docAgent(node, bucket, mem, options, embedding, week);
};
await Promise.all(buckets.map(async bucket => {
let node = mem.find(m => m.name === bucket.subject && !m.name.startsWith('_temp_'));
let embedding: number[] | undefined;
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 week = bucket.subject.startsWith('Journal/') ? {monday, sunday} : undefined;
await runDocAgent(node, bucket, embedding, week);
}));
if(memories instanceof MemoryCache)
memories.rebuild();
}
private buildHeader(node: Memory, week?: {monday: string, sunday: string}, links: string[] = [], backlinks: string[] = []): string {
const tags = node.name.split('/')[0]?.toLowerCase();
const lines = [
'---',
`name: ${node.name}`,
`description: ${node.description || ''}`,
tags ? `tags: [${tags}]` : '',
links.length ? `links: [${links.map(l => `"${l}"`).join(', ')}]` : 'links: []',
backlinks.length ? `backlinks: [${backlinks.map(l => `"${l}"`).join(', ')}]` : 'backlinks: []',
week ? `week: ${week.monday} ${week.sunday}` : '',
`modified: ${new Date().toISOString()}`,
'---',
].filter(Boolean);
return lines.join('\n');
}
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}`;
}
private updateFrontmatter(content: string, updates: {links?: string[], backlinks?: string[]}): string {
const match = content.match(/^---\n([\s\S]*?)\n---\n\n?([\s\S]*)$/);
if (!match) return content;
const [, fm, body] = match;
let newFm = fm;
if (updates.links !== undefined) {
const linksList = updates.links.length ? `[${updates.links.map(l => `"${l}"`).join(', ')}]` : '[]';
newFm = newFm.replace(/^links:.*$/m, `links: ${linksList}`);
}
if (updates.backlinks !== undefined) {
const backlinksList = updates.backlinks.length ? `[${updates.backlinks.map(l => `"${l}"`).join(', ')}]` : '[]';
newFm = newFm.replace(/^backlinks:.*$/m, `backlinks: ${backlinksList}`);
}
newFm = newFm.replace(/^modified:.*$/m, `modified: ${new Date().toISOString()}`);
return `---\n${newFm}\n---\n\n${body}`;
}
private stripHeader(content: string): string {
return content.replace(/^---[\s\S]*?---\n?/, '').trimStart();
}
private async docAgent(node: Memory, bucket: FactBucket, memories: Memory[], options: LLMRequest, precomputedEmbedding?: number[], week?: {monday: string, sunday: string}): Promise<void> {
const {links: oldLinks} = extractMetadata(node.content);
let finalContent = node.content; let finalContent = node.content;
const isJournalCompression = node.name.match(/^Journal\/\d{4}-\d{2}$/);
const systemPrompt = isJournalCompression
? `You are a journal compressor. Condense the daily entries below into a monthly summary.
Format: await this.llm.ask(
# ${node.name} `New facts to integrate:\n${bucket.facts.map(f => `- ${f}`).join('\n')}`,
{
## Themes model: options.model,
(Recurring topics, moods, patterns) temperature: 0.3,
system: `You are a knowledge base editor. Integrate the provided facts into the document below.
## Key Events
(Important moments, decisions, milestones)
## Notable Conversations
(Significant discussions or revelations)
Rules:
- Use [[WikiLinks]] to reference permanent notes using full paths like [[People/Sarah]] or [[Projects/Website]]
- Keep it concise but preserve emotional/temporal context
- Discard filler but keep things the user vented about or cared about
- If a fact belongs in a permanent note, link to it instead of duplicating
Current monthly summary:
\`\`\`markdown
${node.content || '(empty — first compression for this month)'}
\`\`\``
: `You are a knowledge base editor. Integrate the provided facts into the document below.
Formatting rules: Formatting rules:
- Use Obsidian-style markdown: # headings, **bold** for key terms, bullet lists for facts - Use Obsidian-style markdown: # headings, **bold** for key terms, bullet lists for facts
@@ -330,50 +503,75 @@ Formatting rules:
- Keep the document concise, factual, and human-readable - Keep the document concise, factual, and human-readable
- Resolve any contradictions between old content and new facts (new facts win) - Resolve any contradictions between old content and new facts (new facts win)
- Do not add filler, preamble, or AI commentary — just clean knowledge documents - 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' : ''}
All nodes: All nodes:
${this.listNodes(memories).map(n => n.name).join(', ') || 'none'} ${this.listNodes(memories).map(n => n.name).join(', ') || 'none'}
Current document: Current document:
\`\`\`markdown \`\`\`markdown
${node.content || '(empty — this is a new document)'} ${node.content || '(empty — this is a new document)'}
\`\`\``; \`\`\``,
await this.llm.ask(
`New facts to integrate:\n${bucket.facts.map(f => `- ${f}`).join('\n')}`,
{
model: options.model,
temperature: 0.3,
system: systemPrompt,
tools: [{ tools: [{
name: 'update_document', name: 'update_document',
description: 'Write the complete updated document content', description: 'Write the complete updated document content. Include everything after the frontmatter block — the frontmatter will be recalculated automatically.',
args: { args: {
description: {type: 'string', description: 'One-line description of what this document covers, no formatting or emojis', required: true}, description: {type: 'string', description: 'One-line description of what this document covers, no formatting or emojis', required: true},
content: {type: 'string', description: 'Fully updated document in markdown', required: true}, content: {type: 'string', description: 'Document body in markdown, without the frontmatter block', required: true},
}, },
fn:(args: any) => { fn: (args: any) => {
node.description = args.description; node.description = args.description;
finalContent = args.content; finalContent = args.content;
return 'Saved'; return 'Saved';
} },
}] }],
} }
); );
node.content = finalContent; const newLinks = extractLinks(finalContent).filter(l => l !== node.name);
node.links = extractLinks(finalContent); const newLinkSet = new Set(newLinks);
const needsEmbed = !node.embedding?.length || node.description !== memories.find(m => m.name === node.name)?.description; const oldLinkSet = new Set(oldLinks);
if (needsEmbed) {
const [e] = await this.llm.embedding(node.description); for (const added of newLinkSet) {
if (!oldLinkSet.has(added)) {
const target = memories.find(m => m.name === added);
if (target) {
const {backlinks} = extractMetadata(target.content);
if (!backlinks.includes(node.name)) {
target.content = this.updateFrontmatter(target.content, {
backlinks: [...backlinks, node.name],
});
}
}
}
}
for (const removed of oldLinkSet) {
if (!newLinkSet.has(removed)) {
const target = memories.find(m => m.name === removed);
if (target) {
const {backlinks} = extractMetadata(target.content);
target.content = this.updateFrontmatter(target.content, {
backlinks: backlinks.filter(b => b !== node.name),
});
}
}
}
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; if (e) node.embedding = e.embedding;
} }
} }
private async factAgent(conversation: string, memories: Memory[], options: LLMRequest): Promise<FactBucket[]> { private async factAgent(conversation: string, memories: Memory[], options: LLMRequest, weekKey: string): Promise<FactBucket[]> {
const buckets: FactBucket[] = []; const buckets: FactBucket[] = [];
const today = new Date().toISOString().split('T')[0];
await this.llm.ask(conversation, { await this.llm.ask(conversation, {
model: options.model, model: options.model,
temperature: 0.2, temperature: 0.2,
@@ -386,116 +584,35 @@ Rules:
- DO NOT extract greetings, pleasantries, or generic exchanges - DO NOT extract greetings, pleasantries, or generic exchanges
- If nothing worth remembering was said, do not call any tools - If nothing worth remembering was said, do not call any tools
**Organizational patterns:** When extracting facts, you MUST also decide the exact destination path:
- Journal entries use paths like: Journal/${today} - Use an existing node name if the facts clearly belong there
- People use paths like: People/Name - All information primary about the user should go under "Personal" (e.g., Personal/Goals, Personal/Habits)
- Projects use paths like: Projects/Name - When required, create a new path following collection/subject format (e.g., People/Sarah, Projects/Oxide)
- Personal info uses paths like: Personal/Goals, Personal/Tasks, etc. - For journal entries, use "journal" (will auto-route to Journal/${weekKey})
- General knowledge uses paths like: Biology/Topic, History/Topic, etc.
Learn from existing nodes and follow the same pattern when extracting. Available nodes:
Group facts by subject. For each group call \`extract_facts\` once with the FULL PATH.
Known nodes (name: description):
${this.listNodes(memories).map(n => `- ${n.name}: ${n.description}`).join('\n') || 'None yet.'}`, ${this.listNodes(memories).map(n => `- ${n.name}: ${n.description}`).join('\n') || 'None yet.'}`,
tools: [{ tools: [{
name: 'extract_facts', name: 'extract_facts',
description: 'Submit a group of related facts for a specific subject', description: 'Submit facts with their destination',
args: { args: {
subject: {type: 'string', description: 'Full path for the subject (e.g., "Journal/2025-01-27", "People/Sarah", "Projects/Website")', required: true}, 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 list of extracted facts', 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) => { fn: (args: any) => {
const subject = args.destination.trim().toLowerCase() === 'journal'
? `Journal/${weekKey}`
: args.destination;
buckets.push({ buckets.push({
subject: args.subject, subject,
facts: args.facts.split(',').map((f: string) => f.trim()).filter(Boolean), facts: args.facts.split(',').map((f: string) => f.trim()).filter(Boolean),
isNew: args.create_new,
}); });
return 'Recorded'; return 'Recorded';
} },
}] }],
}); });
return buckets; return buckets;
} }
private async organizingAgent(bucket: FactBucket, memories: Memory[], options: LLMRequest): Promise<Memory> {
let candidates = this.listNodes(memories);
let attempts = 0;
const maxAttempts = 3;
while (attempts++ < maxAttempts) {
let home = '', mode: string | null = null;
const resp = await this.llm.ask(`Subject: ${bucket.subject}\n\nFacts:\n${bucket.facts.map(f => `- ${f}`).join('\n')}`, {
model: options.model,
temperature: 0.1,
system: `You are a knowledge organizer. Your job is to find the correct home for the supplied facts.
1. Review the facts and the node list below. Pick the most likely match or decide if a new node is needed.
2. If you picked an existing node, use \`read\` to verify it's the right place.
- After reading, call either \`confirm\` (correct node) or \`mismatched\` (wrong node).
3. If none of the nodes match, call \`create\` to make a new node.
**Organizational patterns:**
- Journal entries: Journal/YYYY-MM-DD
- People: People/Name
- Projects: Projects/Name
- Personal: Personal/Goals, Personal/Tasks, etc.
- Knowledge: Biology/Topic, History/Topic, etc.
Available nodes:
${candidates.map(n => `- ${n.name}: ${n.description}`).join('\n') || 'None — create a new node.'}`,
tools: [{
name: 'read',
description: 'Read a node file to verify it is the right home for these facts',
args: {name: {type: 'string', description: 'Exact node name (full path)', required: true}},
fn: ({name}) => {
const mem = memories.find(m => m.name === name);
if (!mem) return 'Node not found';
home = name;
return this.formatMemory(mem);
}
}, {
name: 'confirm',
description: 'Confirm this is the correct node for the facts',
args: {},
fn: () => {
mode = 'success';
resp.abort();
}
}, {
name: 'mismatched',
description: 'This is not the node you are looking for',
args: {},
fn: () => {
mode = 'failed';
resp.abort();
}
}, {
name: 'create',
description: 'No existing node fits — create a new one',
args: {
name: {type: 'string', description: 'Full path for the new node (e.g., "People/Sarah", "Journal/2025-01-27")', required: true}
},
fn: ({name}) => {
home = name;
mode = 'create';
resp.abort();
}
}]
});
if(mode === 'create') {
return this.createNode(home, memories);
} else if (mode === 'failed') {
candidates = candidates.filter(c => c.name !== home);
if(!candidates.length) return this.createNode(bucket.subject, memories);
} else if (mode === 'success') {
const existing = memories.find(m => m.name === home);
return existing || this.createNode(home, memories);
}
}
return this.createNode(bucket.subject, memories);
}
} }