feat: Enhance summary hook to include last user message from transcript (#95)

* feat: Enhance summary hook to include last user message from transcript

- Added functionality to extract the last user message from a JSONL transcript file in the summary hook.
- Updated the summary hook to send the last user message along with the summary request.
- Modified the SDKSession interface to include an optional last_user_message field.
- Updated the summary prompt to incorporate the last user message in the output format.
- Refactored worker service to handle the last user message in the summarize queue.
- Enhanced session manager to track and broadcast processing status based on active sessions and queue depth.
- Improved error handling and logging for better traceability during transcript reading and processing.

* feat(worker): enhance processing status broadcasting and session management

- Added immediate broadcasting of processing status when a prompt is received.
- Implemented logging for generator completion in multiple locations.
- Updated `broadcastProcessingStatus` to include queue depth and active session count in logs.
- Modified session iterator to stop yielding messages after a summary is yielded, with appropriate logging.
This commit is contained in:
Alex Newman
2025-11-11 17:38:22 -05:00
committed by GitHub
parent ecb8b39f6d
commit 39fedfc5fc
8 changed files with 266 additions and 99 deletions
+41 -39
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env node
import{stdin as D}from"process";import X from"better-sqlite3";import{join as u,dirname as U,basename as V}from"path";import{homedir as L}from"os";import{existsSync as Z,mkdirSync as M}from"fs";import{fileURLToPath as w}from"url";function F(){return typeof __dirname<"u"?__dirname:U(w(import.meta.url))}var se=F(),m=process.env.CLAUDE_MEM_DATA_DIR||u(L(),".claude-mem"),N=process.env.CLAUDE_CONFIG_DIR||u(L(),".claude"),te=u(m,"archives"),re=u(m,"logs"),ne=u(m,"trash"),oe=u(m,"backups"),ie=u(m,"settings.json"),A=u(m,"claude-mem.db"),ae=u(m,"vector-db"),de=u(N,"settings.json"),pe=u(N,"commands"),ce=u(N,"CLAUDE.md");function C(d){M(d,{recursive:!0})}var O=(n=>(n[n.DEBUG=0]="DEBUG",n[n.INFO=1]="INFO",n[n.WARN=2]="WARN",n[n.ERROR=3]="ERROR",n[n.SILENT=4]="SILENT",n))(O||{}),I=class{level;useColor;constructor(){let e=process.env.CLAUDE_MEM_LOG_LEVEL?.toUpperCase()||"INFO";this.level=O[e]??1,this.useColor=process.stdout.isTTY??!1}correlationId(e,s){return`obs-${e}-${s}`}sessionId(e){return`session-${e}`}formatData(e){if(e==null)return"";if(typeof e=="string")return e;if(typeof e=="number"||typeof e=="boolean")return e.toString();if(typeof e=="object"){if(e instanceof Error)return this.level===0?`${e.message}
${e.stack}`:e.message;if(Array.isArray(e))return`[${e.length} items]`;let s=Object.keys(e);return s.length===0?"{}":s.length<=3?JSON.stringify(e):`{${s.length} keys: ${s.slice(0,3).join(", ")}...}`}return String(e)}formatTool(e,s){if(!s)return e;try{let t=typeof s=="string"?JSON.parse(s):s;if(e==="Bash"&&t.command){let r=t.command.length>50?t.command.substring(0,50)+"...":t.command;return`${e}(${r})`}if(e==="Read"&&t.file_path){let r=t.file_path.split("/").pop()||t.file_path;return`${e}(${r})`}if(e==="Edit"&&t.file_path){let r=t.file_path.split("/").pop()||t.file_path;return`${e}(${r})`}if(e==="Write"&&t.file_path){let r=t.file_path.split("/").pop()||t.file_path;return`${e}(${r})`}return e}catch{return e}}log(e,s,t,r,n){if(e<this.level)return;let o=new Date().toISOString().replace("T"," ").substring(0,23),i=O[e].padEnd(5),p=s.padEnd(6),_="";r?.correlationId?_=`[${r.correlationId}] `:r?.sessionId&&(_=`[session-${r.sessionId}] `);let E="";n!=null&&(this.level===0&&typeof n=="object"?E=`
`+JSON.stringify(n,null,2):E=" "+this.formatData(n));let T="";if(r){let{sessionId:l,sdkSessionId:S,correlationId:c,...a}=r;Object.keys(a).length>0&&(T=` {${Object.entries(a).map(([k,x])=>`${k}=${x}`).join(", ")}}`)}let g=`[${o}] [${i}] [${p}] ${_}${t}${T}${E}`;e===3?console.error(g):console.log(g)}debug(e,s,t,r){this.log(0,e,s,t,r)}info(e,s,t,r){this.log(1,e,s,t,r)}warn(e,s,t,r){this.log(2,e,s,t,r)}error(e,s,t,r){this.log(3,e,s,t,r)}dataIn(e,s,t,r){this.info(e,`\u2192 ${s}`,t,r)}dataOut(e,s,t,r){this.info(e,`\u2190 ${s}`,t,r)}success(e,s,t,r){this.info(e,`\u2713 ${s}`,t,r)}failure(e,s,t,r){this.error(e,`\u2717 ${s}`,t,r)}timing(e,s,t,r){this.info(e,`\u23F1 ${s}`,r,{duration:`${t}ms`})}},b=new I;var R=class{db;constructor(){C(m),this.db=new X(A),this.db.pragma("journal_mode = WAL"),this.db.pragma("synchronous = NORMAL"),this.db.pragma("foreign_keys = ON"),this.initializeSchema(),this.ensureWorkerPortColumn(),this.ensurePromptTrackingColumns(),this.removeSessionSummariesUniqueConstraint(),this.addObservationHierarchicalFields(),this.makeObservationsTextNullable(),this.createUserPromptsTable()}initializeSchema(){try{this.db.exec(`
import{stdin as D}from"process";import{readFileSync as Y,existsSync as K}from"fs";import X from"better-sqlite3";import{join as u,dirname as U,basename as z}from"path";import{homedir as L}from"os";import{existsSync as te,mkdirSync as M}from"fs";import{fileURLToPath as w}from"url";function F(){return typeof __dirname<"u"?__dirname:U(w(import.meta.url))}var ne=F(),E=process.env.CLAUDE_MEM_DATA_DIR||u(L(),".claude-mem"),N=process.env.CLAUDE_CONFIG_DIR||u(L(),".claude"),oe=u(E,"archives"),ie=u(E,"logs"),ae=u(E,"trash"),pe=u(E,"backups"),de=u(E,"settings.json"),A=u(E,"claude-mem.db"),ce=u(E,"vector-db"),_e=u(N,"settings.json"),ue=u(N,"commands"),me=u(N,"CLAUDE.md");function C(a){M(a,{recursive:!0})}var O=(n=>(n[n.DEBUG=0]="DEBUG",n[n.INFO=1]="INFO",n[n.WARN=2]="WARN",n[n.ERROR=3]="ERROR",n[n.SILENT=4]="SILENT",n))(O||{}),f=class{level;useColor;constructor(){let e=process.env.CLAUDE_MEM_LOG_LEVEL?.toUpperCase()||"INFO";this.level=O[e]??1,this.useColor=process.stdout.isTTY??!1}correlationId(e,s){return`obs-${e}-${s}`}sessionId(e){return`session-${e}`}formatData(e){if(e==null)return"";if(typeof e=="string")return e;if(typeof e=="number"||typeof e=="boolean")return e.toString();if(typeof e=="object"){if(e instanceof Error)return this.level===0?`${e.message}
${e.stack}`:e.message;if(Array.isArray(e))return`[${e.length} items]`;let s=Object.keys(e);return s.length===0?"{}":s.length<=3?JSON.stringify(e):`{${s.length} keys: ${s.slice(0,3).join(", ")}...}`}return String(e)}formatTool(e,s){if(!s)return e;try{let t=typeof s=="string"?JSON.parse(s):s;if(e==="Bash"&&t.command){let r=t.command.length>50?t.command.substring(0,50)+"...":t.command;return`${e}(${r})`}if(e==="Read"&&t.file_path){let r=t.file_path.split("/").pop()||t.file_path;return`${e}(${r})`}if(e==="Edit"&&t.file_path){let r=t.file_path.split("/").pop()||t.file_path;return`${e}(${r})`}if(e==="Write"&&t.file_path){let r=t.file_path.split("/").pop()||t.file_path;return`${e}(${r})`}return e}catch{return e}}log(e,s,t,r,n){if(e<this.level)return;let i=new Date().toISOString().replace("T"," ").substring(0,23),o=O[e].padEnd(5),d=s.padEnd(6),_="";r?.correlationId?_=`[${r.correlationId}] `:r?.sessionId&&(_=`[session-${r.sessionId}] `);let m="";n!=null&&(this.level===0&&typeof n=="object"?m=`
`+JSON.stringify(n,null,2):m=" "+this.formatData(n));let S="";if(r){let{sessionId:l,sdkSessionId:g,correlationId:c,...p}=r;Object.keys(p).length>0&&(S=` {${Object.entries(p).map(([k,x])=>`${k}=${x}`).join(", ")}}`)}let b=`[${i}] [${o}] [${d}] ${_}${t}${S}${m}`;e===3?console.error(b):console.log(b)}debug(e,s,t,r){this.log(0,e,s,t,r)}info(e,s,t,r){this.log(1,e,s,t,r)}warn(e,s,t,r){this.log(2,e,s,t,r)}error(e,s,t,r){this.log(3,e,s,t,r)}dataIn(e,s,t,r){this.info(e,`\u2192 ${s}`,t,r)}dataOut(e,s,t,r){this.info(e,`\u2190 ${s}`,t,r)}success(e,s,t,r){this.info(e,`\u2713 ${s}`,t,r)}failure(e,s,t,r){this.error(e,`\u2717 ${s}`,t,r)}timing(e,s,t,r){this.info(e,`\u23F1 ${s}`,r,{duration:`${t}ms`})}},T=new f;var R=class{db;constructor(){C(E),this.db=new X(A),this.db.pragma("journal_mode = WAL"),this.db.pragma("synchronous = NORMAL"),this.db.pragma("foreign_keys = ON"),this.initializeSchema(),this.ensureWorkerPortColumn(),this.ensurePromptTrackingColumns(),this.removeSessionSummariesUniqueConstraint(),this.addObservationHierarchicalFields(),this.makeObservationsTextNullable(),this.createUserPromptsTable()}initializeSchema(){try{this.db.exec(`
CREATE TABLE IF NOT EXISTS schema_versions (
id INTEGER PRIMARY KEY,
version INTEGER UNIQUE NOT NULL,
@@ -63,7 +63,7 @@ ${e.stack}`:e.message;if(Array.isArray(e))return`[${e.length} items]`;let s=Obje
CREATE INDEX IF NOT EXISTS idx_session_summaries_sdk_session ON session_summaries(sdk_session_id);
CREATE INDEX IF NOT EXISTS idx_session_summaries_project ON session_summaries(project);
CREATE INDEX IF NOT EXISTS idx_session_summaries_created ON session_summaries(created_at_epoch DESC);
`),this.db.prepare("INSERT INTO schema_versions (version, applied_at) VALUES (?, ?)").run(4,new Date().toISOString()),console.error("[SessionStore] Migration004 applied successfully"))}catch(e){throw console.error("[SessionStore] Schema initialization error:",e.message),e}}ensureWorkerPortColumn(){try{if(this.db.prepare("SELECT version FROM schema_versions WHERE version = ?").get(5))return;this.db.pragma("table_info(sdk_sessions)").some(r=>r.name==="worker_port")||(this.db.exec("ALTER TABLE sdk_sessions ADD COLUMN worker_port INTEGER"),console.error("[SessionStore] Added worker_port column to sdk_sessions table")),this.db.prepare("INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)").run(5,new Date().toISOString())}catch(e){console.error("[SessionStore] Migration error:",e.message)}}ensurePromptTrackingColumns(){try{if(this.db.prepare("SELECT version FROM schema_versions WHERE version = ?").get(6))return;this.db.pragma("table_info(sdk_sessions)").some(p=>p.name==="prompt_counter")||(this.db.exec("ALTER TABLE sdk_sessions ADD COLUMN prompt_counter INTEGER DEFAULT 0"),console.error("[SessionStore] Added prompt_counter column to sdk_sessions table")),this.db.pragma("table_info(observations)").some(p=>p.name==="prompt_number")||(this.db.exec("ALTER TABLE observations ADD COLUMN prompt_number INTEGER"),console.error("[SessionStore] Added prompt_number column to observations table")),this.db.pragma("table_info(session_summaries)").some(p=>p.name==="prompt_number")||(this.db.exec("ALTER TABLE session_summaries ADD COLUMN prompt_number INTEGER"),console.error("[SessionStore] Added prompt_number column to session_summaries table")),this.db.prepare("INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)").run(6,new Date().toISOString())}catch(e){console.error("[SessionStore] Prompt tracking migration error:",e.message)}}removeSessionSummariesUniqueConstraint(){try{if(this.db.prepare("SELECT version FROM schema_versions WHERE version = ?").get(7))return;if(!this.db.pragma("index_list(session_summaries)").some(r=>r.unique===1)){this.db.prepare("INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)").run(7,new Date().toISOString());return}console.error("[SessionStore] Removing UNIQUE constraint from session_summaries.sdk_session_id..."),this.db.exec("BEGIN TRANSACTION");try{this.db.exec(`
`),this.db.prepare("INSERT INTO schema_versions (version, applied_at) VALUES (?, ?)").run(4,new Date().toISOString()),console.error("[SessionStore] Migration004 applied successfully"))}catch(e){throw console.error("[SessionStore] Schema initialization error:",e.message),e}}ensureWorkerPortColumn(){try{if(this.db.prepare("SELECT version FROM schema_versions WHERE version = ?").get(5))return;this.db.pragma("table_info(sdk_sessions)").some(r=>r.name==="worker_port")||(this.db.exec("ALTER TABLE sdk_sessions ADD COLUMN worker_port INTEGER"),console.error("[SessionStore] Added worker_port column to sdk_sessions table")),this.db.prepare("INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)").run(5,new Date().toISOString())}catch(e){console.error("[SessionStore] Migration error:",e.message)}}ensurePromptTrackingColumns(){try{if(this.db.prepare("SELECT version FROM schema_versions WHERE version = ?").get(6))return;this.db.pragma("table_info(sdk_sessions)").some(d=>d.name==="prompt_counter")||(this.db.exec("ALTER TABLE sdk_sessions ADD COLUMN prompt_counter INTEGER DEFAULT 0"),console.error("[SessionStore] Added prompt_counter column to sdk_sessions table")),this.db.pragma("table_info(observations)").some(d=>d.name==="prompt_number")||(this.db.exec("ALTER TABLE observations ADD COLUMN prompt_number INTEGER"),console.error("[SessionStore] Added prompt_number column to observations table")),this.db.pragma("table_info(session_summaries)").some(d=>d.name==="prompt_number")||(this.db.exec("ALTER TABLE session_summaries ADD COLUMN prompt_number INTEGER"),console.error("[SessionStore] Added prompt_number column to session_summaries table")),this.db.prepare("INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)").run(6,new Date().toISOString())}catch(e){console.error("[SessionStore] Prompt tracking migration error:",e.message)}}removeSessionSummariesUniqueConstraint(){try{if(this.db.prepare("SELECT version FROM schema_versions WHERE version = ?").get(7))return;if(!this.db.pragma("index_list(session_summaries)").some(r=>r.unique===1)){this.db.prepare("INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)").run(7,new Date().toISOString());return}console.error("[SessionStore] Removing UNIQUE constraint from session_summaries.sdk_session_id..."),this.db.exec("BEGIN TRANSACTION");try{this.db.exec(`
CREATE TABLE session_summaries_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sdk_session_id TEXT NOT NULL,
@@ -244,12 +244,12 @@ ${e.stack}`:e.message;if(Array.isArray(e))return`[${e.length} items]`;let s=Obje
SELECT *
FROM observations
WHERE id = ?
`).get(e)||null}getObservationsByIds(e,s={}){if(e.length===0)return[];let{orderBy:t="date_desc",limit:r}=s,n=t==="date_asc"?"ASC":"DESC",o=r?`LIMIT ${r}`:"",i=e.map(()=>"?").join(",");return this.db.prepare(`
`).get(e)||null}getObservationsByIds(e,s={}){if(e.length===0)return[];let{orderBy:t="date_desc",limit:r}=s,n=t==="date_asc"?"ASC":"DESC",i=r?`LIMIT ${r}`:"",o=e.map(()=>"?").join(",");return this.db.prepare(`
SELECT *
FROM observations
WHERE id IN (${i})
WHERE id IN (${o})
ORDER BY created_at_epoch ${n}
${o}
${i}
`).all(...e)}getSummaryForSession(e){return this.db.prepare(`
SELECT
request, investigated, learned, completed, next_steps,
@@ -262,7 +262,7 @@ ${e.stack}`:e.message;if(Array.isArray(e))return`[${e.length} items]`;let s=Obje
SELECT files_read, files_modified
FROM observations
WHERE sdk_session_id = ?
`).all(e),r=new Set,n=new Set;for(let o of t){if(o.files_read)try{let i=JSON.parse(o.files_read);Array.isArray(i)&&i.forEach(p=>r.add(p))}catch{}if(o.files_modified)try{let i=JSON.parse(o.files_modified);Array.isArray(i)&&i.forEach(p=>n.add(p))}catch{}}return{filesRead:Array.from(r),filesModified:Array.from(n)}}getSessionById(e){return this.db.prepare(`
`).all(e),r=new Set,n=new Set;for(let i of t){if(i.files_read)try{let o=JSON.parse(i.files_read);Array.isArray(o)&&o.forEach(d=>r.add(d))}catch{}if(i.files_modified)try{let o=JSON.parse(i.files_modified);Array.isArray(o)&&o.forEach(d=>n.add(d))}catch{}}return{filesRead:Array.from(r),filesModified:Array.from(n)}}getSessionById(e){return this.db.prepare(`
SELECT id, claude_session_id, sdk_session_id, project, user_prompt
FROM sdk_sessions
WHERE id = ?
@@ -289,17 +289,17 @@ ${e.stack}`:e.message;if(Array.isArray(e))return`[${e.length} items]`;let s=Obje
SELECT prompt_counter FROM sdk_sessions WHERE id = ?
`).get(e)?.prompt_counter||1}getPromptCounter(e){return this.db.prepare(`
SELECT prompt_counter FROM sdk_sessions WHERE id = ?
`).get(e)?.prompt_counter||0}createSDKSession(e,s,t){let r=new Date,n=r.getTime(),i=this.db.prepare(`
`).get(e)?.prompt_counter||0}createSDKSession(e,s,t){let r=new Date,n=r.getTime(),o=this.db.prepare(`
INSERT OR IGNORE INTO sdk_sessions
(claude_session_id, sdk_session_id, project, user_prompt, started_at, started_at_epoch, status)
VALUES (?, ?, ?, ?, ?, ?, 'active')
`).run(e,e,s,t,r.toISOString(),n);return i.lastInsertRowid===0||i.changes===0?this.db.prepare(`
`).run(e,e,s,t,r.toISOString(),n);return o.lastInsertRowid===0||o.changes===0?this.db.prepare(`
SELECT id FROM sdk_sessions WHERE claude_session_id = ? LIMIT 1
`).get(e).id:i.lastInsertRowid}updateSDKSessionId(e,s){return this.db.prepare(`
`).get(e).id:o.lastInsertRowid}updateSDKSessionId(e,s){return this.db.prepare(`
UPDATE sdk_sessions
SET sdk_session_id = ?
WHERE id = ? AND sdk_session_id IS NULL
`).run(s,e).changes===0?(b.debug("DB","sdk_session_id already set, skipping update",{sessionId:e,sdkSessionId:s}),!1):!0}setWorkerPort(e,s){this.db.prepare(`
`).run(s,e).changes===0?(T.debug("DB","sdk_session_id already set, skipping update",{sessionId:e,sdkSessionId:s}),!1):!0}setWorkerPort(e,s){this.db.prepare(`
UPDATE sdk_sessions
SET worker_port = ?
WHERE id = ?
@@ -312,29 +312,29 @@ ${e.stack}`:e.message;if(Array.isArray(e))return`[${e.length} items]`;let s=Obje
INSERT INTO user_prompts
(claude_session_id, prompt_number, prompt_text, created_at, created_at_epoch)
VALUES (?, ?, ?, ?, ?)
`).run(e,s,t,r.toISOString(),n).lastInsertRowid}storeObservation(e,s,t,r){let n=new Date,o=n.getTime();this.db.prepare(`
`).run(e,s,t,r.toISOString(),n).lastInsertRowid}storeObservation(e,s,t,r){let n=new Date,i=n.getTime();this.db.prepare(`
SELECT id FROM sdk_sessions WHERE sdk_session_id = ?
`).get(e)||(this.db.prepare(`
INSERT INTO sdk_sessions
(claude_session_id, sdk_session_id, project, started_at, started_at_epoch, status)
VALUES (?, ?, ?, ?, ?, 'active')
`).run(e,e,s,n.toISOString(),o),console.error(`[SessionStore] Auto-created session record for session_id: ${e}`));let E=this.db.prepare(`
`).run(e,e,s,n.toISOString(),i),console.error(`[SessionStore] Auto-created session record for session_id: ${e}`));let m=this.db.prepare(`
INSERT INTO observations
(sdk_session_id, project, type, title, subtitle, facts, narrative, concepts,
files_read, files_modified, prompt_number, created_at, created_at_epoch)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(e,s,t.type,t.title,t.subtitle,JSON.stringify(t.facts),t.narrative,JSON.stringify(t.concepts),JSON.stringify(t.files_read),JSON.stringify(t.files_modified),r||null,n.toISOString(),o);return{id:Number(E.lastInsertRowid),createdAtEpoch:o}}storeSummary(e,s,t,r){let n=new Date,o=n.getTime();this.db.prepare(`
`).run(e,s,t.type,t.title,t.subtitle,JSON.stringify(t.facts),t.narrative,JSON.stringify(t.concepts),JSON.stringify(t.files_read),JSON.stringify(t.files_modified),r||null,n.toISOString(),i);return{id:Number(m.lastInsertRowid),createdAtEpoch:i}}storeSummary(e,s,t,r){let n=new Date,i=n.getTime();this.db.prepare(`
SELECT id FROM sdk_sessions WHERE sdk_session_id = ?
`).get(e)||(this.db.prepare(`
INSERT INTO sdk_sessions
(claude_session_id, sdk_session_id, project, started_at, started_at_epoch, status)
VALUES (?, ?, ?, ?, ?, 'active')
`).run(e,e,s,n.toISOString(),o),console.error(`[SessionStore] Auto-created session record for session_id: ${e}`));let E=this.db.prepare(`
`).run(e,e,s,n.toISOString(),i),console.error(`[SessionStore] Auto-created session record for session_id: ${e}`));let m=this.db.prepare(`
INSERT INTO session_summaries
(sdk_session_id, project, request, investigated, learned, completed,
next_steps, notes, prompt_number, created_at, created_at_epoch)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(e,s,t.request,t.investigated,t.learned,t.completed,t.next_steps,t.notes,r||null,n.toISOString(),o);return{id:Number(E.lastInsertRowid),createdAtEpoch:o}}markSessionCompleted(e){let s=new Date,t=s.getTime();this.db.prepare(`
`).run(e,s,t.request,t.investigated,t.learned,t.completed,t.next_steps,t.notes,r||null,n.toISOString(),i);return{id:Number(m.lastInsertRowid),createdAtEpoch:i}}markSessionCompleted(e){let s=new Date,t=s.getTime();this.db.prepare(`
UPDATE sdk_sessions
SET status = 'completed', completed_at = ?, completed_at_epoch = ?
WHERE id = ?
@@ -342,62 +342,64 @@ ${e.stack}`:e.message;if(Array.isArray(e))return`[${e.length} items]`;let s=Obje
UPDATE sdk_sessions
SET status = 'failed', completed_at = ?, completed_at_epoch = ?
WHERE id = ?
`).run(s.toISOString(),t,e)}getSessionSummariesByIds(e,s={}){if(e.length===0)return[];let{orderBy:t="date_desc",limit:r}=s,n=t==="date_asc"?"ASC":"DESC",o=r?`LIMIT ${r}`:"",i=e.map(()=>"?").join(",");return this.db.prepare(`
`).run(s.toISOString(),t,e)}getSessionSummariesByIds(e,s={}){if(e.length===0)return[];let{orderBy:t="date_desc",limit:r}=s,n=t==="date_asc"?"ASC":"DESC",i=r?`LIMIT ${r}`:"",o=e.map(()=>"?").join(",");return this.db.prepare(`
SELECT * FROM session_summaries
WHERE id IN (${i})
WHERE id IN (${o})
ORDER BY created_at_epoch ${n}
${o}
`).all(...e)}getUserPromptsByIds(e,s={}){if(e.length===0)return[];let{orderBy:t="date_desc",limit:r}=s,n=t==="date_asc"?"ASC":"DESC",o=r?`LIMIT ${r}`:"",i=e.map(()=>"?").join(",");return this.db.prepare(`
${i}
`).all(...e)}getUserPromptsByIds(e,s={}){if(e.length===0)return[];let{orderBy:t="date_desc",limit:r}=s,n=t==="date_asc"?"ASC":"DESC",i=r?`LIMIT ${r}`:"",o=e.map(()=>"?").join(",");return this.db.prepare(`
SELECT
up.*,
s.project,
s.sdk_session_id
FROM user_prompts up
JOIN sdk_sessions s ON up.claude_session_id = s.claude_session_id
WHERE up.id IN (${i})
WHERE up.id IN (${o})
ORDER BY up.created_at_epoch ${n}
${o}
`).all(...e)}getTimelineAroundTimestamp(e,s=10,t=10,r){return this.getTimelineAroundObservation(null,e,s,t,r)}getTimelineAroundObservation(e,s,t=10,r=10,n){let o=n?"AND project = ?":"",i=n?[n]:[],p,_;if(e!==null){let l=`
${i}
`).all(...e)}getTimelineAroundTimestamp(e,s=10,t=10,r){return this.getTimelineAroundObservation(null,e,s,t,r)}getTimelineAroundObservation(e,s,t=10,r=10,n){let i=n?"AND project = ?":"",o=n?[n]:[],d,_;if(e!==null){let l=`
SELECT id, created_at_epoch
FROM observations
WHERE id <= ? ${o}
WHERE id <= ? ${i}
ORDER BY id DESC
LIMIT ?
`,S=`
`,g=`
SELECT id, created_at_epoch
FROM observations
WHERE id >= ? ${o}
WHERE id >= ? ${i}
ORDER BY id ASC
LIMIT ?
`;try{let c=this.db.prepare(l).all(e,...i,t+1),a=this.db.prepare(S).all(e,...i,r+1);if(c.length===0&&a.length===0)return{observations:[],sessions:[],prompts:[]};p=c.length>0?c[c.length-1].created_at_epoch:s,_=a.length>0?a[a.length-1].created_at_epoch:s}catch(c){return console.error("[SessionStore] Error getting boundary observations:",c.message),{observations:[],sessions:[],prompts:[]}}}else{let l=`
`;try{let c=this.db.prepare(l).all(e,...o,t+1),p=this.db.prepare(g).all(e,...o,r+1);if(c.length===0&&p.length===0)return{observations:[],sessions:[],prompts:[]};d=c.length>0?c[c.length-1].created_at_epoch:s,_=p.length>0?p[p.length-1].created_at_epoch:s}catch(c){return console.error("[SessionStore] Error getting boundary observations:",c.message),{observations:[],sessions:[],prompts:[]}}}else{let l=`
SELECT created_at_epoch
FROM observations
WHERE created_at_epoch <= ? ${o}
WHERE created_at_epoch <= ? ${i}
ORDER BY created_at_epoch DESC
LIMIT ?
`,S=`
`,g=`
SELECT created_at_epoch
FROM observations
WHERE created_at_epoch >= ? ${o}
WHERE created_at_epoch >= ? ${i}
ORDER BY created_at_epoch ASC
LIMIT ?
`;try{let c=this.db.prepare(l).all(s,...i,t),a=this.db.prepare(S).all(s,...i,r+1);if(c.length===0&&a.length===0)return{observations:[],sessions:[],prompts:[]};p=c.length>0?c[c.length-1].created_at_epoch:s,_=a.length>0?a[a.length-1].created_at_epoch:s}catch(c){return console.error("[SessionStore] Error getting boundary timestamps:",c.message),{observations:[],sessions:[],prompts:[]}}}let E=`
`;try{let c=this.db.prepare(l).all(s,...o,t),p=this.db.prepare(g).all(s,...o,r+1);if(c.length===0&&p.length===0)return{observations:[],sessions:[],prompts:[]};d=c.length>0?c[c.length-1].created_at_epoch:s,_=p.length>0?p[p.length-1].created_at_epoch:s}catch(c){return console.error("[SessionStore] Error getting boundary timestamps:",c.message),{observations:[],sessions:[],prompts:[]}}}let m=`
SELECT *
FROM observations
WHERE created_at_epoch >= ? AND created_at_epoch <= ? ${o}
WHERE created_at_epoch >= ? AND created_at_epoch <= ? ${i}
ORDER BY created_at_epoch ASC
`,T=`
`,S=`
SELECT *
FROM session_summaries
WHERE created_at_epoch >= ? AND created_at_epoch <= ? ${o}
WHERE created_at_epoch >= ? AND created_at_epoch <= ? ${i}
ORDER BY created_at_epoch ASC
`,g=`
`,b=`
SELECT up.*, s.project, s.sdk_session_id
FROM user_prompts up
JOIN sdk_sessions s ON up.claude_session_id = s.claude_session_id
WHERE up.created_at_epoch >= ? AND up.created_at_epoch <= ? ${o.replace("project","s.project")}
WHERE up.created_at_epoch >= ? AND up.created_at_epoch <= ? ${i.replace("project","s.project")}
ORDER BY up.created_at_epoch ASC
`;try{let l=this.db.prepare(E).all(p,_,...i),S=this.db.prepare(T).all(p,_,...i),c=this.db.prepare(g).all(p,_,...i);return{observations:l,sessions:S.map(a=>({id:a.id,sdk_session_id:a.sdk_session_id,project:a.project,request:a.request,completed:a.completed,next_steps:a.next_steps,created_at:a.created_at,created_at_epoch:a.created_at_epoch})),prompts:c.map(a=>({id:a.id,claude_session_id:a.claude_session_id,project:a.project,prompt:a.prompt_text,created_at:a.created_at,created_at_epoch:a.created_at_epoch}))}}catch(l){return console.error("[SessionStore] Error querying timeline records:",l.message),{observations:[],sessions:[],prompts:[]}}}close(){this.db.close()}};function H(d,e,s){return d==="PreCompact"?e?{continue:!0,suppressOutput:!0}:{continue:!1,stopReason:s.reason||"Pre-compact operation failed",suppressOutput:!0}:d==="SessionStart"?e&&s.context?{continue:!0,suppressOutput:!0,hookSpecificOutput:{hookEventName:"SessionStart",additionalContext:s.context}}:{continue:!0,suppressOutput:!0}:d==="UserPromptSubmit"||d==="PostToolUse"?{continue:!0,suppressOutput:!0}:d==="Stop"?{continue:!0,suppressOutput:!0}:{continue:e,suppressOutput:!0,...s.reason&&!e?{stopReason:s.reason}:{}}}function y(d,e,s={}){let t=H(d,e,s);return JSON.stringify(t)}import B from"path";import{homedir as P}from"os";import{existsSync as j,readFileSync as $}from"fs";var W=100;function h(){try{let d=B.join(P(),".claude-mem","settings.json");if(j(d)){let e=JSON.parse($(d,"utf-8")),s=parseInt(e.env?.CLAUDE_MEM_WORKER_PORT,10);if(!isNaN(s))return s}}catch{}return parseInt(process.env.CLAUDE_MEM_WORKER_PORT||"37777",10)}async function G(){try{let d=h();return(await fetch(`http://127.0.0.1:${d}/health`,{signal:AbortSignal.timeout(W)})).ok}catch{return!1}}async function v(){if(await G())return;let d=h();throw new Error(`Worker service is not responding on port ${d}.
`;try{let l=this.db.prepare(m).all(d,_,...o),g=this.db.prepare(S).all(d,_,...o),c=this.db.prepare(b).all(d,_,...o);return{observations:l,sessions:g.map(p=>({id:p.id,sdk_session_id:p.sdk_session_id,project:p.project,request:p.request,completed:p.completed,next_steps:p.next_steps,created_at:p.created_at,created_at_epoch:p.created_at_epoch})),prompts:c.map(p=>({id:p.id,claude_session_id:p.claude_session_id,project:p.project,prompt:p.prompt_text,created_at:p.created_at,created_at_epoch:p.created_at_epoch}))}}catch(l){return console.error("[SessionStore] Error querying timeline records:",l.message),{observations:[],sessions:[],prompts:[]}}}close(){this.db.close()}};function H(a,e,s){return a==="PreCompact"?e?{continue:!0,suppressOutput:!0}:{continue:!1,stopReason:s.reason||"Pre-compact operation failed",suppressOutput:!0}:a==="SessionStart"?e&&s.context?{continue:!0,suppressOutput:!0,hookSpecificOutput:{hookEventName:"SessionStart",additionalContext:s.context}}:{continue:!0,suppressOutput:!0}:a==="UserPromptSubmit"||a==="PostToolUse"?{continue:!0,suppressOutput:!0}:a==="Stop"?{continue:!0,suppressOutput:!0}:{continue:e,suppressOutput:!0,...s.reason&&!e?{stopReason:s.reason}:{}}}function y(a,e,s={}){let t=H(a,e,s);return JSON.stringify(t)}import B from"path";import{homedir as P}from"os";import{existsSync as j,readFileSync as $}from"fs";var W=100;function h(){try{let a=B.join(P(),".claude-mem","settings.json");if(j(a)){let e=JSON.parse($(a,"utf-8")),s=parseInt(e.env?.CLAUDE_MEM_WORKER_PORT,10);if(!isNaN(s))return s}}catch{}return parseInt(process.env.CLAUDE_MEM_WORKER_PORT||"37777",10)}async function G(){try{let a=h();return(await fetch(`http://127.0.0.1:${a}/health`,{signal:AbortSignal.timeout(W)})).ok}catch{return!1}}async function v(){if(await G())return;let a=h();throw new Error(`Worker service is not responding on port ${a}.
If you just updated the plugin, PM2's watch mode should restart automatically.
If the problem persists, run: pm2 restart claude-mem-worker`)}async function Y(d){if(!d)throw new Error("summaryHook requires input");let{session_id:e}=d;await v();let s=new R,t=s.createSDKSession(e,"",""),r=s.getPromptCounter(t);s.close();let n=h();b.dataIn("HOOK","Stop: Requesting summary",{sessionId:t,workerPort:n,promptNumber:r});try{let o=await fetch(`http://127.0.0.1:${n}/sessions/${t}/summarize`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({prompt_number:r}),signal:AbortSignal.timeout(2e3)});if(!o.ok){let i=await o.text();throw b.failure("HOOK","Failed to generate summary",{sessionId:t,status:o.status},i),new Error(`Failed to request summary from worker: ${o.status} ${i}`)}b.debug("HOOK","Summary request sent successfully",{sessionId:t})}catch(o){throw o.cause?.code==="ECONNREFUSED"||o.name==="TimeoutError"||o.message.includes("fetch failed")?new Error("There's a problem with the worker. If you just updated, type `pm2 restart claude-mem-worker` in your terminal to continue"):o}finally{await fetch(`http://127.0.0.1:${n}/api/processing`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({isProcessing:!1})})}console.log(y("Stop",!0))}var f="";D.on("data",d=>f+=d);D.on("end",async()=>{let d=f?JSON.parse(f):void 0;await Y(d)});
If the problem persists, run: pm2 restart claude-mem-worker`)}function q(a){if(!a||!K(a))return"";try{let e=Y(a,"utf-8").trim();if(!e)return"";let s=e.split(`
`);for(let t=s.length-1;t>=0;t--)try{let r=JSON.parse(s[t]);if(r.role==="user"&&r.content){if(typeof r.content=="string")return r.content;if(Array.isArray(r.content))return r.content.filter(i=>i.type==="text").map(i=>i.text).join(`
`)}}catch{continue}}catch(e){T.error("HOOK","Failed to read transcript",{transcriptPath:a},e)}return""}async function V(a){if(!a)throw new Error("summaryHook requires input");let{session_id:e}=a;await v();let s=new R,t=s.createSDKSession(e,"",""),r=s.getPromptCounter(t);s.close();let n=h(),i=q(a.transcript_path||"");T.dataIn("HOOK","Stop: Requesting summary",{sessionId:t,workerPort:n,promptNumber:r,hasLastUserMessage:!!i});try{let o=await fetch(`http://127.0.0.1:${n}/sessions/${t}/summarize`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({prompt_number:r,last_user_message:i}),signal:AbortSignal.timeout(2e3)});if(!o.ok){let d=await o.text();throw T.failure("HOOK","Failed to generate summary",{sessionId:t,status:o.status},d),new Error(`Failed to request summary from worker: ${o.status} ${d}`)}T.debug("HOOK","Summary request sent successfully",{sessionId:t})}catch(o){throw o.cause?.code==="ECONNREFUSED"||o.name==="TimeoutError"||o.message.includes("fetch failed")?new Error("There's a problem with the worker. If you just updated, type `pm2 restart claude-mem-worker` in your terminal to continue"):o}finally{await fetch(`http://127.0.0.1:${n}/api/processing`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({isProcessing:!1})})}console.log(y("Stop",!0))}var I="";D.on("data",a=>I+=a);D.on("end",async()=>{let a=I?JSON.parse(I):void 0;await V(a)});
File diff suppressed because one or more lines are too long
+54 -2
View File
@@ -4,6 +4,7 @@
*/
import { stdin } from 'process';
import { readFileSync, existsSync } from 'fs';
import { SessionStore } from '../services/sqlite/SessionStore.js';
import { createHookResponse } from './hook-response.js';
import { logger } from '../utils/logger.js';
@@ -12,9 +13,53 @@ import { ensureWorkerRunning, getWorkerPort } from '../shared/worker-utils.js';
export interface StopInput {
session_id: string;
cwd: string;
transcript_path?: string;
[key: string]: any;
}
/**
* Extract last user message from transcript JSONL file
*/
function extractLastUserMessage(transcriptPath: string): string {
if (!transcriptPath || !existsSync(transcriptPath)) {
return '';
}
try {
const content = readFileSync(transcriptPath, 'utf-8').trim();
if (!content) {
return '';
}
const lines = content.split('\n');
// Parse JSONL and find last user message
for (let i = lines.length - 1; i >= 0; i--) {
try {
const line = JSON.parse(lines[i]);
if (line.role === 'user' && line.content) {
// Extract text content (handle both string and array formats)
if (typeof line.content === 'string') {
return line.content;
} else if (Array.isArray(line.content)) {
const textParts = line.content
.filter((c: any) => c.type === 'text')
.map((c: any) => c.text);
return textParts.join('\n');
}
}
} catch (parseError) {
// Skip malformed lines
continue;
}
}
} catch (error) {
logger.error('HOOK', 'Failed to read transcript', { transcriptPath }, error as Error);
}
return '';
}
/**
* Summary Hook Main Logic
*/
@@ -37,17 +82,24 @@ async function summaryHook(input?: StopInput): Promise<void> {
const port = getWorkerPort();
// Extract last user message from transcript
const lastUserMessage = extractLastUserMessage(input.transcript_path || '');
logger.dataIn('HOOK', 'Stop: Requesting summary', {
sessionId: sessionDbId,
workerPort: port,
promptNumber
promptNumber,
hasLastUserMessage: !!lastUserMessage
});
try {
const response = await fetch(`http://127.0.0.1:${port}/sessions/${sessionDbId}/summarize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt_number: promptNumber }),
body: JSON.stringify({
prompt_number: promptNumber,
last_user_message: lastUserMessage
}),
signal: AbortSignal.timeout(2000)
});
+7 -1
View File
@@ -17,6 +17,7 @@ export interface SDKSession {
sdk_session_id: string | null;
project: string;
user_prompt: string;
last_user_message?: string;
}
/**
@@ -165,13 +166,18 @@ export function buildObservationPrompt(obs: Observation): string {
* Build prompt to generate progress summary
*/
export function buildSummaryPrompt(session: SDKSession): string {
const lastUserMessage = session.last_user_message || '';
return `PROGRESS SUMMARY CHECKPOINT
===========================
Write progress notes of what was done, what was learned, and what's next. This is a checkpoint to capture progress so far. The session is ongoing - you may receive more requests and tool executions after this summary. Write "next_steps" as the current trajectory of work (what's actively being worked on or coming up next), not as post-session future work. Always write at least a minimal summary explaining current progress, even if work is still in early stages, so that users see a summary output tied to each request.
Last User Message:
${lastUserMessage}
Respond in this XML format:
<summary>
<request>[Short title related to the most recent prompt]</request>
<request>[Short title related to the last user message above]</request>
<investigated>[What has been explored so far? What was examined?]</investigated>
<learned>[What have you learned about how things work?]</learned>
<completed>[What work has been completed so far? What has shipped or changed?]</completed>
+82 -31
View File
@@ -44,9 +44,6 @@ export class WorkerService {
private paginationHelper: PaginationHelper;
private settingsManager: SettingsManager;
// Processing status tracking for viewer UI spinner
private isProcessing: boolean = false;
constructor() {
this.app = express();
@@ -58,6 +55,11 @@ export class WorkerService {
this.paginationHelper = new PaginationHelper(this.dbManager);
this.settingsManager = new SettingsManager(this.dbManager);
// Set callback for when sessions are deleted (to update activity indicator)
this.sessionManager.setOnSessionDeleted(() => {
this.broadcastProcessingStatus();
});
this.mcpClient = new Client({
name: 'worker-search-proxy',
version: '1.0.0'
@@ -273,10 +275,11 @@ export class WorkerService {
timestamp: Date.now()
});
// Send initial processing status
// Send initial processing status (based on queue depth + active generators)
const isProcessing = this.sessionManager.isAnySessionProcessing();
this.sseBroadcaster.broadcast({
type: 'processing_status',
isProcessing: this.isProcessing
isProcessing
});
}
@@ -316,6 +319,12 @@ export class WorkerService {
}
});
// Start activity indicator immediately when prompt arrives (work is about to begin)
this.sseBroadcaster.broadcast({
type: 'processing_status',
isProcessing: true
});
// Sync user prompt to Chroma with error logging
const chromaStart = Date.now();
const promptText = latestPrompt.prompt_text;
@@ -344,8 +353,8 @@ export class WorkerService {
});
}
// Start processing indicator
this.broadcastProcessingStatus(true);
// Broadcast processing status (based on queue depth)
this.broadcastProcessingStatus();
// Start SDK agent in background (pass worker ref for spinner control)
logger.info('SESSION', 'Generator starting', {
@@ -354,9 +363,17 @@ export class WorkerService {
promptNum: session.lastPromptNumber
});
session.generatorPromise = this.sdkAgent.startSession(session, this).catch(err => {
logger.failure('SDK', 'SDK agent error', { sessionId: sessionDbId }, err);
});
session.generatorPromise = this.sdkAgent.startSession(session, this)
.catch(err => {
logger.failure('SDK', 'SDK agent error', { sessionId: sessionDbId }, err);
})
.finally(() => {
// Clear generator reference when completed
logger.info('SESSION', `Generator finished`, { sessionId: sessionDbId });
session.generatorPromise = null;
// Broadcast status change (generator finished, may stop spinner)
this.broadcastProcessingStatus();
});
// Broadcast SSE event
this.sseBroadcaster.broadcast({
@@ -397,11 +414,22 @@ export class WorkerService {
queueDepth: session.pendingMessages.length
});
session.generatorPromise = this.sdkAgent.startSession(session, this).catch(err => {
logger.failure('SDK', 'SDK agent error', { sessionId: sessionDbId }, err);
});
session.generatorPromise = this.sdkAgent.startSession(session, this)
.catch(err => {
logger.failure('SDK', 'SDK agent error', { sessionId: sessionDbId }, err);
})
.finally(() => {
// Clear generator reference when completed
logger.info('SESSION', `Generator finished`, { sessionId: sessionDbId });
session.generatorPromise = null;
// Broadcast status change (generator finished, may stop spinner)
this.broadcastProcessingStatus();
});
}
// Broadcast activity status (queue depth changed)
this.broadcastProcessingStatus();
// Broadcast SSE event
this.sseBroadcaster.broadcast({
type: 'observation_queued',
@@ -422,7 +450,9 @@ export class WorkerService {
private handleSummarize(req: Request, res: Response): void {
try {
const sessionDbId = parseInt(req.params.sessionDbId, 10);
this.sessionManager.queueSummarize(sessionDbId);
const { last_user_message } = req.body;
this.sessionManager.queueSummarize(sessionDbId, last_user_message);
// CRITICAL: Ensure SDK agent is running to consume the queue
const session = this.sessionManager.getSession(sessionDbId);
@@ -432,11 +462,22 @@ export class WorkerService {
queueDepth: session.pendingMessages.length
});
session.generatorPromise = this.sdkAgent.startSession(session, this).catch(err => {
logger.failure('SDK', 'SDK agent error', { sessionId: sessionDbId }, err);
});
session.generatorPromise = this.sdkAgent.startSession(session, this)
.catch(err => {
logger.failure('SDK', 'SDK agent error', { sessionId: sessionDbId }, err);
})
.finally(() => {
// Clear generator reference when completed
logger.info('SESSION', `Generator finished`, { sessionId: sessionDbId });
session.generatorPromise = null;
// Broadcast status change (generator finished, may stop spinner)
this.broadcastProcessingStatus();
});
}
// Broadcast activity status (queue depth changed)
this.broadcastProcessingStatus();
res.json({ status: 'queued' });
} catch (error) {
logger.failure('WORKER', 'Summarize queuing failed', {}, error as Error);
@@ -511,8 +552,8 @@ export class WorkerService {
// Mark session complete in database
this.dbManager.markSessionComplete(sessionDbId);
// Stop processing indicator
this.broadcastProcessingStatus(false);
// Broadcast processing status (based on queue depth)
this.broadcastProcessingStatus();
// Broadcast SSE event
this.sseBroadcaster.broadcast({
@@ -722,7 +763,8 @@ export class WorkerService {
* Get processing status (for viewer UI spinner)
*/
private handleGetProcessingStatus(req: Request, res: Response): void {
res.json({ isProcessing: this.isProcessing });
const isProcessing = this.sessionManager.isAnySessionProcessing();
res.json({ isProcessing });
}
// ============================================================================
@@ -731,9 +773,19 @@ export class WorkerService {
/**
* Broadcast processing status change to SSE clients
* Checks both queue depth and active generators to prevent premature spinner stop
*/
broadcastProcessingStatus(isProcessing: boolean): void {
this.isProcessing = isProcessing;
broadcastProcessingStatus(): void {
const isProcessing = this.sessionManager.isAnySessionProcessing();
const queueDepth = this.sessionManager.getTotalQueueDepth();
const activeSessions = this.sessionManager.getActiveSessionCount();
logger.info('WORKER', 'Broadcasting processing status', {
isProcessing,
queueDepth,
activeSessions
});
this.sseBroadcaster.broadcast({
type: 'processing_status',
isProcessing
@@ -742,22 +794,21 @@ export class WorkerService {
/**
* Set processing status (called by hooks)
* NOTE: This now broadcasts computed status based on active processing (ignores input)
*/
private handleSetProcessing(req: Request, res: Response): void {
try {
const { isProcessing } = req.body;
// Broadcast current computed status (ignores manual input)
this.broadcastProcessingStatus();
if (typeof isProcessing !== 'boolean') {
res.status(400).json({ error: 'isProcessing must be a boolean' });
return;
}
this.broadcastProcessingStatus(isProcessing);
logger.debug('WORKER', 'Processing status updated', { isProcessing });
const isProcessing = this.sessionManager.isAnySessionProcessing();
const queueDepth = this.sessionManager.getTotalQueueDepth();
const activeSessions = this.sessionManager.getActiveSessionCount();
logger.debug('WORKER', 'Processing status broadcast', { isProcessing, queueDepth, activeSessions });
res.json({ status: 'ok', isProcessing });
} catch (error) {
logger.failure('WORKER', 'Failed to set processing status', {}, error as Error);
logger.failure('WORKER', 'Failed to broadcast processing status', {}, error as Error);
res.status(500).json({ error: (error as Error).message });
}
}
+1
View File
@@ -28,6 +28,7 @@ export interface PendingMessage {
tool_response?: any;
prompt_number?: number;
cwd?: string;
last_user_message?: string;
}
export interface ObservationData {
+5 -4
View File
@@ -188,7 +188,8 @@ export class SDKAgent {
id: session.sessionDbId,
sdk_session_id: session.sdkSessionId,
project: session.project,
user_prompt: session.userPrompt
user_prompt: session.userPrompt,
last_user_message: message.last_user_message || ''
})
},
session_id: session.claudeSessionId,
@@ -351,9 +352,9 @@ export class SDKAgent {
}
}
// Check and stop spinner after processing (debounced)
if (worker && typeof worker.checkAndStopSpinner === 'function') {
worker.checkAndStopSpinner();
// Broadcast activity status after processing (queue may have changed)
if (worker && typeof worker.broadcastProcessingStatus === 'function') {
worker.broadcastProcessingStatus();
}
}
+53 -2
View File
@@ -17,11 +17,19 @@ export class SessionManager {
private dbManager: DatabaseManager;
private sessions: Map<number, ActiveSession> = new Map();
private sessionQueues: Map<number, EventEmitter> = new Map();
private onSessionDeletedCallback?: () => void;
constructor(dbManager: DatabaseManager) {
this.dbManager = dbManager;
}
/**
* Set callback to be called when a session is deleted (for broadcasting status)
*/
setOnSessionDeleted(callback: () => void): void {
this.onSessionDeletedCallback = callback;
}
/**
* Initialize a new session or return existing one
*/
@@ -115,7 +123,7 @@ export class SessionManager {
* Queue a summarize request (zero-latency notification)
* Auto-initializes session if not in memory but exists in database
*/
queueSummarize(sessionDbId: number): void {
queueSummarize(sessionDbId: number, lastUserMessage: string): void {
// Auto-initialize from database if needed (handles worker restarts)
let session = this.sessions.get(sessionDbId);
if (!session) {
@@ -124,7 +132,10 @@ export class SessionManager {
const beforeDepth = session.pendingMessages.length;
session.pendingMessages.push({ type: 'summarize' });
session.pendingMessages.push({
type: 'summarize',
last_user_message: lastUserMessage
});
const afterDepth = session.pendingMessages.length;
@@ -165,6 +176,11 @@ export class SessionManager {
duration: `${(sessionDuration / 1000).toFixed(1)}s`,
project: session.project
});
// Trigger callback to broadcast status update (spinner may need to stop)
if (this.onSessionDeletedCallback) {
this.onSessionDeletedCallback();
}
}
/**
@@ -191,6 +207,35 @@ export class SessionManager {
return this.sessions.size;
}
/**
* Get total queue depth across all sessions (for activity indicator)
*/
getTotalQueueDepth(): number {
let total = 0;
for (const session of this.sessions.values()) {
total += session.pendingMessages.length;
}
return total;
}
/**
* Check if any session is actively processing (has pending messages OR active generator)
* Used for activity indicator to prevent spinner from stopping while SDK is processing
*/
isAnySessionProcessing(): boolean {
for (const session of this.sessions.values()) {
// Has queued messages waiting to be processed
if (session.pendingMessages.length > 0) {
return true;
}
// Has active SDK generator running (processing dequeued messages)
if (session.generatorPromise !== null) {
return true;
}
}
return false;
}
/**
* Get message iterator for SDKAgent to consume (event-driven, no polling)
* Auto-initializes session if not in memory but exists in database
@@ -226,6 +271,12 @@ export class SessionManager {
while (session.pendingMessages.length > 0) {
const message = session.pendingMessages.shift()!;
yield message;
// If we just yielded a summary, that's the end of this batch - stop the iterator
if (message.type === 'summarize') {
logger.info('SESSION', `Summary yielded - ending generator`, { sessionId: sessionDbId });
return;
}
}
}
}