Files
claude-mem/plugin/scripts/new-hook.js
Alex Newman 52d2f72a82 Standardize and enhance error handling across hooks and worker service (#295)
* Enhance error logging in hooks

- Added detailed error logging in context-hook, new-hook, save-hook, and summary-hook to capture status, project, port, and relevant session information on failures.
- Improved error messages thrown in save-hook and summary-hook to include specific context about the failure.

* Refactor migration logging to use console.log instead of console.error

- Updated SessionSearch and SessionStore classes to replace console.error with console.log for migration-related messages.
- Added notes in the documentation to clarify the use of console.log for migration messages due to the unavailability of the structured logger during constructor execution.

* Refactor SDKAgent and silent-debug utility to simplify error handling

- Updated SDKAgent to use direct defaults instead of happy_path_error__with_fallback for non-critical fields such as last_user_message, last_assistant_message, title, filesRead, filesModified, concepts, and summary.request.
- Enhanced silent-debug documentation to clarify appropriate use cases for happy_path_error__with_fallback, emphasizing its role in handling unexpected null/undefined values while discouraging its use for nullable fields with valid defaults.

* fix: correct happy_path_error__with_fallback usage to prevent false errors

Fixes false "Missing cwd" and "Missing transcript_path" errors that were
flooding silent.log even when values were present.

Root cause: happy_path_error__with_fallback was being called unconditionally
instead of only when the value was actually missing.

Pattern changed from:
  value: happy_path_error__with_fallback('Missing', {}, value || '')

To correct usage:
  value: value || happy_path_error__with_fallback('Missing', {}, '')

Fixed in:
- src/hooks/save-hook.ts (PostToolUse hook)
- src/hooks/summary-hook.ts (Stop hook)
- src/services/worker/http/routes/SessionRoutes.ts (2 instances)

Impact: Eliminates false error noise, making actual errors visible.

Addresses issue #260 - users were seeing "Missing cwd" errors despite
Claude Code correctly passing all required fields.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* Enhance error logging and handling across services

- Improved error messages in SessionStore to include project context when fetching boundary observations and timestamps.
- Updated ChromaSync error handling to provide more informative messages regarding client initialization failures, including the project context.
- Enhanced error logging in WorkerService to include the package path when reading version fails.
- Added detailed error logging in worker-utils to capture expected and running versions during health checks.
- Extended WorkerErrorMessageOptions to include actualError for more informative restart instructions.

* Refactor error handling in hooks to use standardized fetch error handler

- Introduced a new error handler `handleFetchError` in `shared/error-handler.ts` to standardize logging and user-facing error messages for fetch failures across hooks.
- Updated `context-hook.ts`, `new-hook.ts`, `save-hook.ts`, and `summary-hook.ts` to utilize the new error handler, improving consistency and maintainability.
- Removed redundant imports and error handling logic related to worker restart instructions from the hooks.

* feat: add comprehensive error handling tests for hooks and ChromaSync client

---------

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-13 23:25:43 -05:00

17 lines
14 KiB
JavaScript
Executable File

#!/usr/bin/env bun
import xt from"path";import{stdin as z}from"process";function tt(n,t,e){return n==="SessionStart"?t&&e.context?{continue:!0,suppressOutput:!0,hookSpecificOutput:{hookEventName:"SessionStart",additionalContext:e.context}}:{continue:!0,suppressOutput:!0}:n==="UserPromptSubmit"||n==="PostToolUse"?{continue:!0,suppressOutput:!0}:n==="Stop"?{continue:!0,suppressOutput:!0}:{continue:t,suppressOutput:!0,...e.reason&&!t?{stopReason:e.reason}:{}}}function A(n,t,e={}){let r=tt(n,t,e);return JSON.stringify(r)}import C from"path";import{homedir as Dt}from"os";import{spawnSync as Lt}from"child_process";import{existsSync as bt,writeFileSync as V,readFileSync as Rt,mkdirSync as wt}from"fs";import{readFileSync as nt,writeFileSync as ot,existsSync as st}from"fs";import{join as it}from"path";import{homedir as at}from"os";var et=["bugfix","feature","refactor","discovery","decision","change"],rt=["how-it-works","why-it-exists","what-changed","problem-solution","gotcha","pattern","trade-off"];var N=et.join(","),x=rt.join(",");var l=class{static DEFAULTS={CLAUDE_MEM_MODEL:"claude-haiku-4-5",CLAUDE_MEM_CONTEXT_OBSERVATIONS:"50",CLAUDE_MEM_WORKER_PORT:"37777",CLAUDE_MEM_WORKER_HOST:"127.0.0.1",CLAUDE_MEM_SKIP_TOOLS:"ListMcpResourcesTool,SlashCommand,Skill,TodoWrite,AskUserQuestion",CLAUDE_MEM_DATA_DIR:it(at(),".claude-mem"),CLAUDE_MEM_LOG_LEVEL:"INFO",CLAUDE_MEM_PYTHON_VERSION:"3.13",CLAUDE_CODE_PATH:"",CLAUDE_MEM_CONTEXT_SHOW_READ_TOKENS:"true",CLAUDE_MEM_CONTEXT_SHOW_WORK_TOKENS:"true",CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_AMOUNT:"true",CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_PERCENT:"true",CLAUDE_MEM_CONTEXT_OBSERVATION_TYPES:N,CLAUDE_MEM_CONTEXT_OBSERVATION_CONCEPTS:x,CLAUDE_MEM_CONTEXT_FULL_COUNT:"5",CLAUDE_MEM_CONTEXT_FULL_FIELD:"narrative",CLAUDE_MEM_CONTEXT_SESSION_COUNT:"10",CLAUDE_MEM_CONTEXT_SHOW_LAST_SUMMARY:"true",CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE:"false"};static getAllDefaults(){return{...this.DEFAULTS}}static get(t){return this.DEFAULTS[t]}static getInt(t){let e=this.get(t);return parseInt(e,10)}static getBool(t){return this.get(t)==="true"}static loadFromFile(t){if(!st(t))return this.getAllDefaults();let e=nt(t,"utf-8"),r=JSON.parse(e),o=r;if(r.env&&typeof r.env=="object"){o=r.env;try{ot(t,JSON.stringify(o,null,2),"utf-8"),p.info("SETTINGS","Migrated settings file from nested to flat schema",{settingsPath:t})}catch(i){p.warn("SETTINGS","Failed to auto-migrate settings file",{settingsPath:t},i)}}let s={...this.DEFAULTS};for(let i of Object.keys(this.DEFAULTS))o[i]!==void 0&&(s[i]=o[i]);return s}};var M=(s=>(s[s.DEBUG=0]="DEBUG",s[s.INFO=1]="INFO",s[s.WARN=2]="WARN",s[s.ERROR=3]="ERROR",s[s.SILENT=4]="SILENT",s))(M||{}),D=class{level=null;useColor;constructor(){this.useColor=process.stdout.isTTY??!1}getLevel(){if(this.level===null){let t=l.get("CLAUDE_MEM_LOG_LEVEL").toUpperCase();this.level=M[t]??1}return this.level}correlationId(t,e){return`obs-${t}-${e}`}sessionId(t){return`session-${t}`}formatData(t){if(t==null)return"";if(typeof t=="string")return t;if(typeof t=="number"||typeof t=="boolean")return t.toString();if(typeof t=="object"){if(t instanceof Error)return this.getLevel()===0?`${t.message}
${t.stack}`:t.message;if(Array.isArray(t))return`[${t.length} items]`;let e=Object.keys(t);return e.length===0?"{}":e.length<=3?JSON.stringify(t):`{${e.length} keys: ${e.slice(0,3).join(", ")}...}`}return String(t)}formatTool(t,e){if(!e)return t;try{let r=typeof e=="string"?JSON.parse(e):e;if(t==="Bash"&&r.command){let o=r.command.length>50?r.command.substring(0,50)+"...":r.command;return`${t}(${o})`}if(t==="Read"&&r.file_path){let o=r.file_path.split("/").pop()||r.file_path;return`${t}(${o})`}if(t==="Edit"&&r.file_path){let o=r.file_path.split("/").pop()||r.file_path;return`${t}(${o})`}if(t==="Write"&&r.file_path){let o=r.file_path.split("/").pop()||r.file_path;return`${t}(${o})`}return t}catch{return t}}log(t,e,r,o,s){if(t<this.getLevel())return;let i=new Date().toISOString().replace("T"," ").substring(0,23),u=M[t].padEnd(5),g=e.padEnd(6),c="";o?.correlationId?c=`[${o.correlationId}] `:o?.sessionId&&(c=`[session-${o.sessionId}] `);let a="";s!=null&&(this.getLevel()===0&&typeof s=="object"?a=`
`+JSON.stringify(s,null,2):a=" "+this.formatData(s));let O="";if(o){let{sessionId:Ht,sdkSessionId:Wt,correlationId:Ft,...U}=o;Object.keys(U).length>0&&(O=` {${Object.entries(U).map(([Q,Z])=>`${Q}=${Z}`).join(", ")}}`)}let v=`[${i}] [${u}] [${g}] ${c}${r}${O}${a}`;t===3?console.error(v):console.log(v)}debug(t,e,r,o){this.log(0,t,e,r,o)}info(t,e,r,o){this.log(1,t,e,r,o)}warn(t,e,r,o){this.log(2,t,e,r,o)}error(t,e,r,o){this.log(3,t,e,r,o)}dataIn(t,e,r,o){this.info(t,`\u2192 ${e}`,r,o)}dataOut(t,e,r,o){this.info(t,`\u2190 ${e}`,r,o)}success(t,e,r,o){this.info(t,`\u2713 ${e}`,r,o)}failure(t,e,r,o){this.error(t,`\u2717 ${e}`,r,o)}timing(t,e,r,o){this.info(t,`\u23F1 ${e}`,o,{duration:`${r}ms`})}},p=new D;var L={DEFAULT:5e3,HEALTH_CHECK:1e3,WORKER_STARTUP_WAIT:1e3,WORKER_STARTUP_RETRIES:15,WINDOWS_MULTIPLIER:1.5};function $(n){return process.platform==="win32"?Math.round(n*L.WINDOWS_MULTIPLIER):n}import{existsSync as w,readFileSync as gt,writeFileSync as Et,unlinkSync as _t,mkdirSync as K}from"fs";import{createWriteStream as dt}from"fs";import{join as T}from"path";import{spawn as St}from"child_process";import{homedir as Tt}from"os";import{join as m,dirname as ct,basename as ee}from"path";import{homedir as ut}from"os";import{fileURLToPath as pt}from"url";function lt(){return typeof __dirname<"u"?__dirname:ct(pt(import.meta.url))}var ie=lt(),f=l.get("CLAUDE_MEM_DATA_DIR"),b=process.env.CLAUDE_CONFIG_DIR||m(ut(),".claude"),ae=m(f,"archives"),ce=m(f,"logs"),ue=m(f,"trash"),pe=m(f,"backups"),le=m(f,"settings.json"),me=m(f,"claude-mem.db"),fe=m(f,"vector-db"),ge=m(b,"settings.json"),Ee=m(b,"commands"),_e=m(b,"CLAUDE.md");import{spawnSync as mt}from"child_process";import{existsSync as ft}from"fs";import{join as H}from"path";import{homedir as W}from"os";function R(){let n=process.platform==="win32";try{if(mt("bun",["--version"],{encoding:"utf-8",stdio:["pipe","pipe","pipe"],shell:n}).status===0)return"bun"}catch{}let t=n?[H(W(),".bun","bin","bun.exe")]:[H(W(),".bun","bin","bun"),"/usr/local/bin/bun","/opt/homebrew/bin/bun","/home/linuxbrew/.linuxbrew/bin/bun"];for(let e of t)if(ft(e))return e;return null}function F(){return R()!==null}var S=T(f,"worker.pid"),j=T(f,"logs"),B=T(Tt(),".claude","plugins","marketplaces","thedotmack"),ht=5e3,Ot=1e4,Ct=200,At=1e3,Mt=100,h=class{static async start(t){if(isNaN(t)||t<1024||t>65535)return{success:!1,error:`Invalid port ${t}. Must be between 1024 and 65535`};if(await this.isRunning())return{success:!0,pid:this.getPidInfo()?.pid};K(j,{recursive:!0});let e=T(B,"plugin","scripts","worker-service.cjs");if(!w(e))return{success:!1,error:`Worker script not found at ${e}`};let r=this.getLogFilePath();return this.startWithBun(e,r,t)}static isBunAvailable(){return F()}static async startWithBun(t,e,r){let o=R();if(!o)return{success:!1,error:"Bun is required but not found in PATH or common installation paths. Install from https://bun.sh"};try{let s=process.platform==="win32",i=St(o,[t],{detached:!0,stdio:["ignore","pipe","pipe"],env:{...process.env,CLAUDE_MEM_WORKER_PORT:String(r)},cwd:B,...s&&{windowsHide:!0}}),u=dt(e,{flags:"a"});return i.stdout?.pipe(u),i.stderr?.pipe(u),i.unref(),i.pid?(this.writePidFile({pid:i.pid,port:r,startedAt:new Date().toISOString(),version:process.env.npm_package_version||"unknown"}),this.waitForHealth(i.pid,r)):{success:!1,error:"Failed to get PID from spawned process"}}catch(s){return{success:!1,error:s instanceof Error?s.message:String(s)}}}static async stop(t=ht){let e=this.getPidInfo();if(!e)return!0;try{process.kill(e.pid,"SIGTERM"),await this.waitForExit(e.pid,t)}catch{try{process.kill(e.pid,"SIGKILL")}catch{}}return this.removePidFile(),!0}static async restart(t){return await this.stop(),this.start(t)}static async status(){let t=this.getPidInfo();if(!t)return{running:!1};let e=this.isProcessAlive(t.pid);return{running:e,pid:e?t.pid:void 0,port:e?t.port:void 0,uptime:e?this.formatUptime(t.startedAt):void 0}}static async isRunning(){let t=this.getPidInfo();if(!t)return!1;let e=this.isProcessAlive(t.pid);return e||this.removePidFile(),e}static getPidInfo(){try{if(!w(S))return null;let t=gt(S,"utf-8"),e=JSON.parse(t);return typeof e.pid!="number"||typeof e.port!="number"?null:e}catch{return null}}static writePidFile(t){K(f,{recursive:!0}),Et(S,JSON.stringify(t,null,2))}static removePidFile(){try{w(S)&&_t(S)}catch{}}static isProcessAlive(t){try{return process.kill(t,0),!0}catch{return!1}}static async waitForHealth(t,e,r=Ot){let o=Date.now();for(;Date.now()-o<r;){if(!this.isProcessAlive(t))return{success:!1,error:"Process died during startup"};try{if((await fetch(`http://127.0.0.1:${e}/health`,{signal:AbortSignal.timeout(At)})).ok)return{success:!0,pid:t}}catch{}await new Promise(s=>setTimeout(s,Ct))}return{success:!1,error:"Health check timed out"}}static async waitForExit(t,e){let r=Date.now();for(;Date.now()-r<e;){if(!this.isProcessAlive(t))return;await new Promise(o=>setTimeout(o,Mt))}throw new Error("Process did not exit within timeout")}static getLogFilePath(){let t=new Date().toISOString().slice(0,10);return T(j,`worker-${t}.log`)}static formatUptime(t){let e=new Date(t).getTime(),o=Date.now()-e,s=Math.floor(o/1e3),i=Math.floor(s/60),u=Math.floor(i/60),g=Math.floor(u/24);return g>0?`${g}d ${u%24}h`:u>0?`${u}h ${i%60}m`:i>0?`${i}m ${s%60}s`:`${s}s`}};function E(n={}){let{port:t,includeSkillFallback:e=!1,customPrefix:r,actualError:o}=n,s=process.platform==="win32",i=s?"%USERPROFILE%\\.claude\\plugins\\marketplaces\\thedotmack":"~/.claude/plugins/marketplaces/thedotmack",u=s?"Command Prompt or PowerShell":"Terminal",g=r||"Worker service connection failed.",c=t?` (port ${t})`:"",a=`${g}${c}
`;return a+=`To restart the worker:
`,a+=`1. Exit Claude Code completely
`,a+=`2. Open ${u}
`,a+=`3. Navigate to: ${i}
`,a+="4. Run: npm run worker:restart",e&&(a+=`
If that doesn't work, try: /troubleshoot`),o&&(a=`Worker Error: ${o}
${a}`),a}var X=C.join(Dt(),".claude","plugins","marketplaces","thedotmack"),Y=$(L.HEALTH_CHECK),d=null;function _(){if(d!==null)return d;try{let n=C.join(l.get("CLAUDE_MEM_DATA_DIR"),"settings.json"),t=l.loadFromFile(n);return d=parseInt(t.CLAUDE_MEM_WORKER_PORT,10),d}catch(n){return p.debug("SYSTEM","Failed to load port from settings, using default",{error:n}),d=parseInt(l.get("CLAUDE_MEM_WORKER_PORT"),10),d}}async function I(){try{let n=_();return(await fetch(`http://127.0.0.1:${n}/health`,{signal:AbortSignal.timeout(Y)})).ok}catch(n){return p.debug("SYSTEM","Worker health check failed",{error:n instanceof Error?n.message:String(n),errorType:n?.constructor?.name}),!1}}function It(){try{let n=C.join(X,"package.json");return JSON.parse(Rt(n,"utf-8")).version}catch(n){return p.debug("SYSTEM","Failed to read plugin version",{error:n instanceof Error?n.message:String(n)}),null}}async function kt(){try{let n=_(),t=await fetch(`http://127.0.0.1:${n}/api/version`,{signal:AbortSignal.timeout(Y)});return t.ok?(await t.json()).version:null}catch(n){return p.debug("SYSTEM","Failed to get worker version",{error:n instanceof Error?n.message:String(n)}),null}}async function G(){let n=It(),t=await kt();!n||!t||n!==t&&(p.info("SYSTEM","Worker version mismatch detected - restarting worker",{pluginVersion:n,workerVersion:t}),await h.restart(_()),await new Promise(e=>setTimeout(e,1e3)),await I()||p.error("SYSTEM","Worker failed to restart after version mismatch",{expectedVersion:n,runningVersion:t,port}))}async function yt(){let n=l.get("CLAUDE_MEM_DATA_DIR"),t=C.join(n,".pm2-migrated");if(wt(n,{recursive:!0}),!bt(t))try{Lt("pm2",["delete","claude-mem-worker"],{stdio:"ignore"}),V(t,new Date().toISOString(),"utf-8"),p.debug("SYSTEM","PM2 cleanup completed and marked")}catch{V(t,new Date().toISOString(),"utf-8")}let e=_(),r=await h.start(e);return r.success||p.error("SYSTEM","Failed to start worker",{platform:process.platform,port:e,error:r.error,marketplaceRoot:X}),r.success}async function J(){if(await I()){await G();return}if(!await yt()){let e=_();throw new Error(E({port:e,customPrefix:`Worker service failed to start on port ${e}.`}))}for(let e=0;e<5;e++)if(await new Promise(r=>setTimeout(r,500)),await I()){await G();return}let t=_();throw p.error("SYSTEM","Worker started but not responding to health checks"),new Error(E({port:t,customPrefix:`Worker service started but is not responding on port ${t}.`}))}import{appendFileSync as Pt}from"fs";import{homedir as vt}from"os";import{join as Ut}from"path";var Nt=Ut(vt(),".claude-mem","silent.log");function q(n,t,e=""){let r=new Date().toISOString(),u=((new Error().stack||"").split(`
`)[2]||"").match(/at\s+(?:.*\s+)?\(?([^:]+):(\d+):(\d+)\)?/),g=u?`${u[1].split("/").pop()}:${u[2]}`:"unknown",c=`[${r}] [HAPPY-PATH-ERROR] [${g}] ${n}`;if(t!==void 0)try{c+=` ${JSON.stringify(t)}`}catch(a){c+=` [stringify error: ${a}]`}c+=`
`;try{Pt(Nt,c)}catch(a){console.error("[silent-debug] Failed to write to log:",a)}return e}function k(n){throw n.cause?.code==="ECONNREFUSED"||n.code==="ConnectionRefused"||n.name==="TimeoutError"||n.message?.includes("fetch failed")||n.message?.includes("Unable to connect")?new Error(E()):n}function y(n,t,e){p.error("HOOK",`${e.operation} failed`,{status:n.status,...e},t);let r=e.toolName?`Failed ${e.operation} for ${e.toolName}: ${E()}`:`${e.operation} failed: ${E()}`;throw new Error(r)}async function $t(n){if(await J(),!n)throw new Error("newHook requires input");let{session_id:t,cwd:e,prompt:r}=n,o=xt.basename(e);q("[new-hook] Input received",{session_id:t,project:o,prompt_length:r?.length});let s=_(),i,u;try{let c=await fetch(`http://127.0.0.1:${s}/api/sessions/init`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({claudeSessionId:t,project:o,prompt:r}),signal:AbortSignal.timeout(5e3)});if(!c.ok){let O=await c.text();y(c,O,{hookName:"new",operation:"Session initialization",project:o,port:s})}let a=await c.json();if(i=a.sessionDbId,u=a.promptNumber,a.skipped&&a.reason==="private"){console.error(`[new-hook] Session ${i}, prompt #${u} (fully private - skipped)`),console.log(A("UserPromptSubmit",!0));return}console.error(`[new-hook] Session ${i}, prompt #${u}`)}catch(c){k(c)}let g=r.startsWith("/")?r.substring(1):r;try{let c=await fetch(`http://127.0.0.1:${s}/sessions/${i}/init`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({userPrompt:g,promptNumber:u}),signal:AbortSignal.timeout(5e3)});if(!c.ok){let a=await c.text();y(c,a,{hookName:"new",operation:"SDK agent start",project:o,port:s,sessionId:String(i)})}}catch(c){k(c)}console.log(A("UserPromptSubmit",!0))}var P="";z.on("data",n=>P+=n);z.on("end",async()=>{let n=P?JSON.parse(P):void 0;await $t(n)});