Patched memory merging
All checks were successful
Publish Library / Build NPM Project (push) Successful in 45s
Publish Library / Tag Version (push) Successful in 10s

This commit is contained in:
2026-08-28 16:48:46 -04:00
parent 0a6f1e4d62
commit ff0ee0b60e
2 changed files with 16 additions and 23 deletions

View File

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

View File

@@ -4,7 +4,7 @@ import {AiTool} from './tools.ts';
import {KDPoint, KDTree} from './kd-tree.ts'; import {KDPoint, KDTree} from './kd-tree.ts';
import {escapeRegex} from '@ztimson/utils'; import {escapeRegex} from '@ztimson/utils';
const MERGE_THRESHOLD = 0.88; const MERGE_THRESHOLD = 0.12;
const PENDING_HEADING = '## Pending'; const PENDING_HEADING = '## Pending';
const GENERIC_TEMPLATE = `# {{Title}} const GENERIC_TEMPLATE = `# {{Title}}
@@ -191,13 +191,13 @@ export type MemoryOptions = {
} }
export class MemoryManager { export class MemoryManager {
private recentlyTouched = new Map<string, number>(); private mergeLock: Promise<any> = Promise.resolve();
private queues = new Map<string, { private queues = new Map<string, {
dirty: boolean, dirty: boolean,
request: {abort?: () => void} | null, request: {abort?: () => void} | null,
task: Promise<void>, task: Promise<void>,
}>(); }>();
private recentlyTouched = new Map<string, number>();
tools = { tools = {
forget: (memories: Memory[] | MemoryCache): AiTool => ({ forget: (memories: Memory[] | MemoryCache): AiTool => ({
@@ -351,10 +351,6 @@ ${ghosts.length ? `${ghosts.map(g => `- ${g}: (Ghost)`).join('\n')}` : ''}`,
return memories.map(m => ({name: m.name, description: m.description})); return memories.map(m => ({name: m.name, description: m.description}));
} }
/** Find the nearest node above the similarity threshold and fold the smaller/less-connected one into
* the other. Journals are exempt — they're partitioned by date, not topic, and merging across weeks
* would wreck the timeline. Returns 'merged' if `node` absorbed another (caller should re-run the doc
* agent), 'absorbed' if `node` itself got folded away (caller should stop touching it), or null. */
private async checkMerge(node: Memory, memories: Memory[] | MemoryCache, options: LLMRequest, threshold = MERGE_THRESHOLD): Promise<Memory | null> { private async checkMerge(node: Memory, memories: Memory[] | MemoryCache, options: LLMRequest, threshold = MERGE_THRESHOLD): Promise<Memory | null> {
if (!node.embedding?.length || node.name.startsWith('Journal/')) return null; if (!node.embedding?.length || node.name.startsWith('Journal/')) return null;
const store = this.access(memories); const store = this.access(memories);
@@ -404,8 +400,9 @@ ${ghosts.length ? `${ghosts.map(g => `- ${g}: (Ghost)`).join('\n')}` : ''}`,
do { do {
entry.dirty = false; entry.dirty = false;
await this.docAgent(current, store.list, options, entry); await this.docAgent(current, store.list, options, entry);
const merged = await this.checkMerge(current, memories, options); this.mergeLock = this.mergeLock.then(() => this.checkMerge(current, memories, options));
if (merged) { current = merged; entry.dirty = true; } const merged = await this.mergeLock;
if(merged) current = merged;
} while (entry.dirty); } while (entry.dirty);
})().finally(() => { })().finally(() => {
this.queues.delete(key); this.queues.delete(key);
@@ -432,11 +429,9 @@ ${ghosts.length ? `${ghosts.map(g => `- ${g}: (Ghost)`).join('\n')}` : ''}`,
If it has a "## Pending" section, fold all new material into the appropriate part, resolve overlap, then remove the section entirely. If no section, just tidy per the rules below. If it has a "## Pending" section, fold all new material into the appropriate part, resolve overlap, then remove the section entirely. If no section, just tidy per the rules below.
Use this loose structure, adapting headings to what the content needs: Use this loose structure, adapting headings to what the content needs:
\`\`\`markdown
# Title ${GENERIC_TEMPLATE}
## Summary \`\`\`
## Details
## Related
Rules: Rules:
- Contradictions: newer facts always win — delete outdated statements entirely - Contradictions: newer facts always win — delete outdated statements entirely
@@ -483,11 +478,9 @@ ${currentBody}
system: `You are a knowledge base editor merging two overlapping Obsidian documents into one. Newer facts win on contradiction. system: `You are a knowledge base editor merging two overlapping Obsidian documents into one. Newer facts win on contradiction.
Structure loosely: Structure loosely:
\`\`\`markdown
# Title ${GENERIC_TEMPLATE}
## Summary \`\`\`
## Details
## Related
Combine both documents, resolve duplication and contradictions. Combine both documents, resolve duplication and contradictions.
@@ -617,16 +610,16 @@ ${stripHeader(b.content)}
touched.push(node); touched.push(node);
} }
for (const node of touched) { await Promise.all(touched.map(async node => {
const [e] = await this.llm.embedding(`${node.description}\n\n${stripHeader(node.content)}`.trim()); const [e] = await this.llm.embedding(`${node.description}\n\n${stripHeader(node.content)}`.trim());
if (e) node.embedding = e.embedding; if (e) node.embedding = e.embedding;
this.touch(node.name); this.touch(node.name);
} }));
if (touched.length) { if (touched.length) {
store.commit(); store.commit();
(pending as any).content = `Saved to ${touched.map(n => `[[${n.name}]]`).join(', ')}`; (pending as any).content = `Saved to ${touched.map(n => `[[${n.name}]]`).join(', ')}`;
await Promise.all(touched.map(node => this.reconcile(node, memories, options).catch(() => {}))); Promise.all(touched.map(node => this.reconcile(node, memories, options).catch(() => {})));
} else { } else {
(pending as any).content = 'Nothing worth remembering.'; (pending as any).content = 'Nothing worth remembering.';
} }