feat: Add web-based viewer UI for real-time memory stream (#58)

* Add viewer HTML for claude-mem with live stream and settings interface

- Implemented a responsive layout with left and right columns for observations and settings.
- Added status indicators for connection state.
- Integrated server-sent events (SSE) for real-time updates on observations and summaries.
- Created dynamic project filter dropdown based on available observations.
- Developed settings section for environment variables and worker stats.
- Included functionality to save settings and load current stats from the server.
- Enhanced UI with custom styles for better user experience.

* Remove draft implementation plan for v5.1 web UI

* feat: Implement viewer UI with sidebar, feed, and settings management

- Add main viewer template (HTML) with styling for dark mode.
- Create App component to manage state and render Header, Feed, and Sidebar.
- Implement Feed component to display observations and summaries with filtering.
- Develop Header component for project selection and connection status.
- Create ObservationCard and SummaryCard components for displaying individual items.
- Implement Sidebar for settings management and displaying worker/database stats.
- Add hooks for managing SSE connections, settings, and stats fetching.
- Define types for observations, summaries, settings, and stats.

* Enhance UI components and improve layout

- Updated padding and layout for the feed and card components in viewer.html, viewer-template.html, and viewer.html to improve visual spacing and alignment.
- Increased card margins and padding for better readability and aesthetics.
- Adjusted font sizes, weights, and line heights for card titles and subtitles to enhance text clarity and hierarchy.
- Added a new feed-content class to center the feed items and limit their maximum width.
- Modified the Header component to improve the settings icon's SVG structure for better rendering.
- Enhanced the Sidebar component by adding a close button with an SVG icon, improving user experience for closing settings.
- Updated the Sidebar component's props to include an onClose function for handling sidebar closure.

* feat: Add user prompts feature with UI integration

- Implemented a new method in SessionStore to retrieve recent user prompts.
- Updated WorkerService to fetch and broadcast user prompts to clients.
- Enhanced the Feed component to display user prompts alongside observations and summaries.
- Created a new PromptCard component for rendering individual user prompts.
- Modified useSSE hook to handle new prompt events and processing status.
- Updated viewer templates and styles to accommodate the new prompts feature.

* feat: Add project filtering and pagination for observations

- Implemented `getAllProjects` method in `SessionStore` to retrieve unique projects from the database.
- Added `/api/observations` endpoint in `WorkerService` for paginated observations fetching.
- Enhanced `App` component to manage paginated observations and integrate with the new API.
- Updated `Feed` component to support infinite scrolling and loading more observations.
- Modified `Header` to display processing status.
- Refactored `PromptCard` to remove unnecessary processing indicator.
- Introduced `usePagination` hook to handle pagination logic for observations.
- Updated `useSSE` hook to include projects in the state.
- Adjusted types to accommodate new project data.

* Refactor viewer build process and remove deprecated HTML template

- Updated build-viewer.js to copy HTML template to build output with improved logging.
- Removed src/ui/viewer.html as it is no longer needed.
- Enhanced App component to merge observations while removing duplicates using useMemo.
- Improved Feed component to utilize a ref for onLoadMore callback and adjusted infinite scroll logic.
- Updated Sidebar component to use default settings from constants and removed redundant formatting functions.
- Refactored usePagination hook to streamline loading logic and prevent concurrent requests.
- Updated useSSE hook to use centralized API endpoints and improved reconnection logic.
- Refactored useSettings and useStats hooks to utilize constants for API endpoints and timing.
- Introduced ErrorBoundary component for better error handling in the viewer.
- Centralized API endpoint paths, default settings, timing constants, and UI-related constants into dedicated files.
- Added utility functions for formatting uptime and bytes for consistent display across components.

* feat: Enhance session management and pagination for user prompts, summaries, and observations

- Added project field to user prompts in the database and API responses.
- Implemented new API endpoints for fetching summaries and prompts with pagination.
- Updated WorkerService to handle new endpoints and filter results by project.
- Modified App component to manage paginated data for prompts and summaries.
- Refactored Feed component to remove unnecessary filtering and handle combined data.
- Improved usePagination hook to support multiple data types and project filtering.
- Adjusted useSSE hook to only load projects initially, with data fetched via pagination.
- Updated types to include project information for user prompts.

* feat: add SummarySkeleton component and data utility for merging items

- Introduced SummarySkeleton component for displaying loading state in the UI.
- Implemented mergeAndDeduplicateByProject utility function to merge real-time and paginated data while removing duplicates based on project filtering.

* Enhance UI and functionality of the viewer component

- Updated sidebar transition effects to use translate3d for improved performance.
- Added a sidebar header with title and connection status indicators.
- Modified the PromptCard to display project name instead of prompt number.
- Introduced a GitHub and X (Twitter) link in the header for easy access.
- Improved styling for setting descriptions and card hover effects.
- Enhanced Sidebar component to include connection status and updated layout.

* fix: reduce timeout for worker health checks and ensure proper responsiveness
This commit is contained in:
Alex Newman
2025-11-05 22:54:38 -05:00
committed by GitHub
parent ff28db9d76
commit 79ff1849f0
49 changed files with 3622 additions and 309 deletions
+4 -1
View File
@@ -10,4 +10,7 @@ node_modules/
plugin/data/
plugin/data.backup/
package-lock.json
private/
private/
# Generated UI files (built from viewer-template.html)
src/ui/viewer.html
+97 -2
View File
@@ -1,12 +1,12 @@
{
"name": "claude-mem",
"version": "5.0.1",
"version": "5.0.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "claude-mem",
"version": "5.0.1",
"version": "5.0.3",
"license": "AGPL-3.0",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.1.27",
@@ -16,12 +16,16 @@
"glob": "^11.0.3",
"handlebars": "^4.7.8",
"pm2": "^6.0.13",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"zod-to-json-schema": "^3.24.6"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.8",
"@types/express": "^4.17.21",
"@types/node": "^20.0.0",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"esbuild": "^0.25.12",
"tsx": "^4.20.6",
"typescript": "^5.3.0"
@@ -1442,6 +1446,13 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/qs": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
@@ -1456,6 +1467,28 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "18.3.26",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.26.tgz",
"integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.0.2"
}
},
"node_modules/@types/react-dom": {
"version": "18.3.7",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^18.0.0"
}
},
"node_modules/@types/send": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.0.tgz",
@@ -1955,6 +1988,13 @@
"node": ">= 8"
}
},
"node_modules/csstype": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"dev": true,
"license": "MIT"
},
"node_modules/culvert": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/culvert/-/culvert-0.1.2.tgz",
@@ -2287,6 +2327,7 @@
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
@@ -2932,6 +2973,12 @@
"pako": "^0.2.5"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
@@ -2963,6 +3010,18 @@
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT"
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"loose-envify": "cli.js"
}
},
"node_modules/lru-cache": {
"version": "11.2.2",
"license": "ISC",
@@ -3776,6 +3835,32 @@
"rc": "cli.js"
}
},
"node_modules/react": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
},
"peerDependencies": {
"react": "^18.3.1"
}
},
"node_modules/read": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/read/-/read-1.0.7.tgz",
@@ -3978,6 +4063,15 @@
"integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==",
"license": "ISC"
},
"node_modules/scheduler": {
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
}
},
"node_modules/semver": {
"version": "7.7.3",
"license": "ISC",
@@ -4748,6 +4842,7 @@
"node_modules/zod": {
"version": "3.25.76",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
+5 -1
View File
@@ -35,7 +35,7 @@
"test:parser": "npx tsx src/sdk/parser.test.ts",
"test:context": "echo '{\"session_id\":\"test-'$(date +%s)'\",\"cwd\":\"'$(pwd)'\",\"source\":\"startup\"}' | node plugin/scripts/context-hook.js 2>/dev/null",
"test:context:verbose": "echo '{\"session_id\":\"test-'$(date +%s)'\",\"cwd\":\"'$(pwd)'\",\"source\":\"startup\"}' | node plugin/scripts/context-hook.js",
"sync-marketplace": "rsync -av --delete --filter=':- .gitignore' --exclude=.git ./ ~/.claude/plugins/marketplaces/thedotmack/ # --delete flag removes orphaned files from destination only",
"sync-marketplace": "rsync -av --delete --exclude=.git ./ ~/.claude/plugins/marketplaces/thedotmack/ && cd ~/.claude/plugins/marketplaces/thedotmack/ && npm install",
"worker:start": "pm2 start ecosystem.config.cjs",
"worker:stop": "pm2 stop claude-mem-worker",
"worker:restart": "pm2 restart claude-mem-worker",
@@ -51,12 +51,16 @@
"glob": "^11.0.3",
"handlebars": "^4.7.8",
"pm2": "^6.0.13",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"zod-to-json-schema": "^3.24.6"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.8",
"@types/express": "^4.17.21",
"@types/node": "^20.0.0",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"esbuild": "^0.25.12",
"tsx": "^4.20.6",
"typescript": "^5.3.0"
+45 -16
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env node
import{stdin as I}from"process";import M from"better-sqlite3";import{join as E,dirname as y,basename as F}from"path";import{homedir as O}from"os";import{existsSync as $,mkdirSync as k}from"fs";import{fileURLToPath as x}from"url";function U(){return typeof __dirname<"u"?__dirname:y(x(import.meta.url))}var P=U(),u=process.env.CLAUDE_MEM_DATA_DIR||E(O(),".claude-mem"),R=process.env.CLAUDE_CONFIG_DIR||E(O(),".claude"),W=E(u,"archives"),Y=E(u,"logs"),K=E(u,"trash"),V=E(u,"backups"),q=E(u,"settings.json"),f=E(u,"claude-mem.db"),J=E(u,"vector-db"),Q=E(R,"settings.json"),z=E(R,"commands"),Z=E(R,"CLAUDE.md");function L(p){k(p,{recursive:!0})}var N=(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))(N||{}),h=class{level;useColor;constructor(){let e=process.env.CLAUDE_MEM_LOG_LEVEL?.toUpperCase()||"INFO";this.level=N[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=N[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 T="";if(r){let{sessionId:l,sdkSessionId:S,correlationId:c,...a}=r;Object.keys(a).length>0&&(T=` {${Object.entries(a).map(([v,D])=>`${v}=${D}`).join(", ")}}`)}let b=`[${o}] [${i}] [${d}] ${_}${t}${T}${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`})}},A=new h;var g=class{db;constructor(){L(u),this.db=new M(f),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 I}from"process";import M from"better-sqlite3";import{join as E,dirname as y,basename as F}from"path";import{homedir as O}from"os";import{existsSync as H,mkdirSync as k}from"fs";import{fileURLToPath as x}from"url";function U(){return typeof __dirname<"u"?__dirname:y(x(import.meta.url))}var P=U(),l=process.env.CLAUDE_MEM_DATA_DIR||E(O(),".claude-mem"),R=process.env.CLAUDE_CONFIG_DIR||E(O(),".claude"),W=E(l,"archives"),Y=E(l,"logs"),K=E(l,"trash"),V=E(l,"backups"),q=E(l,"settings.json"),f=E(l,"claude-mem.db"),J=E(l,"vector-db"),Q=E(R,"settings.json"),z=E(R,"commands"),Z=E(R,"CLAUDE.md");function L(c){k(c,{recursive:!0})}var h=(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))(h||{}),N=class{level;useColor;constructor(){let e=process.env.CLAUDE_MEM_LOG_LEVEL?.toUpperCase()||"INFO";this.level=h[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=h[e].padEnd(5),d=s.padEnd(6),_="";r?.correlationId?_=`[${r.correlationId}] `:r?.sessionId&&(_=`[session-${r.sessionId}] `);let u="";n!=null&&(this.level===0&&typeof n=="object"?u=`
`+JSON.stringify(n,null,2):u=" "+this.formatData(n));let T="";if(r){let{sessionId:m,sdkSessionId:b,correlationId:p,...a}=r;Object.keys(a).length>0&&(T=` {${Object.entries(a).map(([v,D])=>`${v}=${D}`).join(", ")}}`)}let S=`[${o}] [${i}] [${d}] ${_}${t}${T}${u}`;e===3?console.error(S):console.log(S)}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`})}},A=new N;var g=class{db;constructor(){L(l),this.db=new M(f),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,
@@ -188,7 +188,36 @@ ${e.stack}`:e.message;if(Array.isArray(e))return`[${e.length} items]`;let s=Obje
WHERE project = ?
ORDER BY created_at_epoch DESC
LIMIT ?
`).all(e,s)}getRecentSessionsWithStatus(e,s=3){return this.db.prepare(`
`).all(e,s)}getAllRecentObservations(e=100){return this.db.prepare(`
SELECT id, type, title, subtitle, text, project, prompt_number, created_at, created_at_epoch
FROM observations
ORDER BY created_at_epoch DESC
LIMIT ?
`).all(e)}getAllRecentSummaries(e=50){return this.db.prepare(`
SELECT id, request, investigated, learned, completed, next_steps,
files_read, files_edited, notes, project, prompt_number,
created_at, created_at_epoch
FROM session_summaries
ORDER BY created_at_epoch DESC
LIMIT ?
`).all(e)}getAllRecentUserPrompts(e=100){return this.db.prepare(`
SELECT
up.id,
up.claude_session_id,
s.project,
up.prompt_number,
up.prompt_text,
up.created_at,
up.created_at_epoch
FROM user_prompts up
LEFT JOIN sdk_sessions s ON up.claude_session_id = s.claude_session_id
ORDER BY up.created_at_epoch DESC
LIMIT ?
`).all(e)}getAllProjects(){return this.db.prepare(`
SELECT DISTINCT project
FROM sdk_sessions
ORDER BY project ASC
`).all().map(t=>t.project)}getRecentSessionsWithStatus(e,s=3){return this.db.prepare(`
SELECT * FROM (
SELECT
s.sdk_session_id,
@@ -288,23 +317,23 @@ ${e.stack}`:e.message;if(Array.isArray(e))return`[${e.length} items]`;let s=Obje
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 m=this.db.prepare(`
`).run(e,e,s,n.toISOString(),o),console.error(`[SessionStore] Auto-created session record for session_id: ${e}`));let u=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(m.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(),o);return{id:Number(u.lastInsertRowid),createdAtEpoch:o}}storeSummary(e,s,t,r){let n=new Date,o=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 m=this.db.prepare(`
`).run(e,e,s,n.toISOString(),o),console.error(`[SessionStore] Auto-created session record for session_id: ${e}`));let u=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(m.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(),o);return{id:Number(u.lastInsertRowid),createdAtEpoch:o}}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 = ?
@@ -331,31 +360,31 @@ ${e.stack}`:e.message;if(Array.isArray(e))return`[${e.length} items]`;let s=Obje
WHERE up.id IN (${i})
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]:[],d,_;if(e!==null){let l=`
`).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]:[],d,_;if(e!==null){let m=`
SELECT id, created_at_epoch
FROM observations
WHERE id <= ? ${o}
ORDER BY id DESC
LIMIT ?
`,S=`
`,b=`
SELECT id, created_at_epoch
FROM observations
WHERE id >= ? ${o}
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:[]};d=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 p=this.db.prepare(m).all(e,...i,t+1),a=this.db.prepare(b).all(e,...i,r+1);if(p.length===0&&a.length===0)return{observations:[],sessions:[],prompts:[]};d=p.length>0?p[p.length-1].created_at_epoch:s,_=a.length>0?a[a.length-1].created_at_epoch:s}catch(p){return console.error("[SessionStore] Error getting boundary observations:",p.message),{observations:[],sessions:[],prompts:[]}}}else{let m=`
SELECT created_at_epoch
FROM observations
WHERE created_at_epoch <= ? ${o}
ORDER BY created_at_epoch DESC
LIMIT ?
`,S=`
`,b=`
SELECT created_at_epoch
FROM observations
WHERE created_at_epoch >= ? ${o}
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:[]};d=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 m=`
`;try{let p=this.db.prepare(m).all(s,...i,t),a=this.db.prepare(b).all(s,...i,r+1);if(p.length===0&&a.length===0)return{observations:[],sessions:[],prompts:[]};d=p.length>0?p[p.length-1].created_at_epoch:s,_=a.length>0?a[a.length-1].created_at_epoch:s}catch(p){return console.error("[SessionStore] Error getting boundary timestamps:",p.message),{observations:[],sessions:[],prompts:[]}}}let u=`
SELECT *
FROM observations
WHERE created_at_epoch >= ? AND created_at_epoch <= ? ${o}
@@ -365,11 +394,11 @@ ${e.stack}`:e.message;if(Array.isArray(e))return`[${e.length} items]`;let s=Obje
FROM session_summaries
WHERE created_at_epoch >= ? AND created_at_epoch <= ? ${o}
ORDER BY created_at_epoch ASC
`,b=`
`,S=`
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")}
ORDER BY up.created_at_epoch ASC
`;try{let l=this.db.prepare(m).all(d,_,...i),S=this.db.prepare(T).all(d,_,...i),c=this.db.prepare(b).all(d,_,...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()}};async function C(p){console.error("[claude-mem cleanup] Hook fired",{input:p?{session_id:p.session_id,cwd:p.cwd,reason:p.reason}:null}),p||(console.log("No input provided - this script is designed to run as a Claude Code SessionEnd hook"),console.log(`
Expected input format:`),console.log(JSON.stringify({session_id:"string",cwd:"string",transcript_path:"string",hook_event_name:"SessionEnd",reason:"exit"},null,2)),process.exit(0));let{session_id:e,reason:s}=p;console.error("[claude-mem cleanup] Searching for active SDK session",{session_id:e,reason:s});let t=new g,r=t.findActiveSDKSession(e);r||(console.error("[claude-mem cleanup] No active SDK session found",{session_id:e}),t.close(),console.log('{"continue": true, "suppressOutput": true}'),process.exit(0)),console.error("[claude-mem cleanup] Active SDK session found",{session_id:r.id,sdk_session_id:r.sdk_session_id,project:r.project,worker_port:r.worker_port}),t.markSessionCompleted(r.id),console.error("[claude-mem cleanup] Session marked as completed in database"),t.close(),console.error("[claude-mem cleanup] Cleanup completed successfully"),console.log('{"continue": true, "suppressOutput": true}'),process.exit(0)}if(I.isTTY)C(void 0);else{let p="";I.on("data",e=>p+=e),I.on("end",async()=>{let e=p?JSON.parse(p):void 0;await C(e)})}
`;try{let m=this.db.prepare(u).all(d,_,...i),b=this.db.prepare(T).all(d,_,...i),p=this.db.prepare(S).all(d,_,...i);return{observations:m,sessions:b.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:p.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(m){return console.error("[SessionStore] Error querying timeline records:",m.message),{observations:[],sessions:[],prompts:[]}}}close(){this.db.close()}};async function C(c){console.error("[claude-mem cleanup] Hook fired",{input:c?{session_id:c.session_id,cwd:c.cwd,reason:c.reason}:null}),c||(console.log("No input provided - this script is designed to run as a Claude Code SessionEnd hook"),console.log(`
Expected input format:`),console.log(JSON.stringify({session_id:"string",cwd:"string",transcript_path:"string",hook_event_name:"SessionEnd",reason:"exit"},null,2)),process.exit(0));let{session_id:e,reason:s}=c;console.error("[claude-mem cleanup] Searching for active SDK session",{session_id:e,reason:s});let t=new g,r=t.findActiveSDKSession(e);r||(console.error("[claude-mem cleanup] No active SDK session found",{session_id:e}),t.close(),console.log('{"continue": true, "suppressOutput": true}'),process.exit(0)),console.error("[claude-mem cleanup] Active SDK session found",{session_id:r.id,sdk_session_id:r.sdk_session_id,project:r.project,worker_port:r.worker_port}),t.markSessionCompleted(r.id),console.error("[claude-mem cleanup] Session marked as completed in database"),t.close(),console.error("[claude-mem cleanup] Cleanup completed successfully"),console.log('{"continue": true, "suppressOutput": true}'),process.exit(0)}if(I.isTTY)C(void 0);else{let c="";I.on("data",e=>c+=e),I.on("end",async()=>{let e=c?JSON.parse(c):void 0;await C(e)})}
+67 -38
View File
@@ -1,13 +1,13 @@
#!/usr/bin/env node
import X from"path";import{stdin as M}from"process";import ae from"better-sqlite3";import{join as S,dirname as te,basename as be}from"path";import{homedir as B}from"os";import{existsSync as Ne,mkdirSync as re}from"fs";import{fileURLToPath as ne}from"url";function oe(){return typeof __dirname<"u"?__dirname:te(ne(import.meta.url))}var ie=oe(),I=process.env.CLAUDE_MEM_DATA_DIR||S(B(),".claude-mem"),$=process.env.CLAUDE_CONFIG_DIR||S(B(),".claude"),Ie=S(I,"archives"),Le=S(I,"logs"),ve=S(I,"trash"),ye=S(I,"backups"),Ae=S(I,"settings.json"),W=S(I,"claude-mem.db"),Ce=S(I,"vector-db"),De=S($,"settings.json"),ke=S($,"commands"),xe=S($,"CLAUDE.md");function H(d){re(d,{recursive:!0})}function G(){return S(ie,"..","..")}var U=(o=>(o[o.DEBUG=0]="DEBUG",o[o.INFO=1]="INFO",o[o.WARN=2]="WARN",o[o.ERROR=3]="ERROR",o[o.SILENT=4]="SILENT",o))(U||{}),w=class{level;useColor;constructor(){let e=process.env.CLAUDE_MEM_LOG_LEVEL?.toUpperCase()||"INFO";this.level=U[e]??1,this.useColor=process.stdout.isTTY??!1}correlationId(e,t){return`obs-${e}-${t}`}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 t=Object.keys(e);return t.length===0?"{}":t.length<=3?JSON.stringify(e):`{${t.length} keys: ${t.slice(0,3).join(", ")}...}`}return String(e)}formatTool(e,t){if(!t)return e;try{let s=typeof t=="string"?JSON.parse(t):t;if(e==="Bash"&&s.command){let r=s.command.length>50?s.command.substring(0,50)+"...":s.command;return`${e}(${r})`}if(e==="Read"&&s.file_path){let r=s.file_path.split("/").pop()||s.file_path;return`${e}(${r})`}if(e==="Edit"&&s.file_path){let r=s.file_path.split("/").pop()||s.file_path;return`${e}(${r})`}if(e==="Write"&&s.file_path){let r=s.file_path.split("/").pop()||s.file_path;return`${e}(${r})`}return e}catch{return e}}log(e,t,s,r,o){if(e<this.level)return;let c=new Date().toISOString().replace("T"," ").substring(0,23),a=U[e].padEnd(5),_=t.padEnd(6),l="";r?.correlationId?l=`[${r.correlationId}] `:r?.sessionId&&(l=`[session-${r.sessionId}] `);let E="";o!=null&&(this.level===0&&typeof o=="object"?E=`
`+JSON.stringify(o,null,2):E=" "+this.formatData(o));let n="";if(r){let{sessionId:f,sdkSessionId:N,correlationId:m,...p}=r;Object.keys(p).length>0&&(n=` {${Object.entries(p).map(([u,h])=>`${u}=${h}`).join(", ")}}`)}let v=`[${c}] [${a}] [${_}] ${l}${s}${n}${E}`;e===3?console.error(v):console.log(v)}debug(e,t,s,r){this.log(0,e,t,s,r)}info(e,t,s,r){this.log(1,e,t,s,r)}warn(e,t,s,r){this.log(2,e,t,s,r)}error(e,t,s,r){this.log(3,e,t,s,r)}dataIn(e,t,s,r){this.info(e,`\u2192 ${t}`,s,r)}dataOut(e,t,s,r){this.info(e,`\u2190 ${t}`,s,r)}success(e,t,s,r){this.info(e,`\u2713 ${t}`,s,r)}failure(e,t,s,r){this.error(e,`\u2717 ${t}`,s,r)}timing(e,t,s,r){this.info(e,`\u23F1 ${t}`,r,{duration:`${s}ms`})}},j=new w;var D=class{db;constructor(){H(I),this.db=new ae(W),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 F from"path";import{stdin as M}from"process";import ae from"better-sqlite3";import{join as S,dirname as te,basename as be}from"path";import{homedir as B}from"os";import{existsSync as Ne,mkdirSync as re}from"fs";import{fileURLToPath as ne}from"url";function oe(){return typeof __dirname<"u"?__dirname:te(ne(import.meta.url))}var ie=oe(),I=process.env.CLAUDE_MEM_DATA_DIR||S(B(),".claude-mem"),$=process.env.CLAUDE_CONFIG_DIR||S(B(),".claude"),Ie=S(I,"archives"),Le=S(I,"logs"),ye=S(I,"trash"),ve=S(I,"backups"),Ae=S(I,"settings.json"),j=S(I,"claude-mem.db"),Ce=S(I,"vector-db"),De=S($,"settings.json"),xe=S($,"commands"),ke=S($,"CLAUDE.md");function W(d){re(d,{recursive:!0})}function H(){return S(ie,"..","..")}var U=(o=>(o[o.DEBUG=0]="DEBUG",o[o.INFO=1]="INFO",o[o.WARN=2]="WARN",o[o.ERROR=3]="ERROR",o[o.SILENT=4]="SILENT",o))(U||{}),w=class{level;useColor;constructor(){let e=process.env.CLAUDE_MEM_LOG_LEVEL?.toUpperCase()||"INFO";this.level=U[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,o){if(e<this.level)return;let c=new Date().toISOString().replace("T"," ").substring(0,23),a=U[e].padEnd(5),u=s.padEnd(6),m="";r?.correlationId?m=`[${r.correlationId}] `:r?.sessionId&&(m=`[session-${r.sessionId}] `);let E="";o!=null&&(this.level===0&&typeof o=="object"?E=`
`+JSON.stringify(o,null,2):E=" "+this.formatData(o));let n="";if(r){let{sessionId:f,sdkSessionId:N,correlationId:l,...p}=r;Object.keys(p).length>0&&(n=` {${Object.entries(p).map(([_,h])=>`${_}=${h}`).join(", ")}}`)}let y=`[${c}] [${a}] [${u}] ${m}${t}${n}${E}`;e===3?console.error(y):console.log(y)}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`})}},G=new w;var D=class{db;constructor(){W(I),this.db=new ae(j),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,
applied_at TEXT NOT NULL
)
`);let e=this.db.prepare("SELECT version FROM schema_versions ORDER BY version").all();(e.length>0?Math.max(...e.map(s=>s.version)):0)===0&&(console.error("[SessionStore] Initializing fresh database with migration004..."),this.db.exec(`
`);let e=this.db.prepare("SELECT version FROM schema_versions ORDER BY version").all();(e.length>0?Math.max(...e.map(t=>t.version)):0)===0&&(console.error("[SessionStore] Initializing fresh database with migration004..."),this.db.exec(`
CREATE TABLE IF NOT EXISTS sdk_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
claude_session_id TEXT UNIQUE NOT NULL,
@@ -63,7 +63,7 @@ ${e.stack}`:e.message;if(Array.isArray(e))return`[${e.length} items]`;let t=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(_=>_.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(_=>_.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(_=>_.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(u=>u.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(u=>u.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(u=>u.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,
@@ -99,7 +99,7 @@ ${e.stack}`:e.message;if(Array.isArray(e))return`[${e.length} items]`;let t=Obje
ALTER TABLE observations ADD COLUMN concepts TEXT;
ALTER TABLE observations ADD COLUMN files_read TEXT;
ALTER TABLE observations ADD COLUMN files_modified TEXT;
`),this.db.prepare("INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)").run(8,new Date().toISOString()),console.error("[SessionStore] Successfully added hierarchical fields to observations table")}catch(e){console.error("[SessionStore] Migration error (add hierarchical fields):",e.message)}}makeObservationsTextNullable(){try{if(this.db.prepare("SELECT version FROM schema_versions WHERE version = ?").get(9))return;let s=this.db.pragma("table_info(observations)").find(r=>r.name==="text");if(!s||s.notnull===0){this.db.prepare("INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)").run(9,new Date().toISOString());return}console.error("[SessionStore] Making observations.text nullable..."),this.db.exec("BEGIN TRANSACTION");try{this.db.exec(`
`),this.db.prepare("INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)").run(8,new Date().toISOString()),console.error("[SessionStore] Successfully added hierarchical fields to observations table")}catch(e){console.error("[SessionStore] Migration error (add hierarchical fields):",e.message)}}makeObservationsTextNullable(){try{if(this.db.prepare("SELECT version FROM schema_versions WHERE version = ?").get(9))return;let t=this.db.pragma("table_info(observations)").find(r=>r.name==="text");if(!t||t.notnull===0){this.db.prepare("INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)").run(9,new Date().toISOString());return}console.error("[SessionStore] Making observations.text nullable..."),this.db.exec("BEGIN TRANSACTION");try{this.db.exec(`
CREATE TABLE observations_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sdk_session_id TEXT NOT NULL,
@@ -166,7 +166,7 @@ ${e.stack}`:e.message;if(Array.isArray(e))return`[${e.length} items]`;let t=Obje
INSERT INTO user_prompts_fts(rowid, prompt_text)
VALUES (new.id, new.prompt_text);
END;
`),this.db.exec("COMMIT"),this.db.prepare("INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)").run(10,new Date().toISOString()),console.error("[SessionStore] Successfully created user_prompts table with FTS5 support")}catch(s){throw this.db.exec("ROLLBACK"),s}}catch(e){console.error("[SessionStore] Migration error (create user_prompts table):",e.message)}}getRecentSummaries(e,t=10){return this.db.prepare(`
`),this.db.exec("COMMIT"),this.db.prepare("INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)").run(10,new Date().toISOString()),console.error("[SessionStore] Successfully created user_prompts table with FTS5 support")}catch(t){throw this.db.exec("ROLLBACK"),t}}catch(e){console.error("[SessionStore] Migration error (create user_prompts table):",e.message)}}getRecentSummaries(e,s=10){return this.db.prepare(`
SELECT
request, investigated, learned, completed, next_steps,
files_read, files_edited, notes, prompt_number, created_at
@@ -174,7 +174,7 @@ ${e.stack}`:e.message;if(Array.isArray(e))return`[${e.length} items]`;let t=Obje
WHERE project = ?
ORDER BY created_at_epoch DESC
LIMIT ?
`).all(e,t)}getRecentSummariesWithSessionInfo(e,t=3){return this.db.prepare(`
`).all(e,s)}getRecentSummariesWithSessionInfo(e,s=3){return this.db.prepare(`
SELECT
sdk_session_id, request, learned, completed, next_steps,
prompt_number, created_at
@@ -182,13 +182,42 @@ ${e.stack}`:e.message;if(Array.isArray(e))return`[${e.length} items]`;let t=Obje
WHERE project = ?
ORDER BY created_at_epoch DESC
LIMIT ?
`).all(e,t)}getRecentObservations(e,t=20){return this.db.prepare(`
`).all(e,s)}getRecentObservations(e,s=20){return this.db.prepare(`
SELECT type, text, prompt_number, created_at
FROM observations
WHERE project = ?
ORDER BY created_at_epoch DESC
LIMIT ?
`).all(e,t)}getRecentSessionsWithStatus(e,t=3){return this.db.prepare(`
`).all(e,s)}getAllRecentObservations(e=100){return this.db.prepare(`
SELECT id, type, title, subtitle, text, project, prompt_number, created_at, created_at_epoch
FROM observations
ORDER BY created_at_epoch DESC
LIMIT ?
`).all(e)}getAllRecentSummaries(e=50){return this.db.prepare(`
SELECT id, request, investigated, learned, completed, next_steps,
files_read, files_edited, notes, project, prompt_number,
created_at, created_at_epoch
FROM session_summaries
ORDER BY created_at_epoch DESC
LIMIT ?
`).all(e)}getAllRecentUserPrompts(e=100){return this.db.prepare(`
SELECT
up.id,
up.claude_session_id,
s.project,
up.prompt_number,
up.prompt_text,
up.created_at,
up.created_at_epoch
FROM user_prompts up
LEFT JOIN sdk_sessions s ON up.claude_session_id = s.claude_session_id
ORDER BY up.created_at_epoch DESC
LIMIT ?
`).all(e)}getAllProjects(){return this.db.prepare(`
SELECT DISTINCT project
FROM sdk_sessions
ORDER BY project ASC
`).all().map(t=>t.project)}getRecentSessionsWithStatus(e,s=3){return this.db.prepare(`
SELECT * FROM (
SELECT
s.sdk_session_id,
@@ -205,7 +234,7 @@ ${e.stack}`:e.message;if(Array.isArray(e))return`[${e.length} items]`;let t=Obje
LIMIT ?
)
ORDER BY started_at_epoch ASC
`).all(e,t)}getObservationsForSession(e){return this.db.prepare(`
`).all(e,s)}getObservationsForSession(e){return this.db.prepare(`
SELECT title, subtitle, type, prompt_number
FROM observations
WHERE sdk_session_id = ?
@@ -214,7 +243,7 @@ ${e.stack}`:e.message;if(Array.isArray(e))return`[${e.length} items]`;let t=Obje
SELECT *
FROM observations
WHERE id = ?
`).get(e)||null}getObservationsByIds(e,t={}){if(e.length===0)return[];let{orderBy:s="date_desc",limit:r}=t,o=s==="date_asc"?"ASC":"DESC",c=r?`LIMIT ${r}`:"",a=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,o=t==="date_asc"?"ASC":"DESC",c=r?`LIMIT ${r}`:"",a=e.map(()=>"?").join(",");return this.db.prepare(`
SELECT *
FROM observations
WHERE id IN (${a})
@@ -228,11 +257,11 @@ ${e.stack}`:e.message;if(Array.isArray(e))return`[${e.length} items]`;let t=Obje
WHERE sdk_session_id = ?
ORDER BY created_at_epoch DESC
LIMIT 1
`).get(e)||null}getFilesForSession(e){let s=this.db.prepare(`
`).get(e)||null}getFilesForSession(e){let t=this.db.prepare(`
SELECT files_read, files_modified
FROM observations
WHERE sdk_session_id = ?
`).all(e),r=new Set,o=new Set;for(let c of s){if(c.files_read)try{let a=JSON.parse(c.files_read);Array.isArray(a)&&a.forEach(_=>r.add(_))}catch{}if(c.files_modified)try{let a=JSON.parse(c.files_modified);Array.isArray(a)&&a.forEach(_=>o.add(_))}catch{}}return{filesRead:Array.from(r),filesModified:Array.from(o)}}getSessionById(e){return this.db.prepare(`
`).all(e),r=new Set,o=new Set;for(let c of t){if(c.files_read)try{let a=JSON.parse(c.files_read);Array.isArray(a)&&a.forEach(u=>r.add(u))}catch{}if(c.files_modified)try{let a=JSON.parse(c.files_modified);Array.isArray(a)&&a.forEach(u=>o.add(u))}catch{}}return{filesRead:Array.from(r),filesModified:Array.from(o)}}getSessionById(e){return this.db.prepare(`
SELECT id, claude_session_id, sdk_session_id, project, user_prompt
FROM sdk_sessions
WHERE id = ?
@@ -247,11 +276,11 @@ ${e.stack}`:e.message;if(Array.isArray(e))return`[${e.length} items]`;let t=Obje
FROM sdk_sessions
WHERE claude_session_id = ?
LIMIT 1
`).get(e)||null}reactivateSession(e,t){this.db.prepare(`
`).get(e)||null}reactivateSession(e,s){this.db.prepare(`
UPDATE sdk_sessions
SET status = 'active', user_prompt = ?, worker_port = NULL
WHERE id = ?
`).run(t,e)}incrementPromptCounter(e){return this.db.prepare(`
`).run(s,e)}incrementPromptCounter(e){return this.db.prepare(`
UPDATE sdk_sessions
SET prompt_counter = COALESCE(prompt_counter, 0) + 1
WHERE id = ?
@@ -259,69 +288,69 @@ ${e.stack}`:e.message;if(Array.isArray(e))return`[${e.length} items]`;let t=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,t,s){let r=new Date,o=r.getTime(),a=this.db.prepare(`
`).get(e)?.prompt_counter||0}createSDKSession(e,s,t){let r=new Date,o=r.getTime(),a=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,t,s,r.toISOString(),o);return a.lastInsertRowid===0||a.changes===0?this.db.prepare(`
`).run(e,e,s,t,r.toISOString(),o);return a.lastInsertRowid===0||a.changes===0?this.db.prepare(`
SELECT id FROM sdk_sessions WHERE claude_session_id = ? LIMIT 1
`).get(e).id:a.lastInsertRowid}updateSDKSessionId(e,t){return this.db.prepare(`
`).get(e).id:a.lastInsertRowid}updateSDKSessionId(e,s){return this.db.prepare(`
UPDATE sdk_sessions
SET sdk_session_id = ?
WHERE id = ? AND sdk_session_id IS NULL
`).run(t,e).changes===0?(j.debug("DB","sdk_session_id already set, skipping update",{sessionId:e,sdkSessionId:t}),!1):!0}setWorkerPort(e,t){this.db.prepare(`
`).run(s,e).changes===0?(G.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 = ?
`).run(t,e)}getWorkerPort(e){return this.db.prepare(`
`).run(s,e)}getWorkerPort(e){return this.db.prepare(`
SELECT worker_port
FROM sdk_sessions
WHERE id = ?
LIMIT 1
`).get(e)?.worker_port||null}saveUserPrompt(e,t,s){let r=new Date,o=r.getTime();return this.db.prepare(`
`).get(e)?.worker_port||null}saveUserPrompt(e,s,t){let r=new Date,o=r.getTime();return this.db.prepare(`
INSERT INTO user_prompts
(claude_session_id, prompt_number, prompt_text, created_at, created_at_epoch)
VALUES (?, ?, ?, ?, ?)
`).run(e,t,s,r.toISOString(),o).lastInsertRowid}storeObservation(e,t,s,r){let o=new Date,c=o.getTime();this.db.prepare(`
`).run(e,s,t,r.toISOString(),o).lastInsertRowid}storeObservation(e,s,t,r){let o=new Date,c=o.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,t,o.toISOString(),c),console.error(`[SessionStore] Auto-created session record for session_id: ${e}`));let E=this.db.prepare(`
`).run(e,e,s,o.toISOString(),c),console.error(`[SessionStore] Auto-created session record for session_id: ${e}`));let E=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,t,s.type,s.title,s.subtitle,JSON.stringify(s.facts),s.narrative,JSON.stringify(s.concepts),JSON.stringify(s.files_read),JSON.stringify(s.files_modified),r||null,o.toISOString(),c);return{id:Number(E.lastInsertRowid),createdAtEpoch:c}}storeSummary(e,t,s,r){let o=new Date,c=o.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,o.toISOString(),c);return{id:Number(E.lastInsertRowid),createdAtEpoch:c}}storeSummary(e,s,t,r){let o=new Date,c=o.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,t,o.toISOString(),c),console.error(`[SessionStore] Auto-created session record for session_id: ${e}`));let E=this.db.prepare(`
`).run(e,e,s,o.toISOString(),c),console.error(`[SessionStore] Auto-created session record for session_id: ${e}`));let E=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,t,s.request,s.investigated,s.learned,s.completed,s.next_steps,s.notes,r||null,o.toISOString(),c);return{id:Number(E.lastInsertRowid),createdAtEpoch:c}}markSessionCompleted(e){let t=new Date,s=t.getTime();this.db.prepare(`
`).run(e,s,t.request,t.investigated,t.learned,t.completed,t.next_steps,t.notes,r||null,o.toISOString(),c);return{id:Number(E.lastInsertRowid),createdAtEpoch:c}}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 = ?
`).run(t.toISOString(),s,e)}markSessionFailed(e){let t=new Date,s=t.getTime();this.db.prepare(`
`).run(s.toISOString(),t,e)}markSessionFailed(e){let s=new Date,t=s.getTime();this.db.prepare(`
UPDATE sdk_sessions
SET status = 'failed', completed_at = ?, completed_at_epoch = ?
WHERE id = ?
`).run(t.toISOString(),s,e)}cleanupOrphanedSessions(){let e=new Date,t=e.getTime();return this.db.prepare(`
`).run(s.toISOString(),t,e)}cleanupOrphanedSessions(){let e=new Date,s=e.getTime();return this.db.prepare(`
UPDATE sdk_sessions
SET status = 'failed', completed_at = ?, completed_at_epoch = ?
WHERE status = 'active'
`).run(e.toISOString(),t).changes}getSessionSummariesByIds(e,t={}){if(e.length===0)return[];let{orderBy:s="date_desc",limit:r}=t,o=s==="date_asc"?"ASC":"DESC",c=r?`LIMIT ${r}`:"",a=e.map(()=>"?").join(",");return this.db.prepare(`
`).run(e.toISOString(),s).changes}getSessionSummariesByIds(e,s={}){if(e.length===0)return[];let{orderBy:t="date_desc",limit:r}=s,o=t==="date_asc"?"ASC":"DESC",c=r?`LIMIT ${r}`:"",a=e.map(()=>"?").join(",");return this.db.prepare(`
SELECT * FROM session_summaries
WHERE id IN (${a})
ORDER BY created_at_epoch ${o}
${c}
`).all(...e)}getUserPromptsByIds(e,t={}){if(e.length===0)return[];let{orderBy:s="date_desc",limit:r}=t,o=s==="date_asc"?"ASC":"DESC",c=r?`LIMIT ${r}`:"",a=e.map(()=>"?").join(",");return this.db.prepare(`
`).all(...e)}getUserPromptsByIds(e,s={}){if(e.length===0)return[];let{orderBy:t="date_desc",limit:r}=s,o=t==="date_asc"?"ASC":"DESC",c=r?`LIMIT ${r}`:"",a=e.map(()=>"?").join(",");return this.db.prepare(`
SELECT
up.*,
s.project,
@@ -331,7 +360,7 @@ ${e.stack}`:e.message;if(Array.isArray(e))return`[${e.length} items]`;let t=Obje
WHERE up.id IN (${a})
ORDER BY up.created_at_epoch ${o}
${c}
`).all(...e)}getTimelineAroundTimestamp(e,t=10,s=10,r){return this.getTimelineAroundObservation(null,e,t,s,r)}getTimelineAroundObservation(e,t,s=10,r=10,o){let c=o?"AND project = ?":"",a=o?[o]:[],_,l;if(e!==null){let f=`
`).all(...e)}getTimelineAroundTimestamp(e,s=10,t=10,r){return this.getTimelineAroundObservation(null,e,s,t,r)}getTimelineAroundObservation(e,s,t=10,r=10,o){let c=o?"AND project = ?":"",a=o?[o]:[],u,m;if(e!==null){let f=`
SELECT id, created_at_epoch
FROM observations
WHERE id <= ? ${c}
@@ -343,7 +372,7 @@ ${e.stack}`:e.message;if(Array.isArray(e))return`[${e.length} items]`;let t=Obje
WHERE id >= ? ${c}
ORDER BY id ASC
LIMIT ?
`;try{let m=this.db.prepare(f).all(e,...a,s+1),p=this.db.prepare(N).all(e,...a,r+1);if(m.length===0&&p.length===0)return{observations:[],sessions:[],prompts:[]};_=m.length>0?m[m.length-1].created_at_epoch:t,l=p.length>0?p[p.length-1].created_at_epoch:t}catch(m){return console.error("[SessionStore] Error getting boundary observations:",m.message),{observations:[],sessions:[],prompts:[]}}}else{let f=`
`;try{let l=this.db.prepare(f).all(e,...a,t+1),p=this.db.prepare(N).all(e,...a,r+1);if(l.length===0&&p.length===0)return{observations:[],sessions:[],prompts:[]};u=l.length>0?l[l.length-1].created_at_epoch:s,m=p.length>0?p[p.length-1].created_at_epoch:s}catch(l){return console.error("[SessionStore] Error getting boundary observations:",l.message),{observations:[],sessions:[],prompts:[]}}}else{let f=`
SELECT created_at_epoch
FROM observations
WHERE created_at_epoch <= ? ${c}
@@ -355,7 +384,7 @@ ${e.stack}`:e.message;if(Array.isArray(e))return`[${e.length} items]`;let t=Obje
WHERE created_at_epoch >= ? ${c}
ORDER BY created_at_epoch ASC
LIMIT ?
`;try{let m=this.db.prepare(f).all(t,...a,s),p=this.db.prepare(N).all(t,...a,r+1);if(m.length===0&&p.length===0)return{observations:[],sessions:[],prompts:[]};_=m.length>0?m[m.length-1].created_at_epoch:t,l=p.length>0?p[p.length-1].created_at_epoch:t}catch(m){return console.error("[SessionStore] Error getting boundary timestamps:",m.message),{observations:[],sessions:[],prompts:[]}}}let E=`
`;try{let l=this.db.prepare(f).all(s,...a,t),p=this.db.prepare(N).all(s,...a,r+1);if(l.length===0&&p.length===0)return{observations:[],sessions:[],prompts:[]};u=l.length>0?l[l.length-1].created_at_epoch:s,m=p.length>0?p[p.length-1].created_at_epoch:s}catch(l){return console.error("[SessionStore] Error getting boundary timestamps:",l.message),{observations:[],sessions:[],prompts:[]}}}let E=`
SELECT *
FROM observations
WHERE created_at_epoch >= ? AND created_at_epoch <= ? ${c}
@@ -365,13 +394,13 @@ ${e.stack}`:e.message;if(Array.isArray(e))return`[${e.length} items]`;let t=Obje
FROM session_summaries
WHERE created_at_epoch >= ? AND created_at_epoch <= ? ${c}
ORDER BY created_at_epoch ASC
`,v=`
`,y=`
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 <= ? ${c.replace("project","s.project")}
ORDER BY up.created_at_epoch ASC
`;try{let f=this.db.prepare(E).all(_,l,...a),N=this.db.prepare(n).all(_,l,...a),m=this.db.prepare(v).all(_,l,...a);return{observations:f,sessions:N.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:m.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(f){return console.error("[SessionStore] Error querying timeline records:",f.message),{observations:[],sessions:[],prompts:[]}}}close(){this.db.close()}};import Y from"path";import{spawn as V}from"child_process";var de=parseInt(process.env.CLAUDE_MEM_WORKER_PORT||"37777",10);async function K(d=3e3){try{return(await fetch(`http://127.0.0.1:${de}/health`,{signal:AbortSignal.timeout(d)})).ok}catch{return!1}}async function ce(d=1e4){let e=Date.now(),t=100;for(;Date.now()-e<d;){if(await K(1e3))return!0;await new Promise(s=>setTimeout(s,t))}return!1}async function q(){if(await K(1e3))return;let d=G(),e=Y.join(d,"node_modules",".bin","pm2"),t=Y.join(d,"ecosystem.config.cjs"),s=V(e,["list","--no-color"],{cwd:d,stdio:["ignore","pipe","ignore"]}),r="";if(s.stdout?.on("data",a=>{r+=a.toString()}),await new Promise((a,_)=>{s.on("error",l=>_(l)),s.on("close",l=>{a()})}),!(r.includes("claude-mem-worker")&&r.includes("online"))){let a=V(e,["start",t],{cwd:d,stdio:"ignore"});await new Promise((_,l)=>{a.on("error",E=>l(E)),a.on("close",E=>{E!==0&&E!==null?l(new Error(`PM2 start command failed with exit code ${E}`)):_()})})}if(!await ce(1e4))throw new Error("Worker failed to become healthy after starting")}var pe=parseInt(process.env.CLAUDE_MEM_CONTEXT_OBSERVATIONS||"50",10),J=10,i={reset:"\x1B[0m",bright:"\x1B[1m",dim:"\x1B[2m",cyan:"\x1B[36m",green:"\x1B[32m",yellow:"\x1B[33m",blue:"\x1B[34m",magenta:"\x1B[35m",gray:"\x1B[90m",red:"\x1B[31m"};function _e(d){if(!d)return[];let e=JSON.parse(d);return Array.isArray(e)?e:[]}function ue(d){return new Date(d).toLocaleString("en-US",{month:"short",day:"numeric",hour:"numeric",minute:"2-digit",hour12:!0})}function me(d){return new Date(d).toLocaleString("en-US",{hour:"numeric",minute:"2-digit",hour12:!0})}function le(d){return new Date(d).toLocaleString("en-US",{month:"short",day:"numeric",year:"numeric"})}function Ee(d){return d?Math.ceil(d.length/4):0}function Te(d,e){return X.isAbsolute(d)?X.relative(e,d):d}async function Q(d,e=!1,t=!1){await q();let s=d?.cwd??process.cwd(),r=s?X.basename(s):"unknown-project",o=new D,c=o.db.prepare(`
`;try{let f=this.db.prepare(E).all(u,m,...a),N=this.db.prepare(n).all(u,m,...a),l=this.db.prepare(y).all(u,m,...a);return{observations:f,sessions:N.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:l.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(f){return console.error("[SessionStore] Error querying timeline records:",f.message),{observations:[],sessions:[],prompts:[]}}}close(){this.db.close()}};import Y from"path";import{spawn as V}from"child_process";var de=parseInt(process.env.CLAUDE_MEM_WORKER_PORT||"37777",10);async function q(d=100){try{return(await fetch(`http://127.0.0.1:${de}/health`,{signal:AbortSignal.timeout(d)})).ok}catch{return!1}}async function ce(d=1e4){let e=Date.now(),s=100;for(;Date.now()-e<d;){if(await q(1e3))return!0;await new Promise(t=>setTimeout(t,s))}return!1}async function K(){if(await q())return;let d=H(),e=Y.join(d,"node_modules",".bin","pm2"),s=Y.join(d,"ecosystem.config.cjs"),t=V(e,["list","--no-color"],{cwd:d,stdio:["ignore","pipe","ignore"]}),r="";if(t.stdout?.on("data",a=>{r+=a.toString()}),await new Promise((a,u)=>{t.on("error",m=>u(m)),t.on("close",m=>{a()})}),!(r.includes("claude-mem-worker")&&r.includes("online"))){let a=V(e,["start",s],{cwd:d,stdio:"ignore"});await new Promise((u,m)=>{a.on("error",E=>m(E)),a.on("close",E=>{E!==0&&E!==null?m(new Error(`PM2 start command failed with exit code ${E}`)):u()})})}if(!await ce(1e4))throw new Error("Worker failed to become healthy after starting")}var pe=parseInt(process.env.CLAUDE_MEM_CONTEXT_OBSERVATIONS||"50",10),J=10,i={reset:"\x1B[0m",bright:"\x1B[1m",dim:"\x1B[2m",cyan:"\x1B[36m",green:"\x1B[32m",yellow:"\x1B[33m",blue:"\x1B[34m",magenta:"\x1B[35m",gray:"\x1B[90m",red:"\x1B[31m"};function ue(d){if(!d)return[];let e=JSON.parse(d);return Array.isArray(e)?e:[]}function _e(d){return new Date(d).toLocaleString("en-US",{month:"short",day:"numeric",hour:"numeric",minute:"2-digit",hour12:!0})}function le(d){return new Date(d).toLocaleString("en-US",{hour:"numeric",minute:"2-digit",hour12:!0})}function me(d){return new Date(d).toLocaleString("en-US",{month:"short",day:"numeric",year:"numeric"})}function Ee(d){return d?Math.ceil(d.length/4):0}function Te(d,e){return F.isAbsolute(d)?F.relative(e,d):d}async function Q(d,e=!1,s=!1){await K();let t=d?.cwd??process.cwd(),r=t?F.basename(t):"unknown-project",o=new D,c=o.db.prepare(`
SELECT
id, sdk_session_id, type, title, subtitle, narrative,
facts, concepts, files_read, files_modified,
@@ -393,5 +422,5 @@ ${i.gray}${"\u2500".repeat(60)}${i.reset}
${i.dim}No previous sessions found for this project yet.${i.reset}
`:`# [${r}] recent context
No previous sessions found for this project yet.`;let _=c,l=a.slice(0,J),E=_,n=[];if(e?(n.push(""),n.push(`${i.bright}${i.cyan}\u{1F4DD} [${r}] recent context${i.reset}`),n.push(`${i.gray}${"\u2500".repeat(60)}${i.reset}`),n.push("")):(n.push(`# [${r}] recent context`),n.push("")),E.length>0){e?(n.push(`${i.dim}Legend: \u{1F3AF} session-request | \u{1F534} bugfix | \u{1F7E3} feature | \u{1F504} refactor | \u2705 change | \u{1F535} discovery | \u{1F9E0} decision${i.reset}`),n.push("")):(n.push("**Legend:** \u{1F3AF} session-request | \u{1F534} bugfix | \u{1F7E3} feature | \u{1F504} refactor | \u2705 change | \u{1F535} discovery | \u{1F9E0} decision"),n.push("")),e?(n.push(`${i.dim}\u{1F4A1} Progressive Disclosure: This index shows WHAT exists (titles) and retrieval COST (token counts).${i.reset}`),n.push(`${i.dim} \u2192 Use MCP search tools to fetch full observation details on-demand (Layer 2)${i.reset}`),n.push(`${i.dim} \u2192 Prefer searching observations over re-reading code for past decisions and learnings${i.reset}`),n.push(`${i.dim} \u2192 Critical types (\u{1F534} bugfix, \u{1F9E0} decision) often worth fetching immediately${i.reset}`),n.push("")):(n.push("\u{1F4A1} **Progressive Disclosure:** This index shows WHAT exists (titles) and retrieval COST (token counts)."),n.push("- Use MCP search tools to fetch full observation details on-demand (Layer 2)"),n.push("- Prefer searching observations over re-reading code for past decisions and learnings"),n.push("- Critical types (\u{1F534} bugfix, \u{1F9E0} decision) often worth fetching immediately"),n.push(""));let v=a[0]?.id,f=l.map((u,h)=>{let T=h===0?null:a[h+1];return{...u,displayEpoch:T?T.created_at_epoch:u.created_at_epoch,displayTime:T?T.created_at:u.created_at,isMostRecent:u.id===v}}),N=[...E.map(u=>({type:"observation",data:u})),...f.map(u=>({type:"summary",data:u}))];N.sort((u,h)=>{let T=u.type==="observation"?u.data.created_at_epoch:u.data.displayEpoch,L=h.type==="observation"?h.data.created_at_epoch:h.data.displayEpoch;return T-L});let m=new Map;for(let u of N){let h=u.type==="observation"?u.data.created_at:u.data.displayTime,T=le(h);m.has(T)||m.set(T,[]),m.get(T).push(u)}let p=Array.from(m.entries()).sort((u,h)=>{let T=new Date(u[0]).getTime(),L=new Date(h[0]).getTime();return T-L});for(let[u,h]of p){e?(n.push(`${i.bright}${i.cyan}${u}${i.reset}`),n.push("")):(n.push(`### ${u}`),n.push(""));let T=null,L="",y=!1;for(let k of h)if(k.type==="summary"){y&&(n.push(""),y=!1,T=null,L="");let g=k.data,A=`${g.request||"Session started"} (${ue(g.displayTime)})`,O=g.isMostRecent?"":`claude-mem://session-summary/${g.id}`;if(e){let b=O?`${i.dim}[${O}]${i.reset}`:"";n.push(`\u{1F3AF} ${i.yellow}#S${g.id}${i.reset} ${A} ${b}`)}else{let b=O?` [\u2192](${O})`:"";n.push(`**\u{1F3AF} #S${g.id}** ${A}${b}`)}n.push("")}else{let g=k.data,A=_e(g.files_modified),O=A.length>0?Te(A[0],s):"General";O!==T&&(y&&n.push(""),e?n.push(`${i.dim}${O}${i.reset}`):n.push(`**${O}**`),e||(n.push("| ID | Time | T | Title | Tokens |"),n.push("|----|------|---|-------|--------|")),T=O,y=!0,L="");let b="\u2022";switch(g.type){case"bugfix":b="\u{1F534}";break;case"feature":b="\u{1F7E3}";break;case"refactor":b="\u{1F504}";break;case"change":b="\u2705";break;case"discovery":b="\u{1F535}";break;case"decision":b="\u{1F9E0}";break;default:b="\u2022"}let C=me(g.created_at),F=g.title||"Untitled",x=Ee(g.narrative),P=C!==L,Z=P?C:"";if(L=C,e){let ee=P?`${i.dim}${C}${i.reset}`:" ".repeat(C.length),se=x>0?`${i.dim}(~${x}t)${i.reset}`:"";n.push(` ${i.dim}#${g.id}${i.reset} ${ee} ${b} ${F} ${se}`)}else n.push(`| #${g.id} | ${Z||"\u2033"} | ${b} | ${F} | ~${x} |`)}y&&n.push("")}let R=a[0];R&&(R.completed||R.next_steps)&&(R.completed&&(e?n.push(`${i.green}Completed:${i.reset} ${R.completed}`):n.push(`**Completed**: ${R.completed}`),n.push("")),R.next_steps&&(e?n.push(`${i.magenta}Next Steps:${i.reset} ${R.next_steps}`):n.push(`**Next Steps**: ${R.next_steps}`),n.push(""))),e?n.push(`${i.dim}Use claude-mem MCP search to access records with the given ID${i.reset}`):n.push("*Use claude-mem MCP search to access records with the given ID*")}return o.close(),n.join(`
`).trimEnd()}var z=process.argv.includes("--index"),he=process.argv.includes("--colors");if(M.isTTY||he)Q(void 0,!0,z).then(d=>{console.log(d),process.exit(0)});else{let d="";M.on("data",e=>d+=e),M.on("end",async()=>{let e=d.trim()?JSON.parse(d):void 0,s={hookSpecificOutput:{hookEventName:"SessionStart",additionalContext:await Q(e,!1,z)}};console.log(JSON.stringify(s)),process.exit(0)})}
No previous sessions found for this project yet.`;let u=c,m=a.slice(0,J),E=u,n=[];if(e?(n.push(""),n.push(`${i.bright}${i.cyan}\u{1F4DD} [${r}] recent context${i.reset}`),n.push(`${i.gray}${"\u2500".repeat(60)}${i.reset}`),n.push("")):(n.push(`# [${r}] recent context`),n.push("")),E.length>0){e?(n.push(`${i.dim}Legend: \u{1F3AF} session-request | \u{1F534} bugfix | \u{1F7E3} feature | \u{1F504} refactor | \u2705 change | \u{1F535} discovery | \u{1F9E0} decision${i.reset}`),n.push("")):(n.push("**Legend:** \u{1F3AF} session-request | \u{1F534} bugfix | \u{1F7E3} feature | \u{1F504} refactor | \u2705 change | \u{1F535} discovery | \u{1F9E0} decision"),n.push("")),e?(n.push(`${i.dim}\u{1F4A1} Progressive Disclosure: This index shows WHAT exists (titles) and retrieval COST (token counts).${i.reset}`),n.push(`${i.dim} \u2192 Use MCP search tools to fetch full observation details on-demand (Layer 2)${i.reset}`),n.push(`${i.dim} \u2192 Prefer searching observations over re-reading code for past decisions and learnings${i.reset}`),n.push(`${i.dim} \u2192 Critical types (\u{1F534} bugfix, \u{1F9E0} decision) often worth fetching immediately${i.reset}`),n.push("")):(n.push("\u{1F4A1} **Progressive Disclosure:** This index shows WHAT exists (titles) and retrieval COST (token counts)."),n.push("- Use MCP search tools to fetch full observation details on-demand (Layer 2)"),n.push("- Prefer searching observations over re-reading code for past decisions and learnings"),n.push("- Critical types (\u{1F534} bugfix, \u{1F9E0} decision) often worth fetching immediately"),n.push(""));let y=a[0]?.id,f=m.map((_,h)=>{let T=h===0?null:a[h+1];return{..._,displayEpoch:T?T.created_at_epoch:_.created_at_epoch,displayTime:T?T.created_at:_.created_at,isMostRecent:_.id===y}}),N=[...E.map(_=>({type:"observation",data:_})),...f.map(_=>({type:"summary",data:_}))];N.sort((_,h)=>{let T=_.type==="observation"?_.data.created_at_epoch:_.data.displayEpoch,L=h.type==="observation"?h.data.created_at_epoch:h.data.displayEpoch;return T-L});let l=new Map;for(let _ of N){let h=_.type==="observation"?_.data.created_at:_.data.displayTime,T=me(h);l.has(T)||l.set(T,[]),l.get(T).push(_)}let p=Array.from(l.entries()).sort((_,h)=>{let T=new Date(_[0]).getTime(),L=new Date(h[0]).getTime();return T-L});for(let[_,h]of p){e?(n.push(`${i.bright}${i.cyan}${_}${i.reset}`),n.push("")):(n.push(`### ${_}`),n.push(""));let T=null,L="",v=!1;for(let x of h)if(x.type==="summary"){v&&(n.push(""),v=!1,T=null,L="");let g=x.data,A=`${g.request||"Session started"} (${_e(g.displayTime)})`,O=g.isMostRecent?"":`claude-mem://session-summary/${g.id}`;if(e){let b=O?`${i.dim}[${O}]${i.reset}`:"";n.push(`\u{1F3AF} ${i.yellow}#S${g.id}${i.reset} ${A} ${b}`)}else{let b=O?` [\u2192](${O})`:"";n.push(`**\u{1F3AF} #S${g.id}** ${A}${b}`)}n.push("")}else{let g=x.data,A=ue(g.files_modified),O=A.length>0?Te(A[0],t):"General";O!==T&&(v&&n.push(""),e?n.push(`${i.dim}${O}${i.reset}`):n.push(`**${O}**`),e||(n.push("| ID | Time | T | Title | Tokens |"),n.push("|----|------|---|-------|--------|")),T=O,v=!0,L="");let b="\u2022";switch(g.type){case"bugfix":b="\u{1F534}";break;case"feature":b="\u{1F7E3}";break;case"refactor":b="\u{1F504}";break;case"change":b="\u2705";break;case"discovery":b="\u{1F535}";break;case"decision":b="\u{1F9E0}";break;default:b="\u2022"}let C=le(g.created_at),X=g.title||"Untitled",k=Ee(g.narrative),P=C!==L,Z=P?C:"";if(L=C,e){let ee=P?`${i.dim}${C}${i.reset}`:" ".repeat(C.length),se=k>0?`${i.dim}(~${k}t)${i.reset}`:"";n.push(` ${i.dim}#${g.id}${i.reset} ${ee} ${b} ${X} ${se}`)}else n.push(`| #${g.id} | ${Z||"\u2033"} | ${b} | ${X} | ~${k} |`)}v&&n.push("")}let R=a[0];R&&(R.completed||R.next_steps)&&(R.completed&&(e?n.push(`${i.green}Completed:${i.reset} ${R.completed}`):n.push(`**Completed**: ${R.completed}`),n.push("")),R.next_steps&&(e?n.push(`${i.magenta}Next Steps:${i.reset} ${R.next_steps}`):n.push(`**Next Steps**: ${R.next_steps}`),n.push(""))),e?n.push(`${i.dim}Use claude-mem MCP search to access records with the given ID${i.reset}`):n.push("*Use claude-mem MCP search to access records with the given ID*")}return o.close(),n.join(`
`).trimEnd()}var z=process.argv.includes("--index"),he=process.argv.includes("--colors");if(M.isTTY||he)Q(void 0,!0,z).then(d=>{console.log(d),process.exit(0)});else{let d="";M.on("data",e=>d+=e),M.on("end",async()=>{let e=d.trim()?JSON.parse(d):void 0,t={hookSpecificOutput:{hookEventName:"SessionStart",additionalContext:await Q(e,!1,z)}};console.log(JSON.stringify(t)),process.exit(0)})}
+38 -9
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env node
import Y from"path";import{stdin as U}from"process";import $ from"better-sqlite3";import{join as E,dirname as X,basename as J}from"path";import{homedir as f}from"os";import{existsSync as ee,mkdirSync as F}from"fs";import{fileURLToPath as P}from"url";function H(){return typeof __dirname<"u"?__dirname:X(P(import.meta.url))}var B=H(),m=process.env.CLAUDE_MEM_DATA_DIR||E(f(),".claude-mem"),g=process.env.CLAUDE_CONFIG_DIR||E(f(),".claude"),te=E(m,"archives"),re=E(m,"logs"),ne=E(m,"trash"),oe=E(m,"backups"),ie=E(m,"settings.json"),I=E(m,"claude-mem.db"),ae=E(m,"vector-db"),de=E(g,"settings.json"),pe=E(g,"commands"),ce=E(g,"CLAUDE.md");function L(p){F(p,{recursive:!0})}function A(){return E(B,"..","..")}var h=(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))(h||{}),N=class{level;useColor;constructor(){let e=process.env.CLAUDE_MEM_LOG_LEVEL?.toUpperCase()||"INFO";this.level=h[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}
import Y from"path";import{stdin as U}from"process";import j from"better-sqlite3";import{join as m,dirname as X,basename as J}from"path";import{homedir as I}from"os";import{existsSync as ee,mkdirSync as F}from"fs";import{fileURLToPath as P}from"url";function B(){return typeof __dirname<"u"?__dirname:X(P(import.meta.url))}var H=B(),E=process.env.CLAUDE_MEM_DATA_DIR||m(I(),".claude-mem"),R=process.env.CLAUDE_CONFIG_DIR||m(I(),".claude"),te=m(E,"archives"),re=m(E,"logs"),ne=m(E,"trash"),oe=m(E,"backups"),ie=m(E,"settings.json"),f=m(E,"claude-mem.db"),ae=m(E,"vector-db"),de=m(R,"settings.json"),pe=m(R,"commands"),ce=m(R,"CLAUDE.md");function L(p){F(p,{recursive:!0})}function A(){return m(H,"..","..")}var h=(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))(h||{}),N=class{level;useColor;constructor(){let e=process.env.CLAUDE_MEM_LOG_LEVEL?.toUpperCase()||"INFO";this.level=h[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=h[e].padEnd(5),a=s.padEnd(6),c="";r?.correlationId?c=`[${r.correlationId}] `:r?.sessionId&&(c=`[session-${r.sessionId}] `);let _="";n!=null&&(this.level===0&&typeof n=="object"?_=`
`+JSON.stringify(n,null,2):_=" "+this.formatData(n));let T="";if(r){let{sessionId:l,sdkSessionId:S,correlationId:u,...d}=r;Object.keys(d).length>0&&(T=` {${Object.entries(d).map(([w,M])=>`${w}=${M}`).join(", ")}}`)}let b=`[${i}] [${o}] [${a}] ${c}${t}${T}${_}`;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`})}},C=new N;var R=class{db;constructor(){L(m),this.db=new $(I),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(`
`+JSON.stringify(n,null,2):_=" "+this.formatData(n));let T="";if(r){let{sessionId:l,sdkSessionId:b,correlationId:u,...d}=r;Object.keys(d).length>0&&(T=` {${Object.entries(d).map(([w,M])=>`${w}=${M}`).join(", ")}}`)}let S=`[${i}] [${o}] [${a}] ${c}${t}${T}${_}`;e===3?console.error(S):console.log(S)}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`})}},C=new N;var g=class{db;constructor(){L(E),this.db=new j(f),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,
@@ -188,7 +188,36 @@ ${e.stack}`:e.message;if(Array.isArray(e))return`[${e.length} items]`;let s=Obje
WHERE project = ?
ORDER BY created_at_epoch DESC
LIMIT ?
`).all(e,s)}getRecentSessionsWithStatus(e,s=3){return this.db.prepare(`
`).all(e,s)}getAllRecentObservations(e=100){return this.db.prepare(`
SELECT id, type, title, subtitle, text, project, prompt_number, created_at, created_at_epoch
FROM observations
ORDER BY created_at_epoch DESC
LIMIT ?
`).all(e)}getAllRecentSummaries(e=50){return this.db.prepare(`
SELECT id, request, investigated, learned, completed, next_steps,
files_read, files_edited, notes, project, prompt_number,
created_at, created_at_epoch
FROM session_summaries
ORDER BY created_at_epoch DESC
LIMIT ?
`).all(e)}getAllRecentUserPrompts(e=100){return this.db.prepare(`
SELECT
up.id,
up.claude_session_id,
s.project,
up.prompt_number,
up.prompt_text,
up.created_at,
up.created_at_epoch
FROM user_prompts up
LEFT JOIN sdk_sessions s ON up.claude_session_id = s.claude_session_id
ORDER BY up.created_at_epoch DESC
LIMIT ?
`).all(e)}getAllProjects(){return this.db.prepare(`
SELECT DISTINCT project
FROM sdk_sessions
ORDER BY project ASC
`).all().map(t=>t.project)}getRecentSessionsWithStatus(e,s=3){return this.db.prepare(`
SELECT * FROM (
SELECT
s.sdk_session_id,
@@ -337,25 +366,25 @@ ${e.stack}`:e.message;if(Array.isArray(e))return`[${e.length} items]`;let s=Obje
WHERE id <= ? ${i}
ORDER BY id DESC
LIMIT ?
`,S=`
`,b=`
SELECT id, created_at_epoch
FROM observations
WHERE id >= ? ${i}
ORDER BY id ASC
LIMIT ?
`;try{let u=this.db.prepare(l).all(e,...o,t+1),d=this.db.prepare(S).all(e,...o,r+1);if(u.length===0&&d.length===0)return{observations:[],sessions:[],prompts:[]};a=u.length>0?u[u.length-1].created_at_epoch:s,c=d.length>0?d[d.length-1].created_at_epoch:s}catch(u){return console.error("[SessionStore] Error getting boundary observations:",u.message),{observations:[],sessions:[],prompts:[]}}}else{let l=`
`;try{let u=this.db.prepare(l).all(e,...o,t+1),d=this.db.prepare(b).all(e,...o,r+1);if(u.length===0&&d.length===0)return{observations:[],sessions:[],prompts:[]};a=u.length>0?u[u.length-1].created_at_epoch:s,c=d.length>0?d[d.length-1].created_at_epoch:s}catch(u){return console.error("[SessionStore] Error getting boundary observations:",u.message),{observations:[],sessions:[],prompts:[]}}}else{let l=`
SELECT created_at_epoch
FROM observations
WHERE created_at_epoch <= ? ${i}
ORDER BY created_at_epoch DESC
LIMIT ?
`,S=`
`,b=`
SELECT created_at_epoch
FROM observations
WHERE created_at_epoch >= ? ${i}
ORDER BY created_at_epoch ASC
LIMIT ?
`;try{let u=this.db.prepare(l).all(s,...o,t),d=this.db.prepare(S).all(s,...o,r+1);if(u.length===0&&d.length===0)return{observations:[],sessions:[],prompts:[]};a=u.length>0?u[u.length-1].created_at_epoch:s,c=d.length>0?d[d.length-1].created_at_epoch:s}catch(u){return console.error("[SessionStore] Error getting boundary timestamps:",u.message),{observations:[],sessions:[],prompts:[]}}}let _=`
`;try{let u=this.db.prepare(l).all(s,...o,t),d=this.db.prepare(b).all(s,...o,r+1);if(u.length===0&&d.length===0)return{observations:[],sessions:[],prompts:[]};a=u.length>0?u[u.length-1].created_at_epoch:s,c=d.length>0?d[d.length-1].created_at_epoch:s}catch(u){return console.error("[SessionStore] Error getting boundary timestamps:",u.message),{observations:[],sessions:[],prompts:[]}}}let _=`
SELECT *
FROM observations
WHERE created_at_epoch >= ? AND created_at_epoch <= ? ${i}
@@ -365,10 +394,10 @@ ${e.stack}`:e.message;if(Array.isArray(e))return`[${e.length} items]`;let s=Obje
FROM session_summaries
WHERE created_at_epoch >= ? AND created_at_epoch <= ? ${i}
ORDER BY created_at_epoch ASC
`,b=`
`,S=`
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 <= ? ${i.replace("project","s.project")}
ORDER BY up.created_at_epoch ASC
`;try{let l=this.db.prepare(_).all(a,c,...o),S=this.db.prepare(T).all(a,c,...o),u=this.db.prepare(b).all(a,c,...o);return{observations:l,sessions:S.map(d=>({id:d.id,sdk_session_id:d.sdk_session_id,project:d.project,request:d.request,completed:d.completed,next_steps:d.next_steps,created_at:d.created_at,created_at_epoch:d.created_at_epoch})),prompts:u.map(d=>({id:d.id,claude_session_id:d.claude_session_id,project:d.project,prompt:d.prompt_text,created_at:d.created_at,created_at_epoch:d.created_at_epoch}))}}catch(l){return console.error("[SessionStore] Error querying timeline records:",l.message),{observations:[],sessions:[],prompts:[]}}}close(){this.db.close()}};function W(p,e,s){return p==="PreCompact"?e?{continue:!0,suppressOutput:!0}:{continue:!1,stopReason:s.reason||"Pre-compact operation failed",suppressOutput:!0}:p==="SessionStart"?e&&s.context?{continue:!0,suppressOutput:!0,hookSpecificOutput:{hookEventName:"SessionStart",additionalContext:s.context}}:{continue:!0,suppressOutput:!0}:p==="UserPromptSubmit"||p==="PostToolUse"?{continue:!0,suppressOutput:!0}:p==="Stop"?{continue:!0,suppressOutput:!0}:{continue:e,suppressOutput:!0,...s.reason&&!e?{stopReason:s.reason}:{}}}function v(p,e,s={}){let t=W(p,e,s);return JSON.stringify(t)}import y from"path";import{spawn as D}from"child_process";var G=parseInt(process.env.CLAUDE_MEM_WORKER_PORT||"37777",10);async function k(p=3e3){try{return(await fetch(`http://127.0.0.1:${G}/health`,{signal:AbortSignal.timeout(p)})).ok}catch{return!1}}async function j(p=1e4){let e=Date.now(),s=100;for(;Date.now()-e<p;){if(await k(1e3))return!0;await new Promise(t=>setTimeout(t,s))}return!1}async function x(){if(await k(1e3))return;let p=A(),e=y.join(p,"node_modules",".bin","pm2"),s=y.join(p,"ecosystem.config.cjs"),t=D(e,["list","--no-color"],{cwd:p,stdio:["ignore","pipe","ignore"]}),r="";if(t.stdout?.on("data",o=>{r+=o.toString()}),await new Promise((o,a)=>{t.on("error",c=>a(c)),t.on("close",c=>{o()})}),!(r.includes("claude-mem-worker")&&r.includes("online"))){let o=D(e,["start",s],{cwd:p,stdio:"ignore"});await new Promise((a,c)=>{o.on("error",_=>c(_)),o.on("close",_=>{_!==0&&_!==null?c(new Error(`PM2 start command failed with exit code ${_}`)):a()})})}if(!await j(1e4))throw new Error("Worker failed to become healthy after starting")}async function K(p){if(!p)throw new Error("newHook requires input");let{session_id:e,cwd:s,prompt:t}=p,r=Y.basename(s);await x();let n=new R,i=n.createSDKSession(e,r,t),o=n.incrementPromptCounter(i);n.saveUserPrompt(e,o,t),console.error(`[new-hook] Session ${i}, prompt #${o}`),n.close();let a=parseInt(process.env.CLAUDE_MEM_WORKER_PORT||"37777",10);try{let c=await fetch(`http://127.0.0.1:${a}/sessions/${i}/init`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({project:r,userPrompt:t}),signal:AbortSignal.timeout(5e3)});if(!c.ok){let _=await c.text();throw new Error(`Failed to initialize session: ${c.status} ${_}`)}}catch(c){throw c.cause?.code==="ECONNREFUSED"||c.name==="TimeoutError"||c.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"):c}console.log(v("UserPromptSubmit",!0))}var O="";U.on("data",p=>O+=p);U.on("end",async()=>{let p=O?JSON.parse(O):void 0;await K(p)});
`;try{let l=this.db.prepare(_).all(a,c,...o),b=this.db.prepare(T).all(a,c,...o),u=this.db.prepare(S).all(a,c,...o);return{observations:l,sessions:b.map(d=>({id:d.id,sdk_session_id:d.sdk_session_id,project:d.project,request:d.request,completed:d.completed,next_steps:d.next_steps,created_at:d.created_at,created_at_epoch:d.created_at_epoch})),prompts:u.map(d=>({id:d.id,claude_session_id:d.claude_session_id,project:d.project,prompt:d.prompt_text,created_at:d.created_at,created_at_epoch:d.created_at_epoch}))}}catch(l){return console.error("[SessionStore] Error querying timeline records:",l.message),{observations:[],sessions:[],prompts:[]}}}close(){this.db.close()}};function $(p,e,s){return p==="PreCompact"?e?{continue:!0,suppressOutput:!0}:{continue:!1,stopReason:s.reason||"Pre-compact operation failed",suppressOutput:!0}:p==="SessionStart"?e&&s.context?{continue:!0,suppressOutput:!0,hookSpecificOutput:{hookEventName:"SessionStart",additionalContext:s.context}}:{continue:!0,suppressOutput:!0}:p==="UserPromptSubmit"||p==="PostToolUse"?{continue:!0,suppressOutput:!0}:p==="Stop"?{continue:!0,suppressOutput:!0}:{continue:e,suppressOutput:!0,...s.reason&&!e?{stopReason:s.reason}:{}}}function v(p,e,s={}){let t=$(p,e,s);return JSON.stringify(t)}import y from"path";import{spawn as D}from"child_process";var W=parseInt(process.env.CLAUDE_MEM_WORKER_PORT||"37777",10);async function k(p=100){try{return(await fetch(`http://127.0.0.1:${W}/health`,{signal:AbortSignal.timeout(p)})).ok}catch{return!1}}async function G(p=1e4){let e=Date.now(),s=100;for(;Date.now()-e<p;){if(await k(1e3))return!0;await new Promise(t=>setTimeout(t,s))}return!1}async function x(){if(await k())return;let p=A(),e=y.join(p,"node_modules",".bin","pm2"),s=y.join(p,"ecosystem.config.cjs"),t=D(e,["list","--no-color"],{cwd:p,stdio:["ignore","pipe","ignore"]}),r="";if(t.stdout?.on("data",o=>{r+=o.toString()}),await new Promise((o,a)=>{t.on("error",c=>a(c)),t.on("close",c=>{o()})}),!(r.includes("claude-mem-worker")&&r.includes("online"))){let o=D(e,["start",s],{cwd:p,stdio:"ignore"});await new Promise((a,c)=>{o.on("error",_=>c(_)),o.on("close",_=>{_!==0&&_!==null?c(new Error(`PM2 start command failed with exit code ${_}`)):a()})})}if(!await G(1e4))throw new Error("Worker failed to become healthy after starting")}async function K(p){if(!p)throw new Error("newHook requires input");let{session_id:e,cwd:s,prompt:t}=p,r=Y.basename(s);await x();let n=new g,i=n.createSDKSession(e,r,t),o=n.incrementPromptCounter(i);n.saveUserPrompt(e,o,t),console.error(`[new-hook] Session ${i}, prompt #${o}`),n.close();let a=parseInt(process.env.CLAUDE_MEM_WORKER_PORT||"37777",10);try{let c=await fetch(`http://127.0.0.1:${a}/sessions/${i}/init`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({project:r,userPrompt:t}),signal:AbortSignal.timeout(5e3)});if(!c.ok){let _=await c.text();throw new Error(`Failed to initialize session: ${c.status} ${_}`)}}catch(c){throw c.cause?.code==="ECONNREFUSED"||c.name==="TimeoutError"||c.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"):c}console.log(v("UserPromptSubmit",!0))}var O="";U.on("data",p=>O+=p);U.on("end",async()=>{let p=O?JSON.parse(O):void 0;await K(p)});
+66 -37
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env node
import{stdin as U}from"process";import $ from"better-sqlite3";import{join as E,dirname as X,basename as J}from"path";import{homedir as L}from"os";import{existsSync as ee,mkdirSync as F}from"fs";import{fileURLToPath as P}from"url";function H(){return typeof __dirname<"u"?__dirname:X(P(import.meta.url))}var B=H(),l=process.env.CLAUDE_MEM_DATA_DIR||E(L(),".claude-mem"),h=process.env.CLAUDE_CONFIG_DIR||E(L(),".claude"),te=E(l,"archives"),re=E(l,"logs"),oe=E(l,"trash"),ne=E(l,"backups"),ie=E(l,"settings.json"),A=E(l,"claude-mem.db"),ae=E(l,"vector-db"),de=E(h,"settings.json"),pe=E(h,"commands"),ce=E(h,"CLAUDE.md");function v(p){F(p,{recursive:!0})}function C(){return E(B,"..","..")}var N=(o=>(o[o.DEBUG=0]="DEBUG",o[o.INFO=1]="INFO",o[o.WARN=2]="WARN",o[o.ERROR=3]="ERROR",o[o.SILENT=4]="SILENT",o))(N||{}),O=class{level;useColor;constructor(){let e=process.env.CLAUDE_MEM_LOG_LEVEL?.toUpperCase()||"INFO";this.level=N[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,o){if(e<this.level)return;let n=new Date().toISOString().replace("T"," ").substring(0,23),i=N[e].padEnd(5),a=s.padEnd(6),_="";r?.correlationId?_=`[${r.correlationId}] `:r?.sessionId&&(_=`[session-${r.sessionId}] `);let c="";o!=null&&(this.level===0&&typeof o=="object"?c=`
`+JSON.stringify(o,null,2):c=" "+this.formatData(o));let m="";if(r){let{sessionId:T,sdkSessionId:b,correlationId:u,...d}=r;Object.keys(d).length>0&&(m=` {${Object.entries(d).map(([w,M])=>`${w}=${M}`).join(", ")}}`)}let R=`[${n}] [${i}] [${a}] ${_}${t}${m}${c}`;e===3?console.error(R):console.log(R)}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`})}},S=new O;var g=class{db;constructor(){v(l),this.db=new $(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 U}from"process";import j from"better-sqlite3";import{join as E,dirname as F,basename as J}from"path";import{homedir as L}from"os";import{existsSync as ee,mkdirSync as X}from"fs";import{fileURLToPath as P}from"url";function B(){return typeof __dirname<"u"?__dirname:F(P(import.meta.url))}var H=B(),l=process.env.CLAUDE_MEM_DATA_DIR||E(L(),".claude-mem"),h=process.env.CLAUDE_CONFIG_DIR||E(L(),".claude"),te=E(l,"archives"),re=E(l,"logs"),ne=E(l,"trash"),oe=E(l,"backups"),ie=E(l,"settings.json"),A=E(l,"claude-mem.db"),ae=E(l,"vector-db"),de=E(h,"settings.json"),pe=E(h,"commands"),ce=E(h,"CLAUDE.md");function C(p){X(p,{recursive:!0})}function v(){return E(H,"..","..")}var N=(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))(N||{}),O=class{level;useColor;constructor(){let e=process.env.CLAUDE_MEM_LOG_LEVEL?.toUpperCase()||"INFO";this.level=N[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=N[e].padEnd(5),a=s.padEnd(6),_="";r?.correlationId?_=`[${r.correlationId}] `:r?.sessionId&&(_=`[session-${r.sessionId}] `);let c="";n!=null&&(this.level===0&&typeof n=="object"?c=`
`+JSON.stringify(n,null,2):c=" "+this.formatData(n));let m="";if(r){let{sessionId:T,sdkSessionId:g,correlationId:u,...d}=r;Object.keys(d).length>0&&(m=` {${Object.entries(d).map(([w,M])=>`${w}=${M}`).join(", ")}}`)}let S=`[${o}] [${i}] [${a}] ${_}${t}${m}${c}`;e===3?console.error(S):console.log(S)}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 O;var R=class{db;constructor(){C(l),this.db=new j(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,
@@ -188,7 +188,36 @@ ${e.stack}`:e.message;if(Array.isArray(e))return`[${e.length} items]`;let s=Obje
WHERE project = ?
ORDER BY created_at_epoch DESC
LIMIT ?
`).all(e,s)}getRecentSessionsWithStatus(e,s=3){return this.db.prepare(`
`).all(e,s)}getAllRecentObservations(e=100){return this.db.prepare(`
SELECT id, type, title, subtitle, text, project, prompt_number, created_at, created_at_epoch
FROM observations
ORDER BY created_at_epoch DESC
LIMIT ?
`).all(e)}getAllRecentSummaries(e=50){return this.db.prepare(`
SELECT id, request, investigated, learned, completed, next_steps,
files_read, files_edited, notes, project, prompt_number,
created_at, created_at_epoch
FROM session_summaries
ORDER BY created_at_epoch DESC
LIMIT ?
`).all(e)}getAllRecentUserPrompts(e=100){return this.db.prepare(`
SELECT
up.id,
up.claude_session_id,
s.project,
up.prompt_number,
up.prompt_text,
up.created_at,
up.created_at_epoch
FROM user_prompts up
LEFT JOIN sdk_sessions s ON up.claude_session_id = s.claude_session_id
ORDER BY up.created_at_epoch DESC
LIMIT ?
`).all(e)}getAllProjects(){return this.db.prepare(`
SELECT DISTINCT project
FROM sdk_sessions
ORDER BY project ASC
`).all().map(t=>t.project)}getRecentSessionsWithStatus(e,s=3){return this.db.prepare(`
SELECT * FROM (
SELECT
s.sdk_session_id,
@@ -214,12 +243,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,o=t==="date_asc"?"ASC":"DESC",n=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",o=r?`LIMIT ${r}`:"",i=e.map(()=>"?").join(",");return this.db.prepare(`
SELECT *
FROM observations
WHERE id IN (${i})
ORDER BY created_at_epoch ${o}
${n}
ORDER BY created_at_epoch ${n}
${o}
`).all(...e)}getSummaryForSession(e){return this.db.prepare(`
SELECT
request, investigated, learned, completed, next_steps,
@@ -232,7 +261,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,o=new Set;for(let n of t){if(n.files_read)try{let i=JSON.parse(n.files_read);Array.isArray(i)&&i.forEach(a=>r.add(a))}catch{}if(n.files_modified)try{let i=JSON.parse(n.files_modified);Array.isArray(i)&&i.forEach(a=>o.add(a))}catch{}}return{filesRead:Array.from(r),filesModified:Array.from(o)}}getSessionById(e){return this.db.prepare(`
`).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(a=>r.add(a))}catch{}if(o.files_modified)try{let i=JSON.parse(o.files_modified);Array.isArray(i)&&i.forEach(a=>n.add(a))}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 = ?
@@ -259,17 +288,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,o=r.getTime(),i=this.db.prepare(`
`).get(e)?.prompt_counter||0}createSDKSession(e,s,t){let r=new Date,n=r.getTime(),i=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(),o);return i.lastInsertRowid===0||i.changes===0?this.db.prepare(`
`).run(e,e,s,t,r.toISOString(),n);return i.lastInsertRowid===0||i.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(`
UPDATE sdk_sessions
SET sdk_session_id = ?
WHERE id = ? AND sdk_session_id IS NULL
`).run(s,e).changes===0?(S.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?(b.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 = ?
@@ -278,33 +307,33 @@ ${e.stack}`:e.message;if(Array.isArray(e))return`[${e.length} items]`;let s=Obje
FROM sdk_sessions
WHERE id = ?
LIMIT 1
`).get(e)?.worker_port||null}saveUserPrompt(e,s,t){let r=new Date,o=r.getTime();return this.db.prepare(`
`).get(e)?.worker_port||null}saveUserPrompt(e,s,t){let r=new Date,n=r.getTime();return this.db.prepare(`
INSERT INTO user_prompts
(claude_session_id, prompt_number, prompt_text, created_at, created_at_epoch)
VALUES (?, ?, ?, ?, ?)
`).run(e,s,t,r.toISOString(),o).lastInsertRowid}storeObservation(e,s,t,r){let o=new Date,n=o.getTime();this.db.prepare(`
`).run(e,s,t,r.toISOString(),n).lastInsertRowid}storeObservation(e,s,t,r){let n=new Date,o=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,o.toISOString(),n),console.error(`[SessionStore] Auto-created session record for session_id: ${e}`));let c=this.db.prepare(`
`).run(e,e,s,n.toISOString(),o),console.error(`[SessionStore] Auto-created session record for session_id: ${e}`));let c=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,o.toISOString(),n);return{id:Number(c.lastInsertRowid),createdAtEpoch:n}}storeSummary(e,s,t,r){let o=new Date,n=o.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(),o);return{id:Number(c.lastInsertRowid),createdAtEpoch:o}}storeSummary(e,s,t,r){let n=new Date,o=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,o.toISOString(),n),console.error(`[SessionStore] Auto-created session record for session_id: ${e}`));let c=this.db.prepare(`
`).run(e,e,s,n.toISOString(),o),console.error(`[SessionStore] Auto-created session record for session_id: ${e}`));let c=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,o.toISOString(),n);return{id:Number(c.lastInsertRowid),createdAtEpoch:n}}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(),o);return{id:Number(c.lastInsertRowid),createdAtEpoch:o}}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 = ?
@@ -316,12 +345,12 @@ ${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 status = 'active'
`).run(e.toISOString(),s).changes}getSessionSummariesByIds(e,s={}){if(e.length===0)return[];let{orderBy:t="date_desc",limit:r}=s,o=t==="date_asc"?"ASC":"DESC",n=r?`LIMIT ${r}`:"",i=e.map(()=>"?").join(",");return this.db.prepare(`
`).run(e.toISOString(),s).changes}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(`
SELECT * FROM session_summaries
WHERE id IN (${i})
ORDER BY created_at_epoch ${o}
${n}
`).all(...e)}getUserPromptsByIds(e,s={}){if(e.length===0)return[];let{orderBy:t="date_desc",limit:r}=s,o=t==="date_asc"?"ASC":"DESC",n=r?`LIMIT ${r}`:"",i=e.map(()=>"?").join(",");return this.db.prepare(`
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(`
SELECT
up.*,
s.project,
@@ -329,46 +358,46 @@ ${e.stack}`:e.message;if(Array.isArray(e))return`[${e.length} items]`;let s=Obje
FROM user_prompts up
JOIN sdk_sessions s ON up.claude_session_id = s.claude_session_id
WHERE up.id IN (${i})
ORDER BY up.created_at_epoch ${o}
${n}
`).all(...e)}getTimelineAroundTimestamp(e,s=10,t=10,r){return this.getTimelineAroundObservation(null,e,s,t,r)}getTimelineAroundObservation(e,s,t=10,r=10,o){let n=o?"AND project = ?":"",i=o?[o]:[],a,_;if(e!==null){let T=`
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]:[],a,_;if(e!==null){let T=`
SELECT id, created_at_epoch
FROM observations
WHERE id <= ? ${n}
WHERE id <= ? ${o}
ORDER BY id DESC
LIMIT ?
`,b=`
`,g=`
SELECT id, created_at_epoch
FROM observations
WHERE id >= ? ${n}
WHERE id >= ? ${o}
ORDER BY id ASC
LIMIT ?
`;try{let u=this.db.prepare(T).all(e,...i,t+1),d=this.db.prepare(b).all(e,...i,r+1);if(u.length===0&&d.length===0)return{observations:[],sessions:[],prompts:[]};a=u.length>0?u[u.length-1].created_at_epoch:s,_=d.length>0?d[d.length-1].created_at_epoch:s}catch(u){return console.error("[SessionStore] Error getting boundary observations:",u.message),{observations:[],sessions:[],prompts:[]}}}else{let T=`
`;try{let u=this.db.prepare(T).all(e,...i,t+1),d=this.db.prepare(g).all(e,...i,r+1);if(u.length===0&&d.length===0)return{observations:[],sessions:[],prompts:[]};a=u.length>0?u[u.length-1].created_at_epoch:s,_=d.length>0?d[d.length-1].created_at_epoch:s}catch(u){return console.error("[SessionStore] Error getting boundary observations:",u.message),{observations:[],sessions:[],prompts:[]}}}else{let T=`
SELECT created_at_epoch
FROM observations
WHERE created_at_epoch <= ? ${n}
WHERE created_at_epoch <= ? ${o}
ORDER BY created_at_epoch DESC
LIMIT ?
`,b=`
`,g=`
SELECT created_at_epoch
FROM observations
WHERE created_at_epoch >= ? ${n}
WHERE created_at_epoch >= ? ${o}
ORDER BY created_at_epoch ASC
LIMIT ?
`;try{let u=this.db.prepare(T).all(s,...i,t),d=this.db.prepare(b).all(s,...i,r+1);if(u.length===0&&d.length===0)return{observations:[],sessions:[],prompts:[]};a=u.length>0?u[u.length-1].created_at_epoch:s,_=d.length>0?d[d.length-1].created_at_epoch:s}catch(u){return console.error("[SessionStore] Error getting boundary timestamps:",u.message),{observations:[],sessions:[],prompts:[]}}}let c=`
`;try{let u=this.db.prepare(T).all(s,...i,t),d=this.db.prepare(g).all(s,...i,r+1);if(u.length===0&&d.length===0)return{observations:[],sessions:[],prompts:[]};a=u.length>0?u[u.length-1].created_at_epoch:s,_=d.length>0?d[d.length-1].created_at_epoch:s}catch(u){return console.error("[SessionStore] Error getting boundary timestamps:",u.message),{observations:[],sessions:[],prompts:[]}}}let c=`
SELECT *
FROM observations
WHERE created_at_epoch >= ? AND created_at_epoch <= ? ${n}
WHERE created_at_epoch >= ? AND created_at_epoch <= ? ${o}
ORDER BY created_at_epoch ASC
`,m=`
SELECT *
FROM session_summaries
WHERE created_at_epoch >= ? AND created_at_epoch <= ? ${n}
WHERE created_at_epoch >= ? AND created_at_epoch <= ? ${o}
ORDER BY created_at_epoch ASC
`,R=`
`,S=`
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 <= ? ${n.replace("project","s.project")}
WHERE up.created_at_epoch >= ? AND up.created_at_epoch <= ? ${o.replace("project","s.project")}
ORDER BY up.created_at_epoch ASC
`;try{let T=this.db.prepare(c).all(a,_,...i),b=this.db.prepare(m).all(a,_,...i),u=this.db.prepare(R).all(a,_,...i);return{observations:T,sessions:b.map(d=>({id:d.id,sdk_session_id:d.sdk_session_id,project:d.project,request:d.request,completed:d.completed,next_steps:d.next_steps,created_at:d.created_at,created_at_epoch:d.created_at_epoch})),prompts:u.map(d=>({id:d.id,claude_session_id:d.claude_session_id,project:d.project,prompt:d.prompt_text,created_at:d.created_at,created_at_epoch:d.created_at_epoch}))}}catch(T){return console.error("[SessionStore] Error querying timeline records:",T.message),{observations:[],sessions:[],prompts:[]}}}close(){this.db.close()}};function W(p,e,s){return p==="PreCompact"?e?{continue:!0,suppressOutput:!0}:{continue:!1,stopReason:s.reason||"Pre-compact operation failed",suppressOutput:!0}:p==="SessionStart"?e&&s.context?{continue:!0,suppressOutput:!0,hookSpecificOutput:{hookEventName:"SessionStart",additionalContext:s.context}}:{continue:!0,suppressOutput:!0}:p==="UserPromptSubmit"||p==="PostToolUse"?{continue:!0,suppressOutput:!0}:p==="Stop"?{continue:!0,suppressOutput:!0}:{continue:e,suppressOutput:!0,...s.reason&&!e?{stopReason:s.reason}:{}}}function f(p,e,s={}){let t=W(p,e,s);return JSON.stringify(t)}import y from"path";import{spawn as D}from"child_process";var G=parseInt(process.env.CLAUDE_MEM_WORKER_PORT||"37777",10);async function k(p=3e3){try{return(await fetch(`http://127.0.0.1:${G}/health`,{signal:AbortSignal.timeout(p)})).ok}catch{return!1}}async function j(p=1e4){let e=Date.now(),s=100;for(;Date.now()-e<p;){if(await k(1e3))return!0;await new Promise(t=>setTimeout(t,s))}return!1}async function x(){if(await k(1e3))return;let p=C(),e=y.join(p,"node_modules",".bin","pm2"),s=y.join(p,"ecosystem.config.cjs"),t=D(e,["list","--no-color"],{cwd:p,stdio:["ignore","pipe","ignore"]}),r="";if(t.stdout?.on("data",i=>{r+=i.toString()}),await new Promise((i,a)=>{t.on("error",_=>a(_)),t.on("close",_=>{i()})}),!(r.includes("claude-mem-worker")&&r.includes("online"))){let i=D(e,["start",s],{cwd:p,stdio:"ignore"});await new Promise((a,_)=>{i.on("error",c=>_(c)),i.on("close",c=>{c!==0&&c!==null?_(new Error(`PM2 start command failed with exit code ${c}`)):a()})})}if(!await j(1e4))throw new Error("Worker failed to become healthy after starting")}var Y=new Set(["ListMcpResourcesTool"]);async function K(p){if(!p)throw new Error("saveHook requires input");let{session_id:e,tool_name:s,tool_input:t,tool_output:r}=p;if(Y.has(s)){console.log(f("PostToolUse",!0));return}await x();let o=new g,n=o.createSDKSession(e,"",""),i=o.getPromptCounter(n);o.close();let a=S.formatTool(s,t),_=parseInt(process.env.CLAUDE_MEM_WORKER_PORT||"37777",10);S.dataIn("HOOK",`PostToolUse: ${a}`,{sessionId:n,workerPort:_});try{let c=await fetch(`http://127.0.0.1:${_}/sessions/${n}/observations`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({tool_name:s,tool_input:t!==void 0?JSON.stringify(t):"{}",tool_output:r!==void 0?JSON.stringify(r):"{}",prompt_number:i}),signal:AbortSignal.timeout(2e3)});if(!c.ok){let m=await c.text();throw S.failure("HOOK","Failed to send observation",{sessionId:n,status:c.status},m),new Error(`Failed to send observation to worker: ${c.status} ${m}`)}S.debug("HOOK","Observation sent successfully",{sessionId:n,toolName:s})}catch(c){throw c.cause?.code==="ECONNREFUSED"||c.name==="TimeoutError"||c.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"):c}console.log(f("PostToolUse",!0))}var I="";U.on("data",p=>I+=p);U.on("end",async()=>{let p=I?JSON.parse(I):void 0;await K(p)});
`;try{let T=this.db.prepare(c).all(a,_,...i),g=this.db.prepare(m).all(a,_,...i),u=this.db.prepare(S).all(a,_,...i);return{observations:T,sessions:g.map(d=>({id:d.id,sdk_session_id:d.sdk_session_id,project:d.project,request:d.request,completed:d.completed,next_steps:d.next_steps,created_at:d.created_at,created_at_epoch:d.created_at_epoch})),prompts:u.map(d=>({id:d.id,claude_session_id:d.claude_session_id,project:d.project,prompt:d.prompt_text,created_at:d.created_at,created_at_epoch:d.created_at_epoch}))}}catch(T){return console.error("[SessionStore] Error querying timeline records:",T.message),{observations:[],sessions:[],prompts:[]}}}close(){this.db.close()}};function $(p,e,s){return p==="PreCompact"?e?{continue:!0,suppressOutput:!0}:{continue:!1,stopReason:s.reason||"Pre-compact operation failed",suppressOutput:!0}:p==="SessionStart"?e&&s.context?{continue:!0,suppressOutput:!0,hookSpecificOutput:{hookEventName:"SessionStart",additionalContext:s.context}}:{continue:!0,suppressOutput:!0}:p==="UserPromptSubmit"||p==="PostToolUse"?{continue:!0,suppressOutput:!0}:p==="Stop"?{continue:!0,suppressOutput:!0}:{continue:e,suppressOutput:!0,...s.reason&&!e?{stopReason:s.reason}:{}}}function f(p,e,s={}){let t=$(p,e,s);return JSON.stringify(t)}import y from"path";import{spawn as D}from"child_process";var W=parseInt(process.env.CLAUDE_MEM_WORKER_PORT||"37777",10);async function k(p=100){try{return(await fetch(`http://127.0.0.1:${W}/health`,{signal:AbortSignal.timeout(p)})).ok}catch{return!1}}async function G(p=1e4){let e=Date.now(),s=100;for(;Date.now()-e<p;){if(await k(1e3))return!0;await new Promise(t=>setTimeout(t,s))}return!1}async function x(){if(await k())return;let p=v(),e=y.join(p,"node_modules",".bin","pm2"),s=y.join(p,"ecosystem.config.cjs"),t=D(e,["list","--no-color"],{cwd:p,stdio:["ignore","pipe","ignore"]}),r="";if(t.stdout?.on("data",i=>{r+=i.toString()}),await new Promise((i,a)=>{t.on("error",_=>a(_)),t.on("close",_=>{i()})}),!(r.includes("claude-mem-worker")&&r.includes("online"))){let i=D(e,["start",s],{cwd:p,stdio:"ignore"});await new Promise((a,_)=>{i.on("error",c=>_(c)),i.on("close",c=>{c!==0&&c!==null?_(new Error(`PM2 start command failed with exit code ${c}`)):a()})})}if(!await G(1e4))throw new Error("Worker failed to become healthy after starting")}var Y=new Set(["ListMcpResourcesTool"]);async function K(p){if(!p)throw new Error("saveHook requires input");let{session_id:e,tool_name:s,tool_input:t,tool_output:r}=p;if(Y.has(s)){console.log(f("PostToolUse",!0));return}await x();let n=new R,o=n.createSDKSession(e,"",""),i=n.getPromptCounter(o);n.close();let a=b.formatTool(s,t),_=parseInt(process.env.CLAUDE_MEM_WORKER_PORT||"37777",10);b.dataIn("HOOK",`PostToolUse: ${a}`,{sessionId:o,workerPort:_});try{let c=await fetch(`http://127.0.0.1:${_}/sessions/${o}/observations`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({tool_name:s,tool_input:t!==void 0?JSON.stringify(t):"{}",tool_output:r!==void 0?JSON.stringify(r):"{}",prompt_number:i}),signal:AbortSignal.timeout(2e3)});if(!c.ok){let m=await c.text();throw b.failure("HOOK","Failed to send observation",{sessionId:o,status:c.status},m),new Error(`Failed to send observation to worker: ${c.status} ${m}`)}b.debug("HOOK","Observation sent successfully",{sessionId:o,toolName:s})}catch(c){throw c.cause?.code==="ECONNREFUSED"||c.name==="TimeoutError"||c.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"):c}console.log(f("PostToolUse",!0))}var I="";U.on("data",p=>I+=p);U.on("end",async()=>{let p=I?JSON.parse(I):void 0;await K(p)});
+33 -4
View File
@@ -135,9 +135,9 @@ import{Server as he}from"@modelcontextprotocol/sdk/server/index.js";import{Stdio
FROM user_prompts
WHERE claude_session_id = ?
ORDER BY prompt_number ASC
`).all(e)}close(){this.db.close()}};import me from"better-sqlite3";var K=(o=>(o[o.DEBUG=0]="DEBUG",o[o.INFO=1]="INFO",o[o.WARN=2]="WARN",o[o.ERROR=3]="ERROR",o[o.SILENT=4]="SILENT",o))(K||{}),Q=class{level;useColor;constructor(){let e=process.env.CLAUDE_MEM_LOG_LEVEL?.toUpperCase()||"INFO";this.level=K[e]??1,this.useColor=process.stdout.isTTY??!1}correlationId(e,r){return`obs-${e}-${r}`}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}
`).all(e)}close(){this.db.close()}};import me from"better-sqlite3";var K=(o=>(o[o.DEBUG=0]="DEBUG",o[o.INFO=1]="INFO",o[o.WARN=2]="WARN",o[o.ERROR=3]="ERROR",o[o.SILENT=4]="SILENT",o))(K||{}),J=class{level;useColor;constructor(){let e=process.env.CLAUDE_MEM_LOG_LEVEL?.toUpperCase()||"INFO";this.level=K[e]??1,this.useColor=process.stdout.isTTY??!1}correlationId(e,r){return`obs-${e}-${r}`}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 r=Object.keys(e);return r.length===0?"{}":r.length<=3?JSON.stringify(e):`{${r.length} keys: ${r.slice(0,3).join(", ")}...}`}return String(e)}formatTool(e,r){if(!r)return e;try{let s=typeof r=="string"?JSON.parse(r):r;if(e==="Bash"&&s.command){let t=s.command.length>50?s.command.substring(0,50)+"...":s.command;return`${e}(${t})`}if(e==="Read"&&s.file_path){let t=s.file_path.split("/").pop()||s.file_path;return`${e}(${t})`}if(e==="Edit"&&s.file_path){let t=s.file_path.split("/").pop()||s.file_path;return`${e}(${t})`}if(e==="Write"&&s.file_path){let t=s.file_path.split("/").pop()||s.file_path;return`${e}(${t})`}return e}catch{return e}}log(e,r,s,t,o){if(e<this.level)return;let n=new Date().toISOString().replace("T"," ").substring(0,23),a=K[e].padEnd(5),d=r.padEnd(6),l="";t?.correlationId?l=`[${t.correlationId}] `:t?.sessionId&&(l=`[session-${t.sessionId}] `);let u="";o!=null&&(this.level===0&&typeof o=="object"?u=`
`+JSON.stringify(o,null,2):u=" "+this.formatData(o));let p="";if(t){let{sessionId:f,sdkSessionId:h,correlationId:b,..._}=t;Object.keys(_).length>0&&(p=` {${Object.entries(_).map(([x,T])=>`${x}=${T}`).join(", ")}}`)}let m=`[${n}] [${a}] [${d}] ${l}${s}${p}${u}`;e===3?console.error(m):console.log(m)}debug(e,r,s,t){this.log(0,e,r,s,t)}info(e,r,s,t){this.log(1,e,r,s,t)}warn(e,r,s,t){this.log(2,e,r,s,t)}error(e,r,s,t){this.log(3,e,r,s,t)}dataIn(e,r,s,t){this.info(e,`\u2192 ${r}`,s,t)}dataOut(e,r,s,t){this.info(e,`\u2190 ${r}`,s,t)}success(e,r,s,t){this.info(e,`\u2713 ${r}`,s,t)}failure(e,r,s,t){this.error(e,`\u2717 ${r}`,s,t)}timing(e,r,s,t){this.info(e,`\u23F1 ${r}`,t,{duration:`${s}ms`})}},se=new Q;var H=class{db;constructor(){P(w),this.db=new me(X),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(`
`+JSON.stringify(o,null,2):u=" "+this.formatData(o));let p="";if(t){let{sessionId:f,sdkSessionId:h,correlationId:b,..._}=t;Object.keys(_).length>0&&(p=` {${Object.entries(_).map(([x,T])=>`${x}=${T}`).join(", ")}}`)}let m=`[${n}] [${a}] [${d}] ${l}${s}${p}${u}`;e===3?console.error(m):console.log(m)}debug(e,r,s,t){this.log(0,e,r,s,t)}info(e,r,s,t){this.log(1,e,r,s,t)}warn(e,r,s,t){this.log(2,e,r,s,t)}error(e,r,s,t){this.log(3,e,r,s,t)}dataIn(e,r,s,t){this.info(e,`\u2192 ${r}`,s,t)}dataOut(e,r,s,t){this.info(e,`\u2190 ${r}`,s,t)}success(e,r,s,t){this.info(e,`\u2713 ${r}`,s,t)}failure(e,r,s,t){this.error(e,`\u2717 ${r}`,s,t)}timing(e,r,s,t){this.info(e,`\u23F1 ${r}`,t,{duration:`${s}ms`})}},se=new J;var H=class{db;constructor(){P(w),this.db=new me(X),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,
@@ -324,7 +324,36 @@ ${e.stack}`:e.message;if(Array.isArray(e))return`[${e.length} items]`;let r=Obje
WHERE project = ?
ORDER BY created_at_epoch DESC
LIMIT ?
`).all(e,r)}getRecentSessionsWithStatus(e,r=3){return this.db.prepare(`
`).all(e,r)}getAllRecentObservations(e=100){return this.db.prepare(`
SELECT id, type, title, subtitle, text, project, prompt_number, created_at, created_at_epoch
FROM observations
ORDER BY created_at_epoch DESC
LIMIT ?
`).all(e)}getAllRecentSummaries(e=50){return this.db.prepare(`
SELECT id, request, investigated, learned, completed, next_steps,
files_read, files_edited, notes, project, prompt_number,
created_at, created_at_epoch
FROM session_summaries
ORDER BY created_at_epoch DESC
LIMIT ?
`).all(e)}getAllRecentUserPrompts(e=100){return this.db.prepare(`
SELECT
up.id,
up.claude_session_id,
s.project,
up.prompt_number,
up.prompt_text,
up.created_at,
up.created_at_epoch
FROM user_prompts up
LEFT JOIN sdk_sessions s ON up.claude_session_id = s.claude_session_id
ORDER BY up.created_at_epoch DESC
LIMIT ?
`).all(e)}getAllProjects(){return this.db.prepare(`
SELECT DISTINCT project
FROM sdk_sessions
ORDER BY project ASC
`).all().map(s=>s.project)}getRecentSessionsWithStatus(e,r=3){return this.db.prepare(`
SELECT * FROM (
SELECT
s.sdk_session_id,
@@ -585,4 +614,4 @@ No previous sessions found for project "${e}".`}]};let t=[];t.push("# Recent Ses
`);return{content:[{type:"text",text:o}]}}catch(e){return{content:[{type:"text",text:`Search failed: ${e.message}`}],isError:!0}}}},{name:"get_context_timeline",description:'Get a unified timeline of context (observations, sessions, and prompts) around a specific point in time. All record types are interleaved chronologically. Useful for understanding "what was happening when X occurred". Returns depth_before records before anchor + anchor + depth_after records after (total: depth_before + 1 + depth_after mixed records).',inputSchema:i.object({anchor:i.union([i.number().describe("Observation ID to center timeline around"),i.string().describe("Session ID (format: S123) or ISO timestamp to center timeline around")]).describe('Anchor point: observation ID, session ID (e.g., "S123"), or ISO timestamp'),depth_before:i.number().min(0).max(50).default(10).describe("Number of records to retrieve before anchor, not including anchor (default: 10)"),depth_after:i.number().min(0).max(50).default(10).describe("Number of records to retrieve after anchor, not including anchor (default: 10)"),project:i.string().optional().describe("Filter by project name")}),handler:async c=>{try{let f=function(g){return new Date(g).toLocaleString("en-US",{month:"short",day:"numeric",year:"numeric"})},h=function(g){return new Date(g).toLocaleString("en-US",{hour:"numeric",minute:"2-digit",hour12:!0})},b=function(g){return new Date(g).toLocaleString("en-US",{month:"short",day:"numeric",hour:"numeric",minute:"2-digit",hour12:!0})},_=function(g){return g?Math.ceil(g.length/4):0};var e=f,r=h,s=b,t=_;let{anchor:o,depth_before:n=10,depth_after:a=10,project:d}=c,l,u=o,p;if(typeof o=="number"){let g=N.getObservationById(o);if(!g)return{content:[{type:"text",text:`Observation #${o} not found`}],isError:!0};l=g.created_at_epoch,p=N.getTimelineAroundObservation(o,l,n,a,d)}else if(typeof o=="string")if(o.startsWith("S")||o.startsWith("#S")){let g=o.replace(/^#?S/,""),I=parseInt(g,10),S=N.getSessionSummariesByIds([I]);if(S.length===0)return{content:[{type:"text",text:`Session #${I} not found`}],isError:!0};l=S[0].created_at_epoch,u=`S${I}`,p=N.getTimelineAroundTimestamp(l,n,a,d)}else{let g=new Date(o);if(isNaN(g.getTime()))return{content:[{type:"text",text:`Invalid timestamp: ${o}`}],isError:!0};l=g.getTime(),p=N.getTimelineAroundTimestamp(l,n,a,d)}else return{content:[{type:"text",text:'Invalid anchor: must be observation ID (number), session ID (e.g., "S123"), or ISO timestamp'}],isError:!0};let m=[...p.observations.map(g=>({type:"observation",data:g,epoch:g.created_at_epoch})),...p.sessions.map(g=>({type:"session",data:g,epoch:g.created_at_epoch})),...p.prompts.map(g=>({type:"prompt",data:g,epoch:g.created_at_epoch}))];if(m.sort((g,I)=>g.epoch-I.epoch),m.length===0)return{content:[{type:"text",text:`No context found around ${new Date(l).toLocaleString()} (${n} records before, ${a} records after)`}]};let E=[];E.push(`# Timeline around anchor: ${u}`),E.push(`**Window:** ${n} records before \u2192 ${a} records after | **Items:** ${m.length} (${p.observations.length} obs, ${p.sessions.length} sessions, ${p.prompts.length} prompts)`),E.push(""),E.push("**Legend:** \u{1F3AF} session-request | \u{1F534} bugfix | \u{1F7E3} feature | \u{1F504} refactor | \u2705 change | \u{1F535} discovery | \u{1F9E0} decision"),E.push("");let x=new Map;for(let g of m){let I=f(g.epoch);x.has(I)||x.set(I,[]),x.get(I).push(g)}let T=Array.from(x.entries()).sort((g,I)=>{let S=new Date(g[0]).getTime(),O=new Date(I[0]).getTime();return S-O});for(let[g,I]of T){E.push(`### ${g}`),E.push("");let S=null,O="",C=!1;for(let v of I){let F=typeof u=="number"&&v.type==="observation"&&v.data.id===u||typeof u=="string"&&u.startsWith("S")&&v.type==="session"&&`S${v.data.id}`===u;if(v.type==="session"){C&&(E.push(""),C=!1,S=null,O="");let y=v.data,U=y.request||"Session summary",R=`claude-mem://session-summary/${y.id}`,A=F?" \u2190 **ANCHOR**":"";E.push(`**\u{1F3AF} #S${y.id}** ${U} (${b(v.epoch)}) [\u2192](${R})${A}`),E.push("")}else if(v.type==="prompt"){C&&(E.push(""),C=!1,S=null,O="");let y=v.data,U=y.prompt.length>100?y.prompt.substring(0,100)+"...":y.prompt;E.push(`**\u{1F4AC} User Prompt #${y.prompt_number}** (${b(v.epoch)})`),E.push(`> ${U}`),E.push("")}else if(v.type==="observation"){let y=v.data,U="General";U!==S&&(C&&E.push(""),E.push(`**${U}**`),E.push("| ID | Time | T | Title | Tokens |"),E.push("|----|------|---|-------|--------|"),S=U,C=!0,O="");let R="\u2022";switch(y.type){case"bugfix":R="\u{1F534}";break;case"feature":R="\u{1F7E3}";break;case"refactor":R="\u{1F504}";break;case"change":R="\u2705";break;case"discovery":R="\u{1F535}";break;case"decision":R="\u{1F9E0}";break}let A=h(v.epoch),D=y.title||"Untitled",B=_(y.narrative),Y=A!==O?A:"\u2033";O=A;let Z=F?" \u2190 **ANCHOR**":"";E.push(`| #${y.id} | ${Y} | ${R} | ${D}${Z} | ~${B} |`)}}C&&E.push("")}return{content:[{type:"text",text:E.join(`
`)}]}}catch(o){return{content:[{type:"text",text:`Timeline query failed: ${o.message}`}],isError:!0}}}},{name:"get_timeline_by_query",description:'Search for observations using natural language and get timeline context around the best match. Two modes: "auto" (default) automatically uses top result as timeline anchor; "interactive" returns top matches for you to choose from. This combines search + timeline into a single operation for faster context discovery.',inputSchema:i.object({query:i.string().describe("Natural language search query to find relevant observations"),mode:i.enum(["auto","interactive"]).default("auto").describe("auto: Automatically use top search result as timeline anchor. interactive: Show top N search results for manual anchor selection."),depth_before:i.number().min(0).max(50).default(10).describe("Number of timeline records before anchor (default: 10)"),depth_after:i.number().min(0).max(50).default(10).describe("Number of timeline records after anchor (default: 10)"),limit:i.number().min(1).max(20).default(5).describe("For interactive mode: number of top search results to display (default: 5)"),project:i.string().optional().describe("Filter by project name")}),handler:async c=>{try{let{query:o,mode:n="auto",depth_before:a=10,depth_after:d=10,limit:l=5,project:u}=c,p=[];if(k)try{console.error("[search-server] Using hybrid semantic search for timeline query");let m=await M(o,100);if(console.error(`[search-server] Chroma returned ${m.ids.length} semantic matches`),m.ids.length>0){let f=Date.now()-7776e6,h=m.ids.filter((b,_)=>{let E=m.metadatas[_];return E&&E.created_at_epoch>f});console.error(`[search-server] ${h.length} results within 90-day window`),h.length>0&&(p=N.getObservationsByIds(h,{orderBy:"date_desc",limit:n==="auto"?1:l}),console.error(`[search-server] Hydrated ${p.length} observations from SQLite`))}}catch(m){console.error("[search-server] Chroma query failed, falling back to FTS5:",m.message)}if(p.length===0&&(console.error("[search-server] Using FTS5 keyword search"),p=$.searchObservations(o,{orderBy:"relevance",limit:n==="auto"?1:l,project:u})),p.length===0)return{content:[{type:"text",text:`No observations found matching "${o}". Try a different search query.`}]};if(n==="interactive"){let m=[];m.push("# Timeline Anchor Search Results"),m.push(""),m.push(`Found ${p.length} observation(s) matching "${o}"`),m.push(""),m.push("To get timeline context around any of these observations, use the `get_context_timeline` tool with the observation ID as the anchor."),m.push(""),m.push(`**Top ${p.length} matches:**`),m.push("");for(let f=0;f<p.length;f++){let h=p[f],b=h.title||`Observation #${h.id}`,_=new Date(h.created_at_epoch).toLocaleString(),E=h.type?`[${h.type}]`:"";m.push(`${f+1}. **${E} ${b}**`),m.push(` - ID: ${h.id}`),m.push(` - Date: ${_}`),h.subtitle&&m.push(` - ${h.subtitle}`),m.push(` - Source: claude-mem://observation/${h.id}`),m.push("")}return{content:[{type:"text",text:m.join(`
`)}]}}else{let b=function(S){return new Date(S).toLocaleString("en-US",{month:"short",day:"numeric",year:"numeric"})},_=function(S){return new Date(S).toLocaleString("en-US",{hour:"numeric",minute:"2-digit",hour12:!0})},E=function(S){return new Date(S).toLocaleString("en-US",{month:"short",day:"numeric",hour:"numeric",minute:"2-digit",hour12:!0})},x=function(S){return S?Math.ceil(S.length/4):0};var e=b,r=_,s=E,t=x;let m=p[0];console.error(`[search-server] Auto mode: Using observation #${m.id} as timeline anchor`);let f=N.getTimelineAroundObservation(m.id,m.created_at_epoch,a,d,u),h=[...f.observations.map(S=>({type:"observation",data:S,epoch:S.created_at_epoch})),...f.sessions.map(S=>({type:"session",data:S,epoch:S.created_at_epoch})),...f.prompts.map(S=>({type:"prompt",data:S,epoch:S.created_at_epoch}))];if(h.sort((S,O)=>S.epoch-O.epoch),h.length===0)return{content:[{type:"text",text:`Found observation #${m.id} matching "${o}", but no timeline context available (${a} records before, ${d} records after).`}]};let T=[];T.push(`# Timeline for query: "${o}"`),T.push(`**Anchor:** Observation #${m.id} - ${m.title||"Untitled"}`),T.push(`**Window:** ${a} records before \u2192 ${d} records after | **Items:** ${h.length} (${f.observations.length} obs, ${f.sessions.length} sessions, ${f.prompts.length} prompts)`),T.push(""),T.push("**Legend:** \u{1F3AF} session-request | \u{1F534} bugfix | \u{1F7E3} feature | \u{1F504} refactor | \u2705 change | \u{1F535} discovery | \u{1F9E0} decision"),T.push("");let g=new Map;for(let S of h){let O=b(S.epoch);g.has(O)||g.set(O,[]),g.get(O).push(S)}let I=Array.from(g.entries()).sort((S,O)=>{let C=new Date(S[0]).getTime(),v=new Date(O[0]).getTime();return C-v});for(let[S,O]of I){T.push(`### ${S}`),T.push("");let C=null,v="",F=!1;for(let y of O){let U=y.type==="observation"&&y.data.id===m.id;if(y.type==="session"){F&&(T.push(""),F=!1,C=null,v="");let R=y.data,A=R.request||"Session summary",D=`claude-mem://session-summary/${R.id}`;T.push(`**\u{1F3AF} #S${R.id}** ${A} (${E(y.epoch)}) [\u2192](${D})`),T.push("")}else if(y.type==="prompt"){F&&(T.push(""),F=!1,C=null,v="");let R=y.data,A=R.prompt.length>100?R.prompt.substring(0,100)+"...":R.prompt;T.push(`**\u{1F4AC} User Prompt #${R.prompt_number}** (${E(y.epoch)})`),T.push(`> ${A}`),T.push("")}else if(y.type==="observation"){let R=y.data,A="General";A!==C&&(F&&T.push(""),T.push(`**${A}**`),T.push("| ID | Time | T | Title | Tokens |"),T.push("|----|------|---|-------|--------|"),C=A,F=!0,v="");let D="\u2022";switch(R.type){case"bugfix":D="\u{1F534}";break;case"feature":D="\u{1F7E3}";break;case"refactor":D="\u{1F504}";break;case"change":D="\u2705";break;case"discovery":D="\u{1F535}";break;case"decision":D="\u{1F9E0}";break}let B=_(y.epoch),z=R.title||"Untitled",Y=x(R.narrative),ie=B!==v?B:"\u2033";v=B;let ae=U?" \u2190 **ANCHOR**":"";T.push(`| #${R.id} | ${ie} | ${D} | ${z}${ae} | ~${Y} |`)}}F&&T.push("")}return{content:[{type:"text",text:T.join(`
`)}]}}}catch(o){return{content:[{type:"text",text:`Timeline query failed: ${o.message}`}],isError:!0}}}}],J=new he({name:"claude-mem-search",version:"1.0.0"},{capabilities:{tools:{}}});J.setRequestHandler(ge,async()=>({tools:oe.map(c=>({name:c.name,description:c.description,inputSchema:Te(c.inputSchema)}))}));J.setRequestHandler(be,async c=>{let e=oe.find(r=>r.name===c.params.name);if(!e)throw new Error(`Unknown tool: ${c.params.name}`);try{return await e.handler(c.params.arguments||{})}catch(r){return{content:[{type:"text",text:`Tool execution failed: ${r.message}`}],isError:!0}}});async function Ie(){let c=new _e;await J.connect(c),console.error("[search-server] Claude-mem search server started"),setTimeout(async()=>{try{console.error("[search-server] Initializing Chroma client...");let e=new Ee({command:"uvx",args:["chroma-mcp","--client-type","persistent","--data-dir",te],stderr:"ignore"}),r=new fe({name:"claude-mem-search-chroma-client",version:"1.0.0"},{capabilities:{}});await r.connect(e),k=r,console.error("[search-server] Chroma client connected successfully")}catch(e){console.error("[search-server] Failed to initialize Chroma client:",e.message),console.error("[search-server] Falling back to FTS5-only search"),k=null}},0)}Ie().catch(c=>{console.error("[search-server] Fatal error:",c),process.exit(1)});
`)}]}}}catch(o){return{content:[{type:"text",text:`Timeline query failed: ${o.message}`}],isError:!0}}}}],Q=new he({name:"claude-mem-search",version:"1.0.0"},{capabilities:{tools:{}}});Q.setRequestHandler(ge,async()=>({tools:oe.map(c=>({name:c.name,description:c.description,inputSchema:Te(c.inputSchema)}))}));Q.setRequestHandler(be,async c=>{let e=oe.find(r=>r.name===c.params.name);if(!e)throw new Error(`Unknown tool: ${c.params.name}`);try{return await e.handler(c.params.arguments||{})}catch(r){return{content:[{type:"text",text:`Tool execution failed: ${r.message}`}],isError:!0}}});async function Ie(){let c=new _e;await Q.connect(c),console.error("[search-server] Claude-mem search server started"),setTimeout(async()=>{try{console.error("[search-server] Initializing Chroma client...");let e=new Ee({command:"uvx",args:["chroma-mcp","--client-type","persistent","--data-dir",te],stderr:"ignore"}),r=new fe({name:"claude-mem-search-chroma-client",version:"1.0.0"},{capabilities:{}});await r.connect(e),k=r,console.error("[search-server] Chroma client connected successfully")}catch(e){console.error("[search-server] Failed to initialize Chroma client:",e.message),console.error("[search-server] Falling back to FTS5-only search"),k=null}},0)}Ie().catch(c=>{console.error("[search-server] Fatal error:",c),process.exit(1)});
+66 -37
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env node
import{stdin as U}from"process";import $ from"better-sqlite3";import{join as E,dirname as X,basename as q}from"path";import{homedir as I}from"os";import{existsSync as Z,mkdirSync as F}from"fs";import{fileURLToPath as P}from"url";function H(){return typeof __dirname<"u"?__dirname:X(P(import.meta.url))}var B=H(),m=process.env.CLAUDE_MEM_DATA_DIR||E(I(),".claude-mem"),h=process.env.CLAUDE_CONFIG_DIR||E(I(),".claude"),se=E(m,"archives"),te=E(m,"logs"),re=E(m,"trash"),oe=E(m,"backups"),ne=E(m,"settings.json"),L=E(m,"claude-mem.db"),ie=E(m,"vector-db"),ae=E(h,"settings.json"),de=E(h,"commands"),pe=E(h,"CLAUDE.md");function A(d){F(d,{recursive:!0})}function C(){return E(B,"..","..")}var N=(o=>(o[o.DEBUG=0]="DEBUG",o[o.INFO=1]="INFO",o[o.WARN=2]="WARN",o[o.ERROR=3]="ERROR",o[o.SILENT=4]="SILENT",o))(N||{}),O=class{level;useColor;constructor(){let e=process.env.CLAUDE_MEM_LOG_LEVEL?.toUpperCase()||"INFO";this.level=N[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,o){if(e<this.level)return;let n=new Date().toISOString().replace("T"," ").substring(0,23),i=N[e].padEnd(5),p=s.padEnd(6),c="";r?.correlationId?c=`[${r.correlationId}] `:r?.sessionId&&(c=`[session-${r.sessionId}] `);let u="";o!=null&&(this.level===0&&typeof o=="object"?u=`
`+JSON.stringify(o,null,2):u=" "+this.formatData(o));let T="";if(r){let{sessionId:l,sdkSessionId:S,correlationId:_,...a}=r;Object.keys(a).length>0&&(T=` {${Object.entries(a).map(([w,M])=>`${w}=${M}`).join(", ")}}`)}let R=`[${n}] [${i}] [${p}] ${c}${t}${T}${u}`;e===3?console.error(R):console.log(R)}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 O;var g=class{db;constructor(){A(m),this.db=new $(L),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 U}from"process";import j from"better-sqlite3";import{join as m,dirname as F,basename as V}from"path";import{homedir as f}from"os";import{existsSync as Z,mkdirSync as X}from"fs";import{fileURLToPath as P}from"url";function B(){return typeof __dirname<"u"?__dirname:F(P(import.meta.url))}var H=B(),E=process.env.CLAUDE_MEM_DATA_DIR||m(f(),".claude-mem"),h=process.env.CLAUDE_CONFIG_DIR||m(f(),".claude"),se=m(E,"archives"),te=m(E,"logs"),re=m(E,"trash"),ne=m(E,"backups"),oe=m(E,"settings.json"),L=m(E,"claude-mem.db"),ie=m(E,"vector-db"),ae=m(h,"settings.json"),de=m(h,"commands"),pe=m(h,"CLAUDE.md");function A(d){X(d,{recursive:!0})}function C(){return m(H,"..","..")}var N=(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))(N||{}),O=class{level;useColor;constructor(){let e=process.env.CLAUDE_MEM_LOG_LEVEL?.toUpperCase()||"INFO";this.level=N[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=N[e].padEnd(5),p=s.padEnd(6),c="";r?.correlationId?c=`[${r.correlationId}] `:r?.sessionId&&(c=`[session-${r.sessionId}] `);let u="";n!=null&&(this.level===0&&typeof n=="object"?u=`
`+JSON.stringify(n,null,2):u=" "+this.formatData(n));let T="";if(r){let{sessionId:l,sdkSessionId:b,correlationId:_,...a}=r;Object.keys(a).length>0&&(T=` {${Object.entries(a).map(([w,M])=>`${w}=${M}`).join(", ")}}`)}let S=`[${o}] [${i}] [${p}] ${c}${t}${T}${u}`;e===3?console.error(S):console.log(S)}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`})}},g=new O;var R=class{db;constructor(){A(E),this.db=new j(L),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,
@@ -188,7 +188,36 @@ ${e.stack}`:e.message;if(Array.isArray(e))return`[${e.length} items]`;let s=Obje
WHERE project = ?
ORDER BY created_at_epoch DESC
LIMIT ?
`).all(e,s)}getRecentSessionsWithStatus(e,s=3){return this.db.prepare(`
`).all(e,s)}getAllRecentObservations(e=100){return this.db.prepare(`
SELECT id, type, title, subtitle, text, project, prompt_number, created_at, created_at_epoch
FROM observations
ORDER BY created_at_epoch DESC
LIMIT ?
`).all(e)}getAllRecentSummaries(e=50){return this.db.prepare(`
SELECT id, request, investigated, learned, completed, next_steps,
files_read, files_edited, notes, project, prompt_number,
created_at, created_at_epoch
FROM session_summaries
ORDER BY created_at_epoch DESC
LIMIT ?
`).all(e)}getAllRecentUserPrompts(e=100){return this.db.prepare(`
SELECT
up.id,
up.claude_session_id,
s.project,
up.prompt_number,
up.prompt_text,
up.created_at,
up.created_at_epoch
FROM user_prompts up
LEFT JOIN sdk_sessions s ON up.claude_session_id = s.claude_session_id
ORDER BY up.created_at_epoch DESC
LIMIT ?
`).all(e)}getAllProjects(){return this.db.prepare(`
SELECT DISTINCT project
FROM sdk_sessions
ORDER BY project ASC
`).all().map(t=>t.project)}getRecentSessionsWithStatus(e,s=3){return this.db.prepare(`
SELECT * FROM (
SELECT
s.sdk_session_id,
@@ -214,12 +243,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,o=t==="date_asc"?"ASC":"DESC",n=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",o=r?`LIMIT ${r}`:"",i=e.map(()=>"?").join(",");return this.db.prepare(`
SELECT *
FROM observations
WHERE id IN (${i})
ORDER BY created_at_epoch ${o}
${n}
ORDER BY created_at_epoch ${n}
${o}
`).all(...e)}getSummaryForSession(e){return this.db.prepare(`
SELECT
request, investigated, learned, completed, next_steps,
@@ -232,7 +261,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,o=new Set;for(let n of t){if(n.files_read)try{let i=JSON.parse(n.files_read);Array.isArray(i)&&i.forEach(p=>r.add(p))}catch{}if(n.files_modified)try{let i=JSON.parse(n.files_modified);Array.isArray(i)&&i.forEach(p=>o.add(p))}catch{}}return{filesRead:Array.from(r),filesModified:Array.from(o)}}getSessionById(e){return this.db.prepare(`
`).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(`
SELECT id, claude_session_id, sdk_session_id, project, user_prompt
FROM sdk_sessions
WHERE id = ?
@@ -259,17 +288,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,o=r.getTime(),i=this.db.prepare(`
`).get(e)?.prompt_counter||0}createSDKSession(e,s,t){let r=new Date,n=r.getTime(),i=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(),o);return i.lastInsertRowid===0||i.changes===0?this.db.prepare(`
`).run(e,e,s,t,r.toISOString(),n);return i.lastInsertRowid===0||i.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(`
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?(g.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 = ?
@@ -278,33 +307,33 @@ ${e.stack}`:e.message;if(Array.isArray(e))return`[${e.length} items]`;let s=Obje
FROM sdk_sessions
WHERE id = ?
LIMIT 1
`).get(e)?.worker_port||null}saveUserPrompt(e,s,t){let r=new Date,o=r.getTime();return this.db.prepare(`
`).get(e)?.worker_port||null}saveUserPrompt(e,s,t){let r=new Date,n=r.getTime();return this.db.prepare(`
INSERT INTO user_prompts
(claude_session_id, prompt_number, prompt_text, created_at, created_at_epoch)
VALUES (?, ?, ?, ?, ?)
`).run(e,s,t,r.toISOString(),o).lastInsertRowid}storeObservation(e,s,t,r){let o=new Date,n=o.getTime();this.db.prepare(`
`).run(e,s,t,r.toISOString(),n).lastInsertRowid}storeObservation(e,s,t,r){let n=new Date,o=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,o.toISOString(),n),console.error(`[SessionStore] Auto-created session record for session_id: ${e}`));let u=this.db.prepare(`
`).run(e,e,s,n.toISOString(),o),console.error(`[SessionStore] Auto-created session record for session_id: ${e}`));let u=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,o.toISOString(),n);return{id:Number(u.lastInsertRowid),createdAtEpoch:n}}storeSummary(e,s,t,r){let o=new Date,n=o.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(),o);return{id:Number(u.lastInsertRowid),createdAtEpoch:o}}storeSummary(e,s,t,r){let n=new Date,o=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,o.toISOString(),n),console.error(`[SessionStore] Auto-created session record for session_id: ${e}`));let u=this.db.prepare(`
`).run(e,e,s,n.toISOString(),o),console.error(`[SessionStore] Auto-created session record for session_id: ${e}`));let u=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,o.toISOString(),n);return{id:Number(u.lastInsertRowid),createdAtEpoch:n}}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(),o);return{id:Number(u.lastInsertRowid),createdAtEpoch:o}}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 = ?
@@ -316,12 +345,12 @@ ${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 status = 'active'
`).run(e.toISOString(),s).changes}getSessionSummariesByIds(e,s={}){if(e.length===0)return[];let{orderBy:t="date_desc",limit:r}=s,o=t==="date_asc"?"ASC":"DESC",n=r?`LIMIT ${r}`:"",i=e.map(()=>"?").join(",");return this.db.prepare(`
`).run(e.toISOString(),s).changes}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(`
SELECT * FROM session_summaries
WHERE id IN (${i})
ORDER BY created_at_epoch ${o}
${n}
`).all(...e)}getUserPromptsByIds(e,s={}){if(e.length===0)return[];let{orderBy:t="date_desc",limit:r}=s,o=t==="date_asc"?"ASC":"DESC",n=r?`LIMIT ${r}`:"",i=e.map(()=>"?").join(",");return this.db.prepare(`
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(`
SELECT
up.*,
s.project,
@@ -329,46 +358,46 @@ ${e.stack}`:e.message;if(Array.isArray(e))return`[${e.length} items]`;let s=Obje
FROM user_prompts up
JOIN sdk_sessions s ON up.claude_session_id = s.claude_session_id
WHERE up.id IN (${i})
ORDER BY up.created_at_epoch ${o}
${n}
`).all(...e)}getTimelineAroundTimestamp(e,s=10,t=10,r){return this.getTimelineAroundObservation(null,e,s,t,r)}getTimelineAroundObservation(e,s,t=10,r=10,o){let n=o?"AND project = ?":"",i=o?[o]:[],p,c;if(e!==null){let l=`
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,c;if(e!==null){let l=`
SELECT id, created_at_epoch
FROM observations
WHERE id <= ? ${n}
WHERE id <= ? ${o}
ORDER BY id DESC
LIMIT ?
`,S=`
`,b=`
SELECT id, created_at_epoch
FROM observations
WHERE id >= ? ${n}
WHERE id >= ? ${o}
ORDER BY id ASC
LIMIT ?
`;try{let _=this.db.prepare(l).all(e,...i,t+1),a=this.db.prepare(S).all(e,...i,r+1);if(_.length===0&&a.length===0)return{observations:[],sessions:[],prompts:[]};p=_.length>0?_[_.length-1].created_at_epoch:s,c=a.length>0?a[a.length-1].created_at_epoch:s}catch(_){return console.error("[SessionStore] Error getting boundary observations:",_.message),{observations:[],sessions:[],prompts:[]}}}else{let l=`
`;try{let _=this.db.prepare(l).all(e,...i,t+1),a=this.db.prepare(b).all(e,...i,r+1);if(_.length===0&&a.length===0)return{observations:[],sessions:[],prompts:[]};p=_.length>0?_[_.length-1].created_at_epoch:s,c=a.length>0?a[a.length-1].created_at_epoch:s}catch(_){return console.error("[SessionStore] Error getting boundary observations:",_.message),{observations:[],sessions:[],prompts:[]}}}else{let l=`
SELECT created_at_epoch
FROM observations
WHERE created_at_epoch <= ? ${n}
WHERE created_at_epoch <= ? ${o}
ORDER BY created_at_epoch DESC
LIMIT ?
`,S=`
`,b=`
SELECT created_at_epoch
FROM observations
WHERE created_at_epoch >= ? ${n}
WHERE created_at_epoch >= ? ${o}
ORDER BY created_at_epoch ASC
LIMIT ?
`;try{let _=this.db.prepare(l).all(s,...i,t),a=this.db.prepare(S).all(s,...i,r+1);if(_.length===0&&a.length===0)return{observations:[],sessions:[],prompts:[]};p=_.length>0?_[_.length-1].created_at_epoch:s,c=a.length>0?a[a.length-1].created_at_epoch:s}catch(_){return console.error("[SessionStore] Error getting boundary timestamps:",_.message),{observations:[],sessions:[],prompts:[]}}}let u=`
`;try{let _=this.db.prepare(l).all(s,...i,t),a=this.db.prepare(b).all(s,...i,r+1);if(_.length===0&&a.length===0)return{observations:[],sessions:[],prompts:[]};p=_.length>0?_[_.length-1].created_at_epoch:s,c=a.length>0?a[a.length-1].created_at_epoch:s}catch(_){return console.error("[SessionStore] Error getting boundary timestamps:",_.message),{observations:[],sessions:[],prompts:[]}}}let u=`
SELECT *
FROM observations
WHERE created_at_epoch >= ? AND created_at_epoch <= ? ${n}
WHERE created_at_epoch >= ? AND created_at_epoch <= ? ${o}
ORDER BY created_at_epoch ASC
`,T=`
SELECT *
FROM session_summaries
WHERE created_at_epoch >= ? AND created_at_epoch <= ? ${n}
WHERE created_at_epoch >= ? AND created_at_epoch <= ? ${o}
ORDER BY created_at_epoch ASC
`,R=`
`,S=`
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 <= ? ${n.replace("project","s.project")}
WHERE up.created_at_epoch >= ? AND up.created_at_epoch <= ? ${o.replace("project","s.project")}
ORDER BY up.created_at_epoch ASC
`;try{let l=this.db.prepare(u).all(p,c,...i),S=this.db.prepare(T).all(p,c,...i),_=this.db.prepare(R).all(p,c,...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:_.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 W(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 v(d,e,s={}){let t=W(d,e,s);return JSON.stringify(t)}import y from"path";import{spawn as D}from"child_process";var G=parseInt(process.env.CLAUDE_MEM_WORKER_PORT||"37777",10);async function k(d=3e3){try{return(await fetch(`http://127.0.0.1:${G}/health`,{signal:AbortSignal.timeout(d)})).ok}catch{return!1}}async function j(d=1e4){let e=Date.now(),s=100;for(;Date.now()-e<d;){if(await k(1e3))return!0;await new Promise(t=>setTimeout(t,s))}return!1}async function x(){if(await k(1e3))return;let d=C(),e=y.join(d,"node_modules",".bin","pm2"),s=y.join(d,"ecosystem.config.cjs"),t=D(e,["list","--no-color"],{cwd:d,stdio:["ignore","pipe","ignore"]}),r="";if(t.stdout?.on("data",i=>{r+=i.toString()}),await new Promise((i,p)=>{t.on("error",c=>p(c)),t.on("close",c=>{i()})}),!(r.includes("claude-mem-worker")&&r.includes("online"))){let i=D(e,["start",s],{cwd:d,stdio:"ignore"});await new Promise((p,c)=>{i.on("error",u=>c(u)),i.on("close",u=>{u!==0&&u!==null?c(new Error(`PM2 start command failed with exit code ${u}`)):p()})})}if(!await j(1e4))throw new Error("Worker failed to become healthy after starting")}async function Y(d){if(!d)throw new Error("summaryHook requires input");let{session_id:e}=d;await x();let s=new g,t=s.createSDKSession(e,"",""),r=s.getPromptCounter(t);s.close();let o=parseInt(process.env.CLAUDE_MEM_WORKER_PORT||"37777",10);b.dataIn("HOOK","Stop: Requesting summary",{sessionId:t,workerPort:o,promptNumber:r});try{let n=await fetch(`http://127.0.0.1:${o}/sessions/${t}/summarize`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({prompt_number:r}),signal:AbortSignal.timeout(2e3)});if(!n.ok){let i=await n.text();throw b.failure("HOOK","Failed to generate summary",{sessionId:t,status:n.status},i),new Error(`Failed to request summary from worker: ${n.status} ${i}`)}b.debug("HOOK","Summary request sent successfully",{sessionId:t})}catch(n){throw n.cause?.code==="ECONNREFUSED"||n.name==="TimeoutError"||n.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"):n}console.log(v("Stop",!0))}var f="";U.on("data",d=>f+=d);U.on("end",async()=>{let d=f?JSON.parse(f):void 0;await Y(d)});
`;try{let l=this.db.prepare(u).all(p,c,...i),b=this.db.prepare(T).all(p,c,...i),_=this.db.prepare(S).all(p,c,...i);return{observations:l,sessions:b.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:_.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 $(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 v(d,e,s={}){let t=$(d,e,s);return JSON.stringify(t)}import y from"path";import{spawn as D}from"child_process";var W=parseInt(process.env.CLAUDE_MEM_WORKER_PORT||"37777",10);async function k(d=100){try{return(await fetch(`http://127.0.0.1:${W}/health`,{signal:AbortSignal.timeout(d)})).ok}catch{return!1}}async function G(d=1e4){let e=Date.now(),s=100;for(;Date.now()-e<d;){if(await k(1e3))return!0;await new Promise(t=>setTimeout(t,s))}return!1}async function x(){if(await k())return;let d=C(),e=y.join(d,"node_modules",".bin","pm2"),s=y.join(d,"ecosystem.config.cjs"),t=D(e,["list","--no-color"],{cwd:d,stdio:["ignore","pipe","ignore"]}),r="";if(t.stdout?.on("data",i=>{r+=i.toString()}),await new Promise((i,p)=>{t.on("error",c=>p(c)),t.on("close",c=>{i()})}),!(r.includes("claude-mem-worker")&&r.includes("online"))){let i=D(e,["start",s],{cwd:d,stdio:"ignore"});await new Promise((p,c)=>{i.on("error",u=>c(u)),i.on("close",u=>{u!==0&&u!==null?c(new Error(`PM2 start command failed with exit code ${u}`)):p()})})}if(!await G(1e4))throw new Error("Worker failed to become healthy after starting")}async function Y(d){if(!d)throw new Error("summaryHook requires input");let{session_id:e}=d;await x();let s=new R,t=s.createSDKSession(e,"",""),r=s.getPromptCounter(t);s.close();let n=parseInt(process.env.CLAUDE_MEM_WORKER_PORT||"37777",10);g.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 g.failure("HOOK","Failed to generate summary",{sessionId:t,status:o.status},i),new Error(`Failed to request summary from worker: ${o.status} ${i}`)}g.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}console.log(v("Stop",!0))}var I="";U.on("data",d=>I+=d);U.on("end",async()=>{let d=I?JSON.parse(I):void 0;await Y(d)});
+4 -1
View File
@@ -22,4 +22,7 @@ This message was not added to your startup context, so you can continue working
\u{1F4DD} Claude-Mem Context Loaded
\u2139\uFE0F Note: This appears as stderr but is informational only
`+n)}catch(e){console.error(`\u274C Failed to load context display: ${e}`)}process.exit(3);
`+n+`
\u{1F4FA} Watch live in browser http://localhost:37777/ (New! v5.1)
`)}catch(e){console.error(`\u274C Failed to load context display: ${e}`)}process.exit(3);
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

File diff suppressed because one or more lines are too long
+512
View File
@@ -0,0 +1,512 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>claude-mem viewer</title>
<link rel="icon" type="image/webp" href="claude-mem-logomark.webp">
<style>
@font-face {
font-family: 'Monaspace Radon';
src: url('assets/fonts/monaspace-radon-var.woff2') format('woff2-variations'),
url('assets/fonts/monaspace-radon-var.woff') format('woff-variations');
font-weight: 200 900;
font-display: swap;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica', 'Arial', sans-serif;
background: #1e1e1e;
color: #cccccc;
font-size: 14px;
overflow: hidden;
}
.container {
display: flex;
height: 100vh;
width: 100vw;
position: relative;
}
.main-col {
flex: 1;
display: flex;
flex-direction: column;
}
.sidebar {
position: fixed;
right: 0;
top: 0;
width: 400px;
height: 100vh;
background: #1e1e1e;
border-left: 1px solid #404040;
display: flex;
flex-direction: column;
transform: translate3d(100%, 0, 0);
transition: transform 0.3s ease;
z-index: 100;
will-change: transform;
}
.sidebar.open {
transform: translate3d(0, 0, 0);
}
.header {
padding: 14px 18px;
border-bottom: 1px solid #404040;
display: flex;
justify-content: space-between;
align-items: center;
background: #252526;
}
.sidebar-header {
padding: 14px 18px;
border-bottom: 1px solid #404040;
display: flex;
justify-content: space-between;
align-items: center;
background: #252526;
}
.sidebar-header h1 {
font-size: 16px;
font-weight: 500;
color: #e0e0e0;
}
.header h1 {
font-size: 16px;
font-weight: 500;
color: #e0e0e0;
display: flex;
align-items: center;
gap: 10px;
}
.logomark {
height: 32px;
width: auto;
}
.logomark.spinning {
animation: spin 1.5s linear infinite;
}
.logo-text {
font-family: 'Monaspace Radon', monospace;
font-weight: 100;
font-size: 20px;
letter-spacing: -0.03em;
color: #dadada;
}
.status {
display: flex;
align-items: center;
gap: 12px;
font-size: 13px;
}
.settings-btn {
background: transparent;
border: 1px solid #404040;
padding: 8px;
width: 36px;
height: 36px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: #cccccc;
transition: all 0.15s ease;
}
.settings-btn:hover {
background: #2d2d2d;
border-color: #58a6ff;
}
.settings-btn.active {
background: #0969da;
border-color: #0969da;
color: white;
}
.settings-icon {
width: 18px;
height: 18px;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #e74856;
animation: pulse 2s ease-in-out infinite;
}
.status-dot.connected {
background: #16c60c;
animation: none;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
select,
input,
button {
background: #2d2d2d;
color: #cccccc;
border: 1px solid #404040;
padding: 6px 12px;
font-family: inherit;
font-size: 13px;
border-radius: 4px;
transition: all 0.15s ease;
}
select:hover,
input:hover {
border-color: #58a6ff;
}
select:focus,
input:focus {
outline: none;
border-color: #58a6ff;
box-shadow: 0 0 0 2px rgba(88, 166, 255, 0.2);
}
button {
background: #0969da;
color: #ffffff;
border: none;
font-weight: 500;
cursor: pointer;
}
button:hover:not(:disabled) {
background: #1177e6;
}
button:active:not(:disabled) {
background: #0860ca;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.feed {
flex: 1;
overflow-y: auto;
padding: 24px 18px;
display: flex;
justify-content: center;
}
.feed-content {
width: 100%;
max-width: 42rem;
}
.card {
margin-bottom: 24px;
padding: 20px 24px;
background: #2d2d2d;
border: 1px solid #404040;
border-radius: 8px;
transition: all 0.15s ease;
animation: slideIn 0.3s ease-out;
line-height: 1.7;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.card:hover {
border-color: #505050;
}
.card-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
font-size: 12px;
color: #8b949e;
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
}
.card-type {
padding: 2px 8px;
background: #58a6ff20;
color: #58a6ff;
border-radius: 3px;
font-weight: 500;
text-transform: uppercase;
font-size: 11px;
letter-spacing: 0.5px;
}
.card-title {
font-size: 17px;
margin-bottom: 8px;
color: #e0e0e0;
font-weight: 600;
line-height: 1.4;
letter-spacing: -0.01em;
}
.card-subtitle {
font-size: 14px;
color: #a0a0a0;
margin-bottom: 8px;
line-height: 1.6;
}
.card-meta {
font-size: 12px;
color: #6e7681;
margin-top: 8px;
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
}
.summary-card {
border-color: #9e6a03;
background: #3d2f00;
}
.summary-card:hover {
border-color: #ae7a13;
}
.summary-card .card-type {
background: #f2cc6020;
color: #f2cc60;
}
.summary-card .card-title {
color: #f2cc60;
}
.settings-section {
padding: 18px;
border-bottom: 1px solid #404040;
}
.settings-section h3 {
font-size: 14px;
font-weight: 600;
margin-bottom: 14px;
color: #e0e0e0;
letter-spacing: 0.3px;
}
.form-group {
margin-bottom: 14px;
}
.form-group label {
display: block;
margin-bottom: 6px;
font-size: 12px;
color: #8b949e;
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
}
.setting-description {
font-size: 12px;
color: #8b949e;
margin-bottom: 8px;
line-height: 1.5;
}
.stats-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.stat {
padding: 10px 12px;
background: #2d2d2d;
border: 1px solid #404040;
border-radius: 4px;
}
.stat-label {
color: #8b949e;
margin-bottom: 4px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.stat-value {
font-size: 18px;
color: #e0e0e0;
font-weight: 600;
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
}
.stats-scroll {
flex: 1;
overflow-y: auto;
}
::-webkit-scrollbar {
width: 10px;
}
::-webkit-scrollbar-track {
background: #1e1e1e;
}
::-webkit-scrollbar-thumb {
background: #424242;
border-radius: 5px;
}
::-webkit-scrollbar-thumb:hover {
background: #4e4e4e;
}
.save-status {
margin-top: 8px;
font-size: 12px;
color: #8b949e;
}
.prompt-card {
border-color: #6e40c9;
background: #2d1b4e;
}
.prompt-card:hover {
border-color: #8e6cdb;
}
.prompt-card .card-type {
background: #6e40c920;
color: #8e6cdb;
}
.card-content {
margin-top: 12px;
line-height: 1.6;
color: #cccccc;
white-space: pre-wrap;
word-wrap: break-word;
}
.processing-indicator {
display: inline-flex;
align-items: center;
gap: 6px;
color: #58a6ff;
font-size: 11px;
font-weight: 500;
margin-left: auto;
}
.spinner {
width: 12px;
height: 12px;
border: 2px solid #404040;
border-top-color: #58a6ff;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.summary-skeleton {
opacity: 0.7;
}
.summary-skeleton .processing-indicator {
margin-left: auto;
}
.skeleton-line {
height: 16px;
background: linear-gradient(90deg, #404040 25%, #505050 50%, #404040 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 4px;
margin-bottom: 8px;
}
.skeleton-title {
height: 20px;
width: 80%;
margin-bottom: 10px;
}
.skeleton-subtitle {
height: 16px;
width: 90%;
}
.skeleton-subtitle.short {
width: 60%;
}
@keyframes shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
</style>
</head>
<body>
<div id="root"></div>
<script src="viewer-bundle.js"></script>
</body>
</html>
+21 -3
View File
@@ -40,14 +40,32 @@ async function buildHooks() {
const version = packageJson.version;
console.log(`📌 Version: ${version}`);
// Create output directory
console.log('\n📦 Preparing output directory...');
// Create output directories
console.log('\n📦 Preparing output directories...');
const hooksDir = 'plugin/scripts';
const uiDir = 'plugin/ui';
if (!fs.existsSync(hooksDir)) {
fs.mkdirSync(hooksDir, { recursive: true });
}
console.log('✓ Output directory ready');
if (!fs.existsSync(uiDir)) {
fs.mkdirSync(uiDir, { recursive: true });
}
console.log('✓ Output directories ready');
// Build React viewer
console.log('\n📋 Building React viewer...');
const { spawn } = await import('child_process');
const viewerBuild = spawn('node', ['scripts/build-viewer.js'], { stdio: 'inherit' });
await new Promise((resolve, reject) => {
viewerBuild.on('exit', (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`Viewer build failed with exit code ${code}`));
}
});
});
// Build worker service
console.log(`\n🔧 Building worker service...`);
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env node
import * as esbuild from 'esbuild';
import * as fs from 'fs';
import * as path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const rootDir = path.join(__dirname, '..');
async function buildViewer() {
console.log('Building React viewer...');
try {
// Build React app
await esbuild.build({
entryPoints: [path.join(rootDir, 'src/ui/viewer/index.tsx')],
bundle: true,
minify: true,
sourcemap: false,
target: ['es2020'],
format: 'iife',
outfile: path.join(rootDir, 'plugin/ui/viewer-bundle.js'),
jsx: 'automatic',
loader: {
'.tsx': 'tsx',
'.ts': 'ts'
},
define: {
'process.env.NODE_ENV': '"production"'
}
});
// Copy HTML template to build output
const htmlTemplate = fs.readFileSync(
path.join(rootDir, 'src/ui/viewer-template.html'),
'utf-8'
);
fs.writeFileSync(
path.join(rootDir, 'plugin/ui/viewer.html'),
htmlTemplate
);
// Copy font assets
const fontsDir = path.join(rootDir, 'src/ui/viewer/assets/fonts');
const outputFontsDir = path.join(rootDir, 'plugin/ui/assets/fonts');
if (fs.existsSync(fontsDir)) {
fs.mkdirSync(outputFontsDir, { recursive: true });
const fontFiles = fs.readdirSync(fontsDir);
for (const file of fontFiles) {
fs.copyFileSync(
path.join(fontsDir, file),
path.join(outputFontsDir, file)
);
}
}
console.log('✓ React viewer built successfully');
console.log(' - plugin/ui/viewer-bundle.js');
console.log(' - plugin/ui/viewer.html (from viewer-template.html)');
console.log(' - plugin/ui/assets/fonts/* (font files)');
} catch (error) {
console.error('Failed to build viewer:', error);
process.exit(1);
}
}
buildViewer();
+2 -1
View File
@@ -49,7 +49,8 @@ try {
console.error(
"\n\n📝 Claude-Mem Context Loaded\n" +
" ️ Note: This appears as stderr but is informational only\n\n" +
output
output +
"\n\n📺 Watch live in browser http://localhost:37777/ (New! v5.1)\n"
);
} catch (error) {
+98
View File
@@ -565,6 +565,104 @@ export class SessionStore {
return stmt.all(project, limit) as any[];
}
/**
* Get recent observations across all projects (for web UI)
*/
getAllRecentObservations(limit: number = 100): Array<{
id: number;
type: string;
title: string | null;
subtitle: string | null;
text: string;
project: string;
prompt_number: number | null;
created_at: string;
created_at_epoch: number;
}> {
const stmt = this.db.prepare(`
SELECT id, type, title, subtitle, text, project, prompt_number, created_at, created_at_epoch
FROM observations
ORDER BY created_at_epoch DESC
LIMIT ?
`);
return stmt.all(limit) as any[];
}
/**
* Get recent summaries across all projects (for web UI)
*/
getAllRecentSummaries(limit: number = 50): Array<{
id: number;
request: string | null;
investigated: string | null;
learned: string | null;
completed: string | null;
next_steps: string | null;
files_read: string | null;
files_edited: string | null;
notes: string | null;
project: string;
prompt_number: number | null;
created_at: string;
created_at_epoch: number;
}> {
const stmt = this.db.prepare(`
SELECT id, request, investigated, learned, completed, next_steps,
files_read, files_edited, notes, project, prompt_number,
created_at, created_at_epoch
FROM session_summaries
ORDER BY created_at_epoch DESC
LIMIT ?
`);
return stmt.all(limit) as any[];
}
/**
* Get recent user prompts across all sessions (for web UI)
*/
getAllRecentUserPrompts(limit: number = 100): Array<{
id: number;
claude_session_id: string;
project: string;
prompt_number: number;
prompt_text: string;
created_at: string;
created_at_epoch: number;
}> {
const stmt = this.db.prepare(`
SELECT
up.id,
up.claude_session_id,
s.project,
up.prompt_number,
up.prompt_text,
up.created_at,
up.created_at_epoch
FROM user_prompts up
LEFT JOIN sdk_sessions s ON up.claude_session_id = s.claude_session_id
ORDER BY up.created_at_epoch DESC
LIMIT ?
`);
return stmt.all(limit) as any[];
}
/**
* Get all unique projects from the database (for web UI project filter)
*/
getAllProjects(): string[] {
const stmt = this.db.prepare(`
SELECT DISTINCT project
FROM sdk_sessions
ORDER BY project ASC
`);
const rows = stmt.all() as Array<{ project: string }>;
return rows.map(row => row.project);
}
/**
* Get recent sessions with their status and summary info
*/
+504
View File
@@ -14,6 +14,10 @@ import type { SDKSession } from '../sdk/prompts.js';
import { logger } from '../utils/logger.js';
import { ensureAllDataDirs } from '../shared/paths.js';
import { execSync } from 'child_process';
import { readFileSync, writeFileSync, existsSync, statSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { homedir } from 'os';
const MODEL = process.env.CLAUDE_MEM_MODEL || 'claude-sonnet-4-5';
const DISALLOWED_TOOLS = ['Glob', 'Grep', 'ListMcpResourcesTool', 'WebSearch'];
@@ -96,14 +100,33 @@ class WorkerService {
private port: number = FIXED_PORT;
private sessions: Map<number, ActiveSession> = new Map();
private chromaSync!: ChromaSync;
private sseClients: Set<Response> = new Set();
constructor() {
this.app = express();
this.app.use(express.json({ limit: '50mb' }));
// Serve static files for web UI (viewer-bundle.js, logos, etc.)
const uiDir = this.getUIDirectory();
this.app.use(express.static(uiDir));
// Health check
this.app.get('/health', this.handleHealth.bind(this));
// Web UI viewer
this.app.get('/', this.handleViewerHTML.bind(this));
// SSE stream for web UI
this.app.get('/stream', this.handleSSEStream.bind(this));
// API endpoints for web UI
this.app.get('/api/stats', this.handleStats.bind(this));
this.app.get('/api/settings', this.handleGetSettings.bind(this));
this.app.post('/api/settings', this.handlePostSettings.bind(this));
this.app.get('/api/observations', this.handleGetObservations.bind(this));
this.app.get('/api/summaries', this.handleGetSummaries.bind(this));
this.app.get('/api/prompts', this.handleGetPrompts.bind(this));
// Session endpoints
this.app.post('/sessions/:sessionDbId/init', this.handleInit.bind(this));
this.app.post('/sessions/:sessionDbId/observations', this.handleObservation.bind(this));
@@ -146,6 +169,22 @@ class WorkerService {
});
}
/**
* Get UI directory path (works in both dev ESM and production CJS)
*/
private getUIDirectory(): string {
let scriptDir: string;
if (typeof __dirname !== 'undefined') {
// CJS context (production build)
scriptDir = __dirname;
} else {
// ESM context (development)
const __filename = fileURLToPath(import.meta.url);
scriptDir = dirname(__filename);
}
return join(scriptDir, '..', 'ui');
}
/**
* GET /health
*/
@@ -153,6 +192,411 @@ class WorkerService {
res.json({ status: 'ok' });
}
/**
* GET / - Serve viewer HTML
*/
private handleViewerHTML(_req: Request, res: Response): void {
try {
const uiPath = join(this.getUIDirectory(), 'viewer.html');
const html = readFileSync(uiPath, 'utf-8');
res.setHeader('Content-Type', 'text/html');
res.send(html);
} catch (error: any) {
logger.error('WORKER', 'Failed to serve viewer HTML', {}, error);
res.status(500).send('Failed to load viewer');
}
}
/**
* GET /stream - SSE endpoint for web UI
*/
private handleSSEStream(req: Request, res: Response): void {
// Set SSE headers
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('Access-Control-Allow-Origin', '*');
// Add client to set
this.sseClients.add(res);
logger.info('WORKER', `SSE client connected`, { totalClients: this.sseClients.size });
// Send only projects list - all data will be loaded via pagination
const db = new SessionStore();
const allProjects = db.getAllProjects();
db.close();
const initialData = {
type: 'initial_load',
projects: allProjects,
timestamp: Date.now()
};
res.write(`data: ${JSON.stringify(initialData)}\n\n`);
// Handle client disconnect
req.on('close', () => {
this.sseClients.delete(res);
logger.info('WORKER', `SSE client disconnected`, { remainingClients: this.sseClients.size });
});
}
/**
* Broadcast SSE event to all connected clients
*/
private broadcastSSE(event: any): void {
if (this.sseClients.size === 0) {
return; // No clients connected, skip broadcast
}
const data = `data: ${JSON.stringify(event)}\n\n`;
const clientsToRemove: Response[] = [];
for (const client of this.sseClients) {
try {
client.write(data);
} catch (error) {
// Client disconnected, mark for removal
clientsToRemove.push(client);
}
}
// Clean up disconnected clients
for (const client of clientsToRemove) {
this.sseClients.delete(client);
}
if (clientsToRemove.length > 0) {
logger.info('WORKER', `SSE cleaned up disconnected clients`, { count: clientsToRemove.length });
}
}
/**
* Broadcast processing status to SSE clients
*/
private broadcastProcessingStatus(claudeSessionId: string, isProcessing: boolean): void {
this.broadcastSSE({
type: 'processing_status',
processing: {
session_id: claudeSessionId,
is_processing: isProcessing
}
});
}
/**
* GET /api/stats - Return worker and database stats
*/
private handleStats(_req: Request, res: Response): void {
try {
const db = new SessionStore();
// Get database stats
const obsCount = db.db.prepare('SELECT COUNT(*) as count FROM observations').get() as { count: number };
const sessionCount = db.db.prepare('SELECT COUNT(*) as count FROM sdk_sessions').get() as { count: number };
const summaryCount = db.db.prepare('SELECT COUNT(*) as count FROM session_summaries').get() as { count: number };
// Get database file size
const dbPath = join(homedir(), '.claude-mem', 'claude-mem.db');
let dbSize = 0;
if (existsSync(dbPath)) {
dbSize = statSync(dbPath).size;
}
db.close();
// Get worker stats
const uptime = process.uptime();
const version = process.env.npm_package_version || '5.0.3'; // fallback to current version
res.json({
worker: {
version,
uptime: Math.floor(uptime),
activeSessions: this.sessions.size,
sseClients: this.sseClients.size,
port: this.port
},
database: {
path: dbPath,
size: dbSize,
observations: obsCount.count,
sessions: sessionCount.count,
summaries: summaryCount.count
}
});
} catch (error: any) {
logger.error('WORKER', 'Failed to get stats', {}, error);
res.status(500).json({ error: 'Failed to get stats' });
}
}
/**
* GET /api/settings - Read settings from ~/.claude/settings.json
*/
private handleGetSettings(_req: Request, res: Response): void {
try {
const settingsPath = join(homedir(), '.claude', 'settings.json');
if (!existsSync(settingsPath)) {
// Return defaults if file doesn't exist
res.json({
CLAUDE_MEM_MODEL: 'claude-haiku-4-5',
CLAUDE_MEM_CONTEXT_OBSERVATIONS: '50',
CLAUDE_MEM_WORKER_PORT: '37777'
});
return;
}
const settingsData = readFileSync(settingsPath, 'utf-8');
const settings = JSON.parse(settingsData);
const env = settings.env || {};
res.json({
CLAUDE_MEM_MODEL: env.CLAUDE_MEM_MODEL || 'claude-haiku-4-5',
CLAUDE_MEM_CONTEXT_OBSERVATIONS: env.CLAUDE_MEM_CONTEXT_OBSERVATIONS || '50',
CLAUDE_MEM_WORKER_PORT: env.CLAUDE_MEM_WORKER_PORT || '37777'
});
} catch (error: any) {
logger.error('WORKER', 'Failed to read settings', {}, error);
res.status(500).json({ error: 'Failed to read settings' });
}
}
/**
* POST /api/settings - Update settings in ~/.claude/settings.json
*/
private handlePostSettings(req: Request, res: Response): void {
try {
const { CLAUDE_MEM_MODEL, CLAUDE_MEM_CONTEXT_OBSERVATIONS, CLAUDE_MEM_WORKER_PORT } = req.body;
// Validate inputs
const validModels = ['claude-haiku-4-5', 'claude-sonnet-4-5', 'claude-opus-4'];
if (CLAUDE_MEM_MODEL && !validModels.includes(CLAUDE_MEM_MODEL)) {
res.status(400).json({ success: false, error: `Invalid model name: ${CLAUDE_MEM_MODEL}` });
return;
}
if (CLAUDE_MEM_CONTEXT_OBSERVATIONS) {
const obsCount = parseInt(CLAUDE_MEM_CONTEXT_OBSERVATIONS, 10);
if (isNaN(obsCount) || obsCount < 1 || obsCount > 200) {
res.status(400).json({ success: false, error: 'CLAUDE_MEM_CONTEXT_OBSERVATIONS must be between 1 and 200' });
return;
}
}
if (CLAUDE_MEM_WORKER_PORT) {
const port = parseInt(CLAUDE_MEM_WORKER_PORT, 10);
if (isNaN(port) || port < 1024 || port > 65535) {
res.status(400).json({ success: false, error: 'CLAUDE_MEM_WORKER_PORT must be between 1024 and 65535' });
return;
}
}
// Read existing settings
const settingsPath = join(homedir(), '.claude', 'settings.json');
let settings: any = { env: {} };
if (existsSync(settingsPath)) {
const settingsData = readFileSync(settingsPath, 'utf-8');
settings = JSON.parse(settingsData);
if (!settings.env) {
settings.env = {};
}
}
// Update settings
if (CLAUDE_MEM_MODEL) {
settings.env.CLAUDE_MEM_MODEL = CLAUDE_MEM_MODEL;
}
if (CLAUDE_MEM_CONTEXT_OBSERVATIONS) {
settings.env.CLAUDE_MEM_CONTEXT_OBSERVATIONS = CLAUDE_MEM_CONTEXT_OBSERVATIONS;
}
if (CLAUDE_MEM_WORKER_PORT) {
settings.env.CLAUDE_MEM_WORKER_PORT = CLAUDE_MEM_WORKER_PORT;
}
// Write back
writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
logger.info('WORKER', 'Settings updated', {});
res.json({ success: true, message: 'Settings updated successfully' });
} catch (error: any) {
logger.error('WORKER', 'Failed to update settings', {}, error);
res.status(500).json({ success: false, error: 'Failed to update settings' });
}
}
/**
* GET /api/observations - Paginated observations fetch
* Query params: offset (default 0), limit (default 50), project (optional)
*/
private handleGetObservations(req: Request, res: Response): void {
try {
const offset = parseInt(req.query.offset as string || '0', 10);
const limit = Math.min(parseInt(req.query.limit as string || '50', 10), 100); // Cap at 100
const project = req.query.project as string | undefined;
const db = new SessionStore();
// Build query with optional project filter
let query = `
SELECT id, type, title, subtitle, text, project, prompt_number, created_at, created_at_epoch
FROM observations
`;
let countQuery = 'SELECT COUNT(*) as total FROM observations';
const params: any[] = [];
const countParams: any[] = [];
if (project) {
query += ' WHERE project = ?';
countQuery += ' WHERE project = ?';
params.push(project);
countParams.push(project);
}
query += ' ORDER BY created_at_epoch DESC LIMIT ? OFFSET ?';
params.push(limit, offset);
const stmt = db.db.prepare(query);
const observations = stmt.all(...params);
// Check if there are more results
const countStmt = db.db.prepare(countQuery);
const { total } = countStmt.get(...countParams) as { total: number };
const hasMore = (offset + limit) < total;
db.close();
res.json({
observations,
hasMore,
total,
offset,
limit
});
} catch (error: any) {
logger.error('WORKER', 'Failed to get observations', {}, error);
res.status(500).json({ error: 'Failed to get observations' });
}
}
private handleGetSummaries(req: Request, res: Response): void {
try {
const offset = parseInt(req.query.offset as string || '0', 10);
const limit = Math.min(parseInt(req.query.limit as string || '50', 10), 100); // Cap at 100
const project = req.query.project as string | undefined;
const db = new SessionStore();
// Build query with optional project filter
// JOIN with sdk_sessions to get claude_session_id (needed for UI matching with processingSessions)
let query = `
SELECT
ss.id,
s.claude_session_id as session_id,
ss.request,
ss.learned,
ss.completed,
ss.next_steps,
ss.project,
ss.created_at,
ss.created_at_epoch
FROM session_summaries ss
JOIN sdk_sessions s ON ss.sdk_session_id = s.sdk_session_id
`;
let countQuery = 'SELECT COUNT(*) as total FROM session_summaries';
const params: any[] = [];
const countParams: any[] = [];
if (project) {
query += ' WHERE ss.project = ?';
countQuery += ' WHERE project = ?';
params.push(project);
countParams.push(project);
}
query += ' ORDER BY ss.created_at_epoch DESC LIMIT ? OFFSET ?';
params.push(limit, offset);
const stmt = db.db.prepare(query);
const summaries = stmt.all(...params);
// Check if there are more results
const countStmt = db.db.prepare(countQuery);
const { total } = countStmt.get(...countParams) as { total: number };
const hasMore = (offset + limit) < total;
db.close();
res.json({
summaries,
hasMore,
total,
offset,
limit
});
} catch (error: any) {
logger.error('WORKER', 'Failed to get summaries', {}, error);
res.status(500).json({ error: 'Failed to get summaries' });
}
}
private handleGetPrompts(req: Request, res: Response): void {
try {
const offset = parseInt(req.query.offset as string || '0', 10);
const limit = Math.min(parseInt(req.query.limit as string || '50', 10), 100); // Cap at 100
const project = req.query.project as string | undefined;
const db = new SessionStore();
// Build query with optional project filter - JOIN with sdk_sessions to get project
let query = `
SELECT up.id, up.claude_session_id, s.project, up.prompt_number, up.prompt_text, up.created_at, up.created_at_epoch
FROM user_prompts up
JOIN sdk_sessions s ON up.claude_session_id = s.claude_session_id
`;
let countQuery = `
SELECT COUNT(*) as total
FROM user_prompts up
JOIN sdk_sessions s ON up.claude_session_id = s.claude_session_id
`;
const params: any[] = [];
const countParams: any[] = [];
if (project) {
query += ' WHERE s.project = ?';
countQuery += ' WHERE s.project = ?';
params.push(project);
countParams.push(project);
}
query += ' ORDER BY created_at_epoch DESC LIMIT ? OFFSET ?';
params.push(limit, offset);
const stmt = db.db.prepare(query);
const prompts = stmt.all(...params);
// Check if there are more results
const countStmt = db.db.prepare(countQuery);
const { total } = countStmt.get(...countParams) as { total: number };
const hasMore = (offset + limit) < total;
db.close();
res.json({
prompts,
hasMore,
total,
offset,
limit
});
} catch (error: any) {
logger.error('WORKER', 'Failed to get prompts', {}, error);
res.status(500).json({ error: 'Failed to get prompts' });
}
}
/**
* POST /sessions/:sessionDbId/init
* Body: { project, userPrompt }
@@ -208,6 +652,21 @@ class WorkerService {
db.close();
// Broadcast new prompt to SSE clients (for web UI)
if (latestPrompt) {
this.broadcastSSE({
type: 'new_prompt',
prompt: {
id: latestPrompt.id,
claude_session_id: latestPrompt.claude_session_id,
project: latestPrompt.project,
prompt_number: latestPrompt.prompt_number,
prompt_text: latestPrompt.prompt_text,
created_at_epoch: latestPrompt.created_at_epoch
}
});
}
// Sync user prompt to Chroma (fire-and-forget, but crash on failure)
if (latestPrompt) {
this.chromaSync.syncUserPrompt(
@@ -296,6 +755,9 @@ class WorkerService {
prompt_number
});
// Don't broadcast processing status for observations - only for summaries
// Observations are processed continuously, skeleton should only show during summary generation
res.json({ status: 'queued', queueLength: session.pendingMessages.length });
}
@@ -351,6 +813,9 @@ class WorkerService {
prompt_number
});
// Notify UI that processing is active
this.broadcastProcessingStatus(session.claudeSessionId, true);
res.json({ status: 'queued', queueLength: session.pendingMessages.length });
}
@@ -612,6 +1077,21 @@ class WorkerService {
id
});
// Broadcast to SSE clients (for web UI)
this.broadcastSSE({
type: 'new_observation',
observation: {
id,
session_id: session.claudeSessionId,
type: obs.type,
title: obs.title,
subtitle: obs.subtitle,
project: session.project,
prompt_number: promptNumber,
created_at_epoch: createdAtEpoch
}
});
// Sync to Chroma (non-blocking fire-and-forget, but crash on failure)
this.chromaSync.syncObservation(
id,
@@ -651,6 +1131,27 @@ class WorkerService {
const { id, createdAtEpoch } = db.storeSummary(session.claudeSessionId, session.project, summary, promptNumber);
logger.success('DB', '📝 SUMMARY STORED IN DATABASE', { sessionId: session.sessionDbId, promptNumber, id });
// Broadcast to SSE clients (for web UI)
this.broadcastSSE({
type: 'new_summary',
summary: {
id,
session_id: session.claudeSessionId,
request: summary.request,
investigated: summary.investigated,
learned: summary.learned,
completed: summary.completed,
next_steps: summary.next_steps,
notes: summary.notes,
project: session.project,
prompt_number: promptNumber,
created_at_epoch: createdAtEpoch
}
});
// Notify UI that processing is complete (summary is the final step)
this.broadcastProcessingStatus(session.claudeSessionId, false);
// Sync to Chroma (non-blocking fire-and-forget, but crash on failure)
this.chromaSync.syncSummary(
id,
@@ -677,6 +1178,9 @@ class WorkerService {
promptNumber,
contentSample: content.substring(0, 500)
});
// Still mark processing as complete even if no summary was generated
this.broadcastProcessingStatus(session.claudeSessionId, false);
}
db.close();
+2 -2
View File
@@ -7,7 +7,7 @@ const FIXED_PORT = parseInt(process.env.CLAUDE_MEM_WORKER_PORT || "37777", 10);
/**
* Check if worker is responsive by trying the health endpoint
*/
async function isWorkerHealthy(timeoutMs: number = 3000): Promise<boolean> {
async function isWorkerHealthy(timeoutMs: number = 100): Promise<boolean> {
try {
const response = await fetch(`http://127.0.0.1:${FIXED_PORT}/health`, {
signal: AbortSignal.timeout(timeoutMs)
@@ -42,7 +42,7 @@ async function waitForWorkerHealth(maxWaitMs: number = 10000): Promise<boolean>
*/
export async function ensureWorkerRunning(): Promise<void> {
// First, check if worker is already healthy
if (await isWorkerHealthy(1000)) {
if (await isWorkerHealthy()) {
return; // Worker is already running and responsive
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

+512
View File
@@ -0,0 +1,512 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>claude-mem viewer</title>
<link rel="icon" type="image/webp" href="claude-mem-logomark.webp">
<style>
@font-face {
font-family: 'Monaspace Radon';
src: url('assets/fonts/monaspace-radon-var.woff2') format('woff2-variations'),
url('assets/fonts/monaspace-radon-var.woff') format('woff-variations');
font-weight: 200 900;
font-display: swap;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica', 'Arial', sans-serif;
background: #1e1e1e;
color: #cccccc;
font-size: 14px;
overflow: hidden;
}
.container {
display: flex;
height: 100vh;
width: 100vw;
position: relative;
}
.main-col {
flex: 1;
display: flex;
flex-direction: column;
}
.sidebar {
position: fixed;
right: 0;
top: 0;
width: 400px;
height: 100vh;
background: #1e1e1e;
border-left: 1px solid #404040;
display: flex;
flex-direction: column;
transform: translate3d(100%, 0, 0);
transition: transform 0.3s ease;
z-index: 100;
will-change: transform;
}
.sidebar.open {
transform: translate3d(0, 0, 0);
}
.header {
padding: 14px 18px;
border-bottom: 1px solid #404040;
display: flex;
justify-content: space-between;
align-items: center;
background: #252526;
}
.sidebar-header {
padding: 14px 18px;
border-bottom: 1px solid #404040;
display: flex;
justify-content: space-between;
align-items: center;
background: #252526;
}
.sidebar-header h1 {
font-size: 16px;
font-weight: 500;
color: #e0e0e0;
}
.header h1 {
font-size: 16px;
font-weight: 500;
color: #e0e0e0;
display: flex;
align-items: center;
gap: 10px;
}
.logomark {
height: 32px;
width: auto;
}
.logomark.spinning {
animation: spin 1.5s linear infinite;
}
.logo-text {
font-family: 'Monaspace Radon', monospace;
font-weight: 100;
font-size: 20px;
letter-spacing: -0.03em;
color: #dadada;
}
.status {
display: flex;
align-items: center;
gap: 12px;
font-size: 13px;
}
.settings-btn {
background: transparent;
border: 1px solid #404040;
padding: 8px;
width: 36px;
height: 36px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: #cccccc;
transition: all 0.15s ease;
}
.settings-btn:hover {
background: #2d2d2d;
border-color: #58a6ff;
}
.settings-btn.active {
background: #0969da;
border-color: #0969da;
color: white;
}
.settings-icon {
width: 18px;
height: 18px;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #e74856;
animation: pulse 2s ease-in-out infinite;
}
.status-dot.connected {
background: #16c60c;
animation: none;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
select,
input,
button {
background: #2d2d2d;
color: #cccccc;
border: 1px solid #404040;
padding: 6px 12px;
font-family: inherit;
font-size: 13px;
border-radius: 4px;
transition: all 0.15s ease;
}
select:hover,
input:hover {
border-color: #58a6ff;
}
select:focus,
input:focus {
outline: none;
border-color: #58a6ff;
box-shadow: 0 0 0 2px rgba(88, 166, 255, 0.2);
}
button {
background: #0969da;
color: #ffffff;
border: none;
font-weight: 500;
cursor: pointer;
}
button:hover:not(:disabled) {
background: #1177e6;
}
button:active:not(:disabled) {
background: #0860ca;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.feed {
flex: 1;
overflow-y: auto;
padding: 24px 18px;
display: flex;
justify-content: center;
}
.feed-content {
width: 100%;
max-width: 42rem;
}
.card {
margin-bottom: 24px;
padding: 20px 24px;
background: #2d2d2d;
border: 1px solid #404040;
border-radius: 8px;
transition: all 0.15s ease;
animation: slideIn 0.3s ease-out;
line-height: 1.7;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.card:hover {
border-color: #505050;
}
.card-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
font-size: 12px;
color: #8b949e;
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
}
.card-type {
padding: 2px 8px;
background: #58a6ff20;
color: #58a6ff;
border-radius: 3px;
font-weight: 500;
text-transform: uppercase;
font-size: 11px;
letter-spacing: 0.5px;
}
.card-title {
font-size: 17px;
margin-bottom: 8px;
color: #e0e0e0;
font-weight: 600;
line-height: 1.4;
letter-spacing: -0.01em;
}
.card-subtitle {
font-size: 14px;
color: #a0a0a0;
margin-bottom: 8px;
line-height: 1.6;
}
.card-meta {
font-size: 12px;
color: #6e7681;
margin-top: 8px;
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
}
.summary-card {
border-color: #9e6a03;
background: #3d2f00;
}
.summary-card:hover {
border-color: #ae7a13;
}
.summary-card .card-type {
background: #f2cc6020;
color: #f2cc60;
}
.summary-card .card-title {
color: #f2cc60;
}
.settings-section {
padding: 18px;
border-bottom: 1px solid #404040;
}
.settings-section h3 {
font-size: 14px;
font-weight: 600;
margin-bottom: 14px;
color: #e0e0e0;
letter-spacing: 0.3px;
}
.form-group {
margin-bottom: 14px;
}
.form-group label {
display: block;
margin-bottom: 6px;
font-size: 12px;
color: #8b949e;
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
}
.setting-description {
font-size: 12px;
color: #8b949e;
margin-bottom: 8px;
line-height: 1.5;
}
.stats-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.stat {
padding: 10px 12px;
background: #2d2d2d;
border: 1px solid #404040;
border-radius: 4px;
}
.stat-label {
color: #8b949e;
margin-bottom: 4px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.stat-value {
font-size: 18px;
color: #e0e0e0;
font-weight: 600;
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
}
.stats-scroll {
flex: 1;
overflow-y: auto;
}
::-webkit-scrollbar {
width: 10px;
}
::-webkit-scrollbar-track {
background: #1e1e1e;
}
::-webkit-scrollbar-thumb {
background: #424242;
border-radius: 5px;
}
::-webkit-scrollbar-thumb:hover {
background: #4e4e4e;
}
.save-status {
margin-top: 8px;
font-size: 12px;
color: #8b949e;
}
.prompt-card {
border-color: #6e40c9;
background: #2d1b4e;
}
.prompt-card:hover {
border-color: #8e6cdb;
}
.prompt-card .card-type {
background: #6e40c920;
color: #8e6cdb;
}
.card-content {
margin-top: 12px;
line-height: 1.6;
color: #cccccc;
white-space: pre-wrap;
word-wrap: break-word;
}
.processing-indicator {
display: inline-flex;
align-items: center;
gap: 6px;
color: #58a6ff;
font-size: 11px;
font-weight: 500;
margin-left: auto;
}
.spinner {
width: 12px;
height: 12px;
border: 2px solid #404040;
border-top-color: #58a6ff;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.summary-skeleton {
opacity: 0.7;
}
.summary-skeleton .processing-indicator {
margin-left: auto;
}
.skeleton-line {
height: 16px;
background: linear-gradient(90deg, #404040 25%, #505050 50%, #404040 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 4px;
margin-bottom: 8px;
}
.skeleton-title {
height: 20px;
width: 80%;
margin-bottom: 10px;
}
.skeleton-subtitle {
height: 16px;
width: 90%;
}
.skeleton-subtitle.short {
width: 60%;
}
@keyframes shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
</style>
</head>
<body>
<div id="root"></div>
<script src="viewer-bundle.js"></script>
</body>
</html>
+115
View File
@@ -0,0 +1,115 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { Header } from './components/Header';
import { Feed } from './components/Feed';
import { Sidebar } from './components/Sidebar';
import { useSSE } from './hooks/useSSE';
import { useSettings } from './hooks/useSettings';
import { useStats } from './hooks/useStats';
import { usePagination } from './hooks/usePagination';
import { Observation, Summary, UserPrompt } from './types';
import { mergeAndDeduplicateByProject } from './utils/data';
export function App() {
const [currentFilter, setCurrentFilter] = useState('');
const [sidebarOpen, setSidebarOpen] = useState(false);
const [paginatedObservations, setPaginatedObservations] = useState<Observation[]>([]);
const [paginatedSummaries, setPaginatedSummaries] = useState<Summary[]>([]);
const [paginatedPrompts, setPaginatedPrompts] = useState<UserPrompt[]>([]);
const { observations, summaries, prompts, projects, processingSessions, isConnected } = useSSE();
const { settings, saveSettings, isSaving, saveStatus } = useSettings();
const { stats } = useStats();
const pagination = usePagination(currentFilter);
// Reset paginated data when filter changes
useEffect(() => {
setPaginatedObservations([]);
setPaginatedSummaries([]);
setPaginatedPrompts([]);
}, [currentFilter]);
// Merge real-time data with paginated data, removing duplicates and filtering by project
const allObservations = useMemo(
() => mergeAndDeduplicateByProject(observations, paginatedObservations, currentFilter),
[observations, paginatedObservations, currentFilter]
);
const allSummaries = useMemo(
() => mergeAndDeduplicateByProject(summaries, paginatedSummaries, currentFilter),
[summaries, paginatedSummaries, currentFilter]
);
const allPrompts = useMemo(
() => mergeAndDeduplicateByProject(prompts, paginatedPrompts, currentFilter),
[prompts, paginatedPrompts, currentFilter]
);
// Toggle sidebar
const toggleSidebar = useCallback(() => {
setSidebarOpen(prev => !prev);
}, []);
// Handle loading more data
const handleLoadMore = useCallback(async () => {
try {
const [newObservations, newSummaries, newPrompts] = await Promise.all([
pagination.observations.loadMore(),
pagination.summaries.loadMore(),
pagination.prompts.loadMore()
]);
if (newObservations.length > 0) {
setPaginatedObservations(prev => [...prev, ...newObservations]);
}
if (newSummaries.length > 0) {
setPaginatedSummaries(prev => [...prev, ...newSummaries]);
}
if (newPrompts.length > 0) {
setPaginatedPrompts(prev => [...prev, ...newPrompts]);
}
} catch (error) {
console.error('Failed to load more data:', error);
}
}, [pagination]);
// Load first page when filter changes or pagination handlers update
useEffect(() => {
handleLoadMore();
}, [currentFilter, handleLoadMore]);
return (
<div className="container">
<div className="main-col">
<Header
isConnected={isConnected}
projects={projects}
currentFilter={currentFilter}
onFilterChange={setCurrentFilter}
onSettingsToggle={toggleSidebar}
sidebarOpen={sidebarOpen}
isProcessing={processingSessions.size > 0}
/>
<Feed
observations={allObservations}
summaries={allSummaries}
prompts={allPrompts}
processingSessions={processingSessions}
onLoadMore={handleLoadMore}
isLoading={pagination.observations.isLoading || pagination.summaries.isLoading || pagination.prompts.isLoading}
hasMore={pagination.observations.hasMore || pagination.summaries.hasMore || pagination.prompts.hasMore}
/>
</div>
<Sidebar
isOpen={sidebarOpen}
settings={settings}
stats={stats}
isSaving={isSaving}
saveStatus={saveStatus}
isConnected={isConnected}
onSave={saveSettings}
onClose={toggleSidebar}
/>
</div>
);
}
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,63 @@
import React, { Component, ReactNode, ErrorInfo } from 'react';
interface Props {
children: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
errorInfo: ErrorInfo | null;
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
hasError: false,
error: null,
errorInfo: null
};
}
static getDerivedStateFromError(error: Error): Partial<State> {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('[ErrorBoundary] Caught error:', error, errorInfo);
this.setState({
error,
errorInfo
});
}
render() {
if (this.state.hasError) {
return (
<div style={{ padding: '20px', color: '#ff6b6b', backgroundColor: '#1a1a1a', minHeight: '100vh' }}>
<h1 style={{ fontSize: '24px', marginBottom: '10px' }}>Something went wrong</h1>
<p style={{ marginBottom: '10px', color: '#8b949e' }}>
The application encountered an error. Please refresh the page to try again.
</p>
{this.state.error && (
<details style={{ marginTop: '20px', color: '#8b949e' }}>
<summary style={{ cursor: 'pointer', marginBottom: '10px' }}>Error details</summary>
<pre style={{
backgroundColor: '#0d1117',
padding: '10px',
borderRadius: '6px',
overflow: 'auto'
}}>
{this.state.error.toString()}
{this.state.errorInfo && '\n\n' + this.state.errorInfo.componentStack}
</pre>
</details>
)}
</div>
);
}
return this.props.children;
}
}
+131
View File
@@ -0,0 +1,131 @@
import React, { useMemo, useRef, useEffect } from 'react';
import { Observation, Summary, UserPrompt, FeedItem } from '../types';
import { ObservationCard } from './ObservationCard';
import { SummaryCard } from './SummaryCard';
import { SummarySkeleton } from './SummarySkeleton';
import { PromptCard } from './PromptCard';
import { UI } from '../constants/ui';
interface FeedProps {
observations: Observation[];
summaries: Summary[];
prompts: UserPrompt[];
processingSessions: Set<string>;
onLoadMore: () => void;
isLoading: boolean;
hasMore: boolean;
}
export function Feed({ observations, summaries, prompts, processingSessions, onLoadMore, isLoading, hasMore }: FeedProps) {
const loadMoreRef = useRef<HTMLDivElement>(null);
const onLoadMoreRef = useRef(onLoadMore);
// Keep the callback ref up to date
useEffect(() => {
onLoadMoreRef.current = onLoadMore;
}, [onLoadMore]);
// Set up intersection observer for infinite scroll
useEffect(() => {
const element = loadMoreRef.current;
if (!element) return;
const observer = new IntersectionObserver(
(entries) => {
const first = entries[0];
if (first.isIntersecting && hasMore && !isLoading) {
onLoadMoreRef.current?.();
}
},
{ threshold: UI.LOAD_MORE_THRESHOLD }
);
observer.observe(element);
return () => {
if (element) {
observer.unobserve(element);
}
observer.disconnect();
};
}, [hasMore, isLoading]);
const items = useMemo<FeedItem[]>(() => {
// Create a set of session IDs that already have summaries
const sessionsWithSummaries = new Set(summaries.map(s => s.session_id));
// Find the most recent prompt for each processing session
const sessionPrompts = new Map<string, UserPrompt>();
prompts.forEach(p => {
const existing = sessionPrompts.get(p.claude_session_id);
if (!existing || p.created_at_epoch > existing.created_at_epoch) {
sessionPrompts.set(p.claude_session_id, p);
}
});
// Create skeleton items for sessions being processed that don't have summaries yet
const skeletons: FeedItem[] = [];
processingSessions.forEach(sessionId => {
if (!sessionsWithSummaries.has(sessionId)) {
const prompt = sessionPrompts.get(sessionId);
skeletons.push({
itemType: 'skeleton',
id: sessionId, // Don't add prefix - key construction adds itemType already
session_id: sessionId,
project: prompt?.project,
// Always use current time so skeletons appear at top of feed
created_at_epoch: Date.now()
});
}
});
// Data is already filtered by App.tsx - no need to filter again
const combined = [
...observations.map(o => ({ ...o, itemType: 'observation' as const })),
...summaries.map(s => ({ ...s, itemType: 'summary' as const })),
...prompts.map(p => ({ ...p, itemType: 'prompt' as const })),
...skeletons
];
return combined
.sort((a, b) => b.created_at_epoch - a.created_at_epoch);
}, [observations, summaries, prompts, processingSessions]);
return (
<div className="feed">
<div className="feed-content">
{items.map(item => {
const key = `${item.itemType}-${item.id}`;
if (item.itemType === 'observation') {
return <ObservationCard key={key} observation={item} />;
} else if (item.itemType === 'summary') {
return <SummaryCard key={key} summary={item} />;
} else if (item.itemType === 'skeleton') {
return <SummarySkeleton key={key} sessionId={item.session_id} project={item.project} />;
} else {
return <PromptCard key={key} prompt={item} />;
}
})}
{items.length === 0 && !isLoading && (
<div style={{ textAlign: 'center', padding: '40px', color: '#8b949e' }}>
No items to display
</div>
)}
{isLoading && (
<div style={{ textAlign: 'center', padding: '20px', color: '#8b949e' }}>
<div className="spinner" style={{ display: 'inline-block', marginRight: '10px' }}></div>
Loading more...
</div>
)}
{hasMore && !isLoading && items.length > 0 && (
<div ref={loadMoreRef} style={{ height: '20px', margin: '10px 0' }} />
)}
{!hasMore && items.length > 0 && (
<div style={{ textAlign: 'center', padding: '20px', color: '#8b949e', fontSize: '14px' }}>
No more items to load
</div>
)}
</div>
</div>
);
}
+89
View File
@@ -0,0 +1,89 @@
import React from 'react';
interface HeaderProps {
isConnected: boolean;
projects: string[];
currentFilter: string;
onFilterChange: (filter: string) => void;
onSettingsToggle: () => void;
sidebarOpen: boolean;
isProcessing: boolean;
}
export function Header({
isConnected,
projects,
currentFilter,
onFilterChange,
onSettingsToggle,
sidebarOpen,
isProcessing
}: HeaderProps) {
return (
<div className="header">
<h1>
<img src="claude-mem-logomark.webp" alt="" className={`logomark ${isProcessing ? 'spinning' : ''}`} />
<span className="logo-text">claude-mem</span>
</h1>
<div className="status">
<a
href="https://github.com/thedotmack/claude-mem/"
target="_blank"
rel="noopener noreferrer"
title="GitHub"
style={{
display: 'block',
padding: '8px 4px 8px 8px',
color: '#a0a0a0',
transition: 'color 0.2s',
lineHeight: 0
}}
onMouseEnter={(e) => e.currentTarget.style.color = '#ffffff'}
onMouseLeave={(e) => e.currentTarget.style.color = '#a0a0a0'}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
</a>
<a
href="https://x.com/Claude_Memory"
target="_blank"
rel="noopener noreferrer"
title="X (Twitter)"
style={{
display: 'block',
padding: '8px 8px 8px 4px',
color: '#a0a0a0',
transition: 'color 0.2s',
lineHeight: 0
}}
onMouseEnter={(e) => e.currentTarget.style.color = '#ffffff'}
onMouseLeave={(e) => e.currentTarget.style.color = '#a0a0a0'}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
</svg>
</a>
<select
value={currentFilter}
onChange={e => onFilterChange(e.target.value)}
>
<option value="">All Projects</option>
{projects.map(project => (
<option key={project} value={project}>{project}</option>
))}
</select>
<button
className={`settings-btn ${sidebarOpen ? 'active' : ''}`}
onClick={onSettingsToggle}
title="Settings"
>
<svg className="settings-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
</button>
</div>
</div>
);
}
@@ -0,0 +1,25 @@
import React from 'react';
import { Observation } from '../types';
import { formatDate } from '../utils/formatters';
interface ObservationCardProps {
observation: Observation;
}
export function ObservationCard({ observation }: ObservationCardProps) {
const date = formatDate(observation.created_at_epoch);
return (
<div className="card">
<div className="card-header">
<span className="card-type">{observation.type}</span>
<span>{observation.project}</span>
</div>
<div className="card-title">{observation.title || 'Untitled'}</div>
{observation.subtitle && (
<div className="card-subtitle">{observation.subtitle}</div>
)}
<div className="card-meta">#{observation.id} {date}</div>
</div>
);
}
+24
View File
@@ -0,0 +1,24 @@
import React from 'react';
import { UserPrompt } from '../types';
import { formatDate } from '../utils/formatters';
interface PromptCardProps {
prompt: UserPrompt;
}
export function PromptCard({ prompt }: PromptCardProps) {
return (
<div className="card prompt-card">
<div className="card-header">
<span className="card-type">Prompt</span>
<span>{prompt.project}</span>
</div>
<div className="card-content">
{prompt.prompt_text}
</div>
<div className="card-meta">
{formatDate(prompt.created_at_epoch)}
</div>
</div>
);
}
+168
View File
@@ -0,0 +1,168 @@
import React, { useState, useEffect } from 'react';
import { Settings, Stats } from '../types';
import { DEFAULT_SETTINGS } from '../constants/settings';
import { formatUptime, formatBytes } from '../utils/formatters';
interface SidebarProps {
isOpen: boolean;
settings: Settings;
stats: Stats;
isSaving: boolean;
saveStatus: string;
isConnected: boolean;
onSave: (settings: Settings) => void;
onClose: () => void;
}
export function Sidebar({ isOpen, settings, stats, isSaving, saveStatus, isConnected, onSave, onClose }: SidebarProps) {
const [model, setModel] = useState(settings.CLAUDE_MEM_MODEL || DEFAULT_SETTINGS.CLAUDE_MEM_MODEL);
const [contextObs, setContextObs] = useState(settings.CLAUDE_MEM_CONTEXT_OBSERVATIONS || DEFAULT_SETTINGS.CLAUDE_MEM_CONTEXT_OBSERVATIONS);
const [workerPort, setWorkerPort] = useState(settings.CLAUDE_MEM_WORKER_PORT || DEFAULT_SETTINGS.CLAUDE_MEM_WORKER_PORT);
// Update local state when settings change
useEffect(() => {
setModel(settings.CLAUDE_MEM_MODEL || DEFAULT_SETTINGS.CLAUDE_MEM_MODEL);
setContextObs(settings.CLAUDE_MEM_CONTEXT_OBSERVATIONS || DEFAULT_SETTINGS.CLAUDE_MEM_CONTEXT_OBSERVATIONS);
setWorkerPort(settings.CLAUDE_MEM_WORKER_PORT || DEFAULT_SETTINGS.CLAUDE_MEM_WORKER_PORT);
}, [settings]);
const handleSave = () => {
onSave({
CLAUDE_MEM_MODEL: model,
CLAUDE_MEM_CONTEXT_OBSERVATIONS: contextObs,
CLAUDE_MEM_WORKER_PORT: workerPort
});
};
return (
<div className={`sidebar ${isOpen ? 'open' : ''}`}>
<div className="sidebar-header">
<h1>Settings</h1>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<span className={`status-dot ${isConnected ? 'connected' : ''}`} />
<span style={{ fontSize: '11px', opacity: 0.5, fontWeight: 300 }}>{isConnected ? 'Connected' : 'Disconnected'}</span>
</div>
<button onClick={handleSave} disabled={isSaving}>
{isSaving ? 'Saving...' : 'Save'}
</button>
<button
onClick={onClose}
title="Close settings"
style={{
background: 'transparent',
border: '1px solid #404040',
padding: '8px',
width: '36px',
height: '36px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
</div>
<div className="stats-scroll">
<div className="settings-section">
<h3>Environment Variables</h3>
<div className="form-group">
<label htmlFor="model">CLAUDE_MEM_MODEL</label>
<div className="setting-description">
Model used for AI compression of tool observations. Haiku is fast and cheap, Sonnet offers better quality, Opus is most capable but expensive.
</div>
<select
id="model"
value={model}
onChange={e => setModel(e.target.value)}
>
<option value="claude-haiku-4-5">claude-haiku-4-5</option>
<option value="claude-sonnet-4-5">claude-sonnet-4-5</option>
<option value="claude-opus-4">claude-opus-4</option>
</select>
</div>
<div className="form-group">
<label htmlFor="contextObs">CLAUDE_MEM_CONTEXT_OBSERVATIONS</label>
<div className="setting-description">
Number of recent observations to inject at session start. Higher values provide more context but increase token usage. Default: 50
</div>
<input
type="number"
id="contextObs"
min="1"
max="200"
value={contextObs}
onChange={e => setContextObs(e.target.value)}
/>
</div>
<div className="form-group">
<label htmlFor="workerPort">CLAUDE_MEM_WORKER_PORT</label>
<div className="setting-description">
Port number for the background worker service. Change only if port 37777 conflicts with another service.
</div>
<input
type="number"
id="workerPort"
min="1024"
max="65535"
value={workerPort}
onChange={e => setWorkerPort(e.target.value)}
/>
</div>
{saveStatus && (
<div className="save-status">{saveStatus}</div>
)}
</div>
<div className="settings-section">
<h3>Worker Stats</h3>
<div className="stats-grid">
<div className="stat">
<div className="stat-label">Version</div>
<div className="stat-value">{stats.worker?.version || '-'}</div>
</div>
<div className="stat">
<div className="stat-label">Uptime</div>
<div className="stat-value">{formatUptime(stats.worker?.uptime)}</div>
</div>
<div className="stat">
<div className="stat-label">Active Sessions</div>
<div className="stat-value">{stats.worker?.activeSessions || '0'}</div>
</div>
<div className="stat">
<div className="stat-label">SSE Clients</div>
<div className="stat-value">{stats.worker?.sseClients || '0'}</div>
</div>
</div>
</div>
<div className="settings-section">
<h3>Database Stats</h3>
<div className="stats-grid">
<div className="stat">
<div className="stat-label">DB Size</div>
<div className="stat-value">{formatBytes(stats.database?.size)}</div>
</div>
<div className="stat">
<div className="stat-label">Observations</div>
<div className="stat-value">{stats.database?.observations || '0'}</div>
</div>
<div className="stat">
<div className="stat-label">Sessions</div>
<div className="stat-value">{stats.database?.sessions || '0'}</div>
</div>
<div className="stat">
<div className="stat-label">Summaries</div>
<div className="stat-value">{stats.database?.summaries || '0'}</div>
</div>
</div>
</div>
</div>
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
import React from 'react';
import { Summary } from '../types';
import { formatDate } from '../utils/formatters';
interface SummaryCardProps {
summary: Summary;
}
export function SummaryCard({ summary }: SummaryCardProps) {
const date = formatDate(summary.created_at_epoch);
return (
<div className="card summary-card">
<div className="card-header">
<span className="card-type">SUMMARY</span>
<span>{summary.project}</span>
</div>
{summary.request && (
<div className="card-title">Request: {summary.request}</div>
)}
{summary.learned && (
<div className="card-subtitle">Learned: {summary.learned}</div>
)}
{summary.completed && (
<div className="card-subtitle">Completed: {summary.completed}</div>
)}
{summary.next_steps && (
<div className="card-subtitle">Next: {summary.next_steps}</div>
)}
<div className="card-meta">#{summary.id} {date}</div>
</div>
);
}
@@ -0,0 +1,25 @@
import React from 'react';
interface SummarySkeletonProps {
sessionId: string;
project?: string;
}
export function SummarySkeleton({ sessionId, project }: SummarySkeletonProps) {
return (
<div className="card summary-card summary-skeleton">
<div className="card-header">
<span className="card-type">SUMMARY</span>
{project && <span>{project}</span>}
<div className="processing-indicator">
<div className="spinner"></div>
<span>Generating...</span>
</div>
</div>
<div className="skeleton-line skeleton-title"></div>
<div className="skeleton-line skeleton-subtitle"></div>
<div className="skeleton-line skeleton-subtitle short"></div>
<div className="card-meta">Session: {sessionId}</div>
</div>
);
}
+12
View File
@@ -0,0 +1,12 @@
/**
* API endpoint paths
* Centralized to avoid magic strings scattered throughout the codebase
*/
export const API_ENDPOINTS = {
OBSERVATIONS: '/api/observations',
SUMMARIES: '/api/summaries',
PROMPTS: '/api/prompts',
SETTINGS: '/api/settings',
STATS: '/api/stats',
STREAM: '/stream',
} as const;
+9
View File
@@ -0,0 +1,9 @@
/**
* Default settings values for Claude Memory
* Shared across UI components and hooks
*/
export const DEFAULT_SETTINGS = {
CLAUDE_MEM_MODEL: 'claude-haiku-4-5',
CLAUDE_MEM_CONTEXT_OBSERVATIONS: '50',
CLAUDE_MEM_WORKER_PORT: '37777',
} as const;
+14
View File
@@ -0,0 +1,14 @@
/**
* Timing constants in milliseconds
* All timeout and interval durations used throughout the UI
*/
export const TIMING = {
/** SSE reconnection delay after connection error */
SSE_RECONNECT_DELAY_MS: 3000,
/** Stats refresh interval for worker status polling */
STATS_REFRESH_INTERVAL_MS: 10000,
/** Duration to display save status message before clearing */
SAVE_STATUS_DISPLAY_DURATION_MS: 3000,
} as const;
+11
View File
@@ -0,0 +1,11 @@
/**
* UI-related constants
* Pagination, intersection observer settings, and other UI configuration
*/
export const UI = {
/** Number of observations to load per page */
PAGINATION_PAGE_SIZE: 50,
/** Intersection observer threshold (0-1, percentage of visibility needed to trigger) */
LOAD_MORE_THRESHOLD: 0.1,
} as const;
+98
View File
@@ -0,0 +1,98 @@
import { useState, useEffect, useCallback } from 'react';
import { Observation, Summary, UserPrompt } from '../types';
import { UI } from '../constants/ui';
import { API_ENDPOINTS } from '../constants/api';
interface PaginationState {
isLoading: boolean;
hasMore: boolean;
}
type DataType = 'observations' | 'summaries' | 'prompts';
type DataItem = Observation | Summary | UserPrompt;
/**
* Generic pagination hook for observations, summaries, and prompts
*/
function usePaginationFor(endpoint: string, dataType: DataType, currentFilter: string) {
const [state, setState] = useState<PaginationState>({
isLoading: false,
hasMore: true
});
const [offset, setOffset] = useState(0);
// Reset pagination when filter changes
useEffect(() => {
setOffset(0);
setState({
isLoading: false,
hasMore: true
});
}, [currentFilter]);
/**
* Load more items from the API
*/
const loadMore = useCallback(async (): Promise<DataItem[]> => {
// Prevent concurrent requests using state
if (state.isLoading || !state.hasMore) {
return [];
}
setState(prev => ({ ...prev, isLoading: true }));
try {
// Build query params
const params = new URLSearchParams({
offset: offset.toString(),
limit: UI.PAGINATION_PAGE_SIZE.toString()
});
// Add project filter if present
if (currentFilter) {
params.append('project', currentFilter);
}
const response = await fetch(`${endpoint}?${params}`);
if (!response.ok) {
throw new Error(`Failed to load ${dataType}: ${response.statusText}`);
}
const data = await response.json();
setState(prev => ({
...prev,
isLoading: false,
hasMore: data.hasMore
}));
setOffset(prev => prev + UI.PAGINATION_PAGE_SIZE);
return data[dataType] as DataItem[];
} catch (error) {
console.error(`Failed to load ${dataType}:`, error);
setState(prev => ({ ...prev, isLoading: false }));
return [];
}
}, [offset, state.hasMore, state.isLoading, currentFilter, endpoint, dataType]);
return {
...state,
loadMore
};
}
/**
* Hook for paginating observations
*/
export function usePagination(currentFilter: string) {
const observations = usePaginationFor(API_ENDPOINTS.OBSERVATIONS, 'observations', currentFilter);
const summaries = usePaginationFor(API_ENDPOINTS.SUMMARIES, 'summaries', currentFilter);
const prompts = usePaginationFor(API_ENDPOINTS.PROMPTS, 'prompts', currentFilter);
return {
observations,
summaries,
prompts
};
}
+126
View File
@@ -0,0 +1,126 @@
import { useState, useEffect, useRef } from 'react';
import { Observation, Summary, UserPrompt, StreamEvent } from '../types';
import { API_ENDPOINTS } from '../constants/api';
import { TIMING } from '../constants/timing';
export function useSSE() {
const [observations, setObservations] = useState<Observation[]>([]);
const [summaries, setSummaries] = useState<Summary[]>([]);
const [prompts, setPrompts] = useState<UserPrompt[]>([]);
const [projects, setProjects] = useState<string[]>([]);
const [isConnected, setIsConnected] = useState(false);
const [processingSessions, setProcessingSessions] = useState<Set<string>>(new Set());
const eventSourceRef = useRef<EventSource | null>(null);
const reconnectTimeoutRef = useRef<NodeJS.Timeout>();
useEffect(() => {
const connect = () => {
// Clean up existing connection
if (eventSourceRef.current) {
eventSourceRef.current.close();
}
const eventSource = new EventSource(API_ENDPOINTS.STREAM);
eventSourceRef.current = eventSource;
eventSource.onopen = () => {
console.log('[SSE] Connected');
setIsConnected(true);
// Clear any pending reconnect
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
};
eventSource.onerror = (error) => {
console.error('[SSE] Connection error:', error);
setIsConnected(false);
eventSource.close();
// Reconnect after delay
reconnectTimeoutRef.current = setTimeout(() => {
reconnectTimeoutRef.current = undefined; // Clear before reconnecting
console.log('[SSE] Attempting to reconnect...');
connect();
}, TIMING.SSE_RECONNECT_DELAY_MS);
};
eventSource.onmessage = (event) => {
try {
const data: StreamEvent = JSON.parse(event.data);
switch (data.type) {
case 'initial_load':
console.log('[SSE] Initial load:', {
projects: data.projects?.length || 0
});
// Only load projects list - data will come via pagination
setProjects(data.projects || []);
break;
case 'new_observation':
if (data.observation) {
console.log('[SSE] New observation:', data.observation.id);
setObservations(prev => [data.observation, ...prev]);
}
break;
case 'new_summary':
if (data.summary) {
const summary = data.summary;
console.log('[SSE] New summary:', summary.id);
setSummaries(prev => [summary, ...prev]);
// Mark session as no longer processing (summary is the final step)
setProcessingSessions(prev => {
const next = new Set(prev);
next.delete(summary.session_id);
return next;
});
}
break;
case 'new_prompt':
if (data.prompt) {
const prompt = data.prompt;
console.log('[SSE] New prompt:', prompt.id);
setPrompts(prev => [prompt, ...prev]);
}
break;
case 'processing_status':
if (data.processing) {
const processing = data.processing;
console.log('[SSE] Processing status:', processing);
setProcessingSessions(prev => {
const next = new Set(prev);
if (processing.is_processing) {
next.add(processing.session_id);
} else {
next.delete(processing.session_id);
}
return next;
});
}
break;
}
} catch (error) {
console.error('[SSE] Failed to parse message:', error);
}
};
};
connect();
// Cleanup on unmount
return () => {
if (eventSourceRef.current) {
eventSourceRef.current.close();
}
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
};
}, []);
return { observations, summaries, prompts, projects, processingSessions, isConnected };
}
+56
View File
@@ -0,0 +1,56 @@
import { useState, useEffect } from 'react';
import { Settings } from '../types';
import { DEFAULT_SETTINGS } from '../constants/settings';
import { API_ENDPOINTS } from '../constants/api';
import { TIMING } from '../constants/timing';
export function useSettings() {
const [settings, setSettings] = useState<Settings>(DEFAULT_SETTINGS);
const [isSaving, setIsSaving] = useState(false);
const [saveStatus, setSaveStatus] = useState('');
useEffect(() => {
// Load initial settings
fetch(API_ENDPOINTS.SETTINGS)
.then(res => res.json())
.then(data => {
setSettings({
CLAUDE_MEM_MODEL: data.CLAUDE_MEM_MODEL || DEFAULT_SETTINGS.CLAUDE_MEM_MODEL,
CLAUDE_MEM_CONTEXT_OBSERVATIONS: data.CLAUDE_MEM_CONTEXT_OBSERVATIONS || DEFAULT_SETTINGS.CLAUDE_MEM_CONTEXT_OBSERVATIONS,
CLAUDE_MEM_WORKER_PORT: data.CLAUDE_MEM_WORKER_PORT || DEFAULT_SETTINGS.CLAUDE_MEM_WORKER_PORT
});
})
.catch(error => {
console.error('Failed to load settings:', error);
});
}, []);
const saveSettings = async (newSettings: Settings) => {
setIsSaving(true);
setSaveStatus('Saving...');
try {
const response = await fetch(API_ENDPOINTS.SETTINGS, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newSettings)
});
const result = await response.json();
if (result.success) {
setSettings(newSettings);
setSaveStatus('✓ Saved');
setTimeout(() => setSaveStatus(''), TIMING.SAVE_STATUS_DISPLAY_DURATION_MS);
} else {
setSaveStatus(`✗ Error: ${result.error}`);
}
} catch (error) {
setSaveStatus(`✗ Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
} finally {
setIsSaving(false);
}
};
return { settings, saveSettings, isSaving, saveStatus };
}
+30
View File
@@ -0,0 +1,30 @@
import { useState, useEffect } from 'react';
import { Stats } from '../types';
import { API_ENDPOINTS } from '../constants/api';
import { TIMING } from '../constants/timing';
export function useStats() {
const [stats, setStats] = useState<Stats>({});
useEffect(() => {
const loadStats = async () => {
try {
const response = await fetch(API_ENDPOINTS.STATS);
const data = await response.json();
setStats(data);
} catch (error) {
console.error('Failed to load stats:', error);
}
};
// Load immediately
loadStats();
// Refresh periodically
const interval = setInterval(loadStats, TIMING.STATS_REFRESH_INTERVAL_MS);
return () => clearInterval(interval);
}, []);
return { stats };
}
+16
View File
@@ -0,0 +1,16 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App';
import { ErrorBoundary } from './components/ErrorBoundary';
const container = document.getElementById('root');
if (!container) {
throw new Error('Root element not found');
}
const root = createRoot(container);
root.render(
<ErrorBoundary>
<App />
</ErrorBoundary>
);
+83
View File
@@ -0,0 +1,83 @@
export interface Observation {
id: number;
session_id: string;
project: string;
type: string;
title: string;
subtitle?: string;
content?: string;
created_at_epoch: number;
}
export interface Summary {
id: number;
session_id: string;
project: string;
request?: string;
learned?: string;
completed?: string;
next_steps?: string;
created_at_epoch: number;
}
export interface UserPrompt {
id: number;
claude_session_id: string;
project: string;
prompt_number: number;
prompt_text: string;
created_at_epoch: number;
}
export interface SkeletonItem {
id: string;
session_id: string;
project?: string;
created_at_epoch: number;
}
export type FeedItem =
| (Observation & { itemType: 'observation' })
| (Summary & { itemType: 'summary' })
| (UserPrompt & { itemType: 'prompt' })
| (SkeletonItem & { itemType: 'skeleton' });
export interface StreamEvent {
type: 'initial_load' | 'new_observation' | 'new_summary' | 'new_prompt' | 'processing_status';
observations?: Observation[];
summaries?: Summary[];
prompts?: UserPrompt[];
projects?: string[];
observation?: Observation;
summary?: Summary;
prompt?: UserPrompt;
processing?: {
session_id: string;
is_processing: boolean;
};
}
export interface Settings {
CLAUDE_MEM_MODEL: string;
CLAUDE_MEM_CONTEXT_OBSERVATIONS: string;
CLAUDE_MEM_WORKER_PORT: string;
}
export interface WorkerStats {
version?: string;
uptime?: number;
activeSessions?: number;
sseClients?: number;
}
export interface DatabaseStats {
size?: number;
observations?: number;
sessions?: number;
summaries?: number;
}
export interface Stats {
worker?: WorkerStats;
database?: DatabaseStats;
}
+30
View File
@@ -0,0 +1,30 @@
/**
* Data manipulation utility functions
* Used for merging and deduplicating real-time and paginated data
*/
/**
* Merge real-time SSE items with paginated items, removing duplicates and filtering by project
* @param liveItems - Items from SSE stream
* @param paginatedItems - Items from pagination API (already filtered by project)
* @param projectFilter - Current project filter (empty string = all projects)
* @returns Merged and deduplicated array
*/
export function mergeAndDeduplicateByProject<T extends { id: number; project?: string }>(
liveItems: T[],
paginatedItems: T[],
projectFilter: string
): T[] {
// Filter SSE items by current project (pagination is already filtered)
const filteredLive = projectFilter
? liveItems.filter(item => item.project === projectFilter)
: liveItems;
// Deduplicate using Set
const seen = new Set<number>();
return [...filteredLive, ...paginatedItems].filter(item => {
if (seen.has(item.id)) return false;
seen.add(item.id);
return true;
});
}
+37
View File
@@ -0,0 +1,37 @@
/**
* Formatting utility functions
* Used across UI components for consistent display
*/
/**
* Format epoch timestamp to locale string
* @param epoch - Timestamp in milliseconds since epoch
* @returns Formatted date string
*/
export function formatDate(epoch: number): string {
return new Date(epoch).toLocaleString();
}
/**
* Format seconds into hours and minutes
* @param seconds - Uptime in seconds
* @returns Formatted string like "12h 34m" or "-" if no value
*/
export function formatUptime(seconds?: number): string {
if (!seconds) return '-';
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return `${hours}h ${minutes}m`;
}
/**
* Format bytes into human-readable size
* @param bytes - Size in bytes
* @returns Formatted string like "1.5 MB" or "-" if no value
*/
export function formatBytes(bytes?: number): string {
if (!bytes) return '-';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}