Compare commits

...
2 Commits
Author SHA1 Message Date
ztimson 2921b208da More memory optimizations
Publish Library / Build NPM Project (push) Successful in 1m6s
Publish Library / Tag Version (push) Successful in 14s
2026-09-25 00:33:20 -04:00
ztimson b5aec246ac Memory refinement WIP 2026-09-24 14:25:06 -04:00
9 changed files with 628 additions and 854 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@ztimson/ai-utils",
"version": "1.7.1",
"version": "1.7.2",
"description": "AI Utility library",
"author": "Zak Timson",
"license": "MIT",
+5 -2
View File
@@ -1,11 +1,14 @@
export * from './ai';
export * from './antrhopic';
export * from './audio';
export * from './helpers';
export * from './llm';
export * from './memory';
export * from './memory/graph';
export * from './memory/kd-tree';
export * from './memory/memory';
export * from './memory/memory-state';
export * from './open-ai';
export * from './provider';
export * from './token-pool'
export * from './tools';
export * from './vision';
export * from './utils';
+4 -20
View File
@@ -1,17 +1,19 @@
import {clean, makeUnique, snakeCase} from '@ztimson/utils';
import {AbortablePromise, Ai} from './ai.ts';
import {Anthropic} from './antrhopic.ts';
import {MemoryCache} from './memory/memory-state.ts';
import {Memory, MemoryManager, MemoryOptions} from './memory/memory.ts';
import {OpenAi} from './open-ai.ts';
import {LLMProvider} from './provider.ts';
import {AiTool, AiToolArg} from './tools.ts';
import {fileURLToPath} from 'url';
import {spawn} from 'node:child_process';
import {Memory, MemoryCache, MemoryManager, MemoryOptions, stripHeader} from './memory.ts';
import {mkdtempSync} from 'node:fs';
import fs from 'node:fs/promises';
import {tmpdir} from 'node:os';
import {dirname, join, basename, extname} from 'path';
import { PDFParse } from 'pdf-parse';
import {stripHeader} from './utils.ts';
const MAX_AGENT_DEPTH = 5;
const PDF_OCR_PAGE_THRESHOLD = 12; // above this many pages, OCR scanned pages instead of feeding images to the model
@@ -501,7 +503,7 @@ Description: ${r.description}
Linked: ${makeUnique([...r.links, ...r.backlinks]).join(', ')}
<!-- Truncated -->`).join('\n\n') : ''}`.trim())
}
if(mem.tool) tools.push(this.memoryManager.tools.read(mem.memory));
if(mem.tool) tools.push(...this.memoryManager.tools.read(mem.memory));
}
}
@@ -594,24 +596,6 @@ Linked: ${makeUnique([...r.links, ...r.backlinks]).join(', ')}
return h;
}
/**
* Compare the difference between embeddings (calculates the angle between two vectors)
* @param {number[]} v1 First embedding / vector comparison
* @param {number[]} v2 Second embedding / vector for comparison
* @returns {number} Similarity values 0-1: 0 = unique, 1 = identical
*/
cosineSimilarity(v1: number[], v2: number[]): number {
if (v1.length !== v2.length) throw new Error('Vectors must be same length');
let dotProduct = 0, normA = 0, normB = 0;
for (let i = 0; i < v1.length; i++) {
dotProduct += v1[i] * v2[i];
normA += v1[i] * v1[i];
normB += v2[i] * v2[i];
}
const denominator = Math.sqrt(normA) * Math.sqrt(normB);
return denominator === 0 ? 0 : dotProduct / denominator;
}
/**
* Chunk text into parts for AI digestion
* @param {object | string} target Item that will be chunked (objects get converted)
-794
View File
@@ -1,794 +0,0 @@
import {MemoryNode, patchGraph, rebuildGraph} from './helpers.ts';
import {LLMRequest, LLMMessage} from './llm.ts';
import {AiTool} from './tools.ts';
import {KDTree} from './kd-tree.ts';
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[];
}
type MemoryRef = {
name: string;
description: string;
distance?: number;
}
type FactBucket = {
subject: string;
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) {
const clean = f.trim();
if(clean) seen.set(clean.toLowerCase(), clean);
}
return [...seen.values()];
}
function cosineDistance(a: number[], b: number[]): number {
let dot = 0, normA = 0, normB = 0;
for(let i = 0; i < a.length; i++) {
dot += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
const denom = Math.sqrt(normA) * Math.sqrt(normB);
return denom === 0 ? 1 : 1 - dot / denom;
}
function cosineSearch(query: number[], memories: Memory[], limit: number): MemoryRef[] {
return memories
.filter(m => m.embedding?.length)
.map(m => ({name: m.name, description: m.description, distance: cosineDistance(query, m.embedding)}))
.sort((a, b) => a.distance - b.distance)
.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[] = [];
get length() { return this.memories.length; }
constructor(memories: Memory[]) {
this.memories = memories;
this.tree = new KDTree<MemoryRef>(0);
this.rebuild();
}
private syncTree(): void {
const current = new Set(this.memories.map(m => m.name));
for(const [name, emb] of [...this.indexed]) {
const mem = this.memories.find(m => m.name === name);
if(!mem || !current.has(name) || mem.embedding !== emb) {
this.tree.remove(p => p.name === name);
this.indexed.delete(name);
}
}
for(const mem of this.memories) {
if(!mem.embedding?.length || this.indexed.has(mem.name)) continue;
if(this.tree.dims === 0) this.tree = new KDTree<MemoryRef>(mem.embedding.length, 'cosine');
if(mem.embedding.length !== this.tree.dims) continue; // 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, distance: r.distance}));
}
add(memory: Memory): void {
this.memories.push(memory);
this.rebuild([memory]);
}
update(memory: Memory): void {
const existing = this.memories.find(m => m.name === memory.name);
if(existing) Object.assign(existing, memory);
else this.memories.push(memory);
this.rebuild([existing ?? memory]);
}
remove(name: string): void {
const idx = this.memories.findIndex(m => m.name === name);
if(idx !== -1) {
this.memories.splice(idx, 1);
this.rebuild();
}
}
rebuild(changed?: Memory[]): void {
this.nodes = (changed?.length && this.nodes.length)
? patchGraph(this.memories, this.nodes, changed)
: rebuildGraph(this.memories);
this.syncTree();
}
}
class MemoryAccessor {
readonly list: Memory[];
private readonly cache: MemoryCache | null;
constructor(memories: Memory[] | MemoryCache) {
this.cache = memories instanceof MemoryCache ? memories : null;
this.list = this.cache ? this.cache.memories : <Memory[]>memories;
}
find(name: string): Memory | undefined {
return this.list.find(m => m.name === name);
}
commit(changed?: Memory[]): MemoryNode[] {
if(this.cache) {
this.cache.rebuild(changed);
return this.cache.nodes;
}
return rebuildGraph(this.list);
}
ghosts(): string[] {
const nodes = this.cache ? this.cache.nodes : rebuildGraph(this.list);
return nodes.filter(n => n.missing).map(n => n.name);
}
search(vector: number[], limit: number): MemoryRef[] {
return this.cache ? this.cache.search(vector, limit) : cosineSearch(vector, this.list, limit);
}
forget(name: string): boolean {
const idx = this.list.findIndex(m => m.name === name);
if(idx === -1) return false;
this.list.splice(idx, 1);
this.commit();
return true;
}
async backfillEmbeddings(llm: any): Promise<number> {
const missing = this.list.filter(m => !m.embedding?.length);
if(!missing.length) return 0;
await Promise.all(missing.map(node => embedMemoryFields(node, llm)));
this.commit();
return missing.length;
}
}
export type MemoryOptions = {
/** Memory object */
memory: Memory[] | MemoryCache;
/** Inject N memories into the system prompt */
inject?: boolean;
/** expose recall tool to LLM */
tool?: boolean;
/** Update memory on compression */
update?: boolean;
/** Max context size of memories to inject to each call (removed immediately after use) */
maxTokens?: number;
}
export class MemoryManager {
private 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 => ({
name: 'memory_forget',
description: 'Permanently delete a memory document and clean up all references to it',
args: {
name: {type: 'string', description: 'Exact memory name to forget', required: true}
},
fn: (args: any) => {
const result = this.forget(args.name, memories);
return result ? `Forgotten: ${args.name}` : `Not found: ${args.name}`;
},
}),
read: (memories: Memory[] | MemoryCache): AiTool => ({
name: 'memory_recall',
description: 'Read the full content of a memory document',
args: {
name: {type: 'string', description: 'Exact memory name', required: true}
},
fn: (args: any) => {
const mem = new MemoryAccessor(memories).find(args.name);
if(!mem) return 'Document not found';
this.touch(mem.name);
return mem.content;
},
}),
search: (memories: Memory[] | MemoryCache): AiTool => ({
name: 'memory_search',
description: 'Use embeddings to find the MOST relevant memories, even if NOT relevant',
args: {
query: {type: 'string', description: 'What to look for in the memories', required: true},
limit: {type: 'number', description: 'Number of memories to return', default: 1},
},
fn: async ({query, limit}) => {
const mem = await this.recollect(query, memories, limit);
return mem.map(m => `Memory: ${m.name}
Description: ${m.description}
Links: ${[...m.links, ...m.backlinks].join(', ')}
\`\`\`
${m.content}
\`\`\``).join('\n\n');
},
}),
};
constructor(private llm: any) {}
static normalize(m?: Memory[] | MemoryCache | MemoryOptions) {
if(!m) return null;
const raw = m instanceof MemoryCache || Array.isArray(m);
return raw ? {memory: <Memory[] | MemoryCache>m, inject: true, tool: true, update: true} : {inject: true, tool: true, update: true, ...m};
}
private 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 idx = body.indexOf(PENDING_HEADING);
const newBody = idx === -1
? `${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 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): Promise<FactAgentResult> {
const ghosts = store.ghosts();
const response = await this.llm.ask(conversation, {
model: options.model,
temperature: 0.2,
system: `Turn this conversation into a persistent memory file by extracting information into organized bullet points
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.
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 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, if none - omit returning a tasks array
- 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 factual information regarding a subject
- Record the final/end state, not intermediate changes
- Ignore assistant claims, guesses, greetings, or temporary details
- NEVER create a dossier for something I wouldnt find in a wiki site: temporary information, debugging, guesses, conversations (this is all journal entry stuff!)
- 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:
${this.listNodes(store.list).map(n => `- ${n.name}: ${n.description}`).join('\n') || 'None yet.'}
${ghosts.length ? `${ghosts.map(g => `- ${g}: (Ghost)`).join('\n')}` : ''}`,
schema: {
journal: {type: 'string', description: 'Short bullet point recap, omit if nothing notable happened'},
tasks: {
type: 'array', description: 'Concrete tasks mentioned or completed in the conversation, omit if none', 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; omit if none', 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'}},
},
},
},
},
});
const buckets = new Map<string, string[]>();
for(const bucket of response.buckets ?? []) {
const subject = bucket.subject.trim();
const facts = buckets.get(subject) ?? [];
facts.push(...dedupeFacts(bucket.facts));
buckets.set(subject, facts);
}
return {
buckets: buckets.entries().toArray().map(([subject, facts]) => ({subject, facts})),
journal: (response.journal ?? '').trim(),
tasks: response.tasks ?? [],
};
}
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;
d.setUTCDate(d.getUTCDate() + diff);
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) {
existing.dirty = true;
existing.request?.abort?.();
return existing.task;
}
const entry = {dirty: false, request: null, task: Promise.resolve()};
this.queues.set(key, entry);
const store = new MemoryAccessor(memories);
entry.task = (async () => {
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);
const journal = node.name.startsWith('Journal/');
const system = (journal
? `You maintain one persistent journal document
Rewrite the ENTIRE journal, folding "## Pending" into the existing content removing the heading
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
Rewrite the ENTIRE document, folding "## Pending" into the existing content. Remove the Pending section when finished.
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;
throw err;
} finally {
entry.request = null;
}
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);
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};
const fm = new Map<string, string>();
for(const line of match[1].split('\n')) {
const i = line.indexOf(':');
if(i === -1) continue;
const key = line.slice(0, i).trim();
const raw = line.slice(i + 1).trim();
let value = raw;
try { value = JSON.parse(raw); } catch { }
fm.set(key, value);
}
return {fm, body: match[2]};
}
private touchHeader(node: Memory, body: string): string {
const {fm} = this.parseFrontmatter(node.content);
fm.set('name', node.name);
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, stripHeader(body));
}
private writeFrontmatter(fm: Map<string, string>, body: string): string {
const lines = [...fm.entries()].map(([k, v]) => `${k}: ${JSON.stringify(String(v).replace(/\s+/g, ' ').trim())}`);
return `---\n${lines.join('\n')}\n---\n\n${body.trimStart()}`;
}
decay() {
for(const [name, ttl] of this.recentlyTouched) {
if(ttl <= 1) this.recentlyTouched.delete(name);
else this.recentlyTouched.set(name, ttl - 1);
}
}
touch(name: string, ttl = 2) {
this.recentlyTouched.set(name, ttl);
}
forget(name: string, memories: Memory[] | MemoryCache): boolean {
return new MemoryAccessor(memories).forget(name);
}
async recollect(query: string, memories: Memory[] | MemoryCache, limit = 5, graphDepth = 1): Promise<Memory[]> {
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 [];
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) {
let frontier = [...found];
for(let depth = 0; depth < graphDepth && frontier.length; depth++) {
const next: string[] = [];
for(const name of frontier) {
const node = store.find(name);
if(!node) continue;
for(const link of node.links) {
if(!found.has(link) && store.find(link)) {
found.add(link);
next.push(link);
}
}
}
frontier = next;
}
}
const 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 [];
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 = new MemoryAccessor(memories);
const {buckets, journal, tasks} = await this.factAgent(conversation, store, options);
const touched: Memory[] = [];
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);
}
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);
}
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(', ')}`;
Promise.all(touched.map(node => this.reconcile(node, memories, options).catch(() => {})));
} else {
(pending as any).content = 'Nothing worth remembering.';
}
(touched as any).uid = uid;
return touched;
}
async 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();
}
}
+2 -9
View File
@@ -1,4 +1,5 @@
import {Memory, MemoryCache} from './memory.ts';
import {MemoryCache} from './memory-state.ts';
import type {Memory} from './memory.ts';
export type MemoryNode = {
name: string;
@@ -13,14 +14,6 @@ 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]));
+5 -28
View File
@@ -1,3 +1,5 @@
import {cosineDistance, euclideanDistance} from '../utils.ts';
export type DistanceMetric = "euclidean" | "cosine";
export interface KDPoint<T = unknown> {
@@ -18,28 +20,6 @@ interface KDNode<T> {
deleted?: boolean;
}
// ─── Distance helpers ─────────────────────────────────────────────────────────
function euclidean(a: number[], b: number[]): number {
let sum = 0;
for (let i = 0; i < a.length; i++) {
const d = a[i] - b[i];
sum += d * d;
}
return Math.sqrt(sum);
}
function cosine(a: number[], b: number[]): number {
let dot = 0, normA = 0, normB = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
const denom = Math.sqrt(normA) * Math.sqrt(normB);
return denom === 0 ? 1 : 1 - dot / denom; // distance = 1 - similarity
}
/**
* Keeps the k closest candidates in memory, evicts the furthest when full
*/
@@ -123,7 +103,7 @@ export class KDTree<T = unknown> {
points?: KDPoint<T>[]
) {
this.dims = dims;
this.distanceFn = metric === "cosine" ? cosine : euclidean;
this.distanceFn = metric === "cosine" ? cosineDistance : euclideanDistance;
if (points && points.length > 0) {
this.validateAll(points);
@@ -300,11 +280,8 @@ export class KDTree<T = unknown> {
: [node.right, node.left];
this.searchKNN(near, query, k, heap, depth + 1);
// Only explore the far side if it could contain a closer point.
// For cosine distance we can't prune by axis gap alone, so always explore.
const shouldExplore =
this.distanceFn === cosine
this.distanceFn === cosineDistance
? true
: Math.abs(diff) < heap.worstDistance;
@@ -340,7 +317,7 @@ export class KDTree<T = unknown> {
this.searchRadius(near, query, radius, results, depth + 1);
const shouldExplore =
this.distanceFn === cosine ? true : Math.abs(diff) <= radius;
this.distanceFn === cosineDistance ? true : Math.abs(diff) <= radius;
if (shouldExplore) {
this.searchRadius(far, query, radius, results, depth + 1);
+181
View File
@@ -0,0 +1,181 @@
import {MemoryNode, patchGraph, rebuildGraph} from './graph.ts';
import {KDTree} from './kd-tree.ts';
import type {Memory, MemoryRef, MemoryStore} from './memory.ts';
import {cosineDistance, embedMemoryFields} from '../utils.ts';
const TREE_TOMBSTONE_LIMIT = 0.25;
export function memoryStore(memories: MemoryStore): {
list: Memory[];
cache: MemoryCache | null;
find: (name: string) => Memory | undefined;
ghosts: () => string[];
search: (vector: number[], limit: number) => MemoryRef[];
forget: (name: string) => boolean;
rebuild: (changed?: Memory[]) => MemoryNode[];
backfillEmbeddings: (llm: any) => Promise<number>;
} {
if(memories instanceof MemoryCache) {
return {
list: memories.memories,
cache: memories,
find: name => memories.find(name),
ghosts: () => memories.ghosts(),
search: (vector, limit) => memories.search(vector, limit),
forget: name => memories.remove(name),
rebuild: changed => memories.rebuild(changed),
backfillEmbeddings: llm => memories.backfillEmbeddings(llm),
};
}
return {
list: memories,
cache: null,
find: name => memories.find(m => m.name === name),
ghosts: () => rebuildGraph(memories).filter(n => n.missing).map(n => n.name),
search: (vector, limit) => memories
.filter(m => m.embedding?.length)
.map(m => ({
name: m.name,
description: m.description,
distance: cosineDistance(vector, m.embedding),
}))
.sort((a, b) => a.distance - b.distance)
.slice(0, limit),
forget: name => {
const idx = memories.findIndex(m => m.name === name);
if(idx === -1) return false;
memories.splice(idx, 1);
return true;
},
rebuild: changed => rebuildGraph(memories),
backfillEmbeddings: async llm => {
const missing = memories.filter(m => !m.embedding?.length);
if(!missing.length) return 0;
await Promise.all(missing.map(async node => {
await embedMemoryFields(node, llm);
}));
return missing.length;
},
};
}
export class MemoryCache {
private tree!: KDTree<MemoryRef>;
private indexed = new Map<string, number[]>();
public memories: Memory[];
public nodes: MemoryNode[] = [];
get length() {
return this.memories.length;
}
constructor(memories: Memory[]) {
this.memories = memories;
this.tree = new KDTree<MemoryRef>(0);
this.rebuild();
}
find(name: string): Memory | undefined {
return this.memories.find(m => m.name === name);
}
private syncTree(): void {
const current = new Set(this.memories.map(m => m.name));
for(const [name, emb] of [...this.indexed]) {
const mem = this.memories.find(m => m.name === name);
if(!mem || !current.has(name) || mem.embedding !== emb) {
this.tree.remove(p => p.name === name);
this.indexed.delete(name);
}
}
for(const mem of this.memories) {
if(!mem.embedding?.length || this.indexed.has(mem.name)) continue;
if(this.tree.dims === 0) {
this.tree = new KDTree<MemoryRef>(mem.embedding.length, 'cosine');
}
if(mem.embedding.length !== this.tree.dims) continue;
this.tree.insert({
vector: mem.embedding,
payload: {
name: mem.name,
description: mem.description,
},
});
this.indexed.set(mem.name, mem.embedding);
}
if(this.tree.tombstoneRatio > TREE_TOMBSTONE_LIMIT) {
this.tree.rebalance();
}
}
search(query: number[], limit: number): MemoryRef[] {
if(!this.tree || this.tree.dims === 0) return [];
return this.tree.knn(query, limit).map(r => ({
...r.point.payload,
distance: r.distance,
}));
}
add(memory: Memory): void {
this.memories.push(memory);
this.rebuild([memory]);
}
update(memory: Memory): void {
const existing = this.find(memory.name);
if(existing) Object.assign(existing, memory);
else this.memories.push(memory);
this.rebuild([existing ?? memory]);
}
remove(name: string): boolean {
const idx = this.memories.findIndex(m => m.name === name);
if(idx === -1) return false;
this.memories.splice(idx, 1);
this.rebuild();
return true;
}
ghosts(): string[] {
return this.nodes.filter(n => n.missing).map(n => n.name);
}
rebuild(changed?: Memory[]): MemoryNode[] {
this.nodes = changed?.length && this.nodes.length
? patchGraph(this.memories, this.nodes, changed)
: rebuildGraph(this.memories);
this.syncTree();
return this.nodes;
}
commit(changed?: Memory[]): MemoryNode[] {
return this.rebuild(changed);
}
async backfillEmbeddings(llm: any): Promise<number> {
const missing = this.memories.filter(m => !m.embedding?.length);
if(!missing.length) return 0;
await Promise.all(missing.map(node => embedMemoryFields(node, llm)));
this.commit(missing);
return missing.length;
}
}
+348
View File
@@ -0,0 +1,348 @@
import {AiTool} from '../tools.ts';
import type {LLMMessage, LLMRequest} from '../llm.ts';
import {MemoryCache, memoryStore} from './memory-state.ts';
import {cosineDistance, embedMemoryFields, stripHeader, updateMemory} from '../utils.ts';
const FACT_SIMILARITY_THRESHOLD = 0.62;
const DUPLICATE_THRESHOLD = 0.68;
const PROTECTED_MEMORIES = ['People/User'];
const COLLECTION_WORDS = ['project', 'projects', 'people', 'person', 'managed', 'guides', 'guide', 'research', 'class', 'classes'];
export type Memory = {
name: string;
description: string;
content: string;
embedding: number[];
titleEmbedding?: number[];
bodyEmbeddings?: number[][];
links: string[];
backlinks: string[];
}
export type MemoryRef = {
name: string;
description: string;
distance?: number;
}
export type MemoryOptions = {
memory: Memory[] | MemoryCache;
inject?: boolean;
tool?: boolean;
update?: boolean;
maxTokens?: number;
}
export type MemoryStore = Memory[] | MemoryCache;
/** Create an empty memory shell. */
function emptyNode(name: string, description = ''): Memory {
return {name, description, content: `# ${name.split('/').pop()}\n`, embedding: [], links: [], backlinks: []};
}
function renderNode(node: Memory): string {
return `### ${node.name}
Description: ${node.description}
Links: ${[...node.links, ...node.backlinks].join(', ') || 'none'}
\`\`\`markdown
${node.content}
\`\`\``;
}
function factSimilarity(a: Memory, b: Memory): number {
return !a.bodyEmbeddings?.length || !b.bodyEmbeddings?.length ? 0 : Math.max(...a.bodyEmbeddings.flatMap(av => b.bodyEmbeddings!.map(bv => 1 - cosineDistance(av, bv))));
}
function words(text: string): string[] {
return [...new Set(text.toLowerCase().replace(/[[\]()/_-]/g, ' ').replace(/[^a-z0-9\s]/g, '').split(/\s+/).filter(w => w && !COLLECTION_WORDS.includes(w)))];
}
function jaccard(a: string[], b: string[]): number {
const bs = new Set(b), hit = a.filter(x => bs.has(x)).length, total = new Set([...a, ...b]).size;
return total ? hit / total : 0;
}
function duplicateScore(a: Memory, b: Memory): number {
const name = Math.max(
jaccard(words(a.name), words(b.name)),
jaccard(words(a.name.split('/').pop() || a.name), words(b.name.split('/').pop() || b.name)),
);
const desc = jaccard(words(a.description), words(b.description));
const body = factSimilarity(a, b);
const emb = a.embedding?.length && b.embedding?.length && a.embedding.length === b.embedding.length ? 1 - cosineDistance(a.embedding, b.embedding) : 0;
return Math.max(body, name * 0.9 + desc * 0.06 + emb * 0.04, emb * 0.55 + name * 0.35 + desc * 0.1);
}
function homeScore(node: Memory): number {
return (PROTECTED_MEMORIES.includes(node.name) ? 1e9 : 0)
+ (node.name.includes('/') ? 4 : 0)
+ (node.description && node.description !== 'Persistent memory document' ? 1 : 0)
+ Math.min(stripHeader(node.content).length / 1000, 5);
}
function pickMerge(a: Memory, b: Memory, touched: Set<string>): [drop: Memory, home: Memory] {
const as = homeScore(a), bs = homeScore(b);
if(touched.has(a.name) && !touched.has(b.name)) return as > bs + 2 ? [b, a] : [a, b];
if(touched.has(b.name) && !touched.has(a.name)) return bs > as + 2 ? [a, b] : [b, a];
return as <= bs ? [a, b] : [b, a];
}
/** Build memory tools and memory index text. */
export function memoryTools(llm: any, memories: MemoryStore): {tools: AiTool[]; list: string} {
const store = memoryStore(memories);
const names = new Map<string, string>();
for(const node of store.list)
if(!names.has(node.name)) names.set(node.name, `${node.name} - ${node.description}`);
for(const name of store.ghosts())
if(!names.has(name)) names.set(name, `${name} - ghost node`);
return {
list: [...names.values()].join('\n'),
tools: [
{
name: 'memory_search',
description: 'Semantically search memories for most relevant',
args: {
query: {type: 'string', description: 'Search query', required: true},
limit: {type: 'number', description: 'Maximum results, default 5', default: 5},
},
fn: async ({query, limit = 5}) => {
if(!query?.trim()) return 'Search query is required.';
const [chunk] = await llm.embedding(query, {maxTokens: 8000, overlapTokens: 0});
if(!chunk?.embedding) return 'Failed to create embedding from query';
const results = store.search(chunk.embedding, limit).map(ref => store.find(ref.name)).filter((node): node is Memory => !!node);
return results.length ? results.map(renderNode).join('\n\n---\n\n') : 'No relevant memories found.';
},
},
{
name: 'memory_read',
description: 'Read an entire memory document by name',
args: {name: {type: 'string', description: 'Exact document name', required: true}},
fn: async ({name}) => {
const node = store.find(name);
return node ? renderNode(node) : store.ghosts().includes(name) ? `"${name}" is a ghost node with no document of its own.` : `Not found: "${name}".`;
},
},
{
name: 'memory_delete',
description: 'Delete a duplicate or merged memory',
args: {name: {type: 'string', description: 'Exact document name', required: true}},
fn: async ({name}) => {
store.forget(name);
return `Removed: ${name}`;
},
},
{
name: 'memory_write',
description: 'Create or replace a memory document.',
args: {
name: {type: 'string', description: 'Document name following the entity naming convention.', required: true},
description: {type: 'string', description: 'One factual sentence describing the entire document subject', required: true},
content: {type: 'string', description: 'Complete Markdown document body, including the # title', required: true},
},
fn: async (args: any) => {
const name = String(args.name || '').trim();
if(!name) return 'A document name is required.';
const description = String(args.description || '').trim();
if(!description) return 'A document description is required.';
const content = String(args.content || '').trim();
if(!content) return 'Document content is required.';
let node = store.find(name);
if(!node) {
node = emptyNode(name, description);
if(store.cache) store.cache.add(node);
else store.list.push(node);
}
node.description = name === 'People/User' ? 'All information about the current user' : description.replace(/\s+/g, ' ').trim();
node.content = updateMemory(node, content);
await embedMemoryFields(node, llm);
store.cache?.commit([node]);
return `Updated ${name}`;
},
},
],
};
}
export class MemoryManager {
private memorized = new WeakMap<LLMMessage[], LLMMessage>();
constructor(private llm: any) {}
static normalize(memory?: Memory[] | MemoryCache | MemoryOptions): MemoryOptions | null {
if(!memory) return null;
if(Array.isArray(memory) || memory instanceof MemoryCache) return {memory, inject: true, tool: false, update: false};
if(typeof memory === 'object' && 'memory' in memory) return {inject: true, tool: false, update: false, ...memory};
return null;
}
private memorySystem(list: string): string {
return `You maintain notes written in markdown used for memories from recent conversations using your tools.
Only preserve durable information worth remembering established by the USER.
Do not store assistant guesses, speculation, suggestions, commentary, temporary state, or details that are not worth remembering.
## Rules
- ALWAYS READ a target memory before changing it, \`memory_write\` does a full replace, it DOES NOT append!
- Memories should contain the final state, not deltas
- New conversational context is authoritative when it contracts existing information; reconcile it
- Only remove information when stale, contradicted or duplicated; always preserve existing information, formatting and keep related information together
- Only merge memories when two or more nodes are clearly about the same thing; only split a memory when it is clearly about two distinct subjects
- Use [[WikiLinks]] liberally to record aliases and relationships between entities, even ones without pages yet (ghost nodes)
- Use headings, subheadings, lists, tables and other markdown formatting to make documents clean
- Maintain a \`## Todo List\` of checkboxes AS THE FIRST SUBHEADING when an entity has tasks
- Only create todo items for USER tasks, not AI work
- Only store each in one place, no duplicates
- Use \`People/User\` for personal tasks or as a fallback
## Naming
- Every fact should be grouped with the owning entity
- Always follow the naming convention \`Collection/(Pro)Noun\`
- Facts about the user belong under People/User
- Reuse existing memories when they are clearly the same entity including aliases and ghost references.
- Only create deeper paths when there is a real parent/child entity relationship: \`School/Class/Chapter\`
Valid Examples:
- People/User
- People/John Smith
- Projects/Momentum
- Projects/Momentum/Marketing
- Research/Object Recognition
- Guides/HAM Radio SOP
## Workflow
1. Create groups of durable information and todos based on the owning entity & naming rules above
2. For each group:
1. Read the existing memory(s)
2. Merge the information & todos based on the rules above
3. Write the entire patched document
Available memories:
${list || 'No memory documents exist yet.'}`;
}
private touchedNames(history: LLMMessage[]): string[] {
return [...new Set(history
.filter((h: any) => h.role === 'tool' && h.name === 'memory_write' && !h.error)
.map((h: any) => String(h.args?.name || h.content?.match(/^Updated (.+)$/)?.[1] || '').trim())
.filter(Boolean))];
}
private async backfillEmbeddings(store: ReturnType<typeof memoryStore>): Promise<void> {
const missing = store.list.filter(m => !m.embedding?.length || !m.titleEmbedding?.length || !m.bodyEmbeddings?.length);
await Promise.all(missing.map(m => embedMemoryFields(m, this.llm)));
store.cache?.commit(missing);
}
private closestDuplicate(node: Memory, store: ReturnType<typeof memoryStore>): Memory | null {
return store.list
.filter(m => m.name !== node.name && !m.name.startsWith('Journal/') && !node.name.startsWith('Journal/'))
.map(m => ({node: m, score: duplicateScore(node, m)}))
.filter(x => x.score >= DUPLICATE_THRESHOLD || factSimilarity(node, x.node) >= FACT_SIMILARITY_THRESHOLD)
.sort((a, b) => b.score - a.score)[0]?.node || null;
}
private async rehomeDeleted(drop: Memory, home: Memory, memories: MemoryStore, options: LLMRequest): Promise<void> {
const store = memoryStore(memories);
const backup = structuredClone(drop);
store.forget(drop.name);
try {
const memory = memoryTools(this.llm, memories);
await this.llm.ask(`A duplicate memory document was removed automatically.
Deleted document:
${renderNode(backup)}
Closest surviving home:
${renderNode(home)}
Reinsert every durable unique fact, useful relationship, alias, and user todo from the deleted document into the best remaining memory document.
Usually this should be "${home.name}", but use another existing memory if it is a better home.
Read before writing. Write full replacement documents only.
Do NOT recreate "${backup.name}" unless the deletion was wrong and it is clearly a distinct persistent entity.`, {
model: options.memoryModel || options.model,
temperature: 0.2,
maxTokens: options.maxTokens,
tools: memory.tools,
history: [],
system: this.memorySystem(memory.list),
});
} catch(err) {
if(!store.find(backup.name)) store.cache ? store.cache.add(backup) : store.list.push(backup);
throw err;
} finally {
store.cache?.commit(store.list);
}
}
private async reconcileSimilar(history: LLMMessage[], memories: MemoryStore, options: LLMRequest): Promise<void> {
const store = memoryStore(memories);
const touched = new Set(this.touchedNames(history));
const targets = store.list.filter(m => touched.has(m.name) || [...touched].some(t => duplicateScore(m, store.find(t) || m) >= DUPLICATE_THRESHOLD));
const deleted = new Set<string>();
if(!targets.length) return;
await this.backfillEmbeddings(store);
for(const node of targets) {
if(!store.find(node.name) || deleted.has(node.name) || PROTECTED_MEMORIES.includes(node.name)) continue;
const closest = this.closestDuplicate(node, store);
if(!closest) continue;
const [drop, home] = pickMerge(node, closest, touched);
if(deleted.has(drop.name) || PROTECTED_MEMORIES.includes(drop.name)) continue;
deleted.add(drop.name);
await this.rehomeDeleted(drop, home, memories, options);
await this.backfillEmbeddings(store);
}
}
async recollect(query: string, memory: MemoryStore, limit = 15): Promise<Memory[]> {
const store = memoryStore(memory);
if(!store.list.length || !query?.trim()) return [];
const [chunk] = await this.llm.embedding(query, {maxTokens: 8000, overlapTokens: 0});
return !chunk?.embedding ? [] : store.search(chunk.embedding, limit).map(ref => store.find(ref.name)).filter((m: Memory | undefined): m is Memory => !!m);
}
get tools(): {read: (memory: MemoryStore) => AiTool[]} {
return {read: (memory: MemoryStore) => memoryTools(this.llm, memory).tools};
}
async memorize(history: LLMMessage[], memories: Memory[] | MemoryCache, options: LLMRequest = {},): Promise<Memory[]> {
const store = memoryStore(memories);
const previous = this.memorized.get(history);
let start = 0;
if(previous) {
const index = history.indexOf(previous);
if(index >= 0) start = index + 1;
}
const turns = history.slice(start).filter((h: any) => h.role === 'user' || h.role === 'assistant');
const conversation = turns.map((h: any) => `[${h.role}]: ${h.content}`).join('\n\n').trim();
if(!conversation) return store.list;
const memory = memoryTools(this.llm, memories);
const memoryHistory: LLMMessage[] = [];
await this.llm.ask(conversation, {
model: options.memoryModel || options.model,
temperature: 0.2,
maxTokens: options.maxTokens,
tools: memory.tools,
history: memoryHistory,
system: this.memorySystem(memory.list),
});
await this.reconcileSimilar(memoryHistory, memories, options);
const lastTurn = turns.at(-1);
if(lastTurn) this.memorized.set(history, lastTurn);
return store.list;
}
}
+82
View File
@@ -0,0 +1,82 @@
import {Memory} from './memory/memory.ts';
export function cosineDistance(a: number[], b: number[]): number {
let dot = 0, normA = 0, normB = 0;
for(let i = 0; i < a.length; i++) {
dot += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
const denom = Math.sqrt(normA) * Math.sqrt(normB);
return denom === 0 ? 1 : 1 - dot / denom;
}
export async function embedMemoryFields(node: Memory, llm: any): Promise<void> {
const body = stripHeader(node.content);
const [titleE] = await llm.embedding(node.name.split('/').pop() || node.name);
const [descE] = await llm.embedding(node.description || '');
const bodyChunks = body ? await llm.embedding(body) : [];
if(titleE) node.titleEmbedding = titleE.embedding;
if(descE) node.embedding = descE.embedding;
node.bodyEmbeddings = bodyChunks.map((c: any) => c.embedding).filter(Boolean);
}
export function euclideanDistance(a: number[], b: number[]): number {
let sum = 0;
for(let i = 0; i < a.length; i++) {
const d = a[i] - b[i];
sum += d * d;
}
return Math.sqrt(sum);
}
export function getWeekStart(date: Date = new Date()): string {
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
const day = d.getUTCDay();
const diff = day === 0 ? -6 : 1 - day;
d.setUTCDate(d.getUTCDate() + diff);
return d.toISOString().slice(0, 10);
}
export function journalDescription(journalName?: string): string {
const start = journalName?.split('/').pop() || getWeekStart();
const d = new Date(`${start}T00:00:00Z`);
d.setUTCDate(d.getUTCDate() + 6);
const end = d.toISOString().slice(0, 10);
return `Log from ${start} - ${end}`;
}
function parseFrontmatter(content: string): {fm: Map<string, string>, body: string} {
const match = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
if(!match) return {fm: new Map(), body: content};
const fm = new Map<string, string>();
for(const line of match[1].split('\n')) {
const i = line.indexOf(':');
if(i === -1) continue;
const key = line.slice(0, i).trim();
const raw = line.slice(i + 1).trim();
let value = raw;
try { value = JSON.parse(raw); } catch { }
fm.set(key, value);
}
return {fm, body: match[2]};
}
export function writeFrontmatter(fm: Map<string, string>, body: string): string {
const lines = [...fm.entries()].map(([k, v]) =>
`${k}: ${JSON.stringify(String(v).replace(/\s+/g, ' ').trim())}`);
return `---\n${lines.join('\n')}\n---\n\n${body.trimStart()}`;
}
export function stripHeader(content: string): string {
return content.replace(/^---[\s\S]*?\n---\n?/, '').trimStart();
}
export function updateMemory(node: Memory, body: string): string {
const {fm} = parseFrontmatter(node.content);
fm.set('name', node.name);
fm.set('description', (node.name.startsWith('Journal/') ? journalDescription(node.name) : node.description)
|| 'Persistent memory document');
fm.set('modified', new Date().toISOString());
return writeFrontmatter(fm, stripHeader(body));
}