Compare commits

...
2 Commits
Author SHA1 Message Date
ztimson 1f1a4662d4 Entity based notes
Publish Library / Build NPM Project (push) Successful in 41s
Publish Library / Tag Version (push) Successful in 14s
2026-09-18 22:37:05 -04:00
ztimson ee4147e24e Fixed opanai early termination from tool calls
Publish Library / Build NPM Project (push) Successful in 46s
Publish Library / Tag Version (push) Successful in 15s
2026-09-18 16:07:58 -04:00
3 changed files with 60 additions and 59 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@ztimson/ai-utils", "name": "@ztimson/ai-utils",
"version": "1.6.9", "version": "1.6.11",
"description": "AI Utility library", "description": "AI Utility library",
"author": "Zak Timson", "author": "Zak Timson",
"license": "MIT", "license": "MIT",
+49 -55
View File
@@ -8,13 +8,6 @@ const MERGE_THRESHOLD = 0.12;
const PENDING_HEADING = '## Pending'; const PENDING_HEADING = '## Pending';
const TREE_TOMBSTONE_LIMIT = 0.25; const TREE_TOMBSTONE_LIMIT = 0.25;
const ALIAS_MATCH_THRESHOLD = 0.55; const ALIAS_MATCH_THRESHOLD = 0.55;
const GENERIC_TEMPLATE = `# {{Title}}
## Summary
## Details
## Related`;
export type Memory = { export type Memory = {
name: string; name: string;
@@ -338,7 +331,6 @@ ${m.content}
const candidates = store.list.filter(m => m.name.split('/')[0] === root && m.name !== trimmed); const candidates = store.list.filter(m => m.name.split('/')[0] === root && m.name !== trimmed);
if (!candidates.length) return trimmed; if (!candidates.length) return trimmed;
// fuzzyMatch requires >=2 terms; pad with an empty string when there's only one candidate
const leaves = candidates.map(m => m.name.split('/').slice(1).join('/') || m.name); const leaves = candidates.map(m => m.name.split('/').slice(1).join('/') || m.name);
const probe = leaves.length > 1 ? leaves : [...leaves, '']; const probe = leaves.length > 1 ? leaves : [...leaves, ''];
const {max, similarities} = this.llm.fuzzyMatch(leaf, ...probe); const {max, similarities} = this.llm.fuzzyMatch(leaf, ...probe);
@@ -353,35 +345,35 @@ ${m.content}
const response = await this.llm.ask(conversation, { const response = await this.llm.ask(conversation, {
model: options.model, model: options.model,
temperature: 0.2, temperature: 0.2,
system: `You are a fact extractor for Obsidian-style knowledge vaults. Analyze the conversation and produce: system: `Extract durable memory from this conversation
1. Journal recap (single paragraph) 1. Journal recap
- "Captains Log" style record keeping - Brief "Captain's Log" of what happened, including useful context, decisions, or events
- What was discussed/worked on, decisions, user's events/state/mood, general context - Leave empty for trivial exchanges
- Leave empty only for trivial/empty exchanges/small talk
2. Fact buckets 2. Fact buckets
- ONLY facts the USER explicitly stated about themselves, their work, projects, or decisions made during this conversation - Extract only durable facts explicitly stated by the USER
- NEVER extract greetings, pleasantries, or anything the assistant itself said - Record the final/end state, not intermediate changes
- Extract the final/end state, not deltas - Do not extract assistant claims, guesses, greetings, or temporary conversation details
Path assignment (entity) rules: For each fact, identify its HOME ENTITY:
- Use the owning entity of the fact (even if implied): "New bug on project 51 -> Projects/51" - The HOME ENTITY name should always be a [abstract|pro]noun
- When multiple facts relate to the same entity, pick a primary owner and wikilink related entities - The grammatical subject/owner of the fact is the strongest clue
- Reuse existing entities when the owner already has a node - Prefer an existing entity over creating a new one
- Always group under consistent entity roots (always plural): - A document represents a persistent entity, not a topic, feature, bug, event, decision, setting, or conversation fragment
- Projects/[Name] for all initiatives - Put project facts under the project they belong to, person facts under the person, etc
- People/[Name] for all individuals - New child entities are appropriate only when they are themselves distinct persistent entities
- History/[Name] for all historical figures/events
- Science/[Name] for all scientific concepts
- Child entities nest under their parent entity:
- Projects/51/Memory System, Projects/51/Bug-XYZ, not Bugs/51
- Science/AI/Model-X, not Model-X/AI
Wikilink rules: Example Paths:
- Use [[WikiLinks]] to connect related entities (e.g., [[Projects/51]], [[People/Robert]]) - Projects/[Name]
- Only link specific, existing or implied entity paths — skip generic terms - People/[Name]
- Don't over-link: each link should add clarity or context, not noise - History/[Name]
- Science/[Name]
- [Subject]/[Name]
- Class/[Name]/[Child]
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: Available nodes:
${this.listNodes(store.list).map(n => `- ${n.name}: ${n.description}`).join('\n') || 'None yet.'} ${this.listNodes(store.list).map(n => `- ${n.name}: ${n.description}`).join('\n') || 'None yet.'}
@@ -390,7 +382,7 @@ ${ghosts.length ? `${ghosts.map(g => `- ${g}: (Ghost)`).join('\n')}` : ''}`,
journal: {type: 'string', description: 'Short day-to-day recap; empty if nothing happened.', required: false}, journal: {type: 'string', description: 'Short day-to-day recap; empty if nothing happened.', required: false},
buckets: {type: 'array', description: 'Groups of facts to remember; empty array if nothing worth storing.', items: { buckets: {type: 'array', description: 'Groups of facts to remember; empty array if nothing worth storing.', items: {
type: 'object', items: { type: 'object', items: {
subject: {type: 'string', description: 'Exact node name or new path (e.g. "People/Sarah", "Projects/Oxide")', required: true}, 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'}}, facts: {type: 'array', description: 'Facts to store here', items: {type: 'string'}},
}, },
}, },
@@ -497,24 +489,22 @@ ${ghosts.length ? `${ghosts.map(g => `- ${g}: (Ghost)`).join('\n')}` : ''}`,
description: {type: 'string', description: 'One factual sentence describing the document\'s ENTIRE SUBJECT MATTER — for use as a search/merge fingerprint', required: true}, 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}, content: {type: 'string', description: 'Rewritten document body in markdown, without the frontmatter block', required: true},
}, },
system: `You are a knowledge base editor maintaining one Obsidian-style document. system: `You maintain one persistent knowledge-base document
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. Rewrite the ENTIRE document, folding "## Pending" into the existing content. Remove the Pending section when finished
Use this loose structure, adapting headings to what the content needs: Document design:
\`\`\`markdown - The document represents one entity. Keep information about that entity together
${GENERIC_TEMPLATE} - Let the structure fit the entity; there is NO fixed template
\`\`\` - Preserve useful existing headings and organization. Don't redesign the document without reason
- 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 context only when it adds useful meaning
- Use [[WikiLinks]] for specific related entities; don't create redundant content for linked entities
- Avoid generic filler sections such as Notes, Miscellaneous, Recent, Updates, or Conversation
- No frontmatter, preamble, filler, or AI commentary
Rules: Available nodes to link to:
- Contradictions: "## Pending" holds the newest information — bias toward it. Fold it in as the standing fact and drop the outdated statement, unless the old context adds meaningful nuance (e.g. "previously X, now Y"). This document should read as a source of truth, not an audit log
- Journals (Journal/...): keep entries as a chronological timeline; clean up grammar within entries but never delete history
- Use Obsidian markdown: # headings, **bold**, bullet/numbered lists, tables for 2D data
- Link specific entities and concepts with [[WikiLink]] (e.g., [[Projects/KiwixServer]]); skip generics
- Keep concise, factual, human-readable
- NO frontmatter, filler, preamble, or AI commentary
Available nodes to link to (don't duplicate their content):
${this.listNodes(memories).filter(n => n.name !== node.name).map(n => n.name).join(', ') || 'none'} ${this.listNodes(memories).filter(n => n.name !== node.name).map(n => n.name).join(', ') || 'none'}
Current document: Current document:
@@ -545,18 +535,22 @@ ${currentBody}
model: options.model, model: options.model,
temperature: 0.3, temperature: 0.3,
schema: { schema: {
name: {type: 'string', description: 'New path for the merged doc, collection/subject format (e.g. Projects/Oxide) — only reuse an old title if it\'s genuinely the best fit', required: true}, name: {type: 'string', description: 'Canonical path for the merged entity', required: true},
description: {type: 'string', description: 'One factual sentence describing the merged document\'s subject matter', required: true}, description: {type: 'string', description: 'One factual sentence describing the merged document\'s subject matter', required: true},
content: {type: 'string', description: 'Fully reconciled body in markdown, without frontmatter', required: true}, content: {type: 'string', description: 'Fully reconciled body in markdown, without frontmatter', required: true},
}, },
system: `You are a knowledge base editor merging two overlapping Obsidian documents into one. system: `Determine whether these two documents represent the SAME persistent entity.
Structure loosely: Similarity of subject matter is NOT enough. Do not merge documents merely because they discuss the same project, person, technology, topic, or related work.
\`\`\`markdown
${GENERIC_TEMPLATE}
\`\`\`
Combine both documents, resolve duplication. On contradictions, bias toward whichever document was modified more recently; drop the outdated statement unless the old context adds meaningful nuance. Merge only when the evidence indicates they are duplicate identities, aliases, renamed entities, or two documents accidentally created for the same real-world entity. If they are distinct entities, they must remain separate.
If they are the same entity:
- Choose the canonical/most established path.
- Combine their information into one document and remove duplication.
- Preserve useful structure, technical specifics, history, and [[WikiLinks]].
- Prefer newer information when facts conflict.
- Return the canonical entity name and the fully reconciled document.
Document A ("${a.name}", last modified ${modifiedOf(a)}): Document A ("${a.name}", last modified ${modifiedOf(a)}):
\`\`\`markdown \`\`\`markdown
+10 -3
View File
@@ -110,7 +110,9 @@ export class OpenAi extends LLMProvider {
} }
if(chunk.choices[0]?.delta?.tool_calls) { if(chunk.choices[0]?.delta?.tool_calls) {
for(const deltaTC of chunk.choices[0].delta.tool_calls) { for(const deltaTC of chunk.choices[0].delta.tool_calls) {
const existing = msg.tool_calls.find((tc: any) => tc.index === deltaTC.index); const existing = deltaTC.index != null
? msg.tool_calls.find((tc: any) => tc.index === deltaTC.index)
: (deltaTC.id ? msg.tool_calls.find((tc: any) => tc.id === deltaTC.id) : undefined);
if(existing) { if(existing) {
if(deltaTC.id) existing.id = deltaTC.id; if(deltaTC.id) existing.id = deltaTC.id;
if(deltaTC.function?.name) existing.function.name = deltaTC.function.name; if(deltaTC.function?.name) existing.function.name = deltaTC.function.name;
@@ -133,8 +135,13 @@ export class OpenAi extends LLMProvider {
const duration = Date.now() - callStart; const duration = Date.now() - callStart;
const tps = usage?.completion_tokens && duration > 0 ? usage.completion_tokens / (duration / 1000) : 0; const tps = usage?.completion_tokens && duration > 0 ? usage.completion_tokens / (duration / 1000) : 0;
if(finishReason === 'length') { if(finishReason === 'length' && !controller.signal.aborted) {
console.warn('[OpenAi] Response truncated: max_completion_tokens reached before finish_reason=stop'); if(msg.content?.trim()) history.push({role: 'assistant', content: msg.content.trim(), timestamp: Date.now(), duration, tps});
throw new Error(`[OpenAI] Response hit token limit before completing`);
}
if(!finishReason && !controller.signal.aborted) {
throw new Error('[OpenAI] Stream ended prematurely - connection likely dropped');
} }
const toolCalls = msg.tool_calls || []; const toolCalls = msg.tool_calls || [];