Compare commits

...

21 Commits

Author SHA1 Message Date
Alex Newman 5482052c16 Release v5.2.2: Context hook now displays investigated and learned fields
Improvements:
- Context hook now displays 'investigated' and 'learned' fields from session summaries
- Enhanced SQL query to SELECT these fields from database
- Added color-coded display formatting (blue for investigated, yellow for learned)
- Updated TypeScript types to include nullable investigated and learned fields

Technical changes:
- Updated src/hooks/context-hook.ts to query and display new fields
- Updated built plugin/scripts/context-hook.js
- Bumped version to 5.2.2 in all metadata files

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-08 12:55:53 -05:00
Alex Newman ccb7001ff0 fix: update README to enhance image linking and improve layout consistency 2025-11-07 22:02:17 -05:00
Alex Newman edc20a12a1 fix: remove unnecessary horizontal rule from README for cleaner layout 2025-11-07 22:00:49 -05:00
Alex Newman 45c35c6bdd fix: adjust spacing and alignment in README for improved readability 2025-11-07 22:00:28 -05:00
Alex Newman 47ee0838e3 fix: update README to correct image placement for Claude-Mem preview 2025-11-07 21:52:14 -05:00
Alex Newman 809c9e6639 Implement code changes to enhance functionality and improve performance 2025-11-07 21:51:04 -05:00
Alex Newman 9646527d66 Release v5.2.1: Fix project filter synchronization bugs
This patch release fixes critical race conditions and state synchronization
issues in the viewer UI's project filtering system.

**Bug Fixes:**
- Fixed race condition where offset wasn't reset when filter changed
- Fixed state ref synchronization causing stale hasMore values
- Fixed batched state updates mixing data from different projects
- Fixed useEffect dependency cycle causing double renders
- Combined useEffect hooks for guaranteed execution order

**Technical Changes:**
- Updated App.tsx: Fixed filter change detection and data reset logic
- Updated usePagination.ts: Improved offset and state ref handling
- Updated data.ts: Simplified mergeAndDeduplicateByProject validation
- Updated SessionStore.ts: Filter NULL/empty projects from dropdown
- Added investigated field to Summary interface

**Files Updated:**
- package.json: version 5.2.0 → 5.2.1
- .claude-plugin/marketplace.json: version 5.2.0 → 5.2.1
- plugin/.claude-plugin/plugin.json: version 5.2.0 → 5.2.1
- CLAUDE.md: version 5.2.0 → 5.2.1

All changes follow CLAUDE.md coding standards (DRY, YAGNI, fail-fast).

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-07 20:29:24 -05:00
Alex Newman f27c73b469 Fix project filter synchronization issues in viewer UI (#70)
* feat: Enhance session and summary handling

- Update SQL query in SessionStore to exclude null or empty projects.
- Add 'investigated' field to Summary interface for better tracking.
- Modify App component to handle pagination more efficiently based on current filters.
- Update SummaryCard to display the 'investigated' field if present.
- Refactor usePagination hook to reset pagination state when filters change.
- Adjust mergeAndDeduplicateByProject function to ensure it only merges unfiltered data.

* refactor: address PR feedback - remove redundancies and fix dependency cycles

Fixes based on PR #70 review feedback:

Required:
- Fixed useEffect dependency cycle in App.tsx (removed handleLoadMore from deps)

Recommended:
- Removed redundant filter detection from App.tsx (usePagination handles this)
- Removed redundant stateRef update effect from usePagination.ts
- Simplified mergeAndDeduplicateByProject by removing defensive validation
- Removed unused imports (useRef, useEffect)

All changes follow CLAUDE.md principles: DRY, fail-fast, no defensive programming.

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

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

* fix: Reset paginated data arrays when filter changes

Critical fix for data mixing bug identified in PR review follow-up.

Problem:
When filter changes, usePagination correctly resets its offset to 0, but
the paginated state arrays (observations, summaries, prompts) were NOT
being reset. This caused data from different projects to mix together
because setState appends to the existing array.

Example:
- User views Project A: [A1, A2, A3]
- User switches to Project B
- API fetches [B1, B2, B3]
- setState does: [...prev, ...new] = [A1, A2, A3, B1, B2, B3] 

Solution:
Added a separate useEffect that resets all three paginated arrays when
currentFilter changes. This happens BEFORE handleLoadMore fetches new
data, ensuring clean state for the new filter.

Files changed:
- src/ui/viewer/App.tsx: Added useEffect to reset arrays on filter change
- plugin/ui/viewer-bundle.js: Built UI bundle

Testing:
1. Select Project A, verify data loads
2. Switch to Project B
3. Verify ONLY Project B data is shown (no mixing)
4. Switch back to "All Projects"
5. Verify all data appears correctly

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

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

* refactor: Combine filter change useEffect hooks for guaranteed order

Merged two separate useEffect hooks into one to:
1. Guarantee execution order (reset THEN load)
2. Reduce complexity (one hook instead of two)
3. Make intent clearer with single comment

Before:
- useEffect #1: handleLoadMore() on filter change
- useEffect #2: Reset arrays on filter change
- React could run these in any order

After:
- Single useEffect: Reset arrays THEN handleLoadMore()
- Execution order is now guaranteed

Files changed:
- src/ui/viewer/App.tsx: Combined useEffect hooks
- plugin/ui/viewer-bundle.js: Built UI bundle

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-11-07 20:28:12 -05:00
Alex Newman 7f9959fdb7 Release v5.2.0: Major Worker Service Refactor & UI Improvements
This release merges PR #69, delivering a comprehensive architectural refactor
of the worker service, extensive UI enhancements, and significant code cleanup.

🏗️ Architecture Changes (Worker Service v2)

**Modular Rewrite**: Extracted monolithic worker-service.ts into focused modules:
- DatabaseManager.ts (111 lines): Centralized database initialization
- SessionManager.ts (204 lines): Complete session lifecycle management
- SDKAgent.ts (309 lines): Claude SDK interactions & observation compression
- SSEBroadcaster.ts (86 lines): Server-Sent Events broadcast management
- PaginationHelper.ts (196 lines): Reusable pagination logic
- SettingsManager.ts (68 lines): Viewer settings persistence
- worker-types.ts (176 lines): Shared TypeScript types

**Key Improvements**:
- Eliminated duplicated session logic (4 instances → 1 helper)
- Replaced magic numbers with named constants
- Removed fragile PM2 string parsing
- Fail-fast error handling instead of silent failures
- Fixed SDK agent narrative assignment (obs.title → obs.narrative)

🎨 UI/UX Improvements

**ScrollToTop Component**: GPU-accelerated smooth scrolling button
**ObservationCard Refactor**: Fixed facts toggle, improved metadata display
**Pagination Enhancements**: Better loading states, error recovery, deduplication
**Card Consistency**: Unified layout patterns across all card types

📚 Documentation

**New Files** (7,542 lines):
- context/agent-sdk-ref.md (1,797 lines): Complete Agent SDK reference
- docs/worker-service-architecture.md (1,174 lines): v2 architecture docs
- docs/worker-service-rewrite-outline.md (1,069 lines): Refactor plan
- docs/worker-service-overhead.md (959 lines): Performance analysis
- docs/processing-indicator-*.md (980 lines): Processing status docs
- docs/typescript-errors.md (180 lines): Error reference
- PLAN-full-observation-display.md (468 lines): Future UI roadmap

🧹 Code Cleanup

**Deleted Dead Code** (~2,000 lines):
- src/shared/{config.ts,storage.ts,types.ts}
- src/utils/{platform.ts,usage-logger.ts}
- src/hooks/index.ts, src/sdk/index.ts
- docs/{VIEWER.md,worker-server-architecture.md}

**Files Changed**: 70 total (11 new, 7 deleted, 52 modified)
**Net Impact**: +7,470 lines (11,105 additions, 3,635 deletions)

🐛 Bug Fixes

- Fixed SDK agent narrative assignment (e22edad)
- Corrected PostToolUse hook field name (13643a5)
- Removed unnecessary worker startup from smart-install (6204fe9)
- Simplified context-hook worker management (6204fe9)

 Testing

All systems verified:
- Worker service starts successfully
- All hooks function correctly
- Viewer UI renders properly
- Build pipeline compiles without errors

📖 Reference

PR: #69
Previous Version: 5.1.4
Semantic Version: MINOR (backward compatible features & improvements)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-07 18:33:28 -05:00
claude[bot] 012774b83c docs: add CLAUDE.md explaining docs folder structure
Clarifies that docs/ is a Mintlify documentation site for official
user-facing documentation (.mdx files), while context/ should contain
planning documents, design docs, and internal references.

Lists files currently in docs/ that should be moved to context/:
- typescript-errors.md
- worker-service-*.md files
- processing-indicator-*.md files
- CHROMA.md and chroma-search-completion-plan.md

Co-authored-by: Alex Newman <thedotmack@users.noreply.github.com>
2025-11-07 23:23:28 +00:00
Alex Newman e22edaddf4 fix: update narrative assignment in SDKAgent to use obs.narrative 2025-11-07 18:04:57 -05:00
Alex Newman 6204fe9b9d refactor: remove startWorker function and adjust installation flow 2025-11-07 18:00:42 -05:00
Alex Newman 30a42036aa feat: add scroll-to-top button and improve pagination handling
- Implemented a scroll-to-top button in the viewer UI for better navigation.
- Added styles for the scroll-to-top button in viewer.html and viewer-template.html.
- Created a new ScrollToTop component to manage visibility and scrolling behavior.
- Updated Feed component to include the ScrollToTop component.
- Enhanced pagination logic in usePagination hook to prevent stale closures and improve performance.
- Modified SDKAgent to include additional observation fields for better data handling.
2025-11-07 17:57:54 -05:00
Alex Newman d6f1237283 Refactor ObservationCard to improve facts toggle logic and metadata display
- Introduced hasFactsContent to determine if facts, concepts, or files are present.
- Updated view-mode toggles to conditionally render based on hasFactsContent.
- Modified content rendering to show subtitle only when facts and narrative are off.
- Enhanced metadata footer to display concepts and files only when facts toggle is active, with improved styling for concepts.
2025-11-07 17:42:45 -05:00
Alex Newman 700e3253fa Refactor card components for improved layout and functionality
- Updated card styles in viewer.html and viewer-template.html to enhance padding, margins, and overall layout.
- Introduced new header structure with left-aligned type and project name, and added view mode toggle buttons for facts and narrative.
- Simplified content rendering logic in ObservationCard, allowing for toggling between facts and narrative.
- Updated metadata display in ObservationCard, PromptCard, and SummaryCard to include formatted date and improved layout.
- Removed unnecessary verbose content sections and streamlined the presentation of facts and narrative.
2025-11-07 17:03:05 -05:00
Alex Newman 740d65b5a5 Add TypeScript Agent SDK reference documentation
- Introduced comprehensive API reference for the TypeScript Agent SDK.
- Documented installation instructions for the SDK.
- Detailed the main functions: `query()`, `tool()`, and `createSdkMcpServer()`.
- Defined various types including `Options`, `Query`, `AgentDefinition`, and more.
- Included message types and their structures, such as `SDKMessage`, `SDKAssistantMessage`, and `SDKUserMessage`.
- Explained hook types and their usage within the SDK.
- Provided detailed documentation for tool input and output types.
- Added sections on permission types and other relevant types for better clarity.
2025-11-07 15:05:31 -05:00
Alex Newman 4bc467f7ed feat: Implement Worker Service for long-running HTTP service with PM2 management
- Introduced WorkerService class to handle HTTP requests and manage sessions.
- Added endpoints for health check, session management, and data retrieval.
- Integrated ChromaSync for background data synchronization.
- Implemented SSE for real-time updates to connected clients.
- Added error handling and logging throughout the service.
- Cached Claude executable path for improved performance.
- Included settings management for user configuration.
- Established database interactions for session and observation management.
2025-11-07 13:26:13 -05:00
Alex Newman 7fdfdd5d5e Release v5.1.4: Bugfix for PostToolUse hook schema compliance
Changes:
- Renamed tool_output to tool_response in save-hook.ts to match Claude Code PostToolUse API schema
- Updated worker-service.ts to use tool_response field consistently
- Rebuilt all hooks and worker service with corrected parameter names

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-07 11:53:57 -05:00
Alex Newman e872c2da38 refactor: rename tool_output to tool_response in save-hook and worker-service 2025-11-07 11:50:14 -05:00
Copilot 0b476e971a Release v5.1.3: Version bump for npm install fix (#66)
* Initial plan

* Release v5.1.3: Fix npm install failures with smart caching

Co-authored-by: thedotmack <683968+thedotmack@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: thedotmack <683968+thedotmack@users.noreply.github.com>
2025-11-06 18:05:43 -05:00
Copilot f7b51a963e Fix npm install failures with automatic retry and silent output (#64)
* Initial plan

* Initial investigation: identified npm install failure issue

Co-authored-by: thedotmack <683968+thedotmack@users.noreply.github.com>

* Add retry logic and better error handling to smart-install

Co-authored-by: thedotmack <683968+thedotmack@users.noreply.github.com>

* Fix spacing inconsistency in log messages

Co-authored-by: thedotmack <683968+thedotmack@users.noreply.github.com>

* Refactor worker startup check for better readability

Co-authored-by: thedotmack <683968+thedotmack@users.noreply.github.com>

* Simplify npm install: use plain npm install without flags

Co-authored-by: thedotmack <683968+thedotmack@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: thedotmack <683968+thedotmack@users.noreply.github.com>
2025-11-06 17:21:44 -05:00
45 changed files with 5394 additions and 1959 deletions
+1 -1
View File
@@ -10,7 +10,7 @@
"plugins": [
{
"name": "claude-mem",
"version": "5.1.3",
"version": "5.2.2",
"source": "./plugin",
"description": "Persistent memory system for Claude Code - context compression across sessions"
}
+1 -1
View File
@@ -6,7 +6,7 @@ Claude-mem is a Claude Code plugin providing persistent memory across sessions.
**Your Role**: You are working on the plugin itself. When users interact with Claude Code with this plugin installed, your observations get captured and become their persistent memory.
**Current Version**: 5.1.3
**Current Version**: 5.2.2
## Critical Architecture Knowledge
+468
View File
@@ -0,0 +1,468 @@
# Plan: Display Complete Observation Data in Viewer UI
## Current State Analysis
### What's Currently Shown (5 fields)
-**type** - Displayed as chip/badge (e.g., "discovery", "bugfix")
-**project** - Shown in card header
-**title** - Main card title (shows "Untitled" if null)
-**subtitle** - Optional subheading
-**id + created_at** - Metadata line (e.g., "#1 • 2 hours ago")
### What's Hidden (10+ fields)
-**narrative** - Detailed explanation text (MOST IMPORTANT)
-**facts** - JSON array of key facts (structured bullet points)
-**concepts** - JSON array of concept tags (e.g., "problem-solution", "gotcha")
-**files_read** - JSON array of file paths that were read
-**files_modified** - JSON array of file paths that were modified
-**text** - Legacy unstructured text field (deprecated but still populated)
-**prompt_number** - Which user prompt triggered this observation
-**sdk_session_id** - Session identifier
### Database Schema (Actual Structure)
```sql
observations table:
- id (INTEGER PRIMARY KEY)
- sdk_session_id (TEXT)
- project (TEXT)
- type (TEXT: decision, bugfix, feature, refactor, discovery, change)
- created_at (TEXT ISO timestamp)
- created_at_epoch (INTEGER milliseconds)
- prompt_number (INTEGER nullable)
- title (TEXT nullable)
- subtitle (TEXT nullable)
- narrative (TEXT nullable) -- Rich detailed explanation
- text (TEXT nullable) -- Legacy field
- facts (TEXT nullable) -- JSON array of key facts
- concepts (TEXT nullable) -- JSON array of concept tags
- files_read (TEXT nullable) -- JSON array of file paths
- files_modified (TEXT nullable) -- JSON array of file paths
```
### Issues Found
1. **Type Definition Mismatch**: Three different type definitions exist:
- Actual database schema (most complete)
- `worker-types.ts` Observation interface (flattened, has wrong field names)
- `viewer/types.ts` Observation interface (minimal subset)
2. **Data Loss**: Rich fields are stored in DB but not transmitted to UI:
- narrative, facts, files_read, files_modified all missing from API
3. **PaginationHelper Query Bug**: Selects non-existent fields:
- `session_db_id` (should be `sdk_session_id`)
- `claude_session_id` (doesn't exist in observations table)
- `files` (should be `files_read` + `files_modified`)
## Proposed Implementation Plan
### Phase 1: Fix Data Layer
#### 1.1 Update Viewer Type Definitions
**File**: `src/ui/viewer/types.ts`
```typescript
export interface Observation {
id: number;
sdk_session_id: string;
project: string;
type: string;
title: string | null;
subtitle: string | null;
narrative: string | null; // NEW - detailed explanation
text: string | null; // Legacy field
facts: string | null; // NEW - JSON array of key facts
concepts: string | null; // NEW - JSON array of concept tags
files_read: string | null; // NEW - JSON array of file paths
files_modified: string | null; // NEW - JSON array of file paths
prompt_number: number | null; // NEW - which prompt triggered this
created_at: string;
created_at_epoch: number;
}
```
#### 1.2 Fix PaginationHelper SQL Query
**File**: `src/services/worker/PaginationHelper.ts` (around line 26)
**Current (BROKEN)**:
```typescript
const fields = 'id, session_db_id, claude_session_id, project, type, title, subtitle, text, concepts, files, prompt_number, created_at, created_at_epoch';
```
**Fixed**:
```typescript
const fields = 'id, sdk_session_id, project, type, title, subtitle, narrative, text, facts, concepts, files_read, files_modified, prompt_number, created_at, created_at_epoch';
```
#### 1.3 Update Worker Service v2 Response Mapping
**File**: `src/services/worker-service-v2.ts`
Ensure the `/api/observations` endpoint properly maps all fields from database to response. May need to parse JSON fields (facts, concepts, files_read, files_modified) if they're stored as JSON strings.
### Phase 2: Redesign UI Component
#### 2.1 Update ObservationCard Component
**File**: `src/ui/viewer/components/ObservationCard.tsx`
**New Structure**:
```
┌─────────────────────────────────────────┐
│ [type badge] [project] │ ← Header (always visible)
├─────────────────────────────────────────┤
│ Title │ ← Always visible
│ Subtitle (if present) │ ← Always visible
│ #123 • 2 hours ago [▼ More]│ ← Metadata + Expand button
├─────────────────────────────────────────┤
│ │
│ ┌─ EXPANDED CONTENT (when opened) ───┐ │
│ │ │ │
│ │ 📝 Narrative │ │
│ │ ─────────────────────────────────── │ │
│ │ Detailed explanation text... │ │
│ │ │ │
│ │ 📌 Key Facts │ │
│ │ ─────────────────────────────────── │ │
│ │ • Fact 1 │ │
│ │ • Fact 2 │ │
│ │ • Fact 3 │ │
│ │ │ │
│ │ 🏷️ Concepts │ │
│ │ ─────────────────────────────────── │ │
│ │ [problem-solution] [discovery] │ │
│ │ │ │
│ │ 📁 Files │ │
│ │ ─────────────────────────────────── │ │
│ │ 📖 Read: │ │
│ │ src/hooks/save-hook.ts │ │
│ │ src/services/worker.ts │ │
│ │ ✏️ Modified: │ │
│ │ src/hooks/save-hook.ts │ │
│ │ │ │
│ │ 🔗 Session Info │ │
│ │ ─────────────────────────────────── │ │
│ │ Prompt #5 • Session: abc123... │ │
│ │ │ │
│ └─────────────────────────────────────┘ │
└─────────────────────────────────────────┘
```
**Component Logic**:
```typescript
const ObservationCard = ({ observation }) => {
const [isExpanded, setIsExpanded] = useState(false);
// Parse JSON fields
const facts = observation.facts ? JSON.parse(observation.facts) : [];
const concepts = observation.concepts ? JSON.parse(observation.concepts) : [];
const filesRead = observation.files_read ? JSON.parse(observation.files_read) : [];
const filesModified = observation.files_modified ? JSON.parse(observation.files_modified) : [];
return (
<div className={`card ${isExpanded ? 'card-expanded' : ''}`}>
{/* Header - always visible */}
<div className="card-header">
<span className={`card-type type-${observation.type}`}>
{observation.type}
</span>
<span className="card-project">{observation.project}</span>
</div>
{/* Title/Subtitle - always visible */}
<div className="card-title">{observation.title || 'Untitled'}</div>
{observation.subtitle && (
<div className="card-subtitle">{observation.subtitle}</div>
)}
{/* Metadata + Expand button - always visible */}
<div className="card-meta">
<span>#{observation.id} {formatDate(observation.created_at_epoch)}</span>
<button
className="expand-toggle"
onClick={() => setIsExpanded(!isExpanded)}
>
{isExpanded ? '▲ Less' : '▼ More'}
</button>
</div>
{/* Expanded content - conditional */}
{isExpanded && (
<div className="card-expanded-content">
{/* Narrative Section */}
{observation.narrative && (
<div className="card-section">
<div className="section-header">📝 Narrative</div>
<div className="section-content narrative">
{observation.narrative}
</div>
</div>
)}
{/* Facts Section */}
{facts.length > 0 && (
<div className="card-section">
<div className="section-header">📌 Key Facts</div>
<ul className="section-content facts-list">
{facts.map((fact, i) => (
<li key={i}>{fact}</li>
))}
</ul>
</div>
)}
{/* Concepts Section */}
{concepts.length > 0 && (
<div className="card-section">
<div className="section-header">🏷 Concepts</div>
<div className="section-content concepts">
{concepts.map((concept, i) => (
<span key={i} className="concept-tag">{concept}</span>
))}
</div>
</div>
)}
{/* Files Section */}
{(filesRead.length > 0 || filesModified.length > 0) && (
<div className="card-section">
<div className="section-header">📁 Files</div>
<div className="section-content files">
{filesRead.length > 0 && (
<div className="file-group">
<div className="file-group-label">📖 Read:</div>
{filesRead.map((file, i) => (
<div key={i} className="file-path">{file}</div>
))}
</div>
)}
{filesModified.length > 0 && (
<div className="file-group">
<div className="file-group-label"> Modified:</div>
{filesModified.map((file, i) => (
<div key={i} className="file-path">{file}</div>
))}
</div>
)}
</div>
</div>
)}
{/* Session Info Section */}
<div className="card-section">
<div className="section-header">🔗 Session Info</div>
<div className="section-content session-info">
{observation.prompt_number && (
<span>Prompt #{observation.prompt_number}</span>
)}
{observation.sdk_session_id && (
<span className="session-id">
Session: {observation.sdk_session_id.substring(0, 8)}...
</span>
)}
</div>
</div>
</div>
)}
</div>
);
};
```
### Phase 3: Style Enhancements
#### 3.1 Update Styles
**File**: `src/ui/viewer/styles.css`
**New CSS Classes Needed**:
```css
/* Expanded card state */
.card-expanded {
/* Maybe increase shadow or border when expanded */
}
/* Expand toggle button */
.expand-toggle {
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
font-size: 12px;
padding: 4px 8px;
border-radius: 4px;
}
.expand-toggle:hover {
background: var(--bg-secondary);
}
/* Expanded content container */
.card-expanded-content {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--border-color);
animation: expandDown 0.2s ease-out;
}
@keyframes expandDown {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Section styling */
.card-section {
margin-bottom: 16px;
}
.card-section:last-child {
margin-bottom: 0;
}
.section-header {
font-weight: 600;
font-size: 13px;
color: var(--text-primary);
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 6px;
}
.section-content {
padding-left: 20px;
color: var(--text-secondary);
font-size: 13px;
line-height: 1.6;
}
/* Narrative styling */
.narrative {
max-height: 300px;
overflow-y: auto;
white-space: pre-wrap;
word-wrap: break-word;
}
/* Facts list styling */
.facts-list {
list-style: disc;
margin: 0;
padding-left: 20px;
}
.facts-list li {
margin-bottom: 4px;
}
/* Concepts tags */
.concepts {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.concept-tag {
background: var(--accent-bg);
color: var(--accent-text);
padding: 4px 10px;
border-radius: 12px;
font-size: 11px;
font-weight: 500;
}
/* File paths */
.file-group {
margin-bottom: 8px;
}
.file-group:last-child {
margin-bottom: 0;
}
.file-group-label {
font-weight: 500;
margin-bottom: 4px;
color: var(--text-primary);
}
.file-path {
font-family: 'SF Mono', 'Monaco', 'Courier New', monospace;
font-size: 12px;
padding: 4px 8px;
background: var(--code-bg);
border-radius: 4px;
margin-bottom: 2px;
overflow-x: auto;
white-space: nowrap;
}
/* Session info */
.session-info {
display: flex;
gap: 16px;
font-size: 12px;
}
.session-id {
font-family: 'SF Mono', 'Monaco', 'Courier New', monospace;
color: var(--text-tertiary);
}
```
## Implementation Steps (In Order)
1. **Fix PaginationHelper query** (src/services/worker/PaginationHelper.ts)
- Update SQL SELECT to use correct field names
- Test with `npm run worker:restart:v2`
2. **Update viewer type definitions** (src/ui/viewer/types.ts)
- Add all missing fields to Observation interface
3. **Verify worker service v2 mapping** (src/services/worker-service-v2.ts)
- Ensure `/api/observations` returns all fields
- Test API response with curl or browser
4. **Update ObservationCard component** (src/ui/viewer/components/ObservationCard.tsx)
- Add expand/collapse state
- Add all new sections (narrative, facts, concepts, files, session)
- Add expand toggle button
5. **Update styles** (src/ui/viewer/styles.css)
- Add all new CSS classes for expanded content
- Add animations for smooth expand/collapse
- Style sections, lists, tags, file paths
6. **Build and test**
```bash
npm run build
npm run sync-marketplace
npm run worker:restart:v2
```
7. **Manual testing**
- Open http://localhost:37777
- Click expand button on observations
- Verify all fields display correctly
- Test light/dark mode
- Test with observations that have missing fields (graceful fallback)
## Success Criteria
- [ ] All database fields are fetched in API query
- [ ] All fields are properly typed in TypeScript interfaces
- [ ] ObservationCard shows all data in expanded view
- [ ] Expand/collapse animations work smoothly
- [ ] File paths are formatted in monospace font
- [ ] Concepts display as tag pills
- [ ] Facts display as bulleted list
- [ ] Narrative text wraps properly with scroll for long content
- [ ] No console errors
- [ ] Works in both light and dark themes
## Optional Enhancements (Future)
- [ ] Remember expanded state in localStorage (persist across page refresh)
- [ ] Keyboard shortcuts (Space to expand/collapse focused card)
- [ ] Click file paths to copy to clipboard
- [ ] Search/filter by concepts or files
- [ ] Syntax highlighting for code in narrative
- [ ] Link session_id to session detail view
+31
View File
@@ -27,6 +27,16 @@
</a>
</p>
<br>
<p align="center">
<a href="https://github.com/thedotmack/claude-mem">
<picture>
<img src="docs/cm-preview.gif" alt="Claude-Mem Preview" width="800">
</picture>
</a>
</p>
<p align="center">
<a href="#quick-start">Quick Start</a> •
<a href="#how-it-works">How It Works</a> •
@@ -56,6 +66,7 @@ Start a new Claude Code session in the terminal and enter the following commands
Restart Claude Code. Context from previous sessions will automatically appear in new sessions.
**Key Features:**
- 🧠 **Persistent Memory** - Context survives across sessions
- 📊 **Progressive Disclosure** - Layered memory retrieval with token cost visibility
- 🔍 **9 Search Tools** - Query your project history via MCP
@@ -70,21 +81,25 @@ Restart Claude Code. Context from previous sessions will automatically appear in
📚 **[View Full Documentation](docs/)** - Browse markdown docs on GitHub
💻 **Local Preview**: Run Mintlify docs locally:
```bash
cd docs
npx mintlify dev
```
### Getting Started
- **[Installation Guide](docs/installation.mdx)** - Quick start & advanced installation
- **[Usage Guide](docs/usage/getting-started.mdx)** - How Claude-Mem works automatically
- **[MCP Search Tools](docs/usage/search-tools.mdx)** - Query your project history
### Best Practices
- **[Context Engineering](docs/context-engineering.mdx)** - AI agent context optimization principles
- **[Progressive Disclosure](docs/progressive-disclosure.mdx)** - Philosophy behind Claude-Mem's context priming strategy
### Architecture
- **[Overview](docs/architecture/overview.mdx)** - System components & data flow
- **[Architecture Evolution](docs/architecture-evolution.mdx)** - The journey from v3 to v5
- **[Hooks Architecture](docs/hooks-architecture.mdx)** - How Claude-Mem uses lifecycle hooks
@@ -95,6 +110,7 @@ npx mintlify dev
- **[Viewer UI](docs/VIEWER.md)** - Web-based memory stream visualization
### Configuration & Development
- **[Configuration](docs/configuration.mdx)** - Environment variables & settings
- **[Development](docs/development.mdx)** - Building, testing, contributing
- **[Troubleshooting](docs/troubleshooting.mdx)** - Common issues & solutions
@@ -126,6 +142,7 @@ npx mintlify dev
```
**Core Components:**
1. **7 Lifecycle Hook Scripts** - smart-install, context-hook, user-message-hook, new-hook, save-hook, summary-hook, cleanup-hook
2. **Worker Service** - HTTP API on port 37777 with web viewer UI, managed by PM2
3. **SQLite Database** - Stores sessions, observations, summaries with FTS5 full-text search
@@ -151,6 +168,7 @@ Claude-Mem provides 9 specialized search tools:
9. **get_timeline_by_query** - Search for observations and get timeline context around best match
**Example Queries:**
```
search_observations with query="authentication" and type="decision"
find_by_file with filePath="worker-service.ts"
@@ -167,12 +185,14 @@ See [MCP Search Tools Guide](docs/usage/search-tools.mdx) for detailed examples.
## What's New in v5.1.2
**🎨 Theme Toggle (v5.1.2):**
- Light/dark mode support in viewer UI
- System preference detection
- Persistent theme settings across sessions
- Smooth transitions between themes
**🖥️ Web-Based Viewer UI (v5.1.0):**
- Real-time memory stream visualization at http://localhost:37777
- Server-Sent Events (SSE) for instant updates
- Infinite scroll pagination with automatic deduplication
@@ -181,11 +201,13 @@ See [MCP Search Tools Guide](docs/usage/search-tools.mdx) for detailed examples.
- Auto-reconnection with exponential backoff
**⚡ Smart Install Caching (v5.0.3):**
- Eliminated redundant npm installs on every session (2-5s → 10ms)
- Caches version in `.install-version` file
- Only runs npm install when needed (first time, version change, missing deps)
**🔍 Hybrid Search Architecture (v5.0.0):**
- Chroma vector database for semantic search
- Combined with FTS5 keyword search
- Intelligent context retrieval with 90-day recency filtering
@@ -206,6 +228,7 @@ See [CHANGELOG.md](CHANGELOG.md) for complete version history.
## Key Benefits
### Progressive Disclosure Context
- **Layered memory retrieval** mirrors human memory patterns
- **Layer 1 (Index)**: See what observations exist with token costs at session start
- **Layer 2 (Details)**: Fetch full narratives on-demand via MCP search
@@ -214,21 +237,25 @@ See [CHANGELOG.md](CHANGELOG.md) for complete version history.
- **Type indicators**: Visual cues (🔴 critical, 🟤 decision, 🔵 informational) highlight observation importance
### Automatic Memory
- Context automatically injected when Claude starts
- No manual commands or configuration needed
- Works transparently in the background
### Full History Search
- Search across all sessions and observations
- FTS5 full-text search for fast queries
- Citations link back to specific observations
### Structured Observations
- AI-powered extraction of learnings
- Categorized by type (decision, bugfix, feature, etc.)
- Tagged with concepts and file references
### Multi-Prompt Sessions
- Sessions span multiple user prompts
- Context preserved across `/clear` commands
- Track entire conversation threads
@@ -238,11 +265,13 @@ See [CHANGELOG.md](CHANGELOG.md) for complete version history.
## Configuration
**Model Selection:**
```bash
./claude-mem-settings.sh
```
**Environment Variables:**
- `CLAUDE_MEM_MODEL` - AI model for processing (default: claude-sonnet-4-5)
- `CLAUDE_MEM_WORKER_PORT` - Worker port (default: 37777)
- `CLAUDE_MEM_DATA_DIR` - Data directory override (dev only)
@@ -277,6 +306,7 @@ See [Development Guide](docs/development.mdx) for detailed instructions.
## Troubleshooting
**Common Issues:**
- Worker not starting → `npm run worker:restart`
- No context appearing → `npm run test:context`
- Database issues → `sqlite3 ~/.claude-mem/claude-mem.db "PRAGMA integrity_check;"`
@@ -309,6 +339,7 @@ Copyright (C) 2025 Alex Newman (@thedotmack). All rights reserved.
See the [LICENSE](LICENSE) file for full details.
**What This Means:**
- You can use, modify, and distribute this software freely
- If you modify and deploy on a network server, you must make your source code available
- Derivative works must also be licensed under AGPL-3.0
File diff suppressed because it is too large Load Diff
+73
View File
@@ -0,0 +1,73 @@
# Claude-Mem Documentation Folder
## What This Folder Is
This `docs/` folder is a **Mintlify documentation site** - the official user-facing documentation for claude-mem. It's a structured documentation platform with a specific file format and organization.
## File Structure Requirements
### Mintlify Documentation Files (.mdx)
All official documentation files must be:
- Written in `.mdx` format (Markdown with JSX support)
- Listed in `docs.json` navigation structure
- Follow Mintlify's schema and conventions
The documentation is organized into these sections:
- **Get Started**: Introduction, installation, usage guides
- **Best Practices**: Context engineering, progressive disclosure
- **Configuration & Development**: Settings, dev workflow, troubleshooting
- **Architecture**: System design, components, technical details
### Configuration File
`docs.json` defines:
- Site metadata (name, description, theme)
- Navigation structure
- Branding (logos, colors)
- Footer links and social media
## What Does NOT Belong Here
**Planning documents, design docs, and reference materials should go in `/context/` instead:**
Files that should be in `/context/` (not `/docs/`):
- Planning documents (`*-plan.md`, `*-outline.md`)
- Implementation analysis (`*-audit.md`, `*-code-reference.md`)
- Error tracking (`typescript-errors.md`)
- Design documents not part of official docs
- PR review responses
- Reference materials (like `agent-sdk-ref.md`)
**Example**: The deleted `VIEWER.md` was moved because it was implementation documentation, not user-facing docs.
## Current Files That Should Be Moved
These `.md` files currently in `docs/` should probably be moved to `context/`:
- `typescript-errors.md` - Error tracking
- `worker-service-architecture.md` - Implementation details (not user-facing architecture)
- `processing-indicator-audit.md` - Implementation audit
- `processing-indicator-code-reference.md` - Code reference
- `worker-service-rewrite-outline.md` - Planning document
- `worker-service-overhead.md` - Analysis document
- `CHROMA.md` - Implementation reference (if not user-facing)
- `chroma-search-completion-plan.md` - Planning document
## How to Add Official Documentation
1. Create a new `.mdx` file in the appropriate subdirectory
2. Add the file path to `docs.json` navigation
3. Use Mintlify's frontmatter and components
4. Follow the existing documentation style
## Development Workflow
**For contributors working on claude-mem:**
- Read `/CLAUDE.md` in the project root for development instructions
- Place planning/design docs in `/context/`
- Only add user-facing documentation to `/docs/`
- Test documentation locally with Mintlify CLI if available
## Summary
**Simple Rule**:
- `/docs/` = Official user documentation (Mintlify .mdx files)
- `/context/` = Development context, plans, references, internal docs
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

+25 -23
View File
@@ -10,27 +10,29 @@
*/
module.exports = {
apps: [{
name: 'claude-mem-worker',
script: './plugin/scripts/worker-service.cjs',
// INTENTIONAL: Watch mode enables auto-restart on plugin updates
//
// Why this is enabled:
// - When you run `npm run sync-marketplace` or rebuild the plugin,
// files in ~/.claude/plugins/marketplaces/thedotmack/ change
// - Watch mode detects these changes and auto-restarts the worker
// - Users get the latest code without manually running `pm2 restart`
//
// This is a feature, not a bug - it ensures users always run the
// latest version after plugin updates.
watch: true,
ignore_watch: [
'node_modules',
'logs',
'*.log',
'*.db',
'*.db-*',
'.git'
]
}]
apps: [
{
name: 'claude-mem-worker',
script: './plugin/scripts/worker-service.cjs',
// INTENTIONAL: Watch mode enables auto-restart on plugin updates
//
// Why this is enabled:
// - When you run `npm run sync-marketplace` or rebuild the plugin,
// files in ~/.claude/plugins/marketplaces/thedotmack/ change
// - Watch mode detects these changes and auto-restarts the worker
// - Users get the latest code without manually running `pm2 restart`
//
// This is a feature, not a bug - it ensures users always run the
// latest version after plugin updates.
watch: true,
ignore_watch: [
'node_modules',
'logs',
'*.log',
'*.db',
'*.db-*',
'.git'
]
}
]
};
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "claude-mem",
"version": "5.1.2",
"version": "5.1.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "claude-mem",
"version": "5.1.2",
"version": "5.1.4",
"license": "AGPL-3.0",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.1.27",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "claude-mem",
"version": "5.1.3",
"version": "5.2.2",
"description": "Memory compression system for Claude Code - persist context across sessions",
"keywords": [
"claude",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "claude-mem",
"version": "5.1.3",
"version": "5.2.2",
"description": "Persistent memory system for Claude Code - seamlessly preserve context across sessions",
"author": {
"name": "Alex Newman"
+11 -14
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env node
import{stdin as I}from"process";import w from"better-sqlite3";import{join as E,dirname as k,basename as W}from"path";import{homedir as O}from"os";import{existsSync as K,mkdirSync as x}from"fs";import{fileURLToPath as U}from"url";function M(){return typeof __dirname<"u"?__dirname:k(U(import.meta.url))}var q=M(),u=process.env.CLAUDE_MEM_DATA_DIR||E(O(),".claude-mem"),R=process.env.CLAUDE_CONFIG_DIR||E(O(),".claude"),J=E(u,"archives"),Q=E(u,"logs"),z=E(u,"trash"),Z=E(u,"backups"),ee=E(u,"settings.json"),f=E(u,"claude-mem.db"),se=E(u,"vector-db"),te=E(R,"settings.json"),re=E(R,"commands"),ne=E(R,"CLAUDE.md");function L(c){x(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}
import{stdin as I}from"process";import w from"better-sqlite3";import{join as E,dirname as k,basename as W}from"path";import{homedir as O}from"os";import{existsSync as K,mkdirSync as x}from"fs";import{fileURLToPath as U}from"url";function M(){return typeof __dirname<"u"?__dirname:k(U(import.meta.url))}var q=M(),l=process.env.CLAUDE_MEM_DATA_DIR||E(O(),".claude-mem"),R=process.env.CLAUDE_CONFIG_DIR||E(O(),".claude"),J=E(l,"archives"),Q=E(l,"logs"),z=E(l,"trash"),Z=E(l,"backups"),ee=E(l,"settings.json"),f=E(l,"claude-mem.db"),se=E(l,"vector-db"),te=E(R,"settings.json"),re=E(R,"commands"),ne=E(R,"CLAUDE.md");function L(c){x(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 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:p,...a}=r;Object.keys(a).length>0&&(T=` {${Object.entries(a).map(([y,D])=>`${y}=${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 N;var g=class{db;constructor(){L(u),this.db=new w(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(`
`+JSON.stringify(n,null,2):m=" "+this.formatData(n));let T="";if(r){let{sessionId:u,sdkSessionId:b,correlationId:p,...a}=r;Object.keys(a).length>0&&(T=` {${Object.entries(a).map(([y,D])=>`${y}=${D}`).join(", ")}}`)}let S=`[${o}] [${i}] [${d}] ${_}${t}${T}${m}`;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 w(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,
@@ -216,6 +216,7 @@ ${e.stack}`:e.message;if(Array.isArray(e))return`[${e.length} items]`;let s=Obje
`).all(e)}getAllProjects(){return this.db.prepare(`
SELECT DISTINCT project
FROM sdk_sessions
WHERE project IS NOT NULL AND project != ''
ORDER BY project ASC
`).all().map(t=>t.project)}getRecentSessionsWithStatus(e,s=3){return this.db.prepare(`
SELECT * FROM (
@@ -341,11 +342,7 @@ ${e.stack}`:e.message;if(Array.isArray(e))return`[${e.length} items]`;let s=Obje
UPDATE sdk_sessions
SET status = 'failed', completed_at = ?, completed_at_epoch = ?
WHERE id = ?
`).run(s.toISOString(),t,e)}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(),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(`
`).run(s.toISOString(),t,e)}getSessionSummariesByIds(e,s={}){if(e.length===0)return[];let{orderBy:t="date_desc",limit:r}=s,n=t==="date_asc"?"ASC":"DESC",o=r?`LIMIT ${r}`:"",i=e.map(()=>"?").join(",");return this.db.prepare(`
SELECT * FROM session_summaries
WHERE id IN (${i})
ORDER BY created_at_epoch ${n}
@@ -360,31 +357,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 u=`
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 p=this.db.prepare(l).all(e,...i,t+1),a=this.db.prepare(S).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 l=`
`;try{let p=this.db.prepare(u).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 u=`
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 p=this.db.prepare(l).all(s,...i,t),a=this.db.prepare(S).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 m=`
`;try{let p=this.db.prepare(u).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 m=`
SELECT *
FROM observations
WHERE created_at_epoch >= ? AND created_at_epoch <= ? ${o}
@@ -394,11 +391,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),p=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: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(l){return console.error("[SessionStore] Error querying timeline records:",l.message),{observations:[],sessions:[],prompts:[]}}}close(){this.db.close()}};import X from"path";import{homedir as F}from"os";import{existsSync as B,readFileSync as H}from"fs";function C(){try{let c=X.join(F(),".claude-mem","settings.json");if(B(c)){let e=JSON.parse(H(c,"utf-8")),s=parseInt(e.env?.CLAUDE_MEM_WORKER_PORT,10);if(!isNaN(s))return s}}catch{}return parseInt(process.env.CLAUDE_MEM_WORKER_PORT||"37777",10)}async function v(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(`
`;try{let u=this.db.prepare(m).all(d,_,...i),b=this.db.prepare(T).all(d,_,...i),p=this.db.prepare(S).all(d,_,...i);return{observations:u,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(u){return console.error("[SessionStore] Error querying timeline records:",u.message),{observations:[],sessions:[],prompts:[]}}}close(){this.db.close()}};import X from"path";import{homedir as F}from"os";import{existsSync as B,readFileSync as j}from"fs";function C(){try{let c=X.join(F(),".claude-mem","settings.json");if(B(c)){let e=JSON.parse(j(c,"utf-8")),s=parseInt(e.env?.CLAUDE_MEM_WORKER_PORT,10);if(!isNaN(s))return s}}catch{}return parseInt(process.env.CLAUDE_MEM_WORKER_PORT||"37777",10)}async function v(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();try{let n=r.worker_port||C();await fetch(`http://127.0.0.1:${n}/sessions/${r.id}/complete`,{method:"POST",signal:AbortSignal.timeout(1e3)}),console.error("[claude-mem cleanup] Worker notified to stop processing indicator")}catch(n){console.error("[claude-mem cleanup] Failed to notify worker (non-critical):",n)}console.error("[claude-mem cleanup] Cleanup completed successfully"),console.log('{"continue": true, "suppressOutput": true}'),process.exit(0)}if(I.isTTY)v(void 0);else{let c="";I.on("data",e=>c+=e),I.on("end",async()=>{let e=c?JSON.parse(c):void 0;await v(e)})}
+47 -50
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env node
import X from"path";import{stdin as F}from"process";import ie from"better-sqlite3";import{join as b,dirname as se,basename as Ie}from"path";import{homedir as j}from"os";import{existsSync as ve,mkdirSync as te}from"fs";import{fileURLToPath as re}from"url";function ne(){return typeof __dirname<"u"?__dirname:se(re(import.meta.url))}var oe=ne(),I=process.env.CLAUDE_MEM_DATA_DIR||b(j(),".claude-mem"),$=process.env.CLAUDE_CONFIG_DIR||b(j(),".claude"),De=b(I,"archives"),xe=b(I,"logs"),ke=b(I,"trash"),$e=b(I,"backups"),Ue=b(I,"settings.json"),H=b(I,"claude-mem.db"),Me=b(I,"vector-db"),we=b($,"settings.json"),Fe=b($,"commands"),Xe=b($,"CLAUDE.md");function W(a){te(a,{recursive:!0})}function G(){return b(oe,"..","..")}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||{}),M=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),d=U[e].padEnd(5),_=s.padEnd(6),E="";r?.correlationId?E=`[${r.correlationId}] `:r?.sessionId&&(E=`[session-${r.sessionId}] `);let S="";o!=null&&(this.level===0&&typeof o=="object"?S=`
`+JSON.stringify(o,null,2):S=" "+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,T])=>`${u}=${T}`).join(", ")}}`)}let y=`[${c}] [${d}] [${_}] ${E}${t}${n}${S}`;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`})}},Y=new M;var D=class{db;constructor(){W(I),this.db=new ie(H),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 X from"path";import{stdin as w}from"process";import se from"better-sqlite3";import{join as f,dirname as Q,basename as _e}from"path";import{homedir as j}from"os";import{existsSync as Ee,mkdirSync as z}from"fs";import{fileURLToPath as Z}from"url";function ee(){return typeof __dirname<"u"?__dirname:Q(Z(import.meta.url))}var ge=ee(),I=process.env.CLAUDE_MEM_DATA_DIR||f(j(),".claude-mem"),$=process.env.CLAUDE_CONFIG_DIR||f(j(),".claude"),he=f(I,"archives"),be=f(I,"logs"),Se=f(I,"trash"),fe=f(I,"backups"),Re=f(I,"settings.json"),P=f(I,"claude-mem.db"),Ne=f(I,"vector-db"),Oe=f($,"settings.json"),Ie=f($,"commands"),Le=f($,"CLAUDE.md");function H(p){z(p,{recursive:!0})}var U=(i=>(i[i.DEBUG=0]="DEBUG",i[i.INFO=1]="INFO",i[i.WARN=2]="WARN",i[i.ERROR=3]="ERROR",i[i.SILENT=4]="SILENT",i))(U||{}),M=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,i){if(e<this.level)return;let d=new Date().toISOString().replace("T"," ").substring(0,23),a=U[e].padEnd(5),_=s.padEnd(6),T="";r?.correlationId?T=`[${r.correlationId}] `:r?.sessionId&&(T=`[session-${r.sessionId}] `);let S="";i!=null&&(this.level===0&&typeof i=="object"?S=`
`+JSON.stringify(i,null,2):S=" "+this.formatData(i));let n="";if(r){let{sessionId:R,sdkSessionId:N,correlationId:l,...c}=r;Object.keys(c).length>0&&(n=` {${Object.entries(c).map(([u,g])=>`${u}=${g}`).join(", ")}}`)}let v=`[${d}] [${a}] [${_}] ${T}${t}${n}${S}`;e===3?console.error(v):console.log(v)}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 M;var D=class{db;constructor(){H(I),this.db=new se(P),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,
@@ -216,6 +216,7 @@ ${e.stack}`:e.message;if(Array.isArray(e))return`[${e.length} items]`;let s=Obje
`).all(e)}getAllProjects(){return this.db.prepare(`
SELECT DISTINCT project
FROM sdk_sessions
WHERE project IS NOT NULL AND project != ''
ORDER BY project ASC
`).all().map(t=>t.project)}getRecentSessionsWithStatus(e,s=3){return this.db.prepare(`
SELECT * FROM (
@@ -243,12 +244,12 @@ ${e.stack}`:e.message;if(Array.isArray(e))return`[${e.length} items]`;let s=Obje
SELECT *
FROM observations
WHERE id = ?
`).get(e)||null}getObservationsByIds(e,s={}){if(e.length===0)return[];let{orderBy:t="date_desc",limit:r}=s,o=t==="date_asc"?"ASC":"DESC",c=r?`LIMIT ${r}`:"",d=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,i=t==="date_asc"?"ASC":"DESC",d=r?`LIMIT ${r}`:"",a=e.map(()=>"?").join(",");return this.db.prepare(`
SELECT *
FROM observations
WHERE id IN (${d})
ORDER BY created_at_epoch ${o}
${c}
WHERE id IN (${a})
ORDER BY created_at_epoch ${i}
${d}
`).all(...e)}getSummaryForSession(e){return this.db.prepare(`
SELECT
request, investigated, learned, completed, next_steps,
@@ -261,7 +262,7 @@ ${e.stack}`:e.message;if(Array.isArray(e))return`[${e.length} items]`;let s=Obje
SELECT files_read, files_modified
FROM observations
WHERE sdk_session_id = ?
`).all(e),r=new Set,o=new Set;for(let c of t){if(c.files_read)try{let d=JSON.parse(c.files_read);Array.isArray(d)&&d.forEach(_=>r.add(_))}catch{}if(c.files_modified)try{let d=JSON.parse(c.files_modified);Array.isArray(d)&&d.forEach(_=>o.add(_))}catch{}}return{filesRead:Array.from(r),filesModified:Array.from(o)}}getSessionById(e){return this.db.prepare(`
`).all(e),r=new Set,i=new Set;for(let d of t){if(d.files_read)try{let a=JSON.parse(d.files_read);Array.isArray(a)&&a.forEach(_=>r.add(_))}catch{}if(d.files_modified)try{let a=JSON.parse(d.files_modified);Array.isArray(a)&&a.forEach(_=>i.add(_))}catch{}}return{filesRead:Array.from(r),filesModified:Array.from(i)}}getSessionById(e){return this.db.prepare(`
SELECT id, claude_session_id, sdk_session_id, project, user_prompt
FROM sdk_sessions
WHERE id = ?
@@ -288,17 +289,17 @@ ${e.stack}`:e.message;if(Array.isArray(e))return`[${e.length} items]`;let s=Obje
SELECT prompt_counter FROM sdk_sessions WHERE id = ?
`).get(e)?.prompt_counter||1}getPromptCounter(e){return this.db.prepare(`
SELECT prompt_counter FROM sdk_sessions WHERE id = ?
`).get(e)?.prompt_counter||0}createSDKSession(e,s,t){let r=new Date,o=r.getTime(),d=this.db.prepare(`
`).get(e)?.prompt_counter||0}createSDKSession(e,s,t){let r=new Date,i=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,s,t,r.toISOString(),o);return d.lastInsertRowid===0||d.changes===0?this.db.prepare(`
`).run(e,e,s,t,r.toISOString(),i);return a.lastInsertRowid===0||a.changes===0?this.db.prepare(`
SELECT id FROM sdk_sessions WHERE claude_session_id = ? LIMIT 1
`).get(e).id:d.lastInsertRowid}updateSDKSessionId(e,s){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(s,e).changes===0?(Y.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 = ?
@@ -307,33 +308,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,i=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,c=o.getTime();this.db.prepare(`
`).run(e,s,t,r.toISOString(),i).lastInsertRowid}storeObservation(e,s,t,r){let i=new Date,d=i.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(),c),console.error(`[SessionStore] Auto-created session record for session_id: ${e}`));let S=this.db.prepare(`
`).run(e,e,s,i.toISOString(),d),console.error(`[SessionStore] Auto-created session record for session_id: ${e}`));let S=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(),c);return{id:Number(S.lastInsertRowid),createdAtEpoch:c}}storeSummary(e,s,t,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,i.toISOString(),d);return{id:Number(S.lastInsertRowid),createdAtEpoch:d}}storeSummary(e,s,t,r){let i=new Date,d=i.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(),c),console.error(`[SessionStore] Auto-created session record for session_id: ${e}`));let S=this.db.prepare(`
`).run(e,e,s,i.toISOString(),d),console.error(`[SessionStore] Auto-created session record for session_id: ${e}`));let S=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(),c);return{id:Number(S.lastInsertRowid),createdAtEpoch:c}}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,i.toISOString(),d);return{id:Number(S.lastInsertRowid),createdAtEpoch:d}}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 = ?
@@ -341,66 +342,62 @@ ${e.stack}`:e.message;if(Array.isArray(e))return`[${e.length} items]`;let s=Obje
UPDATE sdk_sessions
SET status = 'failed', completed_at = ?, completed_at_epoch = ?
WHERE id = ?
`).run(s.toISOString(),t,e)}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(),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}`:"",d=e.map(()=>"?").join(",");return this.db.prepare(`
`).run(s.toISOString(),t,e)}getSessionSummariesByIds(e,s={}){if(e.length===0)return[];let{orderBy:t="date_desc",limit:r}=s,i=t==="date_asc"?"ASC":"DESC",d=r?`LIMIT ${r}`:"",a=e.map(()=>"?").join(",");return this.db.prepare(`
SELECT * FROM session_summaries
WHERE id IN (${d})
ORDER BY created_at_epoch ${o}
${c}
`).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}`:"",d=e.map(()=>"?").join(",");return this.db.prepare(`
WHERE id IN (${a})
ORDER BY created_at_epoch ${i}
${d}
`).all(...e)}getUserPromptsByIds(e,s={}){if(e.length===0)return[];let{orderBy:t="date_desc",limit:r}=s,i=t==="date_asc"?"ASC":"DESC",d=r?`LIMIT ${r}`:"",a=e.map(()=>"?").join(",");return this.db.prepare(`
SELECT
up.*,
s.project,
s.sdk_session_id
FROM user_prompts up
JOIN sdk_sessions s ON up.claude_session_id = s.claude_session_id
WHERE up.id IN (${d})
ORDER BY up.created_at_epoch ${o}
${c}
`).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 = ?":"",d=o?[o]:[],_,E;if(e!==null){let f=`
WHERE up.id IN (${a})
ORDER BY up.created_at_epoch ${i}
${d}
`).all(...e)}getTimelineAroundTimestamp(e,s=10,t=10,r){return this.getTimelineAroundObservation(null,e,s,t,r)}getTimelineAroundObservation(e,s,t=10,r=10,i){let d=i?"AND project = ?":"",a=i?[i]:[],_,T;if(e!==null){let R=`
SELECT id, created_at_epoch
FROM observations
WHERE id <= ? ${c}
WHERE id <= ? ${d}
ORDER BY id DESC
LIMIT ?
`,N=`
SELECT id, created_at_epoch
FROM observations
WHERE id >= ? ${c}
WHERE id >= ? ${d}
ORDER BY id ASC
LIMIT ?
`;try{let m=this.db.prepare(f).all(e,...d,t+1),p=this.db.prepare(N).all(e,...d,r+1);if(m.length===0&&p.length===0)return{observations:[],sessions:[],prompts:[]};_=m.length>0?m[m.length-1].created_at_epoch:s,E=p.length>0?p[p.length-1].created_at_epoch:s}catch(m){return console.error("[SessionStore] Error getting boundary observations:",m.message),{observations:[],sessions:[],prompts:[]}}}else{let f=`
`;try{let l=this.db.prepare(R).all(e,...a,t+1),c=this.db.prepare(N).all(e,...a,r+1);if(l.length===0&&c.length===0)return{observations:[],sessions:[],prompts:[]};_=l.length>0?l[l.length-1].created_at_epoch:s,T=c.length>0?c[c.length-1].created_at_epoch:s}catch(l){return console.error("[SessionStore] Error getting boundary observations:",l.message),{observations:[],sessions:[],prompts:[]}}}else{let R=`
SELECT created_at_epoch
FROM observations
WHERE created_at_epoch <= ? ${c}
WHERE created_at_epoch <= ? ${d}
ORDER BY created_at_epoch DESC
LIMIT ?
`,N=`
SELECT created_at_epoch
FROM observations
WHERE created_at_epoch >= ? ${c}
WHERE created_at_epoch >= ? ${d}
ORDER BY created_at_epoch ASC
LIMIT ?
`;try{let m=this.db.prepare(f).all(s,...d,t),p=this.db.prepare(N).all(s,...d,r+1);if(m.length===0&&p.length===0)return{observations:[],sessions:[],prompts:[]};_=m.length>0?m[m.length-1].created_at_epoch:s,E=p.length>0?p[p.length-1].created_at_epoch:s}catch(m){return console.error("[SessionStore] Error getting boundary timestamps:",m.message),{observations:[],sessions:[],prompts:[]}}}let S=`
`;try{let l=this.db.prepare(R).all(s,...a,t),c=this.db.prepare(N).all(s,...a,r+1);if(l.length===0&&c.length===0)return{observations:[],sessions:[],prompts:[]};_=l.length>0?l[l.length-1].created_at_epoch:s,T=c.length>0?c[c.length-1].created_at_epoch:s}catch(l){return console.error("[SessionStore] Error getting boundary timestamps:",l.message),{observations:[],sessions:[],prompts:[]}}}let S=`
SELECT *
FROM observations
WHERE created_at_epoch >= ? AND created_at_epoch <= ? ${c}
WHERE created_at_epoch >= ? AND created_at_epoch <= ? ${d}
ORDER BY created_at_epoch ASC
`,n=`
SELECT *
FROM session_summaries
WHERE created_at_epoch >= ? AND created_at_epoch <= ? ${c}
WHERE created_at_epoch >= ? AND created_at_epoch <= ? ${d}
ORDER BY created_at_epoch ASC
`,y=`
`,v=`
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")}
WHERE up.created_at_epoch >= ? AND up.created_at_epoch <= ? ${d.replace("project","s.project")}
ORDER BY up.created_at_epoch ASC
`;try{let f=this.db.prepare(S).all(_,E,...d),N=this.db.prepare(n).all(_,E,...d),m=this.db.prepare(y).all(_,E,...d);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 w from"path";import{homedir as ae}from"os";import{existsSync as de,readFileSync as ce}from"fs";import{execSync as pe}from"child_process";var _e=100,ue=100,me=1e4;function le(){try{let a=w.join(ae(),".claude-mem","settings.json");if(de(a)){let e=JSON.parse(ce(a,"utf-8")),s=parseInt(e.env?.CLAUDE_MEM_WORKER_PORT,10);if(!isNaN(s))return s}}catch{}return parseInt(process.env.CLAUDE_MEM_WORKER_PORT||"37777",10)}async function K(){try{let a=le();return(await fetch(`http://127.0.0.1:${a}/health`,{signal:AbortSignal.timeout(_e)})).ok}catch{return!1}}async function Ee(){let a=Date.now();for(;Date.now()-a<me;){if(await K())return!0;await new Promise(e=>setTimeout(e,ue))}return!1}async function V(){if(await K())return;let a=G(),e=w.join(a,"node_modules",".bin","pm2"),s=w.join(a,"ecosystem.config.cjs");if(pe(`"${e}" restart "${s}"`,{cwd:a,stdio:"pipe"}),!await Ee())throw new Error("Worker failed to become healthy after restart")}var Te=parseInt(process.env.CLAUDE_MEM_CONTEXT_OBSERVATIONS||"50",10),q=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 he(a){if(!a)return[];let e=JSON.parse(a);return Array.isArray(e)?e:[]}function ge(a){return new Date(a).toLocaleString("en-US",{month:"short",day:"numeric",hour:"numeric",minute:"2-digit",hour12:!0})}function be(a){return new Date(a).toLocaleString("en-US",{hour:"numeric",minute:"2-digit",hour12:!0})}function Se(a){return new Date(a).toLocaleString("en-US",{month:"short",day:"numeric",year:"numeric"})}function fe(a){return a?Math.ceil(a.length/4):0}function Re(a,e){return X.isAbsolute(a)?X.relative(e,a):a}async function J(a,e=!1,s=!1){await V();let t=a?.cwd??process.cwd(),r=t?X.basename(t):"unknown-project",o=new D,c=o.db.prepare(`
`;try{let R=this.db.prepare(S).all(_,T,...a),N=this.db.prepare(n).all(_,T,...a),l=this.db.prepare(v).all(_,T,...a);return{observations:R,sessions:N.map(c=>({id:c.id,sdk_session_id:c.sdk_session_id,project:c.project,request:c.request,completed:c.completed,next_steps:c.next_steps,created_at:c.created_at,created_at_epoch:c.created_at_epoch})),prompts:l.map(c=>({id:c.id,claude_session_id:c.claude_session_id,project:c.project,prompt:c.prompt_text,created_at:c.created_at,created_at_epoch:c.created_at_epoch}))}}catch(R){return console.error("[SessionStore] Error querying timeline records:",R.message),{observations:[],sessions:[],prompts:[]}}}close(){this.db.close()}};var te=parseInt(process.env.CLAUDE_MEM_CONTEXT_OBSERVATIONS||"50",10),W=10,o={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 re(p){if(!p)return[];let e=JSON.parse(p);return Array.isArray(e)?e:[]}function ne(p){return new Date(p).toLocaleString("en-US",{month:"short",day:"numeric",hour:"numeric",minute:"2-digit",hour12:!0})}function ie(p){return new Date(p).toLocaleString("en-US",{hour:"numeric",minute:"2-digit",hour12:!0})}function oe(p){return new Date(p).toLocaleString("en-US",{month:"short",day:"numeric",year:"numeric"})}function ae(p){return p?Math.ceil(p.length/4):0}function de(p,e){return X.isAbsolute(p)?X.relative(e,p):p}async function Y(p,e=!1,s=!1){let t=p?.cwd??process.cwd(),r=t?X.basename(t):"unknown-project",i=new D,d=i.db.prepare(`
SELECT
id, sdk_session_id, type, title, subtitle, narrative,
facts, concepts, files_read, files_modified,
@@ -409,18 +406,18 @@ ${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(r,Te),d=o.db.prepare(`
SELECT id, sdk_session_id, request, completed, next_steps, created_at, created_at_epoch
`).all(r,te),a=i.db.prepare(`
SELECT id, sdk_session_id, request, investigated, learned, completed, next_steps, created_at, created_at_epoch
FROM session_summaries
WHERE project = ?
ORDER BY created_at_epoch DESC
LIMIT ?
`).all(r,q+1);if(c.length===0&&d.length===0)return o.close(),e?`
${i.bright}${i.cyan}\u{1F4DD} [${r}] recent context${i.reset}
${i.gray}${"\u2500".repeat(60)}${i.reset}
`).all(r,W+1);if(d.length===0&&a.length===0)return i.close(),e?`
${o.bright}${o.cyan}\u{1F4DD} [${r}] recent context${o.reset}
${o.gray}${"\u2500".repeat(60)}${o.reset}
${i.dim}No previous sessions found for this project yet.${i.reset}
${o.dim}No previous sessions found for this project yet.${o.reset}
`:`# [${r}] recent context
No previous sessions found for this project yet.`;let _=c,E=d.slice(0,q),S=_,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("")),S.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=d[0]?.id,f=E.map((u,T)=>{let l=T===0?null:d[T+1];return{...u,displayEpoch:l?l.created_at_epoch:u.created_at_epoch,displayTime:l?l.created_at:u.created_at,isMostRecent:u.id===y}}),N=[...S.map(u=>({type:"observation",data:u})),...f.map(u=>({type:"summary",data:u}))];N.sort((u,T)=>{let l=u.type==="observation"?u.data.created_at_epoch:u.data.displayEpoch,L=T.type==="observation"?T.data.created_at_epoch:T.data.displayEpoch;return l-L});let m=new Map;for(let u of N){let T=u.type==="observation"?u.data.created_at:u.data.displayTime,l=Se(T);m.has(l)||m.set(l,[]),m.get(l).push(u)}let p=Array.from(m.entries()).sort((u,T)=>{let l=new Date(u[0]).getTime(),L=new Date(T[0]).getTime();return l-L});for(let[u,T]of p){e?(n.push(`${i.bright}${i.cyan}${u}${i.reset}`),n.push("")):(n.push(`### ${u}`),n.push(""));let l=null,L="",A=!1;for(let x of T)if(x.type==="summary"){A&&(n.push(""),A=!1,l=null,L="");let h=x.data,v=`${h.request||"Session started"} (${ge(h.displayTime)})`,O=h.isMostRecent?"":`claude-mem://session-summary/${h.id}`;if(e){let g=O?`${i.dim}[${O}]${i.reset}`:"";n.push(`\u{1F3AF} ${i.yellow}#S${h.id}${i.reset} ${v} ${g}`)}else{let g=O?` [\u2192](${O})`:"";n.push(`**\u{1F3AF} #S${h.id}** ${v}${g}`)}n.push("")}else{let h=x.data,v=he(h.files_modified),O=v.length>0?Re(v[0],t):"General";O!==l&&(A&&n.push(""),e?n.push(`${i.dim}${O}${i.reset}`):n.push(`**${O}**`),e||(n.push("| ID | Time | T | Title | Tokens |"),n.push("|----|------|---|-------|--------|")),l=O,A=!0,L="");let g="\u2022";switch(h.type){case"bugfix":g="\u{1F534}";break;case"feature":g="\u{1F7E3}";break;case"refactor":g="\u{1F504}";break;case"change":g="\u2705";break;case"discovery":g="\u{1F535}";break;case"decision":g="\u{1F9E0}";break;default:g="\u2022"}let C=be(h.created_at),B=h.title||"Untitled",k=fe(h.narrative),P=C!==L,z=P?C:"";if(L=C,e){let Z=P?`${i.dim}${C}${i.reset}`:" ".repeat(C.length),ee=k>0?`${i.dim}(~${k}t)${i.reset}`:"";n.push(` ${i.dim}#${h.id}${i.reset} ${Z} ${g} ${B} ${ee}`)}else n.push(`| #${h.id} | ${z||"\u2033"} | ${g} | ${B} | ~${k} |`)}A&&n.push("")}let R=d[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 Q=process.argv.includes("--index"),Ne=process.argv.includes("--colors");if(F.isTTY||Ne)J(void 0,!0,Q).then(a=>{console.log(a),process.exit(0)});else{let a="";F.on("data",e=>a+=e),F.on("end",async()=>{let e=a.trim()?JSON.parse(a):void 0,t={hookSpecificOutput:{hookEventName:"SessionStart",additionalContext:await J(e,!1,Q)}};console.log(JSON.stringify(t)),process.exit(0)})}
No previous sessions found for this project yet.`;let _=d,T=a.slice(0,W),S=_,n=[];if(e?(n.push(""),n.push(`${o.bright}${o.cyan}\u{1F4DD} [${r}] recent context${o.reset}`),n.push(`${o.gray}${"\u2500".repeat(60)}${o.reset}`),n.push("")):(n.push(`# [${r}] recent context`),n.push("")),S.length>0){e?(n.push(`${o.dim}Legend: \u{1F3AF} session-request | \u{1F534} bugfix | \u{1F7E3} feature | \u{1F504} refactor | \u2705 change | \u{1F535} discovery | \u{1F9E0} decision${o.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(`${o.dim}\u{1F4A1} Progressive Disclosure: This index shows WHAT exists (titles) and retrieval COST (token counts).${o.reset}`),n.push(`${o.dim} \u2192 Use MCP search tools to fetch full observation details on-demand (Layer 2)${o.reset}`),n.push(`${o.dim} \u2192 Prefer searching observations over re-reading code for past decisions and learnings${o.reset}`),n.push(`${o.dim} \u2192 Critical types (\u{1F534} bugfix, \u{1F9E0} decision) often worth fetching immediately${o.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,R=T.map((u,g)=>{let E=g===0?null:a[g+1];return{...u,displayEpoch:E?E.created_at_epoch:u.created_at_epoch,displayTime:E?E.created_at:u.created_at,isMostRecent:u.id===v}}),N=[...S.map(u=>({type:"observation",data:u})),...R.map(u=>({type:"summary",data:u}))];N.sort((u,g)=>{let E=u.type==="observation"?u.data.created_at_epoch:u.data.displayEpoch,L=g.type==="observation"?g.data.created_at_epoch:g.data.displayEpoch;return E-L});let l=new Map;for(let u of N){let g=u.type==="observation"?u.data.created_at:u.data.displayTime,E=oe(g);l.has(E)||l.set(E,[]),l.get(E).push(u)}let c=Array.from(l.entries()).sort((u,g)=>{let E=new Date(u[0]).getTime(),L=new Date(g[0]).getTime();return E-L});for(let[u,g]of c){e?(n.push(`${o.bright}${o.cyan}${u}${o.reset}`),n.push("")):(n.push(`### ${u}`),n.push(""));let E=null,L="",A=!1;for(let x of g)if(x.type==="summary"){A&&(n.push(""),A=!1,E=null,L="");let h=x.data,y=`${h.request||"Session started"} (${ne(h.displayTime)})`,O=h.isMostRecent?"":`claude-mem://session-summary/${h.id}`;if(e){let b=O?`${o.dim}[${O}]${o.reset}`:"";n.push(`\u{1F3AF} ${o.yellow}#S${h.id}${o.reset} ${y} ${b}`)}else{let b=O?` [\u2192](${O})`:"";n.push(`**\u{1F3AF} #S${h.id}** ${y}${b}`)}n.push("")}else{let h=x.data,y=re(h.files_modified),O=y.length>0?de(y[0],t):"General";O!==E&&(A&&n.push(""),e?n.push(`${o.dim}${O}${o.reset}`):n.push(`**${O}**`),e||(n.push("| ID | Time | T | Title | Tokens |"),n.push("|----|------|---|-------|--------|")),E=O,A=!0,L="");let b="\u2022";switch(h.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=ie(h.created_at),F=h.title||"Untitled",k=ae(h.narrative),B=C!==L,q=B?C:"";if(L=C,e){let K=B?`${o.dim}${C}${o.reset}`:" ".repeat(C.length),J=k>0?`${o.dim}(~${k}t)${o.reset}`:"";n.push(` ${o.dim}#${h.id}${o.reset} ${K} ${b} ${F} ${J}`)}else n.push(`| #${h.id} | ${q||"\u2033"} | ${b} | ${F} | ~${k} |`)}A&&n.push("")}let m=a[0];m&&(m.investigated||m.learned||m.completed||m.next_steps)&&(m.investigated&&(e?n.push(`${o.blue}Investigated:${o.reset} ${m.investigated}`):n.push(`**Investigated**: ${m.investigated}`),n.push("")),m.learned&&(e?n.push(`${o.yellow}Learned:${o.reset} ${m.learned}`):n.push(`**Learned**: ${m.learned}`),n.push("")),m.completed&&(e?n.push(`${o.green}Completed:${o.reset} ${m.completed}`):n.push(`**Completed**: ${m.completed}`),n.push("")),m.next_steps&&(e?n.push(`${o.magenta}Next Steps:${o.reset} ${m.next_steps}`):n.push(`**Next Steps**: ${m.next_steps}`),n.push(""))),e?n.push(`${o.dim}Use claude-mem MCP search to access records with the given ID${o.reset}`):n.push("*Use claude-mem MCP search to access records with the given ID*")}return i.close(),n.join(`
`).trimEnd()}var V=process.argv.includes("--index"),ce=process.argv.includes("--colors");if(w.isTTY||ce)Y(void 0,!0,V).then(p=>{console.log(p),process.exit(0)});else{let p="";w.on("data",e=>p+=e),w.on("end",async()=>{let e=p.trim()?JSON.parse(p):void 0,t={hookSpecificOutput:{hookEventName:"SessionStart",additionalContext:await Y(e,!1,V)}};console.log(JSON.stringify(t)),process.exit(0)})}
+3 -6
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env node
import z from"path";import{stdin as U}from"process";import j from"better-sqlite3";import{join as u,dirname as X,basename as te}from"path";import{homedir as L}from"os";import{existsSync as ie,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||u(L(),".claude-mem"),R=process.env.CLAUDE_CONFIG_DIR||u(L(),".claude"),pe=u(m,"archives"),de=u(m,"logs"),ce=u(m,"trash"),_e=u(m,"backups"),ue=u(m,"settings.json"),A=u(m,"claude-mem.db"),Ee=u(m,"vector-db"),me=u(R,"settings.json"),le=u(R,"commands"),Te=u(R,"CLAUDE.md");function C(a){F(a,{recursive:!0})}function v(){return u(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 z from"path";import{stdin as U}from"process";import j from"better-sqlite3";import{join as u,dirname as X,basename as te}from"path";import{homedir as L}from"os";import{existsSync as ie,mkdirSync as F}from"fs";import{fileURLToPath as H}from"url";function P(){return typeof __dirname<"u"?__dirname:X(H(import.meta.url))}var B=P(),m=process.env.CLAUDE_MEM_DATA_DIR||u(L(),".claude-mem"),R=process.env.CLAUDE_CONFIG_DIR||u(L(),".claude"),pe=u(m,"archives"),de=u(m,"logs"),ce=u(m,"trash"),_e=u(m,"backups"),ue=u(m,"settings.json"),A=u(m,"claude-mem.db"),Ee=u(m,"vector-db"),me=u(R,"settings.json"),le=u(R,"commands"),Te=u(R,"CLAUDE.md");function C(a){F(a,{recursive:!0})}function v(){return u(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}
${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),c="";r?.correlationId?c=`[${r.correlationId}] `:r?.sessionId&&(c=`[session-${r.sessionId}] `);let E="";n!=null&&(this.level===0&&typeof n=="object"?E=`
`+JSON.stringify(n,null,2):E=" "+this.formatData(n));let T="";if(r){let{sessionId:l,sdkSessionId:b,correlationId:_,...p}=r;Object.keys(p).length>0&&(T=` {${Object.entries(p).map(([M,w])=>`${M}=${w}`).join(", ")}}`)}let S=`[${o}] [${i}] [${d}] ${c}${t}${T}${E}`;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`})}},y=new N;var g=class{db;constructor(){C(m),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 (
@@ -216,6 +216,7 @@ ${e.stack}`:e.message;if(Array.isArray(e))return`[${e.length} items]`;let s=Obje
`).all(e)}getAllProjects(){return this.db.prepare(`
SELECT DISTINCT project
FROM sdk_sessions
WHERE project IS NOT NULL AND project != ''
ORDER BY project ASC
`).all().map(t=>t.project)}getRecentSessionsWithStatus(e,s=3){return this.db.prepare(`
SELECT * FROM (
@@ -341,11 +342,7 @@ ${e.stack}`:e.message;if(Array.isArray(e))return`[${e.length} items]`;let s=Obje
UPDATE sdk_sessions
SET status = 'failed', completed_at = ?, completed_at_epoch = ?
WHERE id = ?
`).run(s.toISOString(),t,e)}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(),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(`
`).run(s.toISOString(),t,e)}getSessionSummariesByIds(e,s={}){if(e.length===0)return[];let{orderBy:t="date_desc",limit:r}=s,n=t==="date_asc"?"ASC":"DESC",o=r?`LIMIT ${r}`:"",i=e.map(()=>"?").join(",");return this.db.prepare(`
SELECT * FROM session_summaries
WHERE id IN (${i})
ORDER BY created_at_epoch ${n}
+31 -34
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env node
import{stdin as U}from"process";import j from"better-sqlite3";import{join as E,dirname as F,basename as te}from"path";import{homedir as C}from"os";import{existsSync as ie,mkdirSync as X}from"fs";import{fileURLToPath as H}from"url";function P(){return typeof __dirname<"u"?__dirname:F(H(import.meta.url))}var B=P(),l=process.env.CLAUDE_MEM_DATA_DIR||E(C(),".claude-mem"),h=process.env.CLAUDE_CONFIG_DIR||E(C(),".claude"),de=E(l,"archives"),pe=E(l,"logs"),ce=E(l,"trash"),_e=E(l,"backups"),ue=E(l,"settings.json"),v=E(l,"claude-mem.db"),Ee=E(l,"vector-db"),me=E(h,"settings.json"),le=E(h,"commands"),Te=E(h,"CLAUDE.md");function y(a){X(a,{recursive:!0})}function D(){return E(B,"..","..")}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),u="";r?.correlationId?u=`[${r.correlationId}] `:r?.sessionId&&(u=`[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:S,correlationId:_,...d}=r;Object.keys(d).length>0&&(m=` {${Object.entries(d).map(([M,w])=>`${M}=${w}`).join(", ")}}`)}let g=`[${o}] [${i}] [${p}] ${u}${t}${m}${c}`;e===3?console.error(g):console.log(g)}debug(e,s,t,r){this.log(0,e,s,t,r)}info(e,s,t,r){this.log(1,e,s,t,r)}warn(e,s,t,r){this.log(2,e,s,t,r)}error(e,s,t,r){this.log(3,e,s,t,r)}dataIn(e,s,t,r){this.info(e,`\u2192 ${s}`,t,r)}dataOut(e,s,t,r){this.info(e,`\u2190 ${s}`,t,r)}success(e,s,t,r){this.info(e,`\u2713 ${s}`,t,r)}failure(e,s,t,r){this.error(e,`\u2717 ${s}`,t,r)}timing(e,s,t,r){this.info(e,`\u23F1 ${s}`,r,{duration:`${t}ms`})}},b=new O;var R=class{db;constructor(){y(l),this.db=new j(v),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 te}from"path";import{homedir as C}from"os";import{existsSync as ie,mkdirSync as X}from"fs";import{fileURLToPath as H}from"url";function P(){return typeof __dirname<"u"?__dirname:F(H(import.meta.url))}var B=P(),l=process.env.CLAUDE_MEM_DATA_DIR||E(C(),".claude-mem"),h=process.env.CLAUDE_CONFIG_DIR||E(C(),".claude"),de=E(l,"archives"),pe=E(l,"logs"),ce=E(l,"trash"),_e=E(l,"backups"),ue=E(l,"settings.json"),v=E(l,"claude-mem.db"),Ee=E(l,"vector-db"),me=E(h,"settings.json"),le=E(h,"commands"),Te=E(h,"CLAUDE.md");function y(a){X(a,{recursive:!0})}function D(){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),u="";r?.correlationId?u=`[${r.correlationId}] `:r?.sessionId&&(u=`[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:S,correlationId:_,...d}=r;Object.keys(d).length>0&&(m=` {${Object.entries(d).map(([M,w])=>`${M}=${w}`).join(", ")}}`)}let g=`[${n}] [${i}] [${p}] ${u}${t}${m}${c}`;e===3?console.error(g):console.log(g)}debug(e,s,t,r){this.log(0,e,s,t,r)}info(e,s,t,r){this.log(1,e,s,t,r)}warn(e,s,t,r){this.log(2,e,s,t,r)}error(e,s,t,r){this.log(3,e,s,t,r)}dataIn(e,s,t,r){this.info(e,`\u2192 ${s}`,t,r)}dataOut(e,s,t,r){this.info(e,`\u2190 ${s}`,t,r)}success(e,s,t,r){this.info(e,`\u2713 ${s}`,t,r)}failure(e,s,t,r){this.error(e,`\u2717 ${s}`,t,r)}timing(e,s,t,r){this.info(e,`\u23F1 ${s}`,r,{duration:`${t}ms`})}},b=new O;var R=class{db;constructor(){y(l),this.db=new j(v),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,
@@ -216,6 +216,7 @@ ${e.stack}`:e.message;if(Array.isArray(e))return`[${e.length} items]`;let s=Obje
`).all(e)}getAllProjects(){return this.db.prepare(`
SELECT DISTINCT project
FROM sdk_sessions
WHERE project IS NOT NULL AND project != ''
ORDER BY project ASC
`).all().map(t=>t.project)}getRecentSessionsWithStatus(e,s=3){return this.db.prepare(`
SELECT * FROM (
@@ -243,12 +244,12 @@ ${e.stack}`:e.message;if(Array.isArray(e))return`[${e.length} items]`;let s=Obje
SELECT *
FROM observations
WHERE id = ?
`).get(e)||null}getObservationsByIds(e,s={}){if(e.length===0)return[];let{orderBy:t="date_desc",limit:r}=s,n=t==="date_asc"?"ASC":"DESC",o=r?`LIMIT ${r}`:"",i=e.map(()=>"?").join(",");return this.db.prepare(`
`).get(e)||null}getObservationsByIds(e,s={}){if(e.length===0)return[];let{orderBy:t="date_desc",limit:r}=s,o=t==="date_asc"?"ASC":"DESC",n=r?`LIMIT ${r}`:"",i=e.map(()=>"?").join(",");return this.db.prepare(`
SELECT *
FROM observations
WHERE id IN (${i})
ORDER BY created_at_epoch ${n}
${o}
ORDER BY created_at_epoch ${o}
${n}
`).all(...e)}getSummaryForSession(e){return this.db.prepare(`
SELECT
request, investigated, learned, completed, next_steps,
@@ -261,7 +262,7 @@ ${e.stack}`:e.message;if(Array.isArray(e))return`[${e.length} items]`;let s=Obje
SELECT files_read, files_modified
FROM observations
WHERE sdk_session_id = ?
`).all(e),r=new Set,n=new Set;for(let o of t){if(o.files_read)try{let i=JSON.parse(o.files_read);Array.isArray(i)&&i.forEach(p=>r.add(p))}catch{}if(o.files_modified)try{let i=JSON.parse(o.files_modified);Array.isArray(i)&&i.forEach(p=>n.add(p))}catch{}}return{filesRead:Array.from(r),filesModified:Array.from(n)}}getSessionById(e){return this.db.prepare(`
`).all(e),r=new Set,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(`
SELECT id, claude_session_id, sdk_session_id, project, user_prompt
FROM sdk_sessions
WHERE id = ?
@@ -288,11 +289,11 @@ ${e.stack}`:e.message;if(Array.isArray(e))return`[${e.length} items]`;let s=Obje
SELECT prompt_counter FROM sdk_sessions WHERE id = ?
`).get(e)?.prompt_counter||1}getPromptCounter(e){return this.db.prepare(`
SELECT prompt_counter FROM sdk_sessions WHERE id = ?
`).get(e)?.prompt_counter||0}createSDKSession(e,s,t){let r=new Date,n=r.getTime(),i=this.db.prepare(`
`).get(e)?.prompt_counter||0}createSDKSession(e,s,t){let r=new Date,o=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(),n);return i.lastInsertRowid===0||i.changes===0?this.db.prepare(`
`).run(e,e,s,t,r.toISOString(),o);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
@@ -307,33 +308,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,n=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,s,t,r.toISOString(),n).lastInsertRowid}storeObservation(e,s,t,r){let n=new Date,o=n.getTime();this.db.prepare(`
`).run(e,s,t,r.toISOString(),o).lastInsertRowid}storeObservation(e,s,t,r){let o=new Date,n=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,s,n.toISOString(),o),console.error(`[SessionStore] Auto-created session record for session_id: ${e}`));let c=this.db.prepare(`
`).run(e,e,s,o.toISOString(),n),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,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(`
`).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(`
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 c=this.db.prepare(`
`).run(e,e,s,o.toISOString(),n),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,n.toISOString(),o);return{id:Number(c.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,o.toISOString(),n);return{id:Number(c.lastInsertRowid),createdAtEpoch:n}}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 = ?
@@ -341,16 +342,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 id = ?
`).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(),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(`
`).run(s.toISOString(),t,e)}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(`
SELECT * FROM session_summaries
WHERE id IN (${i})
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(`
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(`
SELECT
up.*,
s.project,
@@ -358,46 +355,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 ${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,u;if(e!==null){let T=`
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,u;if(e!==null){let T=`
SELECT id, created_at_epoch
FROM observations
WHERE id <= ? ${o}
WHERE id <= ? ${n}
ORDER BY id DESC
LIMIT ?
`,S=`
SELECT id, created_at_epoch
FROM observations
WHERE id >= ? ${o}
WHERE id >= ? ${n}
ORDER BY id ASC
LIMIT ?
`;try{let _=this.db.prepare(T).all(e,...i,t+1),d=this.db.prepare(S).all(e,...i,r+1);if(_.length===0&&d.length===0)return{observations:[],sessions:[],prompts:[]};p=_.length>0?_[_.length-1].created_at_epoch:s,u=d.length>0?d[d.length-1].created_at_epoch:s}catch(_){return console.error("[SessionStore] Error getting boundary observations:",_.message),{observations:[],sessions:[],prompts:[]}}}else{let T=`
SELECT created_at_epoch
FROM observations
WHERE created_at_epoch <= ? ${o}
WHERE created_at_epoch <= ? ${n}
ORDER BY created_at_epoch DESC
LIMIT ?
`,S=`
SELECT created_at_epoch
FROM observations
WHERE created_at_epoch >= ? ${o}
WHERE created_at_epoch >= ? ${n}
ORDER BY created_at_epoch ASC
LIMIT ?
`;try{let _=this.db.prepare(T).all(s,...i,t),d=this.db.prepare(S).all(s,...i,r+1);if(_.length===0&&d.length===0)return{observations:[],sessions:[],prompts:[]};p=_.length>0?_[_.length-1].created_at_epoch:s,u=d.length>0?d[d.length-1].created_at_epoch:s}catch(_){return console.error("[SessionStore] Error getting boundary timestamps:",_.message),{observations:[],sessions:[],prompts:[]}}}let c=`
SELECT *
FROM observations
WHERE created_at_epoch >= ? AND created_at_epoch <= ? ${o}
WHERE created_at_epoch >= ? AND created_at_epoch <= ? ${n}
ORDER BY created_at_epoch ASC
`,m=`
SELECT *
FROM session_summaries
WHERE created_at_epoch >= ? AND created_at_epoch <= ? ${o}
WHERE created_at_epoch >= ? AND created_at_epoch <= ? ${n}
ORDER BY created_at_epoch ASC
`,g=`
SELECT up.*, s.project, s.sdk_session_id
FROM user_prompts up
JOIN sdk_sessions s ON up.claude_session_id = s.claude_session_id
WHERE up.created_at_epoch >= ? AND up.created_at_epoch <= ? ${o.replace("project","s.project")}
WHERE up.created_at_epoch >= ? AND up.created_at_epoch <= ? ${n.replace("project","s.project")}
ORDER BY up.created_at_epoch ASC
`;try{let T=this.db.prepare(c).all(p,u,...i),S=this.db.prepare(m).all(p,u,...i),_=this.db.prepare(g).all(p,u,...i);return{observations:T,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:_.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 $(a,e,s){return a==="PreCompact"?e?{continue:!0,suppressOutput:!0}:{continue:!1,stopReason:s.reason||"Pre-compact operation failed",suppressOutput:!0}:a==="SessionStart"?e&&s.context?{continue:!0,suppressOutput:!0,hookSpecificOutput:{hookEventName:"SessionStart",additionalContext:s.context}}:{continue:!0,suppressOutput:!0}:a==="UserPromptSubmit"||a==="PostToolUse"?{continue:!0,suppressOutput:!0}:a==="Stop"?{continue:!0,suppressOutput:!0}:{continue:e,suppressOutput:!0,...s.reason&&!e?{stopReason:s.reason}:{}}}function f(a,e,s={}){let t=$(a,e,s);return JSON.stringify(t)}import I from"path";import{homedir as W}from"os";import{existsSync as G,readFileSync as Y}from"fs";import{execSync as K}from"child_process";var V=100,q=100,J=1e4;function L(){try{let a=I.join(W(),".claude-mem","settings.json");if(G(a)){let e=JSON.parse(Y(a,"utf-8")),s=parseInt(e.env?.CLAUDE_MEM_WORKER_PORT,10);if(!isNaN(s))return s}}catch{}return parseInt(process.env.CLAUDE_MEM_WORKER_PORT||"37777",10)}async function k(){try{let a=L();return(await fetch(`http://127.0.0.1:${a}/health`,{signal:AbortSignal.timeout(V)})).ok}catch{return!1}}async function Q(){let a=Date.now();for(;Date.now()-a<J;){if(await k())return!0;await new Promise(e=>setTimeout(e,q))}return!1}async function x(){if(await k())return;let a=D(),e=I.join(a,"node_modules",".bin","pm2"),s=I.join(a,"ecosystem.config.cjs");if(K(`"${e}" restart "${s}"`,{cwd:a,stdio:"pipe"}),!await Q())throw new Error("Worker failed to become healthy after restart")}var z=new Set(["ListMcpResourcesTool"]);async function Z(a){if(!a)throw new Error("saveHook requires input");let{session_id:e,tool_name:s,tool_input:t,tool_response:r}=a;if(z.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 p=b.formatTool(s,t),u=L();b.dataIn("HOOK",`PostToolUse: ${p}`,{sessionId:o,workerPort:u});try{let c=await fetch(`http://127.0.0.1:${u}/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_response: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 A="";U.on("data",a=>A+=a);U.on("end",async()=>{let a=A?JSON.parse(A):void 0;await Z(a)});
`;try{let T=this.db.prepare(c).all(p,u,...i),S=this.db.prepare(m).all(p,u,...i),_=this.db.prepare(g).all(p,u,...i);return{observations:T,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:_.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 $(a,e,s){return a==="PreCompact"?e?{continue:!0,suppressOutput:!0}:{continue:!1,stopReason:s.reason||"Pre-compact operation failed",suppressOutput:!0}:a==="SessionStart"?e&&s.context?{continue:!0,suppressOutput:!0,hookSpecificOutput:{hookEventName:"SessionStart",additionalContext:s.context}}:{continue:!0,suppressOutput:!0}:a==="UserPromptSubmit"||a==="PostToolUse"?{continue:!0,suppressOutput:!0}:a==="Stop"?{continue:!0,suppressOutput:!0}:{continue:e,suppressOutput:!0,...s.reason&&!e?{stopReason:s.reason}:{}}}function f(a,e,s={}){let t=$(a,e,s);return JSON.stringify(t)}import I from"path";import{homedir as W}from"os";import{existsSync as G,readFileSync as Y}from"fs";import{execSync as K}from"child_process";var V=100,q=100,J=1e4;function L(){try{let a=I.join(W(),".claude-mem","settings.json");if(G(a)){let e=JSON.parse(Y(a,"utf-8")),s=parseInt(e.env?.CLAUDE_MEM_WORKER_PORT,10);if(!isNaN(s))return s}}catch{}return parseInt(process.env.CLAUDE_MEM_WORKER_PORT||"37777",10)}async function k(){try{let a=L();return(await fetch(`http://127.0.0.1:${a}/health`,{signal:AbortSignal.timeout(V)})).ok}catch{return!1}}async function Q(){let a=Date.now();for(;Date.now()-a<J;){if(await k())return!0;await new Promise(e=>setTimeout(e,q))}return!1}async function x(){if(await k())return;let a=D(),e=I.join(a,"node_modules",".bin","pm2"),s=I.join(a,"ecosystem.config.cjs");if(K(`"${e}" restart "${s}"`,{cwd:a,stdio:"pipe"}),!await Q())throw new Error("Worker failed to become healthy after restart")}var z=new Set(["ListMcpResourcesTool"]);async function Z(a){if(!a)throw new Error("saveHook requires input");let{session_id:e,tool_name:s,tool_input:t,tool_response:r}=a;if(z.has(s)){console.log(f("PostToolUse",!0));return}await x();let o=new R,n=o.createSDKSession(e,"",""),i=o.getPromptCounter(n);o.close();let p=b.formatTool(s,t),u=L();b.dataIn("HOOK",`PostToolUse: ${p}`,{sessionId:n,workerPort:u});try{let c=await fetch(`http://127.0.0.1:${u}/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_response: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:n,status:c.status},m),new Error(`Failed to send observation to worker: ${c.status} ${m}`)}b.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 A="";U.on("data",a=>A+=a);U.on("end",async()=>{let a=A?JSON.parse(A):void 0;await Z(a)});
+2 -5
View File
@@ -352,6 +352,7 @@ ${e.stack}`:e.message;if(Array.isArray(e))return`[${e.length} items]`;let s=Obje
`).all(e)}getAllProjects(){return this.db.prepare(`
SELECT DISTINCT project
FROM sdk_sessions
WHERE project IS NOT NULL AND project != ''
ORDER BY project ASC
`).all().map(r=>r.project)}getRecentSessionsWithStatus(e,s=3){return this.db.prepare(`
SELECT * FROM (
@@ -477,11 +478,7 @@ ${e.stack}`:e.message;if(Array.isArray(e))return`[${e.length} items]`;let s=Obje
UPDATE sdk_sessions
SET status = 'failed', completed_at = ?, completed_at_epoch = ?
WHERE id = ?
`).run(s.toISOString(),r,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(),s).changes}getSessionSummariesByIds(e,s={}){if(e.length===0)return[];let{orderBy:r="date_desc",limit:t}=s,n=r==="date_asc"?"ASC":"DESC",o=t?`LIMIT ${t}`:"",a=e.map(()=>"?").join(",");return this.db.prepare(`
`).run(s.toISOString(),r,e)}getSessionSummariesByIds(e,s={}){if(e.length===0)return[];let{orderBy:r="date_desc",limit:t}=s,n=r==="date_asc"?"ASC":"DESC",o=t?`LIMIT ${t}`:"",a=e.map(()=>"?").join(",");return this.db.prepare(`
SELECT * FROM session_summaries
WHERE id IN (${a})
ORDER BY created_at_epoch ${n}
+3 -6
View File
@@ -216,6 +216,7 @@ ${e.stack}`:e.message;if(Array.isArray(e))return`[${e.length} items]`;let s=Obje
`).all(e)}getAllProjects(){return this.db.prepare(`
SELECT DISTINCT project
FROM sdk_sessions
WHERE project IS NOT NULL AND project != ''
ORDER BY project ASC
`).all().map(t=>t.project)}getRecentSessionsWithStatus(e,s=3){return this.db.prepare(`
SELECT * FROM (
@@ -341,11 +342,7 @@ ${e.stack}`:e.message;if(Array.isArray(e))return`[${e.length} items]`;let s=Obje
UPDATE sdk_sessions
SET status = 'failed', completed_at = ?, completed_at_epoch = ?
WHERE id = ?
`).run(s.toISOString(),t,e)}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(),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(`
`).run(s.toISOString(),t,e)}getSessionSummariesByIds(e,s={}){if(e.length===0)return[];let{orderBy:t="date_desc",limit:r}=s,n=t==="date_asc"?"ASC":"DESC",o=r?`LIMIT ${r}`:"",i=e.map(()=>"?").join(",");return this.db.prepare(`
SELECT * FROM session_summaries
WHERE id IN (${i})
ORDER BY created_at_epoch ${n}
@@ -400,4 +397,4 @@ ${e.stack}`:e.message;if(Array.isArray(e))return`[${e.length} items]`;let s=Obje
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(p,u,...i),b=this.db.prepare(T).all(p,u,...i),c=this.db.prepare(g).all(p,u,...i);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:c.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(a,e,s){return a==="PreCompact"?e?{continue:!0,suppressOutput:!0}:{continue:!1,stopReason:s.reason||"Pre-compact operation failed",suppressOutput:!0}:a==="SessionStart"?e&&s.context?{continue:!0,suppressOutput:!0,hookSpecificOutput:{hookEventName:"SessionStart",additionalContext:s.context}}:{continue:!0,suppressOutput:!0}:a==="UserPromptSubmit"||a==="PostToolUse"?{continue:!0,suppressOutput:!0}:a==="Stop"?{continue:!0,suppressOutput:!0}:{continue:e,suppressOutput:!0,...s.reason&&!e?{stopReason:s.reason}:{}}}function D(a,e,s={}){let t=W(a,e,s);return JSON.stringify(t)}import f from"path";import{homedir as $}from"os";import{existsSync as G,readFileSync as Y}from"fs";import{execSync as K}from"child_process";var q=100,V=100,J=1e4;function I(){try{let a=f.join($(),".claude-mem","settings.json");if(G(a)){let e=JSON.parse(Y(a,"utf-8")),s=parseInt(e.env?.CLAUDE_MEM_WORKER_PORT,10);if(!isNaN(s))return s}}catch{}return parseInt(process.env.CLAUDE_MEM_WORKER_PORT||"37777",10)}async function k(){try{let a=I();return(await fetch(`http://127.0.0.1:${a}/health`,{signal:AbortSignal.timeout(q)})).ok}catch{return!1}}async function Q(){let a=Date.now();for(;Date.now()-a<J;){if(await k())return!0;await new Promise(e=>setTimeout(e,V))}return!1}async function x(){if(await k())return;let a=v(),e=f.join(a,"node_modules",".bin","pm2"),s=f.join(a,"ecosystem.config.cjs");if(K(`"${e}" restart "${s}"`,{cwd:a,stdio:"pipe"}),!await Q())throw new Error("Worker failed to become healthy after restart")}async function z(a){if(!a)throw new Error("summaryHook requires input");let{session_id:e}=a;await x();let s=new R,t=s.createSDKSession(e,"",""),r=s.getPromptCounter(t);s.close();let n=I();S.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 S.failure("HOOK","Failed to generate summary",{sessionId:t,status:o.status},i),new Error(`Failed to request summary from worker: ${o.status} ${i}`)}S.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(D("Stop",!0))}var L="";U.on("data",a=>L+=a);U.on("end",async()=>{let a=L?JSON.parse(L):void 0;await z(a)});
`;try{let l=this.db.prepare(m).all(p,u,...i),b=this.db.prepare(T).all(p,u,...i),c=this.db.prepare(g).all(p,u,...i);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:c.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 $(a,e,s){return a==="PreCompact"?e?{continue:!0,suppressOutput:!0}:{continue:!1,stopReason:s.reason||"Pre-compact operation failed",suppressOutput:!0}:a==="SessionStart"?e&&s.context?{continue:!0,suppressOutput:!0,hookSpecificOutput:{hookEventName:"SessionStart",additionalContext:s.context}}:{continue:!0,suppressOutput:!0}:a==="UserPromptSubmit"||a==="PostToolUse"?{continue:!0,suppressOutput:!0}:a==="Stop"?{continue:!0,suppressOutput:!0}:{continue:e,suppressOutput:!0,...s.reason&&!e?{stopReason:s.reason}:{}}}function D(a,e,s={}){let t=$(a,e,s);return JSON.stringify(t)}import f from"path";import{homedir as W}from"os";import{existsSync as G,readFileSync as Y}from"fs";import{execSync as K}from"child_process";var q=100,V=100,J=1e4;function I(){try{let a=f.join(W(),".claude-mem","settings.json");if(G(a)){let e=JSON.parse(Y(a,"utf-8")),s=parseInt(e.env?.CLAUDE_MEM_WORKER_PORT,10);if(!isNaN(s))return s}}catch{}return parseInt(process.env.CLAUDE_MEM_WORKER_PORT||"37777",10)}async function k(){try{let a=I();return(await fetch(`http://127.0.0.1:${a}/health`,{signal:AbortSignal.timeout(q)})).ok}catch{return!1}}async function Q(){let a=Date.now();for(;Date.now()-a<J;){if(await k())return!0;await new Promise(e=>setTimeout(e,V))}return!1}async function x(){if(await k())return;let a=v(),e=f.join(a,"node_modules",".bin","pm2"),s=f.join(a,"ecosystem.config.cjs");if(K(`"${e}" restart "${s}"`,{cwd:a,stdio:"pipe"}),!await Q())throw new Error("Worker failed to become healthy after restart")}async function z(a){if(!a)throw new Error("summaryHook requires input");let{session_id:e}=a;await x();let s=new R,t=s.createSDKSession(e,"",""),r=s.getPromptCounter(t);s.close();let n=I();S.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 S.failure("HOOK","Failed to generate summary",{sessionId:t,status:o.status},i),new Error(`Failed to request summary from worker: ${o.status} ${i}`)}S.debug("HOOK","Summary request sent successfully",{sessionId:t})}catch(o){throw o.cause?.code==="ECONNREFUSED"||o.name==="TimeoutError"||o.message.includes("fetch failed")?new Error("There's a problem with the worker. If you just updated, type `pm2 restart claude-mem-worker` in your terminal to continue"):o}finally{await fetch(`http://127.0.0.1:${n}/api/processing`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({isProcessing:!1})})}console.log(D("Stop",!0))}var L="";U.on("data",a=>L+=a);U.on("end",async()=>{let a=L?JSON.parse(L):void 0;await z(a)});
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+180 -10
View File
@@ -482,7 +482,7 @@
.card {
margin-bottom: 24px;
padding: 20px 24px;
padding: 24px;
background: var(--color-bg-card);
border: 1px solid var(--color-border-primary);
border-radius: 8px;
@@ -510,13 +510,19 @@
.card-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
justify-content: space-between;
margin-bottom: 14px;
font-size: 12px;
color: var(--color-text-muted);
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
}
.card-header-left {
display: flex;
align-items: center;
gap: 10px;
}
.card-type {
padding: 2px 8px;
background: var(--color-type-badge-bg);
@@ -530,25 +536,145 @@
.card-title {
font-size: 17px;
margin-bottom: 8px;
margin-bottom: 14px;
color: var(--color-text-title);
font-weight: 600;
line-height: 1.4;
letter-spacing: -0.01em;
}
.view-mode-toggles {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.view-mode-toggle {
display: flex;
align-items: center;
gap: 4px;
background: var(--color-bg-tertiary);
border: 1px solid var(--color-border-primary);
padding: 4px 8px;
border-radius: 4px;
cursor: pointer;
color: var(--color-text-secondary);
transition: all 0.15s ease;
font-size: 11px;
font-weight: 500;
text-transform: lowercase;
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
}
.view-mode-toggle svg {
flex-shrink: 0;
opacity: 0.7;
transition: opacity 0.15s ease;
}
.view-mode-toggle:hover {
background: var(--color-bg-card-hover);
border-color: var(--color-border-hover);
color: var(--color-text-primary);
}
.view-mode-toggle:hover svg {
opacity: 1;
}
.view-mode-toggle.active {
background: var(--color-accent-primary);
border-color: var(--color-accent-primary);
color: var(--color-text-button);
}
.view-mode-toggle.active svg {
opacity: 1;
}
.view-mode-content {
margin-bottom: 12px;
}
.view-mode-content .card-subtitle {
margin-bottom: 0;
}
.view-mode-content .facts-list {
list-style: disc;
margin: 0;
padding-left: 20px;
color: var(--color-text-secondary);
font-size: 13px;
line-height: 1.7;
}
.view-mode-content .facts-list li {
margin-bottom: 6px;
}
.view-mode-content .narrative {
max-height: 300px;
overflow-y: auto;
white-space: pre-wrap;
word-wrap: break-word;
color: var(--color-text-secondary);
font-size: 13px;
line-height: 1.7;
}
.card-subtitle {
font-size: 14px;
color: var(--color-text-subtitle);
margin-bottom: 8px;
line-height: 1.6;
line-height: 1.7;
margin-bottom: 10px;
}
.card-subtitle:last-child {
margin-bottom: 0;
}
.card-meta {
font-size: 12px;
font-size: 11px;
color: var(--color-text-tertiary);
margin-top: 8px;
margin-top: 18px;
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
display: flex;
flex-wrap: wrap;
gap: 6px;
line-height: 1.5;
}
.meta-date {
color: var(--color-text-tertiary);
}
.meta-concepts {
font-style: italic;
color: var(--color-text-muted);
}
.meta-files {
color: var(--color-text-muted);
font-size: 10px;
}
.meta-files .file-label {
font-weight: 500;
color: var(--color-text-tertiary);
}
/* Stack single column on narrow screens (removed - no longer using card-files) */
@media (max-width: 600px) {
}
/* Project badge styling */
.card-project {
color: var(--color-text-muted);
}
.summary-card {
@@ -672,8 +798,9 @@
}
.card-content {
margin-top: 12px;
line-height: 1.6;
margin-top: 14px;
margin-bottom: 12px;
line-height: 1.7;
color: var(--color-text-primary);
white-space: pre-wrap;
word-wrap: break-word;
@@ -744,6 +871,49 @@
background-position: -200% 0;
}
}
/* Scroll to top button */
.scroll-to-top {
position: fixed;
bottom: 24px;
right: 24px;
width: 48px;
height: 48px;
background: var(--color-bg-button);
color: var(--color-text-button);
border: none;
border-radius: 24px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
transition: all 0.2s ease;
z-index: 50;
animation: fadeInUp 0.3s ease-out;
}
.scroll-to-top:hover {
background: var(--color-bg-button-hover);
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
}
.scroll-to-top:active {
background: var(--color-bg-button-active);
transform: translateY(0);
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>
</head>
+2 -1
View File
@@ -77,6 +77,7 @@ async function buildHooks() {
format: 'cjs',
outfile: `${hooksDir}/${WORKER_SERVICE.name}.cjs`,
minify: true,
logLevel: 'error', // Suppress warnings (import.meta warning is benign)
external: ['better-sqlite3'],
define: {
'__DEFAULT_PACKAGE_VERSION__': `"${version}"`
@@ -147,7 +148,7 @@ async function buildHooks() {
console.log(` Output: ${hooksDir}/`);
console.log(` - Hooks: *-hook.js`);
console.log(` - Worker: worker-service.cjs`);
console.log(` - Search: search-server.js`);
console.log(` - Search: search-server.mjs`);
console.log('\n💡 Note: Dependencies will be auto-installed on first hook execution');
} catch (error) {
+80 -77
View File
@@ -164,82 +164,85 @@ function runNpmInstall() {
log('🔨 Installing dependencies...', colors.bright);
log('', colors.reset);
try {
// Run npm install with error output visible
execSync('npm install --prefer-offline --no-audit --no-fund', {
cwd: PLUGIN_ROOT,
stdio: 'inherit', // Show all output including errors
encoding: 'utf-8',
});
// Try normal install first, then retry with force if it fails
const strategies = [
{ command: 'npm install', label: 'normal' },
{ command: 'npm install --force', label: 'with force flag' },
];
// Verify better-sqlite3 was installed
if (!existsSync(BETTER_SQLITE3_PATH)) {
throw new Error('better-sqlite3 installation verification failed');
let lastError = null;
for (const { command, label } of strategies) {
try {
log(`Attempting install ${label}...`, colors.dim);
// Run npm install silently
execSync(command, {
cwd: PLUGIN_ROOT,
stdio: 'pipe', // Silent output unless error
encoding: 'utf-8',
});
// Verify better-sqlite3 was installed
if (!existsSync(BETTER_SQLITE3_PATH)) {
throw new Error('better-sqlite3 installation verification failed');
}
const version = getPackageVersion();
setInstalledVersion(version);
log('', colors.green);
log('✅ Dependencies installed successfully!', colors.bright);
log(` Version: ${version}`, colors.dim);
log('', colors.reset);
return true;
} catch (error) {
lastError = error;
// Continue to next strategy
}
const version = getPackageVersion();
setInstalledVersion(version);
log('', colors.green);
log('✅ Dependencies installed successfully!', colors.bright);
log(` Version: ${version}`, colors.dim);
log('', colors.reset);
return true;
} catch (error) {
log('', colors.red);
log('❌ Installation failed!', colors.bright);
log('', colors.reset);
// Provide Windows-specific help
if (isWindows && error.message && error.message.includes('better-sqlite3')) {
log(getWindowsErrorHelp(error.message), colors.yellow);
}
// Show generic error info
if (error.stderr) {
log('Error output:', colors.dim);
log(error.stderr.toString(), colors.red);
} else if (error.message) {
log(error.message, colors.red);
}
return false;
}
// All strategies failed - show error
log('', colors.red);
log('❌ Installation failed after retrying!', colors.bright);
log('', colors.reset);
// Provide Windows-specific help
if (isWindows && lastError && lastError.message && lastError.message.includes('better-sqlite3')) {
log(getWindowsErrorHelp(lastError.message), colors.yellow);
}
// Show generic error info with troubleshooting steps
if (lastError) {
if (lastError.stderr) {
log('Error output:', colors.dim);
log(lastError.stderr.toString(), colors.red);
} else if (lastError.message) {
log(lastError.message, colors.red);
}
log('', colors.yellow);
log('📋 Troubleshooting Steps:', colors.bright);
log('', colors.reset);
log('1. Check your internet connection', colors.yellow);
log('2. Try running: npm cache clean --force', colors.yellow);
log('3. Try running: npm install (in plugin directory)', colors.yellow);
log('4. Check npm version: npm --version (requires npm 7+)', colors.yellow);
log('5. Try updating npm: npm install -g npm@latest', colors.yellow);
log('', colors.reset);
}
return false;
}
function startWorker() {
const ECOSYSTEM_CONFIG = join(PLUGIN_ROOT, 'ecosystem.config.cjs');
const PM2_PATH = join(PLUGIN_ROOT, 'node_modules', '.bin', 'pm2');
log('🚀 Starting worker service...', colors.dim);
try {
// Use the full path to PM2 to avoid PATH issues on Windows
// PM2 will either start it or report it's already running (both are success cases)
execSync(`"${PM2_PATH}" start "${ECOSYSTEM_CONFIG}"`, {
cwd: PLUGIN_ROOT,
stdio: 'pipe', // Capture output to avoid clutter
encoding: 'utf-8',
});
log('✓ Worker service ready', colors.dim);
return true;
} catch (error) {
// PM2 errors are often non-critical (e.g., "already running")
// Don't fail the entire setup if worker start has issues
log(`⚠️ Worker startup issue (non-critical): ${error.message}`, colors.yellow);
// Check if it's just because worker is already running
if (error.message && (error.message.includes('already') || error.message.includes('exist'))) {
log('✓ Worker was already running', colors.dim);
return true;
}
return false;
}
/**
* Check if we should fail when worker startup fails
* Returns true if worker failed AND dependencies are missing
*/
function shouldFailOnWorkerStartup(workerStarted) {
return !workerStarted && !existsSync(NODE_MODULES_PATH);
}
async function main() {
@@ -253,21 +256,21 @@ async function main() {
if (!installSuccess) {
log('', colors.red);
log('⚠️ Installation failed - worker startup may fail', colors.yellow);
log('⚠️ Installation failed', colors.yellow);
log('', colors.reset);
// Don't exit - still try to start worker with existing deps
process.exit(1);
}
}
// Always start/ensure worker is running
// This runs whether we installed deps or not
startWorker();
// Worker will be started lazily when needed (e.g., when save-hook sends data)
// Context hook only needs database access, not the worker service
// Success - dependencies installed (if needed) and worker running
// Success - dependencies installed (if needed)
process.exit(0);
} catch (error) {
log(`❌ Unexpected error: ${error.message}`, colors.red);
log('', colors.reset);
process.exit(1);
}
}
+21 -7
View File
@@ -6,7 +6,6 @@
import path from 'path';
import { stdin } from 'process';
import { SessionStore } from '../services/sqlite/SessionStore.js';
import { ensureWorkerRunning } from '../shared/worker-utils.js';
// Configuration: Read from environment or use defaults
const DISPLAY_OBSERVATION_COUNT = parseInt(process.env.CLAUDE_MEM_CONTEXT_OBSERVATIONS || '50', 10);
@@ -127,9 +126,6 @@ function getObservations(db: SessionStore, sessionIds: string[]): Observation[]
* Context Hook Main Logic
*/
async function contextHook(input?: SessionStartInput, useColors: boolean = false, useIndexView: boolean = false): Promise<string> {
// Ensure worker is running
await ensureWorkerRunning();
const cwd = input?.cwd ?? process.cwd();
const project = cwd ? path.basename(cwd) : 'unknown-project';
@@ -151,12 +147,12 @@ async function contextHook(input?: SessionStartInput, useColors: boolean = false
// Get recent summaries (optional - may not exist for recent sessions)
const recentSummaries = db.db.prepare(`
SELECT id, sdk_session_id, request, completed, next_steps, created_at, created_at_epoch
SELECT id, sdk_session_id, request, investigated, learned, completed, next_steps, created_at, created_at_epoch
FROM session_summaries
WHERE project = ?
ORDER BY created_at_epoch DESC
LIMIT ?
`).all(project, DISPLAY_SESSION_COUNT + 1) as Array<{ id: number; sdk_session_id: string; request: string | null; completed: string | null; next_steps: string | null; created_at: string; created_at_epoch: number }>;
`).all(project, DISPLAY_SESSION_COUNT + 1) as Array<{ id: number; sdk_session_id: string; request: string | null; investigated: string | null; learned: string | null; completed: string | null; next_steps: string | null; created_at: string; created_at_epoch: number }>;
// If we have neither observations nor summaries, show empty state
if (allObservations.length === 0 && recentSummaries.length === 0) {
@@ -386,7 +382,25 @@ async function contextHook(input?: SessionStartInput, useColors: boolean = false
// Add full summary details for most recent session
const mostRecentSummary = recentSummaries[0];
if (mostRecentSummary && (mostRecentSummary.completed || mostRecentSummary.next_steps)) {
if (mostRecentSummary && (mostRecentSummary.investigated || mostRecentSummary.learned || mostRecentSummary.completed || mostRecentSummary.next_steps)) {
if (mostRecentSummary.investigated) {
if (useColors) {
output.push(`${colors.blue}Investigated:${colors.reset} ${mostRecentSummary.investigated}`);
} else {
output.push(`**Investigated**: ${mostRecentSummary.investigated}`);
}
output.push('');
}
if (mostRecentSummary.learned) {
if (useColors) {
output.push(`${colors.yellow}Learned:${colors.reset} ${mostRecentSummary.learned}`);
} else {
output.push(`**Learned**: ${mostRecentSummary.learned}`);
}
output.push('');
}
if (mostRecentSummary.completed) {
if (useColors) {
output.push(`${colors.green}Completed:${colors.reset} ${mostRecentSummary.completed}`);
+6
View File
@@ -68,6 +68,12 @@ async function summaryHook(input?: StopInput): Promise<void> {
}
// Re-throw HTTP errors and other errors as-is
throw error;
} finally {
await fetch(`http://127.0.0.1:${port}/api/processing`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ isProcessing: false })
});
}
console.log(createHookResponse('Stop', true));
+5 -16
View File
@@ -656,6 +656,7 @@ export class SessionStore {
const stmt = this.db.prepare(`
SELECT DISTINCT project
FROM sdk_sessions
WHERE project IS NOT NULL AND project != ''
ORDER BY project ASC
`);
@@ -1209,22 +1210,10 @@ export class SessionStore {
stmt.run(now.toISOString(), nowEpoch, id);
}
/**
* Clean up orphaned active sessions (called on worker startup)
*/
cleanupOrphanedSessions(): number {
const now = new Date();
const nowEpoch = now.getTime();
const stmt = this.db.prepare(`
UPDATE sdk_sessions
SET status = 'failed', completed_at = ?, completed_at_epoch = ?
WHERE status = 'active'
`);
const result = stmt.run(now.toISOString(), nowEpoch);
return result.changes;
}
// REMOVED: cleanupOrphanedSessions - violates "EVERYTHING SHOULD SAVE ALWAYS"
// There's no such thing as an "orphaned" session. Sessions are created by hooks
// and managed by Claude Code's lifecycle. Worker restarts don't invalidate them.
// Marking all active sessions as 'failed' on startup destroys the user's current work.
/**
* Get session summaries by IDs (for hybrid Chroma search)
File diff suppressed because it is too large Load Diff
-487
View File
@@ -1,487 +0,0 @@
/**
* Worker Service v2: Clean Object-Oriented Architecture
*
* This is a complete rewrite following the architecture document.
* Key improvements:
* - Single database connection (no open/close churn)
* - Event-driven queues (zero polling)
* - DRY utilities for pagination and settings
* - Clean separation of concerns
* - ~600-700 lines (down from 1173)
*/
import express, { Request, Response } from 'express';
import cors from 'cors';
import http from 'http';
import path from 'path';
import { readFileSync } from 'fs';
import { getPackageRoot } from '../shared/paths.js';
import { getWorkerPort } from '../shared/worker-utils.js';
import { logger } from '../utils/logger.js';
// Import composed services
import { DatabaseManager } from './worker/DatabaseManager.js';
import { SessionManager } from './worker/SessionManager.js';
import { SSEBroadcaster } from './worker/SSEBroadcaster.js';
import { SDKAgent } from './worker/SDKAgent.js';
import { PaginationHelper } from './worker/PaginationHelper.js';
import { SettingsManager } from './worker/SettingsManager.js';
export class WorkerService {
private app: express.Application;
private server: http.Server | null = null;
// Composed services
private dbManager: DatabaseManager;
private sessionManager: SessionManager;
private sseBroadcaster: SSEBroadcaster;
private sdkAgent: SDKAgent;
private paginationHelper: PaginationHelper;
private settingsManager: SettingsManager;
constructor() {
this.app = express();
// Initialize services (dependency injection)
this.dbManager = new DatabaseManager();
this.sessionManager = new SessionManager(this.dbManager);
this.sseBroadcaster = new SSEBroadcaster();
this.sdkAgent = new SDKAgent(this.dbManager, this.sessionManager);
this.paginationHelper = new PaginationHelper(this.dbManager);
this.settingsManager = new SettingsManager(this.dbManager);
this.setupMiddleware();
this.setupRoutes();
}
/**
* Setup Express middleware
*/
private setupMiddleware(): void {
this.app.use(express.json({ limit: '50mb' }));
this.app.use(cors());
}
/**
* Setup HTTP routes
*/
private setupRoutes(): void {
// Health & Viewer
this.app.get('/health', this.handleHealth.bind(this));
this.app.get('/', this.handleViewerUI.bind(this));
this.app.get('/stream', this.handleSSEStream.bind(this));
// Session endpoints
this.app.post('/sessions/:sessionDbId/init', this.handleSessionInit.bind(this));
this.app.post('/sessions/:sessionDbId/observations', this.handleObservations.bind(this));
this.app.post('/sessions/:sessionDbId/summarize', this.handleSummarize.bind(this));
this.app.get('/sessions/:sessionDbId/status', this.handleSessionStatus.bind(this));
this.app.delete('/sessions/:sessionDbId', this.handleSessionDelete.bind(this));
this.app.post('/sessions/:sessionDbId/complete', this.handleSessionComplete.bind(this));
// Data retrieval
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));
this.app.get('/api/stats', this.handleGetStats.bind(this));
// Settings
this.app.get('/api/settings', this.handleGetSettings.bind(this));
this.app.post('/api/settings', this.handleUpdateSettings.bind(this));
}
/**
* Start the worker service
*/
async start(): Promise<void> {
// Initialize database (once, stays open)
await this.dbManager.initialize();
// Cleanup orphaned sessions from previous runs
const cleaned = this.dbManager.cleanupOrphanedSessions();
if (cleaned > 0) {
logger.info('SYSTEM', `Cleaned ${cleaned} orphaned sessions`);
}
// Start HTTP server
const port = getWorkerPort();
this.server = await new Promise<http.Server>((resolve, reject) => {
const srv = this.app.listen(port, () => resolve(srv));
srv.on('error', reject);
});
logger.info('SYSTEM', 'Worker started', { port, pid: process.pid });
}
/**
* Shutdown the worker service
*/
async shutdown(): Promise<void> {
// Shutdown all active sessions
await this.sessionManager.shutdownAll();
// Close HTTP server
if (this.server) {
await new Promise<void>((resolve, reject) => {
this.server!.close(err => err ? reject(err) : resolve());
});
}
// Close database connection
await this.dbManager.close();
logger.info('SYSTEM', 'Worker shutdown complete');
}
// ============================================================================
// Route Handlers
// ============================================================================
/**
* Health check endpoint
*/
private handleHealth(req: Request, res: Response): void {
res.json({ status: 'ok', timestamp: Date.now() });
}
/**
* Serve viewer UI
*/
private handleViewerUI(req: Request, res: Response): void {
try {
const packageRoot = getPackageRoot();
const viewerPath = path.join(packageRoot, 'plugin', 'ui', 'viewer.html');
const html = readFileSync(viewerPath, 'utf-8');
res.setHeader('Content-Type', 'text/html');
res.send(html);
} catch (error) {
logger.failure('WORKER', 'Viewer UI error', {}, error as Error);
res.status(500).json({ error: 'Failed to load viewer UI' });
}
}
/**
* SSE stream endpoint
*/
private handleSSEStream(req: Request, res: Response): void {
// Setup SSE headers
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
// Add client to broadcaster
this.sseBroadcaster.addClient(res);
}
/**
* Initialize a new session
*/
private handleSessionInit(req: Request, res: Response): void {
try {
const sessionDbId = parseInt(req.params.sessionDbId, 10);
const session = this.sessionManager.initializeSession(sessionDbId);
// Start SDK agent in background
this.sdkAgent.startSession(session).catch(err => {
logger.failure('WORKER', 'SDK agent error', { sessionId: sessionDbId }, err);
});
// Broadcast SSE event
this.sseBroadcaster.broadcast({
type: 'session_started',
sessionDbId,
project: session.project
});
res.json({ status: 'initialized', sessionDbId, port: getWorkerPort() });
} catch (error) {
logger.failure('WORKER', 'Session init failed', {}, error as Error);
res.status(500).json({ error: (error as Error).message });
}
}
/**
* Queue observations for processing
*/
private handleObservations(req: Request, res: Response): void {
try {
const sessionDbId = parseInt(req.params.sessionDbId, 10);
const { tool_name, tool_input, tool_response, prompt_number } = req.body;
this.sessionManager.queueObservation(sessionDbId, {
tool_name,
tool_input,
tool_response,
prompt_number
});
// Broadcast SSE event
this.sseBroadcaster.broadcast({
type: 'observation_queued',
sessionDbId
});
res.json({ status: 'queued' });
} catch (error) {
logger.failure('WORKER', 'Observation queuing failed', {}, error as Error);
res.status(500).json({ error: (error as Error).message });
}
}
/**
* Queue summarize request
*/
private handleSummarize(req: Request, res: Response): void {
try {
const sessionDbId = parseInt(req.params.sessionDbId, 10);
this.sessionManager.queueSummarize(sessionDbId);
res.json({ status: 'queued' });
} catch (error) {
logger.failure('WORKER', 'Summarize queuing failed', {}, error as Error);
res.status(500).json({ error: (error as Error).message });
}
}
/**
* Get session status
*/
private handleSessionStatus(req: Request, res: Response): void {
try {
const sessionDbId = parseInt(req.params.sessionDbId, 10);
const session = this.sessionManager.getSession(sessionDbId);
if (!session) {
res.json({ status: 'not_found' });
return;
}
res.json({
status: 'active',
sessionDbId,
project: session.project,
queueLength: session.pendingMessages.length,
uptime: Date.now() - session.startTime
});
} catch (error) {
logger.failure('WORKER', 'Session status failed', {}, error as Error);
res.status(500).json({ error: (error as Error).message });
}
}
/**
* Delete a session
*/
private async handleSessionDelete(req: Request, res: Response): Promise<void> {
try {
const sessionDbId = parseInt(req.params.sessionDbId, 10);
await this.sessionManager.deleteSession(sessionDbId);
// Mark session complete in database
this.dbManager.markSessionComplete(sessionDbId);
// Broadcast SSE event
this.sseBroadcaster.broadcast({
type: 'session_completed',
sessionDbId
});
res.json({ status: 'deleted' });
} catch (error) {
logger.failure('WORKER', 'Session delete failed', {}, error as Error);
res.status(500).json({ error: (error as Error).message });
}
}
/**
* Complete a session (backward compatibility for cleanup-hook)
* cleanup-hook expects POST /sessions/:sessionDbId/complete instead of DELETE
*/
private async handleSessionComplete(req: Request, res: Response): Promise<void> {
try {
const sessionDbId = parseInt(req.params.sessionDbId, 10);
if (isNaN(sessionDbId)) {
res.status(400).json({ success: false, error: 'Invalid session ID' });
return;
}
await this.sessionManager.deleteSession(sessionDbId);
// Mark session complete in database
this.dbManager.markSessionComplete(sessionDbId);
// Broadcast SSE event
this.sseBroadcaster.broadcast({
type: 'session_completed',
timestamp: Date.now(),
sessionDbId
});
res.json({ success: true });
} catch (error) {
logger.failure('WORKER', 'Session complete failed', {}, error as Error);
res.status(500).json({ success: false, error: String(error) });
}
}
/**
* Get paginated observations
*/
private handleGetObservations(req: Request, res: Response): void {
try {
const { offset, limit, project } = parsePaginationParams(req);
const result = this.paginationHelper.getObservations(offset, limit, project);
res.json(result);
} catch (error) {
logger.failure('WORKER', 'Get observations failed', {}, error as Error);
res.status(500).json({ error: (error as Error).message });
}
}
/**
* Get paginated summaries
*/
private handleGetSummaries(req: Request, res: Response): void {
try {
const { offset, limit, project } = parsePaginationParams(req);
const result = this.paginationHelper.getSummaries(offset, limit, project);
res.json(result);
} catch (error) {
logger.failure('WORKER', 'Get summaries failed', {}, error as Error);
res.status(500).json({ error: (error as Error).message });
}
}
/**
* Get paginated user prompts
*/
private handleGetPrompts(req: Request, res: Response): void {
try {
const { offset, limit, project } = parsePaginationParams(req);
const result = this.paginationHelper.getPrompts(offset, limit, project);
res.json(result);
} catch (error) {
logger.failure('WORKER', 'Get prompts failed', {}, error as Error);
res.status(500).json({ error: (error as Error).message });
}
}
/**
* Get database statistics
*/
private handleGetStats(req: Request, res: Response): void {
try {
const db = this.dbManager.getSessionStore().db;
// Get total counts
const totalObservations = db.prepare('SELECT COUNT(*) as count FROM observations').get() as { count: number };
const totalSessions = db.prepare('SELECT COUNT(*) as count FROM sessions').get() as { count: number };
const totalPrompts = db.prepare('SELECT COUNT(*) as count FROM user_prompts').get() as { count: number };
const totalSummaries = db.prepare('SELECT COUNT(*) as count FROM summaries').get() as { count: number };
// Get project counts
const projectCounts: Record<string, any> = {};
const projects = db.prepare('SELECT DISTINCT project FROM observations').all() as Array<{ project: string }>;
for (const { project } of projects) {
const obsCount = db.prepare('SELECT COUNT(*) as count FROM observations WHERE project = ?').get(project) as { count: number };
const sessCount = db.prepare('SELECT COUNT(*) as count FROM sessions WHERE project = ?').get(project) as { count: number };
const promptCount = db.prepare('SELECT COUNT(*) as count FROM user_prompts WHERE project = ?').get(project) as { count: number };
const summCount = db.prepare('SELECT COUNT(*) as count FROM summaries WHERE project = ?').get(project) as { count: number };
projectCounts[project] = {
observations: obsCount.count,
sessions: sessCount.count,
prompts: promptCount.count,
summaries: summCount.count
};
}
res.json({
totalObservations: totalObservations.count,
totalSessions: totalSessions.count,
totalPrompts: totalPrompts.count,
totalSummaries: totalSummaries.count,
projectCounts
});
} catch (error) {
logger.failure('WORKER', 'Get stats failed', {}, error as Error);
res.status(500).json({ error: (error as Error).message });
}
}
/**
* Get viewer settings
*/
private handleGetSettings(req: Request, res: Response): void {
try {
const settings = this.settingsManager.getSettings();
res.json(settings);
} catch (error) {
logger.failure('WORKER', 'Get settings failed', {}, error as Error);
res.status(500).json({ error: (error as Error).message });
}
}
/**
* Update viewer settings
*/
private handleUpdateSettings(req: Request, res: Response): void {
try {
const updates = req.body;
const settings = this.settingsManager.updateSettings(updates);
res.json(settings);
} catch (error) {
logger.failure('WORKER', 'Update settings failed', {}, error as Error);
res.status(500).json({ error: (error as Error).message });
}
}
}
// ============================================================================
// Utilities
// ============================================================================
/**
* Parse pagination parameters from request
*/
function parsePaginationParams(req: Request): { offset: number; limit: number; project?: string } {
const offset = parseInt(req.query.offset as string, 10) || 0;
const limit = Math.min(parseInt(req.query.limit as string, 10) || 20, 100); // Max 100
const project = req.query.project as string | undefined;
return { offset, limit, project };
}
// ============================================================================
// Main Entry Point
// ============================================================================
/**
* Start the worker service (if running as main module)
*/
if (import.meta.url === `file://${process.argv[1]}`) {
const worker = new WorkerService();
// Graceful shutdown
process.on('SIGTERM', async () => {
logger.info('SYSTEM', 'Received SIGTERM, shutting down gracefully');
await worker.shutdown();
process.exit(0);
});
process.on('SIGINT', async () => {
logger.info('SYSTEM', 'Received SIGINT, shutting down gracefully');
await worker.shutdown();
process.exit(0);
});
// Start the worker
worker.start().catch(error => {
logger.failure('SYSTEM', 'Worker startup failed', {}, error);
process.exit(1);
});
}
export default WorkerService;
File diff suppressed because it is too large Load Diff
+18 -15
View File
@@ -81,15 +81,17 @@ export interface ViewerSettings {
export interface Observation {
id: number;
session_db_id: number;
claude_session_id: string;
sdk_session_id: string;
project: string;
type: string;
title: string;
subtitle: string | null;
text: string;
text: string | null;
narrative: string | null;
facts: string | null;
concepts: string | null;
files: string | null;
files_read: string | null;
files_modified: string | null;
prompt_number: number;
created_at: string;
created_at_epoch: number;
@@ -97,13 +99,13 @@ export interface Observation {
export interface Summary {
id: number;
session_db_id: number;
claude_session_id: string;
session_id: string; // claude_session_id (from JOIN)
project: string;
request: string | null;
completion: string | null;
summary: string;
learnings: string | null;
investigated: string | null;
learned: string | null;
completed: string | null;
next_steps: string | null;
notes: string | null;
created_at: string;
created_at_epoch: number;
@@ -111,10 +113,10 @@ export interface Summary {
export interface UserPrompt {
id: number;
session_db_id: number;
claude_session_id: string;
project: string;
prompt: string;
project: string; // From JOIN with sdk_sessions
prompt_number: number;
prompt_text: string;
created_at: string;
created_at_epoch: number;
}
@@ -150,9 +152,10 @@ export interface ParsedObservation {
export interface ParsedSummary {
request: string | null;
completion: string | null;
summary: string;
learnings: string | null;
investigated: string | null;
learned: string | null;
completed: string | null;
next_steps: string | null;
notes: string | null;
}
+3 -7
View File
@@ -81,13 +81,9 @@ export class DatabaseManager {
return this.chromaSync;
}
/**
* Cleanup orphaned sessions from previous runs
* @returns Number of sessions cleaned
*/
cleanupOrphanedSessions(): number {
return this.getSessionStore().cleanupOrphanedSessions();
}
// REMOVED: cleanupOrphanedSessions - violates "EVERYTHING SHOULD SAVE ALWAYS"
// Worker restarts don't make sessions orphaned. Sessions are managed by hooks
// and exist independently of worker state.
/**
* Get session by ID (throws if not found)
+118 -14
View File
@@ -17,43 +17,147 @@ export class PaginationHelper {
this.dbManager = dbManager;
}
/**
* Strip project path from file paths using heuristic
* Converts "/Users/user/project/src/file.ts" -> "src/file.ts"
* Uses first occurrence of project name from left (project root)
*/
private stripProjectPath(filePath: string, projectName: string): string {
const marker = `/${projectName}/`;
const index = filePath.indexOf(marker);
if (index !== -1) {
// Strip everything before and including the project name
return filePath.substring(index + marker.length);
}
// Fallback: return original path if project name not found
return filePath;
}
/**
* Strip project path from JSON array of file paths
*/
private stripProjectPaths(filePathsStr: string | null, projectName: string): string | null {
if (!filePathsStr) return filePathsStr;
try {
// Parse JSON array
const paths = JSON.parse(filePathsStr) as string[];
// Strip project path from each file
const strippedPaths = paths.map(p => this.stripProjectPath(p, projectName));
// Return as JSON string
return JSON.stringify(strippedPaths);
} catch (error) {
// If parsing fails, return original string
return filePathsStr;
}
}
/**
* Sanitize observation by stripping project paths from files
*/
private sanitizeObservation(obs: Observation): Observation {
return {
...obs,
files_read: this.stripProjectPaths(obs.files_read, obs.project),
files_modified: this.stripProjectPaths(obs.files_modified, obs.project)
};
}
/**
* Get paginated observations
*/
getObservations(offset: number, limit: number, project?: string): PaginatedResult<Observation> {
return this.paginate<Observation>(
const result = this.paginate<Observation>(
'observations',
'id, session_db_id, claude_session_id, project, type, title, subtitle, text, concepts, files, prompt_number, created_at, created_at_epoch',
'id, sdk_session_id, project, type, title, subtitle, narrative, text, facts, concepts, files_read, files_modified, prompt_number, created_at, created_at_epoch',
offset,
limit,
project
);
// Strip project paths from file paths before returning
return {
...result,
items: result.items.map(obs => this.sanitizeObservation(obs))
};
}
/**
* Get paginated summaries
*/
getSummaries(offset: number, limit: number, project?: string): PaginatedResult<Summary> {
return this.paginate<Summary>(
'summaries',
'id, session_db_id, claude_session_id, project, request, completion, summary, learnings, notes, created_at, created_at_epoch',
const db = this.dbManager.getSessionStore().db;
let query = `
SELECT
ss.id,
s.claude_session_id as session_id,
ss.request,
ss.investigated,
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
`;
const params: any[] = [];
if (project) {
query += ' WHERE ss.project = ?';
params.push(project);
}
query += ' ORDER BY ss.created_at_epoch DESC LIMIT ? OFFSET ?';
params.push(limit + 1, offset);
const stmt = db.prepare(query);
const results = stmt.all(...params) as Summary[];
return {
items: results.slice(0, limit),
hasMore: results.length > limit,
offset,
limit,
project
);
limit
};
}
/**
* Get paginated user prompts
*/
getPrompts(offset: number, limit: number, project?: string): PaginatedResult<UserPrompt> {
return this.paginate<UserPrompt>(
'user_prompts',
'id, session_db_id, claude_session_id, project, prompt, created_at, created_at_epoch',
const db = this.dbManager.getSessionStore().db;
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
`;
const params: any[] = [];
if (project) {
query += ' WHERE s.project = ?';
params.push(project);
}
query += ' ORDER BY up.created_at_epoch DESC LIMIT ? OFFSET ?';
params.push(limit + 1, offset);
const stmt = db.prepare(query);
const results = stmt.all(...params) as UserPrompt[];
return {
items: results.slice(0, limit),
hasMore: results.length > limit,
offset,
limit,
project
);
limit
};
}
/**
+53 -3
View File
@@ -34,8 +34,9 @@ export class SDKAgent {
/**
* Start SDK agent for a session (event-driven, no polling)
* @param worker WorkerService reference for spinner control (optional)
*/
async startSession(session: ActiveSession): Promise<void> {
async startSession(session: ActiveSession, worker?: any): Promise<void> {
try {
// Find Claude executable
const claudePath = this.findClaudeExecutable();
@@ -74,7 +75,7 @@ export class SDKAgent {
});
// Parse and process response
await this.processSDKResponse(session, textContent);
await this.processSDKResponse(session, textContent, worker);
}
// Log result messages
@@ -168,7 +169,7 @@ export class SDKAgent {
/**
* Process SDK response text (parse XML, save to database, sync to Chroma)
*/
private async processSDKResponse(session: ActiveSession, text: string): Promise<void> {
private async processSDKResponse(session: ActiveSession, text: string, worker?: any): Promise<void> {
// Parse observations
const observations = parseObservations(text, session.claudeSessionId);
@@ -191,6 +192,30 @@ export class SDKAgent {
createdAtEpoch
).catch(() => {});
// Broadcast to SSE clients (for web UI)
if (worker && worker.sseBroadcaster) {
worker.sseBroadcaster.broadcast({
type: 'new_observation',
observation: {
id: obsId,
sdk_session_id: session.sdkSessionId,
session_id: session.claudeSessionId,
type: obs.type,
title: obs.title,
subtitle: obs.subtitle,
text: obs.text || null,
narrative: obs.narrative || null,
facts: JSON.stringify(obs.facts || []),
concepts: JSON.stringify(obs.concepts || []),
files_read: JSON.stringify(obs.files || []),
files_modified: JSON.stringify([]),
project: session.project,
prompt_number: session.lastPromptNumber,
created_at_epoch: createdAtEpoch
}
});
}
logger.info('SDK', 'Observation saved', { obsId, type: obs.type });
}
@@ -216,8 +241,33 @@ export class SDKAgent {
createdAtEpoch
).catch(() => {});
// Broadcast to SSE clients (for web UI)
if (worker && worker.sseBroadcaster) {
worker.sseBroadcaster.broadcast({
type: 'new_summary',
summary: {
id: summaryId,
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: session.lastPromptNumber,
created_at_epoch: createdAtEpoch
}
});
}
logger.info('SDK', 'Summary saved', { summaryId });
}
// Check and stop spinner after processing (debounced)
if (worker && typeof worker.checkAndStopSpinner === 'function') {
worker.checkAndStopSpinner();
}
}
// ============================================================================
+3
View File
@@ -44,12 +44,15 @@ export class SSEBroadcaster {
*/
broadcast(event: SSEEvent): void {
if (this.sseClients.size === 0) {
logger.debug('WORKER', 'SSE broadcast skipped (no clients)', { eventType: event.type });
return; // Short-circuit if no clients
}
const eventWithTimestamp = { ...event, timestamp: Date.now() };
const data = `data: ${JSON.stringify(eventWithTimestamp)}\n\n`;
logger.debug('WORKER', 'SSE broadcast sent', { eventType: event.type, clients: this.sseClients.size });
// Single-pass write + cleanup
for (const client of this.sseClients) {
try {
+28 -6
View File
@@ -69,11 +69,13 @@ export class SessionManager {
/**
* Queue an observation for processing (zero-latency notification)
* Auto-initializes session if not in memory but exists in database
*/
queueObservation(sessionDbId: number, data: ObservationData): void {
const session = this.sessions.get(sessionDbId);
// Auto-initialize from database if needed (handles worker restarts)
let session = this.sessions.get(sessionDbId);
if (!session) {
throw new Error(`Session ${sessionDbId} not active`);
session = this.initializeSession(sessionDbId);
}
session.pendingMessages.push({
@@ -96,11 +98,13 @@ export class SessionManager {
/**
* Queue a summarize request (zero-latency notification)
* Auto-initializes session if not in memory but exists in database
*/
queueSummarize(sessionDbId: number): void {
const session = this.sessions.get(sessionDbId);
// Auto-initialize from database if needed (handles worker restarts)
let session = this.sessions.get(sessionDbId);
if (!session) {
throw new Error(`Session ${sessionDbId} not active`);
session = this.initializeSession(sessionDbId);
}
session.pendingMessages.push({ type: 'summarize' });
@@ -143,13 +147,31 @@ export class SessionManager {
await Promise.all(sessionIds.map(id => this.deleteSession(id)));
}
/**
* Check if any session has pending messages (for spinner tracking)
*/
hasPendingMessages(): boolean {
return Array.from(this.sessions.values()).some(
session => session.pendingMessages.length > 0
);
}
/**
* Get number of active sessions (for stats)
*/
getActiveSessionCount(): number {
return this.sessions.size;
}
/**
* Get message iterator for SDKAgent to consume (event-driven, no polling)
* Auto-initializes session if not in memory but exists in database
*/
async *getMessageIterator(sessionDbId: number): AsyncIterableIterator<PendingMessage> {
const session = this.sessions.get(sessionDbId);
// Auto-initialize from database if needed (handles worker restarts)
let session = this.sessions.get(sessionDbId);
if (!session) {
throw new Error(`Session ${sessionDbId} not active`);
session = this.initializeSession(sessionDbId);
}
const emitter = this.sessionQueues.get(sessionDbId);
+180 -10
View File
@@ -482,7 +482,7 @@
.card {
margin-bottom: 24px;
padding: 20px 24px;
padding: 24px;
background: var(--color-bg-card);
border: 1px solid var(--color-border-primary);
border-radius: 8px;
@@ -510,13 +510,19 @@
.card-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
justify-content: space-between;
margin-bottom: 14px;
font-size: 12px;
color: var(--color-text-muted);
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
}
.card-header-left {
display: flex;
align-items: center;
gap: 10px;
}
.card-type {
padding: 2px 8px;
background: var(--color-type-badge-bg);
@@ -530,25 +536,145 @@
.card-title {
font-size: 17px;
margin-bottom: 8px;
margin-bottom: 14px;
color: var(--color-text-title);
font-weight: 600;
line-height: 1.4;
letter-spacing: -0.01em;
}
.view-mode-toggles {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.view-mode-toggle {
display: flex;
align-items: center;
gap: 4px;
background: var(--color-bg-tertiary);
border: 1px solid var(--color-border-primary);
padding: 4px 8px;
border-radius: 4px;
cursor: pointer;
color: var(--color-text-secondary);
transition: all 0.15s ease;
font-size: 11px;
font-weight: 500;
text-transform: lowercase;
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
}
.view-mode-toggle svg {
flex-shrink: 0;
opacity: 0.7;
transition: opacity 0.15s ease;
}
.view-mode-toggle:hover {
background: var(--color-bg-card-hover);
border-color: var(--color-border-hover);
color: var(--color-text-primary);
}
.view-mode-toggle:hover svg {
opacity: 1;
}
.view-mode-toggle.active {
background: var(--color-accent-primary);
border-color: var(--color-accent-primary);
color: var(--color-text-button);
}
.view-mode-toggle.active svg {
opacity: 1;
}
.view-mode-content {
margin-bottom: 12px;
}
.view-mode-content .card-subtitle {
margin-bottom: 0;
}
.view-mode-content .facts-list {
list-style: disc;
margin: 0;
padding-left: 20px;
color: var(--color-text-secondary);
font-size: 13px;
line-height: 1.7;
}
.view-mode-content .facts-list li {
margin-bottom: 6px;
}
.view-mode-content .narrative {
max-height: 300px;
overflow-y: auto;
white-space: pre-wrap;
word-wrap: break-word;
color: var(--color-text-secondary);
font-size: 13px;
line-height: 1.7;
}
.card-subtitle {
font-size: 14px;
color: var(--color-text-subtitle);
margin-bottom: 8px;
line-height: 1.6;
line-height: 1.7;
margin-bottom: 10px;
}
.card-subtitle:last-child {
margin-bottom: 0;
}
.card-meta {
font-size: 12px;
font-size: 11px;
color: var(--color-text-tertiary);
margin-top: 8px;
margin-top: 18px;
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
display: flex;
flex-wrap: wrap;
gap: 6px;
line-height: 1.5;
}
.meta-date {
color: var(--color-text-tertiary);
}
.meta-concepts {
font-style: italic;
color: var(--color-text-muted);
}
.meta-files {
color: var(--color-text-muted);
font-size: 10px;
}
.meta-files .file-label {
font-weight: 500;
color: var(--color-text-tertiary);
}
/* Stack single column on narrow screens (removed - no longer using card-files) */
@media (max-width: 600px) {
}
/* Project badge styling */
.card-project {
color: var(--color-text-muted);
}
.summary-card {
@@ -672,8 +798,9 @@
}
.card-content {
margin-top: 12px;
line-height: 1.6;
margin-top: 14px;
margin-bottom: 12px;
line-height: 1.7;
color: var(--color-text-primary);
white-space: pre-wrap;
word-wrap: break-word;
@@ -744,6 +871,49 @@
background-position: -200% 0;
}
}
/* Scroll to top button */
.scroll-to-top {
position: fixed;
bottom: 24px;
right: 24px;
width: 48px;
height: 48px;
background: var(--color-bg-button);
color: var(--color-text-button);
border: none;
border-radius: 24px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
transition: all 0.2s ease;
z-index: 50;
animation: fadeInUp 0.3s ease-out;
}
.scroll-to-top:hover {
background: var(--color-bg-button-hover);
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
}
.scroll-to-top:active {
background: var(--color-bg-button-active);
transform: translateY(0);
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>
</head>
+29 -23
View File
@@ -23,28 +23,30 @@ export function App() {
const { preference, resolvedTheme, setThemePreference } = useTheme();
const pagination = usePagination(currentFilter);
// Reset paginated data when filter changes
useEffect(() => {
setPaginatedObservations([]);
setPaginatedSummaries([]);
setPaginatedPrompts([]);
}, [currentFilter]);
// When filtering by project: ONLY use paginated data (API-filtered)
// When showing all projects: merge SSE live data with paginated data
const allObservations = useMemo(() => {
if (currentFilter) {
// Project filter active: API handles filtering, ignore SSE items
return paginatedObservations;
}
// No filter: merge SSE + paginated, deduplicate by ID
return mergeAndDeduplicateByProject(observations, paginatedObservations);
}, [observations, paginatedObservations, 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(() => {
if (currentFilter) {
return paginatedSummaries;
}
return mergeAndDeduplicateByProject(summaries, paginatedSummaries);
}, [summaries, paginatedSummaries, currentFilter]);
const allSummaries = useMemo(
() => mergeAndDeduplicateByProject(summaries, paginatedSummaries, currentFilter),
[summaries, paginatedSummaries, currentFilter]
);
const allPrompts = useMemo(
() => mergeAndDeduplicateByProject(prompts, paginatedPrompts, currentFilter),
[prompts, paginatedPrompts, currentFilter]
);
const allPrompts = useMemo(() => {
if (currentFilter) {
return paginatedPrompts;
}
return mergeAndDeduplicateByProject(prompts, paginatedPrompts);
}, [prompts, paginatedPrompts, currentFilter]);
// Toggle sidebar
const toggleSidebar = useCallback(() => {
@@ -72,12 +74,16 @@ export function App() {
} catch (error) {
console.error('Failed to load more data:', error);
}
}, [pagination]);
}, [currentFilter, pagination.observations, pagination.summaries, pagination.prompts]);
// Load first page when filter changes or pagination handlers update
// Reset paginated data and load first page when filter changes
useEffect(() => {
setPaginatedObservations([]);
setPaginatedSummaries([]);
setPaginatedPrompts([]);
handleLoadMore();
}, [currentFilter, handleLoadMore]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentFilter]);
return (
<div className="container">
+4 -1
View File
@@ -3,6 +3,7 @@ import { Observation, Summary, UserPrompt, FeedItem } from '../types';
import { ObservationCard } from './ObservationCard';
import { SummaryCard } from './SummaryCard';
import { PromptCard } from './PromptCard';
import { ScrollToTop } from './ScrollToTop';
import { UI } from '../constants/ui';
interface FeedProps {
@@ -16,6 +17,7 @@ interface FeedProps {
export function Feed({ observations, summaries, prompts, onLoadMore, isLoading, hasMore }: FeedProps) {
const loadMoreRef = useRef<HTMLDivElement>(null);
const feedRef = useRef<HTMLDivElement>(null);
const onLoadMoreRef = useRef(onLoadMore);
// Keep the callback ref up to date
@@ -59,7 +61,8 @@ export function Feed({ observations, summaries, prompts, onLoadMore, isLoading,
}, [observations, summaries, prompts]);
return (
<div className="feed">
<div className="feed" ref={feedRef}>
<ScrollToTop targetRef={feedRef} />
<div className="feed-content">
{items.map(item => {
const key = `${item.itemType}-${item.id}`;
+129 -7
View File
@@ -1,4 +1,4 @@
import React from 'react';
import React, { useState } from 'react';
import { Observation } from '../types';
import { formatDate } from '../utils/formatters';
@@ -6,20 +6,142 @@ interface ObservationCardProps {
observation: Observation;
}
// Helper to strip project root from file paths
function stripProjectRoot(filePath: string): string {
// Try to extract relative path by finding common project markers
const markers = ['/Scripts/', '/src/', '/plugin/', '/docs/'];
for (const marker of markers) {
const index = filePath.indexOf(marker);
if (index !== -1) {
// Keep the marker and everything after it
return filePath.substring(index + 1);
}
}
// Fallback: if path contains project name, strip everything before it
const projectIndex = filePath.indexOf('claude-mem/');
if (projectIndex !== -1) {
return filePath.substring(projectIndex + 'claude-mem/'.length);
}
// If no markers found, return basename or original path
const parts = filePath.split('/');
return parts.length > 3 ? parts.slice(-3).join('/') : filePath;
}
export function ObservationCard({ observation }: ObservationCardProps) {
const [showFacts, setShowFacts] = useState(false);
const [showNarrative, setShowNarrative] = useState(false);
const date = formatDate(observation.created_at_epoch);
// Parse JSON fields
const facts = observation.facts ? JSON.parse(observation.facts) : [];
const concepts = observation.concepts ? JSON.parse(observation.concepts) : [];
const filesRead = observation.files_read ? JSON.parse(observation.files_read).map(stripProjectRoot) : [];
const filesModified = observation.files_modified ? JSON.parse(observation.files_modified).map(stripProjectRoot) : [];
// Show facts toggle if there are facts, concepts, or files
const hasFactsContent = facts.length > 0 || concepts.length > 0 || filesRead.length > 0 || filesModified.length > 0;
return (
<div className="card">
{/* Header with toggle buttons in top right */}
<div className="card-header">
<span className="card-type">{observation.type}</span>
<span>{observation.project}</span>
<div className="card-header-left">
<span className={`card-type type-${observation.type}`}>
{observation.type}
</span>
<span className="card-project">{observation.project}</span>
</div>
<div className="view-mode-toggles">
{hasFactsContent && (
<button
className={`view-mode-toggle ${showFacts ? 'active' : ''}`}
onClick={() => {
setShowFacts(!showFacts);
if (!showFacts) setShowNarrative(false); // Turn off narrative when turning on facts
}}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 11 12 14 22 4"></polyline>
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path>
</svg>
<span>facts</span>
</button>
)}
{observation.narrative && (
<button
className={`view-mode-toggle ${showNarrative ? 'active' : ''}`}
onClick={() => {
setShowNarrative(!showNarrative);
if (!showNarrative) setShowFacts(false); // Turn off facts when turning on narrative
}}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
<line x1="16" y1="13" x2="8" y2="13"></line>
<line x1="16" y1="17" x2="8" y2="17"></line>
</svg>
<span>narrative</span>
</button>
)}
</div>
</div>
{/* Title */}
<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>
{/* Content based on toggle state */}
<div className="view-mode-content">
{!showFacts && !showNarrative && observation.subtitle && (
<div className="card-subtitle">{observation.subtitle}</div>
)}
{showFacts && facts.length > 0 && (
<ul className="facts-list">
{facts.map((fact: string, i: number) => (
<li key={i}>{fact}</li>
))}
</ul>
)}
{showNarrative && observation.narrative && (
<div className="narrative">
{observation.narrative}
</div>
)}
</div>
{/* Metadata footer - id, date, and conditionally concepts/files when facts toggle is on */}
<div className="card-meta">
<span className="meta-date">#{observation.id} {date}</span>
{showFacts && (concepts.length > 0 || filesRead.length > 0 || filesModified.length > 0) && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'center' }}>
{concepts.map((concept: string, i: number) => (
<span key={i} style={{
padding: '2px 8px',
background: 'var(--color-type-badge-bg)',
color: 'var(--color-type-badge-text)',
borderRadius: '3px',
fontWeight: '500',
fontSize: '10px'
}}>
{concept}
</span>
))}
{filesRead.length > 0 && (
<span className="meta-files">
<span className="file-label">read:</span> {filesRead.join(', ')}
</span>
)}
{filesModified.length > 0 && (
<span className="meta-files">
<span className="file-label">modified:</span> {filesModified.join(', ')}
</span>
)}
</div>
)}
</div>
</div>
);
}
+7 -3
View File
@@ -7,17 +7,21 @@ interface PromptCardProps {
}
export function PromptCard({ prompt }: PromptCardProps) {
const date = formatDate(prompt.created_at_epoch);
return (
<div className="card prompt-card">
<div className="card-header">
<span className="card-type">Prompt</span>
<span>{prompt.project}</span>
<div className="card-header-left">
<span className="card-type">Prompt</span>
<span className="card-project">{prompt.project}</span>
</div>
</div>
<div className="card-content">
{prompt.prompt_text}
</div>
<div className="card-meta">
{formatDate(prompt.created_at_epoch)}
<span className="meta-date">#{prompt.id} {date}</span>
</div>
</div>
);
+57
View File
@@ -0,0 +1,57 @@
import React, { useState, useEffect } from 'react';
interface ScrollToTopProps {
targetRef: React.RefObject<HTMLDivElement>;
}
export function ScrollToTop({ targetRef }: ScrollToTopProps) {
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const handleScroll = () => {
const target = targetRef.current;
if (target) {
setIsVisible(target.scrollTop > 300);
}
};
const target = targetRef.current;
if (target) {
target.addEventListener('scroll', handleScroll);
return () => target.removeEventListener('scroll', handleScroll);
}
}, []); // Empty deps - only set up listener once on mount
const scrollToTop = () => {
const target = targetRef.current;
if (target) {
target.scrollTo({
top: 0,
behavior: 'smooth'
});
}
};
if (!isVisible) return null;
return (
<button
onClick={scrollToTop}
className="scroll-to-top"
aria-label="Scroll to top"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="18 15 12 9 6 15"></polyline>
</svg>
</button>
);
}
+10 -3
View File
@@ -12,12 +12,17 @@ export function SummaryCard({ summary }: SummaryCardProps) {
return (
<div className="card summary-card">
<div className="card-header">
<span className="card-type">SUMMARY</span>
<span>{summary.project}</span>
<div className="card-header-left">
<span className="card-type">SUMMARY</span>
<span className="card-project">{summary.project}</span>
</div>
</div>
{summary.request && (
<div className="card-title">Request: {summary.request}</div>
)}
{summary.investigated && (
<div className="card-subtitle">Investigated: {summary.investigated}</div>
)}
{summary.learned && (
<div className="card-subtitle">Learned: {summary.learned}</div>
)}
@@ -27,7 +32,9 @@ export function SummaryCard({ summary }: SummaryCardProps) {
{summary.next_steps && (
<div className="card-subtitle">Next: {summary.next_steps}</div>
)}
<div className="card-meta">#{summary.id} {date}</div>
<div className="card-meta">
<span className="meta-date">#{summary.id} {date}</span>
</div>
</div>
);
}
+30 -18
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useCallback, useRef } from 'react';
import { Observation, Summary, UserPrompt } from '../types';
import { UI } from '../constants/ui';
import { API_ENDPOINTS } from '../constants/api';
@@ -19,32 +19,42 @@ function usePaginationFor(endpoint: string, dataType: DataType, currentFilter: s
isLoading: false,
hasMore: true
});
const [offset, setOffset] = useState(0);
// Reset pagination when filter changes
useEffect(() => {
setOffset(0);
setState({
isLoading: false,
hasMore: true
});
}, [currentFilter]);
// Track offset and filter in refs to handle synchronous resets
const offsetRef = useRef(0);
const lastFilterRef = useRef(currentFilter);
const stateRef = useRef(state);
/**
* Load more items from the API
* Automatically resets offset to 0 if filter has changed
*/
const loadMore = useCallback(async (): Promise<DataItem[]> => {
// Prevent concurrent requests using state
if (state.isLoading || !state.hasMore) {
// Check if filter changed - if so, reset pagination synchronously
const filterChanged = lastFilterRef.current !== currentFilter;
if (filterChanged) {
offsetRef.current = 0;
lastFilterRef.current = currentFilter;
// Reset state both in React state and ref synchronously
const newState = { isLoading: false, hasMore: true };
setState(newState);
stateRef.current = newState; // Update ref immediately to avoid stale checks
}
// Prevent concurrent requests using ref (always current)
// Skip this check if we just reset the filter - we want to load the first page
if (!filterChanged && (stateRef.current.isLoading || !stateRef.current.hasMore)) {
return [];
}
setState(prev => ({ ...prev, isLoading: true }));
try {
// Build query params
// Build query params using current offset from ref
const params = new URLSearchParams({
offset: offset.toString(),
offset: offsetRef.current.toString(),
limit: UI.PAGINATION_PAGE_SIZE.toString()
});
@@ -59,7 +69,7 @@ function usePaginationFor(endpoint: string, dataType: DataType, currentFilter: s
throw new Error(`Failed to load ${dataType}: ${response.statusText}`);
}
const data = await response.json();
const data = await response.json() as { items: DataItem[], hasMore: boolean };
setState(prev => ({
...prev,
@@ -67,14 +77,16 @@ function usePaginationFor(endpoint: string, dataType: DataType, currentFilter: s
hasMore: data.hasMore
}));
setOffset(prev => prev + UI.PAGINATION_PAGE_SIZE);
return data[dataType] as DataItem[];
// Increment offset after successful load
offsetRef.current += UI.PAGINATION_PAGE_SIZE;
return data.items;
} catch (error) {
console.error(`Failed to load ${dataType}:`, error);
setState(prev => ({ ...prev, isLoading: false }));
return [];
}
}, [offset, state.hasMore, state.isLoading, currentFilter, endpoint, dataType]);
}, [currentFilter, endpoint, dataType]);
return {
...state,
-8
View File
@@ -13,14 +13,6 @@ export function useSSE() {
const eventSourceRef = useRef<EventSource | null>(null);
const reconnectTimeoutRef = useRef<NodeJS.Timeout>();
// Fetch initial processing status on mount
useEffect(() => {
fetch(API_ENDPOINTS.PROCESSING_STATUS)
.then(res => res.json())
.then(data => setIsProcessing(data.isProcessing))
.catch(err => console.error('[SSE] Failed to fetch initial processing status:', err));
}, []);
useEffect(() => {
const connect = () => {
// Clean up existing connection
+12 -4
View File
@@ -1,11 +1,18 @@
export interface Observation {
id: number;
session_id: string;
sdk_session_id: string;
project: string;
type: string;
title: string;
subtitle?: string;
content?: string;
title: string | null;
subtitle: string | null;
narrative: string | null;
text: string | null;
facts: string | null;
concepts: string | null;
files_read: string | null;
files_modified: string | null;
prompt_number: number | null;
created_at: string;
created_at_epoch: number;
}
@@ -14,6 +21,7 @@ export interface Summary {
session_id: string;
project: string;
request?: string;
investigated?: string;
learned?: string;
completed?: string;
next_steps?: string;
+9 -13
View File
@@ -4,25 +4,21 @@
*/
/**
* 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)
* Merge real-time SSE items with paginated items, removing duplicates by ID
* NOTE: This should ONLY be used when no project filter is active.
* When filtering, use ONLY paginated data (API-filtered).
*
* @param liveItems - Items from SSE stream (unfiltered)
* @param paginatedItems - Items from pagination API
* @returns Merged and deduplicated array
*/
export function mergeAndDeduplicateByProject<T extends { id: number; project?: string }>(
liveItems: T[],
paginatedItems: T[],
projectFilter: string
paginatedItems: T[]
): T[] {
// Filter SSE items by current project (pagination is already filtered)
const filteredLive = projectFilter
? liveItems.filter(item => item.project === projectFilter)
: liveItems;
// Deduplicate using Set
// Deduplicate by ID
const seen = new Set<number>();
return [...filteredLive, ...paginatedItems].filter(item => {
return [...liveItems, ...paginatedItems].filter(item => {
if (seen.has(item.id)) return false;
seen.add(item.id);
return true;