@hera-al/server 1.6.44 → 1.6.45
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{query as e}from"@anthropic-ai/claude-agent-sdk";import{MessageQueue as s}from"./message-queue.js";import{resolveModelId as t,resolveModelEntry as i}from"../config.js";import{createLogger as o}from"../utils/logger.js";import{ToolLoopDetector as n}from"./tool-loop-detector.js";import{loadToolRules as r}from"../tools/operational-context-tools.js";const l=o("SessionAgent");export class SessionAgent{sessionKey;config;queue;queryHandle=null;pendingResponses=[];currentResponse="";currentSessionId;model;primaryModel;fallbackActive=!1;queueMode;closed=!1;piProviderConfig=null;outputDone=!1;initialized=!1;opts;collectBuffer=[];lastCollectAt=0;debounceMs;debounceTimer=null;debounceResolve=null;queueCap;dropPolicy;droppedResolvers=[];droppedSummaries=[];sdkSlashCommands=[];channelSender=null;toolUseNotifier=null;typingSetter=null;typingClearer=null;textBlockStreamer=null;pendingTextBlock="";streamedAny=!1;streamedText="";usageRecorder=null;autoApproveTools;loopDetector;skillNudgeEnabled;skillNudgeThreshold;qualityGateEnabled;toolCallsCurrentTurn=0;toolCallsLastTurn=0;messageSentViaTool=!1;pendingPermission=null;pendingQuestion=null;constructor(e,i,o,a,h,u,c,d,p,g,f,m,y,$,b,v,T,w,S,k,K,R,_,x,P){this.sessionKey=e,this.config=i,this.currentSessionId=h??"";const C=u??i.agent.model;this.model=C?t(i,C):"",this.primaryModel=this.model,this.queueMode=i.agent.queueMode,this.debounceMs=Math.max(0,i.agent.queueDebounceMs),this.queueCap=Math.max(0,i.agent.queueCap),this.dropPolicy=i.agent.queueDropPolicy,this.autoApproveTools=i.agent.autoApproveTools,this.loopDetector=new n(e),this.skillNudgeEnabled=i.agent.skillNudge?.enabled??!0,this.skillNudgeThreshold=i.agent.skillNudge?.threshold??10,this.qualityGateEnabled=i.agent.qualityGate?.enabled??!1,this.queue=new s,this.opts={...this.model?{model:this.model}:{},systemPrompt:f?{type:"preset",preset:"claude_code",append:o}:o,...i.agent.maxTurns>0?{maxTurns:i.agent.maxTurns}:{},cwd:i.agent.workspacePath,env:process.env,permissionMode:i.agent.permissionMode,allowDangerouslySkipPermissions:!1,...T?{sandbox:{enabled:!0,autoAllowBashIfSandboxed:!0,network:{allowLocalBinding:!0}}}:{},canUseTool:async(e,s)=>this.handleCanUseTool(e,s),hooks:{PreToolUse:[{hooks:[async e=>{try{const s=e?.tool_name;if(!s)return{};const t=r(i.dataDir);let o=t[s];if(!o){const e=s.includes("__")?s.split("__").pop():void 0;e&&(o=t[e])}return o&&0!==o.length?(l.info(`[${this.sessionKey}] CKR L1: injecting ${o.length} rules for tool ${s}`),{additionalContext:`[CKR Tool Rules for ${s}]\n${o.join("\n")}`}):{}}catch(e){return l.warn(`[${this.sessionKey}] CKR L1 error: ${e}`),{}}}]}],SubagentStart:[{hooks:[async e=>{const s=e?.agent_type??"unknown",t=e?.agent_id??"?";return l.info(`[${this.sessionKey}] Subagent START: ${s} (id=${t})`),this._subagentStartTimes??={},this._subagentStartTimes[t]=Date.now(),{}}]}],SubagentStop:[{hooks:[async e=>{const s=e?.agent_type??"unknown",t=e?.agent_id??"?",i=this._subagentStartTimes??{},o=i[t],n=o?Date.now()-o:-1,r=e?.last_assistant_message,a=r?r.substring(0,200):"(no output)";return l.info(`[${this.sessionKey}] Subagent STOP: ${s} (id=${t}) duration=${n}ms output=${a}`),o&&delete i[t],{}}]}],PreCompact:[{hooks:[async e=>{const s=e?.trigger??"auto";if(l.info(`[${this.sessionKey}] PreCompact hook fired (trigger=${s})`),this.channelSender){const e=this.sessionKey.indexOf(":");if(e>0){const t=this.sessionKey.substring(0,e),i=this.sessionKey.substring(e+1);if("cron"!==t){const e="auto"===s?"The conversation context is getting large — compacting memory to keep things running smoothly.":"Compacting conversation memory...";this.channelSender(t,i,e).catch(()=>{})}}}return{}}]}]},stderr:s=>{l.error(`[${e}] SDK stderr: ${s.trimEnd()}`)},...void 0!==i.agent.compaction?{compactionControl:i.agent.compaction}:{}};const A=i.agent.settingSources;"user"===A?this.opts.settingSources=["user"]:"project"===A?this.opts.settingSources=["project"]:"both"===A&&(this.opts.settingSources=["user","project"]);const I=i.agent.mainFallback;I&&(this.opts.fallbackModel=t(i,I)),i.agent.allowedTools.length>0&&(this.opts.allowedTools=i.agent.allowedTools),i.agent.disallowedTools.length>0&&(this.opts.disallowedTools=i.agent.disallowedTools);const q={};if(Object.keys(i.agent.mcpServers).length>0&&Object.assign(q,i.agent.mcpServers),c&&(q["node-tools"]=c),d&&(q["message-tools"]=d),p&&(q["server-tools"]=p),g&&(q["cron-tools"]=g),$&&(q["tts-tools"]=$),b&&(q["memory-tools"]=b),v&&(q["browser-tools"]=v),w&&(q["pico-tools"]=w),S&&(q["telegram-actions"]=S),k&&(q["a2ui-tools"]=k),K&&(q["dynamic-ui-tools"]=K),R&&(q["plasma-client-tools"]=R),_&&(q["concept-tools"]=_),x&&(q["state-tools"]=x),P&&(q["operational-context-tools"]=P),Object.keys(q).length>0&&(this.opts.mcpServers=q,this.opts.allowedTools&&this.opts.allowedTools.length>0))for(const e of Object.keys(q)){const s=`mcp__${e}__*`;this.opts.allowedTools.includes(s)||this.opts.allowedTools.push(s)}if(h&&(this.opts.resume=h),!1===m&&(this.opts.allowedTools&&this.opts.allowedTools.length>0?this.opts.allowedTools=this.opts.allowedTools.filter(e=>"Task"!==e):(this.opts.disallowedTools||(this.opts.disallowedTools=[]),this.opts.disallowedTools.includes("Task")||this.opts.disallowedTools.push("Task"))),y){const e={};for(const s of i.agent.customSubAgents){if(!s.enabled)continue;const t=s.expandContext?a+"\n\n"+s.prompt:s.prompt;e[s.name]={description:s.description,prompt:t,tools:s.tools,..."inherit"!==s.model?{model:s.model}:{}}}Object.keys(e).length>0&&(this.opts.agents=e)}const j=i.agent.plugins.filter(e=>e.enabled);j.length>0&&(this.opts.options={...this.opts.options,plugins:j.map(e=>({type:"local",path:e.path}))});const D=this.buildEnvForModel(this.model);this.opts.env=D.env,D.disableThinking&&(this.opts.maxThinkingTokens=0),this.piProviderConfig=this.resolvePiConfig(),this.piProviderConfig&&l.info(`[${e}] Pi engine: ${this.piProviderConfig.provider}/${this.piProviderConfig.modelId}`)}resolvePiConfig(){const e=this.model,s=this.config.models?.find(s=>s.id===e),t=s?.name??"";let i;const o=this.config.agent.picoAgent;if(o?.enabled&&Array.isArray(o.modelRefs)&&(i=o.modelRefs.find(e=>e.split(":")[0]===t)),!i){const e=this.config.agent.engine;if(!e||"pi"!==e.type||!e.piModelRef)return null;i=e.piModelRef}const n=i.split(":");if(n.length<2)return l.warn(`[${this.currentSessionId}] Invalid piModelRef (missing ':'): ${i}`),null;const r=n[0].trim();let a,h;if(n.length>=3)a=n[1].trim(),h=n.slice(2).join(":").trim(),h.startsWith(a+":")&&(h=h.substring(a.length+1));else{const e=n[1].trim(),s=e.indexOf("/");s>0?(a=e.substring(0,s),h=e.substring(s+1)):(a="openrouter",h=e)}const u=this.config.models?.find(e=>e.name===r);let c,d;u?.baseURL&&u.baseURL.includes("openrouter.ai")&&"openrouter"!==a&&(l.info(`[${this.currentSessionId}] piModelRef auto-correction: baseURL is openrouter.ai, switching provider from "${a}" to "openrouter" (modelId: "${a}/${h}")`),h=`${a}/${h}`,a="openrouter"),l.info(`[${this.currentSessionId}] piModelRef resolved: provider="${a}", modelId="${h}", contextWindow=${u?.contextWindow??128e3}`);const p=o?.rollingMemoryModel;if(p){const e=p.split(":");if(e.length>=3)c=e[1].trim(),d=e.slice(2).join(":").trim();else if(2===e.length){const s=e[1].indexOf("/");s>0?(c=e[1].substring(0,s).trim(),d=e[1].substring(s+1).trim()):d=e[1].trim()}d&&l.info(`[${this.currentSessionId}] Summarization model resolved: ${c}/${d}`)}return{provider:a,modelId:h,apiKey:u?.apiKey||void 0,baseUrl:u?.baseURL||void 0,contextWindowTokens:u?.contextWindow||void 0,costInput:u?.costInput||void 0,costOutput:u?.costOutput||void 0,costCacheRead:u?.costCacheRead||void 0,costCacheWrite:u?.costCacheWrite||void 0,summarizationProvider:c,summarizationModelId:d}}static API_RETRY_RE=/API Error:\s*(?:500|502|503|529)|overloaded|internal server error/i;async send(e,s){const t=s??0;if(this.closed||this.outputDone)throw new Error("SessionAgent is closed");if(this.skillNudgeEnabled&&this.toolCallsLastTurn>=this.skillNudgeThreshold&&0===t){const s=`[System note: Your previous turn used ${this.toolCallsLastTurn} tool calls. If you discovered a reusable workflow or improved on an existing skill's approach, consider updating or creating a skill (Edit the SKILL.md file directly). Skip this if the task was inherently complex and doesn't generalize.]`;e={...e,text:s+"\n\n"+e.text},this.toolCallsLastTurn=0,l.info(`[${this.sessionKey}] Skill nudge injected (previous turn had ${this.skillNudgeThreshold}+ tool calls)`)}let o;switch(this.loopDetector.reset(),this.ensureInitialized(),this.queueMode){case"collect":o=await this.sendCollect(e);break;case"steer":o=await this.sendSteer(e);break;default:o=await this.sendDirect(e)}const n=o.fullResponse??o.response,r=SessionAgent.API_RETRY_RE.test(n),a=this.config.agent.apiRetry,h=a.maxAttempts;if(r&&t<h){const s=t+1,i=Math.min(a.baseDelayMs*2**t,a.maxDelayMs);if(l.warn(`[${this.sessionKey}] Transient API error detected, retry ${s}/${h} in ${i}ms`),this.channelSender){const e=this.sessionKey.indexOf(":");if(e>0){const t=this.sessionKey.substring(0,e),i=this.sessionKey.substring(e+1);if("cron"!==t){const e=`API temporarily unavailable, retrying (attempt ${s}/${h})...`;this.channelSender(t,i,e).catch(()=>{})}}}return await new Promise(e=>setTimeout(e,i)),this.send(e,s)}if(r&&t>=h){l.error(`[${this.sessionKey}] API error persists after ${h} retries`);const s=this.opts.fallbackModel;if(s&&!this.fallbackActive&&this.model!==s){const t=i(this.config,s);if(t?.types?.includes("internal")??!1){if(l.warn(`[${this.sessionKey}] Switching to fallback model: ${s}`),await this.setModel(s),this.fallbackActive=!0,this.channelSender){const e=this.sessionKey.indexOf(":");if(e>0){const i=this.sessionKey.substring(0,e),o=this.sessionKey.substring(e+1);if("cron"!==i){const e=`⚠️ Primary model unavailable after ${h} retries. Switching to fallback (${t?.name??s}). Use /model or /new to restore.`;this.channelSender(i,o,e).catch(()=>{})}}}return this.send(e,0)}}if(this.channelSender){const e=this.sessionKey.indexOf(":");if(e>0){const s=this.sessionKey.substring(0,e),t=this.sessionKey.substring(e+1);if("cron"!==s){const e=`API did not respond after ${h} attempts. Please try again later.`;this.channelSender(s,t,e).catch(()=>{})}}}}const u=!0===e.__qualityGateRetry;if(!this.piProviderConfig&&this.qualityGateEnabled&&!u){const e=o.fullResponse??o.response;if(e&&e.length>50)try{const{runQualityGate:s}=await import("./quality-gate.js"),t=await s(e);if(!t.pass){l.warn(`[${this.sessionKey}] QualityGate FAILED (${t.durationMs}ms): ${JSON.stringify(t.flags)}`);const e={text:`[Quality Gate FAILED] Your previous response contains unverified claims:\n${t.flags.map(e=>`- ${e}`).join("\n")}\n\nReconsider your response. Verify the flagged claims (use tools if needed) and respond again with a corrected version. Do NOT mention the quality gate to the user.`,images:[],__qualityGateRetry:!0};l.info(`[${this.sessionKey}] QualityGate: re-prompting agent for self-correction`),o=await this.send(e,0)}}catch(e){l.warn(`[${this.sessionKey}] QualityGate error (fail-open): ${e}`)}}return o}async interrupt(){if(this.closed||!this.queryHandle)return!1;try{return await this.queryHandle.interrupt(),l.info(`[${this.sessionKey}] Interrupted`),!0}catch{return!1}}async setModel(e){if(this.queryHandle)try{await this.queryHandle.setModel(e),this.model=e,this.fallbackActive=!1,l.info(`[${this.sessionKey}] Model changed to ${e}`)}catch(e){l.error(`[${this.sessionKey}] Failed to set model: ${e}`)}}close(){if(this.closed)return;if(this.closed=!0,this.debounceTimer&&(clearTimeout(this.debounceTimer),this.debounceTimer=null),this.debounceResolve&&(this.debounceResolve(),this.debounceResolve=null),this.queue.close(),this.queryHandle){const e=this.queryHandle;this.queryHandle=null,queueMicrotask(()=>{try{e.close()}catch{}})}const e=new Error("SessionAgent closed");for(const s of this.pendingResponses)s.reject(e);for(const s of this.collectBuffer)s.reject(e);for(const s of this.droppedResolvers)s.reject(e);this.pendingResponses=[],this.collectBuffer=[],this.droppedResolvers=[],this.droppedSummaries=[],l.info(`[${this.sessionKey}] Closed`)}isActive(){return!this.closed&&!this.outputDone}getSessionId(){return this.currentSessionId}getModel(){return this.model}isFallbackActive(){return this.fallbackActive}getSdkSlashCommands(){return this.sdkSlashCommands}setChannelSender(e){this.channelSender=e}setToolUseNotifier(e){this.toolUseNotifier=e}setTypingSetter(e){this.typingSetter=e}setTypingClearer(e){this.typingClearer=e}setTextBlockStreamer(e){this.textBlockStreamer=e}setUsageRecorder(e){this.usageRecorder=e}buildEnvForModel(e){const s=this.config.models.find(s=>s.id===e);if(!s?.proxy||"not-used"===s.proxy)return{env:{...process.env},proxied:!1,disableThinking:!1};const t={...process.env};return"direct"===s.proxy?(t.ANTHROPIC_BASE_URL=s.baseURL,t.ANTHROPIC_AUTH_TOKEN=s.apiKey,t.ANTHROPIC_API_KEY="",l.info(`[${this.sessionKey}] Direct env applied for model ${e} (url=${t.ANTHROPIC_BASE_URL})`),{env:t,proxied:!0,disableThinking:!1}):(t.ANTHROPIC_BASE_URL=s.fastUrl||this.config.fastProxyUrl,t.ANTHROPIC_AUTH_TOKEN=s.fastProxyApiKey,t.ANTHROPIC_API_KEY="",delete t.ANTHROPIC_BETAS,delete t.CLAUDE_CODE_EXTRA_BODY,l.info(`[${this.sessionKey}] Proxy env applied for model ${e} (url=${t.ANTHROPIC_BASE_URL})`),{env:t,proxied:!0,disableThinking:!0})}hasPendingPermission(){return null!==this.pendingPermission}resolvePermission(e){if(!this.pendingPermission)return;const s=this.pendingPermission;this.pendingPermission=null,e?(l.info(`[${this.sessionKey}] Permission approved: ${s.toolName}`),s.resolve({behavior:"allow",updatedInput:s.input})):(l.info(`[${this.sessionKey}] Permission denied: ${s.toolName}`),s.resolve({behavior:"deny",message:"User denied this action"}))}isBusy(){return this.pendingResponses.length>0}hasPendingQuestion(){return null!==this.pendingQuestion}resolveQuestion(e){if(!this.pendingQuestion)return;const s=this.pendingQuestion;this.pendingQuestion=null,l.info(`[${this.sessionKey}] Question answered: "${e}" for "${s.questionText}"`),s.resolve(e)}async handleCanUseTool(e,s){if("AskUserQuestion"===e){if(!this.channelSender)return l.warn(`[${this.sessionKey}] No channel sender for AskUserQuestion, auto-approving`),{behavior:"allow",updatedInput:s};const e=this.sessionKey.indexOf(":");if(e<0)return{behavior:"allow",updatedInput:s};const t=this.sessionKey.substring(0,e),i=this.sessionKey.substring(e+1);if(!t||!i||"cron"===t)return{behavior:"allow",updatedInput:s};const o=s?.questions;if(!Array.isArray(o)||0===o.length)return{behavior:"allow",updatedInput:s};const n={};for(const e of o){const o=e.question||"?",r=Array.isArray(e.options)?e.options:[],a=[];if(e.header&&a.push(`*${e.header}*`),a.push(o),r.some(e=>e.description)){a.push("");for(const e of r){const s=e.description?`: ${e.description}`:"";a.push(`• ${e.label}${s}`)}}const h=a.join("\n");if(this.typingClearer)try{await this.typingClearer(t,i)}catch{}try{if(r.length>0){const e=r.map(e=>({text:e.label||String(e),callbackData:`__ask:${e.label||String(e)}`}));await this.channelSender(t,i,h,e)}else await this.channelSender(t,i,h)}catch(e){return l.error(`[${this.sessionKey}] Failed to send AskUserQuestion: ${e}`),{behavior:"allow",updatedInput:s}}const u=55e3,c=await new Promise(e=>{const s=setTimeout(()=>{if(this.pendingQuestion){this.pendingQuestion=null;const s=r.length>0?r[0].label||String(r[0]):"No answer";l.warn(`[${this.sessionKey}] Question timeout, defaulting to "${s}"`),this.channelSender&&this.channelSender(t,i,`[Timeout] Auto-selected: ${s}`).catch(()=>{}),e(s)}},u);this.pendingQuestion={resolve:t=>{clearTimeout(s),e(t)},questionText:o}});if(n[o]=c,this.typingSetter)try{await this.typingSetter(t,i)}catch{}}return l.info(`[${this.sessionKey}] AskUserQuestion answered: ${JSON.stringify(n)}`),{behavior:"allow",updatedInput:{questions:s.questions,answers:n}}}const t=this.loopDetector.check(e,s);if("circuit_break"===t.severity)return l.error(`[${this.sessionKey}] CIRCUIT BREAK: ${t.reason}`),{behavior:"deny",message:`[Loop detected] ${t.reason} Try a different approach or ask the user for help.`};if(this.autoApproveTools)return l.debug(`[${this.sessionKey}] Auto-approving tool: ${e}`),{behavior:"allow",updatedInput:s};if(!this.channelSender)return l.warn(`[${this.sessionKey}] No channel sender for interactive permission, auto-approving: ${e}`),{behavior:"allow",updatedInput:s};const i=this.sessionKey.indexOf(":");if(i<0)return{behavior:"allow",updatedInput:s};const o=this.sessionKey.substring(0,i),n=this.sessionKey.substring(i+1);if(!o||!n||"cron"===o)return{behavior:"allow",updatedInput:s};const r=[`[Permission Request] Tool: ${e}`];if("Bash"===e&&s?.command)r.push(`Command: ${s.command}`),s.description&&r.push(`Description: ${s.description}`);else if("Write"===e&&s?.file_path)r.push(`File: ${s.file_path}`);else if("Edit"===e&&s?.file_path)r.push(`File: ${s.file_path}`);else if("ExitPlanMode"===e&&s?.plan){if(r.push(""),r.push(s.plan),Array.isArray(s.allowedPrompts)&&s.allowedPrompts.length>0){r.push(""),r.push("Requested permissions:");for(const e of s.allowedPrompts)r.push(` - [${e.tool}] ${e.prompt}`)}}else{const e=JSON.stringify(s);e.length<=300?r.push(`Input: ${e}`):r.push(`Input: ${e.slice(0,297)}...`)}r.push(""),r.push("Reply: approve to allow, deny to reject");const a=r.join("\n"),h=[{text:"Approve",callbackData:"__tool_perm:approve"},{text:"Deny",callbackData:"__tool_perm:deny"}];try{await this.channelSender(o,n,a,h)}catch(e){return l.error(`[${this.sessionKey}] Failed to send permission request: ${e}`),{behavior:"allow",updatedInput:s}}if(this.typingClearer)try{await this.typingClearer(o,n)}catch{}const u=12e4;return new Promise(t=>{const i=setTimeout(()=>{this.pendingPermission?.resolve===t&&(this.pendingPermission=null,l.warn(`[${this.sessionKey}] Permission timeout for ${e}, auto-denying`),this.channelSender&&this.channelSender(o,n,`[Permission timeout] Tool ${e} denied after 120s`).catch(()=>{}),t({behavior:"deny",message:"Permission request timed out"}))},u);this.pendingPermission={resolve:t,toolName:e,input:s};const r=t;this.pendingPermission.resolve=e=>{clearTimeout(i),r(e)}})}async forwardAskUserQuestion(e){if(!this.channelSender)return;const s=this.sessionKey.indexOf(":");if(s<0)return;const t=this.sessionKey.substring(0,s),i=this.sessionKey.substring(s+1);if(!t||!i||"cron"===t)return;const o=e?.questions;if(Array.isArray(o)){for(const e of o){const s=e.question||"?",o=Array.isArray(e.options)?e.options:[],n=[];if(e.header&&n.push(`*${e.header}*`),n.push(s),o.some(e=>e.description)){n.push("");for(const e of o){const s=e.description?`: ${e.description}`:"";n.push(`• ${e.label}${s}`)}}const r=n.join("\n");try{if(o.length>0){const e=o.map(e=>({text:e.label||String(e),callbackData:e.label||String(e)}));await this.channelSender(t,i,r,e)}else await this.channelSender(t,i,r)}catch(e){l.error(`[${this.sessionKey}] Failed to forward AskUserQuestion: ${e}`)}}if(this.typingClearer)try{await this.typingClearer(t,i)}catch(e){l.error(`[${this.sessionKey}] Failed to clear typing: ${e}`)}}}async notifyToolUse(e){if(!this.toolUseNotifier)return;const s=this.sessionKey.indexOf(":");if(s<0)return;const t=this.sessionKey.substring(0,s),i=this.sessionKey.substring(s+1);if(t&&i&&"cron"!==t)try{await this.toolUseNotifier(t,i,e)}catch(e){l.error(`[${this.sessionKey}] Failed to notify tool use: ${e}`)}}async flushPendingTextBlock(){if(!this.textBlockStreamer||!this.pendingTextBlock)return;const e=this.sessionKey.indexOf(":");if(e<0)return;const s=this.sessionKey.substring(0,e),t=this.sessionKey.substring(e+1);if(!s||!t||"cron"===s)return;const i=this.pendingTextBlock;this.pendingTextBlock="",this.streamedAny=!0,this.streamedText+=i;try{await this.textBlockStreamer(s,t,i)}catch(e){l.error(`[${this.sessionKey}] Text block stream error: ${e}`)}}sendDirect(e){if(this.queueCap>0&&this.pendingResponses.length>=this.queueCap)return l.warn(`[${this.sessionKey}] Queue cap reached (${this.queueCap}), rejecting message`),Promise.resolve({response:"Queue is full. Please wait for the current processing to complete.",sessionId:this.currentSessionId,sessionReset:!1});const s=this.buildQueueMessage(e);return new Promise((e,t)=>{this.pendingResponses.push({resolve:e,reject:t}),this.queue.push(s),l.info(`[${this.sessionKey}] Message queued (pending=${this.pendingResponses.length})`)})}sendCollect(e){return this.pendingResponses.length>0?this.queueCap>0&&this.collectBuffer.length>=this.queueCap?this.applyDropPolicy(e):(this.lastCollectAt=Date.now(),l.info(`[${this.sessionKey}] Collecting message (buffer=${this.collectBuffer.length+1})`),new Promise((s,t)=>{this.collectBuffer.push({prompt:e,resolve:s,reject:t})})):this.sendDirect(e)}async sendSteer(e){return this.pendingResponses.length>0&&(l.info(`[${this.sessionKey}] Steer: interrupting current processing`),await this.interrupt()),this.sendDirect(e)}applyDropPolicy(e){if("new"===this.dropPolicy)return l.warn(`[${this.sessionKey}] Queue cap reached, rejecting new message`),Promise.resolve({response:"Queue is full. Please wait for the current processing to complete.",sessionId:this.currentSessionId,sessionReset:!1});const s=this.collectBuffer.shift();return"summarize"===this.dropPolicy&&this.droppedSummaries.push(function(e,s){const t=e.replace(/\s+/g," ").trim();return t.length<=s?t:`${t.slice(0,s-1).trimEnd()}…`}(s.prompt.text,140)),this.droppedResolvers.push({resolve:s.resolve,reject:s.reject}),l.warn(`[${this.sessionKey}] Queue cap reached, dropped oldest message (policy=${this.dropPolicy}, dropped=${this.droppedResolvers.length})`),this.lastCollectAt=Date.now(),new Promise((s,t)=>{this.collectBuffer.push({prompt:e,resolve:s,reject:t})})}async debounceThenFlush(){if(this.debounceMs<=0||this.closed)this.flushCollectBuffer();else{for(;!this.closed&&this.collectBuffer.length>0;){const e=Date.now()-this.lastCollectAt;if(e>=this.debounceMs)break;await new Promise(s=>{this.debounceResolve=s,this.debounceTimer=setTimeout(s,this.debounceMs-e)}),this.debounceTimer=null,this.debounceResolve=null}this.closed||this.flushCollectBuffer()}}flushCollectBuffer(){if(0===this.collectBuffer.length&&0===this.droppedResolvers.length)return;const e=this.collectBuffer.splice(0),s=e.map(e=>e.prompt),t=this.mergePrompts(s),i=this.buildQueueMessage(t),o=[...this.droppedResolvers.splice(0),...e.map(e=>({resolve:e.resolve,reject:e.reject}))];this.droppedSummaries=[],this.pendingResponses.push({resolve:e=>{for(const s of o)s.resolve(e)},reject:e=>{for(const s of o)s.reject(e)}}),this.queue.push(i),l.info(`[${this.sessionKey}] Flushed ${e.length} collected message(s) as one prompt`)}mergePrompts(e){const s=[],t=[];if(this.droppedSummaries.length>0){s.push(`[${this.droppedSummaries.length} earlier message(s) dropped due to queue cap]`);for(const e of this.droppedSummaries)s.push(`- ${e}`);s.push("")}if(1===e.length&&0===this.droppedSummaries.length)return e[0];if(e.length>0){s.push("[Queued messages while agent was busy]");for(let i=0;i<e.length;i++)e[i].text&&s.push(`${i+1}. ${e[i].text}`),t.push(...e[i].images)}return{text:s.join("\n"),images:t}}ensureInitialized(){if(this.initialized)return;this.initialized=!0;const s=this.piProviderConfig?"pi":"claudecode";l.info(`[${this.sessionKey}] Starting agent: engine=${s}, model=${this.model}, mode=${this.queueMode}, debounce=${this.debounceMs}ms, cap=${this.queueCap||"unlimited"}, drop=${this.dropPolicy}, session=${this.currentSessionId||"new"}`),this.piProviderConfig?this.initPiEngine():(this.queryHandle=e({prompt:this.queue,options:this.opts}),this.processOutput())}async initPiEngine(){try{const e=await import("../pi-agent-provider/index.js"),s=await e.createToolRegistryFromOptions(this.opts);this.queryHandle=e.piQuery({prompt:this.queue,options:this.opts},this.piProviderConfig,s),this.processOutput(),l.info(`[${this.sessionKey}] Pi engine initialized: ${this.piProviderConfig.provider}/${this.piProviderConfig.modelId}`)}catch(s){l.error(`[${this.sessionKey}] Failed to initialize Pi engine: ${s}`),l.warn(`[${this.sessionKey}] Falling back to Claude SDK (claudecode engine)`),this.queryHandle=e({prompt:this.queue,options:this.opts}),this.processOutput()}}buildQueueMessage(e){if(0===e.images.length)return l.debug(`[${this.sessionKey}] SDK request: text-only (${e.text.length} chars): ${this.config.verboseDebugLogs?e.text:e.text.slice(0,15)+"..."}`),{type:"user",message:{role:"user",content:e.text}};const s=[];for(const t of e.images)s.push({type:"image",source:{type:"base64",media_type:t.mimeType,data:t.base64}});return e.text&&s.push({type:"text",text:e.text}),l.debug(`[${this.sessionKey}] SDK request: ${s.length} block(s) [${s.map(e=>"image"===e.type?`image/${e.source.media_type}`:`text(${e.text?.length??0})`).join(", ")}]`),{type:"user",message:{role:"user",content:s}}}async processOutput(){if(this.queryHandle)try{for await(const e of this.queryHandle){if(this.closed)break;if(l.debug(`[${this.sessionKey}] SDK message: type=${e.type}, subtype=${e.subtype??"-"}, keys=${Object.keys(e).join(",")}`),"system"===e.type){const s=e,t=s.subtype;if("init"===t){const e=s.slash_commands;Array.isArray(e)&&(this.sdkSlashCommands=e.map(e=>e.replace(/^\//,"")))}if("compact_boundary"===t){const e=s.compact_metadata,t=["Context compacted."];e?.pre_tokens&&t.push(`Pre-compaction tokens: ${e.pre_tokens}.`),e?.trigger&&t.push(`Trigger: ${e.trigger}.`),this.currentResponse=t.join(" "),l.info(`[${this.sessionKey}] Compact: ${this.currentResponse}`)}else if("init"!==t&&"status"!==t){const e=new Set(["task_started","task_notification","files_persisted","hook_started","hook_progress","hook_response"]),{type:i,...o}=s,n=JSON.stringify(o,null,2);e.has(t)?l.debug(`[${this.sessionKey}] Internal SDK event (${t}): ${n.slice(0,200)}`):(this.currentResponse=n,l.info(`[${this.sessionKey}] System message (${t??"unknown"}): ${n.slice(0,200)}`))}}if("assistant"===e.type){const s=e.message.content,t=s.filter(e=>"text"===e.type).map(e=>e.text).join("");t&&(this.currentResponse=t,this.pendingTextBlock=t);const i=s.map(e=>e.type).join(", ");l.debug(`[${this.sessionKey}] SDK assistant message: blocks=[${i}], text length=${t.length}: ${this.config.verboseDebugLogs?t:t.slice(0,15)+"..."}`);s.some(e=>"tool_use"===e.type)&&this.pendingTextBlock&&this.textBlockStreamer&&!this.qualityGateEnabled&&await this.flushPendingTextBlock();for(const e of s)if("tool_use"===e.type){this.toolCallsCurrentTurn++,"mcp__message-tools__send_message"===e.name&&(this.messageSentViaTool=!0);const s=JSON.stringify(e.input);l.debug(`[${this.sessionKey}] Tool call: ${e.name} ${this.config.verboseDebugLogs?s:s.slice(0,100)+(s.length>100?"...":"")}`),this.toolUseNotifier&&"AskUserQuestion"!==e.name&&await this.notifyToolUse(e.name)}}if("tool_progress"===e.type){const s=e;l.debug(`[${this.sessionKey}] Tool progress: ${s.tool_name} (${s.elapsed_time_seconds}s)`)}if("result"===e.type){const s=e;let t;l.debug(`[${this.sessionKey}] SDK result: subtype=${s.subtype}, stop_reason=${s.stop_reason??"null"}, session=${s.session_id??"n/a"}, result length=${s.result?.length??0}`),"session_id"in s&&(this.currentSessionId=s.session_id),this.usageRecorder&&(void 0!==s.total_cost_usd||s.modelUsage)&&this.usageRecorder(this.sessionKey,s.total_cost_usd,s.duration_ms,s.num_turns,s.modelUsage);const i=s.stop_reason??null;if("success"===s.subtype){if(s.result)this.currentResponse=s.result;else if(!this.currentResponse&&!this.messageSentViaTool&&this.pendingResponses.length<=1&&(void 0!==s.total_cost_usd||s.usage)){const e=[];if(void 0!==s.total_cost_usd&&e.push(`Total cost: $${Number(s.total_cost_usd).toFixed(4)}`),void 0!==s.duration_ms&&e.push(`Duration: ${(s.duration_ms/1e3).toFixed(1)}s`),void 0!==s.num_turns&&e.push(`Turns: ${s.num_turns}`),s.modelUsage)for(const[t,i]of Object.entries(s.modelUsage)){const s=i,o=[` ${t}:`];s.inputTokens&&o.push(`input=${s.inputTokens}`),s.outputTokens&&o.push(`output=${s.outputTokens}`),s.cacheReadInputTokens&&o.push(`cache_read=${s.cacheReadInputTokens}`),s.cacheCreationInputTokens&&o.push(`cache_create=${s.cacheCreationInputTokens}`),void 0!==s.costUSD&&o.push(`cost=$${Number(s.costUSD).toFixed(4)}`),e.push(o.join(" "))}e.length>0&&(this.currentResponse=e.join("\n"))}if(!s.result&&!this.currentResponse&&this.pendingResponses.length<=1){const e=this.piProviderConfig;l.warn(`[${this.sessionKey}] Empty response on success: provider=${e?.provider??"sdk"}, modelId=${e?.modelId??"n/a"}, stop_reason=${i}. Check provider routing and API key.`)}"refusal"===i?(l.warn(`[${this.sessionKey}] Model refused the request`),this.currentResponse||(this.currentResponse="I'm unable to fulfill this request.")):"error"===i?(l.warn(`[${this.sessionKey}] Model returned stop_reason=error`),this.currentResponse||(this.currentResponse="⚠️ Request failed. The provider may be unavailable or the API key/credits may be exhausted.")):"max_tokens"===i&&l.warn(`[${this.sessionKey}] Response truncated: output token limit reached`)}else if("error_max_turns"===s.subtype)t="max_turns",l.warn(`[${this.sessionKey}] Max turns reached`);else if("error_max_budget_usd"===s.subtype)t="max_budget",l.warn(`[${this.sessionKey}] Max budget reached`);else{const e=s.errors??[];if(e.some(e=>e.includes("aborted")))l.info(`[${this.sessionKey}] Request aborted (steer interrupt)`);else if(l.error(`[${this.sessionKey}] SDK error: ${JSON.stringify(s)}`),!this.currentResponse&&!this.streamedAny){const s=e.filter(e=>!e.includes("aborted")).join("; ");this.currentResponse="⚠️ Request failed"+(s?`: ${s}`:". The provider may be unavailable or the API key/credits may be exhausted.")}}const o=this.pendingResponses.shift();if(o){const e=this.currentResponse||"";let s=e;this.streamedAny&&(s=this.pendingTextBlock||"",!s&&e&&e.length>this.streamedText.length&&(s=e.startsWith(this.streamedText)?e.slice(this.streamedText.length).replace(/^\n+/,""):e)),l.info(`[${this.sessionKey}] Response ready: session=${this.currentSessionId}, length=${s.length}${this.streamedAny?` (streamed, full=${e.length})`:""}`),o.resolve({response:s,fullResponse:this.streamedAny?e:void 0,sessionId:this.currentSessionId,sessionReset:!1,errorType:t,stopReason:i})}this.toolCallsLastTurn=this.toolCallsCurrentTurn,this.toolCallsCurrentTurn=0,this.messageSentViaTool=!1,this.currentResponse="",this.pendingTextBlock="",this.streamedAny=!1,this.streamedText="","collect"===this.queueMode&&(this.collectBuffer.length>0||this.droppedResolvers.length>0)&&await this.debounceThenFlush()}}}catch(e){l.error(`[${this.sessionKey}] Output stream error: ${e}`);const s=this.pendingResponses.shift();if(s)if(this.currentSessionId){const t=e instanceof Error?e.message:String(e);l.warn(`[${this.sessionKey}] Session error (${this.currentSessionId}): ${t}`),s.resolve({response:t,sessionId:"",sessionReset:!0})}else s.reject(e instanceof Error?e:new Error(String(e)));const t=new Error("SessionAgent terminated");for(const e of this.pendingResponses)e.reject(t);for(const e of this.collectBuffer)e.reject(t);for(const e of this.droppedResolvers)e.reject(t);this.pendingResponses=[],this.collectBuffer=[],this.droppedResolvers=[],this.droppedSummaries=[]}finally{this.outputDone=!0}}}
|
|
1
|
+
import{query as e}from"@anthropic-ai/claude-agent-sdk";import{MessageQueue as s}from"./message-queue.js";import{resolveModelId as t,resolveModelEntry as i}from"../config.js";import{createLogger as o}from"../utils/logger.js";import{ToolLoopDetector as n}from"./tool-loop-detector.js";import{loadToolRules as r}from"../tools/operational-context-tools.js";const l=o("SessionAgent");export class SessionAgent{sessionKey;config;queue;queryHandle=null;pendingResponses=[];currentResponse="";currentSessionId;model;primaryModel;fallbackActive=!1;queueMode;closed=!1;piProviderConfig=null;outputDone=!1;initialized=!1;opts;collectBuffer=[];lastCollectAt=0;debounceMs;debounceTimer=null;debounceResolve=null;queueCap;dropPolicy;droppedResolvers=[];droppedSummaries=[];sdkSlashCommands=[];channelSender=null;toolUseNotifier=null;typingSetter=null;typingClearer=null;textBlockStreamer=null;pendingTextBlock="";streamedAny=!1;streamedText="";usageRecorder=null;autoApproveTools;loopDetector;skillNudgeEnabled;skillNudgeThreshold;qualityGateEnabled;toolCallsCurrentTurn=0;toolCallsLastTurn=0;messageSentViaTool=!1;pendingPermission=null;pendingQuestion=null;constructor(e,i,o,a,h,u,c,d,p,g,f,m,y,$,b,v,T,w,S,k,_,K,R,x,P){this.sessionKey=e,this.config=i,this.currentSessionId=h??"";const C=u??i.agent.model;this.model=C?t(i,C):"",this.primaryModel=this.model,this.queueMode=i.agent.queueMode,this.debounceMs=Math.max(0,i.agent.queueDebounceMs),this.queueCap=Math.max(0,i.agent.queueCap),this.dropPolicy=i.agent.queueDropPolicy,this.autoApproveTools=i.agent.autoApproveTools,this.loopDetector=new n(e),this.skillNudgeEnabled=i.agent.skillNudge?.enabled??!0,this.skillNudgeThreshold=i.agent.skillNudge?.threshold??10,this.qualityGateEnabled=i.agent.qualityGate?.enabled??!1,this.queue=new s,this.opts={...this.model?{model:this.model}:{},systemPrompt:f?{type:"preset",preset:"claude_code",append:o}:o,...i.agent.maxTurns>0?{maxTurns:i.agent.maxTurns}:{},cwd:i.agent.workspacePath,env:process.env,permissionMode:i.agent.permissionMode,allowDangerouslySkipPermissions:!1,...T?{sandbox:{enabled:!0,autoAllowBashIfSandboxed:!0,network:{allowLocalBinding:!0}}}:{},canUseTool:async(e,s)=>this.handleCanUseTool(e,s),hooks:{PreToolUse:[{hooks:[async e=>{try{const s=e?.tool_name;if(!s)return{};const t=r(i.dataDir);let o=t[s];if(!o){const e=s.includes("__")?s.split("__").pop():void 0;e&&(o=t[e])}return o&&0!==o.length?(l.info(`[${this.sessionKey}] CKR L1: injecting ${o.length} rules for tool ${s}`),{additionalContext:`[CKR Tool Rules for ${s}]\n${o.join("\n")}`}):{}}catch(e){return l.warn(`[${this.sessionKey}] CKR L1 error: ${e}`),{}}}]}],SubagentStart:[{hooks:[async e=>{const s=e?.agent_type??"unknown",t=e?.agent_id??"?",i=e?.description??e?.task_description??"",o=e?.model??"",n=i?` "${i}"`:"",r=o?` [${o}]`:"";return l.info(`[${this.sessionKey}] Subagent START: ${s}${n}${r} (id=${t})`),l.debug(`[${this.sessionKey}] Subagent input keys: ${JSON.stringify(Object.keys(e??{}))}`),this._subagentStartTimes??={},this._subagentStartTimes[t]=Date.now(),{}}]}],SubagentStop:[{hooks:[async e=>{const s=e?.agent_type??"unknown",t=e?.agent_id??"?",i=e?.description??e?.task_description??"",o=e?.model??"",n=i?` "${i}"`:"",r=o?` [${o}]`:"",a=this._subagentStartTimes??{},h=a[t],u=h?Date.now()-h:-1,c=e?.last_assistant_message,d=c?c.substring(0,200):"(no output)";return l.info(`[${this.sessionKey}] Subagent STOP: ${s}${n}${r} (id=${t}) duration=${u}ms output=${d}`),h&&delete a[t],{}}]}],PreCompact:[{hooks:[async e=>{const s=e?.trigger??"auto";if(l.info(`[${this.sessionKey}] PreCompact hook fired (trigger=${s})`),this.channelSender){const e=this.sessionKey.indexOf(":");if(e>0){const t=this.sessionKey.substring(0,e),i=this.sessionKey.substring(e+1);if("cron"!==t){const e="auto"===s?"The conversation context is getting large — compacting memory to keep things running smoothly.":"Compacting conversation memory...";this.channelSender(t,i,e).catch(()=>{})}}}return{}}]}]},stderr:s=>{l.error(`[${e}] SDK stderr: ${s.trimEnd()}`)},...void 0!==i.agent.compaction?{compactionControl:i.agent.compaction}:{}};const A=i.agent.settingSources;"user"===A?this.opts.settingSources=["user"]:"project"===A?this.opts.settingSources=["project"]:"both"===A&&(this.opts.settingSources=["user","project"]);const I=i.agent.mainFallback;I&&(this.opts.fallbackModel=t(i,I)),i.agent.allowedTools.length>0&&(this.opts.allowedTools=i.agent.allowedTools),i.agent.disallowedTools.length>0&&(this.opts.disallowedTools=i.agent.disallowedTools);const q={};if(Object.keys(i.agent.mcpServers).length>0&&Object.assign(q,i.agent.mcpServers),c&&(q["node-tools"]=c),d&&(q["message-tools"]=d),p&&(q["server-tools"]=p),g&&(q["cron-tools"]=g),$&&(q["tts-tools"]=$),b&&(q["memory-tools"]=b),v&&(q["browser-tools"]=v),w&&(q["pico-tools"]=w),S&&(q["telegram-actions"]=S),k&&(q["a2ui-tools"]=k),_&&(q["dynamic-ui-tools"]=_),K&&(q["plasma-client-tools"]=K),R&&(q["concept-tools"]=R),x&&(q["state-tools"]=x),P&&(q["operational-context-tools"]=P),Object.keys(q).length>0&&(this.opts.mcpServers=q,this.opts.allowedTools&&this.opts.allowedTools.length>0))for(const e of Object.keys(q)){const s=`mcp__${e}__*`;this.opts.allowedTools.includes(s)||this.opts.allowedTools.push(s)}if(h&&(this.opts.resume=h),!1===m&&(this.opts.allowedTools&&this.opts.allowedTools.length>0?this.opts.allowedTools=this.opts.allowedTools.filter(e=>"Task"!==e):(this.opts.disallowedTools||(this.opts.disallowedTools=[]),this.opts.disallowedTools.includes("Task")||this.opts.disallowedTools.push("Task"))),y){const e={};for(const s of i.agent.customSubAgents){if(!s.enabled)continue;const t=s.expandContext?a+"\n\n"+s.prompt:s.prompt;e[s.name]={description:s.description,prompt:t,tools:s.tools,..."inherit"!==s.model?{model:s.model}:{}}}Object.keys(e).length>0&&(this.opts.agents=e)}const j=i.agent.plugins.filter(e=>e.enabled);j.length>0&&(this.opts.options={...this.opts.options,plugins:j.map(e=>({type:"local",path:e.path}))});const D=this.buildEnvForModel(this.model);this.opts.env=D.env,D.disableThinking&&(this.opts.maxThinkingTokens=0),this.piProviderConfig=this.resolvePiConfig(),this.piProviderConfig&&l.info(`[${e}] Pi engine: ${this.piProviderConfig.provider}/${this.piProviderConfig.modelId}`)}resolvePiConfig(){const e=this.model,s=this.config.models?.find(s=>s.id===e),t=s?.name??"";let i;const o=this.config.agent.picoAgent;if(o?.enabled&&Array.isArray(o.modelRefs)&&(i=o.modelRefs.find(e=>e.split(":")[0]===t)),!i){const e=this.config.agent.engine;if(!e||"pi"!==e.type||!e.piModelRef)return null;i=e.piModelRef}const n=i.split(":");if(n.length<2)return l.warn(`[${this.currentSessionId}] Invalid piModelRef (missing ':'): ${i}`),null;const r=n[0].trim();let a,h;if(n.length>=3)a=n[1].trim(),h=n.slice(2).join(":").trim(),h.startsWith(a+":")&&(h=h.substring(a.length+1));else{const e=n[1].trim(),s=e.indexOf("/");s>0?(a=e.substring(0,s),h=e.substring(s+1)):(a="openrouter",h=e)}const u=this.config.models?.find(e=>e.name===r);let c,d;u?.baseURL&&u.baseURL.includes("openrouter.ai")&&"openrouter"!==a&&(l.info(`[${this.currentSessionId}] piModelRef auto-correction: baseURL is openrouter.ai, switching provider from "${a}" to "openrouter" (modelId: "${a}/${h}")`),h=`${a}/${h}`,a="openrouter"),l.info(`[${this.currentSessionId}] piModelRef resolved: provider="${a}", modelId="${h}", contextWindow=${u?.contextWindow??128e3}`);const p=o?.rollingMemoryModel;if(p){const e=p.split(":");if(e.length>=3)c=e[1].trim(),d=e.slice(2).join(":").trim();else if(2===e.length){const s=e[1].indexOf("/");s>0?(c=e[1].substring(0,s).trim(),d=e[1].substring(s+1).trim()):d=e[1].trim()}d&&l.info(`[${this.currentSessionId}] Summarization model resolved: ${c}/${d}`)}return{provider:a,modelId:h,apiKey:u?.apiKey||void 0,baseUrl:u?.baseURL||void 0,contextWindowTokens:u?.contextWindow||void 0,costInput:u?.costInput||void 0,costOutput:u?.costOutput||void 0,costCacheRead:u?.costCacheRead||void 0,costCacheWrite:u?.costCacheWrite||void 0,summarizationProvider:c,summarizationModelId:d}}static API_RETRY_RE=/API Error:\s*(?:500|502|503|529)|overloaded|internal server error/i;async send(e,s){const t=s??0;if(this.closed||this.outputDone)throw new Error("SessionAgent is closed");if(this.skillNudgeEnabled&&this.toolCallsLastTurn>=this.skillNudgeThreshold&&0===t){const s=`[System note: Your previous turn used ${this.toolCallsLastTurn} tool calls. If you discovered a reusable workflow or improved on an existing skill's approach, consider updating or creating a skill (Edit the SKILL.md file directly). Skip this if the task was inherently complex and doesn't generalize.]`;e={...e,text:s+"\n\n"+e.text},this.toolCallsLastTurn=0,l.info(`[${this.sessionKey}] Skill nudge injected (previous turn had ${this.skillNudgeThreshold}+ tool calls)`)}let o;switch(this.loopDetector.reset(),this.ensureInitialized(),this.queueMode){case"collect":o=await this.sendCollect(e);break;case"steer":o=await this.sendSteer(e);break;default:o=await this.sendDirect(e)}const n=o.fullResponse??o.response,r=SessionAgent.API_RETRY_RE.test(n),a=this.config.agent.apiRetry,h=a.maxAttempts;if(r&&t<h){const s=t+1,i=Math.min(a.baseDelayMs*2**t,a.maxDelayMs);if(l.warn(`[${this.sessionKey}] Transient API error detected, retry ${s}/${h} in ${i}ms`),this.channelSender){const e=this.sessionKey.indexOf(":");if(e>0){const t=this.sessionKey.substring(0,e),i=this.sessionKey.substring(e+1);if("cron"!==t){const e=`API temporarily unavailable, retrying (attempt ${s}/${h})...`;this.channelSender(t,i,e).catch(()=>{})}}}return await new Promise(e=>setTimeout(e,i)),this.send(e,s)}if(r&&t>=h){l.error(`[${this.sessionKey}] API error persists after ${h} retries`);const s=this.opts.fallbackModel;if(s&&!this.fallbackActive&&this.model!==s){const t=i(this.config,s);if(t?.types?.includes("internal")??!1){if(l.warn(`[${this.sessionKey}] Switching to fallback model: ${s}`),await this.setModel(s),this.fallbackActive=!0,this.channelSender){const e=this.sessionKey.indexOf(":");if(e>0){const i=this.sessionKey.substring(0,e),o=this.sessionKey.substring(e+1);if("cron"!==i){const e=`⚠️ Primary model unavailable after ${h} retries. Switching to fallback (${t?.name??s}). Use /model or /new to restore.`;this.channelSender(i,o,e).catch(()=>{})}}}return this.send(e,0)}}if(this.channelSender){const e=this.sessionKey.indexOf(":");if(e>0){const s=this.sessionKey.substring(0,e),t=this.sessionKey.substring(e+1);if("cron"!==s){const e=`API did not respond after ${h} attempts. Please try again later.`;this.channelSender(s,t,e).catch(()=>{})}}}}const u=!0===e.__qualityGateRetry;if(!this.piProviderConfig&&this.qualityGateEnabled&&!u){const e=o.fullResponse??o.response;if(e&&e.length>50)try{const{runQualityGate:s}=await import("./quality-gate.js"),t=await s(e);if(!t.pass){l.warn(`[${this.sessionKey}] QualityGate FAILED (${t.durationMs}ms): ${JSON.stringify(t.flags)}`);const e={text:`[Quality Gate FAILED] Your previous response contains unverified claims:\n${t.flags.map(e=>`- ${e}`).join("\n")}\n\nReconsider your response. Verify the flagged claims (use tools if needed) and respond again with a corrected version. Do NOT mention the quality gate to the user.`,images:[],__qualityGateRetry:!0};l.info(`[${this.sessionKey}] QualityGate: re-prompting agent for self-correction`),o=await this.send(e,0)}}catch(e){l.warn(`[${this.sessionKey}] QualityGate error (fail-open): ${e}`)}}return o}async interrupt(){if(this.closed||!this.queryHandle)return!1;try{return await this.queryHandle.interrupt(),l.info(`[${this.sessionKey}] Interrupted`),!0}catch{return!1}}async setModel(e){if(this.queryHandle)try{await this.queryHandle.setModel(e),this.model=e,this.fallbackActive=!1,l.info(`[${this.sessionKey}] Model changed to ${e}`)}catch(e){l.error(`[${this.sessionKey}] Failed to set model: ${e}`)}}close(){if(this.closed)return;if(this.closed=!0,this.debounceTimer&&(clearTimeout(this.debounceTimer),this.debounceTimer=null),this.debounceResolve&&(this.debounceResolve(),this.debounceResolve=null),this.queue.close(),this.queryHandle){const e=this.queryHandle;this.queryHandle=null,queueMicrotask(()=>{try{e.close()}catch{}})}const e=new Error("SessionAgent closed");for(const s of this.pendingResponses)s.reject(e);for(const s of this.collectBuffer)s.reject(e);for(const s of this.droppedResolvers)s.reject(e);this.pendingResponses=[],this.collectBuffer=[],this.droppedResolvers=[],this.droppedSummaries=[],l.info(`[${this.sessionKey}] Closed`)}isActive(){return!this.closed&&!this.outputDone}getSessionId(){return this.currentSessionId}getModel(){return this.model}isFallbackActive(){return this.fallbackActive}getSdkSlashCommands(){return this.sdkSlashCommands}setChannelSender(e){this.channelSender=e}setToolUseNotifier(e){this.toolUseNotifier=e}setTypingSetter(e){this.typingSetter=e}setTypingClearer(e){this.typingClearer=e}setTextBlockStreamer(e){this.textBlockStreamer=e}setUsageRecorder(e){this.usageRecorder=e}buildEnvForModel(e){const s=this.config.models.find(s=>s.id===e);if(!s?.proxy||"not-used"===s.proxy)return{env:{...process.env},proxied:!1,disableThinking:!1};const t={...process.env};return"direct"===s.proxy?(t.ANTHROPIC_BASE_URL=s.baseURL,t.ANTHROPIC_AUTH_TOKEN=s.apiKey,t.ANTHROPIC_API_KEY="",l.info(`[${this.sessionKey}] Direct env applied for model ${e} (url=${t.ANTHROPIC_BASE_URL})`),{env:t,proxied:!0,disableThinking:!1}):(t.ANTHROPIC_BASE_URL=s.fastUrl||this.config.fastProxyUrl,t.ANTHROPIC_AUTH_TOKEN=s.fastProxyApiKey,t.ANTHROPIC_API_KEY="",delete t.ANTHROPIC_BETAS,delete t.CLAUDE_CODE_EXTRA_BODY,l.info(`[${this.sessionKey}] Proxy env applied for model ${e} (url=${t.ANTHROPIC_BASE_URL})`),{env:t,proxied:!0,disableThinking:!0})}hasPendingPermission(){return null!==this.pendingPermission}resolvePermission(e){if(!this.pendingPermission)return;const s=this.pendingPermission;this.pendingPermission=null,e?(l.info(`[${this.sessionKey}] Permission approved: ${s.toolName}`),s.resolve({behavior:"allow",updatedInput:s.input})):(l.info(`[${this.sessionKey}] Permission denied: ${s.toolName}`),s.resolve({behavior:"deny",message:"User denied this action"}))}isBusy(){return this.pendingResponses.length>0}hasPendingQuestion(){return null!==this.pendingQuestion}resolveQuestion(e){if(!this.pendingQuestion)return;const s=this.pendingQuestion;this.pendingQuestion=null,l.info(`[${this.sessionKey}] Question answered: "${e}" for "${s.questionText}"`),s.resolve(e)}async handleCanUseTool(e,s){if("AskUserQuestion"===e){if(!this.channelSender)return l.warn(`[${this.sessionKey}] No channel sender for AskUserQuestion, auto-approving`),{behavior:"allow",updatedInput:s};const e=this.sessionKey.indexOf(":");if(e<0)return{behavior:"allow",updatedInput:s};const t=this.sessionKey.substring(0,e),i=this.sessionKey.substring(e+1);if(!t||!i||"cron"===t)return{behavior:"allow",updatedInput:s};const o=s?.questions;if(!Array.isArray(o)||0===o.length)return{behavior:"allow",updatedInput:s};const n={};for(const e of o){const o=e.question||"?",r=Array.isArray(e.options)?e.options:[],a=[];if(e.header&&a.push(`*${e.header}*`),a.push(o),r.some(e=>e.description)){a.push("");for(const e of r){const s=e.description?`: ${e.description}`:"";a.push(`• ${e.label}${s}`)}}const h=a.join("\n");if(this.typingClearer)try{await this.typingClearer(t,i)}catch{}try{if(r.length>0){const e=r.map(e=>({text:e.label||String(e),callbackData:`__ask:${e.label||String(e)}`}));await this.channelSender(t,i,h,e)}else await this.channelSender(t,i,h)}catch(e){return l.error(`[${this.sessionKey}] Failed to send AskUserQuestion: ${e}`),{behavior:"allow",updatedInput:s}}const u=55e3,c=await new Promise(e=>{const s=setTimeout(()=>{if(this.pendingQuestion){this.pendingQuestion=null;const s=r.length>0?r[0].label||String(r[0]):"No answer";l.warn(`[${this.sessionKey}] Question timeout, defaulting to "${s}"`),this.channelSender&&this.channelSender(t,i,`[Timeout] Auto-selected: ${s}`).catch(()=>{}),e(s)}},u);this.pendingQuestion={resolve:t=>{clearTimeout(s),e(t)},questionText:o}});if(n[o]=c,this.typingSetter)try{await this.typingSetter(t,i)}catch{}}return l.info(`[${this.sessionKey}] AskUserQuestion answered: ${JSON.stringify(n)}`),{behavior:"allow",updatedInput:{questions:s.questions,answers:n}}}const t=this.loopDetector.check(e,s);if("circuit_break"===t.severity)return l.error(`[${this.sessionKey}] CIRCUIT BREAK: ${t.reason}`),{behavior:"deny",message:`[Loop detected] ${t.reason} Try a different approach or ask the user for help.`};if(this.autoApproveTools)return l.debug(`[${this.sessionKey}] Auto-approving tool: ${e}`),{behavior:"allow",updatedInput:s};if(!this.channelSender)return l.warn(`[${this.sessionKey}] No channel sender for interactive permission, auto-approving: ${e}`),{behavior:"allow",updatedInput:s};const i=this.sessionKey.indexOf(":");if(i<0)return{behavior:"allow",updatedInput:s};const o=this.sessionKey.substring(0,i),n=this.sessionKey.substring(i+1);if(!o||!n||"cron"===o)return{behavior:"allow",updatedInput:s};const r=[`[Permission Request] Tool: ${e}`];if("Bash"===e&&s?.command)r.push(`Command: ${s.command}`),s.description&&r.push(`Description: ${s.description}`);else if("Write"===e&&s?.file_path)r.push(`File: ${s.file_path}`);else if("Edit"===e&&s?.file_path)r.push(`File: ${s.file_path}`);else if("ExitPlanMode"===e&&s?.plan){if(r.push(""),r.push(s.plan),Array.isArray(s.allowedPrompts)&&s.allowedPrompts.length>0){r.push(""),r.push("Requested permissions:");for(const e of s.allowedPrompts)r.push(` - [${e.tool}] ${e.prompt}`)}}else{const e=JSON.stringify(s);e.length<=300?r.push(`Input: ${e}`):r.push(`Input: ${e.slice(0,297)}...`)}r.push(""),r.push("Reply: approve to allow, deny to reject");const a=r.join("\n"),h=[{text:"Approve",callbackData:"__tool_perm:approve"},{text:"Deny",callbackData:"__tool_perm:deny"}];try{await this.channelSender(o,n,a,h)}catch(e){return l.error(`[${this.sessionKey}] Failed to send permission request: ${e}`),{behavior:"allow",updatedInput:s}}if(this.typingClearer)try{await this.typingClearer(o,n)}catch{}const u=12e4;return new Promise(t=>{const i=setTimeout(()=>{this.pendingPermission?.resolve===t&&(this.pendingPermission=null,l.warn(`[${this.sessionKey}] Permission timeout for ${e}, auto-denying`),this.channelSender&&this.channelSender(o,n,`[Permission timeout] Tool ${e} denied after 120s`).catch(()=>{}),t({behavior:"deny",message:"Permission request timed out"}))},u);this.pendingPermission={resolve:t,toolName:e,input:s};const r=t;this.pendingPermission.resolve=e=>{clearTimeout(i),r(e)}})}async forwardAskUserQuestion(e){if(!this.channelSender)return;const s=this.sessionKey.indexOf(":");if(s<0)return;const t=this.sessionKey.substring(0,s),i=this.sessionKey.substring(s+1);if(!t||!i||"cron"===t)return;const o=e?.questions;if(Array.isArray(o)){for(const e of o){const s=e.question||"?",o=Array.isArray(e.options)?e.options:[],n=[];if(e.header&&n.push(`*${e.header}*`),n.push(s),o.some(e=>e.description)){n.push("");for(const e of o){const s=e.description?`: ${e.description}`:"";n.push(`• ${e.label}${s}`)}}const r=n.join("\n");try{if(o.length>0){const e=o.map(e=>({text:e.label||String(e),callbackData:e.label||String(e)}));await this.channelSender(t,i,r,e)}else await this.channelSender(t,i,r)}catch(e){l.error(`[${this.sessionKey}] Failed to forward AskUserQuestion: ${e}`)}}if(this.typingClearer)try{await this.typingClearer(t,i)}catch(e){l.error(`[${this.sessionKey}] Failed to clear typing: ${e}`)}}}async notifyToolUse(e){if(!this.toolUseNotifier)return;const s=this.sessionKey.indexOf(":");if(s<0)return;const t=this.sessionKey.substring(0,s),i=this.sessionKey.substring(s+1);if(t&&i&&"cron"!==t)try{await this.toolUseNotifier(t,i,e)}catch(e){l.error(`[${this.sessionKey}] Failed to notify tool use: ${e}`)}}async flushPendingTextBlock(){if(!this.textBlockStreamer||!this.pendingTextBlock)return;const e=this.sessionKey.indexOf(":");if(e<0)return;const s=this.sessionKey.substring(0,e),t=this.sessionKey.substring(e+1);if(!s||!t||"cron"===s)return;const i=this.pendingTextBlock;this.pendingTextBlock="",this.streamedAny=!0,this.streamedText+=i;try{await this.textBlockStreamer(s,t,i)}catch(e){l.error(`[${this.sessionKey}] Text block stream error: ${e}`)}}sendDirect(e){if(this.queueCap>0&&this.pendingResponses.length>=this.queueCap)return l.warn(`[${this.sessionKey}] Queue cap reached (${this.queueCap}), rejecting message`),Promise.resolve({response:"Queue is full. Please wait for the current processing to complete.",sessionId:this.currentSessionId,sessionReset:!1});const s=this.buildQueueMessage(e);return new Promise((e,t)=>{this.pendingResponses.push({resolve:e,reject:t}),this.queue.push(s),l.info(`[${this.sessionKey}] Message queued (pending=${this.pendingResponses.length})`)})}sendCollect(e){return this.pendingResponses.length>0?this.queueCap>0&&this.collectBuffer.length>=this.queueCap?this.applyDropPolicy(e):(this.lastCollectAt=Date.now(),l.info(`[${this.sessionKey}] Collecting message (buffer=${this.collectBuffer.length+1})`),new Promise((s,t)=>{this.collectBuffer.push({prompt:e,resolve:s,reject:t})})):this.sendDirect(e)}async sendSteer(e){return this.pendingResponses.length>0&&(l.info(`[${this.sessionKey}] Steer: interrupting current processing`),await this.interrupt()),this.sendDirect(e)}applyDropPolicy(e){if("new"===this.dropPolicy)return l.warn(`[${this.sessionKey}] Queue cap reached, rejecting new message`),Promise.resolve({response:"Queue is full. Please wait for the current processing to complete.",sessionId:this.currentSessionId,sessionReset:!1});const s=this.collectBuffer.shift();return"summarize"===this.dropPolicy&&this.droppedSummaries.push(function(e,s){const t=e.replace(/\s+/g," ").trim();return t.length<=s?t:`${t.slice(0,s-1).trimEnd()}…`}(s.prompt.text,140)),this.droppedResolvers.push({resolve:s.resolve,reject:s.reject}),l.warn(`[${this.sessionKey}] Queue cap reached, dropped oldest message (policy=${this.dropPolicy}, dropped=${this.droppedResolvers.length})`),this.lastCollectAt=Date.now(),new Promise((s,t)=>{this.collectBuffer.push({prompt:e,resolve:s,reject:t})})}async debounceThenFlush(){if(this.debounceMs<=0||this.closed)this.flushCollectBuffer();else{for(;!this.closed&&this.collectBuffer.length>0;){const e=Date.now()-this.lastCollectAt;if(e>=this.debounceMs)break;await new Promise(s=>{this.debounceResolve=s,this.debounceTimer=setTimeout(s,this.debounceMs-e)}),this.debounceTimer=null,this.debounceResolve=null}this.closed||this.flushCollectBuffer()}}flushCollectBuffer(){if(0===this.collectBuffer.length&&0===this.droppedResolvers.length)return;const e=this.collectBuffer.splice(0),s=e.map(e=>e.prompt),t=this.mergePrompts(s),i=this.buildQueueMessage(t),o=[...this.droppedResolvers.splice(0),...e.map(e=>({resolve:e.resolve,reject:e.reject}))];this.droppedSummaries=[],this.pendingResponses.push({resolve:e=>{for(const s of o)s.resolve(e)},reject:e=>{for(const s of o)s.reject(e)}}),this.queue.push(i),l.info(`[${this.sessionKey}] Flushed ${e.length} collected message(s) as one prompt`)}mergePrompts(e){const s=[],t=[];if(this.droppedSummaries.length>0){s.push(`[${this.droppedSummaries.length} earlier message(s) dropped due to queue cap]`);for(const e of this.droppedSummaries)s.push(`- ${e}`);s.push("")}if(1===e.length&&0===this.droppedSummaries.length)return e[0];if(e.length>0){s.push("[Queued messages while agent was busy]");for(let i=0;i<e.length;i++)e[i].text&&s.push(`${i+1}. ${e[i].text}`),t.push(...e[i].images)}return{text:s.join("\n"),images:t}}ensureInitialized(){if(this.initialized)return;this.initialized=!0;const s=this.piProviderConfig?"pi":"claudecode";l.info(`[${this.sessionKey}] Starting agent: engine=${s}, model=${this.model}, mode=${this.queueMode}, debounce=${this.debounceMs}ms, cap=${this.queueCap||"unlimited"}, drop=${this.dropPolicy}, session=${this.currentSessionId||"new"}`),this.piProviderConfig?this.initPiEngine():(this.queryHandle=e({prompt:this.queue,options:this.opts}),this.processOutput())}async initPiEngine(){try{const e=await import("../pi-agent-provider/index.js"),s=await e.createToolRegistryFromOptions(this.opts);this.queryHandle=e.piQuery({prompt:this.queue,options:this.opts},this.piProviderConfig,s),this.processOutput(),l.info(`[${this.sessionKey}] Pi engine initialized: ${this.piProviderConfig.provider}/${this.piProviderConfig.modelId}`)}catch(s){l.error(`[${this.sessionKey}] Failed to initialize Pi engine: ${s}`),l.warn(`[${this.sessionKey}] Falling back to Claude SDK (claudecode engine)`),this.queryHandle=e({prompt:this.queue,options:this.opts}),this.processOutput()}}buildQueueMessage(e){if(0===e.images.length)return l.debug(`[${this.sessionKey}] SDK request: text-only (${e.text.length} chars): ${this.config.verboseDebugLogs?e.text:e.text.slice(0,15)+"..."}`),{type:"user",message:{role:"user",content:e.text}};const s=[];for(const t of e.images)s.push({type:"image",source:{type:"base64",media_type:t.mimeType,data:t.base64}});return e.text&&s.push({type:"text",text:e.text}),l.debug(`[${this.sessionKey}] SDK request: ${s.length} block(s) [${s.map(e=>"image"===e.type?`image/${e.source.media_type}`:`text(${e.text?.length??0})`).join(", ")}]`),{type:"user",message:{role:"user",content:s}}}async processOutput(){if(this.queryHandle)try{for await(const e of this.queryHandle){if(this.closed)break;if(l.debug(`[${this.sessionKey}] SDK message: type=${e.type}, subtype=${e.subtype??"-"}, keys=${Object.keys(e).join(",")}`),"system"===e.type){const s=e,t=s.subtype;if("init"===t){const e=s.slash_commands;Array.isArray(e)&&(this.sdkSlashCommands=e.map(e=>e.replace(/^\//,"")))}if("compact_boundary"===t){const e=s.compact_metadata,t=["Context compacted."];e?.pre_tokens&&t.push(`Pre-compaction tokens: ${e.pre_tokens}.`),e?.trigger&&t.push(`Trigger: ${e.trigger}.`),this.currentResponse=t.join(" "),l.info(`[${this.sessionKey}] Compact: ${this.currentResponse}`)}else if("init"!==t&&"status"!==t){const e=new Set(["task_started","task_notification","files_persisted","hook_started","hook_progress","hook_response"]),{type:i,...o}=s,n=JSON.stringify(o,null,2);e.has(t)?l.debug(`[${this.sessionKey}] Internal SDK event (${t}): ${n.slice(0,200)}`):(this.currentResponse=n,l.info(`[${this.sessionKey}] System message (${t??"unknown"}): ${n.slice(0,200)}`))}}if("assistant"===e.type){const s=e.message.content,t=s.filter(e=>"text"===e.type).map(e=>e.text).join("");t&&(this.currentResponse=t,this.pendingTextBlock=t);const i=s.map(e=>e.type).join(", ");l.debug(`[${this.sessionKey}] SDK assistant message: blocks=[${i}], text length=${t.length}: ${this.config.verboseDebugLogs?t:t.slice(0,15)+"..."}`);s.some(e=>"tool_use"===e.type)&&this.pendingTextBlock&&this.textBlockStreamer&&!this.qualityGateEnabled&&await this.flushPendingTextBlock();for(const e of s)if("tool_use"===e.type){this.toolCallsCurrentTurn++,"mcp__message-tools__send_message"===e.name&&(this.messageSentViaTool=!0);const s=JSON.stringify(e.input);l.debug(`[${this.sessionKey}] Tool call: ${e.name} ${this.config.verboseDebugLogs?s:s.slice(0,100)+(s.length>100?"...":"")}`),this.toolUseNotifier&&"AskUserQuestion"!==e.name&&await this.notifyToolUse(e.name)}}if("tool_progress"===e.type){const s=e;l.debug(`[${this.sessionKey}] Tool progress: ${s.tool_name} (${s.elapsed_time_seconds}s)`)}if("result"===e.type){const s=e;let t;l.debug(`[${this.sessionKey}] SDK result: subtype=${s.subtype}, stop_reason=${s.stop_reason??"null"}, session=${s.session_id??"n/a"}, result length=${s.result?.length??0}`),"session_id"in s&&(this.currentSessionId=s.session_id),this.usageRecorder&&(void 0!==s.total_cost_usd||s.modelUsage)&&this.usageRecorder(this.sessionKey,s.total_cost_usd,s.duration_ms,s.num_turns,s.modelUsage);const i=s.stop_reason??null;if("success"===s.subtype){if(s.result)this.currentResponse=s.result;else if(!this.currentResponse&&!this.messageSentViaTool&&this.pendingResponses.length<=1&&(void 0!==s.total_cost_usd||s.usage)){const e=[];if(void 0!==s.total_cost_usd&&e.push(`Total cost: $${Number(s.total_cost_usd).toFixed(4)}`),void 0!==s.duration_ms&&e.push(`Duration: ${(s.duration_ms/1e3).toFixed(1)}s`),void 0!==s.num_turns&&e.push(`Turns: ${s.num_turns}`),s.modelUsage)for(const[t,i]of Object.entries(s.modelUsage)){const s=i,o=[` ${t}:`];s.inputTokens&&o.push(`input=${s.inputTokens}`),s.outputTokens&&o.push(`output=${s.outputTokens}`),s.cacheReadInputTokens&&o.push(`cache_read=${s.cacheReadInputTokens}`),s.cacheCreationInputTokens&&o.push(`cache_create=${s.cacheCreationInputTokens}`),void 0!==s.costUSD&&o.push(`cost=$${Number(s.costUSD).toFixed(4)}`),e.push(o.join(" "))}e.length>0&&(this.currentResponse=e.join("\n"))}if(!s.result&&!this.currentResponse&&this.pendingResponses.length<=1){const e=this.piProviderConfig;l.warn(`[${this.sessionKey}] Empty response on success: provider=${e?.provider??"sdk"}, modelId=${e?.modelId??"n/a"}, stop_reason=${i}. Check provider routing and API key.`)}"refusal"===i?(l.warn(`[${this.sessionKey}] Model refused the request`),this.currentResponse||(this.currentResponse="I'm unable to fulfill this request.")):"error"===i?(l.warn(`[${this.sessionKey}] Model returned stop_reason=error`),this.currentResponse||(this.currentResponse="⚠️ Request failed. The provider may be unavailable or the API key/credits may be exhausted.")):"max_tokens"===i&&l.warn(`[${this.sessionKey}] Response truncated: output token limit reached`)}else if("error_max_turns"===s.subtype)t="max_turns",l.warn(`[${this.sessionKey}] Max turns reached`);else if("error_max_budget_usd"===s.subtype)t="max_budget",l.warn(`[${this.sessionKey}] Max budget reached`);else{const e=s.errors??[];if(e.some(e=>e.includes("aborted")))l.info(`[${this.sessionKey}] Request aborted (steer interrupt)`);else if(l.error(`[${this.sessionKey}] SDK error: ${JSON.stringify(s)}`),!this.currentResponse&&!this.streamedAny){const s=e.filter(e=>!e.includes("aborted")).join("; ");this.currentResponse="⚠️ Request failed"+(s?`: ${s}`:". The provider may be unavailable or the API key/credits may be exhausted.")}}const o=this.pendingResponses.shift();if(o){const e=this.currentResponse||"";let s=e;this.streamedAny&&(s=this.pendingTextBlock||"",!s&&e&&e.length>this.streamedText.length&&(s=e.startsWith(this.streamedText)?e.slice(this.streamedText.length).replace(/^\n+/,""):e)),l.info(`[${this.sessionKey}] Response ready: session=${this.currentSessionId}, length=${s.length}${this.streamedAny?` (streamed, full=${e.length})`:""}`),o.resolve({response:s,fullResponse:this.streamedAny?e:void 0,sessionId:this.currentSessionId,sessionReset:!1,errorType:t,stopReason:i})}this.toolCallsLastTurn=this.toolCallsCurrentTurn,this.toolCallsCurrentTurn=0,this.messageSentViaTool=!1,this.currentResponse="",this.pendingTextBlock="",this.streamedAny=!1,this.streamedText="","collect"===this.queueMode&&(this.collectBuffer.length>0||this.droppedResolvers.length>0)&&await this.debounceThenFlush()}}}catch(e){l.error(`[${this.sessionKey}] Output stream error: ${e}`);const s=this.pendingResponses.shift();if(s)if(this.currentSessionId){const t=e instanceof Error?e.message:String(e);l.warn(`[${this.sessionKey}] Session error (${this.currentSessionId}): ${t}`),s.resolve({response:t,sessionId:"",sessionReset:!0})}else s.reject(e instanceof Error?e:new Error(String(e)));const t=new Error("SessionAgent terminated");for(const e of this.pendingResponses)e.reject(t);for(const e of this.collectBuffer)e.reject(t);for(const e of this.droppedResolvers)e.reject(t);this.pendingResponses=[],this.collectBuffer=[],this.droppedResolvers=[],this.droppedSummaries=[]}finally{this.outputDone=!0}}}
|
|
@@ -66,6 +66,8 @@ export declare class MemorySearch {
|
|
|
66
66
|
private conceptCache;
|
|
67
67
|
private conceptCacheMap;
|
|
68
68
|
private conceptCacheTime;
|
|
69
|
+
private queryCache;
|
|
70
|
+
private dbVersion;
|
|
69
71
|
private openai;
|
|
70
72
|
private indexDbPath;
|
|
71
73
|
private searchDbPath;
|
|
@@ -129,7 +131,7 @@ export declare class MemorySearch {
|
|
|
129
131
|
search(query: string, maxResults?: number): Promise<MemorySearchResult[]>;
|
|
130
132
|
/**
|
|
131
133
|
* Record that chunks were accessed (expanded/used in a response).
|
|
132
|
-
* Writes to indexDb
|
|
134
|
+
* Writes to indexDb so data survives snapshot cycles (indexDb → searchDb copy).
|
|
133
135
|
*/
|
|
134
136
|
recordAccess(chunkIds: number[]): void;
|
|
135
137
|
/**
|
|
@@ -146,6 +148,7 @@ export declare class MemorySearch {
|
|
|
146
148
|
}>;
|
|
147
149
|
/**
|
|
148
150
|
* Update importance scores for specific chunks (used by dreaming LLM re-scoring).
|
|
151
|
+
* Writes to indexDb so data survives snapshot cycles.
|
|
149
152
|
*/
|
|
150
153
|
updateImportance(updates: Array<{
|
|
151
154
|
chunkId: number;
|
|
@@ -153,7 +156,7 @@ export declare class MemorySearch {
|
|
|
153
156
|
}>): number;
|
|
154
157
|
/**
|
|
155
158
|
* Fetch utility data for a set of chunk IDs from the search DB.
|
|
156
|
-
* Returns a Map of chunkId → { access_count, last_accessed, first_accessed }.
|
|
159
|
+
* Returns a Map of chunkId → { access_count, last_accessed, first_accessed, importance, access_times, maturity }.
|
|
157
160
|
*/
|
|
158
161
|
private getUtilityScores;
|
|
159
162
|
readFile(path: string, from?: number, lines?: number): {
|
|
@@ -194,5 +197,15 @@ export declare class MemorySearch {
|
|
|
194
197
|
private fetchEmbeddings;
|
|
195
198
|
/** Embed a single query text, returns null on failure. */
|
|
196
199
|
private embedText;
|
|
200
|
+
/**
|
|
201
|
+
* T0/T1 cache lookup. Returns cached results or null.
|
|
202
|
+
* T0: exact query string match (same dbVersion + within TTL).
|
|
203
|
+
* T1: Jaccard similarity ≥ threshold on query tokens.
|
|
204
|
+
*/
|
|
205
|
+
private cacheLookup;
|
|
206
|
+
/**
|
|
207
|
+
* Store search results in cache. Evicts oldest entries if over capacity.
|
|
208
|
+
*/
|
|
209
|
+
private cacheStore;
|
|
197
210
|
}
|
|
198
211
|
//# sourceMappingURL=memory-search.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{existsSync as e,readdirSync as t,readFileSync as n,statSync as s,copyFileSync as i,renameSync as c,unlinkSync as r,watch as a}from"node:fs";import{join as h,relative as o,basename as d,dirname as l}from"node:path";import u from"better-sqlite3";import p from"openai";import m from"hnswlib-node";const{HierarchicalNSW:E}=m;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;conceptStore=null;conceptCache=null;conceptCacheMap=null;conceptCacheTime=0;openai=null;indexDbPath;searchDbPath;searchNextDbPath;indexHnswPath;searchHnswPath;searchNextHnswPath;constructor(e,t,n){this.memoryDir=e,this.dataDir=t,this.opts=n,this.indexDbPath=h(t,"memory-index.db"),this.searchDbPath=h(t,"memory-search.db"),this.searchNextDbPath=h(t,"memory-search-next.db"),this.indexHnswPath=h(t,"memory-vectors.hnsw"),this.searchHnswPath=h(t,"memory-vectors-search.hnsw"),this.searchNextHnswPath=h(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)}setConceptStore(e){this.conceptStore=e,T.info("ConceptStore connected — spreading activation enabled"),this.populateChunkEntities()}getConceptCacheEntries(){if(this.conceptCache&&Date.now()-this.conceptCacheTime<36e5)return this.conceptCache;if(!this.conceptStore)return[];const e=this.conceptStore.getConceptCacheData();this.conceptCache=e.filter(e=>e.label.length>=3).map(e=>({id:e.id,label:e.label,labelLower:e.label.toLowerCase(),fan:e.fan,sMax:e.sMax??1.6})),this.conceptCacheMap=new Map;for(const e of this.conceptCache)this.conceptCacheMap.set(e.id,e);return this.conceptCacheTime=Date.now(),T.info(`Concept cache refreshed: ${this.conceptCache.length} entries`),this.conceptCache}extractEntities(e){const t=this.getConceptCacheEntries();if(0===t.length)return[];const n=e.toLowerCase(),s=[];for(const e of t)e.fan>30||n.includes(e.labelLower)&&s.push(e.id);return s}computeSpreadingActivation(e,t,n){if(0===e.length||0===t.length)return 0;const s=this.conceptCacheMap;if(!s)return 0;const i=1/e.length,c=new Set(t);let r=0;for(const t of e){const e=s.get(t);if(!e)continue;const a=Math.max(e.sMax-Math.log(e.fan+1),0);c.has(t)&&(r+=i*a);const h=n.get(t);if(h)for(const{neighbor:e}of h)if(e!==t&&c.has(e)){const t=s.get(e);if(!t)continue;r+=i*Math.max(t.sMax-Math.log(t.fan+1),0)*.5}}return r}populateChunkEntities(){if(!this.indexDb||!this.conceptStore)return;const e=this.indexDb.prepare("SELECT COUNT(*) as c FROM chunk_entities").get().c;if(e>0)return void T.debug(`chunk_entities already populated (${e} rows)`);if(0===this.getConceptCacheEntries().length)return;const t=this.indexDb.prepare("SELECT id, content FROM chunks").all();if(0===t.length)return;const n=this.indexDb.prepare("INSERT OR IGNORE INTO chunk_entities (chunk_id, concept_id) VALUES (?, ?)");let s=0;this.indexDb.transaction(()=>{for(const e of t){const t=this.extractEntities(e.content);for(const i of t)n.run(e.id,i),s++}})(),this.publishSnapshot(),T.info(`Phase 3: Populated chunk_entities — ${s} mappings for ${t.length} chunks`)}getChunkEntities(e,t){if(this.searchDb){const t=this.searchDb.prepare("SELECT concept_id FROM chunk_entities WHERE chunk_id = ?").all(e);if(t.length>0)return t.map(e=>e.concept_id)}return this.extractEntities(t)}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 u(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 -- Phase 3: Pre-computed entity→chunk mapping for spreading activation\n CREATE TABLE IF NOT EXISTS chunk_entities (\n chunk_id INTEGER NOT NULL,\n concept_id TEXT NOT NULL,\n PRIMARY KEY (chunk_id, concept_id)\n );\n CREATE INDEX IF NOT EXISTS idx_chunk_entities_concept ON chunk_entities(concept_id);\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 importance INTEGER DEFAULT 5,\n access_times TEXT DEFAULT '[]'\n );\n"),this.migrateChunkUtility(),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)),this.conceptStore&&this.populateChunkEntities(),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{r(this.indexHnswPath)}catch{}}}migrateChunkUtility(){for(const e of[this.indexDb,this.searchDb]){if(!e)continue;if(!e.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='chunk_utility'").get())continue;const t=e.prepare("PRAGMA table_info(chunk_utility)").all(),n=new Set(t.map(e=>e.name));n.has("importance")||(T.info("Migrating chunk_utility: adding importance column"),e.exec("ALTER TABLE chunk_utility ADD COLUMN importance INTEGER DEFAULT 5")),n.has("access_times")||(T.info("Migrating chunk_utility: adding access_times column"),e.exec("ALTER TABLE chunk_utility ADD COLUMN access_times TEXT DEFAULT '[]'"))}}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,c=String(this.opts.embeddingDimensions),a=void 0!==n&&n!==i,h=void 0!==s&&s!==c;if(a||h){const e=[];a&&e.push(`model: ${n} → ${i}`),h&&e.push(`dimensions: ${s} → ${c}`),T.info(`Embedding config changed (${e.join(", ")}). Wiping embeddings + HNSW for full re-embed.`),this.indexDb.exec("DELETE FROM embeddings");try{r(this.indexHnswPath)}catch{}}t.run("embedding_model",i),t.run("embedding_dimensions",c)}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 c=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),r=Math.min(c.length,40),a=this.searchDb.prepare("SELECT doc_path, role, timestamp, session_key, content, l0 FROM chunks WHERE id = ?"),h=[];for(let e=0;e<r;e++){const{id:t,score:n}=c[e],s=a.get(t);s&&h.push({id:t,score:n,row:s})}const o=36e5,d=this.getUtilityScores(h.map(e=>e.id)),l=Date.now();for(const e of h){const t=d.get(e.id),n=t?.access_times??[],s=t?.importance??5;let i=0;if(n.length>0)for(const e of n){const t=Math.max((l-e)/o,.1);i+=Math.pow(t,-.5)}else if(e.row.timestamp){const t=new Date(e.row.timestamp).getTime();if(!isNaN(t)){const e=Math.max((l-t)/o,.1);i=Math.pow(e,-.5)}}if(i>0){const t=Math.log(i),n=.02*(s-5);e.score+=.01*t+n}}if(this.conceptStore){const t=this.extractEntities(e);if(t.length>0){const e=this.conceptStore.getNeighbors(t);let n=0;for(const s of h){const i=this.getChunkEntities(s.id,s.row.content),c=this.computeSpreadingActivation(t,i,e);c>0&&(s.score+=.015*c,n++)}n>0&&T.debug(`Spreading activation: boosted ${n}/${h.length} candidates (query entities: [${t.join(", ")}])`)}}h.sort((e,t)=>t.score-e.score);const u=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,c=e[e.length-1]?.score??0,r=i-c||1,a=[],h=new Set(e.map((e,t)=>t));a.push(0),h.delete(0);for(;a.length<t&&h.size>0;){let t=-1,i=-1/0;for(const o of h){const h=(e[o].score-c)/r;let d=0;for(const e of a){const t=S(s[o],s[e]);t>d&&(d=t)}const l=n*h-(1-n)*d;l>i&&(i=l,t=o)}if(t<0)break;a.push(t),h.delete(t)}return a.map(t=>e[t])}(h,n,.7),p=[];for(const{id:e,score:t,row:n}of u){const s=n.l0&&this.l0Generator.enabled?n.l0:n.content.length>this.opts.maxSnippetChars?n.content.slice(0,this.opts.maxSnippetChars)+"...":n.content;p.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)}": ${p.length} results (sparse=${s.length}, dense=${i.length}, candidates=${h.length}, mmr=${u.length})`),p}recordAccess(e){if(!this.searchDb||0===e.length)return;const t=(new Date).toISOString(),n=Date.now(),s=this.searchDb.prepare("\n INSERT INTO chunk_utility (chunk_id, access_count, last_accessed, first_accessed, access_times)\n VALUES (?, 1, ?, ?, ?)\n ON CONFLICT(chunk_id) DO UPDATE SET\n access_count = access_count + 1,\n last_accessed = excluded.last_accessed,\n access_times = excluded.access_times\n "),i=this.searchDb.prepare("SELECT access_times FROM chunk_utility WHERE chunk_id = ?");this.searchDb.transaction(()=>{for(const c of e){const e=i.get(c);let r=[];if(e?.access_times)try{r=JSON.parse(e.access_times)}catch{}r.push(n),r.length>50&&(r=r.slice(-50)),s.run(c,t,t,JSON.stringify(r))}})(),T.debug(`Recorded access for ${e.length} chunks`)}getRecentlyAccessedChunks(e=7,t=100){if(!this.searchDb)return[];if(!this.searchDb.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='chunk_utility'").get())return[];const n=new Date(Date.now()-864e5*e).toISOString();return this.searchDb.prepare("\n SELECT cu.chunk_id, c.content, c.doc_path, cu.importance, cu.access_count, cu.last_accessed\n FROM chunk_utility cu\n JOIN chunks c ON c.id = cu.chunk_id\n WHERE cu.last_accessed >= ? AND cu.access_count > 0\n ORDER BY cu.access_count DESC, cu.last_accessed DESC\n LIMIT ?\n ").all(n,t).map(e=>({chunkId:e.chunk_id,content:e.content.slice(0,500),path:e.doc_path,importance:e.importance??5,accessCount:e.access_count,lastAccessed:e.last_accessed}))}updateImportance(e){if(!this.searchDb||0===e.length)return 0;const t=this.searchDb.prepare("UPDATE chunk_utility SET importance = ? WHERE chunk_id = ?");let n=0;return this.searchDb.transaction(()=>{for(const{chunkId:s,importance:i}of e){const e=Math.max(1,Math.min(10,Math.round(i)));t.run(e,s).changes>0&&n++}})(),T.info(`Updated importance for ${n}/${e.length} chunks`),n}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, importance, access_times FROM chunk_utility WHERE chunk_id IN (${n})`).all(...e);for(const e of s){let n=[];if(e.access_times)try{n=JSON.parse(e.access_times)}catch{}t.set(e.chunk_id,{access_count:e.access_count,last_accessed:e.last_accessed,first_accessed:e.first_accessed,importance:e.importance??5,access_times:n})}return t}readFile(t,s,i){const c=h(this.memoryDir,t);if(!e(c))return{path:t,content:`[File not found: ${t}]`};const r=n(c,"utf-8"),a=r.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:r}}listFiles(e,t){const n=_(this.memoryDir),s=[];for(const i of n){const n=d(i.relPath).match(/^(\d{4}-\d{2}-\d{2})/);if(!n)continue;const c=n[1];c>=e&&c<=t&&s.push({path:i.relPath,sessionKey:i.sessionKey,size:i.size})}return s.sort((e,t)=>e.path.localeCompare(t.path)),s}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 t=_(this.memoryDir);let s=0;const i=this.indexDb.prepare("INSERT OR REPLACE INTO documents (path, mtime_ms, size) VALUES (?, ?, ?)"),c=this.indexDb.prepare("SELECT mtime_ms, size FROM documents WHERE path = ?"),r=this.indexDb.prepare("DELETE FROM chunks WHERE doc_path = ?"),a=this.indexDb.prepare("SELECT id FROM chunks WHERE doc_path = ?"),h=this.indexDb.prepare("DELETE FROM embeddings WHERE chunk_id IN (SELECT id FROM chunks WHERE doc_path = ?)"),o=this.indexDb.prepare("INSERT INTO chunks (doc_path, chunk_idx, role, timestamp, session_key, content) VALUES (?, ?, ?, ?, ?, ?)"),d=this.indexDb.prepare("\n INSERT INTO chunk_utility (chunk_id, importance)\n VALUES (?, ?)\n ON CONFLICT(chunk_id) DO UPDATE SET\n importance = excluded.importance\n "),l=this.indexDb.prepare("INSERT OR IGNORE INTO chunk_entities (chunk_id, concept_id) VALUES (?, ?)"),u=this.indexDb.prepare("DELETE FROM chunk_entities WHERE chunk_id IN (SELECT id FROM chunks WHERE doc_path = ?)"),p=this.indexDb.prepare("SELECT path FROM documents").all().map(e=>e.path),m=new Set(t.map(e=>e.relPath));for(const e of p)if(!m.has(e)){const t=a.all(e);for(const{id:e}of t)try{this.indexHnsw?.markDelete(e)}catch{}h.run(e),r.run(e),this.indexDb.prepare("DELETE FROM documents WHERE path = ?").run(e),T.debug(`Removed deleted file from index: ${e}`)}for(const e of t){const t=c.get(e.relPath);if(t&&t.mtime_ms===e.mtimeMs&&t.size===e.size){s+=this.indexDb.prepare("SELECT COUNT(*) as c FROM chunks WHERE doc_path = ?").get(e.relPath).c;continue}const p=w(n(e.fullPath,"utf-8"),e.relPath,e.sessionKey),m=a.all(e.relPath);for(const{id:e}of m)try{this.indexHnsw?.markDelete(e)}catch{}u.run(e.relPath),h.run(e.relPath),r.run(e.relPath);this.indexDb.transaction(()=>{for(let t=0;t<p.length;t++){const n=p[t],s=o.run(e.relPath,t,n.role,n.timestamp,n.sessionKey,n.content),i=Number(s.lastInsertRowid),c=D(n.content,e.relPath);if(5!==c&&d.run(i,c),this.conceptStore){const e=this.extractEntities(n.content);for(const t of e)l.run(i,t)}}i.run(e.relPath,e.mtimeMs,e.size)})(),s+=p.length,T.debug(`Indexed ${e.relPath}: ${p.length} chunks`)}T.info(`Indexed ${s} chunks from ${t.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)),c=this.opts.prefixDocument.trim();c&&T.debug(`Using prefixDocument (template: ${c}) → 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=h(this.dataDir,".memory-search-next.tmp"),n=h(this.dataDir,".memory-vectors-search-next.tmp");try{this.indexDb.pragma("wal_checkpoint(TRUNCATE)"),i(this.indexDbPath,t),c(t,this.searchNextDbPath),e(this.indexHnswPath)&&(i(this.indexHnswPath,n),c(n,this.searchNextHnswPath)),T.debug("Published search snapshot (DB + HNSW)")}catch(e){T.error(`Failed to publish snapshot: ${e}`);try{r(t)}catch{}try{r(n)}catch{}}}maybeSwap(){if(e(this.searchNextDbPath))try{this.searchDb&&(this.searchDb.close(),this.searchDb=null),this.searchHnsw=null,c(this.searchNextDbPath,this.searchDbPath),e(this.searchNextHnswPath)&&c(this.searchNextHnswPath,this.searchHnswPath),this.searchDb=new u(this.searchDbPath),this.searchDb.pragma("journal_mode = WAL"),this.migrateChunkUtility(),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 u(this.searchDbPath))}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 D(e,t){let n=5;return"MEMORY.md"===t&&(n=9),t.startsWith("knowledge/")&&(n=8),/\b(decid|commit|important|critical|must|priority|never|always)\b/i.test(e)&&(n=Math.max(n,7)),/\b(password|key|token|secret|wallet|address)\b/i.test(e)&&(n=Math.max(n,8)),/\b(ok|thanks|got it|sure|yes|no|grazie|va bene)\b/i.test(e)&&e.length<80&&(n=Math.min(n,2)),/\d{4}-\d{2}-\d{2}/.test(e)&&(n=Math.max(n,6)),/https?:\/\//.test(e)&&(n=Math.max(n,6)),/\b(TODO|FIXME|HACK|BUG)\b/.test(e)&&(n=Math.max(n,7)),Math.min(10,Math.max(1,n))}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]:"",c=t?t[2]:"",r=t?e.slice(t[0].length).trim():e.trim();if(!r)continue;const a=1500,h=100;if(r.length<=a)s.push({role:i,timestamp:c,sessionKey:n,content:r});else{let e=0;for(;e<r.length;){const t=Math.min(e+a,r.length),o=r.slice(e,t);if(s.push({role:i,timestamp:c,sessionKey:n,content:o}),e=t-h,e+h>=r.length)break}}}return s}function _(e){const n=[];return function i(c){let r;try{r=t(c,{withFileTypes:!0})}catch{return}for(const t of r){const r=h(c,t.name);if(t.isDirectory())i(r);else if(t.name.endsWith(".md"))try{const t=s(r),i=o(e,r),c=d(l(r));n.push({fullPath:r,relPath:i,sessionKey:c===d(e)?"":c,mtimeMs:Math.floor(t.mtimeMs),size:t.size})}catch{}}}(e),n}function S(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 c=e.size+t.size-n;return 0===c?0:n/c}
|
|
1
|
+
import{existsSync as e,readdirSync as t,readFileSync as n,statSync as s,copyFileSync as i,renameSync as c,unlinkSync as r,watch as a}from"node:fs";import{join as o,relative as h,basename as d,dirname as u}from"node:path";import l from"better-sqlite3";import p from"openai";import m from"hnswlib-node";const{HierarchicalNSW:E}=m;import{createLogger as f}from"../utils/logger.js";import{L0Generator as b,L0_DEFAULT as g}from"./l0-generator.js";const T=f("MemorySearch"),x="cosine",D={draft:1,validated:1.03,core:1.07};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;conceptStore=null;conceptCache=null;conceptCacheMap=null;conceptCacheTime=0;queryCache=[];dbVersion=0;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 p({apiKey:n.apiKey,...n.baseURL?{baseURL:n.baseURL}:{}})),this.l0Generator=new b(n.l0??g)}setConceptStore(e){this.conceptStore=e,T.info("ConceptStore connected — spreading activation enabled"),this.populateChunkEntities()}getConceptCacheEntries(){if(this.conceptCache&&Date.now()-this.conceptCacheTime<36e5)return this.conceptCache;if(!this.conceptStore)return[];const e=this.conceptStore.getConceptCacheData();this.conceptCache=e.filter(e=>e.label.length>=3).map(e=>({id:e.id,label:e.label,labelLower:e.label.toLowerCase(),fan:e.fan,sMax:e.sMax??1.6})),this.conceptCacheMap=new Map;for(const e of this.conceptCache)this.conceptCacheMap.set(e.id,e);return this.conceptCacheTime=Date.now(),T.info(`Concept cache refreshed: ${this.conceptCache.length} entries`),this.conceptCache}extractEntities(e){const t=this.getConceptCacheEntries();if(0===t.length)return[];const n=e.toLowerCase(),s=[];for(const e of t)e.fan>30||n.includes(e.labelLower)&&s.push(e.id);return s}computeSpreadingActivation(e,t,n){if(0===e.length||0===t.length)return 0;const s=this.conceptCacheMap;if(!s)return 0;const i=1/e.length,c=new Set(t);let r=0;for(const t of e){const e=s.get(t);if(!e)continue;const a=Math.max(e.sMax-Math.log(e.fan+1),0);c.has(t)&&(r+=i*a);const o=n.get(t);if(o)for(const{neighbor:e}of o)if(e!==t&&c.has(e)){const t=s.get(e);if(!t)continue;r+=i*Math.max(t.sMax-Math.log(t.fan+1),0)*.5}}return r}populateChunkEntities(){if(!this.indexDb||!this.conceptStore)return;const e=this.indexDb.prepare("SELECT COUNT(*) as c FROM chunk_entities").get().c;if(e>0)return void T.debug(`chunk_entities already populated (${e} rows)`);if(0===this.getConceptCacheEntries().length)return;const t=this.indexDb.prepare("SELECT id, content FROM chunks").all();if(0===t.length)return;const n=this.indexDb.prepare("INSERT OR IGNORE INTO chunk_entities (chunk_id, concept_id) VALUES (?, ?)");let s=0;this.indexDb.transaction(()=>{for(const e of t){const t=this.extractEntities(e.content);for(const i of t)n.run(e.id,i),s++}})(),this.publishSnapshot(),T.info(`Phase 3: Populated chunk_entities — ${s} mappings for ${t.length} chunks`)}getChunkEntities(e,t){if(this.searchDb){const t=this.searchDb.prepare("SELECT concept_id FROM chunk_entities WHERE chunk_id = ?").all(e);if(t.length>0)return t.map(e=>e.concept_id)}return this.extractEntities(t)}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 l(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 -- Phase 3: Pre-computed entity→chunk mapping for spreading activation\n CREATE TABLE IF NOT EXISTS chunk_entities (\n chunk_id INTEGER NOT NULL,\n concept_id TEXT NOT NULL,\n PRIMARY KEY (chunk_id, concept_id)\n );\n CREATE INDEX IF NOT EXISTS idx_chunk_entities_concept ON chunk_entities(concept_id);\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 importance INTEGER DEFAULT 5,\n access_times TEXT DEFAULT '[]',\n maturity TEXT DEFAULT 'draft'\n );\n"),this.migrateChunkUtility(),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)),this.conceptStore&&this.populateChunkEntities(),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{r(this.indexHnswPath)}catch{}}}migrateChunkUtility(){for(const e of[this.indexDb,this.searchDb]){if(!e)continue;if(!e.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='chunk_utility'").get())continue;const t=e.prepare("PRAGMA table_info(chunk_utility)").all(),n=new Set(t.map(e=>e.name));n.has("importance")||(T.info("Migrating chunk_utility: adding importance column"),e.exec("ALTER TABLE chunk_utility ADD COLUMN importance INTEGER DEFAULT 5")),n.has("access_times")||(T.info("Migrating chunk_utility: adding access_times column"),e.exec("ALTER TABLE chunk_utility ADD COLUMN access_times TEXT DEFAULT '[]'")),n.has("maturity")||(T.info("Migrating chunk_utility: adding maturity column"),e.exec("ALTER TABLE chunk_utility ADD COLUMN maturity TEXT DEFAULT 'draft'"))}}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,c=String(this.opts.embeddingDimensions),a=void 0!==n&&n!==i,o=void 0!==s&&s!==c;if(a||o){const e=[];a&&e.push(`model: ${n} → ${i}`),o&&e.push(`dimensions: ${s} → ${c}`),T.info(`Embedding config changed (${e.join(", ")}). Wiping embeddings + HNSW for full re-embed.`),this.indexDb.exec("DELETE FROM embeddings");try{r(this.indexHnswPath)}catch{}}t.run("embedding_model",i),t.run("embedding_dimensions",c)}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.cacheLookup(e,n);if(s)return s;const i=this.bm25Search(e,20);let c=[];try{const t=await this.embedText(e);t&&this.searchHnsw&&this.searchHnsw.getCurrentCount()>0&&(c=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}(i,c,this.opts.rrfK),a=Math.min(r.length,40),o=this.searchDb.prepare("SELECT doc_path, role, timestamp, session_key, content, l0 FROM chunks WHERE id = ?"),h=[];for(let e=0;e<a;e++){const{id:t,score:n}=r[e],s=o.get(t);s&&h.push({id:t,score:n,row:s})}const d=36e5,u=this.getUtilityScores(h.map(e=>e.id)),l=Date.now();for(const e of h){const t=u.get(e.id),n=t?.access_times??[],s=t?.importance??5;let i=0;if(n.length>0)for(const e of n){const t=Math.max((l-e)/d,.1);i+=Math.pow(t,-.5)}else if(e.row.timestamp){const t=new Date(e.row.timestamp).getTime();if(!isNaN(t)){const e=Math.max((l-t)/d,.1);i=Math.pow(e,-.5)}}if(i>0){const t=Math.log(i),n=.02*(s-5);e.score+=.01*t+n}}if(this.conceptStore){const t=this.extractEntities(e);if(t.length>0){const e=this.conceptStore.getNeighbors(t);let n=0;for(const s of h){const i=this.getChunkEntities(s.id,s.row.content),c=this.computeSpreadingActivation(t,i,e);c>0&&(s.score+=.015*c,n++)}n>0&&T.debug(`Spreading activation: boosted ${n}/${h.length} candidates (query entities: [${t.join(", ")}])`)}}for(const e of h){const t=u.get(e.id),n=D[t?.maturity??"draft"];1!==n&&(e.score*=n)}h.sort((e,t)=>t.score-e.score);const p=function(e,t,n){if(e.length<=t)return e;const s=e.map(e=>S(e.row.content)),i=e[0]?.score??1,c=e[e.length-1]?.score??0,r=i-c||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 h of o){const o=(e[h].score-c)/r;let d=0;for(const e of a){const t=R(s[h],s[e]);t>d&&(d=t)}const u=n*o-(1-n)*d;u>i&&(i=u,t=h)}if(t<0)break;a.push(t),o.delete(t)}return a.map(t=>e[t])}(h,n,.7),m=[];for(const{id:e,score:t,row:n}of p){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 this.cacheStore(e,m),T.info(`Search "${e.slice(0,60)}": ${m.length} results (sparse=${i.length}, dense=${c.length}, candidates=${h.length}, mmr=${p.length})`),m}recordAccess(e){const t=this.indexDb??this.searchDb;if(!t||0===e.length)return;const n=(new Date).toISOString(),s=Date.now(),i=t.prepare("\n INSERT INTO chunk_utility (chunk_id, access_count, last_accessed, first_accessed, access_times)\n VALUES (?, 1, ?, ?, ?)\n ON CONFLICT(chunk_id) DO UPDATE SET\n access_count = access_count + 1,\n last_accessed = excluded.last_accessed,\n access_times = excluded.access_times\n "),c=t.prepare("SELECT access_count, access_times, maturity FROM chunk_utility WHERE chunk_id = ?"),r=t.prepare("UPDATE chunk_utility SET maturity = ? WHERE chunk_id = ?");t.transaction(()=>{for(const t of e){const e=c.get(t);let a=[];if(e?.access_times)try{a=JSON.parse(e.access_times)}catch{}a.push(s),a.length>50&&(a=a.slice(-50)),i.run(t,n,n,JSON.stringify(a));const o=(e?.access_count??0)+1,h=e?.maturity??"draft",d=y(h,o);d!==h&&(r.run(d,t),T.debug(`Chunk ${t} maturity: ${h} → ${d} (access_count=${o})`))}})(),T.debug(`Recorded access for ${e.length} chunks`)}getRecentlyAccessedChunks(e=7,t=100){if(!this.searchDb)return[];if(!this.searchDb.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='chunk_utility'").get())return[];const n=new Date(Date.now()-864e5*e).toISOString();return this.searchDb.prepare("\n SELECT cu.chunk_id, c.content, c.doc_path, cu.importance, cu.access_count, cu.last_accessed\n FROM chunk_utility cu\n JOIN chunks c ON c.id = cu.chunk_id\n WHERE cu.last_accessed >= ? AND cu.access_count > 0\n ORDER BY cu.access_count DESC, cu.last_accessed DESC\n LIMIT ?\n ").all(n,t).map(e=>({chunkId:e.chunk_id,content:e.content.slice(0,500),path:e.doc_path,importance:e.importance??5,accessCount:e.access_count,lastAccessed:e.last_accessed}))}updateImportance(e){const t=this.indexDb??this.searchDb;if(!t||0===e.length)return 0;const n=t.prepare("UPDATE chunk_utility SET importance = ? WHERE chunk_id = ?");let s=0;return t.transaction(()=>{for(const{chunkId:t,importance:i}of e){const e=Math.max(1,Math.min(10,Math.round(i)));n.run(e,t).changes>0&&s++}})(),T.info(`Updated importance for ${s}/${e.length} chunks`),s}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, importance, access_times, maturity FROM chunk_utility WHERE chunk_id IN (${n})`).all(...e);for(const e of s){let n=[];if(e.access_times)try{n=JSON.parse(e.access_times)}catch{}const s="validated"===e.maturity||"core"===e.maturity?e.maturity:"draft";t.set(e.chunk_id,{access_count:e.access_count,last_accessed:e.last_accessed,first_accessed:e.first_accessed,importance:e.importance??5,access_times:n,maturity:s})}return t}readFile(t,s,i){const c=o(this.memoryDir,t);if(!e(c))return{path:t,content:`[File not found: ${t}]`};const r=n(c,"utf-8"),a=r.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:r}}listFiles(e,t){const n=k(this.memoryDir),s=[];for(const i of n){const n=d(i.relPath).match(/^(\d{4}-\d{2}-\d{2})/);if(!n)continue;const c=n[1];c>=e&&c<=t&&s.push({path:i.relPath,sessionKey:i.sessionKey,size:i.size})}return s.sort((e,t)=>e.path.localeCompare(t.path)),s}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 t=k(this.memoryDir);let s=0;const i=this.indexDb.prepare("INSERT OR REPLACE INTO documents (path, mtime_ms, size) VALUES (?, ?, ?)"),c=this.indexDb.prepare("SELECT mtime_ms, size FROM documents WHERE path = ?"),r=this.indexDb.prepare("DELETE FROM chunks WHERE doc_path = ?"),a=this.indexDb.prepare("SELECT id FROM chunks WHERE doc_path = ?"),o=this.indexDb.prepare("DELETE FROM embeddings WHERE chunk_id IN (SELECT id FROM chunks WHERE doc_path = ?)"),h=this.indexDb.prepare("INSERT INTO chunks (doc_path, chunk_idx, role, timestamp, session_key, content) VALUES (?, ?, ?, ?, ?, ?)"),d=this.indexDb.prepare("\n INSERT INTO chunk_utility (chunk_id, importance)\n VALUES (?, ?)\n ON CONFLICT(chunk_id) DO UPDATE SET\n importance = excluded.importance\n "),u=this.indexDb.prepare("INSERT OR IGNORE INTO chunk_entities (chunk_id, concept_id) VALUES (?, ?)"),l=this.indexDb.prepare("DELETE FROM chunk_entities WHERE chunk_id IN (SELECT id FROM chunks WHERE doc_path = ?)"),p=this.indexDb.prepare("SELECT path FROM documents").all().map(e=>e.path),m=new Set(t.map(e=>e.relPath));for(const e of p)if(!m.has(e)){const t=a.all(e);for(const{id:e}of t)try{this.indexHnsw?.markDelete(e)}catch{}o.run(e),r.run(e),this.indexDb.prepare("DELETE FROM documents WHERE path = ?").run(e),T.debug(`Removed deleted file from index: ${e}`)}for(const e of t){const t=c.get(e.relPath);if(t&&t.mtime_ms===e.mtimeMs&&t.size===e.size){s+=this.indexDb.prepare("SELECT COUNT(*) as c FROM chunks WHERE doc_path = ?").get(e.relPath).c;continue}const p=_(n(e.fullPath,"utf-8"),e.relPath,e.sessionKey),m=a.all(e.relPath);for(const{id:e}of m)try{this.indexHnsw?.markDelete(e)}catch{}l.run(e.relPath),o.run(e.relPath),r.run(e.relPath);this.indexDb.transaction(()=>{for(let t=0;t<p.length;t++){const n=p[t],s=h.run(e.relPath,t,n.role,n.timestamp,n.sessionKey,n.content),i=Number(s.lastInsertRowid),c=w(n.content,e.relPath);if(5!==c&&d.run(i,c),this.conceptStore){const e=this.extractEntities(n.content);for(const t of e)u.run(i,t)}}i.run(e.relPath,e.mtimeMs,e.size)})(),s+=p.length,T.debug(`Indexed ${e.relPath}: ${p.length} chunks`)}T.info(`Indexed ${s} chunks from ${t.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)),c=this.opts.prefixDocument.trim();c&&T.debug(`Using prefixDocument (template: ${c}) → 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),c(t,this.searchNextDbPath),e(this.indexHnswPath)&&(i(this.indexHnswPath,n),c(n,this.searchNextHnswPath)),T.debug("Published search snapshot (DB + HNSW)")}catch(e){T.error(`Failed to publish snapshot: ${e}`);try{r(t)}catch{}try{r(n)}catch{}}}maybeSwap(){if(e(this.searchNextDbPath))try{this.searchDb&&(this.searchDb.close(),this.searchDb=null),this.searchHnsw=null,c(this.searchNextDbPath,this.searchDbPath),e(this.searchNextHnswPath)&&c(this.searchNextHnswPath,this.searchHnswPath),this.searchDb=new l(this.searchDbPath),this.searchDb.pragma("journal_mode = WAL"),this.migrateChunkUtility(),this.dbVersion++,this.queryCache=[],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 l(this.searchDbPath))}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}}cacheLookup(e,t){const n=Date.now();this.queryCache=this.queryCache.filter(e=>e.dbVersion===this.dbVersion&&n-e.timestamp<6e5);const s=this.queryCache.find(t=>t.query===e);if(s)return T.info(`Cache T0 (exact) hit for "${e.slice(0,60)}"`),s.results.slice(0,t);const i=S(e);if(0===i.size)return null;let c=null,r=0;for(const e of this.queryCache){const t=R(i,e.tokens);t>r&&(r=t,c=e)}return c&&r>=.75?(T.info(`Cache T1 (fuzzy, sim=${r.toFixed(2)}) hit for "${e.slice(0,60)}" ≈ "${c.query.slice(0,60)}"`),c.results.slice(0,t)):null}cacheStore(e,t){this.queryCache.length>=200&&this.queryCache.shift(),this.queryCache.push({query:e,tokens:S(e),results:t,timestamp:Date.now(),dbVersion:this.dbVersion})}}function y(e,t){switch(e){case"draft":return t>=3?"validated":"draft";case"validated":return t>=10?"core":t<1?"draft":"validated";case"core":return t<7?"validated":"core";default:return"draft"}}function w(e,t){let n=5;return"MEMORY.md"===t&&(n=9),t.startsWith("knowledge/")&&(n=8),/\b(decid|commit|important|critical|must|priority|never|always)\b/i.test(e)&&(n=Math.max(n,7)),/\b(password|key|token|secret|wallet|address)\b/i.test(e)&&(n=Math.max(n,8)),/\b(ok|thanks|got it|sure|yes|no|grazie|va bene)\b/i.test(e)&&e.length<80&&(n=Math.min(n,2)),/\d{4}-\d{2}-\d{2}/.test(e)&&(n=Math.max(n,6)),/https?:\/\//.test(e)&&(n=Math.max(n,6)),/\b(TODO|FIXME|HACK|BUG)\b/.test(e)&&(n=Math.max(n,7)),Math.min(10,Math.max(1,n))}function _(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]:"",c=t?t[2]:"",r=t?e.slice(t[0].length).trim():e.trim();if(!r)continue;const a=1500,o=100;if(r.length<=a)s.push({role:i,timestamp:c,sessionKey:n,content:r});else{let e=0;for(;e<r.length;){const t=Math.min(e+a,r.length),h=r.slice(e,t);if(s.push({role:i,timestamp:c,sessionKey:n,content:h}),e=t-o,e+o>=r.length)break}}}return s}function k(e){const n=[];return function i(c){let r;try{r=t(c,{withFileTypes:!0})}catch{return}for(const t of r){const r=o(c,t.name);if(t.isDirectory())i(r);else if(t.name.endsWith(".md"))try{const t=s(r),i=h(e,r),c=d(u(r));n.push({fullPath:r,relPath:i,sessionKey:c===d(e)?"":c,mtimeMs:Math.floor(t.mtimeMs),size:t.size})}catch{}}}(e),n}function S(e){const t=new Set;for(const n of e.toLowerCase().matchAll(/\b\w{2,}\b/g))t.add(n[0]);return t}function R(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 c=e.size+t.size-n;return 0===c?0:n/c}
|
package/installationPkg/SOUL.md
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
**Be resourceful before asking.** Try to figure it out. Read the file. Check the context. Search for it. *Then* ask if you're stuck. The goal is to come back with answers, not questions.
|
|
12
12
|
|
|
13
|
-
**Never guess verifiable data.**
|
|
13
|
+
**Never guess verifiable data.** Verification rules are specific if-then triggers (IF-1 through IF-8 in SYSTEM_PROMPT.md Integrity section). Each maps to a real past failure. The general principle: if you can check it, check BEFORE answering. But the implementation is behavioral, not philosophical — specific cues trigger specific verification actions.
|
|
14
14
|
|
|
15
15
|
**Earn trust through competence.** Your human gave you access to their stuff. Don't make them regret it. Be careful with external actions (emails, tweets, anything public). Be bold with internal ones (reading, organizing, learning).
|
|
16
16
|
|