@hera-al/server 1.6.29 → 1.6.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- import{hostname as e,type as n,release as t,arch as o}from"node:os";import{modelRefName as s}from"../config.js";import{loadTemplate as a,loadBuiltInTools as r,formatWorkspaceFiles as i,filterForSubagent as l}from"./workspace-files.js";import{createLogger as c}from"../utils/logger.js";const d=c("PromptBuilder");export function buildSystemPrompt(c){const{config:u,sessionContext:p,mode:g,hasNodeTools:f,hasMessageTools:y,coderSkill:T,subagentTask:_,toolServers:E}=c,I="minimal"===g?"SYSTEM_PROMPT_SUBAGENT.md":"SYSTEM_PROMPT.md",S=a(u.dataDir,I),w="minimal"===g?l(c.workspaceFiles):c.workspaceFiles;var b;const O=resolvePlaceholders(S,{SESSION_KEY:p.sessionKey,CHANNEL:p.channel,CHAT_ID:p.chatId,SESSION_ID:p.sessionId||"(new session)",MODEL:s(u.agent.model),HOSTNAME:e(),OS:`${n()} ${t()} (${o()})`,WORKSPACE_DIR:u.agent.workspacePath,DATA_DIR:u.dataDir,MEMORY_FILE:p.memoryFile||"(memory disabled)",ATTACHMENTS_DIR:p.attachmentsDir||"(memory disabled)",TIMEZONE:u.timezone||Intl.DateTimeFormat().resolvedOptions().timeZone,SUBAGENT_TASK:_??"",NODE_TOOLS_INSTRUCTIONS:f?"# Remote Nodes\n\nYou have access to remote nodes — external machines that you can control. Use the node tools to discover and interact with them.\n\n## Available tools\n\n- **list_nodes**: Call this to see which nodes are currently connected. Returns each node's ID, name, platform, hostname, and available commands. Always call this first before trying to execute commands, so you know which nodes are online and what their IDs are.\n\n- **node_exec**: Execute a command on a specific node. You must provide the nodeId (from list_nodes), the command name, and its parameters.\n\n## Supported commands\n\n- **shell.run**: Run a shell command on the node. Params: { cmd: string, args?: string[], cwd?: string, timeout?: number, env?: Record<string,string> }. Returns { stdout, stderr, exitCode }.\n- **shell.which**: Check if a binary exists on the node. Params: { cmd: string }. Returns { path } or null.\n\n## Guidelines\n\n- Always call list_nodes first to discover available nodes and their IDs. Do not guess node IDs.\n- When a user asks to run something on a remote machine, a node, or a specific hostname, call list_nodes to see what's online, then use node_exec with the appropriate nodeId.\n- If multiple nodes are connected, ask the user which one to use when the intent is ambiguous.\n- If no nodes are connected, inform the user that no remote nodes are available.\n- Report command results clearly: show stdout, note any stderr, and mention non-zero exit codes.":"",MESSAGE_TOOLS_INSTRUCTIONS:y?"# Messaging\n\nYou have tools to send messages to chat channels. Each message you process includes a <session_info> block with the current channel and chatId.\n\n## Available tools\n\n- **send_message**: Send a text message to a specific channel and chat. Use the channel and chatId from the session context to reply on the current conversation. You can also send to a different channel or chatId if instructed.\n\n- **list_channels**: List all registered channels. Returns each channel's name and whether it is active.\n\n## Guidelines\n\n- Use send_message when you need to proactively send a message outside of the normal response flow (e.g. notifications, forwarding, or sending to a different chat).\n- Your normal response text is already delivered to the user. Only use send_message for additional messages or cross-channel communication.\n- The channel and chatId from the session context identify the current conversation. Use them to send follow-up messages to the same chat.\n- If the user asks you to message someone on a different channel or chat, use the appropriate channel name and chatId.\n- Never spam or send unsolicited messages. Only send when explicitly asked or when it is clearly part of the task.":"",HEARTBEAT_INSTRUCTIONS:u.cron.enabled?(b=u.cron.heartbeat.message,`# Heartbeats\n\nHeartbeat prompt: ${b}\nIf you receive a heartbeat poll (a user message matching the heartbeat prompt above), and there is nothing that needs attention, reply exactly:\nHEARTBEAT_OK\nThe system treats a leading/trailing "HEARTBEAT_OK" as a heartbeat ack and may suppress it (not deliver to the user).\nIf something needs attention, do NOT include "HEARTBEAT_OK"; reply with the alert text instead.`):"",HEARTBEAT_PROMPT:u.cron.enabled?u.cron.heartbeat.message:"",CLAUDE_BUILT_IN_TOOLS:T?"":r(u.dataDir,g),SEARCH_IN_MEMORIES:"builtin-only"!==u.memory.recallStrategy?"## Memory Search Tools\n\nYou have access to memory search tools for recalling past conversations and knowledge:\n\n- `memory_search` — semantically searches Markdown chunks (~400 token target, 80-token overlap) from `MEMORY.md` + `memory/**/*.md`. It returns snippet text (capped ~700 chars), file path, line range, and score. No full file payload is returned.\n- `memory_get` — reads a specific memory Markdown file (workspace-relative), optionally from a starting line and for N lines. Paths outside `MEMORY.md` / `memory/` are rejected.\n\nUse `memory_search` to find relevant past context before answering questions that might relate to previous conversations. Use `memory_get` to read the full content of a memory file when a search snippet is not enough.":"",AVAILABLE_TOOLS:m(E),RUNTIME_LINE:h(u,p),WORKSPACE_FILES:i(w)}).replace(/\n{3,}/g,"\n\n");return d.debug(`System prompt built (mode=${g}, template=${I}, length=${O.length})`),O}export function resolvePlaceholders(e,n){return e.replace(/\{\{(\w+)\}\}/g,(e,t)=>t in n?n[t]:(d.warn(`Unknown placeholder: {{${t}}}`),`{{${t}}}`))}function m(e){if(!e||0===e.length)return"";const n=[];for(const t of e){const e=t,o=e.instance?._registeredTools;if(o)for(const[e,t]of Object.entries(o)){const o=t.description||"",s=t.inputSchema?.def?.shape,a=[];if(s)for(const[e,n]of Object.entries(s)){let t=n.type||"unknown";"ZodNumber"===t?t="number":"ZodString"===t?t="string":"ZodBoolean"===t?t="boolean":"ZodArray"===t?t="array":"ZodObject"===t?t="object":"ZodEnum"===t&&(t="enum");const o=n.isOptional?.()??!1;a.push(`${e}${o?"?":""}: ${t}`)}const r=a.length>0?`Params: { ${a.join(", ")} }`:"No parameters.",i=o.match(/\.\s*(Returns?\s.+?)\.?\s*$/i),l=i?i[1].trim():"";let c=`- **${e}**: ${i?o.slice(0,i.index).trim():o.trim()}. ${r}`;l&&(c+=`. ${l}.`),n.push(c)}}return 0===n.length?"":n.join("\n")}function h(a,r){return`host=${e()} | os=${n()} ${t()} (${o()}) | model=${s(a.agent.model)} | channel=${r.channel} | session=${r.sessionKey}`}export function buildPrompt(e,n,t){const o=[],s=[];n&&o.push("<conversation_history>",n,"</conversation_history>","");for(const n of e.contentBlocks)"text"===n.type&&n.text?o.push(n.text):"image"===n.type&&n.imageBase64&&s.push({base64:n.imageBase64,mimeType:n.imageMimeType??"image/jpeg"});const a=e.savedFiles.filter(e=>!e.endsWith(".tgs"));if(a.length>0){o.push(""),o.push("Files available in the current working directory:");for(const e of a)o.push(`- ${e}`)}return{text:o.join("\n"),images:s}}
1
+ import{hostname as e,type as n,release as t,arch as o}from"node:os";import{modelRefName as s}from"../config.js";import{loadTemplate as a,loadBuiltInTools as r,formatWorkspaceFiles as i,filterForSubagent as l}from"./workspace-files.js";import{createLogger as c}from"../utils/logger.js";const d=c("PromptBuilder");export function buildSystemPrompt(c){const{config:u,sessionContext:p,mode:g,hasNodeTools:f,hasMessageTools:y,coderSkill:T,subagentTask:_,toolServers:E}=c,S="minimal"===g?"SYSTEM_PROMPT_SUBAGENT.md":"SYSTEM_PROMPT.md",I=a(u.dataDir,S),w="minimal"===g?l(c.workspaceFiles):c.workspaceFiles;var b;const O=resolvePlaceholders(I,{SESSION_KEY:p.sessionKey,CHANNEL:p.channel,CHAT_ID:p.chatId,SESSION_ID:p.sessionId||"(new session)",MODEL:s(u.agent.model),HOSTNAME:e(),OS:`${n()} ${t()} (${o()})`,WORKSPACE_DIR:u.agent.workspacePath,DATA_DIR:u.dataDir,MEMORY_FILE:p.memoryFile||"(memory disabled)",ATTACHMENTS_DIR:p.attachmentsDir||"(memory disabled)",TIMEZONE:u.timezone||Intl.DateTimeFormat().resolvedOptions().timeZone,CURRENT_DATE:(new Date).toLocaleDateString("en-US",{timeZone:u.timezone||void 0,weekday:"long",year:"numeric",month:"long",day:"numeric"}),SUBAGENT_TASK:_??"",NODE_TOOLS_INSTRUCTIONS:f?"# Remote Nodes\n\nYou have access to remote nodes — external machines that you can control. Use the node tools to discover and interact with them.\n\n## Available tools\n\n- **list_nodes**: Call this to see which nodes are currently connected. Returns each node's ID, name, platform, hostname, and available commands. Always call this first before trying to execute commands, so you know which nodes are online and what their IDs are.\n\n- **node_exec**: Execute a command on a specific node. You must provide the nodeId (from list_nodes), the command name, and its parameters.\n\n## Supported commands\n\n- **shell.run**: Run a shell command on the node. Params: { cmd: string, args?: string[], cwd?: string, timeout?: number, env?: Record<string,string> }. Returns { stdout, stderr, exitCode }.\n- **shell.which**: Check if a binary exists on the node. Params: { cmd: string }. Returns { path } or null.\n\n## Guidelines\n\n- Always call list_nodes first to discover available nodes and their IDs. Do not guess node IDs.\n- When a user asks to run something on a remote machine, a node, or a specific hostname, call list_nodes to see what's online, then use node_exec with the appropriate nodeId.\n- If multiple nodes are connected, ask the user which one to use when the intent is ambiguous.\n- If no nodes are connected, inform the user that no remote nodes are available.\n- Report command results clearly: show stdout, note any stderr, and mention non-zero exit codes.":"",MESSAGE_TOOLS_INSTRUCTIONS:y?"# Messaging\n\nYou have tools to send messages to chat channels. Each message you process includes a <session_info> block with the current channel and chatId.\n\n## Available tools\n\n- **send_message**: Send a text message to a specific channel and chat. Use the channel and chatId from the session context to reply on the current conversation. You can also send to a different channel or chatId if instructed.\n\n- **list_channels**: List all registered channels. Returns each channel's name and whether it is active.\n\n## Guidelines\n\n- Use send_message when you need to proactively send a message outside of the normal response flow (e.g. notifications, forwarding, or sending to a different chat).\n- Your normal response text is already delivered to the user. Only use send_message for additional messages or cross-channel communication.\n- The channel and chatId from the session context identify the current conversation. Use them to send follow-up messages to the same chat.\n- If the user asks you to message someone on a different channel or chat, use the appropriate channel name and chatId.\n- Never spam or send unsolicited messages. Only send when explicitly asked or when it is clearly part of the task.":"",HEARTBEAT_INSTRUCTIONS:u.cron.enabled?(b=u.cron.heartbeat.message,`# Heartbeats\n\nHeartbeat prompt: ${b}\nIf you receive a heartbeat poll (a user message matching the heartbeat prompt above), and there is nothing that needs attention, reply exactly:\nHEARTBEAT_OK\nThe system treats a leading/trailing "HEARTBEAT_OK" as a heartbeat ack and may suppress it (not deliver to the user).\nIf something needs attention, do NOT include "HEARTBEAT_OK"; reply with the alert text instead.`):"",HEARTBEAT_PROMPT:u.cron.enabled?u.cron.heartbeat.message:"",CLAUDE_BUILT_IN_TOOLS:T?"":r(u.dataDir,g),SEARCH_IN_MEMORIES:"builtin-only"!==u.memory.recallStrategy?"## Memory Search Tools\n\nYou have access to memory search tools for recalling past conversations and knowledge:\n\n- `memory_search` — semantically searches Markdown chunks (~400 token target, 80-token overlap) from `MEMORY.md` + `memory/**/*.md`. It returns snippet text (capped ~700 chars), file path, line range, and score. No full file payload is returned.\n- `memory_get` — reads a specific memory Markdown file (workspace-relative), optionally from a starting line and for N lines. Paths outside `MEMORY.md` / `memory/` are rejected.\n\nUse `memory_search` to find relevant past context before answering questions that might relate to previous conversations. Use `memory_get` to read the full content of a memory file when a search snippet is not enough.":"",AVAILABLE_TOOLS:m(E),RUNTIME_LINE:h(u,p),WORKSPACE_FILES:i(w)}).replace(/\n{3,}/g,"\n\n");return d.debug(`System prompt built (mode=${g}, template=${S}, length=${O.length})`),O}export function resolvePlaceholders(e,n){return e.replace(/\{\{(\w+)\}\}/g,(e,t)=>t in n?n[t]:(d.warn(`Unknown placeholder: {{${t}}}`),`{{${t}}}`))}function m(e){if(!e||0===e.length)return"";const n=[];for(const t of e){const e=t,o=e.instance?._registeredTools;if(o)for(const[e,t]of Object.entries(o)){const o=t.description||"",s=t.inputSchema?.def?.shape,a=[];if(s)for(const[e,n]of Object.entries(s)){let t=n.type||"unknown";"ZodNumber"===t?t="number":"ZodString"===t?t="string":"ZodBoolean"===t?t="boolean":"ZodArray"===t?t="array":"ZodObject"===t?t="object":"ZodEnum"===t&&(t="enum");const o=n.isOptional?.()??!1;a.push(`${e}${o?"?":""}: ${t}`)}const r=a.length>0?`Params: { ${a.join(", ")} }`:"No parameters.",i=o.match(/\.\s*(Returns?\s.+?)\.?\s*$/i),l=i?i[1].trim():"";let c=`- **${e}**: ${i?o.slice(0,i.index).trim():o.trim()}. ${r}`;l&&(c+=`. ${l}.`),n.push(c)}}return 0===n.length?"":n.join("\n")}function h(a,r){return`host=${e()} | os=${n()} ${t()} (${o()}) | model=${s(a.agent.model)} | channel=${r.channel} | session=${r.sessionKey}`}export function buildPrompt(e,n,t){const o=[],s=[];n&&o.push("<conversation_history>",n,"</conversation_history>","");for(const n of e.contentBlocks)"text"===n.type&&n.text?o.push(n.text):"image"===n.type&&n.imageBase64&&s.push({base64:n.imageBase64,mimeType:n.imageMimeType??"image/jpeg"});const a=e.savedFiles.filter(e=>!e.endsWith(".tgs"));if(a.length>0){o.push(""),o.push("Files available in the current working directory:");for(const e of a)o.push(`- ${e}`)}return{text:o.join("\n"),images:s}}
@@ -72,6 +72,16 @@ export declare class ConceptStore {
72
72
  * Returns the node IDs along the path and the triples connecting them.
73
73
  */
74
74
  findPath(from: string, to: string, maxDepth?: number): PathResult;
75
+ /**
76
+ * Navigate the graph with temporal filtering on triples.
77
+ * Like query(), but only includes triples created within the given time range.
78
+ * Inspired by MAGMA multi-view traversal (TuringPost survey, Mar 2026).
79
+ */
80
+ queryTemporal(nodeId: string, depth?: number, options?: {
81
+ since?: string;
82
+ until?: string;
83
+ recentFirst?: boolean;
84
+ }): QueryResult;
75
85
  /**
76
86
  * Fuzzy search on concept labels. Returns matching concepts.
77
87
  */
@@ -1 +1 @@
1
- import{existsSync as e,readFileSync as t}from"node:fs";import{join as s}from"node:path";import r from"better-sqlite3";import{createLogger as n}from"../utils/logger.js";const c=n("ConceptStore");export class ConceptStore{db;constructor(e){const t=s(e,"concepts.db");this.db=new r(t),this.db.pragma("journal_mode = WAL"),this.db.pragma("foreign_keys = ON"),this.migrate(),c.info(`ConceptStore opened: ${t}`)}migrate(){this.db.exec("\n CREATE TABLE IF NOT EXISTS concepts (\n id TEXT PRIMARY KEY,\n label TEXT NOT NULL,\n created_at TEXT DEFAULT (datetime('now')),\n last_accessed TEXT,\n access_count INTEGER DEFAULT 0,\n source TEXT DEFAULT 'migration'\n );\n\n CREATE TABLE IF NOT EXISTS triples (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n subject TEXT NOT NULL,\n predicate TEXT NOT NULL,\n object TEXT NOT NULL,\n created_at TEXT DEFAULT (datetime('now')),\n source TEXT DEFAULT 'migration'\n );\n\n CREATE TABLE IF NOT EXISTS concept_drafts (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n text TEXT NOT NULL,\n context TEXT,\n session_key TEXT,\n created_at TEXT DEFAULT (datetime('now')),\n processed INTEGER DEFAULT 0\n );\n\n CREATE INDEX IF NOT EXISTS idx_triples_subject ON triples(subject);\n CREATE INDEX IF NOT EXISTS idx_triples_object ON triples(object);\n CREATE INDEX IF NOT EXISTS idx_triples_predicate ON triples(predicate);\n CREATE UNIQUE INDEX IF NOT EXISTS idx_triples_unique ON triples(subject, predicate, object);\n CREATE INDEX IF NOT EXISTS idx_concepts_access ON concepts(access_count, last_accessed);\n CREATE INDEX IF NOT EXISTS idx_drafts_pending ON concept_drafts(processed);\n ")}query(e,t=1){const s=this.db.prepare("SELECT * FROM concepts WHERE id = ?").get(e);if(!s){const s=this.db.prepare("SELECT * FROM concepts WHERE label LIKE ? LIMIT 1").get(`%${e}%`);return s?this.query(s.id,t):{center:null,concepts:[],triples:[]}}this.db.prepare("UPDATE concepts SET last_accessed = datetime('now'), access_count = access_count + 1 WHERE id = ?").run(e);const r=new Set([e]);let n=[e];const c=[];for(let e=0;e<t&&0!==n.length;e++){const e=n.map(()=>"?").join(","),t=this.db.prepare(`SELECT * FROM triples WHERE subject IN (${e})`).all(...n),s=this.db.prepare(`SELECT * FROM triples WHERE object IN (${e})`).all(...n),o=[];for(const e of[...t,...s]){c.push(e);for(const t of[e.subject,e.object])r.has(t)||(r.add(t),o.push(t))}n=o}const o=[...r],E=[];for(let e=0;e<o.length;e+=500){const t=o.slice(e,e+500),s=t.map(()=>"?").join(","),r=this.db.prepare(`SELECT * FROM concepts WHERE id IN (${s})`).all(...t);E.push(...r)}const i=new Set;return{center:s,concepts:E,triples:c.filter(e=>!i.has(e.id)&&(i.add(e.id),!0))}}findPath(e,t,s=6){if(e===t)return{found:!0,path:[e],triples:[]};const r=new Map,n=new Set([e]);let c=[e];for(let o=0;o<s&&0!==c.length;o++){const s=[],o=c.map(()=>"?").join(","),E=this.db.prepare(`SELECT * FROM triples WHERE subject IN (${o})`).all(...c),i=this.db.prepare(`SELECT * FROM triples WHERE object IN (${o})`).all(...c);for(const c of[...E,...i]){const o=[{node:c.object,from:c.subject},{node:c.subject,from:c.object}];for(const{node:E,from:i}of o)if(!n.has(E)&&n.has(i)&&(n.add(E),r.set(E,{parent:i,triple:c}),s.push(E),E===t)){const s=[t],n=[];let c=t;for(;c!==e;){const e=r.get(c);n.unshift(e.triple),s.unshift(e.parent),c=e.parent}return{found:!0,path:s,triples:n}}}c=s}return{found:!1,path:[],triples:[]}}search(e,t=10){return this.db.prepare("SELECT * FROM concepts WHERE label LIKE ? ORDER BY access_count DESC LIMIT ?").all(`%${e}%`,t)}stats(){return{totalConcepts:this.db.prepare("SELECT COUNT(*) as c FROM concepts").get().c,totalTriples:this.db.prepare("SELECT COUNT(*) as c FROM triples").get().c,totalDraftsPending:this.db.prepare("SELECT COUNT(*) as c FROM concept_drafts WHERE processed = 0").get().c,orphanConcepts:this.db.prepare("\n SELECT COUNT(*) as c FROM concepts\n WHERE id NOT IN (SELECT subject FROM triples)\n AND id NOT IN (SELECT object FROM triples)\n ").get().c,neverAccessed:this.db.prepare("SELECT COUNT(*) as c FROM concepts WHERE access_count = 0").get().c,topAccessed:this.db.prepare("SELECT id, label, access_count FROM concepts ORDER BY access_count DESC LIMIT 10").all()}}addDraft(e,t,s){const r=this.db.prepare("INSERT INTO concept_drafts (text, context, session_key) VALUES (?, ?, ?)").run(e,t??null,s??null);return c.info(`Draft added: "${e.slice(0,60)}..." (id=${r.lastInsertRowid})`),r.lastInsertRowid}addConcept(e,t,s="dreaming"){this.db.prepare("INSERT OR IGNORE INTO concepts (id, label, source) VALUES (?, ?, ?)").run(e,t,s)}addTriple(e,t,s,r="dreaming"){try{return this.db.prepare("INSERT OR IGNORE INTO triples (subject, predicate, object, source) VALUES (?, ?, ?, ?)").run(e,t,s,r),!0}catch{return!1}}removeConcept(e){this.db.prepare("DELETE FROM triples WHERE subject = ? OR object = ?").run(e,e),this.db.prepare("DELETE FROM concepts WHERE id = ?").run(e),c.info(`Concept removed: ${e}`)}removeTriple(e,t,s){this.db.prepare("DELETE FROM triples WHERE subject = ? AND predicate = ? AND object = ?").run(e,t,s)}updateConceptLabel(e,t){this.db.prepare("UPDATE concepts SET label = ? WHERE id = ?").run(t,e)}getPendingDrafts(){return this.db.prepare("SELECT * FROM concept_drafts WHERE processed = 0 ORDER BY created_at ASC").all()}markDraftProcessed(e){this.db.prepare("UPDATE concept_drafts SET processed = 1 WHERE id = ?").run(e)}importFromTurtleIfEmpty(s){if(this.db.prepare("SELECT COUNT(*) as c FROM concepts").get().c>0)return;if(!e(s))return void c.info("No CONCEPTS.md found, starting with empty concept graph");c.info(`Importing concepts from ${s}...`);const r=t(s,"utf-8");this.importTurtle(r)}importTurtle(e){let t=0,s=0;const r=this.db.prepare("INSERT OR IGNORE INTO concepts (id, label, source) VALUES (?, ?, 'migration')"),n=this.db.prepare("INSERT OR IGNORE INTO triples (subject, predicate, object, source) VALUES (?, ?, ?, 'migration')");return this.db.transaction(()=>{for(const c of e.split("\n")){const e=c.trim();if(!e||e.startsWith("#")||e.startsWith("@prefix")||e.startsWith("```"))continue;const o=e.match(/^:(\S+)\s+rdfs:label\s+"([^"]+)"\s*\.\s*$/);if(o){const[,e,s]=o;r.run(e,s),t++;continue}const E=e.match(/^:(\S+)\s+rel:(\S+)\s+:(\S+)\s*\.\s*$/);if(E){const[,e,t,c]=E;r.run(e,e),r.run(c,c),n.run(e,t,c),s++;continue}const i=e.match(/^:(\S+)\s+rel:(\S+)\s+"([^"]+)"\s*\.\s*$/);if(i){const[,e,t,c]=i;r.run(e,e),n.run(e,t,c),s++;continue}const p=e.match(/^:(\S+)\s+rel:(\S+)\s+(\S+)\s*\.\s*$/);if(p){const[,e,t,c]=p;"."===c||c.startsWith("rel:")||c.startsWith("rdfs:")||(r.run(e,e),n.run(e,t,c),s++)}}})(),c.info(`Imported ${t} concepts and ${s} triples from Turtle`),{concepts:t,triples:s}}close(){this.db.close(),c.info("ConceptStore closed")}}
1
+ import{existsSync as e,readFileSync as t}from"node:fs";import{join as s}from"node:path";import r from"better-sqlite3";import{createLogger as c}from"../utils/logger.js";const n=c("ConceptStore");export class ConceptStore{db;constructor(e){const t=s(e,"concepts.db");this.db=new r(t),this.db.pragma("journal_mode = WAL"),this.db.pragma("foreign_keys = ON"),this.migrate(),n.info(`ConceptStore opened: ${t}`)}migrate(){this.db.exec("\n CREATE TABLE IF NOT EXISTS concepts (\n id TEXT PRIMARY KEY,\n label TEXT NOT NULL,\n created_at TEXT DEFAULT (datetime('now')),\n last_accessed TEXT,\n access_count INTEGER DEFAULT 0,\n source TEXT DEFAULT 'migration'\n );\n\n CREATE TABLE IF NOT EXISTS triples (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n subject TEXT NOT NULL,\n predicate TEXT NOT NULL,\n object TEXT NOT NULL,\n created_at TEXT DEFAULT (datetime('now')),\n source TEXT DEFAULT 'migration'\n );\n\n CREATE TABLE IF NOT EXISTS concept_drafts (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n text TEXT NOT NULL,\n context TEXT,\n session_key TEXT,\n created_at TEXT DEFAULT (datetime('now')),\n processed INTEGER DEFAULT 0\n );\n\n CREATE INDEX IF NOT EXISTS idx_triples_subject ON triples(subject);\n CREATE INDEX IF NOT EXISTS idx_triples_object ON triples(object);\n CREATE INDEX IF NOT EXISTS idx_triples_predicate ON triples(predicate);\n CREATE UNIQUE INDEX IF NOT EXISTS idx_triples_unique ON triples(subject, predicate, object);\n CREATE INDEX IF NOT EXISTS idx_concepts_access ON concepts(access_count, last_accessed);\n CREATE INDEX IF NOT EXISTS idx_drafts_pending ON concept_drafts(processed);\n ")}query(e,t=1){const s=this.db.prepare("SELECT * FROM concepts WHERE id = ?").get(e);if(!s){const s=this.db.prepare("SELECT * FROM concepts WHERE label LIKE ? LIMIT 1").get(`%${e}%`);return s?this.query(s.id,t):{center:null,concepts:[],triples:[]}}this.db.prepare("UPDATE concepts SET last_accessed = datetime('now'), access_count = access_count + 1 WHERE id = ?").run(e);const r=new Set([e]);let c=[e];const n=[];for(let e=0;e<t&&0!==c.length;e++){const e=c.map(()=>"?").join(","),t=this.db.prepare(`SELECT * FROM triples WHERE subject IN (${e})`).all(...c),s=this.db.prepare(`SELECT * FROM triples WHERE object IN (${e})`).all(...c),o=[];for(const e of[...t,...s]){n.push(e);for(const t of[e.subject,e.object])r.has(t)||(r.add(t),o.push(t))}c=o}const o=[...r],E=[];for(let e=0;e<o.length;e+=500){const t=o.slice(e,e+500),s=t.map(()=>"?").join(","),r=this.db.prepare(`SELECT * FROM concepts WHERE id IN (${s})`).all(...t);E.push(...r)}const p=new Set;return{center:s,concepts:E,triples:n.filter(e=>!p.has(e.id)&&(p.add(e.id),!0))}}findPath(e,t,s=6){if(e===t)return{found:!0,path:[e],triples:[]};const r=new Map,c=new Set([e]);let n=[e];for(let o=0;o<s&&0!==n.length;o++){const s=[],o=n.map(()=>"?").join(","),E=this.db.prepare(`SELECT * FROM triples WHERE subject IN (${o})`).all(...n),p=this.db.prepare(`SELECT * FROM triples WHERE object IN (${o})`).all(...n);for(const n of[...E,...p]){const o=[{node:n.object,from:n.subject},{node:n.subject,from:n.object}];for(const{node:E,from:p}of o)if(!c.has(E)&&c.has(p)&&(c.add(E),r.set(E,{parent:p,triple:n}),s.push(E),E===t)){const s=[t],c=[];let n=t;for(;n!==e;){const e=r.get(n);c.unshift(e.triple),s.unshift(e.parent),n=e.parent}return{found:!0,path:s,triples:c}}}n=s}return{found:!1,path:[],triples:[]}}queryTemporal(e,t=1,s){const r=this.db.prepare("SELECT * FROM concepts WHERE id = ?").get(e);if(!r){const r=this.db.prepare("SELECT * FROM concepts WHERE label LIKE ? LIMIT 1").get(`%${e}%`);return r?this.queryTemporal(r.id,t,s):{center:null,concepts:[],triples:[]}}this.db.prepare("UPDATE concepts SET last_accessed = datetime('now'), access_count = access_count + 1 WHERE id = ?").run(e);const c=[],n=[];s?.since&&(c.push("created_at >= ?"),n.push(s.since)),s?.until&&(c.push("created_at <= ?"),n.push(s.until));const o=c.length>0?` AND ${c.join(" AND ")}`:"",E=s?.recentFirst?" ORDER BY created_at DESC":"",p=new Set([e]);let i=[e];const a=[];for(let e=0;e<t&&0!==i.length;e++){const e=i.map(()=>"?").join(","),t=this.db.prepare(`SELECT * FROM triples WHERE subject IN (${e})${o}${E}`).all(...i,...n),s=this.db.prepare(`SELECT * FROM triples WHERE object IN (${e})${o}${E}`).all(...i,...n),r=[];for(const e of[...t,...s]){a.push(e);for(const t of[e.subject,e.object])p.has(t)||(p.add(t),r.push(t))}i=r}const d=[...p],T=[];for(let e=0;e<d.length;e+=500){const t=d.slice(e,e+500),s=t.map(()=>"?").join(","),r=this.db.prepare(`SELECT * FROM concepts WHERE id IN (${s})`).all(...t);T.push(...r)}const l=new Set;return{center:r,concepts:T,triples:a.filter(e=>!l.has(e.id)&&(l.add(e.id),!0))}}search(e,t=10){return this.db.prepare("SELECT * FROM concepts WHERE label LIKE ? ORDER BY access_count DESC LIMIT ?").all(`%${e}%`,t)}stats(){return{totalConcepts:this.db.prepare("SELECT COUNT(*) as c FROM concepts").get().c,totalTriples:this.db.prepare("SELECT COUNT(*) as c FROM triples").get().c,totalDraftsPending:this.db.prepare("SELECT COUNT(*) as c FROM concept_drafts WHERE processed = 0").get().c,orphanConcepts:this.db.prepare("\n SELECT COUNT(*) as c FROM concepts\n WHERE id NOT IN (SELECT subject FROM triples)\n AND id NOT IN (SELECT object FROM triples)\n ").get().c,neverAccessed:this.db.prepare("SELECT COUNT(*) as c FROM concepts WHERE access_count = 0").get().c,topAccessed:this.db.prepare("SELECT id, label, access_count FROM concepts ORDER BY access_count DESC LIMIT 10").all()}}addDraft(e,t,s){const r=this.db.prepare("INSERT INTO concept_drafts (text, context, session_key) VALUES (?, ?, ?)").run(e,t??null,s??null);return n.info(`Draft added: "${e.slice(0,60)}..." (id=${r.lastInsertRowid})`),r.lastInsertRowid}addConcept(e,t,s="dreaming"){this.db.prepare("INSERT OR IGNORE INTO concepts (id, label, source) VALUES (?, ?, ?)").run(e,t,s)}addTriple(e,t,s,r="dreaming"){try{return this.db.prepare("INSERT OR IGNORE INTO triples (subject, predicate, object, source) VALUES (?, ?, ?, ?)").run(e,t,s,r),!0}catch{return!1}}removeConcept(e){this.db.prepare("DELETE FROM triples WHERE subject = ? OR object = ?").run(e,e),this.db.prepare("DELETE FROM concepts WHERE id = ?").run(e),n.info(`Concept removed: ${e}`)}removeTriple(e,t,s){this.db.prepare("DELETE FROM triples WHERE subject = ? AND predicate = ? AND object = ?").run(e,t,s)}updateConceptLabel(e,t){this.db.prepare("UPDATE concepts SET label = ? WHERE id = ?").run(t,e)}getPendingDrafts(){return this.db.prepare("SELECT * FROM concept_drafts WHERE processed = 0 ORDER BY created_at ASC").all()}markDraftProcessed(e){this.db.prepare("UPDATE concept_drafts SET processed = 1 WHERE id = ?").run(e)}importFromTurtleIfEmpty(s){if(this.db.prepare("SELECT COUNT(*) as c FROM concepts").get().c>0)return;if(!e(s))return void n.info("No CONCEPTS.md found, starting with empty concept graph");n.info(`Importing concepts from ${s}...`);const r=t(s,"utf-8");this.importTurtle(r)}importTurtle(e){let t=0,s=0;const r=this.db.prepare("INSERT OR IGNORE INTO concepts (id, label, source) VALUES (?, ?, 'migration')"),c=this.db.prepare("INSERT OR IGNORE INTO triples (subject, predicate, object, source) VALUES (?, ?, ?, 'migration')");return this.db.transaction(()=>{for(const n of e.split("\n")){const e=n.trim();if(!e||e.startsWith("#")||e.startsWith("@prefix")||e.startsWith("```"))continue;const o=e.match(/^:(\S+)\s+rdfs:label\s+"([^"]+)"\s*\.\s*$/);if(o){const[,e,s]=o;r.run(e,s),t++;continue}const E=e.match(/^:(\S+)\s+rel:(\S+)\s+:(\S+)\s*\.\s*$/);if(E){const[,e,t,n]=E;r.run(e,e),r.run(n,n),c.run(e,t,n),s++;continue}const p=e.match(/^:(\S+)\s+rel:(\S+)\s+"([^"]+)"\s*\.\s*$/);if(p){const[,e,t,n]=p;r.run(e,e),c.run(e,t,n),s++;continue}const i=e.match(/^:(\S+)\s+rel:(\S+)\s+(\S+)\s*\.\s*$/);if(i){const[,e,t,n]=i;"."===n||n.startsWith("rel:")||n.startsWith("rdfs:")||(r.run(e,e),c.run(e,t,n),s++)}}})(),n.info(`Imported ${t} concepts and ${s} triples from Turtle`),{concepts:t,triples:s}}close(){this.db.close(),n.info("ConceptStore closed")}}
@@ -83,6 +83,16 @@ export declare class MemorySearch {
83
83
  private initIndexHnsw;
84
84
  private ensureHnswCapacity;
85
85
  search(query: string, maxResults?: number): Promise<MemorySearchResult[]>;
86
+ /**
87
+ * Record that chunks were accessed (expanded/used in a response).
88
+ * Writes to indexDb — will propagate to searchDb on next snapshot.
89
+ */
90
+ recordAccess(chunkIds: number[]): void;
91
+ /**
92
+ * Fetch utility data for a set of chunk IDs from the search DB.
93
+ * Returns a Map of chunkId → { access_count, last_accessed, first_accessed }.
94
+ */
95
+ private getUtilityScores;
86
96
  readFile(path: string, from?: number, lines?: number): {
87
97
  path: string;
88
98
  content: string;
@@ -1 +1 @@
1
- import{existsSync as e,readdirSync as t,readFileSync as n,statSync as s,copyFileSync as i,renameSync as r,unlinkSync as h,watch as o}from"node:fs";import{join as a,relative as c,basename as d,dirname as l}from"node:path";import m from"better-sqlite3";import p from"openai";import u from"hnswlib-node";const{HierarchicalNSW:b}=u;import{createLogger as E}from"../utils/logger.js";import{L0Generator as f,L0_DEFAULT as g}from"./l0-generator.js";const x=E("MemorySearch"),w="cosine";export class MemorySearch{memoryDir;dataDir;opts;indexDb=null;indexHnsw=null;watcher=null;debounceTimer=null;embedTimer=null;indexing=!1;embedding=!1;stopped=!0;l0Generator;searchDb=null;searchHnsw=null;openai=null;indexDbPath;searchDbPath;searchNextDbPath;indexHnswPath;searchHnswPath;searchNextHnswPath;constructor(e,t,n){this.memoryDir=e,this.dataDir=t,this.opts=n,this.indexDbPath=a(t,"memory-index.db"),this.searchDbPath=a(t,"memory-search.db"),this.searchNextDbPath=a(t,"memory-search-next.db"),this.indexHnswPath=a(t,"memory-vectors.hnsw"),this.searchHnswPath=a(t,"memory-vectors-search.hnsw"),this.searchNextHnswPath=a(t,"memory-vectors-search-next.hnsw"),this.isOpenAI()&&(this.openai=new p({apiKey:n.apiKey,...n.baseURL?{baseURL:n.baseURL}:{}})),this.l0Generator=new f(n.l0??g)}isOpenAI(){return(this.opts.baseURL||"https://api.openai.com/v1").includes("openai.com")}getMaxInjectedChars(){return this.opts.maxInjectedChars}async start(){x.info("Starting memory search engine..."),this.stopped=!1,this.indexDb=new m(this.indexDbPath),this.indexDb.pragma("journal_mode = WAL"),this.migrateEmbeddingsTable(),this.indexDb.exec("\n CREATE TABLE IF NOT EXISTS documents (\n path TEXT PRIMARY KEY,\n mtime_ms INTEGER NOT NULL,\n size INTEGER NOT NULL\n );\n\n CREATE TABLE IF NOT EXISTS chunks (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n doc_path TEXT NOT NULL,\n chunk_idx INTEGER NOT NULL,\n role TEXT NOT NULL DEFAULT '',\n timestamp TEXT NOT NULL DEFAULT '',\n session_key TEXT NOT NULL DEFAULT '',\n content TEXT NOT NULL,\n UNIQUE(doc_path, chunk_idx)\n );\n\n CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5(\n content,\n content='chunks',\n content_rowid='id',\n tokenize='porter unicode61'\n );\n\n CREATE TRIGGER IF NOT EXISTS chunks_ai AFTER INSERT ON chunks BEGIN\n INSERT INTO chunks_fts(rowid, content) VALUES (new.id, new.content);\n END;\n\n CREATE TRIGGER IF NOT EXISTS chunks_ad AFTER DELETE ON chunks BEGIN\n INSERT INTO chunks_fts(chunks_fts, rowid, content) VALUES ('delete', old.id, old.content);\n END;\n\n CREATE TRIGGER IF NOT EXISTS chunks_au AFTER UPDATE ON chunks BEGIN\n INSERT INTO chunks_fts(chunks_fts, rowid, content) VALUES ('delete', old.id, old.content);\n INSERT INTO chunks_fts(rowid, content) VALUES (new.id, new.content);\n END;\n\n -- Lookup table only (no vector BLOB) — vectors live in HNSW index\n CREATE TABLE IF NOT EXISTS embeddings (\n chunk_id INTEGER PRIMARY KEY\n );\n\n -- Metadata for detecting config changes (model, dimensions)\n CREATE TABLE IF NOT EXISTS meta (\n key TEXT PRIMARY KEY,\n value TEXT NOT NULL\n );\n"),this.checkEmbeddingConfigChange(),this.initIndexHnsw(),await this.indexFiles(),await this.embedPending(),this.indexDb&&await this.l0Generator.start(this.indexDb),this.publishSnapshot(),this.maybeSwap(),this.startWatcher(),this.opts.embedIntervalMs>0&&(this.embedTimer=setInterval(()=>{this.embedPending().catch(e=>x.error(`Embed cycle error: ${e}`))},this.opts.embedIntervalMs)),x.info("Memory search engine started")}stop(){this.stopped=!0,this.watcher&&(this.watcher.close(),this.watcher=null),this.debounceTimer&&(clearTimeout(this.debounceTimer),this.debounceTimer=null),this.embedTimer&&(clearInterval(this.embedTimer),this.embedTimer=null),this.indexDb&&(this.indexDb.close(),this.indexDb=null),this.searchDb&&(this.searchDb.close(),this.searchDb=null),this.indexHnsw=null,this.searchHnsw=null,this.l0Generator.stop(),x.info("Memory search engine stopped")}migrateEmbeddingsTable(){if(!this.indexDb)return;if(this.indexDb.prepare("PRAGMA table_info(embeddings)").all().some(e=>"vector"===e.name)){x.info("Migrating: dropping old embeddings table (had vector BLOB). All embeddings will be re-created via HNSW."),this.indexDb.exec("DROP TABLE IF EXISTS embeddings");try{h(this.indexHnswPath)}catch{}}}checkEmbeddingConfigChange(){if(!this.indexDb)return;const e=this.indexDb.prepare("SELECT value FROM meta WHERE key = ?"),t=this.indexDb.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)"),n=e.get("embedding_model")?.value,s=e.get("embedding_dimensions")?.value,i=this.opts.embeddingModel,r=String(this.opts.embeddingDimensions),o=void 0!==n&&n!==i,a=void 0!==s&&s!==r;if(o||a){const e=[];o&&e.push(`model: ${n} → ${i}`),a&&e.push(`dimensions: ${s} → ${r}`),x.info(`Embedding config changed (${e.join(", ")}). Wiping embeddings + HNSW for full re-embed.`),this.indexDb.exec("DELETE FROM embeddings");try{h(this.indexHnswPath)}catch{}}t.run("embedding_model",i),t.run("embedding_dimensions",r)}initIndexHnsw(){const t=this.opts.embeddingDimensions;if(this.indexHnsw=new b(w,t),e(this.indexHnswPath))try{this.indexHnsw.readIndexSync(this.indexHnswPath,!0),x.info(`Loaded HNSW index: ${this.indexHnsw.getCurrentCount()} points`)}catch(e){x.warn(`Failed to load HNSW index, creating new: ${e}`),this.indexHnsw.initIndex({maxElements:1e4,m:16,efConstruction:200,allowReplaceDeleted:!0})}else this.indexHnsw.initIndex({maxElements:1e4,m:16,efConstruction:200,allowReplaceDeleted:!0}),x.info("Created new HNSW index")}ensureHnswCapacity(e){if(!this.indexHnsw)return;const t=this.indexHnsw.getMaxElements();if(this.indexHnsw.getCurrentCount()+e>t){const n=Math.max(2*t,this.indexHnsw.getCurrentCount()+e+1e3);this.indexHnsw.resizeIndex(n),x.info(`Resized HNSW index: ${t} → ${n}`)}}async search(e,t){const n=t??this.opts.maxResults;if(this.maybeSwap(),!this.searchDb)return x.warn("Search DB not available"),[];const s=this.bm25Search(e,20);let i=[];try{const t=await this.embedText(e);t&&this.searchHnsw&&this.searchHnsw.getCurrentCount()>0&&(i=this.denseSearch(t,20))}catch(e){x.warn(`Dense search failed, using BM25 only: ${e}`)}const r=function(e,t,n){const s=new Map;for(let t=0;t<e.length;t++){const{id:i}=e[t];s.set(i,(s.get(i)??0)+1/(n+t+1))}for(let e=0;e<t.length;e++){const{id:i}=t[e];s.set(i,(s.get(i)??0)+1/(n+e+1))}const i=Array.from(s.entries()).map(([e,t])=>({id:e,score:t})).sort((e,t)=>t.score-e.score);return i}(s,i,this.opts.rrfK),h=Math.min(r.length,40),o=this.searchDb.prepare("SELECT doc_path, role, timestamp, session_key, content, l0 FROM chunks WHERE id = ?"),a=[];for(let e=0;e<h;e++){const{id:t,score:n}=r[e],s=o.get(t);s&&a.push({id:t,score:n,row:s})}const c=function(e,t,n){if(e.length<=t)return e;const s=e.map(e=>function(e){const t=new Set;for(const n of e.toLowerCase().matchAll(/\b\w{2,}\b/g))t.add(n[0]);return t}(e.row.content)),i=e[0]?.score??1,r=e[e.length-1]?.score??0,h=i-r||1,o=[],a=new Set(e.map((e,t)=>t));o.push(0),a.delete(0);for(;o.length<t&&a.size>0;){let t=-1,i=-1/0;for(const c of a){const a=(e[c].score-r)/h;let d=0;for(const e of o){const t=D(s[c],s[e]);t>d&&(d=t)}const l=n*a-(1-n)*d;l>i&&(i=l,t=c)}if(t<0)break;o.push(t),a.delete(t)}return o.map(t=>e[t])}(a,n,.7),d=[];for(const{id:e,score:t,row:n}of c){const s=n.l0&&this.l0Generator.enabled?n.l0:n.content.length>this.opts.maxSnippetChars?n.content.slice(0,this.opts.maxSnippetChars)+"...":n.content;d.push({chunkId:e,path:n.doc_path,sessionKey:n.session_key,snippet:s,score:t,role:n.role,timestamp:n.timestamp})}return x.info(`Search "${e.slice(0,60)}": ${d.length} results (sparse=${s.length}, dense=${i.length}, candidates=${a.length}, mmr=${c.length})`),d}readFile(t,s,i){const r=a(this.memoryDir,t);if(!e(r))return{path:t,content:`[File not found: ${t}]`};const h=n(r,"utf-8"),o=h.split("\n");if(void 0!==s||void 0!==i){const e=Math.max(0,(s??1)-1),n=i??o.length;return{path:t,content:o.slice(e,e+n).join("\n")}}return{path:t,content:h}}expandChunks(e){if(!this.searchDb||0===e.length)return[];const t=e.map(()=>"?").join(",");return this.searchDb.prepare(`SELECT id, content, doc_path, role, timestamp FROM chunks WHERE id IN (${t})`).all(...e).map(e=>({chunkId:e.id,content:e.content,path:e.doc_path,role:e.role,timestamp:e.timestamp}))}bm25Search(e,t){if(!this.searchDb)return[];try{const n=function(e){const t=e.replace(/[^\w\s]/g," ").split(/\s+/).filter(e=>e.length>0);return 0===t.length?"":t.map(e=>`"${e}"`).join(" OR ")}(e);if(!n)return[];return this.searchDb.prepare("\n SELECT chunks.id, bm25(chunks_fts) as rank\n FROM chunks_fts\n JOIN chunks ON chunks.id = chunks_fts.rowid\n WHERE chunks_fts MATCH ?\n ORDER BY rank\n LIMIT ?\n ").all(n,t).map(e=>({id:e.id,score:-e.rank}))}catch(e){return x.warn(`BM25 search error: ${e}`),[]}}denseSearch(e,t){if(!this.searchHnsw||0===this.searchHnsw.getCurrentCount())return[];const n=Math.min(t,this.searchHnsw.getCurrentCount()),s=this.searchHnsw.searchKnn(Array.from(e),n);return s.neighbors.map((e,t)=>({id:e,score:1-s.distances[t]}))}startWatcher(){if(e(this.memoryDir))try{this.watcher=o(this.memoryDir,{recursive:!0},(e,t)=>{this.debounceTimer&&clearTimeout(this.debounceTimer),this.debounceTimer=setTimeout(()=>{this.runIndexCycle()},this.opts.updateDebounceMs)})}catch(e){x.warn(`Could not start file watcher: ${e}`)}}async runIndexCycle(){if(!this.indexing){this.indexing=!0;try{await this.indexFiles(),this.indexDb&&await this.l0Generator.generate(this.indexDb),this.publishSnapshot()}catch(e){x.error(`Index cycle error: ${e}`)}finally{this.indexing=!1}}}async indexFiles(){if(!this.indexDb||!e(this.memoryDir))return;const i=function(e){const n=[];function i(r){let h;try{h=t(r,{withFileTypes:!0})}catch{return}for(const t of h){const h=a(r,t.name);if(t.isDirectory())i(h);else if(t.name.endsWith(".md"))try{const t=s(h),i=c(e,h),r=d(l(h));n.push({fullPath:h,relPath:i,sessionKey:r===d(e)?"":r,mtimeMs:Math.floor(t.mtimeMs),size:t.size})}catch{}}}return i(e),n}(this.memoryDir);let r=0;const h=this.indexDb.prepare("INSERT OR REPLACE INTO documents (path, mtime_ms, size) VALUES (?, ?, ?)"),o=this.indexDb.prepare("SELECT mtime_ms, size FROM documents WHERE path = ?"),m=this.indexDb.prepare("DELETE FROM chunks WHERE doc_path = ?"),p=this.indexDb.prepare("SELECT id FROM chunks WHERE doc_path = ?"),u=this.indexDb.prepare("DELETE FROM embeddings WHERE chunk_id IN (SELECT id FROM chunks WHERE doc_path = ?)"),b=this.indexDb.prepare("INSERT INTO chunks (doc_path, chunk_idx, role, timestamp, session_key, content) VALUES (?, ?, ?, ?, ?, ?)"),E=this.indexDb.prepare("SELECT path FROM documents").all().map(e=>e.path),f=new Set(i.map(e=>e.relPath));for(const e of E)if(!f.has(e)){const t=p.all(e);for(const{id:e}of t)try{this.indexHnsw?.markDelete(e)}catch{}u.run(e),m.run(e),this.indexDb.prepare("DELETE FROM documents WHERE path = ?").run(e),x.debug(`Removed deleted file from index: ${e}`)}for(const e of i){const t=o.get(e.relPath);if(t&&t.mtime_ms===e.mtimeMs&&t.size===e.size){r+=this.indexDb.prepare("SELECT COUNT(*) as c FROM chunks WHERE doc_path = ?").get(e.relPath).c;continue}const s=T(n(e.fullPath,"utf-8"),e.relPath,e.sessionKey),i=p.all(e.relPath);for(const{id:e}of i)try{this.indexHnsw?.markDelete(e)}catch{}u.run(e.relPath),m.run(e.relPath);this.indexDb.transaction(()=>{for(let t=0;t<s.length;t++){const n=s[t];b.run(e.relPath,t,n.role,n.timestamp,n.sessionKey,n.content)}h.run(e.relPath,e.mtimeMs,e.size)})(),r+=s.length,x.debug(`Indexed ${e.relPath}: ${s.length} chunks`)}x.info(`Indexed ${r} chunks from ${i.length} files`)}async embedPending(){if(!this.stopped&&!this.embedding&&this.indexDb&&this.indexHnsw){this.embedding=!0;try{const e=this.indexDb.prepare("\n SELECT c.id, c.content FROM chunks c\n LEFT JOIN embeddings e ON e.chunk_id = c.id\n WHERE e.chunk_id IS NULL\n ").all();if(0===e.length)return;x.info(`Embedding ${e.length} pending chunks...`),this.ensureHnswCapacity(e.length);const t=this.indexDb.prepare("INSERT OR REPLACE INTO embeddings (chunk_id) VALUES (?)");for(let n=0;n<e.length;n+=100){if(this.stopped)return void x.warn("embedPending aborted: engine stopped");const s=e.slice(n,n+100),i=s.map(e=>this.applyPrefix(this.opts.prefixDocument,e.content).slice(0,8e3)),r=this.opts.prefixDocument.trim();r&&x.debug(`Using prefixDocument (template: ${r}) → result sample: [${i[0].slice(0,80)}]`);try{let e;if(this.openai){const t=await this.openai.embeddings.create({model:this.opts.embeddingModel,input:i,dimensions:this.opts.embeddingDimensions});e=t.data.sort((e,t)=>e.index-t.index).map(e=>e.embedding)}else e=await this.fetchEmbeddings(i);if(this.stopped||!this.indexDb||!this.indexHnsw)return void x.warn("embedPending aborted: engine stopped during embedding");this.indexDb.transaction(()=>{for(let n=0;n<e.length;n++)this.indexHnsw.addPoint(e[n],s[n].id,!0),t.run(s[n].id)})(),x.debug(`Embedded batch ${n/100+1}: ${s.length} chunks`)}catch(e){if(this.stopped)return;x.error(`Embedding batch failed: ${e}`)}}if(this.stopped||!this.indexHnsw)return;this.indexHnsw.writeIndexSync(this.indexHnswPath),this.publishSnapshot(),x.info(`Embedded ${e.length} chunks (HNSW: ${this.indexHnsw.getCurrentCount()} total points)`)}finally{this.embedding=!1}}}publishSnapshot(){if(!this.indexDb)return;const t=a(this.dataDir,".memory-search-next.tmp"),n=a(this.dataDir,".memory-vectors-search-next.tmp");try{this.indexDb.pragma("wal_checkpoint(TRUNCATE)"),i(this.indexDbPath,t),r(t,this.searchNextDbPath),e(this.indexHnswPath)&&(i(this.indexHnswPath,n),r(n,this.searchNextHnswPath)),x.debug("Published search snapshot (DB + HNSW)")}catch(e){x.error(`Failed to publish snapshot: ${e}`);try{h(t)}catch{}try{h(n)}catch{}}}maybeSwap(){if(e(this.searchNextDbPath))try{this.searchDb&&(this.searchDb.close(),this.searchDb=null),this.searchHnsw=null,r(this.searchNextDbPath,this.searchDbPath),e(this.searchNextHnswPath)&&r(this.searchNextHnswPath,this.searchHnswPath),this.searchDb=new m(this.searchDbPath,{readonly:!0}),e(this.searchHnswPath)?(this.searchHnsw=new b(w,this.opts.embeddingDimensions),this.searchHnsw.readIndexSync(this.searchHnswPath),this.searchHnsw.setEf(50),x.debug(`Swapped to new search DB + HNSW (${this.searchHnsw.getCurrentCount()} points)`)):x.debug("Swapped to new search DB (no HNSW index yet)")}catch(t){x.error(`Failed to swap search DB: ${t}`);try{e(this.searchDbPath)&&(this.searchDb=new m(this.searchDbPath,{readonly:!0}))}catch{}}}applyPrefix(e,t){const n=e.trim();return n?n.replace(/\{content\}/g,()=>t):t}async fetchEmbeddings(e){const t=`${(this.opts.baseURL||"").replace(/\/+$/,"")}/embed`,n={"Content-Type":"application/json"};this.opts.apiKey&&(n.Authorization=`Bearer ${this.opts.apiKey}`);const s=await fetch(t,{method:"POST",headers:n,body:JSON.stringify({model:this.opts.embeddingModel,input:e})});if(!s.ok){const e=await s.text().catch(()=>"(no body)");throw new Error(`Embedding API ${s.status}: ${e.slice(0,300)}`)}const i=await s.json();if(Array.isArray(i.embeddings))return i.embeddings;throw new Error(`Unknown embedding response format. Keys: ${Object.keys(i).join(", ")}`)}async embedText(e){try{const t=this.applyPrefix(this.opts.prefixQuery,e),n=this.opts.prefixQuery.trim();if(n&&x.debug(`Using prefixQuery (template: ${n}) → result sample: [${t.slice(0,80)}]`),this.openai){const e=await this.openai.embeddings.create({model:this.opts.embeddingModel,input:t.slice(0,8e3),dimensions:this.opts.embeddingDimensions});return new Float32Array(e.data[0].embedding)}const s=await this.fetchEmbeddings([t.slice(0,8e3)]);return new Float32Array(s[0])}catch(e){return x.error(`Failed to embed query: ${e}`),null}}}function T(e,t,n){const s=[],i=e.split(/^### /m);for(const e of i){if(!e.trim())continue;const t=e.match(/^(user|assistant)\s*\(([^)]+)\)\s*\n/),i=t?t[1]:"",r=t?t[2]:"",h=t?e.slice(t[0].length).trim():e.trim();if(!h)continue;const o=1500,a=100;if(h.length<=o)s.push({role:i,timestamp:r,sessionKey:n,content:h});else{let e=0;for(;e<h.length;){const t=Math.min(e+o,h.length),c=h.slice(e,t);if(s.push({role:i,timestamp:r,sessionKey:n,content:c}),e=t-a,e+a>=h.length)break}}}return s}function D(e,t){if(0===e.size&&0===t.size)return 0;let n=0;const s=e.size<=t.size?e:t,i=e.size<=t.size?t:e;for(const e of s)i.has(e)&&n++;const r=e.size+t.size-n;return 0===r?0:n/r}
1
+ import{existsSync as e,readdirSync as t,readFileSync as n,statSync as s,copyFileSync as i,renameSync as r,unlinkSync as h,watch as a}from"node:fs";import{join as o,relative as c,basename as d,dirname as l}from"node:path";import m from"better-sqlite3";import u from"openai";import p from"hnswlib-node";const{HierarchicalNSW:E}=p;import{createLogger as b}from"../utils/logger.js";import{L0Generator as f,L0_DEFAULT as g}from"./l0-generator.js";const T=b("MemorySearch"),x="cosine";export class MemorySearch{memoryDir;dataDir;opts;indexDb=null;indexHnsw=null;watcher=null;debounceTimer=null;embedTimer=null;indexing=!1;embedding=!1;stopped=!0;l0Generator;searchDb=null;searchHnsw=null;openai=null;indexDbPath;searchDbPath;searchNextDbPath;indexHnswPath;searchHnswPath;searchNextHnswPath;constructor(e,t,n){this.memoryDir=e,this.dataDir=t,this.opts=n,this.indexDbPath=o(t,"memory-index.db"),this.searchDbPath=o(t,"memory-search.db"),this.searchNextDbPath=o(t,"memory-search-next.db"),this.indexHnswPath=o(t,"memory-vectors.hnsw"),this.searchHnswPath=o(t,"memory-vectors-search.hnsw"),this.searchNextHnswPath=o(t,"memory-vectors-search-next.hnsw"),this.isOpenAI()&&(this.openai=new u({apiKey:n.apiKey,...n.baseURL?{baseURL:n.baseURL}:{}})),this.l0Generator=new f(n.l0??g)}isOpenAI(){return(this.opts.baseURL||"https://api.openai.com/v1").includes("openai.com")}getMaxInjectedChars(){return this.opts.maxInjectedChars}async start(){T.info("Starting memory search engine..."),this.stopped=!1,this.indexDb=new m(this.indexDbPath),this.indexDb.pragma("journal_mode = WAL"),this.migrateEmbeddingsTable(),this.indexDb.exec("\n CREATE TABLE IF NOT EXISTS documents (\n path TEXT PRIMARY KEY,\n mtime_ms INTEGER NOT NULL,\n size INTEGER NOT NULL\n );\n\n CREATE TABLE IF NOT EXISTS chunks (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n doc_path TEXT NOT NULL,\n chunk_idx INTEGER NOT NULL,\n role TEXT NOT NULL DEFAULT '',\n timestamp TEXT NOT NULL DEFAULT '',\n session_key TEXT NOT NULL DEFAULT '',\n content TEXT NOT NULL,\n UNIQUE(doc_path, chunk_idx)\n );\n\n CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5(\n content,\n content='chunks',\n content_rowid='id',\n tokenize='porter unicode61'\n );\n\n CREATE TRIGGER IF NOT EXISTS chunks_ai AFTER INSERT ON chunks BEGIN\n INSERT INTO chunks_fts(rowid, content) VALUES (new.id, new.content);\n END;\n\n CREATE TRIGGER IF NOT EXISTS chunks_ad AFTER DELETE ON chunks BEGIN\n INSERT INTO chunks_fts(chunks_fts, rowid, content) VALUES ('delete', old.id, old.content);\n END;\n\n CREATE TRIGGER IF NOT EXISTS chunks_au AFTER UPDATE ON chunks BEGIN\n INSERT INTO chunks_fts(chunks_fts, rowid, content) VALUES ('delete', old.id, old.content);\n INSERT INTO chunks_fts(rowid, content) VALUES (new.id, new.content);\n END;\n\n -- Lookup table only (no vector BLOB) — vectors live in HNSW index\n CREATE TABLE IF NOT EXISTS embeddings (\n chunk_id INTEGER PRIMARY KEY\n );\n\n -- Metadata for detecting config changes (model, dimensions)\n CREATE TABLE IF NOT EXISTS meta (\n key TEXT PRIMARY KEY,\n value TEXT NOT NULL\n );\n\n -- Utility scoring: tracks how often each chunk is retrieved and used\n CREATE TABLE IF NOT EXISTS chunk_utility (\n chunk_id INTEGER PRIMARY KEY,\n access_count INTEGER DEFAULT 0,\n last_accessed TEXT,\n first_accessed TEXT\n );\n"),this.checkEmbeddingConfigChange(),this.initIndexHnsw(),await this.indexFiles(),await this.embedPending(),this.indexDb&&await this.l0Generator.start(this.indexDb),this.publishSnapshot(),this.maybeSwap(),this.startWatcher(),this.opts.embedIntervalMs>0&&(this.embedTimer=setInterval(()=>{this.embedPending().catch(e=>T.error(`Embed cycle error: ${e}`))},this.opts.embedIntervalMs)),T.info("Memory search engine started")}stop(){this.stopped=!0,this.watcher&&(this.watcher.close(),this.watcher=null),this.debounceTimer&&(clearTimeout(this.debounceTimer),this.debounceTimer=null),this.embedTimer&&(clearInterval(this.embedTimer),this.embedTimer=null),this.indexDb&&(this.indexDb.close(),this.indexDb=null),this.searchDb&&(this.searchDb.close(),this.searchDb=null),this.indexHnsw=null,this.searchHnsw=null,this.l0Generator.stop(),T.info("Memory search engine stopped")}migrateEmbeddingsTable(){if(!this.indexDb)return;if(this.indexDb.prepare("PRAGMA table_info(embeddings)").all().some(e=>"vector"===e.name)){T.info("Migrating: dropping old embeddings table (had vector BLOB). All embeddings will be re-created via HNSW."),this.indexDb.exec("DROP TABLE IF EXISTS embeddings");try{h(this.indexHnswPath)}catch{}}}checkEmbeddingConfigChange(){if(!this.indexDb)return;const e=this.indexDb.prepare("SELECT value FROM meta WHERE key = ?"),t=this.indexDb.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)"),n=e.get("embedding_model")?.value,s=e.get("embedding_dimensions")?.value,i=this.opts.embeddingModel,r=String(this.opts.embeddingDimensions),a=void 0!==n&&n!==i,o=void 0!==s&&s!==r;if(a||o){const e=[];a&&e.push(`model: ${n} → ${i}`),o&&e.push(`dimensions: ${s} → ${r}`),T.info(`Embedding config changed (${e.join(", ")}). Wiping embeddings + HNSW for full re-embed.`),this.indexDb.exec("DELETE FROM embeddings");try{h(this.indexHnswPath)}catch{}}t.run("embedding_model",i),t.run("embedding_dimensions",r)}initIndexHnsw(){const t=this.opts.embeddingDimensions;if(this.indexHnsw=new E(x,t),e(this.indexHnswPath))try{this.indexHnsw.readIndexSync(this.indexHnswPath,!0),T.info(`Loaded HNSW index: ${this.indexHnsw.getCurrentCount()} points`)}catch(e){T.warn(`Failed to load HNSW index, creating new: ${e}`),this.indexHnsw.initIndex({maxElements:1e4,m:16,efConstruction:200,allowReplaceDeleted:!0})}else this.indexHnsw.initIndex({maxElements:1e4,m:16,efConstruction:200,allowReplaceDeleted:!0}),T.info("Created new HNSW index")}ensureHnswCapacity(e){if(!this.indexHnsw)return;const t=this.indexHnsw.getMaxElements();if(this.indexHnsw.getCurrentCount()+e>t){const n=Math.max(2*t,this.indexHnsw.getCurrentCount()+e+1e3);this.indexHnsw.resizeIndex(n),T.info(`Resized HNSW index: ${t} → ${n}`)}}async search(e,t){const n=t??this.opts.maxResults;if(this.maybeSwap(),!this.searchDb)return T.warn("Search DB not available"),[];const s=this.bm25Search(e,20);let i=[];try{const t=await this.embedText(e);t&&this.searchHnsw&&this.searchHnsw.getCurrentCount()>0&&(i=this.denseSearch(t,20))}catch(e){T.warn(`Dense search failed, using BM25 only: ${e}`)}const r=function(e,t,n){const s=new Map;for(let t=0;t<e.length;t++){const{id:i}=e[t];s.set(i,(s.get(i)??0)+1/(n+t+1))}for(let e=0;e<t.length;e++){const{id:i}=t[e];s.set(i,(s.get(i)??0)+1/(n+e+1))}const i=Array.from(s.entries()).map(([e,t])=>({id:e,score:t})).sort((e,t)=>t.score-e.score);return i}(s,i,this.opts.rrfK),h=Math.min(r.length,40),a=this.searchDb.prepare("SELECT doc_path, role, timestamp, session_key, content, l0 FROM chunks WHERE id = ?"),o=[];for(let e=0;e<h;e++){const{id:t,score:n}=r[e],s=a.get(t);s&&o.push({id:t,score:n,row:s})}const c=this.getUtilityScores(o.map(e=>e.id)),d=Date.now();for(const e of o){const t=c.get(e.id),n=t?.access_count??0;if(n>0&&(e.score+=.05*Math.log(n+1)),0===n&&e.row.timestamp){const t=new Date(e.row.timestamp).getTime();if(!isNaN(t)){const n=(d-t)/864e5;n>30&&(e.score-=Math.min(.001*(n-30),.05))}}}o.sort((e,t)=>t.score-e.score);const l=function(e,t,n){if(e.length<=t)return e;const s=e.map(e=>function(e){const t=new Set;for(const n of e.toLowerCase().matchAll(/\b\w{2,}\b/g))t.add(n[0]);return t}(e.row.content)),i=e[0]?.score??1,r=e[e.length-1]?.score??0,h=i-r||1,a=[],o=new Set(e.map((e,t)=>t));a.push(0),o.delete(0);for(;a.length<t&&o.size>0;){let t=-1,i=-1/0;for(const c of o){const o=(e[c].score-r)/h;let d=0;for(const e of a){const t=D(s[c],s[e]);t>d&&(d=t)}const l=n*o-(1-n)*d;l>i&&(i=l,t=c)}if(t<0)break;a.push(t),o.delete(t)}return a.map(t=>e[t])}(o,n,.7),m=[];for(const{id:e,score:t,row:n}of l){const s=n.l0&&this.l0Generator.enabled?n.l0:n.content.length>this.opts.maxSnippetChars?n.content.slice(0,this.opts.maxSnippetChars)+"...":n.content;m.push({chunkId:e,path:n.doc_path,sessionKey:n.session_key,snippet:s,score:t,role:n.role,timestamp:n.timestamp})}return T.info(`Search "${e.slice(0,60)}": ${m.length} results (sparse=${s.length}, dense=${i.length}, candidates=${o.length}, mmr=${l.length})`),m}recordAccess(e){if(!this.indexDb||0===e.length)return;const t=(new Date).toISOString(),n=this.indexDb.prepare("\n INSERT INTO chunk_utility (chunk_id, access_count, last_accessed, first_accessed)\n VALUES (?, 1, ?, ?)\n ON CONFLICT(chunk_id) DO UPDATE SET\n access_count = access_count + 1,\n last_accessed = excluded.last_accessed\n ");this.indexDb.transaction(()=>{for(const s of e)n.run(s,t,t)})(),T.debug(`Recorded access for ${e.length} chunks`)}getUtilityScores(e){const t=new Map;if(!this.searchDb||0===e.length)return t;if(!this.searchDb.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='chunk_utility'").get())return t;const n=e.map(()=>"?").join(","),s=this.searchDb.prepare(`SELECT chunk_id, access_count, last_accessed, first_accessed FROM chunk_utility WHERE chunk_id IN (${n})`).all(...e);for(const e of s)t.set(e.chunk_id,{access_count:e.access_count,last_accessed:e.last_accessed,first_accessed:e.first_accessed});return t}readFile(t,s,i){const r=o(this.memoryDir,t);if(!e(r))return{path:t,content:`[File not found: ${t}]`};const h=n(r,"utf-8"),a=h.split("\n");if(void 0!==s||void 0!==i){const e=Math.max(0,(s??1)-1),n=i??a.length;return{path:t,content:a.slice(e,e+n).join("\n")}}return{path:t,content:h}}expandChunks(e){if(!this.searchDb||0===e.length)return[];const t=e.map(()=>"?").join(","),n=this.searchDb.prepare(`SELECT id, content, doc_path, role, timestamp FROM chunks WHERE id IN (${t})`).all(...e);return n.length>0&&this.recordAccess(n.map(e=>e.id)),n.map(e=>({chunkId:e.id,content:e.content,path:e.doc_path,role:e.role,timestamp:e.timestamp}))}bm25Search(e,t){if(!this.searchDb)return[];try{const n=function(e){const t=e.replace(/[^\w\s]/g," ").split(/\s+/).filter(e=>e.length>0);return 0===t.length?"":t.map(e=>`"${e}"`).join(" OR ")}(e);if(!n)return[];return this.searchDb.prepare("\n SELECT chunks.id, bm25(chunks_fts) as rank\n FROM chunks_fts\n JOIN chunks ON chunks.id = chunks_fts.rowid\n WHERE chunks_fts MATCH ?\n ORDER BY rank\n LIMIT ?\n ").all(n,t).map(e=>({id:e.id,score:-e.rank}))}catch(e){return T.warn(`BM25 search error: ${e}`),[]}}denseSearch(e,t){if(!this.searchHnsw||0===this.searchHnsw.getCurrentCount())return[];const n=Math.min(t,this.searchHnsw.getCurrentCount()),s=this.searchHnsw.searchKnn(Array.from(e),n);return s.neighbors.map((e,t)=>({id:e,score:1-s.distances[t]}))}startWatcher(){if(e(this.memoryDir))try{this.watcher=a(this.memoryDir,{recursive:!0},(e,t)=>{this.debounceTimer&&clearTimeout(this.debounceTimer),this.debounceTimer=setTimeout(()=>{this.runIndexCycle()},this.opts.updateDebounceMs)})}catch(e){T.warn(`Could not start file watcher: ${e}`)}}async runIndexCycle(){if(!this.indexing){this.indexing=!0;try{await this.indexFiles(),this.indexDb&&await this.l0Generator.generate(this.indexDb),this.publishSnapshot()}catch(e){T.error(`Index cycle error: ${e}`)}finally{this.indexing=!1}}}async indexFiles(){if(!this.indexDb||!e(this.memoryDir))return;const i=function(e){const n=[];function i(r){let h;try{h=t(r,{withFileTypes:!0})}catch{return}for(const t of h){const h=o(r,t.name);if(t.isDirectory())i(h);else if(t.name.endsWith(".md"))try{const t=s(h),i=c(e,h),r=d(l(h));n.push({fullPath:h,relPath:i,sessionKey:r===d(e)?"":r,mtimeMs:Math.floor(t.mtimeMs),size:t.size})}catch{}}}return i(e),n}(this.memoryDir);let r=0;const h=this.indexDb.prepare("INSERT OR REPLACE INTO documents (path, mtime_ms, size) VALUES (?, ?, ?)"),a=this.indexDb.prepare("SELECT mtime_ms, size FROM documents WHERE path = ?"),m=this.indexDb.prepare("DELETE FROM chunks WHERE doc_path = ?"),u=this.indexDb.prepare("SELECT id FROM chunks WHERE doc_path = ?"),p=this.indexDb.prepare("DELETE FROM embeddings WHERE chunk_id IN (SELECT id FROM chunks WHERE doc_path = ?)"),E=this.indexDb.prepare("INSERT INTO chunks (doc_path, chunk_idx, role, timestamp, session_key, content) VALUES (?, ?, ?, ?, ?, ?)"),b=this.indexDb.prepare("SELECT path FROM documents").all().map(e=>e.path),f=new Set(i.map(e=>e.relPath));for(const e of b)if(!f.has(e)){const t=u.all(e);for(const{id:e}of t)try{this.indexHnsw?.markDelete(e)}catch{}p.run(e),m.run(e),this.indexDb.prepare("DELETE FROM documents WHERE path = ?").run(e),T.debug(`Removed deleted file from index: ${e}`)}for(const e of i){const t=a.get(e.relPath);if(t&&t.mtime_ms===e.mtimeMs&&t.size===e.size){r+=this.indexDb.prepare("SELECT COUNT(*) as c FROM chunks WHERE doc_path = ?").get(e.relPath).c;continue}const s=w(n(e.fullPath,"utf-8"),e.relPath,e.sessionKey),i=u.all(e.relPath);for(const{id:e}of i)try{this.indexHnsw?.markDelete(e)}catch{}p.run(e.relPath),m.run(e.relPath);this.indexDb.transaction(()=>{for(let t=0;t<s.length;t++){const n=s[t];E.run(e.relPath,t,n.role,n.timestamp,n.sessionKey,n.content)}h.run(e.relPath,e.mtimeMs,e.size)})(),r+=s.length,T.debug(`Indexed ${e.relPath}: ${s.length} chunks`)}T.info(`Indexed ${r} chunks from ${i.length} files`)}async embedPending(){if(!this.stopped&&!this.embedding&&this.indexDb&&this.indexHnsw){this.embedding=!0;try{const e=this.indexDb.prepare("\n SELECT c.id, c.content FROM chunks c\n LEFT JOIN embeddings e ON e.chunk_id = c.id\n WHERE e.chunk_id IS NULL\n ").all();if(0===e.length)return;T.info(`Embedding ${e.length} pending chunks...`),this.ensureHnswCapacity(e.length);const t=this.indexDb.prepare("INSERT OR REPLACE INTO embeddings (chunk_id) VALUES (?)");for(let n=0;n<e.length;n+=100){if(this.stopped)return void T.warn("embedPending aborted: engine stopped");const s=e.slice(n,n+100),i=s.map(e=>this.applyPrefix(this.opts.prefixDocument,e.content).slice(0,8e3)),r=this.opts.prefixDocument.trim();r&&T.debug(`Using prefixDocument (template: ${r}) → result sample: [${i[0].slice(0,80)}]`);try{let e;if(this.openai){const t=await this.openai.embeddings.create({model:this.opts.embeddingModel,input:i,dimensions:this.opts.embeddingDimensions});e=t.data.sort((e,t)=>e.index-t.index).map(e=>e.embedding)}else e=await this.fetchEmbeddings(i);if(this.stopped||!this.indexDb||!this.indexHnsw)return void T.warn("embedPending aborted: engine stopped during embedding");this.indexDb.transaction(()=>{for(let n=0;n<e.length;n++)this.indexHnsw.addPoint(e[n],s[n].id,!0),t.run(s[n].id)})(),T.debug(`Embedded batch ${n/100+1}: ${s.length} chunks`)}catch(e){if(this.stopped)return;T.error(`Embedding batch failed: ${e}`)}}if(this.stopped||!this.indexHnsw)return;this.indexHnsw.writeIndexSync(this.indexHnswPath),this.publishSnapshot(),T.info(`Embedded ${e.length} chunks (HNSW: ${this.indexHnsw.getCurrentCount()} total points)`)}finally{this.embedding=!1}}}publishSnapshot(){if(!this.indexDb)return;const t=o(this.dataDir,".memory-search-next.tmp"),n=o(this.dataDir,".memory-vectors-search-next.tmp");try{this.indexDb.pragma("wal_checkpoint(TRUNCATE)"),i(this.indexDbPath,t),r(t,this.searchNextDbPath),e(this.indexHnswPath)&&(i(this.indexHnswPath,n),r(n,this.searchNextHnswPath)),T.debug("Published search snapshot (DB + HNSW)")}catch(e){T.error(`Failed to publish snapshot: ${e}`);try{h(t)}catch{}try{h(n)}catch{}}}maybeSwap(){if(e(this.searchNextDbPath))try{this.searchDb&&(this.searchDb.close(),this.searchDb=null),this.searchHnsw=null,r(this.searchNextDbPath,this.searchDbPath),e(this.searchNextHnswPath)&&r(this.searchNextHnswPath,this.searchHnswPath),this.searchDb=new m(this.searchDbPath,{readonly:!0}),e(this.searchHnswPath)?(this.searchHnsw=new E(x,this.opts.embeddingDimensions),this.searchHnsw.readIndexSync(this.searchHnswPath),this.searchHnsw.setEf(50),T.debug(`Swapped to new search DB + HNSW (${this.searchHnsw.getCurrentCount()} points)`)):T.debug("Swapped to new search DB (no HNSW index yet)")}catch(t){T.error(`Failed to swap search DB: ${t}`);try{e(this.searchDbPath)&&(this.searchDb=new m(this.searchDbPath,{readonly:!0}))}catch{}}}applyPrefix(e,t){const n=e.trim();return n?n.replace(/\{content\}/g,()=>t):t}async fetchEmbeddings(e){const t=`${(this.opts.baseURL||"").replace(/\/+$/,"")}/embed`,n={"Content-Type":"application/json"};this.opts.apiKey&&(n.Authorization=`Bearer ${this.opts.apiKey}`);const s=await fetch(t,{method:"POST",headers:n,body:JSON.stringify({model:this.opts.embeddingModel,input:e})});if(!s.ok){const e=await s.text().catch(()=>"(no body)");throw new Error(`Embedding API ${s.status}: ${e.slice(0,300)}`)}const i=await s.json();if(Array.isArray(i.embeddings))return i.embeddings;throw new Error(`Unknown embedding response format. Keys: ${Object.keys(i).join(", ")}`)}async embedText(e){try{const t=this.applyPrefix(this.opts.prefixQuery,e),n=this.opts.prefixQuery.trim();if(n&&T.debug(`Using prefixQuery (template: ${n}) → result sample: [${t.slice(0,80)}]`),this.openai){const e=await this.openai.embeddings.create({model:this.opts.embeddingModel,input:t.slice(0,8e3),dimensions:this.opts.embeddingDimensions});return new Float32Array(e.data[0].embedding)}const s=await this.fetchEmbeddings([t.slice(0,8e3)]);return new Float32Array(s[0])}catch(e){return T.error(`Failed to embed query: ${e}`),null}}}function w(e,t,n){const s=[],i=e.split(/^### /m);for(const e of i){if(!e.trim())continue;const t=e.match(/^(user|assistant)\s*\(([^)]+)\)\s*\n/),i=t?t[1]:"",r=t?t[2]:"",h=t?e.slice(t[0].length).trim():e.trim();if(!h)continue;const a=1500,o=100;if(h.length<=a)s.push({role:i,timestamp:r,sessionKey:n,content:h});else{let e=0;for(;e<h.length;){const t=Math.min(e+a,h.length),c=h.slice(e,t);if(s.push({role:i,timestamp:r,sessionKey:n,content:c}),e=t-o,e+o>=h.length)break}}}return s}function D(e,t){if(0===e.size&&0===t.size)return 0;let n=0;const s=e.size<=t.size?e:t,i=e.size<=t.size?t:e;for(const e of s)i.has(e)&&n++;const r=e.size+t.size-n;return 0===r?0:n/r}
@@ -1 +1 @@
1
- export function configJS(){return"\n/* ---- Env var ref markers ---- */\nfunction markEnvRefs(container){\n if(!container) return;\n container.querySelectorAll('input').forEach(function(inp){\n if(/^\\$\\{[A-Za-z_][A-Za-z0-9_]*\\}$/.test(inp.value)){\n inp.classList.add('env-ref');\n inp.title='Stored as environment variable in .env';\n } else {\n inp.classList.remove('env-ref');\n inp.title='';\n }\n });\n}\n\n/* ---- STT ---- */\nasync function loadSTT(){\n currentConfig = await fetchAPI('/config');\n const s = currentConfig.stt||{};\n document.getElementById('sttEnabled').checked = !!s.enabled;\n document.getElementById('sttProvider').value = s.provider||'openai-whisper';\n const oai = s['openai-whisper']||{};\n populateSTTModelSelect();\n var sttModels = (currentConfig&&currentConfig.models)||[];\n document.getElementById('sttModelRef').value = typeof upgradeModelRef==='function' ? upgradeModelRef(oai.modelRef||'', sttModels) : (oai.modelRef||'');\n document.getElementById('sttOAILang').value = oai.language||'';\n const loc = s['local-whisper']||{};\n document.getElementById('sttLocalBin').value = loc.binaryPath||'whisper';\n document.getElementById('sttLocalModel').value = loc.model||'base';\n updateSTTFields();\n if(!loadSTT._bound){\n document.getElementById('sttProvider').addEventListener('change', updateSTTFields);\n loadSTT._bound = true;\n }\n sectionsLoaded.stt = true;\n}\nfunction updateSTTFields(){\n const prov = document.getElementById('sttProvider').value;\n document.getElementById('sttOpenAI').style.display = prov==='openai-whisper'?'':'none';\n document.getElementById('sttLocal').style.display = prov==='local-whisper'?'':'none';\n}\n\n/* ---- Memory ---- */\nasync function loadMemory(){\n currentConfig = await fetchAPI('/config');\n const m = currentConfig.memory||{};\n document.getElementById('memEnabled').checked = !!m.enabled;\n document.getElementById('memDir').value = m.dir||'./memory';\n document.getElementById('memStrategy').value = m.recallStrategy||'builtin-only';\n // Search settings\n const s = m.search||{};\n populateMemSearchModelSelect();\n var memModels = (currentConfig&&currentConfig.models)||[];\n document.getElementById('memSearchModelRef').value = typeof upgradeModelRef==='function' ? upgradeModelRef(s.modelRef||'', memModels) : (s.modelRef||'');\n document.getElementById('memSearchEmbModel').value = s.embeddingModel||'text-embedding-3-small';\n document.getElementById('memSearchPrefixQuery').value = s.prefixQuery||'';\n document.getElementById('memSearchPrefixDocument').value = s.prefixDocument||'';\n var dimsVal = String(s.embeddingDimensions||1536);\n document.getElementById('memSearchDims').value = dimsVal;\n document.getElementById('memSearchDimsValue').textContent = dimsVal;\n document.getElementById('memSearchMaxResults').value = s.maxResults||6;\n document.getElementById('memSearchDebounce').value = s.updateDebounceMs||3000;\n document.getElementById('memSearchEmbedInterval').value = s.embedIntervalMs||300000;\n document.getElementById('memSearchMaxSnippet').value = s.maxSnippetChars||700;\n document.getElementById('memSearchMaxInjected').value = s.maxInjectedChars||4000;\n document.getElementById('memSearchRrfK').value = s.rrfK||60;\n updateMemSearchFields();\n // L0 settings\n const l0 = m.l0||{};\n document.getElementById('l0Enabled').checked = l0.enabled !== false; // default true\n document.getElementById('l0Model').value = l0.model||'';\n updateL0Hint();\n document.getElementById('l0Model').addEventListener('input', updateL0Hint);\n // Intercept toggle-off\n document.getElementById('memEnabled').addEventListener('change', function(){\n if(!this.checked){\n this.checked = true; // revert until confirmed\n document.getElementById('memDisableModal').classList.add('open');\n }\n });\n sectionsLoaded.memory = true;\n}\nfunction updateMemSearchFields(){\n var strategy = document.getElementById('memStrategy').value;\n document.getElementById('memSearchSettings').style.display = strategy==='search'?'':'none';\n}\nfunction updateL0Hint(){\n var model = (document.getElementById('l0Model').value||'').trim();\n document.getElementById('l0OpenAIHint').style.display = model ? '' : 'none';\n}\nasync function testEmbedding(){\n var btn = document.getElementById('memSearchTestBtn');\n var result = document.getElementById('memSearchTestResult');\n btn.disabled = true;\n btn.textContent = 'Testing...';\n result.style.color = 'var(--text-muted)';\n result.textContent = '';\n try {\n var body = {\n modelRef: document.getElementById('memSearchModelRef').value,\n embeddingModel: document.getElementById('memSearchEmbModel').value || 'text-embedding-3-small',\n embeddingDimensions: parseInt(document.getElementById('memSearchDims').value) || 1536\n };\n var resp = await fetch(API+'/memory-search/test-embedding', {method:'POST', headers:{'Content-Type':'application/json','X-CSRF-Token':_csrfToken}, credentials:'include', body:JSON.stringify(body)});\n var data = await resp.json();\n if(data.ok){\n result.style.color = 'var(--success, #22c55e)';\n result.textContent = '✓ OK — model: '+data.model+', dims: '+data.dimensions+', latency: '+data.latencyMs+'ms';\n } else {\n result.style.color = 'var(--error, #ef4444)';\n result.textContent = '✗ '+data.error;\n }\n } catch(err){\n result.style.color = 'var(--error, #ef4444)';\n result.textContent = '✗ Connection error: '+err.message;\n } finally {\n btn.disabled = false;\n btn.textContent = 'Test Embedding';\n }\n}\nfunction closeMemDisableModal(confirmed){\n document.getElementById('memDisableModal').classList.remove('open');\n if(confirmed){\n document.getElementById('memEnabled').checked = false;\n saveConfig();\n }\n}\n\n/* ---- Settings ---- */\nasync function loadSettings(){\n currentConfig = await fetchAPI('/config');\n document.getElementById('settingsHost').value = currentConfig.host || '127.0.0.1';\n document.getElementById('settingsUiPort').value = (currentConfig.nostromo && currentConfig.nostromo.port) || '3001';\n document.getElementById('settingsTimezone').value = currentConfig.timezone || '';\n document.getElementById('settingsAutoRestart').checked = !!(currentConfig.nostromo && currentConfig.nostromo.autoRestart);\n document.getElementById('settingsConfigCheckInterval').value = (currentConfig.nostromo && currentConfig.nostromo.configCheckInterval) || 5;\n sectionsLoaded.settings = true;\n}\n\n/* ---- Access key display ---- */\nvar _currentKey = '';\nvar _keyRevealed = false;\n\nfunction maskKey(key){\n // Show first 4 chars, mask the rest: \"ABCD-****-****-****\"\n if(key.length<=4) return key;\n return key.substring(0,4) + key.substring(4).replace(/[A-Za-z0-9]/g, '*');\n}\n\nasync function loadCurrentKey(){\n try{\n const res = await fetch(API+'/key');\n const data = await res.json();\n _currentKey = data.key || '';\n _keyRevealed = false;\n var row = document.getElementById('currentKeyRow');\n var el = document.getElementById('currentKeyValue');\n if(_currentKey && _currentKey !== '0000'){\n el.textContent = maskKey(_currentKey);\n row.style.display = 'flex';\n } else {\n row.style.display = 'none';\n }\n document.getElementById('keyEyeOff').style.display = '';\n document.getElementById('keyEyeOn').style.display = 'none';\n }catch(e){}\n}\n\nfunction toggleKeyVisibility(){\n _keyRevealed = !_keyRevealed;\n var el = document.getElementById('currentKeyValue');\n el.textContent = _keyRevealed ? _currentKey : maskKey(_currentKey);\n document.getElementById('keyEyeOff').style.display = _keyRevealed ? 'none' : '';\n document.getElementById('keyEyeOn').style.display = _keyRevealed ? '' : 'none';\n}\n\nasync function copyKey(){\n try{\n await navigator.clipboard.writeText(_currentKey);\n document.getElementById('keyCopyIcon').style.display = 'none';\n document.getElementById('keyCheckIcon').style.display = '';\n setTimeout(function(){\n document.getElementById('keyCopyIcon').style.display = '';\n document.getElementById('keyCheckIcon').style.display = 'none';\n }, 1500);\n }catch(e){ toast('Copy failed','err'); }\n}\n\n/* ---- Regenerate key ---- */\nfunction confirmRegenKey(){\n document.getElementById('regenKeyModal').classList.add('open');\n}\nfunction closeRegenKeyModal(){\n document.getElementById('regenKeyModal').classList.remove('open');\n}\nasync function regenKey(){\n try{\n const res = await fetch(API+'/key/regenerate',{method:'POST',headers:{'X-CSRF-Token':_csrfToken}});\n const data = await res.json();\n if(data.key){\n showNewKey(data.key);\n _currentKey = data.key;\n _keyRevealed = false;\n var el = document.getElementById('currentKeyValue');\n el.textContent = maskKey(data.key);\n document.getElementById('currentKeyRow').style.display = 'flex';\n document.getElementById('keyEyeOff').style.display = '';\n document.getElementById('keyEyeOn').style.display = 'none';\n }\n }catch(e){ toast('Failed','err'); }\n}\n\n/* ---- Save config ---- */\nfunction gatherConfig(){\n if(!currentConfig){ toast('Config not loaded yet — cannot save','err'); return null; }\n const cfg = JSON.parse(JSON.stringify(currentConfig));\n // Verbose debug logs\n var logVerboseEl = document.getElementById('logVerbose');\n if(logVerboseEl) cfg.verboseDebugLogs = logVerboseEl.checked;\n // Channels\n const wrap = document.getElementById('channelCards');\n if(wrap){\n if(!cfg.channels) cfg.channels={};\n for(const ch of CHANNEL_LIST){\n const toggle = wrap.querySelector('[data-ch-toggle=\"'+ch+'\"]');\n if(!toggle) continue;\n if(!cfg.channels[ch]) cfg.channels[ch]={};\n cfg.channels[ch].enabled = toggle.checked;\n }\n // Collect all channel fields via data-ch-field=\"channel.account.key\" or \"channel.key\"\n wrap.querySelectorAll('[data-ch-field]').forEach(inp=>{\n const parts = inp.dataset.chField.split('.');\n const chName = parts[0];\n if(!cfg.channels[chName]) cfg.channels[chName]={};\n if(parts.length === 3){\n // channel.account.key\n const [, acct, key] = parts;\n if(!cfg.channels[chName].accounts) cfg.channels[chName].accounts={};\n if(!cfg.channels[chName].accounts[acct]) cfg.channels[chName].accounts[acct]={};\n var val;\n if(key==='allowFrom') val = inp.value.split(',').map(function(s){return s.trim()}).filter(Boolean);\n else if(inp.type==='number') val = parseInt(inp.value)||0;\n else val = inp.value;\n cfg.channels[chName].accounts[acct][key] = val;\n } else if(parts.length === 2){\n // channel.key (e.g. responses.port)\n const key = parts[1];\n cfg.channels[chName][key] = inp.type==='number' ? (parseInt(inp.value)||0) : inp.value;\n }\n });\n }\n // Models\n if(currentConfig&&currentConfig.models) cfg.models = currentConfig.models;\n // Agent — only gather from DOM if loadAgent() has populated the fields\n if(sectionsLoaded.agent){\n cfg.agent = cfg.agent||{};\n cfg.agent.model = document.getElementById('agentModel').value;\n cfg.agent.mainFallback = document.getElementById('agentMainFallback').value;\n cfg.agent.maxTurns = parseInt(document.getElementById('agentMaxTurns').value)||10;\n cfg.agent.permissionMode = document.getElementById('agentPermMode').value;\n cfg.agent.sessionTTL = parseInt(document.getElementById('agentSessionTTL').value)||3600;\n cfg.agent.settingSources = document.getElementById('agentSettingSources').value;\n cfg.agent.builtinCoderSkill = document.getElementById('agentCoderSkill').checked;\n cfg.agent.autoRenew = parseInt(document.getElementById('agentAutoRenew').value)||0;\n cfg.agent.allowedTools = [];\n document.querySelectorAll('#agentToolsGrid [data-tool]').forEach(function(cb){if(cb.checked) cfg.agent.allowedTools.push(cb.dataset.tool);});\n cfg.agent.disallowedTools = [];\n cfg.agent.queueMode = document.getElementById('agentQueueMode').value;\n cfg.agent.queueDebounceMs = parseInt(document.getElementById('agentDebounceMs').value)||0;\n cfg.agent.queueCap = parseInt(document.getElementById('agentQueueCap').value)||0;\n cfg.agent.queueDropPolicy = document.getElementById('agentDropPolicy').value;\n cfg.agent.inflightTyping = document.getElementById('agentInflightTyping').checked;\n cfg.agent.autoApproveTools = document.getElementById('agentAutoApprove').checked;\n // Pico Agent\n cfg.agent.picoAgent = {\n enabled: document.getElementById('picoEnabled').checked,\n modelRefs: _picoModels.map(function(m){ return m.name + ':' + m.piProvider + ':' + m.piModelId; }),\n rollingMemoryModel: document.getElementById('picoRollingModel').value\n };\n delete cfg.agent.engine; // remove legacy\n }\n // Custom SubAgents (preserved from currentConfig, mutated in-place by the UI)\n if(currentConfig && currentConfig.agent && currentConfig.agent.customSubAgents){\n if(!cfg.agent) cfg.agent = {};\n cfg.agent.customSubAgents = currentConfig.agent.customSubAgents;\n }\n // Plugins (preserved from currentConfig, mutated in-place by the UI)\n if(currentConfig && currentConfig.agent && currentConfig.agent.plugins){\n if(!cfg.agent) cfg.agent = {};\n cfg.agent.plugins = currentConfig.agent.plugins;\n }\n // STT — only gather from DOM if loadSTT() has populated the fields\n if(sectionsLoaded.stt){\n cfg.stt = cfg.stt||{};\n cfg.stt.enabled = document.getElementById('sttEnabled').checked;\n cfg.stt.provider = document.getElementById('sttProvider').value;\n var sttModelName = document.getElementById('sttModelRef').value;\n cfg.stt['openai-whisper'] = {\n modelRef: sttModelName,\n model: 'whisper-1',\n language: document.getElementById('sttOAILang').value\n };\n cfg.stt['local-whisper'] = {\n binaryPath: document.getElementById('sttLocalBin').value,\n model: document.getElementById('sttLocalModel').value\n };\n }\n // TTS — only gather from DOM if loadTTS() has populated the fields\n if(sectionsLoaded.tts){\n cfg.tts = cfg.tts||{};\n cfg.tts.enabled = document.getElementById('ttsEnabled').checked;\n cfg.tts.provider = document.getElementById('ttsProvider').value;\n cfg.tts.maxTextLength = parseInt(document.getElementById('ttsMaxTextLength').value)||4096;\n cfg.tts.timeoutMs = parseInt(document.getElementById('ttsTimeoutMs').value)||30000;\n cfg.tts.edge = {\n voice: document.getElementById('ttsEdgeVoice').value || 'en-US-MichelleNeural'\n };\n cfg.tts.openai = {\n modelRef: document.getElementById('ttsOAIModelRef').value,\n model: document.getElementById('ttsOAIModel').value || 'gpt-4o-mini-tts',\n voice: document.getElementById('ttsOAIVoice').value || 'alloy'\n };\n cfg.tts.elevenlabs = {\n modelRef: document.getElementById('ttsELModelRef').value,\n voiceId: document.getElementById('ttsELVoiceId').value || 'pMsXgVXv3BLzUgSXRplE',\n modelId: document.getElementById('ttsELModelId').value || 'eleven_multilingual_v2'\n };\n }\n // Memory — only gather from DOM if loadMemory() has populated the fields\n if(sectionsLoaded.memory){\n cfg.memory = cfg.memory||{};\n cfg.memory.enabled = document.getElementById('memEnabled').checked;\n cfg.memory.dir = document.getElementById('memDir').value;\n cfg.memory.recallStrategy = document.getElementById('memStrategy').value;\n cfg.memory.search = {\n enabled: cfg.memory.recallStrategy === 'search',\n modelRef: document.getElementById('memSearchModelRef').value,\n embeddingModel: document.getElementById('memSearchEmbModel').value || 'text-embedding-3-small',\n prefixQuery: document.getElementById('memSearchPrefixQuery').value || '',\n prefixDocument: document.getElementById('memSearchPrefixDocument').value || '',\n embeddingDimensions: parseInt(document.getElementById('memSearchDims').value) || 1536,\n updateDebounceMs: parseInt(document.getElementById('memSearchDebounce').value) || 3000,\n embedIntervalMs: parseInt(document.getElementById('memSearchEmbedInterval').value) || 300000,\n maxResults: parseInt(document.getElementById('memSearchMaxResults').value) || 6,\n maxSnippetChars: parseInt(document.getElementById('memSearchMaxSnippet').value) || 700,\n maxInjectedChars: parseInt(document.getElementById('memSearchMaxInjected').value) || 4000,\n rrfK: parseInt(document.getElementById('memSearchRrfK').value) || 60\n };\n cfg.memory.l0 = {\n enabled: document.getElementById('l0Enabled').checked,\n model: (document.getElementById('l0Model').value||'').trim()\n };\n }\n // Cron — only gather from DOM if loadCron() has populated the fields\n if(sectionsLoaded.cron){\n cfg.cron = cfg.cron||{};\n cfg.cron.enabled = document.getElementById('cronEnabled').checked;\n cfg.cron.isolated = document.getElementById('cronIsolated').checked;\n cfg.cron.broadcastEvents = document.getElementById('cronBroadcast').checked;\n cfg.cron.heartbeat = cfg.cron.heartbeat||{};\n cfg.cron.heartbeat.enabled = document.getElementById('hbEnabled').checked;\n cfg.cron.heartbeat.channel = document.getElementById('hbChannel').value;\n cfg.cron.heartbeat.chatId = document.getElementById('hbChatId').value;\n cfg.cron.heartbeat.every = parseInt(document.getElementById('hbEvery').value)||1800000;\n cfg.cron.heartbeat.message = document.getElementById('hbMessage').value;\n cfg.cron.heartbeat.ackMaxChars = parseInt(document.getElementById('hbAckMaxChars').value)||300;\n }\n // Settings — only gather from DOM if loadSettings() has populated the fields\n if(sectionsLoaded.settings){\n cfg.host = document.getElementById('settingsHost').value || '127.0.0.1';\n cfg.timezone = document.getElementById('settingsTimezone').value || '';\n cfg.nostromo = cfg.nostromo||{};\n cfg.nostromo.port = parseInt(document.getElementById('settingsUiPort').value)||3001;\n cfg.nostromo.autoRestart = document.getElementById('settingsAutoRestart').checked;\n cfg.nostromo.configCheckInterval = parseInt(document.getElementById('settingsConfigCheckInterval').value)||5;\n }\n return cfg;\n}\nasync function saveConfig(){\n const cfg = gatherConfig();\n if(!cfg) return;\n // Validate memory search prefix fields contain {content} if non-empty\n if(cfg.memory && cfg.memory.search){\n var pq = (cfg.memory.search.prefixQuery||'').trim();\n var pd = (cfg.memory.search.prefixDocument||'').trim();\n if(pq && pq.indexOf('{content}')===-1){ toast('Prefix Query must contain the {content} placeholder. Save aborted.','err'); return; }\n if(pd && pd.indexOf('{content}')===-1){ toast('Prefix Document must contain the {content} placeholder. Save aborted.','err'); return; }\n }\n // Block save if heartbeat is enabled but message is too short\n if(cfg.cron && cfg.cron.heartbeat && cfg.cron.heartbeat.enabled){\n var hbMsg = (cfg.cron.heartbeat.message||'').trim();\n if(hbMsg.length < 15){\n toast('Heartbeat message is too short (minimum 15 characters). Save aborted.','err');\n return;\n }\n }\n try{\n const res = await fetch(API+'/config',{method:'PUT',headers:{'Content-Type':'application/json','X-CSRF-Token':_csrfToken},body:JSON.stringify(cfg)});\n if(!res.ok){ const d=await res.json(); toast(d.error||'Save failed','err'); return; }\n currentConfig = await fetchAPI('/config');\n clearDirty();\n toast('Configuration saved','ok');\n updateConfigCheckPolling();\n // Re-render current section with protected values from server\n var activeSec = document.querySelector('.section.active');\n if(activeSec){\n var id = activeSec.id.replace('sec-','');\n if(id==='models') renderModelsTable();\n if(id==='vars') renderVarsTable();\n }\n }catch(e){ console.error('Save failed:',e); toast('Save failed: '+(e.message||e),'err'); }\n}\n"}
1
+ export function configJS(){return"\n/* ---- Env var ref markers ---- */\nfunction markEnvRefs(container){\n if(!container) return;\n container.querySelectorAll('input').forEach(function(inp){\n if(/^\\$\\{[A-Za-z_][A-Za-z0-9_]*\\}$/.test(inp.value)){\n inp.classList.add('env-ref');\n inp.title='Stored as environment variable in .env';\n } else {\n inp.classList.remove('env-ref');\n inp.title='';\n }\n });\n}\n\n/* ---- STT ---- */\nasync function loadSTT(){\n currentConfig = await fetchAPI('/config');\n const s = currentConfig.stt||{};\n document.getElementById('sttEnabled').checked = !!s.enabled;\n document.getElementById('sttProvider').value = s.provider||'openai-whisper';\n const oai = s['openai-whisper']||{};\n populateSTTModelSelect();\n var sttModels = (currentConfig&&currentConfig.models)||[];\n document.getElementById('sttModelRef').value = typeof upgradeModelRef==='function' ? upgradeModelRef(oai.modelRef||'', sttModels) : (oai.modelRef||'');\n document.getElementById('sttOAILang').value = oai.language||'';\n const loc = s['local-whisper']||{};\n document.getElementById('sttLocalBin').value = loc.binaryPath||'whisper';\n document.getElementById('sttLocalModel').value = loc.model||'base';\n updateSTTFields();\n if(!loadSTT._bound){\n document.getElementById('sttProvider').addEventListener('change', updateSTTFields);\n loadSTT._bound = true;\n }\n sectionsLoaded.stt = true;\n}\nfunction updateSTTFields(){\n const prov = document.getElementById('sttProvider').value;\n document.getElementById('sttOpenAI').style.display = prov==='openai-whisper'?'':'none';\n document.getElementById('sttLocal').style.display = prov==='local-whisper'?'':'none';\n}\n\n/* ---- Memory ---- */\nasync function loadMemory(){\n currentConfig = await fetchAPI('/config');\n const m = currentConfig.memory||{};\n document.getElementById('memEnabled').checked = !!m.enabled;\n document.getElementById('memDir').value = m.dir||'./memory';\n document.getElementById('memStrategy').value = m.recallStrategy||'builtin-only';\n // Search settings\n const s = m.search||{};\n populateMemSearchModelSelect();\n var memModels = (currentConfig&&currentConfig.models)||[];\n document.getElementById('memSearchModelRef').value = typeof upgradeModelRef==='function' ? upgradeModelRef(s.modelRef||'', memModels) : (s.modelRef||'');\n document.getElementById('memSearchEmbModel').value = s.embeddingModel||'text-embedding-3-small';\n document.getElementById('memSearchPrefixQuery').value = s.prefixQuery||'';\n document.getElementById('memSearchPrefixDocument').value = s.prefixDocument||'';\n var dimsVal = String(s.embeddingDimensions||1536);\n document.getElementById('memSearchDims').value = dimsVal;\n document.getElementById('memSearchDimsValue').textContent = dimsVal;\n document.getElementById('memSearchMaxResults').value = s.maxResults||6;\n document.getElementById('memSearchDebounce').value = s.updateDebounceMs||3000;\n document.getElementById('memSearchEmbedInterval').value = s.embedIntervalMs||300000;\n document.getElementById('memSearchMaxSnippet').value = s.maxSnippetChars||700;\n document.getElementById('memSearchMaxInjected').value = s.maxInjectedChars??4000;\n document.getElementById('memSearchRrfK').value = s.rrfK||60;\n updateMemSearchFields();\n // L0 settings\n const l0 = m.l0||{};\n document.getElementById('l0Enabled').checked = l0.enabled !== false; // default true\n document.getElementById('l0Model').value = l0.model||'';\n updateL0Hint();\n document.getElementById('l0Model').addEventListener('input', updateL0Hint);\n // Intercept toggle-off\n document.getElementById('memEnabled').addEventListener('change', function(){\n if(!this.checked){\n this.checked = true; // revert until confirmed\n document.getElementById('memDisableModal').classList.add('open');\n }\n });\n sectionsLoaded.memory = true;\n}\nfunction updateMemSearchFields(){\n var strategy = document.getElementById('memStrategy').value;\n document.getElementById('memSearchSettings').style.display = strategy==='search'?'':'none';\n}\nfunction updateL0Hint(){\n var model = (document.getElementById('l0Model').value||'').trim();\n document.getElementById('l0OpenAIHint').style.display = model ? '' : 'none';\n}\nasync function testEmbedding(){\n var btn = document.getElementById('memSearchTestBtn');\n var result = document.getElementById('memSearchTestResult');\n btn.disabled = true;\n btn.textContent = 'Testing...';\n result.style.color = 'var(--text-muted)';\n result.textContent = '';\n try {\n var body = {\n modelRef: document.getElementById('memSearchModelRef').value,\n embeddingModel: document.getElementById('memSearchEmbModel').value || 'text-embedding-3-small',\n embeddingDimensions: parseInt(document.getElementById('memSearchDims').value) || 1536\n };\n var resp = await fetch(API+'/memory-search/test-embedding', {method:'POST', headers:{'Content-Type':'application/json','X-CSRF-Token':_csrfToken}, credentials:'include', body:JSON.stringify(body)});\n var data = await resp.json();\n if(data.ok){\n result.style.color = 'var(--success, #22c55e)';\n result.textContent = '✓ OK — model: '+data.model+', dims: '+data.dimensions+', latency: '+data.latencyMs+'ms';\n } else {\n result.style.color = 'var(--error, #ef4444)';\n result.textContent = '✗ '+data.error;\n }\n } catch(err){\n result.style.color = 'var(--error, #ef4444)';\n result.textContent = '✗ Connection error: '+err.message;\n } finally {\n btn.disabled = false;\n btn.textContent = 'Test Embedding';\n }\n}\nfunction closeMemDisableModal(confirmed){\n document.getElementById('memDisableModal').classList.remove('open');\n if(confirmed){\n document.getElementById('memEnabled').checked = false;\n saveConfig();\n }\n}\n\n/* ---- Settings ---- */\nasync function loadSettings(){\n currentConfig = await fetchAPI('/config');\n document.getElementById('settingsHost').value = currentConfig.host || '127.0.0.1';\n document.getElementById('settingsUiPort').value = (currentConfig.nostromo && currentConfig.nostromo.port) || '3001';\n document.getElementById('settingsTimezone').value = currentConfig.timezone || '';\n document.getElementById('settingsAutoRestart').checked = !!(currentConfig.nostromo && currentConfig.nostromo.autoRestart);\n document.getElementById('settingsConfigCheckInterval').value = (currentConfig.nostromo && currentConfig.nostromo.configCheckInterval) || 5;\n sectionsLoaded.settings = true;\n}\n\n/* ---- Access key display ---- */\nvar _currentKey = '';\nvar _keyRevealed = false;\n\nfunction maskKey(key){\n // Show first 4 chars, mask the rest: \"ABCD-****-****-****\"\n if(key.length<=4) return key;\n return key.substring(0,4) + key.substring(4).replace(/[A-Za-z0-9]/g, '*');\n}\n\nasync function loadCurrentKey(){\n try{\n const res = await fetch(API+'/key');\n const data = await res.json();\n _currentKey = data.key || '';\n _keyRevealed = false;\n var row = document.getElementById('currentKeyRow');\n var el = document.getElementById('currentKeyValue');\n if(_currentKey && _currentKey !== '0000'){\n el.textContent = maskKey(_currentKey);\n row.style.display = 'flex';\n } else {\n row.style.display = 'none';\n }\n document.getElementById('keyEyeOff').style.display = '';\n document.getElementById('keyEyeOn').style.display = 'none';\n }catch(e){}\n}\n\nfunction toggleKeyVisibility(){\n _keyRevealed = !_keyRevealed;\n var el = document.getElementById('currentKeyValue');\n el.textContent = _keyRevealed ? _currentKey : maskKey(_currentKey);\n document.getElementById('keyEyeOff').style.display = _keyRevealed ? 'none' : '';\n document.getElementById('keyEyeOn').style.display = _keyRevealed ? '' : 'none';\n}\n\nasync function copyKey(){\n try{\n await navigator.clipboard.writeText(_currentKey);\n document.getElementById('keyCopyIcon').style.display = 'none';\n document.getElementById('keyCheckIcon').style.display = '';\n setTimeout(function(){\n document.getElementById('keyCopyIcon').style.display = '';\n document.getElementById('keyCheckIcon').style.display = 'none';\n }, 1500);\n }catch(e){ toast('Copy failed','err'); }\n}\n\n/* ---- Regenerate key ---- */\nfunction confirmRegenKey(){\n document.getElementById('regenKeyModal').classList.add('open');\n}\nfunction closeRegenKeyModal(){\n document.getElementById('regenKeyModal').classList.remove('open');\n}\nasync function regenKey(){\n try{\n const res = await fetch(API+'/key/regenerate',{method:'POST',headers:{'X-CSRF-Token':_csrfToken}});\n const data = await res.json();\n if(data.key){\n showNewKey(data.key);\n _currentKey = data.key;\n _keyRevealed = false;\n var el = document.getElementById('currentKeyValue');\n el.textContent = maskKey(data.key);\n document.getElementById('currentKeyRow').style.display = 'flex';\n document.getElementById('keyEyeOff').style.display = '';\n document.getElementById('keyEyeOn').style.display = 'none';\n }\n }catch(e){ toast('Failed','err'); }\n}\n\n/* ---- Save config ---- */\nfunction gatherConfig(){\n if(!currentConfig){ toast('Config not loaded yet — cannot save','err'); return null; }\n const cfg = JSON.parse(JSON.stringify(currentConfig));\n // Verbose debug logs\n var logVerboseEl = document.getElementById('logVerbose');\n if(logVerboseEl) cfg.verboseDebugLogs = logVerboseEl.checked;\n // Channels\n const wrap = document.getElementById('channelCards');\n if(wrap){\n if(!cfg.channels) cfg.channels={};\n for(const ch of CHANNEL_LIST){\n const toggle = wrap.querySelector('[data-ch-toggle=\"'+ch+'\"]');\n if(!toggle) continue;\n if(!cfg.channels[ch]) cfg.channels[ch]={};\n cfg.channels[ch].enabled = toggle.checked;\n }\n // Collect all channel fields via data-ch-field=\"channel.account.key\" or \"channel.key\"\n wrap.querySelectorAll('[data-ch-field]').forEach(inp=>{\n const parts = inp.dataset.chField.split('.');\n const chName = parts[0];\n if(!cfg.channels[chName]) cfg.channels[chName]={};\n if(parts.length === 3){\n // channel.account.key\n const [, acct, key] = parts;\n if(!cfg.channels[chName].accounts) cfg.channels[chName].accounts={};\n if(!cfg.channels[chName].accounts[acct]) cfg.channels[chName].accounts[acct]={};\n var val;\n if(key==='allowFrom') val = inp.value.split(',').map(function(s){return s.trim()}).filter(Boolean);\n else if(inp.type==='number') val = parseInt(inp.value)||0;\n else val = inp.value;\n cfg.channels[chName].accounts[acct][key] = val;\n } else if(parts.length === 2){\n // channel.key (e.g. responses.port)\n const key = parts[1];\n cfg.channels[chName][key] = inp.type==='number' ? (parseInt(inp.value)||0) : inp.value;\n }\n });\n }\n // Models\n if(currentConfig&&currentConfig.models) cfg.models = currentConfig.models;\n // Agent — only gather from DOM if loadAgent() has populated the fields\n if(sectionsLoaded.agent){\n cfg.agent = cfg.agent||{};\n cfg.agent.model = document.getElementById('agentModel').value;\n cfg.agent.mainFallback = document.getElementById('agentMainFallback').value;\n cfg.agent.maxTurns = parseInt(document.getElementById('agentMaxTurns').value)||10;\n cfg.agent.permissionMode = document.getElementById('agentPermMode').value;\n cfg.agent.sessionTTL = parseInt(document.getElementById('agentSessionTTL').value)||3600;\n cfg.agent.settingSources = document.getElementById('agentSettingSources').value;\n cfg.agent.builtinCoderSkill = document.getElementById('agentCoderSkill').checked;\n cfg.agent.autoRenew = parseInt(document.getElementById('agentAutoRenew').value)||0;\n cfg.agent.allowedTools = [];\n document.querySelectorAll('#agentToolsGrid [data-tool]').forEach(function(cb){if(cb.checked) cfg.agent.allowedTools.push(cb.dataset.tool);});\n cfg.agent.disallowedTools = [];\n cfg.agent.queueMode = document.getElementById('agentQueueMode').value;\n cfg.agent.queueDebounceMs = parseInt(document.getElementById('agentDebounceMs').value)||0;\n cfg.agent.queueCap = parseInt(document.getElementById('agentQueueCap').value)||0;\n cfg.agent.queueDropPolicy = document.getElementById('agentDropPolicy').value;\n cfg.agent.inflightTyping = document.getElementById('agentInflightTyping').checked;\n cfg.agent.autoApproveTools = document.getElementById('agentAutoApprove').checked;\n // Pico Agent\n cfg.agent.picoAgent = {\n enabled: document.getElementById('picoEnabled').checked,\n modelRefs: _picoModels.map(function(m){ return m.name + ':' + m.piProvider + ':' + m.piModelId; }),\n rollingMemoryModel: document.getElementById('picoRollingModel').value\n };\n delete cfg.agent.engine; // remove legacy\n }\n // Custom SubAgents (preserved from currentConfig, mutated in-place by the UI)\n if(currentConfig && currentConfig.agent && currentConfig.agent.customSubAgents){\n if(!cfg.agent) cfg.agent = {};\n cfg.agent.customSubAgents = currentConfig.agent.customSubAgents;\n }\n // Plugins (preserved from currentConfig, mutated in-place by the UI)\n if(currentConfig && currentConfig.agent && currentConfig.agent.plugins){\n if(!cfg.agent) cfg.agent = {};\n cfg.agent.plugins = currentConfig.agent.plugins;\n }\n // STT — only gather from DOM if loadSTT() has populated the fields\n if(sectionsLoaded.stt){\n cfg.stt = cfg.stt||{};\n cfg.stt.enabled = document.getElementById('sttEnabled').checked;\n cfg.stt.provider = document.getElementById('sttProvider').value;\n var sttModelName = document.getElementById('sttModelRef').value;\n cfg.stt['openai-whisper'] = {\n modelRef: sttModelName,\n model: 'whisper-1',\n language: document.getElementById('sttOAILang').value\n };\n cfg.stt['local-whisper'] = {\n binaryPath: document.getElementById('sttLocalBin').value,\n model: document.getElementById('sttLocalModel').value\n };\n }\n // TTS — only gather from DOM if loadTTS() has populated the fields\n if(sectionsLoaded.tts){\n cfg.tts = cfg.tts||{};\n cfg.tts.enabled = document.getElementById('ttsEnabled').checked;\n cfg.tts.provider = document.getElementById('ttsProvider').value;\n cfg.tts.maxTextLength = parseInt(document.getElementById('ttsMaxTextLength').value)||4096;\n cfg.tts.timeoutMs = parseInt(document.getElementById('ttsTimeoutMs').value)||30000;\n cfg.tts.edge = {\n voice: document.getElementById('ttsEdgeVoice').value || 'en-US-MichelleNeural'\n };\n cfg.tts.openai = {\n modelRef: document.getElementById('ttsOAIModelRef').value,\n model: document.getElementById('ttsOAIModel').value || 'gpt-4o-mini-tts',\n voice: document.getElementById('ttsOAIVoice').value || 'alloy'\n };\n cfg.tts.elevenlabs = {\n modelRef: document.getElementById('ttsELModelRef').value,\n voiceId: document.getElementById('ttsELVoiceId').value || 'pMsXgVXv3BLzUgSXRplE',\n modelId: document.getElementById('ttsELModelId').value || 'eleven_multilingual_v2'\n };\n }\n // Memory — only gather from DOM if loadMemory() has populated the fields\n if(sectionsLoaded.memory){\n cfg.memory = cfg.memory||{};\n cfg.memory.enabled = document.getElementById('memEnabled').checked;\n cfg.memory.dir = document.getElementById('memDir').value;\n cfg.memory.recallStrategy = document.getElementById('memStrategy').value;\n cfg.memory.search = {\n enabled: cfg.memory.recallStrategy === 'search',\n modelRef: document.getElementById('memSearchModelRef').value,\n embeddingModel: document.getElementById('memSearchEmbModel').value || 'text-embedding-3-small',\n prefixQuery: document.getElementById('memSearchPrefixQuery').value || '',\n prefixDocument: document.getElementById('memSearchPrefixDocument').value || '',\n embeddingDimensions: parseInt(document.getElementById('memSearchDims').value) || 1536,\n updateDebounceMs: parseInt(document.getElementById('memSearchDebounce').value) || 3000,\n embedIntervalMs: parseInt(document.getElementById('memSearchEmbedInterval').value) || 300000,\n maxResults: parseInt(document.getElementById('memSearchMaxResults').value) || 6,\n maxSnippetChars: parseInt(document.getElementById('memSearchMaxSnippet').value) || 700,\n maxInjectedChars: parseInt(document.getElementById('memSearchMaxInjected').value || '4000'),\n rrfK: parseInt(document.getElementById('memSearchRrfK').value) || 60\n };\n cfg.memory.l0 = {\n enabled: document.getElementById('l0Enabled').checked,\n model: (document.getElementById('l0Model').value||'').trim()\n };\n }\n // Cron — only gather from DOM if loadCron() has populated the fields\n if(sectionsLoaded.cron){\n cfg.cron = cfg.cron||{};\n cfg.cron.enabled = document.getElementById('cronEnabled').checked;\n cfg.cron.isolated = document.getElementById('cronIsolated').checked;\n cfg.cron.broadcastEvents = document.getElementById('cronBroadcast').checked;\n cfg.cron.heartbeat = cfg.cron.heartbeat||{};\n cfg.cron.heartbeat.enabled = document.getElementById('hbEnabled').checked;\n cfg.cron.heartbeat.channel = document.getElementById('hbChannel').value;\n cfg.cron.heartbeat.chatId = document.getElementById('hbChatId').value;\n cfg.cron.heartbeat.every = parseInt(document.getElementById('hbEvery').value)||1800000;\n cfg.cron.heartbeat.message = document.getElementById('hbMessage').value;\n cfg.cron.heartbeat.ackMaxChars = parseInt(document.getElementById('hbAckMaxChars').value)||300;\n }\n // Settings — only gather from DOM if loadSettings() has populated the fields\n if(sectionsLoaded.settings){\n cfg.host = document.getElementById('settingsHost').value || '127.0.0.1';\n cfg.timezone = document.getElementById('settingsTimezone').value || '';\n cfg.nostromo = cfg.nostromo||{};\n cfg.nostromo.port = parseInt(document.getElementById('settingsUiPort').value)||3001;\n cfg.nostromo.autoRestart = document.getElementById('settingsAutoRestart').checked;\n cfg.nostromo.configCheckInterval = parseInt(document.getElementById('settingsConfigCheckInterval').value)||5;\n }\n return cfg;\n}\nasync function saveConfig(){\n const cfg = gatherConfig();\n if(!cfg) return;\n // Validate memory search prefix fields contain {content} if non-empty\n if(cfg.memory && cfg.memory.search){\n var pq = (cfg.memory.search.prefixQuery||'').trim();\n var pd = (cfg.memory.search.prefixDocument||'').trim();\n if(pq && pq.indexOf('{content}')===-1){ toast('Prefix Query must contain the {content} placeholder. Save aborted.','err'); return; }\n if(pd && pd.indexOf('{content}')===-1){ toast('Prefix Document must contain the {content} placeholder. Save aborted.','err'); return; }\n }\n // Block save if heartbeat is enabled but message is too short\n if(cfg.cron && cfg.cron.heartbeat && cfg.cron.heartbeat.enabled){\n var hbMsg = (cfg.cron.heartbeat.message||'').trim();\n if(hbMsg.length < 15){\n toast('Heartbeat message is too short (minimum 15 characters). Save aborted.','err');\n return;\n }\n }\n try{\n const res = await fetch(API+'/config',{method:'PUT',headers:{'Content-Type':'application/json','X-CSRF-Token':_csrfToken},body:JSON.stringify(cfg)});\n if(!res.ok){ const d=await res.json(); toast(d.error||'Save failed','err'); return; }\n currentConfig = await fetchAPI('/config');\n clearDirty();\n toast('Configuration saved','ok');\n updateConfigCheckPolling();\n // Re-render current section with protected values from server\n var activeSec = document.querySelector('.section.active');\n if(activeSec){\n var id = activeSec.id.replace('sec-','');\n if(id==='models') renderModelsTable();\n if(id==='vars') renderVarsTable();\n }\n }catch(e){ console.error('Save failed:',e); toast('Save failed: '+(e.message||e),'err'); }\n}\n"}
@@ -1 +1 @@
1
- import{createSdkMcpServer as t,tool as e}from"@anthropic-ai/claude-agent-sdk";import{z as n}from"zod";import{createLogger as o}from"../utils/logger.js";const r=o("ConceptTools");export function createConceptToolsServer(o){return t({name:"concept-tools",version:"1.0.0",tools:[e("concept_query","Navigate the concept graph from a node. Returns the center concept and all connected concepts/triples within the specified depth. Use this to explore relationships between people, projects, tools, and ideas.",{node:n.string().describe("Concept ID or label fragment to start from (e.g. 'lorenzo', 'dexter', 'savona')"),depth:n.number().optional().describe("How many hops to traverse (default 1, max 3)")},async t=>{try{const e=Math.min(t.depth??1,3),n=o.query(t.node,e);if(!n.center){const e=o.search(t.node,3);return 0===e.length?{content:[{type:"text",text:`No concept found matching "${t.node}". Try concept_search for fuzzy matching.`}]}:{content:[{type:"text",text:`No exact match for "${t.node}". Did you mean:\n${e.map(t=>`- ${t.id} (${t.label})`).join("\n")}`}]}}const r=[];if(r.push(`## ${n.center.label} (${n.center.id})`),r.push(`Accessed ${n.center.access_count} times | Source: ${n.center.source}`),r.push(""),n.triples.length>0){r.push("### Relations");for(const t of n.triples){const e=n.concepts.find(e=>e.id===t.subject)?.label??t.subject,o=n.concepts.find(e=>e.id===t.object)?.label??t.object;r.push(`- ${e} → ${t.predicate} → ${o}`)}}if(n.concepts.length>1){r.push(""),r.push(`### Connected concepts (${n.concepts.length-1})`);for(const t of n.concepts)t.id!==n.center.id&&r.push(`- ${t.id}: ${t.label}`)}return{content:[{type:"text",text:r.join("\n")}]}}catch(t){const e=t instanceof Error?t.message:String(t);return r.error(`concept_query error: ${e}`),{content:[{type:"text",text:`Error: ${e}`}],isError:!0}}}),e("concept_path","Find the shortest path between two concepts in the graph. Useful for discovering indirect connections (e.g. how 'dexter' relates to 'savona').",{from:n.string().describe("Starting concept ID"),to:n.string().describe("Target concept ID")},async t=>{try{const e=o.findPath(t.from,t.to);if(!e.found)return{content:[{type:"text",text:`No path found between "${t.from}" and "${t.to}" within 6 hops.`}]};const n=[];if(n.push(`### Path: ${t.from} → ${t.to} (${e.path.length-1} hops)`),n.push(""),n.push(e.path.join(" → ")),n.push(""),e.triples.length>0){n.push("### Connections");for(const t of e.triples)n.push(`- ${t.subject} → ${t.predicate} → ${t.object}`)}return{content:[{type:"text",text:n.join("\n")}]}}catch(t){const e=t instanceof Error?t.message:String(t);return r.error(`concept_path error: ${e}`),{content:[{type:"text",text:`Error: ${e}`}],isError:!0}}}),e("concept_search","Search concepts by label (fuzzy match). Returns matching concepts sorted by access frequency.",{query:n.string().describe("Search query — matches against concept labels"),limit:n.number().optional().describe("Max results (default 10)")},async t=>{try{const e=o.search(t.query,t.limit??10);if(0===e.length)return{content:[{type:"text",text:`No concepts matching "${t.query}".`}]};const n=e.map(t=>`- **${t.id}**: ${t.label} (accessed ${t.access_count}x, source: ${t.source})`);return{content:[{type:"text",text:`Found ${e.length} concepts:\n${n.join("\n")}`}]}}catch(t){const e=t instanceof Error?t.message:String(t);return r.error(`concept_search error: ${e}`),{content:[{type:"text",text:`Error: ${e}`}],isError:!0}}}),e("concept_stats","Get concept graph health statistics: total concepts, triples, orphans, never-accessed nodes, pending drafts, and top-accessed concepts.",{},async()=>{try{const t=o.stats(),e=[];if(e.push("### Concept Graph Stats"),e.push(`- Concepts: ${t.totalConcepts}`),e.push(`- Triples: ${t.totalTriples}`),e.push(`- Pending drafts: ${t.totalDraftsPending}`),e.push(`- Orphan concepts: ${t.orphanConcepts}`),e.push(`- Never accessed: ${t.neverAccessed}`),t.topAccessed.length>0){e.push(""),e.push("### Most accessed");for(const n of t.topAccessed)e.push(`- ${n.id}: ${n.label} (${n.access_count}x)`)}return{content:[{type:"text",text:e.join("\n")}]}}catch(t){const e=t instanceof Error?t.message:String(t);return r.error(`concept_stats error: ${e}`),{content:[{type:"text",text:`Error: ${e}`}],isError:!0}}}),e("concept_draft","Add an observation to the concept draft staging area. Dreaming will process it into proper concepts and triples during the nightly cycle. Use this when you notice a new relationship, person, or fact worth adding to the knowledge graph.",{text:n.string().describe("The observation — describe the concept or relationship in natural language (e.g. 'Dott. Rossi is Caterina\\'s osteopath, seen on Feb 19')"),context:n.string().optional().describe("Optional context about the conversation where this came up")},async t=>{try{return{content:[{type:"text",text:`Draft #${o.addDraft(t.text,t.context)} saved. Will be processed by dreaming.`}]}}catch(t){const e=t instanceof Error?t.message:String(t);return r.error(`concept_draft error: ${e}`),{content:[{type:"text",text:`Error: ${e}`}],isError:!0}}})]})}
1
+ import{createSdkMcpServer as e,tool as t}from"@anthropic-ai/claude-agent-sdk";import{z as n}from"zod";import{createLogger as r}from"../utils/logger.js";const o=r("ConceptTools");export function createConceptToolsServer(r){return e({name:"concept-tools",version:"1.0.0",tools:[t("concept_query","Navigate the concept graph from a node. Returns the center concept and all connected concepts/triples within the specified depth. Use this to explore relationships between people, projects, tools, and ideas. Supports temporal filtering with 'since' parameter to only see recent connections.",{node:n.string().describe("Concept ID or label fragment to start from (e.g. 'lorenzo', 'dexter', 'savona')"),depth:n.number().optional().describe("How many hops to traverse (default 1, max 3)"),since:n.string().optional().describe("Only include triples created after this ISO date (e.g. '2026-03-01'). Useful for finding recent connections."),recentFirst:n.boolean().optional().describe("Sort triples by creation date, most recent first (default false)")},async e=>{try{const t=Math.min(e.depth??1,3),n=e.since||e.recentFirst?r.queryTemporal(e.node,t,{since:e.since,recentFirst:e.recentFirst}):r.query(e.node,t);if(!n.center){const t=r.search(e.node,3);return 0===t.length?{content:[{type:"text",text:`No concept found matching "${e.node}". Try concept_search for fuzzy matching.`}]}:{content:[{type:"text",text:`No exact match for "${e.node}". Did you mean:\n${t.map(e=>`- ${e.id} (${e.label})`).join("\n")}`}]}}const o=[];if(o.push(`## ${n.center.label} (${n.center.id})`),o.push(`Accessed ${n.center.access_count} times | Source: ${n.center.source}`),o.push(""),n.triples.length>0){o.push("### Relations");for(const e of n.triples){const t=n.concepts.find(t=>t.id===e.subject)?.label??e.subject,r=n.concepts.find(t=>t.id===e.object)?.label??e.object;o.push(`- ${t} → ${e.predicate} → ${r}`)}}if(n.concepts.length>1){o.push(""),o.push(`### Connected concepts (${n.concepts.length-1})`);for(const e of n.concepts)e.id!==n.center.id&&o.push(`- ${e.id}: ${e.label}`)}return{content:[{type:"text",text:o.join("\n")}]}}catch(e){const t=e instanceof Error?e.message:String(e);return o.error(`concept_query error: ${t}`),{content:[{type:"text",text:`Error: ${t}`}],isError:!0}}}),t("concept_path","Find the shortest path between two concepts in the graph. Useful for discovering indirect connections (e.g. how 'dexter' relates to 'savona').",{from:n.string().describe("Starting concept ID"),to:n.string().describe("Target concept ID")},async e=>{try{const t=r.findPath(e.from,e.to);if(!t.found)return{content:[{type:"text",text:`No path found between "${e.from}" and "${e.to}" within 6 hops.`}]};const n=[];if(n.push(`### Path: ${e.from} → ${e.to} (${t.path.length-1} hops)`),n.push(""),n.push(t.path.join(" → ")),n.push(""),t.triples.length>0){n.push("### Connections");for(const e of t.triples)n.push(`- ${e.subject} → ${e.predicate} → ${e.object}`)}return{content:[{type:"text",text:n.join("\n")}]}}catch(e){const t=e instanceof Error?e.message:String(e);return o.error(`concept_path error: ${t}`),{content:[{type:"text",text:`Error: ${t}`}],isError:!0}}}),t("concept_search","Search concepts by label (fuzzy match). Returns matching concepts sorted by access frequency.",{query:n.string().describe("Search query — matches against concept labels"),limit:n.number().optional().describe("Max results (default 10)")},async e=>{try{const t=r.search(e.query,e.limit??10);if(0===t.length)return{content:[{type:"text",text:`No concepts matching "${e.query}".`}]};const n=t.map(e=>`- **${e.id}**: ${e.label} (accessed ${e.access_count}x, source: ${e.source})`);return{content:[{type:"text",text:`Found ${t.length} concepts:\n${n.join("\n")}`}]}}catch(e){const t=e instanceof Error?e.message:String(e);return o.error(`concept_search error: ${t}`),{content:[{type:"text",text:`Error: ${t}`}],isError:!0}}}),t("concept_stats","Get concept graph health statistics: total concepts, triples, orphans, never-accessed nodes, pending drafts, and top-accessed concepts.",{},async()=>{try{const e=r.stats(),t=[];if(t.push("### Concept Graph Stats"),t.push(`- Concepts: ${e.totalConcepts}`),t.push(`- Triples: ${e.totalTriples}`),t.push(`- Pending drafts: ${e.totalDraftsPending}`),t.push(`- Orphan concepts: ${e.orphanConcepts}`),t.push(`- Never accessed: ${e.neverAccessed}`),e.topAccessed.length>0){t.push(""),t.push("### Most accessed");for(const n of e.topAccessed)t.push(`- ${n.id}: ${n.label} (${n.access_count}x)`)}return{content:[{type:"text",text:t.join("\n")}]}}catch(e){const t=e instanceof Error?e.message:String(e);return o.error(`concept_stats error: ${t}`),{content:[{type:"text",text:`Error: ${t}`}],isError:!0}}}),t("concept_draft","Add an observation to the concept draft staging area. Dreaming will process it into proper concepts and triples during the nightly cycle. Use this when you notice a new relationship, person, or fact worth adding to the knowledge graph.",{text:n.string().describe("The observation — describe the concept or relationship in natural language (e.g. 'Dott. Rossi is Caterina\\'s osteopath, seen on Feb 19')"),context:n.string().optional().describe("Optional context about the conversation where this came up")},async e=>{try{return{content:[{type:"text",text:`Draft #${r.addDraft(e.text,e.context)} saved. Will be processed by dreaming.`}]}}catch(e){const t=e instanceof Error?e.message:String(e);return o.error(`concept_draft error: ${t}`),{content:[{type:"text",text:`Error: ${t}`}],isError:!0}}})]})}
@@ -1 +1 @@
1
- import{createSdkMcpServer as e,tool as t}from"@anthropic-ai/claude-agent-sdk";import{z as r}from"zod";import{createLogger as n}from"../utils/logger.js";const o=n("MemoryTools");export function createMemoryToolsServer(n){return e({name:"memory-tools",version:"1.0.0",tools:[t("memory_search","Search conversation memory using hybrid keyword + semantic search. Returns matching chunks from past conversations with relevance scores. Results include chunkId — use memory_expand to get full chunk content when snippets are insufficient. Formulate clear, specific queries. For broad searches, call multiple times with different angles or phrasings.",{query:r.string().describe("The search query — be specific and descriptive for best results"),maxResults:r.number().optional().describe("Maximum number of results to return (default 6)")},async e=>{try{const t=await n.search(e.query,e.maxResults);if(0===t.length)return{content:[{type:"text",text:"No matching memories found."}]};const r=n.getMaxInjectedChars();let s=JSON.stringify(t,null,2);if(r>0&&s.length>r){const e=[...t];for(;e.length>1&&(e.pop(),s=JSON.stringify(e,null,2),!(s.length<=r)););s.length>r&&(s=s.slice(0,r)+"\n... [truncated — result exceeded maxInjectedChars limit]"),o.info(`memory_search: trimmed from ${t.length} to ${e.length} results to fit maxInjectedChars=${r}`)}return{content:[{type:"text",text:s}]}}catch(e){const t=e instanceof Error?e.message:String(e);return o.error(`memory_search error: ${t}`),{content:[{type:"text",text:`Search error: ${t}`}],isError:!0}}}),t("memory_expand","Get the full content of one or more memory chunks by their IDs (from memory_search results). Use this when a search snippet or L0 abstract is too short and you need the complete text.",{ids:r.array(r.number()).describe("Array of chunk IDs from memory_search results (chunkId field)")},async e=>{try{const t=n.expandChunks(e.ids);return 0===t.length?{content:[{type:"text",text:"No chunks found for the given IDs."}]}:{content:[{type:"text",text:JSON.stringify(t,null,2)}]}}catch(e){const t=e instanceof Error?e.message:String(e);return o.error(`memory_expand error: ${t}`),{content:[{type:"text",text:`Expand error: ${t}`}],isError:!0}}}),t("memory_get","Read a memory file (full or a specific slice) to get surrounding context after a search hit. Use the path from memory_search results.",{path:r.string().describe("Relative path to the memory file (from memory_search results)"),from:r.number().optional().describe("Starting line number (1-based). Omit to read from the beginning."),lines:r.number().optional().describe("Number of lines to read. Omit to read the entire file.")},async e=>{try{const t=n.readFile(e.path,e.from,e.lines);return{content:[{type:"text",text:JSON.stringify(t,null,2)}]}}catch(e){const t=e instanceof Error?e.message:String(e);return o.error(`memory_get error: ${t}`),{content:[{type:"text",text:`Read error: ${t}`}],isError:!0}}})]})}
1
+ import{createSdkMcpServer as e,tool as r}from"@anthropic-ai/claude-agent-sdk";import{z as t}from"zod";import{createLogger as n}from"../utils/logger.js";const o=n("MemoryTools");export function createMemoryToolsServer(n){return e({name:"memory-tools",version:"1.0.0",tools:[r("memory_search","Search conversation memory using hybrid keyword + semantic search. Returns matching chunks from past conversations with relevance scores. Results include chunkId — use memory_expand to get full chunk content when snippets are insufficient. Formulate clear, specific queries. For broad searches, call multiple times with different angles or phrasings.",{query:t.string().describe("The search query — be specific and descriptive for best results"),maxResults:t.number().optional().describe("Maximum number of results to return (default 6)")},async e=>{try{const r=await n.search(e.query,e.maxResults);if(0===r.length)return{content:[{type:"text",text:"No matching memories found."}]};const t=n.getMaxInjectedChars();let s=JSON.stringify(r,null,2);if(t>0&&s.length>t){const e=[...r];for(;e.length>1&&(e.pop(),s=JSON.stringify(e,null,2),!(s.length<=t)););s.length>t&&(s=s.slice(0,t)+"\n... [truncated — result exceeded maxInjectedChars limit]"),o.info(`memory_search: trimmed from ${r.length} to ${e.length} results to fit maxInjectedChars=${t}`)}return{content:[{type:"text",text:s}]}}catch(e){const r=e instanceof Error?e.message:String(e);return o.error(`memory_search error: ${r}`),{content:[{type:"text",text:`Search error: ${r}`}],isError:!0}}}),r("memory_expand","Get the full content of one or more memory chunks by their IDs (from memory_search results). Use this when a search snippet or L0 abstract is too short and you need the complete text.",{ids:t.array(t.number()).describe("Array of chunk IDs from memory_search results (chunkId field)")},async e=>{try{const r=n.expandChunks(e.ids);return 0===r.length?{content:[{type:"text",text:"No chunks found for the given IDs."}]}:{content:[{type:"text",text:JSON.stringify(r,null,2)}]}}catch(e){const r=e instanceof Error?e.message:String(e);return o.error(`memory_expand error: ${r}`),{content:[{type:"text",text:`Expand error: ${r}`}],isError:!0}}}),r("memory_get","Read a memory file (full or a specific slice) to get surrounding context after a search hit. Use the path from memory_search results.",{path:t.string().describe("Relative path to the memory file (from memory_search results)"),from:t.number().optional().describe("Starting line number (1-based). Omit to read from the beginning."),lines:t.number().optional().describe("Number of lines to read. Omit to read the entire file.")},async e=>{try{const r=n.readFile(e.path,e.from,e.lines);return{content:[{type:"text",text:JSON.stringify(r,null,2)}]}}catch(e){const r=e instanceof Error?e.message:String(e);return o.error(`memory_get error: ${r}`),{content:[{type:"text",text:`Read error: ${r}`}],isError:!0}}}),r("memory_record_access","Record that memory chunks were accessed/used in a response. This improves future retrieval by boosting frequently-used chunks. Automatically called by memory_expand, but can also be called manually for chunks from memory_search that were used without expanding.",{ids:t.array(t.number()).describe("Array of chunk IDs that were used")},async e=>{try{return n.recordAccess(e.ids),{content:[{type:"text",text:`Recorded access for ${e.ids.length} chunks.`}]}}catch(e){const r=e instanceof Error?e.message:String(e);return o.error(`memory_record_access error: ${r}`),{content:[{type:"text",text:`Error: ${r}`}],isError:!0}}})]})}
@@ -32,7 +32,7 @@ The session key identifies this conversation within the gateway. The memory file
32
32
 
33
33
  ## Current Date & Time
34
34
 
35
- Time zone: {{TIMEZONE}}
35
+ {{CURRENT_DATE}} — Time zone: {{TIMEZONE}}
36
36
 
37
37
  ## Available tools
38
38
 
@@ -20,7 +20,7 @@ Complete the task above and return the result. Do not engage in conversation bey
20
20
 
21
21
  ## Current Date & Time
22
22
 
23
- Time zone: {{TIMEZONE}}
23
+ {{CURRENT_DATE}} — Time zone: {{TIMEZONE}}
24
24
 
25
25
  ## Available tools
26
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hera-al/server",
3
- "version": "1.6.29",
3
+ "version": "1.6.31",
4
4
  "private": false,
5
5
  "description": "Hera Artificial Life — Multi-channel AI agent gateway with autonomous capabilities",
6
6
  "license": "MIT",