Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c1a16096ae | |||
| ff0ee0b60e |
@@ -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",
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
46
src/llm.ts
46
src/llm.ts
@@ -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);
|
||||||
|
|||||||
@@ -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.';
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user