Compare commits

...

2 Commits
1.6.4 ... 1.6.6

Author SHA1 Message Date
c1a16096ae Keep message progress on abort
All checks were successful
Publish Library / Build NPM Project (push) Successful in 43s
Publish Library / Tag Version (push) Successful in 14s
2026-08-29 21:18:28 -04:00
ff0ee0b60e Patched memory merging
All checks were successful
Publish Library / Build NPM Project (push) Successful in 45s
Publish Library / Tag Version (push) Successful in 10s
2026-08-28 16:48:46 -04:00
4 changed files with 52 additions and 35 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "@ztimson/ai-utils", "name": "@ztimson/ai-utils",
"version": "1.6.4", "version": "1.6.6",
"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 { Audio } from './audio.ts';
import {Vision} from './vision.ts'; import {Vision} from './vision.ts';
export type AbortablePromise<T> = Promise<T> & { export type AbortablePromise<T> = Promise<T> & {
abort: () => any abort: (keep?: boolean) => any
}; };
export type AiOptions = { export type AiOptions = {

View File

@@ -265,7 +265,7 @@ class LLM {
}; };
} }
private setupAgent(agents: Agent[] = [], allAgents: Agent[], history: LLMMessage[], aborts: (() => void)[], depth = 0, delegateState: {resp: string | null}): AiTool[] { private setupAgent(agents: Agent[] = [], allAgents: Agent[], history: LLMMessage[], aborts: ((keep?: boolean) => void)[], depth = 0, delegateState: {resp: string | null}): AiTool[] {
return agents.map(a => { return agents.map(a => {
const toolName = `${a.delegate ? '' : 'sub'}agent_${snakeCase(a.name)}`; const toolName = `${a.delegate ? '' : 'sub'}agent_${snakeCase(a.name)}`;
return { return {
@@ -397,11 +397,13 @@ ${a.system}`,
if(!this.models[m]) throw new Error(`Model does not exist: ${m}`); if(!this.models[m]) throw new Error(`Model does not exist: ${m}`);
let request: AbortablePromise<string> | null = null; let request: AbortablePromise<string> | null = null;
let aborted = false; let aborted = false;
const nestedAborts: (() => void)[] = []; let keepOnAbort = true;
const abort = () => { const nestedAborts: ((keep?: boolean) => void)[] = [];
const abort = (keep = true) => {
aborted = true; aborted = true;
request?.abort?.(); keepOnAbort = keep;
nestedAborts.forEach(a => a()); request?.abort?.(keep);
nestedAborts.forEach(a => a(keep));
}; };
let promise: any; let promise: any;
@@ -411,9 +413,25 @@ ${a.system}`,
let tools: AiTool[] = options.tools || this.ai.options.llm?.tools || []; let tools: AiTool[] = options.tools || this.ai.options.llm?.tools || [];
const prompts: string[] = []; const prompts: string[] = [];
let history = options.history || []; let history = options.history || [];
const historyStart = history.length;
const files = options.files || []; const files = options.files || [];
if(message || files.length) history.push({role: 'user', content: message || '', timestamp: Date.now()}); if(message || files.length) history.push({role: 'user', content: message || '', timestamp: Date.now()});
// Accumulate streamed text so it can be committed to history if aborted mid-generation
let partialText = '';
const onStream = options.stream;
const stream = (chunk: {text?: string, tool?: string, done?: true}) => {
if(chunk.text) partialText += chunk.text;
return onStream?.(chunk);
};
/** Commit (keep) or discard this turn's progress on abort, then throw */
const abortNow = (): never => {
if(keepOnAbort) { if(partialText) history.push({role: 'assistant', content: partialText, timestamp: Date.now()}); }
else history.splice(historyStart, history.length - historyStart);
throw Object.assign(new Error('Aborted'), {name: 'AbortError'});
};
// MCP // MCP
const mcp = options.mcp || this.ai.options?.llm?.mcp; const mcp = options.mcp || this.ai.options?.llm?.mcp;
if(mcp?.length) { if(mcp?.length) {
@@ -441,8 +459,8 @@ ${a.system}`,
const mems = mem.memory instanceof MemoryCache ? mem.memory.memories : mem.memory; const mems = mem.memory instanceof MemoryCache ? mem.memory.memories : mem.memory;
if(mems.length) { if(mems.length) {
if(mem.inject) { if(mem.inject) {
const pool = 15; // candidates considered, cheap since only refs are listed const pool = 15;
const budget = mem.maxTokens ?? 2000; // actual content injected const budget = mem.maxTokens ?? 2000;
const relevant = await this.memoryManager.recollect(message, mem.memory, pool); const relevant = await this.memoryManager.recollect(message, mem.memory, pool);
let used = 0; let used = 0;
@@ -481,7 +499,7 @@ Linked: ${makeUnique([...r.links, ...r.backlinks]).join(', ')}
} }
} }
if(aborted) throw Object.assign(new Error('Aborted'), {name: 'AbortError'}); if(aborted) abortNow();
const lastMsg = history[history.length - 1]; const lastMsg = history[history.length - 1];
if(files.length && lastMsg?.role === 'user') lastMsg.files = files; if(files.length && lastMsg?.role === 'user') lastMsg.files = files;
@@ -500,11 +518,17 @@ Linked: ${makeUnique([...r.links, ...r.backlinks]).join(', ')}
const toolTimings = new Map<string, {duration: number, tps: number}>(); const toolTimings = new Map<string, {duration: number, tps: number}>();
tools = this.wrapToolTiming(tools, toolTimings); tools = this.wrapToolTiming(tools, toolTimings);
if(aborted) throw Object.assign(new Error('Aborted'), {name: 'AbortError'}); if(aborted) abortNow();
prompts.unshift(options.system || this.ai.options.llm?.system || ''); prompts.unshift(options.system || this.ai.options.llm?.system || '');
request = this.models[m].ask('', {...options, tools, system: prompts.filter(Boolean).join('\n\n')}); request = this.models[m].ask('', {...options, tools, stream, system: prompts.filter(Boolean).join('\n\n')});
let resp = await request; let resp: string;
try {
resp = await request;
} catch(err: any) {
if(aborted) return abortNow();
throw err;
}
// Strip the file injection shim // Strip the file injection shim
restores.forEach(({msg, content}) => msg.content = content); restores.forEach(({msg, content}) => msg.content = content);

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.';
} }