Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 831cb6a2fc | |||
| 2cf176e8c9 | |||
| 23358e2c6d | |||
| 12fdb43ce4 | |||
| 2ec9e58607 | |||
| 339e452bc0 | |||
| c71248f3a1 | |||
| 5f34cae636 | |||
| 356e3acae3 | |||
| b7d0664868 | |||
| 501e929138 | |||
| 4aab8362e1 | |||
| 6c60ff7bcf | |||
| dada4e52c2 | |||
| 59b7d15301 | |||
| 98b8d72ca8 | |||
| d8912a7bba | |||
| 6f6cdf221b | |||
| 181447ee6a | |||
| bfe45ae75e | |||
| 6c6a92f201 | |||
| f28f2d691a | |||
| f9b43ac47b | |||
| ea02eb8354 | |||
| 1fc1419edd | |||
| 69cd734b53 | |||
| f38e78bdd5 | |||
| 610be468e4 | |||
| fe95b0edda | |||
| 306f636534 | |||
| 49f8bf0f64 | |||
| d616307781 | |||
| 84a23450c8 | |||
| def01790ea | |||
| 2a60794485 | |||
| d6041ae4ec | |||
| 77ceca7a6e | |||
| 85bd88f110 | |||
| 90360db9fc | |||
| a7a7187b83 | |||
| b0f47a840f | |||
| b02ff4c0a5 | |||
| b7c6649d9e | |||
| 8326fa59df | |||
| e3186ede5d | |||
| 86d0d1a21a | |||
| 3f8beaa10d | |||
| 3d31c6e46d |
@@ -10,7 +10,7 @@
|
||||
"plugins": [
|
||||
{
|
||||
"name": "claude-mem",
|
||||
"version": "8.2.0",
|
||||
"version": "8.2.4",
|
||||
"source": "./plugin",
|
||||
"description": "Persistent memory system for Claude Code - context compression across sessions"
|
||||
}
|
||||
|
||||
+230
-91
@@ -4,116 +4,255 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
## [8.1.0] - 2025-12-25
|
||||
|
||||
## Summary
|
||||
|
||||
This minor release brings significant architectural improvements focused on **explicit user control**, **simplified session management**, and **enhanced worker reliability**. The automatic recovery system has been replaced with a manual recovery approach, giving users complete control over when observations are reprocessed.
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
**Manual Recovery Replaces Automatic Recovery**
|
||||
- Worker no longer automatically reprocesses stuck observations on startup
|
||||
- Users must explicitly trigger recovery via CLI tool or HTTP API
|
||||
- Prevents unexpected duplicate observations and processing
|
||||
- See the new Manual Recovery documentation for migration guide
|
||||
|
||||
**Removed Cleanup Hook**
|
||||
- `cleanup-hook.ts` and `plugin/scripts/cleanup-hook.js` have been removed
|
||||
- Hook behavior was moved to session completion handler
|
||||
|
||||
**Hook Timeout Increased**
|
||||
- Default hook timeout changed from 5000ms to 120000ms
|
||||
- Accommodates longer-running operations during startup
|
||||
|
||||
## New Features
|
||||
|
||||
**Queue Management API**
|
||||
- `GET /api/pending-queue` - View processing queue status, stuck messages, and session work
|
||||
- `POST /api/pending-queue/process` - Manually trigger recovery with session limits
|
||||
- Detailed queue statistics including stuck detection (>5 minute threshold)
|
||||
|
||||
**CLI Recovery Tool**
|
||||
- `bun scripts/check-pending-queue.ts` - Interactive queue inspection and recovery
|
||||
- `--process` flag for non-interactive mode
|
||||
- `--limit N` to control sessions processed per batch
|
||||
- npm scripts: `npm run queue:check` and `npm run queue:process`
|
||||
|
||||
**Data Routes API**
|
||||
- New DataRoutes module for queue management endpoints
|
||||
- Session-aware pending work tracking
|
||||
## [8.2.3] - 2025-12-27
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
**Observation Timestamps Fixed**
|
||||
- Corrected timestamp handling throughout the observation lifecycle
|
||||
- Fixed `created_at_epoch` preservation in database operations
|
||||
- Fix worker port environment variable in smart-install script
|
||||
- Implement file-based locking mechanism for worker operations to prevent race conditions
|
||||
- Fix restart command references in documentation (changed from `claude-mem restart` to `npm run worker:restart`)
|
||||
|
||||
**Enhanced Worker Reliability**
|
||||
- Added error handlers to Chroma sync operations (prevents crashes on timeout)
|
||||
- Version mismatch now logs warning instead of force-restarting worker
|
||||
- Improved polling mechanism with increased retries and reduced interval
|
||||
## [8.2.2] - 2025-12-27
|
||||
|
||||
## Refactoring
|
||||
## What's Changed
|
||||
|
||||
**Simplified Session Management**
|
||||
- Removed 279 lines of complexity from SessionStore
|
||||
- `createSDKSession` simplified to pure `INSERT OR IGNORE`
|
||||
- Removed auto-create logic from `storeObservation` and `storeSummary`
|
||||
- Deleted 11 unused session management methods
|
||||
- `prompt_number` now derived from `user_prompts` count
|
||||
### Features
|
||||
- Add OpenRouter provider settings and documentation
|
||||
- Add modal footer with save button and status indicators
|
||||
- Implement self-spawn pattern for background worker execution
|
||||
|
||||
**Simplified Worker Utils**
|
||||
- Removed 117 lines of legacy code
|
||||
- Removed PM2 cleanup logic
|
||||
- Streamlined `ensureWorkerRunning` function
|
||||
### Bug Fixes
|
||||
- Resolve critical error handling issues in worker lifecycle
|
||||
- Handle Windows/Unix kill errors in orphaned process cleanup
|
||||
- Validate spawn pid before writing PID file
|
||||
- Handle process exit in waitForProcessesExit filter
|
||||
- Use readiness endpoint for health checks instead of port check
|
||||
- Add missing OpenRouter and Gemini settings to settingKeys array
|
||||
|
||||
**SDK Agent Improvements**
|
||||
- Removed complex session creation retry logic
|
||||
- Cleaner prompt number retrieval from SessionRoutes
|
||||
### Other Changes
|
||||
- Enhance error handling and validation in agents and routes
|
||||
- Delete obsolete process management files (ProcessManager, worker-wrapper, worker-cli)
|
||||
- Update hooks.json to use worker-service.cjs CLI
|
||||
- Add comprehensive tests for hook constants and worker spawn functionality
|
||||
|
||||
## Documentation
|
||||
## [8.2.1] - 2025-12-27
|
||||
|
||||
**New Manual Recovery Guide** (`docs/public/usage/manual-recovery.mdx`)
|
||||
- Complete 450-line guide for recovery workflows
|
||||
- Interactive CLI usage examples
|
||||
- HTTP API integration examples
|
||||
- Troubleshooting stuck messages
|
||||
- Cron job and monitoring script examples
|
||||
## 🔧 Worker Lifecycle Hardening
|
||||
|
||||
**Enhanced Troubleshooting** (`docs/public/troubleshooting.mdx`)
|
||||
- Added 195 lines of manual recovery troubleshooting
|
||||
- Queue state explanations
|
||||
- Direct database inspection queries
|
||||
This patch release addresses critical bugs discovered during PR review of the self-spawn pattern introduced in 8.2.0. The worker daemon now handles edge cases robustly across both Unix and Windows platforms.
|
||||
|
||||
**Updated Development Guide**
|
||||
- Changed testing philosophy to emphasize real-world testing
|
||||
- Added manual testing workflow documentation
|
||||
- Queue health verification procedures
|
||||
### 🐛 Critical Bug Fixes
|
||||
|
||||
**Worker Service Docs Updated**
|
||||
- Documented 22 HTTP endpoints (up from 20)
|
||||
- Queue management endpoint documentation
|
||||
#### Process Exit Detection Fixed
|
||||
The `waitForProcessesExit` function was crashing when processes exited during monitoring. The `process.kill(pid, 0)` call throws when a process no longer exists, which was not being caught. Now wrapped in try/catch to correctly identify exited processes.
|
||||
|
||||
## Dependencies
|
||||
#### Spawn PID Validation
|
||||
The worker daemon now validates that `spawn()` actually returned a valid PID before writing to the PID file. Previously, spawn failures could leave invalid PID files that broke subsequent lifecycle operations.
|
||||
|
||||
- Upgraded `@anthropic-ai/claude-agent-sdk` from `^0.1.67` to `^0.1.76`
|
||||
#### Cross-Platform Orphan Cleanup
|
||||
- **Unix**: Replaced single `kill` command with individual `process.kill()` calls wrapped in try/catch, so one already-exited process doesn't abort cleanup of remaining orphans
|
||||
- **Windows**: Wrapped `taskkill` calls in try/catch for the same reason
|
||||
|
||||
## New Scripts
|
||||
#### Health Check Reliability
|
||||
Changed `waitForHealth` to use the `/api/readiness` endpoint (returns 503 until fully initialized) instead of just checking if the port is in use. Callers now wait for *actual* worker readiness, not just network availability.
|
||||
|
||||
- `scripts/check-pending-queue.ts` - CLI tool for queue management
|
||||
- `scripts/fix-all-timestamps.ts` - Timestamp correction utility
|
||||
- `scripts/fix-corrupted-timestamps.ts` - Corrupted timestamp repair
|
||||
- `scripts/investigate-timestamps.ts` - Timestamp debugging tool
|
||||
- `scripts/validate-timestamp-logic.ts` - Timestamp validation
|
||||
- `scripts/verify-timestamp-fix.ts` - Post-fix verification
|
||||
### 🔄 Refactoring
|
||||
|
||||
## Migration Guide
|
||||
#### Code Consolidation (-580 lines)
|
||||
Deleted obsolete process management infrastructure that was replaced by the self-spawn pattern:
|
||||
- `src/services/process/ProcessManager.ts` (433 lines) - PID management now in worker-service
|
||||
- `src/cli/worker-cli.ts` (81 lines) - CLI handling now in worker-service
|
||||
- `src/services/worker-wrapper.ts` (157 lines) - Replaced by `--daemon` flag
|
||||
|
||||
1. **After upgrading**: Run `bun scripts/check-pending-queue.ts` to check for stuck messages
|
||||
2. **If messages found**: Run `bun scripts/check-pending-queue.ts --process` to recover
|
||||
3. **Optional**: Add recovery to your workflow (cron job, pre-shutdown script)
|
||||
4. **Note**: Automatic recovery no longer happens - you must trigger it manually
|
||||
#### Updated Hook Commands
|
||||
All hooks now use `worker-service.cjs` CLI directly instead of the deleted `worker-cli.js`.
|
||||
|
||||
### ⏱️ Timeout Adjustments
|
||||
|
||||
Increased timeouts throughout for compatibility with slow systems:
|
||||
|
||||
| Component | Before | After |
|
||||
|-----------|--------|-------|
|
||||
| Default hook timeout | 120s | 300s |
|
||||
| Health check timeout | 1s | 30s |
|
||||
| Health check retries | 15 | 300 |
|
||||
| Context initialization | 30s | 300s |
|
||||
| MCP connection | 15s | 300s |
|
||||
| PowerShell commands | 5s | 60s |
|
||||
| Git commands | 30s | 300s |
|
||||
| NPM install | 120s | 600s |
|
||||
| Hook worker commands | 30s | 180s |
|
||||
|
||||
### 🧪 Testing
|
||||
|
||||
Added comprehensive test suites:
|
||||
- `tests/hook-constants.test.ts` - Validates timeout configurations
|
||||
- `tests/worker-spawn.test.ts` - Tests worker CLI and health endpoints
|
||||
|
||||
### 🛡️ Additional Robustness
|
||||
|
||||
- PID validation in restart command (matches start command behavior)
|
||||
- Try/catch around `forceKillProcess()` for graceful shutdown
|
||||
- Try/catch around `getChildProcesses()` for Windows failures
|
||||
- Improved logging for PID file operations and HTTP shutdown
|
||||
|
||||
---
|
||||
|
||||
**Full Changelog**: https://github.com/thedotmack/claude-mem/compare/v8.2.0...v8.2.1
|
||||
|
||||
## [8.2.0] - 2025-12-26
|
||||
|
||||
## 🚀 Gemini API as Alternative AI Provider
|
||||
|
||||
This release introduces **Google Gemini API** as an alternative to the Claude Agent SDK for observation extraction. This gives users flexibility in choosing their AI backend while maintaining full feature parity.
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
#### Gemini Provider Integration
|
||||
- **New `GeminiAgent`**: Complete implementation using Gemini's REST API for observation and summary extraction
|
||||
- **Provider selection**: Choose between Claude or Gemini directly in the Settings UI
|
||||
- **API key management**: Configure via UI or `GEMINI_API_KEY` environment variable
|
||||
- **Multi-turn conversations**: Full conversation history tracking for context-aware extraction
|
||||
|
||||
#### Supported Gemini Models
|
||||
- `gemini-2.5-flash-preview-05-20` (default)
|
||||
- `gemini-2.5-pro-preview-05-06`
|
||||
- `gemini-2.0-flash`
|
||||
- `gemini-2.0-flash-lite`
|
||||
|
||||
#### Rate Limiting
|
||||
- Built-in rate limiting for Gemini free tier (15 RPM) and paid tier (1000 RPM)
|
||||
- Configurable via `gemini_has_billing` setting in the UI
|
||||
|
||||
#### Resilience Features
|
||||
- **Graceful fallback**: Automatically falls back to Claude SDK if Gemini is selected but no API key is configured
|
||||
- **Hot-swap providers**: Switch between Claude and Gemini without restarting the worker
|
||||
- **Empty response handling**: Messages properly marked as processed even when Gemini returns empty responses (prevents stuck queue states)
|
||||
- **Timestamp preservation**: Recovered backlog messages retain their original timestamps
|
||||
|
||||
### 🎨 UI Improvements
|
||||
|
||||
- **Spinning favicon**: Visual indicator during observation processing
|
||||
- **Provider status**: Clear indication of which AI provider is active
|
||||
|
||||
### 📚 Documentation
|
||||
|
||||
- New [Gemini Provider documentation](https://docs.claude-mem.ai/usage/gemini-provider) with setup guide and troubleshooting
|
||||
|
||||
### ⚙️ New Settings
|
||||
|
||||
| Setting | Values | Description |
|
||||
|---------|--------|-------------|
|
||||
| `CLAUDE_MEM_PROVIDER` | `claude` \| `gemini` | AI provider for observation extraction |
|
||||
| `CLAUDE_MEM_GEMINI_API_KEY` | string | Gemini API key |
|
||||
| `CLAUDE_MEM_GEMINI_MODEL` | see above | Gemini model to use |
|
||||
| `gemini_has_billing` | boolean | Enable higher rate limits for paid accounts |
|
||||
|
||||
---
|
||||
|
||||
## 🙏 Contributor Shout-out
|
||||
|
||||
Huge thanks to **Alexander Knigge** ([@AlexanderKnigge](https://x.com/AlexanderKnigge)) for contributing the Gemini provider implementation! This feature significantly expands claude-mem's flexibility and gives users more choice in their AI backend.
|
||||
|
||||
---
|
||||
|
||||
**Full Changelog**: https://github.com/thedotmack/claude-mem/compare/v8.1.0...v8.2.0
|
||||
|
||||
## [8.1.0] - 2025-12-25
|
||||
|
||||
## The 3-Month Battle Against Complexity
|
||||
|
||||
**TL;DR:** For three months, Claude's instinct to add code instead of delete it caused the same bugs to recur. What should have been 5 lines of code became ~1000 lines, 11 useless methods, and 7+ failed "fixes." The timestamp corruption that finally broke things was just a symptom. The real achievement: **984 lines of code deleted.**
|
||||
|
||||
---
|
||||
|
||||
## What Actually Happened
|
||||
|
||||
Every Claude Code hook receives a session ID. That's all you need.
|
||||
|
||||
But Claude built an entire redundant session management system on top:
|
||||
- An `sdk_sessions` table with status tracking, port assignment, and prompt counting
|
||||
- 11 methods in `SessionStore` to manage this artificial complexity
|
||||
- Auto-creation logic scattered across 3 locations
|
||||
- A cleanup hook that "completed" sessions at the end
|
||||
|
||||
**Why?** Because it seemed "robust." Because "what if the session doesn't exist?"
|
||||
|
||||
But the edge cases didn't exist. Hooks ALWAYS provide session IDs. The "defensive" code was solving imaginary problems while creating real ones.
|
||||
|
||||
---
|
||||
|
||||
## The Pattern of Failure
|
||||
|
||||
Every time a bug appeared, Claude's instinct was to **ADD** more code:
|
||||
|
||||
| Bug | What Claude Added | What Should Have Happened |
|
||||
|-----|------------------|--------------------------|
|
||||
| Race conditions | Auto-create fallbacks | Delete the auto-create logic |
|
||||
| Duplicate observations | Validation layers | Delete the code path allowing duplicates |
|
||||
| UNIQUE constraint violations | Try-catch with fallbacks | Use `INSERT OR IGNORE` (5 characters) |
|
||||
| Session not found | Silent auto-creation | **FAIL LOUDLY** (it's a hook bug) |
|
||||
|
||||
---
|
||||
|
||||
## The 7+ Failed Attempts
|
||||
|
||||
- **Nov 4**: "Always store session data regardless of pre-existence." Complexity planted.
|
||||
- **Nov 11**: `INSERT OR IGNORE` recognized. But complexity documented, not removed.
|
||||
- **Nov 21**: Duplicate observations bug. Fixed. Then broken again by endless mode.
|
||||
- **Dec 5**: "6 hours of work delivered zero value." User requests self-audit.
|
||||
- **Dec 20**: "Phase 2: Eliminated Race Conditions" — felt like progress. Complexity remained.
|
||||
- **Dec 24**: Finally, forced deletion.
|
||||
|
||||
The user stated "hooks provide session IDs, no extra management needed" **seven times** across months. Claude didn't listen.
|
||||
|
||||
---
|
||||
|
||||
## The Fix
|
||||
|
||||
### Deleted (984 lines):
|
||||
- 11 `SessionStore` methods: `incrementPromptCounter`, `getPromptCounter`, `setWorkerPort`, `getWorkerPort`, `markSessionCompleted`, `markSessionFailed`, `reactivateSession`, `findActiveSDKSession`, `findAnySDKSession`, `updateSDKSessionId`
|
||||
- Auto-create logic from `storeObservation` and `storeSummary`
|
||||
- The entire cleanup hook (was aborting SDK agent and causing data loss)
|
||||
- 117 lines from `worker-utils.ts`
|
||||
|
||||
### What remains (~10 lines):
|
||||
```javascript
|
||||
createSDKSession(sessionId) {
|
||||
db.run('INSERT OR IGNORE INTO sdk_sessions (...) VALUES (...)');
|
||||
return db.query('SELECT id FROM sdk_sessions WHERE ...').get(sessionId);
|
||||
}
|
||||
```
|
||||
|
||||
**That's it.**
|
||||
|
||||
---
|
||||
|
||||
## Behavior Change
|
||||
|
||||
- **Before:** Missing session? Auto-create silently. Bug hidden.
|
||||
- **After:** Missing session? Storage fails. Bug visible immediately.
|
||||
|
||||
---
|
||||
|
||||
## New Tools
|
||||
|
||||
Since we're now explicit about recovery instead of silently papering over problems:
|
||||
|
||||
- `GET /api/pending-queue` - See what's stuck
|
||||
- `POST /api/pending-queue/process` - Manually trigger recovery
|
||||
- `npm run queue:check` / `npm run queue:process` - CLI equivalents
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
- Upgraded `@anthropic-ai/claude-agent-sdk` from `^0.1.67` to `^0.1.76`
|
||||
|
||||
---
|
||||
|
||||
**PR #437:** https://github.com/thedotmack/claude-mem/pull/437
|
||||
|
||||
*The evidence: Observations #3646, #6738, #7598, #12860, #12866, #13046, #15259, #20995, #21055, #30524, #31080, #32114, #32116, #32125, #32126, #32127, #32146, #32324—the complete record of a 3-month battle.*
|
||||
|
||||
## [8.0.6] - 2025-12-24
|
||||
|
||||
|
||||
@@ -83,4 +83,3 @@ This architecture preserves the open-source nature of the project while enabling
|
||||
# Important
|
||||
|
||||
No need to edit the changelog ever, it's generated automatically.
|
||||
No need to run tests, they are useless and are always deleted.
|
||||
|
||||
@@ -0,0 +1,664 @@
|
||||
# Session Continuity Regression Fix - Phased Execution Plan
|
||||
|
||||
**Project**: claude-mem
|
||||
**Issue**: Session continuity broken - each prompt creates new session instead of continuing existing one
|
||||
**Root Cause**: Session SDK ID not propagated correctly from new-hook through to SDKAgent
|
||||
**History**: Recurring issue over 3 months with 7 previous fix attempts that added complexity
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Add Diagnostic Logging
|
||||
|
||||
**Goal**: Add comprehensive logging to trace session ID and prompt number flow through the entire system.
|
||||
|
||||
**Context**: Session continuity requires `claudeSessionId` to flow from hook → SessionStore → SessionManager → SDKAgent. We need to verify this flow is working correctly.
|
||||
|
||||
**Files to Modify**:
|
||||
1. `src/hooks/new-hook.ts`
|
||||
2. `src/services/worker/http/routes/SessionRoutes.ts`
|
||||
3. `src/services/worker/SessionManager.ts`
|
||||
4. `src/services/worker/SDKAgent.ts`
|
||||
|
||||
**Implementation Steps**:
|
||||
|
||||
### 1.1 Add Logging to `src/hooks/new-hook.ts`
|
||||
|
||||
Add logging at these locations:
|
||||
|
||||
**Line ~24** (after receiving hook input):
|
||||
```typescript
|
||||
console.log('[NEW-HOOK] Received hook input:', {
|
||||
session_id: hookInput.session_id,
|
||||
has_prompt: !!hookInput.prompt,
|
||||
cwd: hookInput.cwd
|
||||
});
|
||||
```
|
||||
|
||||
**Line ~46-47** (before first API call):
|
||||
```typescript
|
||||
console.log('[NEW-HOOK] Calling /api/sessions/init:', {
|
||||
claudeSessionId: session_id,
|
||||
project,
|
||||
prompt_length: prompt?.length
|
||||
});
|
||||
```
|
||||
|
||||
**Line ~51** (after first API call):
|
||||
```typescript
|
||||
console.log('[NEW-HOOK] Received from /api/sessions/init:', {
|
||||
sessionDbId: sessionData.sessionDbId,
|
||||
promptNumber: sessionData.promptNumber,
|
||||
skipped: sessionData.skipped
|
||||
});
|
||||
```
|
||||
|
||||
**Line ~68** (before second API call):
|
||||
```typescript
|
||||
console.log('[NEW-HOOK] Calling /sessions/{sessionDbId}/init:', {
|
||||
sessionDbId: sessionData.sessionDbId,
|
||||
promptNumber: sessionData.promptNumber,
|
||||
userPrompt_length: cleanedPrompt?.length
|
||||
});
|
||||
```
|
||||
|
||||
### 1.2 Add Logging to `src/services/worker/http/routes/SessionRoutes.ts`
|
||||
|
||||
**In `handleSessionInitByClaudeId` method (~line 483)**:
|
||||
```typescript
|
||||
console.log('[SESSION-ROUTES] handleSessionInitByClaudeId called:', {
|
||||
claudeSessionId,
|
||||
project,
|
||||
prompt_length: prompt?.length
|
||||
});
|
||||
```
|
||||
|
||||
**After `createSDKSession` call (~line 493)**:
|
||||
```typescript
|
||||
console.log('[SESSION-ROUTES] createSDKSession returned:', {
|
||||
sessionDbId,
|
||||
claudeSessionId
|
||||
});
|
||||
```
|
||||
|
||||
**After prompt number calculation (~line 497)**:
|
||||
```typescript
|
||||
console.log('[SESSION-ROUTES] Calculated promptNumber:', {
|
||||
sessionDbId,
|
||||
promptNumber,
|
||||
currentCount
|
||||
});
|
||||
```
|
||||
|
||||
**In `handleSessionInit` method (~line 175)**:
|
||||
```typescript
|
||||
const { userPrompt, promptNumber } = req.body;
|
||||
console.log('[SESSION-ROUTES] handleSessionInit called:', {
|
||||
sessionDbId,
|
||||
promptNumber,
|
||||
has_userPrompt: !!userPrompt
|
||||
});
|
||||
```
|
||||
|
||||
### 1.3 Add Logging to `src/services/worker/SessionManager.ts`
|
||||
|
||||
**In `initializeSession` method at start (~line 50)**:
|
||||
```typescript
|
||||
console.log('[SESSION-MANAGER] initializeSession called:', {
|
||||
sessionDbId,
|
||||
promptNumber,
|
||||
has_currentUserPrompt: !!currentUserPrompt
|
||||
});
|
||||
```
|
||||
|
||||
**When session exists in memory (~line 55)**:
|
||||
```typescript
|
||||
console.log('[SESSION-MANAGER] Returning cached session:', {
|
||||
sessionDbId,
|
||||
claudeSessionId: session.claudeSessionId,
|
||||
lastPromptNumber: session.lastPromptNumber
|
||||
});
|
||||
```
|
||||
|
||||
**After fetching from database (~line 87)**:
|
||||
```typescript
|
||||
console.log('[SESSION-MANAGER] Fetched session from database:', {
|
||||
sessionDbId,
|
||||
claude_session_id: dbSession.claude_session_id,
|
||||
sdk_session_id: dbSession.sdk_session_id
|
||||
});
|
||||
```
|
||||
|
||||
**When creating new session object (~line 109-116)**:
|
||||
```typescript
|
||||
console.log('[SESSION-MANAGER] Creating new session object:', {
|
||||
sessionDbId,
|
||||
claudeSessionId: dbSession.claude_session_id,
|
||||
lastPromptNumber: promptNumber || /* fallback value */
|
||||
});
|
||||
```
|
||||
|
||||
### 1.4 Add Logging to `src/services/worker/SDKAgent.ts`
|
||||
|
||||
**In `startSession` method (~line 72)**:
|
||||
```typescript
|
||||
console.log('[SDK-AGENT] Starting SDK query with:', {
|
||||
sessionDbId: session.sessionDbId,
|
||||
claudeSessionId: session.claudeSessionId,
|
||||
resume_parameter: session.claudeSessionId,
|
||||
lastPromptNumber: session.lastPromptNumber
|
||||
});
|
||||
```
|
||||
|
||||
**In `createMessageGenerator` method (~line 200)**:
|
||||
```typescript
|
||||
const isInitPrompt = session.lastPromptNumber === 1;
|
||||
console.log('[SDK-AGENT] Creating message generator:', {
|
||||
sessionDbId: session.sessionDbId,
|
||||
claudeSessionId: session.claudeSessionId,
|
||||
lastPromptNumber: session.lastPromptNumber,
|
||||
isInitPrompt,
|
||||
promptType: isInitPrompt ? 'INIT' : 'CONTINUATION'
|
||||
});
|
||||
```
|
||||
|
||||
**Success Criteria**:
|
||||
- [ ] All 15+ log points added across 4 files
|
||||
- [ ] Build succeeds with no TypeScript errors
|
||||
- [ ] Worker service restarts successfully
|
||||
|
||||
**Handoff to Phase 2**: After adding logging, build with `npm run build-and-sync`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Test and Gather Diagnostic Data
|
||||
|
||||
**Goal**: Execute test conversation and collect logs to identify where session ID propagation breaks.
|
||||
|
||||
**Prerequisites**: Phase 1 completed, logging in place, worker service running
|
||||
|
||||
**Test Procedure**:
|
||||
|
||||
### 2.1 Start Fresh Conversation
|
||||
|
||||
In a new Claude Code session:
|
||||
1. Clear any existing logs: `bun ~/.claude/plugins/marketplaces/thedotmack/scripts/worker-service.cjs > /tmp/worker-logs.txt 2>&1 &`
|
||||
2. Send first prompt: "test prompt 1"
|
||||
3. Send second prompt: "test prompt 2"
|
||||
4. Send third prompt: "test prompt 3"
|
||||
|
||||
### 2.2 Collect Logs
|
||||
|
||||
View worker logs:
|
||||
```bash
|
||||
tail -f /tmp/worker-logs.txt | grep -E '\[NEW-HOOK\]|\[SESSION-ROUTES\]|\[SESSION-MANAGER\]|\[SDK-AGENT\]'
|
||||
```
|
||||
|
||||
### 2.3 Check Database State
|
||||
|
||||
**Query 1 - Check sessions table**:
|
||||
```bash
|
||||
cd ~/.claude-mem
|
||||
sqlite3 claude-mem.db "SELECT id, claude_session_id, sdk_session_id, status, started_at FROM sdk_sessions ORDER BY id DESC LIMIT 10;"
|
||||
```
|
||||
|
||||
**Expected**: Same `claude_session_id` for all 3 prompts
|
||||
|
||||
**Query 2 - Check user prompts table**:
|
||||
```bash
|
||||
sqlite3 claude-mem.db "SELECT claude_session_id, prompt_number, created_at FROM user_prompts ORDER BY created_at DESC LIMIT 10;"
|
||||
```
|
||||
|
||||
**Expected**: Same `claude_session_id` with prompt_number: 1, 2, 3
|
||||
|
||||
### 2.4 Analyze Data Flow
|
||||
|
||||
For each prompt (1, 2, 3), trace in logs:
|
||||
|
||||
1. **NEW-HOOK** receives `session_id` from Claude Code
|
||||
2. **SESSION-ROUTES** receives `claudeSessionId` in API call
|
||||
3. **SESSION-ROUTES** creates/gets `sessionDbId`
|
||||
4. **SESSION-ROUTES** calculates `promptNumber`
|
||||
5. **SESSION-MANAGER** fetches/creates session with `claudeSessionId`
|
||||
6. **SDK-AGENT** uses `claudeSessionId` as resume parameter
|
||||
7. **SDK-AGENT** selects INIT vs CONTINUATION prompt
|
||||
|
||||
**Key Questions to Answer**:
|
||||
- [ ] Does `session_id` from hook stay the same across all 3 prompts?
|
||||
- [ ] Does `claudeSessionId` match across all log entries for same conversation?
|
||||
- [ ] Does `promptNumber` increment: 1, 2, 3?
|
||||
- [ ] Does `lastPromptNumber` match `promptNumber` in SessionManager?
|
||||
- [ ] Does SDK-AGENT receive correct `resume` parameter on prompts 2+?
|
||||
- [ ] Does SDK-AGENT select CONTINUATION prompt for prompts 2+?
|
||||
|
||||
**Success Criteria**:
|
||||
- [ ] Logs collected for 3 test prompts
|
||||
- [ ] Database queries run and results saved
|
||||
- [ ] Data flow analysis completed
|
||||
- [ ] Failure point identified
|
||||
|
||||
**Handoff to Phase 3**: Document exact failure point (which log entry shows incorrect value) and move to fix implementation
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Implement Fix Based on Findings
|
||||
|
||||
**Goal**: Fix the identified root cause of session continuity failure.
|
||||
|
||||
**Prerequisites**: Phase 2 completed, failure point identified from logs/database
|
||||
|
||||
**Common Fix Scenarios**:
|
||||
|
||||
### Scenario A: Hook Receives Different `session_id` Each Time
|
||||
|
||||
**Symptom in Logs**:
|
||||
```
|
||||
[NEW-HOOK] Received hook input: { session_id: 'abc-123', ... } // Prompt 1
|
||||
[NEW-HOOK] Received hook input: { session_id: 'def-456', ... } // Prompt 2 - DIFFERENT!
|
||||
```
|
||||
|
||||
**Root Cause**: Hook not receiving consistent session ID from Claude Code
|
||||
|
||||
**Fix Location**: This is external to codebase - investigate Claude Code hook configuration or report bug
|
||||
|
||||
**Action**: Create GitHub issue in claude-code repo with evidence
|
||||
|
||||
### Scenario B: `promptNumber` Not Passed or Calculated Correctly
|
||||
|
||||
**Symptom in Logs**:
|
||||
```
|
||||
[SESSION-ROUTES] Calculated promptNumber: { promptNumber: 1, currentCount: 1 } // Prompt 2 - WRONG!
|
||||
```
|
||||
|
||||
**Root Cause**: User prompt not being saved to database, or count query failing
|
||||
|
||||
**Fix Location**: `src/services/worker/http/routes/SessionRoutes.ts` line 520
|
||||
|
||||
**Fix**:
|
||||
```typescript
|
||||
// Add error handling around saveUserPrompt
|
||||
try {
|
||||
this.dbManager.getSessionStore().saveUserPrompt(
|
||||
claudeSessionId,
|
||||
promptNumber,
|
||||
cleanedPrompt
|
||||
);
|
||||
console.log('[SESSION-ROUTES] Successfully saved user prompt:', {
|
||||
claudeSessionId,
|
||||
promptNumber
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[SESSION-ROUTES] Failed to save user prompt:', error);
|
||||
throw new Error(`Failed to save user prompt: ${error.message}`);
|
||||
}
|
||||
```
|
||||
|
||||
### Scenario C: Session Manager Uses Wrong Fallback Logic
|
||||
|
||||
**Symptom in Logs**:
|
||||
```
|
||||
[SESSION-MANAGER] Creating new session object: { lastPromptNumber: 1 } // Prompt 2 - WRONG!
|
||||
```
|
||||
|
||||
**Root Cause**: Fragile `||` operator causing incorrect fallback when `promptNumber` is valid
|
||||
|
||||
**Fix Location**: `src/services/worker/SessionManager.ts` line 116
|
||||
|
||||
**Fix**:
|
||||
```typescript
|
||||
// Replace fragile || with explicit undefined check
|
||||
lastPromptNumber: promptNumber !== undefined
|
||||
? promptNumber
|
||||
: this.dbManager.getSessionStore().getPromptNumberFromUserPrompts(dbSession.claude_session_id),
|
||||
```
|
||||
|
||||
### Scenario D: Database Session Not Found
|
||||
|
||||
**Symptom in Logs**:
|
||||
```
|
||||
[SESSION-MANAGER] Fetched session from database: { claude_session_id: undefined }
|
||||
```
|
||||
|
||||
**Root Cause**: `createSDKSession` INSERT failed silently, or session was deleted
|
||||
|
||||
**Fix Location**: `src/services/sqlite/SessionStore.ts` line 1086-1101
|
||||
|
||||
**Fix**:
|
||||
```typescript
|
||||
// Add validation after INSERT OR IGNORE
|
||||
const result = 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(claudeSessionId, claudeSessionId, project, userPrompt, now, nowEpoch, 'active');
|
||||
|
||||
// Verify session exists
|
||||
const row = this.db.prepare('SELECT id FROM sdk_sessions WHERE claude_session_id = ?')
|
||||
.get(claudeSessionId);
|
||||
|
||||
if (!row) {
|
||||
throw new Error(`Failed to create or retrieve SDK session for claudeSessionId: ${claudeSessionId}`);
|
||||
}
|
||||
|
||||
return row.id;
|
||||
```
|
||||
|
||||
### Scenario E: SDK Agent Receives Empty `claudeSessionId`
|
||||
|
||||
**Symptom in Logs**:
|
||||
```
|
||||
[SDK-AGENT] Starting SDK query with: { claudeSessionId: undefined, resume_parameter: undefined }
|
||||
```
|
||||
|
||||
**Root Cause**: SessionManager created session object with missing `claudeSessionId`
|
||||
|
||||
**Fix Location**: `src/services/worker/SessionManager.ts` line 109
|
||||
|
||||
**Fix**:
|
||||
```typescript
|
||||
// Add validation before using database values
|
||||
if (!dbSession.claude_session_id) {
|
||||
throw new Error(`Database session ${sessionDbId} has no claude_session_id`);
|
||||
}
|
||||
|
||||
session = {
|
||||
sessionDbId,
|
||||
claudeSessionId: dbSession.claude_session_id,
|
||||
// ... rest of session object
|
||||
};
|
||||
```
|
||||
|
||||
**Success Criteria**:
|
||||
- [ ] Fix implemented at identified failure point
|
||||
- [ ] Validation added to fail loudly on errors
|
||||
- [ ] Build succeeds
|
||||
- [ ] Worker service restarts successfully
|
||||
|
||||
**Handoff to Phase 4**: Build and deploy fix, then run verification tests
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Verify Fix and Test Session Continuity
|
||||
|
||||
**Goal**: Confirm session continuity is working correctly after fix.
|
||||
|
||||
**Prerequisites**: Phase 3 completed, fix deployed, worker service running
|
||||
|
||||
**Verification Procedure**:
|
||||
|
||||
### 4.1 Run Full Test Conversation
|
||||
|
||||
In a fresh Claude Code session:
|
||||
|
||||
1. **Prompt 1**: "This is test prompt one for session continuity"
|
||||
2. **Prompt 2**: "This is test prompt two, continuing the session"
|
||||
3. **Prompt 3**: "This is test prompt three, still continuing"
|
||||
4. **Prompt 4**: "Final test prompt four"
|
||||
|
||||
### 4.2 Check Logs
|
||||
|
||||
Verify in worker logs:
|
||||
|
||||
**All prompts show same `session_id`**:
|
||||
```
|
||||
[NEW-HOOK] Received hook input: { session_id: 'abc-123' } // All 4 prompts
|
||||
```
|
||||
|
||||
**Prompt numbers increment**:
|
||||
```
|
||||
[SESSION-ROUTES] Calculated promptNumber: { promptNumber: 1 } // Prompt 1
|
||||
[SESSION-ROUTES] Calculated promptNumber: { promptNumber: 2 } // Prompt 2
|
||||
[SESSION-ROUTES] Calculated promptNumber: { promptNumber: 3 } // Prompt 3
|
||||
[SESSION-ROUTES] Calculated promptNumber: { promptNumber: 4 } // Prompt 4
|
||||
```
|
||||
|
||||
**SDK Agent uses continuation prompts**:
|
||||
```
|
||||
[SDK-AGENT] Creating message generator: { promptType: 'INIT' } // Prompt 1
|
||||
[SDK-AGENT] Creating message generator: { promptType: 'CONTINUATION' } // Prompt 2
|
||||
[SDK-AGENT] Creating message generator: { promptType: 'CONTINUATION' } // Prompt 3
|
||||
[SDK-AGENT] Creating message generator: { promptType: 'CONTINUATION' } // Prompt 4
|
||||
```
|
||||
|
||||
### 4.3 Verify Database State
|
||||
|
||||
**Check sessions table**:
|
||||
```bash
|
||||
sqlite3 ~/.claude-mem/claude-mem.db "SELECT id, claude_session_id, sdk_session_id FROM sdk_sessions ORDER BY id DESC LIMIT 5;"
|
||||
```
|
||||
|
||||
**Expected**: Only ONE session record for the 4 prompts, `claude_session_id` and `sdk_session_id` are identical
|
||||
|
||||
**Check user_prompts table**:
|
||||
```bash
|
||||
sqlite3 ~/.claude-mem/claude-mem.db "SELECT claude_session_id, prompt_number, created_at FROM user_prompts ORDER BY created_at DESC LIMIT 5;"
|
||||
```
|
||||
|
||||
**Expected**: 4 records with same `claude_session_id`, prompt_number values: 4, 3, 2, 1
|
||||
|
||||
### 4.4 Functional Test
|
||||
|
||||
Verify actual session continuity behavior:
|
||||
|
||||
1. **Prompt 1**: "My favorite color is blue"
|
||||
2. **Prompt 2**: "What is my favorite color?"
|
||||
- **Expected**: Response mentions "blue"
|
||||
3. **Prompt 3**: "Change it to red"
|
||||
4. **Prompt 4**: "What is my favorite color now?"
|
||||
- **Expected**: Response mentions "red"
|
||||
|
||||
**Success Criteria**:
|
||||
- [x] Same `session_id` across all 4 prompts in logs
|
||||
- [x] Prompt numbers increment: 1, 2, 3, 4
|
||||
- [x] INIT prompt only for first prompt
|
||||
- [x] CONTINUATION prompts for prompts 2, 3, 4
|
||||
- [x] Only one session record in database
|
||||
- [x] Four user_prompts records with incremental prompt_number
|
||||
- [x] Functional test shows session continuity working
|
||||
|
||||
**Handoff to Phase 5**: If all criteria pass, proceed to cleanup. If any fail, return to Phase 2 with new diagnostic focus.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Cleanup and Documentation
|
||||
|
||||
**Goal**: Remove excessive logging, update documentation, close issues.
|
||||
|
||||
**Prerequisites**: Phase 4 completed successfully, session continuity verified working
|
||||
|
||||
**Cleanup Steps**:
|
||||
|
||||
### 5.1 Reduce Logging Verbosity (Optional)
|
||||
|
||||
You can either:
|
||||
- **Keep all diagnostic logging** for future debugging (recommended)
|
||||
- **Remove logging** to reduce noise in production logs
|
||||
- **Convert to debug level** if logging framework supports it
|
||||
|
||||
If removing logging, remove the `console.log` statements added in Phase 1 from:
|
||||
- `src/hooks/new-hook.ts`
|
||||
- `src/services/worker/http/routes/SessionRoutes.ts`
|
||||
- `src/services/worker/SessionManager.ts`
|
||||
- `src/services/worker/SDKAgent.ts`
|
||||
|
||||
### 5.2 Update Documentation
|
||||
|
||||
If the fix revealed any architectural insights, update:
|
||||
- `CLAUDE.md` - Add any new gotchas or patterns discovered
|
||||
- `README.md` - Update if user-facing behavior changed
|
||||
- Code comments - Document the fix rationale
|
||||
|
||||
### 5.3 Create Regression Test (Future Work)
|
||||
|
||||
Consider adding automated test:
|
||||
```typescript
|
||||
describe('Session Continuity', () => {
|
||||
it('should use same session ID across multiple prompts', async () => {
|
||||
// Test that verifies session ID propagation
|
||||
});
|
||||
|
||||
it('should increment prompt numbers correctly', async () => {
|
||||
// Test that verifies prompt number calculation
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 5.4 Close Related Issues
|
||||
|
||||
Search GitHub for related issues:
|
||||
```bash
|
||||
gh issue list --search "session continuity" --state open
|
||||
gh issue list --search "session persistence" --state open
|
||||
gh issue list --search "new session" --state open
|
||||
```
|
||||
|
||||
Close with comment explaining the fix.
|
||||
|
||||
**Success Criteria**:
|
||||
- [ ] Logging cleaned up as desired
|
||||
- [ ] Documentation updated
|
||||
- [ ] Related GitHub issues closed
|
||||
- [ ] No regressions introduced
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Key Files and What They Do
|
||||
|
||||
| File | Purpose | Critical Lines |
|
||||
|------|---------|----------------|
|
||||
| `src/hooks/new-hook.ts` | Hook entry point, receives session_id from Claude Code | 24, 34, 46-47, 63-68 |
|
||||
| `src/services/worker/http/routes/SessionRoutes.ts` | HTTP endpoints for session init, calculates prompt numbers | 482-533, 171-227 |
|
||||
| `src/services/sqlite/SessionStore.ts` | Database operations for sessions and user prompts | 1086-1101, 1053-1058 |
|
||||
| `src/services/worker/SessionManager.ts` | In-memory session management, bridges DB and SDK | 49-141, esp. 109, 116 |
|
||||
| `src/services/worker/SDKAgent.ts` | SDK integration, sends resume parameter and prompts | 68-77, 195-218, 200-202 |
|
||||
| `src/sdk/prompts.ts` | Init and continuation prompt templates | 30-87, 169-229 |
|
||||
|
||||
### Build and Deploy Commands
|
||||
|
||||
```bash
|
||||
# Build TypeScript
|
||||
npm run build
|
||||
|
||||
# Sync to marketplace and restart worker
|
||||
npm run build-and-sync
|
||||
|
||||
# Restart worker only
|
||||
killall bun
|
||||
bun ~/.claude/plugins/marketplaces/thedotmack/scripts/worker-service.cjs &
|
||||
|
||||
# Check worker is running
|
||||
curl http://localhost:37777/health
|
||||
```
|
||||
|
||||
### Database Queries
|
||||
|
||||
```bash
|
||||
# Check sessions
|
||||
sqlite3 ~/.claude-mem/claude-mem.db "SELECT * FROM sdk_sessions ORDER BY id DESC LIMIT 10;"
|
||||
|
||||
# Check user prompts
|
||||
sqlite3 ~/.claude-mem/claude-mem.db "SELECT * FROM user_prompts ORDER BY created_at DESC LIMIT 10;"
|
||||
|
||||
# Count prompts per session
|
||||
sqlite3 ~/.claude-mem/claude-mem.db "SELECT claude_session_id, COUNT(*) as prompt_count FROM user_prompts GROUP BY claude_session_id ORDER BY prompt_count DESC LIMIT 10;"
|
||||
```
|
||||
|
||||
### Debugging Tips
|
||||
|
||||
1. **Check worker is running**: `curl http://localhost:37777/health`
|
||||
2. **View worker logs**: `tail -f /tmp/worker-logs.txt`
|
||||
3. **Check hook output**: Logs appear in Claude Code's stderr
|
||||
4. **Database locked**: `killall bun` then restart worker
|
||||
5. **Stale build**: `rm -rf plugin/scripts/*.js && npm run build`
|
||||
|
||||
---
|
||||
|
||||
## Phase Execution Checklist
|
||||
|
||||
Use this checklist when executing phases in new chat contexts:
|
||||
|
||||
**Phase 1: Diagnostic Logging**
|
||||
- [ ] Read this plan document
|
||||
- [ ] Read the 4 files to modify
|
||||
- [ ] Add all 15+ log points
|
||||
- [ ] Build with `npm run build-and-sync`
|
||||
- [ ] Verify worker restarts
|
||||
- [ ] Mark phase complete, handoff to Phase 2
|
||||
|
||||
**Phase 2: Test and Gather Data**
|
||||
- [ ] Read Phase 2 section
|
||||
- [ ] Run 3 test prompts
|
||||
- [ ] Collect and save logs
|
||||
- [ ] Run database queries
|
||||
- [ ] Trace data flow
|
||||
- [ ] Identify failure point
|
||||
- [ ] Document failure point
|
||||
- [ ] Mark phase complete, handoff to Phase 3
|
||||
|
||||
**Phase 3: Implement Fix**
|
||||
- [ ] Read Phase 3 section
|
||||
- [ ] Review failure point from Phase 2
|
||||
- [ ] Select applicable scenario
|
||||
- [ ] Implement fix
|
||||
- [ ] Add validation
|
||||
- [ ] Build and deploy
|
||||
- [ ] Mark phase complete, handoff to Phase 4
|
||||
|
||||
**Phase 4: Verify Fix**
|
||||
- [ ] Read Phase 4 section
|
||||
- [ ] Run 4 test prompts
|
||||
- [ ] Check logs for correct behavior
|
||||
- [ ] Verify database state
|
||||
- [ ] Run functional test
|
||||
- [ ] All success criteria pass
|
||||
- [ ] Mark phase complete, handoff to Phase 5
|
||||
|
||||
**Phase 5: Cleanup**
|
||||
- [ ] Read Phase 5 section
|
||||
- [ ] Clean up logging (optional)
|
||||
- [ ] Update documentation
|
||||
- [ ] Close GitHub issues
|
||||
- [ ] Mark phase complete
|
||||
- [ ] Session continuity regression FIX COMPLETE ✅
|
||||
|
||||
---
|
||||
|
||||
## Context for New Chat Sessions
|
||||
|
||||
When starting a new phase, provide this context:
|
||||
|
||||
**I'm working on Phase [X] of the Session Continuity Regression Fix for claude-mem.**
|
||||
|
||||
**Background**: Session continuity is broken - each prompt creates a new session instead of continuing. This has been a recurring issue for 3 months. The root cause is that session SDK ID is not being propagated correctly from new-hook through to SDKAgent.
|
||||
|
||||
**Current Status**: [Briefly describe what previous phases accomplished]
|
||||
|
||||
**This Phase Goal**: [Copy the goal from the phase section]
|
||||
|
||||
**Plan Document**: Read `/Users/alexnewman/Scripts/claude-mem/PLAN-SESSION-CONTINUITY-FIX.md` for full context.
|
||||
|
||||
---
|
||||
|
||||
## Success Metrics
|
||||
|
||||
**Overall Fix Success**:
|
||||
- [ ] Same session ID used across multiple prompts in one conversation
|
||||
- [ ] Prompt numbers increment correctly (1, 2, 3, ...)
|
||||
- [ ] Init prompt only sent on first prompt
|
||||
- [ ] Continuation prompts sent on subsequent prompts
|
||||
- [ ] SDK receives correct resume parameter
|
||||
- [ ] Only one session record created per conversation
|
||||
- [ ] Functional session continuity test passes
|
||||
- [ ] No new regressions introduced
|
||||
|
||||
**Regression Prevention**:
|
||||
- [ ] Validation added to fail loudly on errors
|
||||
- [ ] No silent fallbacks that hide bugs
|
||||
- [ ] Database queries verified
|
||||
- [ ] Session ID propagation explicitly tested
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: 2025-12-27
|
||||
**Author**: Claude (investigating 3-month recurring session continuity regression)
|
||||
@@ -106,7 +106,7 @@ pm2 logs claude-mem-worker # View logs
|
||||
```bash
|
||||
npm run worker:start # Start worker
|
||||
npm run worker:stop # Stop worker
|
||||
claude-mem restart # Restart worker
|
||||
npm run worker:restart # Restart worker
|
||||
npm run worker:status # Check status
|
||||
npm run worker:logs # View logs
|
||||
```
|
||||
@@ -305,7 +305,7 @@ No migration logic runs on subsequent sessions.
|
||||
| `pm2 list` | `npm run worker:status` | Shows worker status |
|
||||
| `pm2 start <script>` | `npm run worker:start` | Start worker |
|
||||
| `pm2 stop claude-mem-worker` | `npm run worker:stop` | Stop worker |
|
||||
| `pm2 restart claude-mem-worker` | `claude-mem restart` | Restart worker |
|
||||
| `pm2 restart claude-mem-worker` | `npm run worker:restart` | Restart worker |
|
||||
| `pm2 delete claude-mem-worker` | `npm run worker:stop` | Remove worker |
|
||||
| `pm2 logs claude-mem-worker` | `npm run worker:logs` | View logs |
|
||||
| `pm2 describe claude-mem-worker` | `npm run worker:status` | Detailed status |
|
||||
@@ -451,7 +451,7 @@ pm2 save # Persist the deletion
|
||||
rm ~/.claude-mem/.pm2-migrated
|
||||
|
||||
# Restart worker
|
||||
claude-mem restart
|
||||
npm run worker:restart
|
||||
```
|
||||
|
||||
### Scenario 2: Stale PID File (Process Dead)
|
||||
@@ -483,7 +483,7 @@ lsof -i :37777
|
||||
kill -9 <PID>
|
||||
|
||||
# Restart worker
|
||||
claude-mem restart
|
||||
npm run worker:restart
|
||||
```
|
||||
|
||||
### Common Error Messages
|
||||
|
||||
@@ -416,7 +416,7 @@ If searches fail, check worker service:
|
||||
|
||||
```bash
|
||||
npm run worker:status # Check status
|
||||
claude-mem restart # Restart worker
|
||||
npm run worker:restart # Restart worker
|
||||
npm run worker:logs # View logs
|
||||
```
|
||||
|
||||
|
||||
@@ -597,7 +597,7 @@ npm run worker:start
|
||||
npm run worker:stop
|
||||
|
||||
# Restart worker
|
||||
claude-mem restart
|
||||
npm run worker:restart
|
||||
|
||||
# View logs
|
||||
npm run worker:logs
|
||||
|
||||
@@ -14,7 +14,7 @@ Settings are managed in `~/.claude-mem/settings.json`. The file is auto-created
|
||||
| Setting | Default | Description |
|
||||
|-------------------------------|---------------------------------|---------------------------------------|
|
||||
| `CLAUDE_MEM_MODEL` | `sonnet` | AI model for processing observations (when using Claude) |
|
||||
| `CLAUDE_MEM_PROVIDER` | `claude` | AI provider: `claude` or `gemini` |
|
||||
| `CLAUDE_MEM_PROVIDER` | `claude` | AI provider: `claude`, `gemini`, or `openrouter` |
|
||||
| `CLAUDE_MEM_MODE` | `code` | Active mode profile (e.g., `code--es`, `email-investigation`) |
|
||||
| `CLAUDE_MEM_CONTEXT_OBSERVATIONS` | `50` | Number of observations to inject |
|
||||
| `CLAUDE_MEM_WORKER_PORT` | `37777` | Worker service port |
|
||||
@@ -29,6 +29,19 @@ Settings are managed in `~/.claude-mem/settings.json`. The file is auto-created
|
||||
|
||||
See [Gemini Provider](usage/gemini-provider) for detailed configuration and free tier information.
|
||||
|
||||
### OpenRouter Provider Settings
|
||||
|
||||
| Setting | Default | Description |
|
||||
|----------------------------------------------|-----------------------------|---------------------------------------|
|
||||
| `CLAUDE_MEM_OPENROUTER_API_KEY` | — | OpenRouter API key ([get key](https://openrouter.ai/keys)) |
|
||||
| `CLAUDE_MEM_OPENROUTER_MODEL` | `xiaomi/mimo-v2-flash:free` | Model identifier (supports 100+ models) |
|
||||
| `CLAUDE_MEM_OPENROUTER_MAX_CONTEXT_MESSAGES` | `20` | Max messages in conversation history |
|
||||
| `CLAUDE_MEM_OPENROUTER_MAX_TOKENS` | `100000` | Token budget safety limit |
|
||||
| `CLAUDE_MEM_OPENROUTER_SITE_URL` | — | Optional: URL for analytics |
|
||||
| `CLAUDE_MEM_OPENROUTER_APP_NAME` | `claude-mem` | Optional: App name for analytics |
|
||||
|
||||
See [OpenRouter Provider](usage/openrouter-provider) for detailed configuration, free model list, and usage guide.
|
||||
|
||||
### System Configuration
|
||||
|
||||
| Setting | Default | Description |
|
||||
@@ -345,7 +358,7 @@ Edit `~/.claude-mem/settings.json`:
|
||||
|
||||
Then restart the worker:
|
||||
```bash
|
||||
claude-mem restart
|
||||
npm run worker:restart
|
||||
```
|
||||
|
||||
### Custom Model
|
||||
@@ -360,7 +373,7 @@ Edit `~/.claude-mem/settings.json`:
|
||||
Then restart the worker:
|
||||
```bash
|
||||
export CLAUDE_MEM_MODEL=opus
|
||||
claude-mem restart
|
||||
npm run worker:restart
|
||||
```
|
||||
|
||||
### Custom Skip Tools
|
||||
@@ -417,7 +430,7 @@ Enable debug logging:
|
||||
|
||||
```bash
|
||||
export DEBUG=claude-mem:*
|
||||
claude-mem restart
|
||||
npm run worker:restart
|
||||
npm run worker:logs
|
||||
```
|
||||
|
||||
@@ -435,7 +448,7 @@ npm run worker:logs
|
||||
|
||||
1. Restart worker after changes:
|
||||
```bash
|
||||
claude-mem restart
|
||||
npm run worker:restart
|
||||
```
|
||||
|
||||
2. Verify environment variables:
|
||||
@@ -469,7 +482,7 @@ If port 37777 is already in use:
|
||||
|
||||
2. Restart worker:
|
||||
```bash
|
||||
claude-mem restart
|
||||
npm run worker:restart
|
||||
```
|
||||
|
||||
3. Verify new port:
|
||||
|
||||
@@ -165,7 +165,7 @@ npm run build
|
||||
1. Make changes to React components in `src/ui/viewer/`
|
||||
2. Build: `npm run build`
|
||||
3. Sync to installed plugin: `npm run sync-marketplace`
|
||||
4. Restart worker: `claude-mem restart`
|
||||
4. Restart worker: `npm run worker:restart`
|
||||
5. Refresh browser at http://localhost:37777
|
||||
|
||||
**Hot Reload**: Not currently supported. Full rebuild + restart required for changes.
|
||||
@@ -395,7 +395,7 @@ When developing new features:
|
||||
```bash
|
||||
npm run build
|
||||
npm run sync-marketplace
|
||||
claude-mem restart
|
||||
npm run worker:restart
|
||||
```
|
||||
|
||||
2. **Test in real session**:
|
||||
@@ -560,7 +560,7 @@ export async function createObservation(
|
||||
|
||||
```bash
|
||||
export DEBUG=claude-mem:*
|
||||
claude-mem restart
|
||||
npm run worker:restart
|
||||
npm run worker:logs
|
||||
```
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"introduction",
|
||||
"installation",
|
||||
"usage/getting-started",
|
||||
"usage/openrouter-provider",
|
||||
"usage/gemini-provider",
|
||||
"usage/search-tools",
|
||||
"usage/claude-desktop",
|
||||
|
||||
@@ -94,7 +94,7 @@ git checkout beta/endless-mode
|
||||
npm install
|
||||
|
||||
# Restart the worker
|
||||
claude-mem restart
|
||||
npm run worker:restart
|
||||
```
|
||||
|
||||
**To return to stable:**
|
||||
@@ -103,7 +103,7 @@ claude-mem restart
|
||||
cd ~/.claude/plugins/marketplaces/thedotmack/
|
||||
git checkout main
|
||||
npm install
|
||||
claude-mem restart
|
||||
npm run worker:restart
|
||||
```
|
||||
|
||||
## Summary
|
||||
|
||||
@@ -534,7 +534,7 @@ npm run worker:status
|
||||
npm run worker:logs
|
||||
|
||||
# Restart
|
||||
claude-mem restart
|
||||
npm run worker:restart
|
||||
|
||||
# Stop
|
||||
npm run worker:stop
|
||||
|
||||
@@ -57,7 +57,7 @@ CLAUDE_MEM_PYTHON_VERSION=3.13 # Python version for chroma-mcp
|
||||
```bash
|
||||
npm run build # Compile TypeScript (hooks + worker)
|
||||
npm run sync-marketplace # Copy to ~/.claude/plugins
|
||||
claude-mem restart # Restart worker
|
||||
npm run worker:restart # Restart worker
|
||||
npm run worker:logs # View worker logs
|
||||
npm run worker:status # Check worker status
|
||||
```
|
||||
|
||||
@@ -48,14 +48,14 @@ The skill includes comprehensive diagnostics, automated repair sequences, and de
|
||||
|
||||
4. Restart worker service:
|
||||
```bash
|
||||
claude-mem restart
|
||||
npm run worker:restart
|
||||
```
|
||||
|
||||
5. Check for port conflicts:
|
||||
```bash
|
||||
# If port 37777 is in use by another service
|
||||
export CLAUDE_MEM_WORKER_PORT=38000
|
||||
claude-mem restart
|
||||
npm run worker:restart
|
||||
```
|
||||
|
||||
### Theme Toggle Not Persisting
|
||||
@@ -110,7 +110,7 @@ The skill includes comprehensive diagnostics, automated repair sequences, and de
|
||||
|
||||
5. Restart worker and refresh browser:
|
||||
```bash
|
||||
claude-mem restart
|
||||
npm run worker:restart
|
||||
```
|
||||
|
||||
### Chroma/Python Dependency Issues (v5.0.0+)
|
||||
@@ -225,7 +225,7 @@ The skill includes comprehensive diagnostics, automated repair sequences, and de
|
||||
3. Or use a different port:
|
||||
```bash
|
||||
export CLAUDE_MEM_WORKER_PORT=38000
|
||||
claude-mem restart
|
||||
npm run worker:restart
|
||||
```
|
||||
|
||||
4. Verify new port:
|
||||
@@ -282,7 +282,7 @@ The skill includes comprehensive diagnostics, automated repair sequences, and de
|
||||
|
||||
4. Restart worker:
|
||||
```bash
|
||||
claude-mem restart
|
||||
npm run worker:restart
|
||||
```
|
||||
|
||||
### Manual Recovery for Stuck Observations
|
||||
@@ -839,7 +839,7 @@ sqlite3 ~/.claude-mem/claude-mem.db "
|
||||
|
||||
2. Restart worker:
|
||||
```bash
|
||||
claude-mem restart
|
||||
npm run worker:restart
|
||||
```
|
||||
|
||||
3. Clean up old data (see "Database Too Large" above)
|
||||
@@ -916,7 +916,7 @@ sqlite3 ~/.claude-mem/claude-mem.db "
|
||||
|
||||
```bash
|
||||
export DEBUG=claude-mem:*
|
||||
claude-mem restart
|
||||
npm run worker:restart
|
||||
npm run worker:logs
|
||||
```
|
||||
|
||||
@@ -976,7 +976,7 @@ SELECT created_at, tool_name FROM observations ORDER BY created_at DESC LIMIT 10
|
||||
|
||||
**Cause**: Worker not running or port mismatch.
|
||||
|
||||
**Solution**: Restart worker with `claude-mem restart`.
|
||||
**Solution**: Restart worker with `npm run worker:restart`.
|
||||
|
||||
### "Database is locked"
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ npm run worker:start
|
||||
npm run worker:stop
|
||||
|
||||
# Restart worker service
|
||||
claude-mem restart
|
||||
npm run worker:restart
|
||||
|
||||
# View worker logs
|
||||
npm run worker:logs
|
||||
|
||||
@@ -258,7 +258,7 @@ sqlite3 ~/.claude-mem/claude-mem.db "
|
||||
|
||||
3. **Restart worker**:
|
||||
```bash
|
||||
claude-mem restart
|
||||
npm run worker:restart
|
||||
```
|
||||
|
||||
4. **Check database integrity**:
|
||||
@@ -308,7 +308,7 @@ bun scripts/check-pending-queue.ts --process
|
||||
4. **Increase worker memory** (if using custom runner):
|
||||
```bash
|
||||
export NODE_OPTIONS="--max-old-space-size=4096"
|
||||
claude-mem restart
|
||||
npm run worker:restart
|
||||
```
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
---
|
||||
title: "OpenRouter Provider"
|
||||
description: "Access 100+ AI models through OpenRouter's unified API, including free models for cost-effective observation extraction"
|
||||
---
|
||||
|
||||
# OpenRouter Provider
|
||||
|
||||
Claude-mem supports [OpenRouter](https://openrouter.ai) as an alternative provider for observation extraction. OpenRouter provides a unified API to access 100+ models from different providers including Google, Meta, Mistral, DeepSeek, and many others—often with generous free tiers.
|
||||
|
||||
<Tip>
|
||||
**Free Models Available**: OpenRouter offers several completely free models, making it an excellent choice for reducing observation extraction costs to zero while maintaining quality.
|
||||
</Tip>
|
||||
|
||||
## Why Use OpenRouter?
|
||||
|
||||
- **Access to 100+ models**: Choose from models across multiple providers through one API
|
||||
- **Free tier options**: Several high-quality models are completely free to use
|
||||
- **Cost flexibility**: Pay-as-you-go pricing on premium models with no commitments
|
||||
- **Seamless fallback**: Automatically falls back to Claude if OpenRouter is unavailable
|
||||
- **Hot-swappable**: Switch providers without restarting the worker
|
||||
- **Multi-turn conversations**: Full conversation history maintained across API calls
|
||||
|
||||
## Free Models on OpenRouter
|
||||
|
||||
OpenRouter actively supports democratizing AI access by offering free models. These are production-ready models suitable for observation extraction.
|
||||
|
||||
### Featured Free Models
|
||||
|
||||
| Model | ID | Parameters | Context | Best For |
|
||||
|-------|------|------------|---------|----------|
|
||||
| **Xiaomi MiMo-V2-Flash** | `xiaomi/mimo-v2-flash:free` | 309B (15B active, MoE) | 256K | Reasoning, coding, agents |
|
||||
| **Gemini 2.0 Flash** | `google/gemini-2.0-flash-exp:free` | — | 1M | General purpose |
|
||||
| **Gemini 2.5 Flash** | `google/gemini-2.5-flash-preview:free` | — | 1M | Latest capabilities |
|
||||
| **DeepSeek R1** | `deepseek/deepseek-r1:free` | 671B | 64K | Reasoning, analysis |
|
||||
| **Llama 3.1 70B** | `meta-llama/llama-3.1-70b-instruct:free` | 70B | 128K | General purpose |
|
||||
| **Llama 3.1 8B** | `meta-llama/llama-3.1-8b-instruct:free` | 8B | 128K | Fast, lightweight |
|
||||
| **Mistral Nemo** | `mistralai/mistral-nemo:free` | 12B | 128K | Efficient performance |
|
||||
|
||||
<Note>
|
||||
**Default Model**: Claude-mem uses `xiaomi/mimo-v2-flash:free` by default—a 309B parameter mixture-of-experts model that ranks #1 on SWE-bench Verified and excels at coding and reasoning tasks.
|
||||
</Note>
|
||||
|
||||
### Free Model Considerations
|
||||
|
||||
- **Rate limits**: Free models may have stricter rate limits than paid models
|
||||
- **Availability**: Free capacity depends on provider partnerships and demand
|
||||
- **Queue times**: During peak usage, requests may be queued briefly
|
||||
- **Max tokens**: Most free models support 65,536 completion tokens
|
||||
|
||||
All free models support:
|
||||
- Tool use and function calling
|
||||
- Temperature and sampling controls
|
||||
- Stop sequences
|
||||
- Streaming responses
|
||||
|
||||
## Getting an API Key
|
||||
|
||||
1. Go to [OpenRouter](https://openrouter.ai)
|
||||
2. Sign in with Google, GitHub, or email
|
||||
3. Navigate to [API Keys](https://openrouter.ai/keys)
|
||||
4. Click **Create Key**
|
||||
5. Copy and securely store your API key
|
||||
|
||||
<Tip>
|
||||
**Free to start**: No credit card required to create an account or use free models. Add credits only if you want to use premium models.
|
||||
</Tip>
|
||||
|
||||
## Configuration
|
||||
|
||||
### Settings
|
||||
|
||||
| Setting | Values | Default | Description |
|
||||
|---------|--------|---------|-------------|
|
||||
| `CLAUDE_MEM_PROVIDER` | `claude`, `gemini`, `openrouter` | `claude` | AI provider for observation extraction |
|
||||
| `CLAUDE_MEM_OPENROUTER_API_KEY` | string | — | Your OpenRouter API key |
|
||||
| `CLAUDE_MEM_OPENROUTER_MODEL` | string | `xiaomi/mimo-v2-flash:free` | Model identifier (see list above) |
|
||||
| `CLAUDE_MEM_OPENROUTER_MAX_CONTEXT_MESSAGES` | number | `20` | Max messages in conversation history |
|
||||
| `CLAUDE_MEM_OPENROUTER_MAX_TOKENS` | number | `100000` | Token budget safety limit |
|
||||
| `CLAUDE_MEM_OPENROUTER_SITE_URL` | string | — | Optional: URL for analytics attribution |
|
||||
| `CLAUDE_MEM_OPENROUTER_APP_NAME` | string | `claude-mem` | Optional: App name for analytics |
|
||||
|
||||
### Using the Settings UI
|
||||
|
||||
1. Open the viewer at http://localhost:37777
|
||||
2. Click the **gear icon** to open Settings
|
||||
3. Under **AI Provider**, select **OpenRouter**
|
||||
4. Enter your OpenRouter API key
|
||||
5. Optionally select a different model
|
||||
|
||||
Settings are applied immediately—no restart required.
|
||||
|
||||
### Manual Configuration
|
||||
|
||||
Edit `~/.claude-mem/settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"CLAUDE_MEM_PROVIDER": "openrouter",
|
||||
"CLAUDE_MEM_OPENROUTER_API_KEY": "sk-or-v1-your-key-here",
|
||||
"CLAUDE_MEM_OPENROUTER_MODEL": "xiaomi/mimo-v2-flash:free"
|
||||
}
|
||||
```
|
||||
|
||||
Alternatively, set the API key via environment variable:
|
||||
|
||||
```bash
|
||||
export OPENROUTER_API_KEY="sk-or-v1-your-key-here"
|
||||
```
|
||||
|
||||
The settings file takes precedence over the environment variable.
|
||||
|
||||
## Model Selection Guide
|
||||
|
||||
### For Free Usage (No Cost)
|
||||
|
||||
**Recommended**: `xiaomi/mimo-v2-flash:free`
|
||||
- Best-in-class performance on coding benchmarks
|
||||
- 256K context window handles large observations
|
||||
- 65K max completion tokens
|
||||
- Mixture-of-experts architecture (15B active parameters)
|
||||
|
||||
**Alternatives**:
|
||||
- `google/gemini-2.0-flash-exp:free` - 1M context, Google's flagship
|
||||
- `deepseek/deepseek-r1:free` - Excellent reasoning capabilities
|
||||
- `meta-llama/llama-3.1-70b-instruct:free` - Strong general purpose
|
||||
|
||||
### For Paid Usage (Higher Quality/Speed)
|
||||
|
||||
| Model | Price (per 1M tokens) | Best For |
|
||||
|-------|----------------------|----------|
|
||||
| `anthropic/claude-3.5-sonnet` | $3 in / $15 out | Highest quality observations |
|
||||
| `google/gemini-2.0-flash` | $0.075 in / $0.30 out | Fast, cost-effective |
|
||||
| `openai/gpt-4o` | $2.50 in / $10 out | GPT-4 quality |
|
||||
|
||||
## Context Window Management
|
||||
|
||||
OpenRouter agent implements intelligent context management to prevent runaway costs:
|
||||
|
||||
### Automatic Truncation
|
||||
|
||||
The agent uses a sliding window strategy:
|
||||
1. Checks if message count exceeds `MAX_CONTEXT_MESSAGES` (default: 20)
|
||||
2. Checks if estimated tokens exceed `MAX_TOKENS` (default: 100,000)
|
||||
3. If limits exceeded, keeps most recent messages only
|
||||
4. Logs warnings with dropped message counts
|
||||
|
||||
### Token Estimation
|
||||
|
||||
- Conservative estimate: 1 token ≈ 4 characters
|
||||
- Used for proactive context management
|
||||
- Actual usage logged from API response
|
||||
|
||||
### Cost Tracking
|
||||
|
||||
Logs include detailed usage information:
|
||||
|
||||
```
|
||||
OpenRouter API usage: {
|
||||
model: "xiaomi/mimo-v2-flash:free",
|
||||
inputTokens: 2500,
|
||||
outputTokens: 1200,
|
||||
totalTokens: 3700,
|
||||
estimatedCostUSD: "0.00",
|
||||
messagesInContext: 8
|
||||
}
|
||||
```
|
||||
|
||||
## Provider Switching
|
||||
|
||||
You can switch between providers at any time:
|
||||
|
||||
- **No restart required**: Changes take effect on the next observation
|
||||
- **Conversation history preserved**: When switching mid-session, the new provider sees the full conversation context
|
||||
- **Seamless transition**: All providers use the same observation format
|
||||
|
||||
### Switching via UI
|
||||
|
||||
1. Open Settings in the viewer
|
||||
2. Change the **AI Provider** dropdown
|
||||
3. The next observation will use the new provider
|
||||
|
||||
### Switching via Settings File
|
||||
|
||||
```json
|
||||
{
|
||||
"CLAUDE_MEM_PROVIDER": "openrouter"
|
||||
}
|
||||
```
|
||||
|
||||
## Fallback Behavior
|
||||
|
||||
If OpenRouter encounters errors, claude-mem automatically falls back to the Claude Agent SDK:
|
||||
|
||||
**Triggers fallback:**
|
||||
- Rate limiting (HTTP 429)
|
||||
- Server errors (HTTP 500, 502, 503)
|
||||
- Network issues (connection refused, timeout)
|
||||
- Generic fetch failures
|
||||
|
||||
**Does not trigger fallback:**
|
||||
- Missing API key (logs warning, uses Claude from start)
|
||||
- Invalid API key (fails with error)
|
||||
|
||||
When fallback occurs:
|
||||
1. A warning is logged
|
||||
2. Any in-progress messages are reset to pending
|
||||
3. Claude SDK takes over with the full conversation context
|
||||
|
||||
<Note>
|
||||
**Fallback is transparent**: Your observations continue processing without interruption. The fallback preserves all conversation context.
|
||||
</Note>
|
||||
|
||||
## Multi-Turn Conversation Support
|
||||
|
||||
OpenRouter agent maintains full conversation history across API calls:
|
||||
|
||||
```
|
||||
Session Created
|
||||
↓
|
||||
Load Pending Messages (observations from queue)
|
||||
↓
|
||||
For each message:
|
||||
→ Add to conversation history
|
||||
→ Call OpenRouter API with FULL history
|
||||
→ Parse XML response
|
||||
→ Store observations in database
|
||||
→ Sync to Chroma vector DB
|
||||
↓
|
||||
Session complete
|
||||
```
|
||||
|
||||
This enables:
|
||||
- Coherent multi-turn exchanges
|
||||
- Context preservation across observations
|
||||
- Seamless provider switching mid-session
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "OpenRouter API key not configured"
|
||||
|
||||
Either:
|
||||
- Set `CLAUDE_MEM_OPENROUTER_API_KEY` in `~/.claude-mem/settings.json`, or
|
||||
- Set the `OPENROUTER_API_KEY` environment variable
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
Free models may have rate limits during peak usage. If you hit rate limits:
|
||||
- Claude-mem automatically falls back to Claude SDK
|
||||
- Consider switching to a different free model
|
||||
- Add credits for premium model access
|
||||
|
||||
### Model Not Found
|
||||
|
||||
Verify the model ID is correct:
|
||||
- Check [OpenRouter Models](https://openrouter.ai/models) for current availability
|
||||
- Use the `:free` suffix for free model variants
|
||||
- Model IDs are case-sensitive
|
||||
|
||||
### High Token Usage Warning
|
||||
|
||||
If you see warnings about high token usage (>50,000 per request):
|
||||
- Reduce `CLAUDE_MEM_OPENROUTER_MAX_CONTEXT_MESSAGES`
|
||||
- Reduce `CLAUDE_MEM_OPENROUTER_MAX_TOKENS`
|
||||
- Consider a model with larger context window
|
||||
|
||||
### Connection Errors
|
||||
|
||||
If you see connection errors:
|
||||
- Check your internet connection
|
||||
- Verify OpenRouter service status at [status.openrouter.ai](https://status.openrouter.ai)
|
||||
- The agent will automatically fall back to Claude
|
||||
|
||||
## API Details
|
||||
|
||||
OpenRouter uses an OpenAI-compatible REST API:
|
||||
|
||||
**Endpoint**: `https://openrouter.ai/api/v1/chat/completions`
|
||||
|
||||
**Headers**:
|
||||
```
|
||||
Authorization: Bearer {apiKey}
|
||||
HTTP-Referer: https://github.com/thedotmack/claude-mem
|
||||
X-Title: claude-mem
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
**Request Format**:
|
||||
```json
|
||||
{
|
||||
"model": "xiaomi/mimo-v2-flash:free",
|
||||
"messages": [
|
||||
{"role": "system", "content": "..."},
|
||||
{"role": "user", "content": "..."}
|
||||
],
|
||||
"temperature": 0.3,
|
||||
"max_tokens": 4096
|
||||
}
|
||||
```
|
||||
|
||||
## Comparing Providers
|
||||
|
||||
| Feature | Claude (SDK) | Gemini | OpenRouter |
|
||||
|---------|-------------|--------|------------|
|
||||
| **Cost** | Pay per token | Free tier + paid | Free models + paid |
|
||||
| **Models** | Claude only | Gemini only | 100+ models |
|
||||
| **Quality** | Highest | High | Varies by model |
|
||||
| **Rate limits** | Based on tier | 5-4000 RPM | Varies by model |
|
||||
| **Fallback** | N/A (primary) | → Claude | → Claude |
|
||||
| **Setup** | Automatic | API key required | API key required |
|
||||
|
||||
<Tip>
|
||||
**Recommendation**: Start with OpenRouter's free `xiaomi/mimo-v2-flash:free` model for zero-cost observation extraction. If you need higher quality or encounter rate limits, switch to Claude or add OpenRouter credits for premium models.
|
||||
</Tip>
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Configuration](../configuration) - Full settings reference
|
||||
- [Gemini Provider](gemini-provider) - Alternative free provider
|
||||
- [Getting Started](getting-started) - Basic usage guide
|
||||
- [Troubleshooting](../troubleshooting) - Common issues
|
||||
@@ -176,7 +176,7 @@ This design ensures that private content never reaches the database, search indi
|
||||
1. Verify correct syntax: `<private>content</private>`
|
||||
2. Check `~/.claude-mem/silent.log` for errors
|
||||
3. Ensure worker is running: `npm run worker:status`
|
||||
4. Restart worker: `claude-mem restart`
|
||||
4. Restart worker: `npm run worker:restart`
|
||||
|
||||
### Partial Content Stored
|
||||
|
||||
|
||||
@@ -364,7 +364,7 @@ If search isn't working, check the worker service:
|
||||
|
||||
```bash
|
||||
npm run worker:status # Check worker status
|
||||
claude-mem restart # Restart if needed
|
||||
npm run worker:restart # Restart if needed
|
||||
npm run worker:logs # View logs
|
||||
```
|
||||
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claude-mem",
|
||||
"version": "8.2.0",
|
||||
"version": "8.2.4",
|
||||
"description": "Memory compression system for Claude Code - persist context across sessions",
|
||||
"keywords": [
|
||||
"claude",
|
||||
@@ -31,6 +31,7 @@
|
||||
"bun": ">=1.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "npm run build-and-sync",
|
||||
"build": "node scripts/build-hooks.js",
|
||||
"build-and-sync": "npm run build && npm run sync-marketplace && sleep 1 && cd ~/.claude/plugins/marketplaces/thedotmack && npm run worker:restart",
|
||||
"sync-marketplace": "node scripts/sync-marketplace.cjs",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claude-mem",
|
||||
"version": "8.2.0",
|
||||
"version": "8.2.4",
|
||||
"description": "Persistent memory system for Claude Code - seamlessly preserve context across sessions",
|
||||
"author": {
|
||||
"name": "Alex Newman"
|
||||
|
||||
+17
-12
@@ -7,12 +7,17 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bun \"${CLAUDE_PLUGIN_ROOT}/scripts/worker-cli.js\" restart",
|
||||
"timeout": 30
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/smart-install.js\"",
|
||||
"timeout": 300
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/smart-install.js\" && node \"${CLAUDE_PLUGIN_ROOT}/scripts/context-hook.js\"",
|
||||
"command": "bun \"${CLAUDE_PLUGIN_ROOT}/scripts/worker-service.cjs\" start",
|
||||
"timeout": 180
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/context-hook.js\"",
|
||||
"timeout": 300
|
||||
},
|
||||
{
|
||||
@@ -28,13 +33,13 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bun \"${CLAUDE_PLUGIN_ROOT}/scripts/worker-cli.js\" start",
|
||||
"timeout": 30
|
||||
"command": "bun \"${CLAUDE_PLUGIN_ROOT}/scripts/worker-service.cjs\" start",
|
||||
"timeout": 180
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/new-hook.js\"",
|
||||
"timeout": 120
|
||||
"timeout": 300
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -45,13 +50,13 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bun \"${CLAUDE_PLUGIN_ROOT}/scripts/worker-cli.js\" start",
|
||||
"timeout": 30
|
||||
"command": "bun \"${CLAUDE_PLUGIN_ROOT}/scripts/worker-service.cjs\" start",
|
||||
"timeout": 180
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/save-hook.js\"",
|
||||
"timeout": 120
|
||||
"timeout": 300
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -61,13 +66,13 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bun \"${CLAUDE_PLUGIN_ROOT}/scripts/worker-cli.js\" start",
|
||||
"timeout": 30
|
||||
"command": "bun \"${CLAUDE_PLUGIN_ROOT}/scripts/worker-service.cjs\" start",
|
||||
"timeout": 180
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/summary-hook.js\"",
|
||||
"timeout": 120
|
||||
"timeout": 300
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claude-mem-plugin",
|
||||
"version": "8.1.0",
|
||||
"version": "8.2.4",
|
||||
"private": true,
|
||||
"description": "Runtime dependencies for claude-mem bundled hooks",
|
||||
"type": "module",
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,14 +1,17 @@
|
||||
#!/usr/bin/env bun
|
||||
import{stdin as C}from"process";import M from"path";import{homedir as x}from"os";import{readFileSync as b}from"fs";import{readFileSync as $,writeFileSync as v,existsSync as P}from"fs";import{join as w}from"path";import{homedir as W}from"os";var m="bugfix,feature,refactor,discovery,decision,change",D="how-it-works,why-it-exists,what-changed,problem-solution,gotcha,pattern,trade-off";var c=class{static DEFAULTS={CLAUDE_MEM_MODEL:"claude-sonnet-4-5",CLAUDE_MEM_CONTEXT_OBSERVATIONS:"50",CLAUDE_MEM_WORKER_PORT:"37777",CLAUDE_MEM_WORKER_HOST:"127.0.0.1",CLAUDE_MEM_SKIP_TOOLS:"ListMcpResourcesTool,SlashCommand,Skill,TodoWrite,AskUserQuestion",CLAUDE_MEM_PROVIDER:"claude",CLAUDE_MEM_GEMINI_API_KEY:"",CLAUDE_MEM_GEMINI_MODEL:"gemini-2.5-flash-lite",CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED:"true",CLAUDE_MEM_DATA_DIR:w(W(),".claude-mem"),CLAUDE_MEM_LOG_LEVEL:"INFO",CLAUDE_MEM_PYTHON_VERSION:"3.13",CLAUDE_CODE_PATH:"",CLAUDE_MEM_MODE:"code",CLAUDE_MEM_CONTEXT_SHOW_READ_TOKENS:"true",CLAUDE_MEM_CONTEXT_SHOW_WORK_TOKENS:"true",CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_AMOUNT:"true",CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_PERCENT:"true",CLAUDE_MEM_CONTEXT_OBSERVATION_TYPES:m,CLAUDE_MEM_CONTEXT_OBSERVATION_CONCEPTS:D,CLAUDE_MEM_CONTEXT_FULL_COUNT:"5",CLAUDE_MEM_CONTEXT_FULL_FIELD:"narrative",CLAUDE_MEM_CONTEXT_SESSION_COUNT:"10",CLAUDE_MEM_CONTEXT_SHOW_LAST_SUMMARY:"true",CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE:"false"};static getAllDefaults(){return{...this.DEFAULTS}}static get(t){return this.DEFAULTS[t]}static getInt(t){let r=this.get(t);return parseInt(r,10)}static getBool(t){return this.get(t)==="true"}static loadFromFile(t){try{if(!P(t))return this.getAllDefaults();let r=$(t,"utf-8"),e=JSON.parse(r),n=e;if(e.env&&typeof e.env=="object"){n=e.env;try{v(t,JSON.stringify(n,null,2),"utf-8"),a.info("SETTINGS","Migrated settings file from nested to flat schema",{settingsPath:t})}catch(i){a.warn("SETTINGS","Failed to auto-migrate settings file",{settingsPath:t},i)}}let s={...this.DEFAULTS};for(let i of Object.keys(this.DEFAULTS))n[i]!==void 0&&(s[i]=n[i]);return s}catch(r){return a.warn("SETTINGS","Failed to load settings, using defaults",{settingsPath:t},r),this.getAllDefaults()}}};var p=(s=>(s[s.DEBUG=0]="DEBUG",s[s.INFO=1]="INFO",s[s.WARN=2]="WARN",s[s.ERROR=3]="ERROR",s[s.SILENT=4]="SILENT",s))(p||{}),O=class{level=null;useColor;constructor(){this.useColor=process.stdout.isTTY??!1}getLevel(){if(this.level===null){let t=c.get("CLAUDE_MEM_LOG_LEVEL").toUpperCase();this.level=p[t]??1}return this.level}correlationId(t,r){return`obs-${t}-${r}`}sessionId(t){return`session-${t}`}formatData(t){if(t==null)return"";if(typeof t=="string")return t;if(typeof t=="number"||typeof t=="boolean")return t.toString();if(typeof t=="object"){if(t instanceof Error)return this.getLevel()===0?`${t.message}
|
||||
${t.stack}`:t.message;if(Array.isArray(t))return`[${t.length} items]`;let r=Object.keys(t);return r.length===0?"{}":r.length<=3?JSON.stringify(t):`{${r.length} keys: ${r.slice(0,3).join(", ")}...}`}return String(t)}formatTool(t,r){if(!r)return t;let e=typeof r=="string"?JSON.parse(r):r;if(t==="Bash"&&e.command)return`${t}(${e.command})`;if(e.file_path)return`${t}(${e.file_path})`;if(e.notebook_path)return`${t}(${e.notebook_path})`;if(t==="Glob"&&e.pattern)return`${t}(${e.pattern})`;if(t==="Grep"&&e.pattern)return`${t}(${e.pattern})`;if(e.url)return`${t}(${e.url})`;if(e.query)return`${t}(${e.query})`;if(t==="Task"){if(e.subagent_type)return`${t}(${e.subagent_type})`;if(e.description)return`${t}(${e.description})`}return t==="Skill"&&e.skill?`${t}(${e.skill})`:t==="LSP"&&e.operation?`${t}(${e.operation})`:t}formatTimestamp(t){let r=t.getFullYear(),e=String(t.getMonth()+1).padStart(2,"0"),n=String(t.getDate()).padStart(2,"0"),s=String(t.getHours()).padStart(2,"0"),i=String(t.getMinutes()).padStart(2,"0"),E=String(t.getSeconds()).padStart(2,"0"),S=String(t.getMilliseconds()).padStart(3,"0");return`${r}-${e}-${n} ${s}:${i}:${E}.${S}`}log(t,r,e,n,s){if(t<this.getLevel())return;let i=this.formatTimestamp(new Date),E=p[t].padEnd(5),S=r.padEnd(6),_="";n?.correlationId?_=`[${n.correlationId}] `:n?.sessionId&&(_=`[session-${n.sessionId}] `);let l="";s!=null&&(this.getLevel()===0&&typeof s=="object"?l=`
|
||||
`+JSON.stringify(s,null,2):l=" "+this.formatData(s));let T="";if(n){let{sessionId:B,sdkSessionId:Y,correlationId:J,...A}=n;Object.keys(A).length>0&&(T=` {${Object.entries(A).map(([k,y])=>`${k}=${y}`).join(", ")}}`)}let L=`[${i}] [${E}] [${S}] ${_}${e}${T}${l}`;t===3?console.error(L):console.log(L)}debug(t,r,e,n){this.log(0,t,r,e,n)}info(t,r,e,n){this.log(1,t,r,e,n)}warn(t,r,e,n){this.log(2,t,r,e,n)}error(t,r,e,n){this.log(3,t,r,e,n)}dataIn(t,r,e,n){this.info(t,`\u2192 ${r}`,e,n)}dataOut(t,r,e,n){this.info(t,`\u2190 ${r}`,e,n)}success(t,r,e,n){this.info(t,`\u2713 ${r}`,e,n)}failure(t,r,e,n){this.error(t,`\u2717 ${r}`,e,n)}timing(t,r,e,n){this.info(t,`\u23F1 ${r}`,n,{duration:`${e}ms`})}happyPathError(t,r,e,n,s=""){let _=((new Error().stack||"").split(`
|
||||
`)[2]||"").match(/at\s+(?:.*\s+)?\(?([^:]+):(\d+):(\d+)\)?/),l=_?`${_[1].split("/").pop()}:${_[2]}`:"unknown",T={...e,location:l};return this.warn(t,`[HAPPY-PATH] ${r}`,T,n),s}},a=new O;var g={DEFAULT:12e4,HEALTH_CHECK:1e3,WORKER_STARTUP_WAIT:1e3,WORKER_STARTUP_RETRIES:15,PRE_RESTART_SETTLE_DELAY:2e3,WINDOWS_MULTIPLIER:1.5};function d(o){return process.platform==="win32"?Math.round(o*g.WINDOWS_MULTIPLIER):o}function I(o={}){let{port:t,includeSkillFallback:r=!1,customPrefix:e,actualError:n}=o,s=e||"Worker service connection failed.",i=t?` (port ${t})`:"",E=`${s}${i}
|
||||
import{stdin as C}from"process";import A from"path";import{homedir as K}from"os";import{readFileSync as X}from"fs";import{readFileSync as v,writeFileSync as w,existsSync as F}from"fs";import{join as W}from"path";import{homedir as x}from"os";var d="bugfix,feature,refactor,discovery,decision,change",R="how-it-works,why-it-exists,what-changed,problem-solution,gotcha,pattern,trade-off";var a=class{static DEFAULTS={CLAUDE_MEM_MODEL:"claude-sonnet-4-5",CLAUDE_MEM_CONTEXT_OBSERVATIONS:"50",CLAUDE_MEM_WORKER_PORT:"37777",CLAUDE_MEM_WORKER_HOST:"127.0.0.1",CLAUDE_MEM_SKIP_TOOLS:"ListMcpResourcesTool,SlashCommand,Skill,TodoWrite,AskUserQuestion",CLAUDE_MEM_PROVIDER:"claude",CLAUDE_MEM_GEMINI_API_KEY:"",CLAUDE_MEM_GEMINI_MODEL:"gemini-2.5-flash-lite",CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED:"true",CLAUDE_MEM_OPENROUTER_API_KEY:"",CLAUDE_MEM_OPENROUTER_MODEL:"xiaomi/mimo-v2-flash:free",CLAUDE_MEM_OPENROUTER_SITE_URL:"",CLAUDE_MEM_OPENROUTER_APP_NAME:"claude-mem",CLAUDE_MEM_OPENROUTER_MAX_CONTEXT_MESSAGES:"20",CLAUDE_MEM_OPENROUTER_MAX_TOKENS:"100000",CLAUDE_MEM_DATA_DIR:W(x(),".claude-mem"),CLAUDE_MEM_LOG_LEVEL:"INFO",CLAUDE_MEM_PYTHON_VERSION:"3.13",CLAUDE_CODE_PATH:"",CLAUDE_MEM_MODE:"code",CLAUDE_MEM_CONTEXT_SHOW_READ_TOKENS:"true",CLAUDE_MEM_CONTEXT_SHOW_WORK_TOKENS:"true",CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_AMOUNT:"true",CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_PERCENT:"true",CLAUDE_MEM_CONTEXT_OBSERVATION_TYPES:d,CLAUDE_MEM_CONTEXT_OBSERVATION_CONCEPTS:R,CLAUDE_MEM_CONTEXT_FULL_COUNT:"5",CLAUDE_MEM_CONTEXT_FULL_FIELD:"narrative",CLAUDE_MEM_CONTEXT_SESSION_COUNT:"10",CLAUDE_MEM_CONTEXT_SHOW_LAST_SUMMARY:"true",CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE:"false"};static getAllDefaults(){return{...this.DEFAULTS}}static get(t){return this.DEFAULTS[t]}static getInt(t){let r=this.get(t);return parseInt(r,10)}static getBool(t){return this.get(t)==="true"}static loadFromFile(t){try{if(!F(t))return this.getAllDefaults();let r=v(t,"utf-8"),e=JSON.parse(r),n=e;if(e.env&&typeof e.env=="object"){n=e.env;try{w(t,JSON.stringify(n,null,2),"utf-8"),_.info("SETTINGS","Migrated settings file from nested to flat schema",{settingsPath:t})}catch(s){_.warn("SETTINGS","Failed to auto-migrate settings file",{settingsPath:t},s)}}let i={...this.DEFAULTS};for(let s of Object.keys(this.DEFAULTS))n[s]!==void 0&&(i[s]=n[s]);return i}catch(r){return _.warn("SETTINGS","Failed to load settings, using defaults",{settingsPath:t},r),this.getAllDefaults()}}};import{appendFileSync as b,existsSync as H,mkdirSync as G}from"fs";import{join as S}from"path";var p=(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))(p||{}),f=class{level=null;useColor;logFilePath=null;constructor(){this.useColor=process.stdout.isTTY??!1,this.initializeLogFile()}initializeLogFile(){try{let t=a.get("CLAUDE_MEM_DATA_DIR"),r=S(t,"logs");H(r)||G(r,{recursive:!0});let e=new Date().toISOString().split("T")[0];this.logFilePath=S(r,`claude-mem-${e}.log`)}catch(t){console.error("[LOGGER] Failed to initialize log file:",t),this.logFilePath=null}}getLevel(){if(this.level===null)try{let t=a.get("CLAUDE_MEM_DATA_DIR"),r=S(t,"settings.json"),n=a.loadFromFile(r).CLAUDE_MEM_LOG_LEVEL.toUpperCase();this.level=p[n]??1}catch(t){console.error("[LOGGER] Failed to load settings, using INFO level:",t),this.level=1}return this.level}correlationId(t,r){return`obs-${t}-${r}`}sessionId(t){return`session-${t}`}formatData(t){if(t==null)return"";if(typeof t=="string")return t;if(typeof t=="number"||typeof t=="boolean")return t.toString();if(typeof t=="object"){if(t instanceof Error)return this.getLevel()===0?`${t.message}
|
||||
${t.stack}`:t.message;if(Array.isArray(t))return`[${t.length} items]`;let r=Object.keys(t);return r.length===0?"{}":r.length<=3?JSON.stringify(t):`{${r.length} keys: ${r.slice(0,3).join(", ")}...}`}return String(t)}formatTool(t,r){if(!r)return t;let e=typeof r=="string"?JSON.parse(r):r;if(t==="Bash"&&e.command)return`${t}(${e.command})`;if(e.file_path)return`${t}(${e.file_path})`;if(e.notebook_path)return`${t}(${e.notebook_path})`;if(t==="Glob"&&e.pattern)return`${t}(${e.pattern})`;if(t==="Grep"&&e.pattern)return`${t}(${e.pattern})`;if(e.url)return`${t}(${e.url})`;if(e.query)return`${t}(${e.query})`;if(t==="Task"){if(e.subagent_type)return`${t}(${e.subagent_type})`;if(e.description)return`${t}(${e.description})`}return t==="Skill"&&e.skill?`${t}(${e.skill})`:t==="LSP"&&e.operation?`${t}(${e.operation})`:t}formatTimestamp(t){let r=t.getFullYear(),e=String(t.getMonth()+1).padStart(2,"0"),n=String(t.getDate()).padStart(2,"0"),i=String(t.getHours()).padStart(2,"0"),s=String(t.getMinutes()).padStart(2,"0"),E=String(t.getSeconds()).padStart(2,"0"),T=String(t.getMilliseconds()).padStart(3,"0");return`${r}-${e}-${n} ${i}:${s}:${E}.${T}`}log(t,r,e,n,i){if(t<this.getLevel())return;let s=this.formatTimestamp(new Date),E=p[t].padEnd(5),T=r.padEnd(6),l="";n?.correlationId?l=`[${n.correlationId}] `:n?.sessionId&&(l=`[session-${n.sessionId}] `);let c="";i!=null&&(this.getLevel()===0&&typeof i=="object"?c=`
|
||||
`+JSON.stringify(i,null,2):c=" "+this.formatData(i));let O="";if(n){let{sessionId:D,sdkSessionId:Q,correlationId:Z,...m}=n;Object.keys(m).length>0&&(O=` {${Object.entries(m).map(([P,$])=>`${P}=${$}`).join(", ")}}`)}let L=`[${s}] [${E}] [${T}] ${l}${e}${O}${c}`;if(this.logFilePath)try{b(this.logFilePath,L+`
|
||||
`,"utf8")}catch(D){process.stderr.write(`[LOGGER] Failed to write to log file: ${D}
|
||||
`)}else process.stderr.write(L+`
|
||||
`)}debug(t,r,e,n){this.log(0,t,r,e,n)}info(t,r,e,n){this.log(1,t,r,e,n)}warn(t,r,e,n){this.log(2,t,r,e,n)}error(t,r,e,n){this.log(3,t,r,e,n)}dataIn(t,r,e,n){this.info(t,`\u2192 ${r}`,e,n)}dataOut(t,r,e,n){this.info(t,`\u2190 ${r}`,e,n)}success(t,r,e,n){this.info(t,`\u2713 ${r}`,e,n)}failure(t,r,e,n){this.error(t,`\u2717 ${r}`,e,n)}timing(t,r,e,n){this.info(t,`\u23F1 ${r}`,n,{duration:`${e}ms`})}happyPathError(t,r,e,n,i=""){let l=((new Error().stack||"").split(`
|
||||
`)[2]||"").match(/at\s+(?:.*\s+)?\(?([^:]+):(\d+):(\d+)\)?/),c=l?`${l[1].split("/").pop()}:${l[2]}`:"unknown",O={...e,location:c};return this.warn(t,`[HAPPY-PATH] ${r}`,O,n),i}},_=new f;var g={DEFAULT:3e5,HEALTH_CHECK:3e4,WORKER_STARTUP_WAIT:1e3,WORKER_STARTUP_RETRIES:300,PRE_RESTART_SETTLE_DELAY:2e3,WINDOWS_MULTIPLIER:1.5};function U(o){return process.platform==="win32"?Math.round(o*g.WINDOWS_MULTIPLIER):o}function h(o={}){let{port:t,includeSkillFallback:r=!1,customPrefix:e,actualError:n}=o,i=e||"Worker service connection failed.",s=t?` (port ${t})`:"",E=`${i}${s}
|
||||
|
||||
`;return E+=`To restart the worker:
|
||||
`,E+=`1. Exit Claude Code completely
|
||||
`,E+=`2. Run: claude-mem restart
|
||||
`,E+=`2. Run: npm run worker:restart
|
||||
`,E+="3. Restart Claude Code",r&&(E+=`
|
||||
|
||||
If that doesn't work, try: /troubleshoot`),n&&(E=`Worker Error: ${n}
|
||||
|
||||
${E}`),E}var H=M.join(x(),".claude","plugins","marketplaces","thedotmack"),R=d(g.HEALTH_CHECK),f=null;function u(){if(f!==null)return f;let o=M.join(c.get("CLAUDE_MEM_DATA_DIR"),"settings.json"),t=c.loadFromFile(o);return f=parseInt(t.CLAUDE_MEM_WORKER_PORT,10),f}async function F(){let o=u();return(await fetch(`http://127.0.0.1:${o}/api/readiness`,{signal:AbortSignal.timeout(R)})).ok}function G(){let o=M.join(H,"package.json");return JSON.parse(b(o,"utf-8")).version}async function K(){let o=u(),t=await fetch(`http://127.0.0.1:${o}/api/version`,{signal:AbortSignal.timeout(R)});if(!t.ok)throw new Error(`Failed to get worker version: ${t.status}`);return(await t.json()).version}async function j(){let o=G(),t=await K();o!==t&&a.warn("SYSTEM","Worker version mismatch",{pluginVersion:o,workerVersion:t,hint:"Restart worker with: claude-mem worker restart"})}async function U(){for(let r=0;r<25;r++){try{if(await F()){await j();return}}catch{}await new Promise(e=>setTimeout(e,200))}throw new Error(I({port:u(),customPrefix:"Worker did not become ready within 5 seconds."}))}import V from"path";function h(o){if(!o||o.trim()==="")return a.warn("PROJECT_NAME","Empty cwd provided, using fallback",{cwd:o}),"unknown-project";let t=V.basename(o);if(t===""){if(process.platform==="win32"){let e=o.match(/^([A-Z]):\\/i);if(e){let s=`drive-${e[1].toUpperCase()}`;return a.info("PROJECT_NAME","Drive root detected",{cwd:o,projectName:s}),s}}return a.warn("PROJECT_NAME","Root directory detected, using fallback",{cwd:o}),"unknown-project"}return t}async function N(o){await U();let t=o?.cwd??process.cwd(),r=h(t),n=`http://127.0.0.1:${u()}/api/context/inject?project=${encodeURIComponent(r)}`,s=await fetch(n,{signal:AbortSignal.timeout(g.DEFAULT)});if(!s.ok)throw new Error(`Context generation failed: ${s.status}`);return(await s.text()).trim()}var X=process.argv.includes("--colors");if(C.isTTY||X)N(void 0).then(o=>{console.log(o),process.exit(0)});else{let o="";C.on("data",t=>o+=t),C.on("end",async()=>{let t;try{t=o.trim()?JSON.parse(o):void 0}catch(e){throw new Error(`Failed to parse hook input: ${e instanceof Error?e.message:String(e)}`)}let r=await N(t);console.log(JSON.stringify({hookSpecificOutput:{hookEventName:"SessionStart",additionalContext:r}})),process.exit(0)})}
|
||||
${E}`),E}var j=A.join(K(),".claude","plugins","marketplaces","thedotmack"),I=U(g.HEALTH_CHECK),M=null;function u(){if(M!==null)return M;let o=A.join(a.get("CLAUDE_MEM_DATA_DIR"),"settings.json"),t=a.loadFromFile(o);return M=parseInt(t.CLAUDE_MEM_WORKER_PORT,10),M}async function V(){let o=u();return(await fetch(`http://127.0.0.1:${o}/api/readiness`,{signal:AbortSignal.timeout(I)})).ok}function Y(){let o=A.join(j,"package.json");return JSON.parse(X(o,"utf-8")).version}async function B(){let o=u(),t=await fetch(`http://127.0.0.1:${o}/api/version`,{signal:AbortSignal.timeout(I)});if(!t.ok)throw new Error(`Failed to get worker version: ${t.status}`);return(await t.json()).version}async function J(){let o=Y(),t=await B();o!==t&&_.warn("SYSTEM","Worker version mismatch",{pluginVersion:o,workerVersion:t,hint:"Restart worker with: claude-mem worker restart"})}async function N(){for(let r=0;r<25;r++){try{if(await V()){await J();return}}catch{}await new Promise(e=>setTimeout(e,200))}throw new Error(h({port:u(),customPrefix:"Worker did not become ready within 5 seconds."}))}import z from"path";function k(o){if(!o||o.trim()==="")return _.warn("PROJECT_NAME","Empty cwd provided, using fallback",{cwd:o}),"unknown-project";let t=z.basename(o);if(t===""){if(process.platform==="win32"){let e=o.match(/^([A-Z]):\\/i);if(e){let i=`drive-${e[1].toUpperCase()}`;return _.info("PROJECT_NAME","Drive root detected",{cwd:o,projectName:i}),i}}return _.warn("PROJECT_NAME","Root directory detected, using fallback",{cwd:o}),"unknown-project"}return t}async function y(o){await N();let t=o?.cwd??process.cwd(),r=k(t),n=`http://127.0.0.1:${u()}/api/context/inject?project=${encodeURIComponent(r)}`,i=await fetch(n,{signal:AbortSignal.timeout(g.DEFAULT)});if(!i.ok)throw new Error(`Context generation failed: ${i.status}`);return(await i.text()).trim()}var q=process.argv.includes("--colors");if(C.isTTY||q)y(void 0).then(o=>{console.log(o),process.exit(0)});else{let o="";C.on("data",t=>o+=t),C.on("end",async()=>{let t;try{t=o.trim()?JSON.parse(o):void 0}catch(e){throw new Error(`Failed to parse hook input: ${e instanceof Error?e.message:String(e)}`)}let r=await y(t);console.log(JSON.stringify({hookSpecificOutput:{hookEventName:"SessionStart",additionalContext:r}})),process.exit(0)})}
|
||||
|
||||
File diff suppressed because one or more lines are too long
+13
-10
@@ -1,14 +1,17 @@
|
||||
#!/usr/bin/env bun
|
||||
import{stdin as k}from"process";var f=JSON.stringify({continue:!0,suppressOutput:!0});import C from"path";import{homedir as H}from"os";import{readFileSync as x}from"fs";import{readFileSync as P,writeFileSync as v,existsSync as w}from"fs";import{join as b}from"path";import{homedir as W}from"os";var D="bugfix,feature,refactor,discovery,decision,change",d="how-it-works,why-it-exists,what-changed,problem-solution,gotcha,pattern,trade-off";var g=class{static DEFAULTS={CLAUDE_MEM_MODEL:"claude-sonnet-4-5",CLAUDE_MEM_CONTEXT_OBSERVATIONS:"50",CLAUDE_MEM_WORKER_PORT:"37777",CLAUDE_MEM_WORKER_HOST:"127.0.0.1",CLAUDE_MEM_SKIP_TOOLS:"ListMcpResourcesTool,SlashCommand,Skill,TodoWrite,AskUserQuestion",CLAUDE_MEM_PROVIDER:"claude",CLAUDE_MEM_GEMINI_API_KEY:"",CLAUDE_MEM_GEMINI_MODEL:"gemini-2.5-flash-lite",CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED:"true",CLAUDE_MEM_DATA_DIR:b(W(),".claude-mem"),CLAUDE_MEM_LOG_LEVEL:"INFO",CLAUDE_MEM_PYTHON_VERSION:"3.13",CLAUDE_CODE_PATH:"",CLAUDE_MEM_MODE:"code",CLAUDE_MEM_CONTEXT_SHOW_READ_TOKENS:"true",CLAUDE_MEM_CONTEXT_SHOW_WORK_TOKENS:"true",CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_AMOUNT:"true",CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_PERCENT:"true",CLAUDE_MEM_CONTEXT_OBSERVATION_TYPES:D,CLAUDE_MEM_CONTEXT_OBSERVATION_CONCEPTS:d,CLAUDE_MEM_CONTEXT_FULL_COUNT:"5",CLAUDE_MEM_CONTEXT_FULL_FIELD:"narrative",CLAUDE_MEM_CONTEXT_SESSION_COUNT:"10",CLAUDE_MEM_CONTEXT_SHOW_LAST_SUMMARY:"true",CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE:"false"};static getAllDefaults(){return{...this.DEFAULTS}}static get(t){return this.DEFAULTS[t]}static getInt(t){let r=this.get(t);return parseInt(r,10)}static getBool(t){return this.get(t)==="true"}static loadFromFile(t){try{if(!w(t))return this.getAllDefaults();let r=P(t,"utf-8"),e=JSON.parse(r),n=e;if(e.env&&typeof e.env=="object"){n=e.env;try{v(t,JSON.stringify(n,null,2),"utf-8"),_.info("SETTINGS","Migrated settings file from nested to flat schema",{settingsPath:t})}catch(E){_.warn("SETTINGS","Failed to auto-migrate settings file",{settingsPath:t},E)}}let s={...this.DEFAULTS};for(let E of Object.keys(this.DEFAULTS))n[E]!==void 0&&(s[E]=n[E]);return s}catch(r){return _.warn("SETTINGS","Failed to load settings, using defaults",{settingsPath:t},r),this.getAllDefaults()}}};var T=(s=>(s[s.DEBUG=0]="DEBUG",s[s.INFO=1]="INFO",s[s.WARN=2]="WARN",s[s.ERROR=3]="ERROR",s[s.SILENT=4]="SILENT",s))(T||{}),O=class{level=null;useColor;constructor(){this.useColor=process.stdout.isTTY??!1}getLevel(){if(this.level===null){let t=g.get("CLAUDE_MEM_LOG_LEVEL").toUpperCase();this.level=T[t]??1}return this.level}correlationId(t,r){return`obs-${t}-${r}`}sessionId(t){return`session-${t}`}formatData(t){if(t==null)return"";if(typeof t=="string")return t;if(typeof t=="number"||typeof t=="boolean")return t.toString();if(typeof t=="object"){if(t instanceof Error)return this.getLevel()===0?`${t.message}
|
||||
${t.stack}`:t.message;if(Array.isArray(t))return`[${t.length} items]`;let r=Object.keys(t);return r.length===0?"{}":r.length<=3?JSON.stringify(t):`{${r.length} keys: ${r.slice(0,3).join(", ")}...}`}return String(t)}formatTool(t,r){if(!r)return t;let e=typeof r=="string"?JSON.parse(r):r;if(t==="Bash"&&e.command)return`${t}(${e.command})`;if(e.file_path)return`${t}(${e.file_path})`;if(e.notebook_path)return`${t}(${e.notebook_path})`;if(t==="Glob"&&e.pattern)return`${t}(${e.pattern})`;if(t==="Grep"&&e.pattern)return`${t}(${e.pattern})`;if(e.url)return`${t}(${e.url})`;if(e.query)return`${t}(${e.query})`;if(t==="Task"){if(e.subagent_type)return`${t}(${e.subagent_type})`;if(e.description)return`${t}(${e.description})`}return t==="Skill"&&e.skill?`${t}(${e.skill})`:t==="LSP"&&e.operation?`${t}(${e.operation})`:t}formatTimestamp(t){let r=t.getFullYear(),e=String(t.getMonth()+1).padStart(2,"0"),n=String(t.getDate()).padStart(2,"0"),s=String(t.getHours()).padStart(2,"0"),E=String(t.getMinutes()).padStart(2,"0"),i=String(t.getSeconds()).padStart(2,"0"),c=String(t.getMilliseconds()).padStart(3,"0");return`${r}-${e}-${n} ${s}:${E}:${i}.${c}`}log(t,r,e,n,s){if(t<this.getLevel())return;let E=this.formatTimestamp(new Date),i=T[t].padEnd(5),c=r.padEnd(6),a="";n?.correlationId?a=`[${n.correlationId}] `:n?.sessionId&&(a=`[session-${n.sessionId}] `);let l="";s!=null&&(this.getLevel()===0&&typeof s=="object"?l=`
|
||||
`+JSON.stringify(s,null,2):l=" "+this.formatData(s));let u="";if(n){let{sessionId:Y,sdkSessionId:J,correlationId:q,...L}=n;Object.keys(L).length>0&&(u=` {${Object.entries(L).map(([y,$])=>`${y}=${$}`).join(", ")}}`)}let m=`[${E}] [${i}] [${c}] ${a}${e}${u}${l}`;t===3?console.error(m):console.log(m)}debug(t,r,e,n){this.log(0,t,r,e,n)}info(t,r,e,n){this.log(1,t,r,e,n)}warn(t,r,e,n){this.log(2,t,r,e,n)}error(t,r,e,n){this.log(3,t,r,e,n)}dataIn(t,r,e,n){this.info(t,`\u2192 ${r}`,e,n)}dataOut(t,r,e,n){this.info(t,`\u2190 ${r}`,e,n)}success(t,r,e,n){this.info(t,`\u2713 ${r}`,e,n)}failure(t,r,e,n){this.error(t,`\u2717 ${r}`,e,n)}timing(t,r,e,n){this.info(t,`\u23F1 ${r}`,n,{duration:`${e}ms`})}happyPathError(t,r,e,n,s=""){let a=((new Error().stack||"").split(`
|
||||
`)[2]||"").match(/at\s+(?:.*\s+)?\(?([^:]+):(\d+):(\d+)\)?/),l=a?`${a[1].split("/").pop()}:${a[2]}`:"unknown",u={...e,location:l};return this.warn(t,`[HAPPY-PATH] ${r}`,u,n),s}},_=new O;var M={DEFAULT:12e4,HEALTH_CHECK:1e3,WORKER_STARTUP_WAIT:1e3,WORKER_STARTUP_RETRIES:15,PRE_RESTART_SETTLE_DELAY:2e3,WINDOWS_MULTIPLIER:1.5};function R(o){return process.platform==="win32"?Math.round(o*M.WINDOWS_MULTIPLIER):o}function I(o={}){let{port:t,includeSkillFallback:r=!1,customPrefix:e,actualError:n}=o,s=e||"Worker service connection failed.",E=t?` (port ${t})`:"",i=`${s}${E}
|
||||
import{stdin as y}from"process";var S=JSON.stringify({continue:!0,suppressOutput:!0});import C from"path";import{homedir as X}from"os";import{readFileSync as j}from"fs";import{readFileSync as w,writeFileSync as b,existsSync as F}from"fs";import{join as H}from"path";import{homedir as W}from"os";var R="bugfix,feature,refactor,discovery,decision,change",U="how-it-works,why-it-exists,what-changed,problem-solution,gotcha,pattern,trade-off";var c=class{static DEFAULTS={CLAUDE_MEM_MODEL:"claude-sonnet-4-5",CLAUDE_MEM_CONTEXT_OBSERVATIONS:"50",CLAUDE_MEM_WORKER_PORT:"37777",CLAUDE_MEM_WORKER_HOST:"127.0.0.1",CLAUDE_MEM_SKIP_TOOLS:"ListMcpResourcesTool,SlashCommand,Skill,TodoWrite,AskUserQuestion",CLAUDE_MEM_PROVIDER:"claude",CLAUDE_MEM_GEMINI_API_KEY:"",CLAUDE_MEM_GEMINI_MODEL:"gemini-2.5-flash-lite",CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED:"true",CLAUDE_MEM_OPENROUTER_API_KEY:"",CLAUDE_MEM_OPENROUTER_MODEL:"xiaomi/mimo-v2-flash:free",CLAUDE_MEM_OPENROUTER_SITE_URL:"",CLAUDE_MEM_OPENROUTER_APP_NAME:"claude-mem",CLAUDE_MEM_OPENROUTER_MAX_CONTEXT_MESSAGES:"20",CLAUDE_MEM_OPENROUTER_MAX_TOKENS:"100000",CLAUDE_MEM_DATA_DIR:H(W(),".claude-mem"),CLAUDE_MEM_LOG_LEVEL:"INFO",CLAUDE_MEM_PYTHON_VERSION:"3.13",CLAUDE_CODE_PATH:"",CLAUDE_MEM_MODE:"code",CLAUDE_MEM_CONTEXT_SHOW_READ_TOKENS:"true",CLAUDE_MEM_CONTEXT_SHOW_WORK_TOKENS:"true",CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_AMOUNT:"true",CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_PERCENT:"true",CLAUDE_MEM_CONTEXT_OBSERVATION_TYPES:R,CLAUDE_MEM_CONTEXT_OBSERVATION_CONCEPTS:U,CLAUDE_MEM_CONTEXT_FULL_COUNT:"5",CLAUDE_MEM_CONTEXT_FULL_FIELD:"narrative",CLAUDE_MEM_CONTEXT_SESSION_COUNT:"10",CLAUDE_MEM_CONTEXT_SHOW_LAST_SUMMARY:"true",CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE:"false"};static getAllDefaults(){return{...this.DEFAULTS}}static get(t){return this.DEFAULTS[t]}static getInt(t){let r=this.get(t);return parseInt(r,10)}static getBool(t){return this.get(t)==="true"}static loadFromFile(t){try{if(!F(t))return this.getAllDefaults();let r=w(t,"utf-8"),e=JSON.parse(r),n=e;if(e.env&&typeof e.env=="object"){n=e.env;try{b(t,JSON.stringify(n,null,2),"utf-8"),E.info("SETTINGS","Migrated settings file from nested to flat schema",{settingsPath:t})}catch(a){E.warn("SETTINGS","Failed to auto-migrate settings file",{settingsPath:t},a)}}let i={...this.DEFAULTS};for(let a of Object.keys(this.DEFAULTS))n[a]!==void 0&&(i[a]=n[a]);return i}catch(r){return E.warn("SETTINGS","Failed to load settings, using defaults",{settingsPath:t},r),this.getAllDefaults()}}};import{appendFileSync as K,existsSync as x,mkdirSync as G}from"fs";import{join as T}from"path";var f=(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))(f||{}),M=class{level=null;useColor;logFilePath=null;constructor(){this.useColor=process.stdout.isTTY??!1,this.initializeLogFile()}initializeLogFile(){try{let t=c.get("CLAUDE_MEM_DATA_DIR"),r=T(t,"logs");x(r)||G(r,{recursive:!0});let e=new Date().toISOString().split("T")[0];this.logFilePath=T(r,`claude-mem-${e}.log`)}catch(t){console.error("[LOGGER] Failed to initialize log file:",t),this.logFilePath=null}}getLevel(){if(this.level===null)try{let t=c.get("CLAUDE_MEM_DATA_DIR"),r=T(t,"settings.json"),n=c.loadFromFile(r).CLAUDE_MEM_LOG_LEVEL.toUpperCase();this.level=f[n]??1}catch(t){console.error("[LOGGER] Failed to load settings, using INFO level:",t),this.level=1}return this.level}correlationId(t,r){return`obs-${t}-${r}`}sessionId(t){return`session-${t}`}formatData(t){if(t==null)return"";if(typeof t=="string")return t;if(typeof t=="number"||typeof t=="boolean")return t.toString();if(typeof t=="object"){if(t instanceof Error)return this.getLevel()===0?`${t.message}
|
||||
${t.stack}`:t.message;if(Array.isArray(t))return`[${t.length} items]`;let r=Object.keys(t);return r.length===0?"{}":r.length<=3?JSON.stringify(t):`{${r.length} keys: ${r.slice(0,3).join(", ")}...}`}return String(t)}formatTool(t,r){if(!r)return t;let e=typeof r=="string"?JSON.parse(r):r;if(t==="Bash"&&e.command)return`${t}(${e.command})`;if(e.file_path)return`${t}(${e.file_path})`;if(e.notebook_path)return`${t}(${e.notebook_path})`;if(t==="Glob"&&e.pattern)return`${t}(${e.pattern})`;if(t==="Grep"&&e.pattern)return`${t}(${e.pattern})`;if(e.url)return`${t}(${e.url})`;if(e.query)return`${t}(${e.query})`;if(t==="Task"){if(e.subagent_type)return`${t}(${e.subagent_type})`;if(e.description)return`${t}(${e.description})`}return t==="Skill"&&e.skill?`${t}(${e.skill})`:t==="LSP"&&e.operation?`${t}(${e.operation})`:t}formatTimestamp(t){let r=t.getFullYear(),e=String(t.getMonth()+1).padStart(2,"0"),n=String(t.getDate()).padStart(2,"0"),i=String(t.getHours()).padStart(2,"0"),a=String(t.getMinutes()).padStart(2,"0"),s=String(t.getSeconds()).padStart(2,"0"),l=String(t.getMilliseconds()).padStart(3,"0");return`${r}-${e}-${n} ${i}:${a}:${s}.${l}`}log(t,r,e,n,i){if(t<this.getLevel())return;let a=this.formatTimestamp(new Date),s=f[t].padEnd(5),l=r.padEnd(6),_="";n?.correlationId?_=`[${n.correlationId}] `:n?.sessionId&&(_=`[session-${n.sessionId}] `);let g="";i!=null&&(this.getLevel()===0&&typeof i=="object"?g=`
|
||||
`+JSON.stringify(i,null,2):g=" "+this.formatData(i));let u="";if(n){let{sessionId:D,sdkSessionId:Z,correlationId:tt,...d}=n;Object.keys(d).length>0&&(u=` {${Object.entries(d).map(([$,v])=>`${$}=${v}`).join(", ")}}`)}let m=`[${a}] [${s}] [${l}] ${_}${e}${u}${g}`;if(this.logFilePath)try{K(this.logFilePath,m+`
|
||||
`,"utf8")}catch(D){process.stderr.write(`[LOGGER] Failed to write to log file: ${D}
|
||||
`)}else process.stderr.write(m+`
|
||||
`)}debug(t,r,e,n){this.log(0,t,r,e,n)}info(t,r,e,n){this.log(1,t,r,e,n)}warn(t,r,e,n){this.log(2,t,r,e,n)}error(t,r,e,n){this.log(3,t,r,e,n)}dataIn(t,r,e,n){this.info(t,`\u2192 ${r}`,e,n)}dataOut(t,r,e,n){this.info(t,`\u2190 ${r}`,e,n)}success(t,r,e,n){this.info(t,`\u2713 ${r}`,e,n)}failure(t,r,e,n){this.error(t,`\u2717 ${r}`,e,n)}timing(t,r,e,n){this.info(t,`\u23F1 ${r}`,n,{duration:`${e}ms`})}happyPathError(t,r,e,n,i=""){let _=((new Error().stack||"").split(`
|
||||
`)[2]||"").match(/at\s+(?:.*\s+)?\(?([^:]+):(\d+):(\d+)\)?/),g=_?`${_[1].split("/").pop()}:${_[2]}`:"unknown",u={...e,location:g};return this.warn(t,`[HAPPY-PATH] ${r}`,u,n),i}},E=new M;var A={DEFAULT:3e5,HEALTH_CHECK:3e4,WORKER_STARTUP_WAIT:1e3,WORKER_STARTUP_RETRIES:300,PRE_RESTART_SETTLE_DELAY:2e3,WINDOWS_MULTIPLIER:1.5};function h(o){return process.platform==="win32"?Math.round(o*A.WINDOWS_MULTIPLIER):o}function N(o={}){let{port:t,includeSkillFallback:r=!1,customPrefix:e,actualError:n}=o,i=e||"Worker service connection failed.",a=t?` (port ${t})`:"",s=`${i}${a}
|
||||
|
||||
`;return i+=`To restart the worker:
|
||||
`,i+=`1. Exit Claude Code completely
|
||||
`,i+=`2. Run: claude-mem restart
|
||||
`,i+="3. Restart Claude Code",r&&(i+=`
|
||||
`;return s+=`To restart the worker:
|
||||
`,s+=`1. Exit Claude Code completely
|
||||
`,s+=`2. Run: npm run worker:restart
|
||||
`,s+="3. Restart Claude Code",r&&(s+=`
|
||||
|
||||
If that doesn't work, try: /troubleshoot`),n&&(i=`Worker Error: ${n}
|
||||
If that doesn't work, try: /troubleshoot`),n&&(s=`Worker Error: ${n}
|
||||
|
||||
${i}`),i}var F=C.join(H(),".claude","plugins","marketplaces","thedotmack"),h=R(M.HEALTH_CHECK),S=null;function p(){if(S!==null)return S;let o=C.join(g.get("CLAUDE_MEM_DATA_DIR"),"settings.json"),t=g.loadFromFile(o);return S=parseInt(t.CLAUDE_MEM_WORKER_PORT,10),S}async function G(){let o=p();return(await fetch(`http://127.0.0.1:${o}/api/readiness`,{signal:AbortSignal.timeout(h)})).ok}function K(){let o=C.join(F,"package.json");return JSON.parse(x(o,"utf-8")).version}async function j(){let o=p(),t=await fetch(`http://127.0.0.1:${o}/api/version`,{signal:AbortSignal.timeout(h)});if(!t.ok)throw new Error(`Failed to get worker version: ${t.status}`);return(await t.json()).version}async function V(){let o=K(),t=await j();o!==t&&_.warn("SYSTEM","Worker version mismatch",{pluginVersion:o,workerVersion:t,hint:"Restart worker with: claude-mem worker restart"})}async function U(){for(let r=0;r<25;r++){try{if(await G()){await V();return}}catch{}await new Promise(e=>setTimeout(e,200))}throw new Error(I({port:p(),customPrefix:"Worker did not become ready within 5 seconds."}))}import X from"path";function N(o){if(!o||o.trim()==="")return _.warn("PROJECT_NAME","Empty cwd provided, using fallback",{cwd:o}),"unknown-project";let t=X.basename(o);if(t===""){if(process.platform==="win32"){let e=o.match(/^([A-Z]):\\/i);if(e){let s=`drive-${e[1].toUpperCase()}`;return _.info("PROJECT_NAME","Drive root detected",{cwd:o,projectName:s}),s}}return _.warn("PROJECT_NAME","Root directory detected, using fallback",{cwd:o}),"unknown-project"}return t}async function B(o){if(await U(),!o)throw new Error("newHook requires input");let{session_id:t,cwd:r,prompt:e}=o,n=N(r),s=p(),E=await fetch(`http://127.0.0.1:${s}/api/sessions/init`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({claudeSessionId:t,project:n,prompt:e}),signal:AbortSignal.timeout(5e3)});if(!E.ok)throw new Error(`Session initialization failed: ${E.status}`);let i=await E.json(),c=i.sessionDbId,a=i.promptNumber;if(i.skipped&&i.reason==="private"){console.error(`[new-hook] Session ${c}, prompt #${a} (fully private - skipped)`),console.log(f);return}console.error(`[new-hook] Session ${c}, prompt #${a}`);let l=e.startsWith("/")?e.substring(1):e,u=await fetch(`http://127.0.0.1:${s}/sessions/${c}/init`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({userPrompt:l,promptNumber:a}),signal:AbortSignal.timeout(5e3)});if(!u.ok)throw new Error(`SDK agent start failed: ${u.status}`);console.log(f)}var A="";k.on("data",o=>A+=o);k.on("end",async()=>{let o;try{o=A?JSON.parse(A):void 0}catch(t){throw new Error(`Failed to parse hook input: ${t instanceof Error?t.message:String(t)}`)}await B(o)});
|
||||
${s}`),s}var V=C.join(X(),".claude","plugins","marketplaces","thedotmack"),I=h(A.HEALTH_CHECK),O=null;function p(){if(O!==null)return O;let o=C.join(c.get("CLAUDE_MEM_DATA_DIR"),"settings.json"),t=c.loadFromFile(o);return O=parseInt(t.CLAUDE_MEM_WORKER_PORT,10),O}async function B(){let o=p();return(await fetch(`http://127.0.0.1:${o}/api/readiness`,{signal:AbortSignal.timeout(I)})).ok}function Y(){let o=C.join(V,"package.json");return JSON.parse(j(o,"utf-8")).version}async function J(){let o=p(),t=await fetch(`http://127.0.0.1:${o}/api/version`,{signal:AbortSignal.timeout(I)});if(!t.ok)throw new Error(`Failed to get worker version: ${t.status}`);return(await t.json()).version}async function z(){let o=Y(),t=await J();o!==t&&E.warn("SYSTEM","Worker version mismatch",{pluginVersion:o,workerVersion:t,hint:"Restart worker with: claude-mem worker restart"})}async function k(){for(let r=0;r<25;r++){try{if(await B()){await z();return}}catch{}await new Promise(e=>setTimeout(e,200))}throw new Error(N({port:p(),customPrefix:"Worker did not become ready within 5 seconds."}))}import q from"path";function P(o){if(!o||o.trim()==="")return E.warn("PROJECT_NAME","Empty cwd provided, using fallback",{cwd:o}),"unknown-project";let t=q.basename(o);if(t===""){if(process.platform==="win32"){let e=o.match(/^([A-Z]):\\/i);if(e){let i=`drive-${e[1].toUpperCase()}`;return E.info("PROJECT_NAME","Drive root detected",{cwd:o,projectName:i}),i}}return E.warn("PROJECT_NAME","Root directory detected, using fallback",{cwd:o}),"unknown-project"}return t}async function Q(o){if(await k(),!o)throw new Error("newHook requires input");let{session_id:t,cwd:r,prompt:e}=o,n=P(r);E.info("HOOK","new-hook: Received hook input",{session_id:t,has_prompt:!!e,cwd:r});let i=p();E.info("HOOK","new-hook: Calling /api/sessions/init",{claudeSessionId:t,project:n,prompt_length:e?.length});let a=await fetch(`http://127.0.0.1:${i}/api/sessions/init`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({claudeSessionId:t,project:n,prompt:e}),signal:AbortSignal.timeout(5e3)});if(!a.ok)throw new Error(`Session initialization failed: ${a.status}`);let s=await a.json(),l=s.sessionDbId,_=s.promptNumber;if(E.info("HOOK","new-hook: Received from /api/sessions/init",{sessionDbId:l,promptNumber:_,skipped:s.skipped}),s.skipped&&s.reason==="private"){E.info("HOOK",`new-hook: Session ${l}, prompt #${_} (fully private - skipped)`),console.log(S);return}E.info("HOOK",`new-hook: Session ${l}, prompt #${_}`);let g=e.startsWith("/")?e.substring(1):e;E.info("HOOK","new-hook: Calling /sessions/{sessionDbId}/init",{sessionDbId:l,promptNumber:_,userPrompt_length:g?.length});let u=await fetch(`http://127.0.0.1:${i}/sessions/${l}/init`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({userPrompt:g,promptNumber:_}),signal:AbortSignal.timeout(5e3)});if(!u.ok)throw new Error(`SDK agent start failed: ${u.status}`);console.log(S)}var L="";y.on("data",o=>L+=o);y.on("end",async()=>{let o;try{o=L?JSON.parse(L):void 0}catch(t){throw new Error(`Failed to parse hook input: ${t instanceof Error?t.message:String(t)}`)}await Q(o)});
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
#!/usr/bin/env bun
|
||||
import{stdin as N}from"process";var D=JSON.stringify({continue:!0,suppressOutput:!0});import{readFileSync as k,writeFileSync as P,existsSync as v}from"fs";import{join as w}from"path";import{homedir as H}from"os";var m="bugfix,feature,refactor,discovery,decision,change",d="how-it-works,why-it-exists,what-changed,problem-solution,gotcha,pattern,trade-off";var c=class{static DEFAULTS={CLAUDE_MEM_MODEL:"claude-sonnet-4-5",CLAUDE_MEM_CONTEXT_OBSERVATIONS:"50",CLAUDE_MEM_WORKER_PORT:"37777",CLAUDE_MEM_WORKER_HOST:"127.0.0.1",CLAUDE_MEM_SKIP_TOOLS:"ListMcpResourcesTool,SlashCommand,Skill,TodoWrite,AskUserQuestion",CLAUDE_MEM_PROVIDER:"claude",CLAUDE_MEM_GEMINI_API_KEY:"",CLAUDE_MEM_GEMINI_MODEL:"gemini-2.5-flash-lite",CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED:"true",CLAUDE_MEM_DATA_DIR:w(H(),".claude-mem"),CLAUDE_MEM_LOG_LEVEL:"INFO",CLAUDE_MEM_PYTHON_VERSION:"3.13",CLAUDE_CODE_PATH:"",CLAUDE_MEM_MODE:"code",CLAUDE_MEM_CONTEXT_SHOW_READ_TOKENS:"true",CLAUDE_MEM_CONTEXT_SHOW_WORK_TOKENS:"true",CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_AMOUNT:"true",CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_PERCENT:"true",CLAUDE_MEM_CONTEXT_OBSERVATION_TYPES:m,CLAUDE_MEM_CONTEXT_OBSERVATION_CONCEPTS:d,CLAUDE_MEM_CONTEXT_FULL_COUNT:"5",CLAUDE_MEM_CONTEXT_FULL_FIELD:"narrative",CLAUDE_MEM_CONTEXT_SESSION_COUNT:"10",CLAUDE_MEM_CONTEXT_SHOW_LAST_SUMMARY:"true",CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE:"false"};static getAllDefaults(){return{...this.DEFAULTS}}static get(t){return this.DEFAULTS[t]}static getInt(t){let r=this.get(t);return parseInt(r,10)}static getBool(t){return this.get(t)==="true"}static loadFromFile(t){try{if(!v(t))return this.getAllDefaults();let r=k(t,"utf-8"),e=JSON.parse(r),n=e;if(e.env&&typeof e.env=="object"){n=e.env;try{P(t,JSON.stringify(n,null,2),"utf-8"),a.info("SETTINGS","Migrated settings file from nested to flat schema",{settingsPath:t})}catch(i){a.warn("SETTINGS","Failed to auto-migrate settings file",{settingsPath:t},i)}}let o={...this.DEFAULTS};for(let i of Object.keys(this.DEFAULTS))n[i]!==void 0&&(o[i]=n[i]);return o}catch(r){return a.warn("SETTINGS","Failed to load settings, using defaults",{settingsPath:t},r),this.getAllDefaults()}}};var f=(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))(f||{}),p=class{level=null;useColor;constructor(){this.useColor=process.stdout.isTTY??!1}getLevel(){if(this.level===null){let t=c.get("CLAUDE_MEM_LOG_LEVEL").toUpperCase();this.level=f[t]??1}return this.level}correlationId(t,r){return`obs-${t}-${r}`}sessionId(t){return`session-${t}`}formatData(t){if(t==null)return"";if(typeof t=="string")return t;if(typeof t=="number"||typeof t=="boolean")return t.toString();if(typeof t=="object"){if(t instanceof Error)return this.getLevel()===0?`${t.message}
|
||||
${t.stack}`:t.message;if(Array.isArray(t))return`[${t.length} items]`;let r=Object.keys(t);return r.length===0?"{}":r.length<=3?JSON.stringify(t):`{${r.length} keys: ${r.slice(0,3).join(", ")}...}`}return String(t)}formatTool(t,r){if(!r)return t;let e=typeof r=="string"?JSON.parse(r):r;if(t==="Bash"&&e.command)return`${t}(${e.command})`;if(e.file_path)return`${t}(${e.file_path})`;if(e.notebook_path)return`${t}(${e.notebook_path})`;if(t==="Glob"&&e.pattern)return`${t}(${e.pattern})`;if(t==="Grep"&&e.pattern)return`${t}(${e.pattern})`;if(e.url)return`${t}(${e.url})`;if(e.query)return`${t}(${e.query})`;if(t==="Task"){if(e.subagent_type)return`${t}(${e.subagent_type})`;if(e.description)return`${t}(${e.description})`}return t==="Skill"&&e.skill?`${t}(${e.skill})`:t==="LSP"&&e.operation?`${t}(${e.operation})`:t}formatTimestamp(t){let r=t.getFullYear(),e=String(t.getMonth()+1).padStart(2,"0"),n=String(t.getDate()).padStart(2,"0"),o=String(t.getHours()).padStart(2,"0"),i=String(t.getMinutes()).padStart(2,"0"),E=String(t.getSeconds()).padStart(2,"0"),_=String(t.getMilliseconds()).padStart(3,"0");return`${r}-${e}-${n} ${o}:${i}:${E}.${_}`}log(t,r,e,n,o){if(t<this.getLevel())return;let i=this.formatTimestamp(new Date),E=f[t].padEnd(5),_=r.padEnd(6),l="";n?.correlationId?l=`[${n.correlationId}] `:n?.sessionId&&(l=`[session-${n.sessionId}] `);let u="";o!=null&&(this.getLevel()===0&&typeof o=="object"?u=`
|
||||
`+JSON.stringify(o,null,2):u=" "+this.formatData(o));let S="";if(n){let{sessionId:j,sdkSessionId:B,correlationId:Y,...L}=n;Object.keys(L).length>0&&(S=` {${Object.entries(L).map(([y,$])=>`${y}=${$}`).join(", ")}}`)}let A=`[${i}] [${E}] [${_}] ${l}${e}${S}${u}`;t===3?console.error(A):console.log(A)}debug(t,r,e,n){this.log(0,t,r,e,n)}info(t,r,e,n){this.log(1,t,r,e,n)}warn(t,r,e,n){this.log(2,t,r,e,n)}error(t,r,e,n){this.log(3,t,r,e,n)}dataIn(t,r,e,n){this.info(t,`\u2192 ${r}`,e,n)}dataOut(t,r,e,n){this.info(t,`\u2190 ${r}`,e,n)}success(t,r,e,n){this.info(t,`\u2713 ${r}`,e,n)}failure(t,r,e,n){this.error(t,`\u2717 ${r}`,e,n)}timing(t,r,e,n){this.info(t,`\u23F1 ${r}`,n,{duration:`${e}ms`})}happyPathError(t,r,e,n,o=""){let l=((new Error().stack||"").split(`
|
||||
`)[2]||"").match(/at\s+(?:.*\s+)?\(?([^:]+):(\d+):(\d+)\)?/),u=l?`${l[1].split("/").pop()}:${l[2]}`:"unknown",S={...e,location:u};return this.warn(t,`[HAPPY-PATH] ${r}`,S,n),o}},a=new p;import M from"path";import{homedir as W}from"os";import{readFileSync as b}from"fs";var g={DEFAULT:12e4,HEALTH_CHECK:1e3,WORKER_STARTUP_WAIT:1e3,WORKER_STARTUP_RETRIES:15,PRE_RESTART_SETTLE_DELAY:2e3,WINDOWS_MULTIPLIER:1.5};function U(s){return process.platform==="win32"?Math.round(s*g.WINDOWS_MULTIPLIER):s}function I(s={}){let{port:t,includeSkillFallback:r=!1,customPrefix:e,actualError:n}=s,o=e||"Worker service connection failed.",i=t?` (port ${t})`:"",E=`${o}${i}
|
||||
import{stdin as P}from"process";var U=JSON.stringify({continue:!0,suppressOutput:!0});import{readFileSync as v,writeFileSync as w,existsSync as F}from"fs";import{join as H}from"path";import{homedir as W}from"os";var R="bugfix,feature,refactor,discovery,decision,change",d="how-it-works,why-it-exists,what-changed,problem-solution,gotcha,pattern,trade-off";var a=class{static DEFAULTS={CLAUDE_MEM_MODEL:"claude-sonnet-4-5",CLAUDE_MEM_CONTEXT_OBSERVATIONS:"50",CLAUDE_MEM_WORKER_PORT:"37777",CLAUDE_MEM_WORKER_HOST:"127.0.0.1",CLAUDE_MEM_SKIP_TOOLS:"ListMcpResourcesTool,SlashCommand,Skill,TodoWrite,AskUserQuestion",CLAUDE_MEM_PROVIDER:"claude",CLAUDE_MEM_GEMINI_API_KEY:"",CLAUDE_MEM_GEMINI_MODEL:"gemini-2.5-flash-lite",CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED:"true",CLAUDE_MEM_OPENROUTER_API_KEY:"",CLAUDE_MEM_OPENROUTER_MODEL:"xiaomi/mimo-v2-flash:free",CLAUDE_MEM_OPENROUTER_SITE_URL:"",CLAUDE_MEM_OPENROUTER_APP_NAME:"claude-mem",CLAUDE_MEM_OPENROUTER_MAX_CONTEXT_MESSAGES:"20",CLAUDE_MEM_OPENROUTER_MAX_TOKENS:"100000",CLAUDE_MEM_DATA_DIR:H(W(),".claude-mem"),CLAUDE_MEM_LOG_LEVEL:"INFO",CLAUDE_MEM_PYTHON_VERSION:"3.13",CLAUDE_CODE_PATH:"",CLAUDE_MEM_MODE:"code",CLAUDE_MEM_CONTEXT_SHOW_READ_TOKENS:"true",CLAUDE_MEM_CONTEXT_SHOW_WORK_TOKENS:"true",CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_AMOUNT:"true",CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_PERCENT:"true",CLAUDE_MEM_CONTEXT_OBSERVATION_TYPES:R,CLAUDE_MEM_CONTEXT_OBSERVATION_CONCEPTS:d,CLAUDE_MEM_CONTEXT_FULL_COUNT:"5",CLAUDE_MEM_CONTEXT_FULL_FIELD:"narrative",CLAUDE_MEM_CONTEXT_SESSION_COUNT:"10",CLAUDE_MEM_CONTEXT_SHOW_LAST_SUMMARY:"true",CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE:"false"};static getAllDefaults(){return{...this.DEFAULTS}}static get(t){return this.DEFAULTS[t]}static getInt(t){let r=this.get(t);return parseInt(r,10)}static getBool(t){return this.get(t)==="true"}static loadFromFile(t){try{if(!F(t))return this.getAllDefaults();let r=v(t,"utf-8"),e=JSON.parse(r),n=e;if(e.env&&typeof e.env=="object"){n=e.env;try{w(t,JSON.stringify(n,null,2),"utf-8"),_.info("SETTINGS","Migrated settings file from nested to flat schema",{settingsPath:t})}catch(i){_.warn("SETTINGS","Failed to auto-migrate settings file",{settingsPath:t},i)}}let o={...this.DEFAULTS};for(let i of Object.keys(this.DEFAULTS))n[i]!==void 0&&(o[i]=n[i]);return o}catch(r){return _.warn("SETTINGS","Failed to load settings, using defaults",{settingsPath:t},r),this.getAllDefaults()}}};import{appendFileSync as b,existsSync as x,mkdirSync as G}from"fs";import{join as M}from"path";var f=(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))(f||{}),p=class{level=null;useColor;logFilePath=null;constructor(){this.useColor=process.stdout.isTTY??!1,this.initializeLogFile()}initializeLogFile(){try{let t=a.get("CLAUDE_MEM_DATA_DIR"),r=M(t,"logs");x(r)||G(r,{recursive:!0});let e=new Date().toISOString().split("T")[0];this.logFilePath=M(r,`claude-mem-${e}.log`)}catch(t){console.error("[LOGGER] Failed to initialize log file:",t),this.logFilePath=null}}getLevel(){if(this.level===null)try{let t=a.get("CLAUDE_MEM_DATA_DIR"),r=M(t,"settings.json"),n=a.loadFromFile(r).CLAUDE_MEM_LOG_LEVEL.toUpperCase();this.level=f[n]??1}catch(t){console.error("[LOGGER] Failed to load settings, using INFO level:",t),this.level=1}return this.level}correlationId(t,r){return`obs-${t}-${r}`}sessionId(t){return`session-${t}`}formatData(t){if(t==null)return"";if(typeof t=="string")return t;if(typeof t=="number"||typeof t=="boolean")return t.toString();if(typeof t=="object"){if(t instanceof Error)return this.getLevel()===0?`${t.message}
|
||||
${t.stack}`:t.message;if(Array.isArray(t))return`[${t.length} items]`;let r=Object.keys(t);return r.length===0?"{}":r.length<=3?JSON.stringify(t):`{${r.length} keys: ${r.slice(0,3).join(", ")}...}`}return String(t)}formatTool(t,r){if(!r)return t;let e=typeof r=="string"?JSON.parse(r):r;if(t==="Bash"&&e.command)return`${t}(${e.command})`;if(e.file_path)return`${t}(${e.file_path})`;if(e.notebook_path)return`${t}(${e.notebook_path})`;if(t==="Glob"&&e.pattern)return`${t}(${e.pattern})`;if(t==="Grep"&&e.pattern)return`${t}(${e.pattern})`;if(e.url)return`${t}(${e.url})`;if(e.query)return`${t}(${e.query})`;if(t==="Task"){if(e.subagent_type)return`${t}(${e.subagent_type})`;if(e.description)return`${t}(${e.description})`}return t==="Skill"&&e.skill?`${t}(${e.skill})`:t==="LSP"&&e.operation?`${t}(${e.operation})`:t}formatTimestamp(t){let r=t.getFullYear(),e=String(t.getMonth()+1).padStart(2,"0"),n=String(t.getDate()).padStart(2,"0"),o=String(t.getHours()).padStart(2,"0"),i=String(t.getMinutes()).padStart(2,"0"),E=String(t.getSeconds()).padStart(2,"0"),l=String(t.getMilliseconds()).padStart(3,"0");return`${r}-${e}-${n} ${o}:${i}:${E}.${l}`}log(t,r,e,n,o){if(t<this.getLevel())return;let i=this.formatTimestamp(new Date),E=f[t].padEnd(5),l=r.padEnd(6),g="";n?.correlationId?g=`[${n.correlationId}] `:n?.sessionId&&(g=`[session-${n.sessionId}] `);let c="";o!=null&&(this.getLevel()===0&&typeof o=="object"?c=`
|
||||
`+JSON.stringify(o,null,2):c=" "+this.formatData(o));let T="";if(n){let{sessionId:D,sdkSessionId:z,correlationId:Q,...m}=n;Object.keys(m).length>0&&(T=` {${Object.entries(m).map(([$,k])=>`${$}=${k}`).join(", ")}}`)}let C=`[${i}] [${E}] [${l}] ${g}${e}${T}${c}`;if(this.logFilePath)try{b(this.logFilePath,C+`
|
||||
`,"utf8")}catch(D){process.stderr.write(`[LOGGER] Failed to write to log file: ${D}
|
||||
`)}else process.stderr.write(C+`
|
||||
`)}debug(t,r,e,n){this.log(0,t,r,e,n)}info(t,r,e,n){this.log(1,t,r,e,n)}warn(t,r,e,n){this.log(2,t,r,e,n)}error(t,r,e,n){this.log(3,t,r,e,n)}dataIn(t,r,e,n){this.info(t,`\u2192 ${r}`,e,n)}dataOut(t,r,e,n){this.info(t,`\u2190 ${r}`,e,n)}success(t,r,e,n){this.info(t,`\u2713 ${r}`,e,n)}failure(t,r,e,n){this.error(t,`\u2717 ${r}`,e,n)}timing(t,r,e,n){this.info(t,`\u23F1 ${r}`,n,{duration:`${e}ms`})}happyPathError(t,r,e,n,o=""){let g=((new Error().stack||"").split(`
|
||||
`)[2]||"").match(/at\s+(?:.*\s+)?\(?([^:]+):(\d+):(\d+)\)?/),c=g?`${g[1].split("/").pop()}:${g[2]}`:"unknown",T={...e,location:c};return this.warn(t,`[HAPPY-PATH] ${r}`,T,n),o}},_=new p;import A from"path";import{homedir as K}from"os";import{readFileSync as X}from"fs";var u={DEFAULT:3e5,HEALTH_CHECK:3e4,WORKER_STARTUP_WAIT:1e3,WORKER_STARTUP_RETRIES:300,PRE_RESTART_SETTLE_DELAY:2e3,WINDOWS_MULTIPLIER:1.5};function I(s){return process.platform==="win32"?Math.round(s*u.WINDOWS_MULTIPLIER):s}function h(s={}){let{port:t,includeSkillFallback:r=!1,customPrefix:e,actualError:n}=s,o=e||"Worker service connection failed.",i=t?` (port ${t})`:"",E=`${o}${i}
|
||||
|
||||
`;return E+=`To restart the worker:
|
||||
`,E+=`1. Exit Claude Code completely
|
||||
`,E+=`2. Run: claude-mem restart
|
||||
`,E+=`2. Run: npm run worker:restart
|
||||
`,E+="3. Restart Claude Code",r&&(E+=`
|
||||
|
||||
If that doesn't work, try: /troubleshoot`),n&&(E=`Worker Error: ${n}
|
||||
|
||||
${E}`),E}var F=M.join(W(),".claude","plugins","marketplaces","thedotmack"),R=U(g.HEALTH_CHECK),O=null;function T(){if(O!==null)return O;let s=M.join(c.get("CLAUDE_MEM_DATA_DIR"),"settings.json"),t=c.loadFromFile(s);return O=parseInt(t.CLAUDE_MEM_WORKER_PORT,10),O}async function x(){let s=T();return(await fetch(`http://127.0.0.1:${s}/api/readiness`,{signal:AbortSignal.timeout(R)})).ok}function K(){let s=M.join(F,"package.json");return JSON.parse(b(s,"utf-8")).version}async function G(){let s=T(),t=await fetch(`http://127.0.0.1:${s}/api/version`,{signal:AbortSignal.timeout(R)});if(!t.ok)throw new Error(`Failed to get worker version: ${t.status}`);return(await t.json()).version}async function V(){let s=K(),t=await G();s!==t&&a.warn("SYSTEM","Worker version mismatch",{pluginVersion:s,workerVersion:t,hint:"Restart worker with: claude-mem worker restart"})}async function h(){for(let r=0;r<25;r++){try{if(await x()){await V();return}}catch{}await new Promise(e=>setTimeout(e,200))}throw new Error(I({port:T(),customPrefix:"Worker did not become ready within 5 seconds."}))}async function X(s){if(await h(),!s)throw new Error("saveHook requires input");let{session_id:t,cwd:r,tool_name:e,tool_input:n,tool_response:o}=s,i=T(),E=a.formatTool(e,n);if(a.dataIn("HOOK",`PostToolUse: ${E}`,{workerPort:i}),!r)throw new Error(`Missing cwd in PostToolUse hook input for session ${t}, tool ${e}`);let _=await fetch(`http://127.0.0.1:${i}/api/sessions/observations`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({claudeSessionId:t,tool_name:e,tool_input:n,tool_response:o,cwd:r}),signal:AbortSignal.timeout(g.DEFAULT)});if(!_.ok)throw new Error(`Observation storage failed: ${_.status}`);a.debug("HOOK","Observation sent successfully",{toolName:e}),console.log(D)}var C="";N.on("data",s=>C+=s);N.on("end",async()=>{let s;try{s=C?JSON.parse(C):void 0}catch(t){throw new Error(`Failed to parse hook input: ${t instanceof Error?t.message:String(t)}`)}await X(s)});
|
||||
${E}`),E}var V=A.join(K(),".claude","plugins","marketplaces","thedotmack"),N=I(u.HEALTH_CHECK),S=null;function O(){if(S!==null)return S;let s=A.join(a.get("CLAUDE_MEM_DATA_DIR"),"settings.json"),t=a.loadFromFile(s);return S=parseInt(t.CLAUDE_MEM_WORKER_PORT,10),S}async function j(){let s=O();return(await fetch(`http://127.0.0.1:${s}/api/readiness`,{signal:AbortSignal.timeout(N)})).ok}function B(){let s=A.join(V,"package.json");return JSON.parse(X(s,"utf-8")).version}async function Y(){let s=O(),t=await fetch(`http://127.0.0.1:${s}/api/version`,{signal:AbortSignal.timeout(N)});if(!t.ok)throw new Error(`Failed to get worker version: ${t.status}`);return(await t.json()).version}async function J(){let s=B(),t=await Y();s!==t&&_.warn("SYSTEM","Worker version mismatch",{pluginVersion:s,workerVersion:t,hint:"Restart worker with: claude-mem worker restart"})}async function y(){for(let r=0;r<25;r++){try{if(await j()){await J();return}}catch{}await new Promise(e=>setTimeout(e,200))}throw new Error(h({port:O(),customPrefix:"Worker did not become ready within 5 seconds."}))}async function q(s){if(await y(),!s)throw new Error("saveHook requires input");let{session_id:t,cwd:r,tool_name:e,tool_input:n,tool_response:o}=s,i=O(),E=_.formatTool(e,n);if(_.dataIn("HOOK",`PostToolUse: ${E}`,{workerPort:i}),!r)throw new Error(`Missing cwd in PostToolUse hook input for session ${t}, tool ${e}`);let l=await fetch(`http://127.0.0.1:${i}/api/sessions/observations`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({claudeSessionId:t,tool_name:e,tool_input:n,tool_response:o,cwd:r}),signal:AbortSignal.timeout(u.DEFAULT)});if(!l.ok)throw new Error(`Observation storage failed: ${l.status}`);_.debug("HOOK","Observation sent successfully",{toolName:e}),console.log(U)}var L="";P.on("data",s=>L+=s);P.on("end",async()=>{let s;try{s=L?JSON.parse(L):void 0}catch(t){throw new Error(`Failed to parse hook input: ${t instanceof Error?t.message:String(t)}`)}await q(s)});
|
||||
|
||||
@@ -291,7 +291,7 @@ function installCLI() {
|
||||
console.error('📋 Add to PATH (run once in PowerShell as Admin):');
|
||||
console.error(` [Environment]::SetEnvironmentVariable("Path", $env:Path + ";${cliDir}", "User")`);
|
||||
console.error('');
|
||||
console.error(' Then restart your terminal and use: claude-mem start|stop|restart|status');
|
||||
console.error(' Then restart your terminal and use: npm run worker:start|stop|restart|status');
|
||||
} catch (error) {
|
||||
console.error(`⚠️ Could not install CLI: ${error.message}`);
|
||||
console.error(` You can still use: bun "${WORKER_CLI}" <command>`);
|
||||
@@ -333,9 +333,9 @@ exec "${bunPath}" "${WORKER_CLI}" "$@"
|
||||
console.error('📋 Add to PATH (add to ~/.bashrc or ~/.zshrc):');
|
||||
console.error(' export PATH="$HOME/.local/bin:$PATH"');
|
||||
console.error('');
|
||||
console.error(' Then restart your terminal and use: claude-mem start|stop|restart|status');
|
||||
console.error(' Then restart your terminal and use: npm run worker:start|stop|restart|status');
|
||||
} else {
|
||||
console.error(' Usage: claude-mem start|stop|restart|status');
|
||||
console.error(' Usage: npm run worker:start|stop|restart|status');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`⚠️ Could not install CLI: ${error.message}`);
|
||||
@@ -439,8 +439,31 @@ try {
|
||||
|
||||
// Step 3: Install dependencies if needed
|
||||
if (needsInstall()) {
|
||||
const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf-8'));
|
||||
const newVersion = pkg.version;
|
||||
|
||||
installDeps();
|
||||
console.error('✅ Dependencies installed');
|
||||
|
||||
// Auto-restart worker to pick up new code
|
||||
const port = process.env.CLAUDE_MEM_WORKER_PORT || 37777;
|
||||
console.error(`[claude-mem] Plugin updated to v${newVersion} - restarting worker...`);
|
||||
try {
|
||||
// Graceful shutdown via HTTP (curl is cross-platform enough)
|
||||
execSync(`curl -s -X POST http://127.0.0.1:${port}/api/admin/shutdown`, {
|
||||
stdio: 'ignore',
|
||||
shell: IS_WINDOWS,
|
||||
timeout: 5000
|
||||
});
|
||||
// Brief wait for port to free
|
||||
execSync(IS_WINDOWS ? 'timeout /t 1 /nobreak >nul' : 'sleep 0.5', {
|
||||
stdio: 'ignore',
|
||||
shell: true
|
||||
});
|
||||
} catch {
|
||||
// Worker wasn't running or already stopped - that's fine
|
||||
}
|
||||
// Worker will be started fresh by next hook in chain (worker-service.cjs start)
|
||||
}
|
||||
|
||||
// Step 4: Install CLI to PATH
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
#!/usr/bin/env bun
|
||||
import{stdin as N}from"process";var T=JSON.stringify({continue:!0,suppressOutput:!0});import{readFileSync as w,writeFileSync as v,existsSync as H}from"fs";import{join as P}from"path";import{homedir as W}from"os";var d="bugfix,feature,refactor,discovery,decision,change",I="how-it-works,why-it-exists,what-changed,problem-solution,gotcha,pattern,trade-off";var g=class{static DEFAULTS={CLAUDE_MEM_MODEL:"claude-sonnet-4-5",CLAUDE_MEM_CONTEXT_OBSERVATIONS:"50",CLAUDE_MEM_WORKER_PORT:"37777",CLAUDE_MEM_WORKER_HOST:"127.0.0.1",CLAUDE_MEM_SKIP_TOOLS:"ListMcpResourcesTool,SlashCommand,Skill,TodoWrite,AskUserQuestion",CLAUDE_MEM_PROVIDER:"claude",CLAUDE_MEM_GEMINI_API_KEY:"",CLAUDE_MEM_GEMINI_MODEL:"gemini-2.5-flash-lite",CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED:"true",CLAUDE_MEM_DATA_DIR:P(W(),".claude-mem"),CLAUDE_MEM_LOG_LEVEL:"INFO",CLAUDE_MEM_PYTHON_VERSION:"3.13",CLAUDE_CODE_PATH:"",CLAUDE_MEM_MODE:"code",CLAUDE_MEM_CONTEXT_SHOW_READ_TOKENS:"true",CLAUDE_MEM_CONTEXT_SHOW_WORK_TOKENS:"true",CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_AMOUNT:"true",CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_PERCENT:"true",CLAUDE_MEM_CONTEXT_OBSERVATION_TYPES:d,CLAUDE_MEM_CONTEXT_OBSERVATION_CONCEPTS:I,CLAUDE_MEM_CONTEXT_FULL_COUNT:"5",CLAUDE_MEM_CONTEXT_FULL_FIELD:"narrative",CLAUDE_MEM_CONTEXT_SESSION_COUNT:"10",CLAUDE_MEM_CONTEXT_SHOW_LAST_SUMMARY:"true",CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE:"false"};static getAllDefaults(){return{...this.DEFAULTS}}static get(t){return this.DEFAULTS[t]}static getInt(t){let r=this.get(t);return parseInt(r,10)}static getBool(t){return this.get(t)==="true"}static loadFromFile(t){try{if(!H(t))return this.getAllDefaults();let r=w(t,"utf-8"),e=JSON.parse(r),n=e;if(e.env&&typeof e.env=="object"){n=e.env;try{v(t,JSON.stringify(n,null,2),"utf-8"),c.info("SETTINGS","Migrated settings file from nested to flat schema",{settingsPath:t})}catch(a){c.warn("SETTINGS","Failed to auto-migrate settings file",{settingsPath:t},a)}}let o={...this.DEFAULTS};for(let a of Object.keys(this.DEFAULTS))n[a]!==void 0&&(o[a]=n[a]);return o}catch(r){return c.warn("SETTINGS","Failed to load settings, using defaults",{settingsPath:t},r),this.getAllDefaults()}}};var O=(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))(O||{}),M=class{level=null;useColor;constructor(){this.useColor=process.stdout.isTTY??!1}getLevel(){if(this.level===null){let t=g.get("CLAUDE_MEM_LOG_LEVEL").toUpperCase();this.level=O[t]??1}return this.level}correlationId(t,r){return`obs-${t}-${r}`}sessionId(t){return`session-${t}`}formatData(t){if(t==null)return"";if(typeof t=="string")return t;if(typeof t=="number"||typeof t=="boolean")return t.toString();if(typeof t=="object"){if(t instanceof Error)return this.getLevel()===0?`${t.message}
|
||||
${t.stack}`:t.message;if(Array.isArray(t))return`[${t.length} items]`;let r=Object.keys(t);return r.length===0?"{}":r.length<=3?JSON.stringify(t):`{${r.length} keys: ${r.slice(0,3).join(", ")}...}`}return String(t)}formatTool(t,r){if(!r)return t;let e=typeof r=="string"?JSON.parse(r):r;if(t==="Bash"&&e.command)return`${t}(${e.command})`;if(e.file_path)return`${t}(${e.file_path})`;if(e.notebook_path)return`${t}(${e.notebook_path})`;if(t==="Glob"&&e.pattern)return`${t}(${e.pattern})`;if(t==="Grep"&&e.pattern)return`${t}(${e.pattern})`;if(e.url)return`${t}(${e.url})`;if(e.query)return`${t}(${e.query})`;if(t==="Task"){if(e.subagent_type)return`${t}(${e.subagent_type})`;if(e.description)return`${t}(${e.description})`}return t==="Skill"&&e.skill?`${t}(${e.skill})`:t==="LSP"&&e.operation?`${t}(${e.operation})`:t}formatTimestamp(t){let r=t.getFullYear(),e=String(t.getMonth()+1).padStart(2,"0"),n=String(t.getDate()).padStart(2,"0"),o=String(t.getHours()).padStart(2,"0"),a=String(t.getMinutes()).padStart(2,"0"),i=String(t.getSeconds()).padStart(2,"0"),_=String(t.getMilliseconds()).padStart(3,"0");return`${r}-${e}-${n} ${o}:${a}:${i}.${_}`}log(t,r,e,n,o){if(t<this.getLevel())return;let a=this.formatTimestamp(new Date),i=O[t].padEnd(5),_=r.padEnd(6),E="";n?.correlationId?E=`[${n.correlationId}] `:n?.sessionId&&(E=`[session-${n.sessionId}] `);let l="";o!=null&&(this.getLevel()===0&&typeof o=="object"?l=`
|
||||
`+JSON.stringify(o,null,2):l=" "+this.formatData(o));let S="";if(n){let{sessionId:J,sdkSessionId:q,correlationId:z,...D}=n;Object.keys(D).length>0&&(S=` {${Object.entries(D).map(([$,k])=>`${$}=${k}`).join(", ")}}`)}let C=`[${a}] [${i}] [${_}] ${E}${e}${S}${l}`;t===3?console.error(C):console.log(C)}debug(t,r,e,n){this.log(0,t,r,e,n)}info(t,r,e,n){this.log(1,t,r,e,n)}warn(t,r,e,n){this.log(2,t,r,e,n)}error(t,r,e,n){this.log(3,t,r,e,n)}dataIn(t,r,e,n){this.info(t,`\u2192 ${r}`,e,n)}dataOut(t,r,e,n){this.info(t,`\u2190 ${r}`,e,n)}success(t,r,e,n){this.info(t,`\u2713 ${r}`,e,n)}failure(t,r,e,n){this.error(t,`\u2717 ${r}`,e,n)}timing(t,r,e,n){this.info(t,`\u23F1 ${r}`,n,{duration:`${e}ms`})}happyPathError(t,r,e,n,o=""){let E=((new Error().stack||"").split(`
|
||||
`)[2]||"").match(/at\s+(?:.*\s+)?\(?([^:]+):(\d+):(\d+)\)?/),l=E?`${E[1].split("/").pop()}:${E[2]}`:"unknown",S={...e,location:l};return this.warn(t,`[HAPPY-PATH] ${r}`,S,n),o}},c=new M;import m from"path";import{homedir as x}from"os";import{readFileSync as b}from"fs";var u={DEFAULT:12e4,HEALTH_CHECK:1e3,WORKER_STARTUP_WAIT:1e3,WORKER_STARTUP_RETRIES:15,PRE_RESTART_SETTLE_DELAY:2e3,WINDOWS_MULTIPLIER:1.5};function h(s){return process.platform==="win32"?Math.round(s*u.WINDOWS_MULTIPLIER):s}function R(s={}){let{port:t,includeSkillFallback:r=!1,customPrefix:e,actualError:n}=s,o=e||"Worker service connection failed.",a=t?` (port ${t})`:"",i=`${o}${a}
|
||||
import{stdin as k}from"process";var f=JSON.stringify({continue:!0,suppressOutput:!0});import{readFileSync as v,writeFileSync as F,existsSync as x}from"fs";import{join as H}from"path";import{homedir as W}from"os";var d="bugfix,feature,refactor,discovery,decision,change",h="how-it-works,why-it-exists,what-changed,problem-solution,gotcha,pattern,trade-off";var c=class{static DEFAULTS={CLAUDE_MEM_MODEL:"claude-sonnet-4-5",CLAUDE_MEM_CONTEXT_OBSERVATIONS:"50",CLAUDE_MEM_WORKER_PORT:"37777",CLAUDE_MEM_WORKER_HOST:"127.0.0.1",CLAUDE_MEM_SKIP_TOOLS:"ListMcpResourcesTool,SlashCommand,Skill,TodoWrite,AskUserQuestion",CLAUDE_MEM_PROVIDER:"claude",CLAUDE_MEM_GEMINI_API_KEY:"",CLAUDE_MEM_GEMINI_MODEL:"gemini-2.5-flash-lite",CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED:"true",CLAUDE_MEM_OPENROUTER_API_KEY:"",CLAUDE_MEM_OPENROUTER_MODEL:"xiaomi/mimo-v2-flash:free",CLAUDE_MEM_OPENROUTER_SITE_URL:"",CLAUDE_MEM_OPENROUTER_APP_NAME:"claude-mem",CLAUDE_MEM_OPENROUTER_MAX_CONTEXT_MESSAGES:"20",CLAUDE_MEM_OPENROUTER_MAX_TOKENS:"100000",CLAUDE_MEM_DATA_DIR:H(W(),".claude-mem"),CLAUDE_MEM_LOG_LEVEL:"INFO",CLAUDE_MEM_PYTHON_VERSION:"3.13",CLAUDE_CODE_PATH:"",CLAUDE_MEM_MODE:"code",CLAUDE_MEM_CONTEXT_SHOW_READ_TOKENS:"true",CLAUDE_MEM_CONTEXT_SHOW_WORK_TOKENS:"true",CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_AMOUNT:"true",CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_PERCENT:"true",CLAUDE_MEM_CONTEXT_OBSERVATION_TYPES:d,CLAUDE_MEM_CONTEXT_OBSERVATION_CONCEPTS:h,CLAUDE_MEM_CONTEXT_FULL_COUNT:"5",CLAUDE_MEM_CONTEXT_FULL_FIELD:"narrative",CLAUDE_MEM_CONTEXT_SESSION_COUNT:"10",CLAUDE_MEM_CONTEXT_SHOW_LAST_SUMMARY:"true",CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE:"false"};static getAllDefaults(){return{...this.DEFAULTS}}static get(t){return this.DEFAULTS[t]}static getInt(t){let r=this.get(t);return parseInt(r,10)}static getBool(t){return this.get(t)==="true"}static loadFromFile(t){try{if(!x(t))return this.getAllDefaults();let r=v(t,"utf-8"),e=JSON.parse(r),n=e;if(e.env&&typeof e.env=="object"){n=e.env;try{F(t,JSON.stringify(n,null,2),"utf-8"),g.info("SETTINGS","Migrated settings file from nested to flat schema",{settingsPath:t})}catch(E){g.warn("SETTINGS","Failed to auto-migrate settings file",{settingsPath:t},E)}}let o={...this.DEFAULTS};for(let E of Object.keys(this.DEFAULTS))n[E]!==void 0&&(o[E]=n[E]);return o}catch(r){return g.warn("SETTINGS","Failed to load settings, using defaults",{settingsPath:t},r),this.getAllDefaults()}}};import{appendFileSync as b,existsSync as G,mkdirSync as K}from"fs";import{join as M}from"path";var p=(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))(p||{}),A=class{level=null;useColor;logFilePath=null;constructor(){this.useColor=process.stdout.isTTY??!1,this.initializeLogFile()}initializeLogFile(){try{let t=c.get("CLAUDE_MEM_DATA_DIR"),r=M(t,"logs");G(r)||K(r,{recursive:!0});let e=new Date().toISOString().split("T")[0];this.logFilePath=M(r,`claude-mem-${e}.log`)}catch(t){console.error("[LOGGER] Failed to initialize log file:",t),this.logFilePath=null}}getLevel(){if(this.level===null)try{let t=c.get("CLAUDE_MEM_DATA_DIR"),r=M(t,"settings.json"),n=c.loadFromFile(r).CLAUDE_MEM_LOG_LEVEL.toUpperCase();this.level=p[n]??1}catch(t){console.error("[LOGGER] Failed to load settings, using INFO level:",t),this.level=1}return this.level}correlationId(t,r){return`obs-${t}-${r}`}sessionId(t){return`session-${t}`}formatData(t){if(t==null)return"";if(typeof t=="string")return t;if(typeof t=="number"||typeof t=="boolean")return t.toString();if(typeof t=="object"){if(t instanceof Error)return this.getLevel()===0?`${t.message}
|
||||
${t.stack}`:t.message;if(Array.isArray(t))return`[${t.length} items]`;let r=Object.keys(t);return r.length===0?"{}":r.length<=3?JSON.stringify(t):`{${r.length} keys: ${r.slice(0,3).join(", ")}...}`}return String(t)}formatTool(t,r){if(!r)return t;let e=typeof r=="string"?JSON.parse(r):r;if(t==="Bash"&&e.command)return`${t}(${e.command})`;if(e.file_path)return`${t}(${e.file_path})`;if(e.notebook_path)return`${t}(${e.notebook_path})`;if(t==="Glob"&&e.pattern)return`${t}(${e.pattern})`;if(t==="Grep"&&e.pattern)return`${t}(${e.pattern})`;if(e.url)return`${t}(${e.url})`;if(e.query)return`${t}(${e.query})`;if(t==="Task"){if(e.subagent_type)return`${t}(${e.subagent_type})`;if(e.description)return`${t}(${e.description})`}return t==="Skill"&&e.skill?`${t}(${e.skill})`:t==="LSP"&&e.operation?`${t}(${e.operation})`:t}formatTimestamp(t){let r=t.getFullYear(),e=String(t.getMonth()+1).padStart(2,"0"),n=String(t.getDate()).padStart(2,"0"),o=String(t.getHours()).padStart(2,"0"),E=String(t.getMinutes()).padStart(2,"0"),i=String(t.getSeconds()).padStart(2,"0"),_=String(t.getMilliseconds()).padStart(3,"0");return`${r}-${e}-${n} ${o}:${E}:${i}.${_}`}log(t,r,e,n,o){if(t<this.getLevel())return;let E=this.formatTimestamp(new Date),i=p[t].padEnd(5),_=r.padEnd(6),a="";n?.correlationId?a=`[${n.correlationId}] `:n?.sessionId&&(a=`[session-${n.sessionId}] `);let l="";o!=null&&(this.getLevel()===0&&typeof o=="object"?l=`
|
||||
`+JSON.stringify(o,null,2):l=" "+this.formatData(o));let S="";if(n){let{sessionId:R,sdkSessionId:tt,correlationId:et,...U}=n;Object.keys(U).length>0&&(S=` {${Object.entries(U).map(([P,w])=>`${P}=${w}`).join(", ")}}`)}let D=`[${E}] [${i}] [${_}] ${a}${e}${S}${l}`;if(this.logFilePath)try{b(this.logFilePath,D+`
|
||||
`,"utf8")}catch(R){process.stderr.write(`[LOGGER] Failed to write to log file: ${R}
|
||||
`)}else process.stderr.write(D+`
|
||||
`)}debug(t,r,e,n){this.log(0,t,r,e,n)}info(t,r,e,n){this.log(1,t,r,e,n)}warn(t,r,e,n){this.log(2,t,r,e,n)}error(t,r,e,n){this.log(3,t,r,e,n)}dataIn(t,r,e,n){this.info(t,`\u2192 ${r}`,e,n)}dataOut(t,r,e,n){this.info(t,`\u2190 ${r}`,e,n)}success(t,r,e,n){this.info(t,`\u2713 ${r}`,e,n)}failure(t,r,e,n){this.error(t,`\u2717 ${r}`,e,n)}timing(t,r,e,n){this.info(t,`\u23F1 ${r}`,n,{duration:`${e}ms`})}happyPathError(t,r,e,n,o=""){let a=((new Error().stack||"").split(`
|
||||
`)[2]||"").match(/at\s+(?:.*\s+)?\(?([^:]+):(\d+):(\d+)\)?/),l=a?`${a[1].split("/").pop()}:${a[2]}`:"unknown",S={...e,location:l};return this.warn(t,`[HAPPY-PATH] ${r}`,S,n),o}},g=new A;import L from"path";import{homedir as X}from"os";import{readFileSync as V}from"fs";var u={DEFAULT:3e5,HEALTH_CHECK:3e4,WORKER_STARTUP_WAIT:1e3,WORKER_STARTUP_RETRIES:300,PRE_RESTART_SETTLE_DELAY:2e3,WINDOWS_MULTIPLIER:1.5};function I(s){return process.platform==="win32"?Math.round(s*u.WINDOWS_MULTIPLIER):s}function N(s={}){let{port:t,includeSkillFallback:r=!1,customPrefix:e,actualError:n}=s,o=e||"Worker service connection failed.",E=t?` (port ${t})`:"",i=`${o}${E}
|
||||
|
||||
`;return i+=`To restart the worker:
|
||||
`,i+=`1. Exit Claude Code completely
|
||||
`,i+=`2. Run: claude-mem restart
|
||||
`,i+=`2. Run: npm run worker:restart
|
||||
`,i+="3. Restart Claude Code",r&&(i+=`
|
||||
|
||||
If that doesn't work, try: /troubleshoot`),n&&(i=`Worker Error: ${n}
|
||||
|
||||
${i}`),i}var F=m.join(x(),".claude","plugins","marketplaces","thedotmack"),U=h(u.HEALTH_CHECK),p=null;function f(){if(p!==null)return p;let s=m.join(g.get("CLAUDE_MEM_DATA_DIR"),"settings.json"),t=g.loadFromFile(s);return p=parseInt(t.CLAUDE_MEM_WORKER_PORT,10),p}async function K(){let s=f();return(await fetch(`http://127.0.0.1:${s}/api/readiness`,{signal:AbortSignal.timeout(U)})).ok}function G(){let s=m.join(F,"package.json");return JSON.parse(b(s,"utf-8")).version}async function V(){let s=f(),t=await fetch(`http://127.0.0.1:${s}/api/version`,{signal:AbortSignal.timeout(U)});if(!t.ok)throw new Error(`Failed to get worker version: ${t.status}`);return(await t.json()).version}async function X(){let s=G(),t=await V();s!==t&&c.warn("SYSTEM","Worker version mismatch",{pluginVersion:s,workerVersion:t,hint:"Restart worker with: claude-mem worker restart"})}async function y(){for(let r=0;r<25;r++){try{if(await K()){await X();return}}catch{}await new Promise(e=>setTimeout(e,200))}throw new Error(R({port:f(),customPrefix:"Worker did not become ready within 5 seconds."}))}import{readFileSync as j,existsSync as B}from"fs";function A(s,t,r=!1){if(!s||!B(s))throw new Error(`Transcript path missing or file does not exist: ${s}`);let e=j(s,"utf-8").trim();if(!e)throw new Error(`Transcript file exists but is empty: ${s}`);let n=e.split(`
|
||||
`),o=!1;for(let a=n.length-1;a>=0;a--){let i=JSON.parse(n[a]);if(i.type===t&&(o=!0,i.message?.content)){let _="",E=i.message.content;if(typeof E=="string")_=E;else if(Array.isArray(E))_=E.filter(l=>l.type==="text").map(l=>l.text).join(`
|
||||
`);else throw new Error(`Unknown message content format in transcript. Type: ${typeof E}`);return r&&(_=_.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g,""),_=_.replace(/\n{3,}/g,`
|
||||
${i}`),i}var j=L.join(X(),".claude","plugins","marketplaces","thedotmack"),y=I(u.HEALTH_CHECK),T=null;function O(){if(T!==null)return T;let s=L.join(c.get("CLAUDE_MEM_DATA_DIR"),"settings.json"),t=c.loadFromFile(s);return T=parseInt(t.CLAUDE_MEM_WORKER_PORT,10),T}async function B(){let s=O();return(await fetch(`http://127.0.0.1:${s}/api/readiness`,{signal:AbortSignal.timeout(y)})).ok}function Y(){let s=L.join(j,"package.json");return JSON.parse(V(s,"utf-8")).version}async function J(){let s=O(),t=await fetch(`http://127.0.0.1:${s}/api/version`,{signal:AbortSignal.timeout(y)});if(!t.ok)throw new Error(`Failed to get worker version: ${t.status}`);return(await t.json()).version}async function q(){let s=Y(),t=await J();s!==t&&g.warn("SYSTEM","Worker version mismatch",{pluginVersion:s,workerVersion:t,hint:"Restart worker with: claude-mem worker restart"})}async function $(){for(let r=0;r<25;r++){try{if(await B()){await q();return}}catch{}await new Promise(e=>setTimeout(e,200))}throw new Error(N({port:O(),customPrefix:"Worker did not become ready within 5 seconds."}))}import{readFileSync as z,existsSync as Q}from"fs";function m(s,t,r=!1){if(!s||!Q(s))throw new Error(`Transcript path missing or file does not exist: ${s}`);let e=z(s,"utf-8").trim();if(!e)throw new Error(`Transcript file exists but is empty: ${s}`);let n=e.split(`
|
||||
`),o=!1;for(let E=n.length-1;E>=0;E--){let i=JSON.parse(n[E]);if(i.type===t&&(o=!0,i.message?.content)){let _="",a=i.message.content;if(typeof a=="string")_=a;else if(Array.isArray(a))_=a.filter(l=>l.type==="text").map(l=>l.text).join(`
|
||||
`);else throw new Error(`Unknown message content format in transcript. Type: ${typeof a}`);return r&&(_=_.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g,""),_=_.replace(/\n{3,}/g,`
|
||||
|
||||
`).trim()),_}}if(!o)throw new Error(`No message found for role '${t}' in transcript: ${s}`);return""}async function Y(s){if(await y(),!s)throw new Error("summaryHook requires input");let{session_id:t}=s,r=f();if(!s.transcript_path)throw new Error(`Missing transcript_path in Stop hook input for session ${t}`);let e=A(s.transcript_path,"user"),n=A(s.transcript_path,"assistant",!0);c.dataIn("HOOK","Stop: Requesting summary",{workerPort:r,hasLastUserMessage:!!e,hasLastAssistantMessage:!!n});let o=await fetch(`http://127.0.0.1:${r}/api/sessions/summarize`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({claudeSessionId:t,last_user_message:e,last_assistant_message:n}),signal:AbortSignal.timeout(u.DEFAULT)});if(!o.ok)throw console.log(T),new Error(`Summary generation failed: ${o.status}`);c.debug("HOOK","Summary request sent successfully"),console.log(T)}var L="";N.on("data",s=>L+=s);N.on("end",async()=>{let s;try{s=L?JSON.parse(L):void 0}catch(t){throw new Error(`Failed to parse hook input: ${t instanceof Error?t.message:String(t)}`)}await Y(s)});
|
||||
`).trim()),_}}if(!o)throw new Error(`No message found for role '${t}' in transcript: ${s}`);return""}async function Z(s){if(await $(),!s)throw new Error("summaryHook requires input");let{session_id:t}=s,r=O();if(!s.transcript_path)throw new Error(`Missing transcript_path in Stop hook input for session ${t}`);let e=m(s.transcript_path,"user"),n=m(s.transcript_path,"assistant",!0);g.dataIn("HOOK","Stop: Requesting summary",{workerPort:r,hasLastUserMessage:!!e,hasLastAssistantMessage:!!n});let o=await fetch(`http://127.0.0.1:${r}/api/sessions/summarize`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({claudeSessionId:t,last_user_message:e,last_assistant_message:n}),signal:AbortSignal.timeout(u.DEFAULT)});if(!o.ok)throw console.log(f),new Error(`Summary generation failed: ${o.status}`);g.debug("HOOK","Summary request sent successfully"),console.log(f)}var C="";k.on("data",s=>C+=s);k.on("end",async()=>{let s;try{s=C?JSON.parse(C):void 0}catch(t){throw new Error(`Failed to parse hook input: ${t instanceof Error?t.message:String(t)}`)}await Z(s)});
|
||||
|
||||
@@ -1,25 +1,28 @@
|
||||
#!/usr/bin/env bun
|
||||
import{basename as X}from"path";import M from"path";import{homedir as b}from"os";import{readFileSync as H}from"fs";import{readFileSync as k,writeFileSync as v,existsSync as W}from"fs";import{join as P}from"path";import{homedir as w}from"os";var D="bugfix,feature,refactor,discovery,decision,change",m="how-it-works,why-it-exists,what-changed,problem-solution,gotcha,pattern,trade-off";var _=class{static DEFAULTS={CLAUDE_MEM_MODEL:"claude-sonnet-4-5",CLAUDE_MEM_CONTEXT_OBSERVATIONS:"50",CLAUDE_MEM_WORKER_PORT:"37777",CLAUDE_MEM_WORKER_HOST:"127.0.0.1",CLAUDE_MEM_SKIP_TOOLS:"ListMcpResourcesTool,SlashCommand,Skill,TodoWrite,AskUserQuestion",CLAUDE_MEM_PROVIDER:"claude",CLAUDE_MEM_GEMINI_API_KEY:"",CLAUDE_MEM_GEMINI_MODEL:"gemini-2.5-flash-lite",CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED:"true",CLAUDE_MEM_DATA_DIR:P(w(),".claude-mem"),CLAUDE_MEM_LOG_LEVEL:"INFO",CLAUDE_MEM_PYTHON_VERSION:"3.13",CLAUDE_CODE_PATH:"",CLAUDE_MEM_MODE:"code",CLAUDE_MEM_CONTEXT_SHOW_READ_TOKENS:"true",CLAUDE_MEM_CONTEXT_SHOW_WORK_TOKENS:"true",CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_AMOUNT:"true",CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_PERCENT:"true",CLAUDE_MEM_CONTEXT_OBSERVATION_TYPES:D,CLAUDE_MEM_CONTEXT_OBSERVATION_CONCEPTS:m,CLAUDE_MEM_CONTEXT_FULL_COUNT:"5",CLAUDE_MEM_CONTEXT_FULL_FIELD:"narrative",CLAUDE_MEM_CONTEXT_SESSION_COUNT:"10",CLAUDE_MEM_CONTEXT_SHOW_LAST_SUMMARY:"true",CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE:"false"};static getAllDefaults(){return{...this.DEFAULTS}}static get(t){return this.DEFAULTS[t]}static getInt(t){let r=this.get(t);return parseInt(r,10)}static getBool(t){return this.get(t)==="true"}static loadFromFile(t){try{if(!W(t))return this.getAllDefaults();let r=k(t,"utf-8"),e=JSON.parse(r),n=e;if(e.env&&typeof e.env=="object"){n=e.env;try{v(t,JSON.stringify(n,null,2),"utf-8"),c.info("SETTINGS","Migrated settings file from nested to flat schema",{settingsPath:t})}catch(E){c.warn("SETTINGS","Failed to auto-migrate settings file",{settingsPath:t},E)}}let o={...this.DEFAULTS};for(let E of Object.keys(this.DEFAULTS))n[E]!==void 0&&(o[E]=n[E]);return o}catch(r){return c.warn("SETTINGS","Failed to load settings, using defaults",{settingsPath:t},r),this.getAllDefaults()}}};var p=(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))(p||{}),f=class{level=null;useColor;constructor(){this.useColor=process.stdout.isTTY??!1}getLevel(){if(this.level===null){let t=_.get("CLAUDE_MEM_LOG_LEVEL").toUpperCase();this.level=p[t]??1}return this.level}correlationId(t,r){return`obs-${t}-${r}`}sessionId(t){return`session-${t}`}formatData(t){if(t==null)return"";if(typeof t=="string")return t;if(typeof t=="number"||typeof t=="boolean")return t.toString();if(typeof t=="object"){if(t instanceof Error)return this.getLevel()===0?`${t.message}
|
||||
${t.stack}`:t.message;if(Array.isArray(t))return`[${t.length} items]`;let r=Object.keys(t);return r.length===0?"{}":r.length<=3?JSON.stringify(t):`{${r.length} keys: ${r.slice(0,3).join(", ")}...}`}return String(t)}formatTool(t,r){if(!r)return t;let e=typeof r=="string"?JSON.parse(r):r;if(t==="Bash"&&e.command)return`${t}(${e.command})`;if(e.file_path)return`${t}(${e.file_path})`;if(e.notebook_path)return`${t}(${e.notebook_path})`;if(t==="Glob"&&e.pattern)return`${t}(${e.pattern})`;if(t==="Grep"&&e.pattern)return`${t}(${e.pattern})`;if(e.url)return`${t}(${e.url})`;if(e.query)return`${t}(${e.query})`;if(t==="Task"){if(e.subagent_type)return`${t}(${e.subagent_type})`;if(e.description)return`${t}(${e.description})`}return t==="Skill"&&e.skill?`${t}(${e.skill})`:t==="LSP"&&e.operation?`${t}(${e.operation})`:t}formatTimestamp(t){let r=t.getFullYear(),e=String(t.getMonth()+1).padStart(2,"0"),n=String(t.getDate()).padStart(2,"0"),o=String(t.getHours()).padStart(2,"0"),E=String(t.getMinutes()).padStart(2,"0"),i=String(t.getSeconds()).padStart(2,"0"),u=String(t.getMilliseconds()).padStart(3,"0");return`${r}-${e}-${n} ${o}:${E}:${i}.${u}`}log(t,r,e,n,o){if(t<this.getLevel())return;let E=this.formatTimestamp(new Date),i=p[t].padEnd(5),u=r.padEnd(6),a="";n?.correlationId?a=`[${n.correlationId}] `:n?.sessionId&&(a=`[session-${n.sessionId}] `);let l="";o!=null&&(this.getLevel()===0&&typeof o=="object"?l=`
|
||||
`+JSON.stringify(o,null,2):l=" "+this.formatData(o));let T="";if(n){let{sessionId:Y,sdkSessionId:J,correlationId:q,...A}=n;Object.keys(A).length>0&&(T=` {${Object.entries(A).map(([y,$])=>`${y}=${$}`).join(", ")}}`)}let L=`[${E}] [${i}] [${u}] ${a}${e}${T}${l}`;t===3?console.error(L):console.log(L)}debug(t,r,e,n){this.log(0,t,r,e,n)}info(t,r,e,n){this.log(1,t,r,e,n)}warn(t,r,e,n){this.log(2,t,r,e,n)}error(t,r,e,n){this.log(3,t,r,e,n)}dataIn(t,r,e,n){this.info(t,`\u2192 ${r}`,e,n)}dataOut(t,r,e,n){this.info(t,`\u2190 ${r}`,e,n)}success(t,r,e,n){this.info(t,`\u2713 ${r}`,e,n)}failure(t,r,e,n){this.error(t,`\u2717 ${r}`,e,n)}timing(t,r,e,n){this.info(t,`\u23F1 ${r}`,n,{duration:`${e}ms`})}happyPathError(t,r,e,n,o=""){let a=((new Error().stack||"").split(`
|
||||
`)[2]||"").match(/at\s+(?:.*\s+)?\(?([^:]+):(\d+):(\d+)\)?/),l=a?`${a[1].split("/").pop()}:${a[2]}`:"unknown",T={...e,location:l};return this.warn(t,`[HAPPY-PATH] ${r}`,T,n),o}},c=new f;var O={DEFAULT:12e4,HEALTH_CHECK:1e3,WORKER_STARTUP_WAIT:1e3,WORKER_STARTUP_RETRIES:15,PRE_RESTART_SETTLE_DELAY:2e3,WINDOWS_MULTIPLIER:1.5},I={SUCCESS:0,FAILURE:1,USER_MESSAGE_ONLY:3};function U(s){return process.platform==="win32"?Math.round(s*O.WINDOWS_MULTIPLIER):s}function R(s={}){let{port:t,includeSkillFallback:r=!1,customPrefix:e,actualError:n}=s,o=e||"Worker service connection failed.",E=t?` (port ${t})`:"",i=`${o}${E}
|
||||
import{basename as z}from"path";import f from"path";import{homedir as K}from"os";import{readFileSync as X}from"fs";import{readFileSync as v,writeFileSync as F,existsSync as w}from"fs";import{join as W}from"path";import{homedir as b}from"os";var U="bugfix,feature,refactor,discovery,decision,change",R="how-it-works,why-it-exists,what-changed,problem-solution,gotcha,pattern,trade-off";var _=class{static DEFAULTS={CLAUDE_MEM_MODEL:"claude-sonnet-4-5",CLAUDE_MEM_CONTEXT_OBSERVATIONS:"50",CLAUDE_MEM_WORKER_PORT:"37777",CLAUDE_MEM_WORKER_HOST:"127.0.0.1",CLAUDE_MEM_SKIP_TOOLS:"ListMcpResourcesTool,SlashCommand,Skill,TodoWrite,AskUserQuestion",CLAUDE_MEM_PROVIDER:"claude",CLAUDE_MEM_GEMINI_API_KEY:"",CLAUDE_MEM_GEMINI_MODEL:"gemini-2.5-flash-lite",CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED:"true",CLAUDE_MEM_OPENROUTER_API_KEY:"",CLAUDE_MEM_OPENROUTER_MODEL:"xiaomi/mimo-v2-flash:free",CLAUDE_MEM_OPENROUTER_SITE_URL:"",CLAUDE_MEM_OPENROUTER_APP_NAME:"claude-mem",CLAUDE_MEM_OPENROUTER_MAX_CONTEXT_MESSAGES:"20",CLAUDE_MEM_OPENROUTER_MAX_TOKENS:"100000",CLAUDE_MEM_DATA_DIR:W(b(),".claude-mem"),CLAUDE_MEM_LOG_LEVEL:"INFO",CLAUDE_MEM_PYTHON_VERSION:"3.13",CLAUDE_CODE_PATH:"",CLAUDE_MEM_MODE:"code",CLAUDE_MEM_CONTEXT_SHOW_READ_TOKENS:"true",CLAUDE_MEM_CONTEXT_SHOW_WORK_TOKENS:"true",CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_AMOUNT:"true",CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_PERCENT:"true",CLAUDE_MEM_CONTEXT_OBSERVATION_TYPES:U,CLAUDE_MEM_CONTEXT_OBSERVATION_CONCEPTS:R,CLAUDE_MEM_CONTEXT_FULL_COUNT:"5",CLAUDE_MEM_CONTEXT_FULL_FIELD:"narrative",CLAUDE_MEM_CONTEXT_SESSION_COUNT:"10",CLAUDE_MEM_CONTEXT_SHOW_LAST_SUMMARY:"true",CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE:"false"};static getAllDefaults(){return{...this.DEFAULTS}}static get(t){return this.DEFAULTS[t]}static getInt(t){let r=this.get(t);return parseInt(r,10)}static getBool(t){return this.get(t)==="true"}static loadFromFile(t){try{if(!w(t))return this.getAllDefaults();let r=v(t,"utf-8"),e=JSON.parse(r),n=e;if(e.env&&typeof e.env=="object"){n=e.env;try{F(t,JSON.stringify(n,null,2),"utf-8"),l.info("SETTINGS","Migrated settings file from nested to flat schema",{settingsPath:t})}catch(E){l.warn("SETTINGS","Failed to auto-migrate settings file",{settingsPath:t},E)}}let o={...this.DEFAULTS};for(let E of Object.keys(this.DEFAULTS))n[E]!==void 0&&(o[E]=n[E]);return o}catch(r){return l.warn("SETTINGS","Failed to load settings, using defaults",{settingsPath:t},r),this.getAllDefaults()}}};import{appendFileSync as x,existsSync as G,mkdirSync as H}from"fs";import{join as O}from"path";var S=(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))(S||{}),p=class{level=null;useColor;logFilePath=null;constructor(){this.useColor=process.stdout.isTTY??!1,this.initializeLogFile()}initializeLogFile(){try{let t=_.get("CLAUDE_MEM_DATA_DIR"),r=O(t,"logs");G(r)||H(r,{recursive:!0});let e=new Date().toISOString().split("T")[0];this.logFilePath=O(r,`claude-mem-${e}.log`)}catch(t){console.error("[LOGGER] Failed to initialize log file:",t),this.logFilePath=null}}getLevel(){if(this.level===null)try{let t=_.get("CLAUDE_MEM_DATA_DIR"),r=O(t,"settings.json"),n=_.loadFromFile(r).CLAUDE_MEM_LOG_LEVEL.toUpperCase();this.level=S[n]??1}catch(t){console.error("[LOGGER] Failed to load settings, using INFO level:",t),this.level=1}return this.level}correlationId(t,r){return`obs-${t}-${r}`}sessionId(t){return`session-${t}`}formatData(t){if(t==null)return"";if(typeof t=="string")return t;if(typeof t=="number"||typeof t=="boolean")return t.toString();if(typeof t=="object"){if(t instanceof Error)return this.getLevel()===0?`${t.message}
|
||||
${t.stack}`:t.message;if(Array.isArray(t))return`[${t.length} items]`;let r=Object.keys(t);return r.length===0?"{}":r.length<=3?JSON.stringify(t):`{${r.length} keys: ${r.slice(0,3).join(", ")}...}`}return String(t)}formatTool(t,r){if(!r)return t;let e=typeof r=="string"?JSON.parse(r):r;if(t==="Bash"&&e.command)return`${t}(${e.command})`;if(e.file_path)return`${t}(${e.file_path})`;if(e.notebook_path)return`${t}(${e.notebook_path})`;if(t==="Glob"&&e.pattern)return`${t}(${e.pattern})`;if(t==="Grep"&&e.pattern)return`${t}(${e.pattern})`;if(e.url)return`${t}(${e.url})`;if(e.query)return`${t}(${e.query})`;if(t==="Task"){if(e.subagent_type)return`${t}(${e.subagent_type})`;if(e.description)return`${t}(${e.description})`}return t==="Skill"&&e.skill?`${t}(${e.skill})`:t==="LSP"&&e.operation?`${t}(${e.operation})`:t}formatTimestamp(t){let r=t.getFullYear(),e=String(t.getMonth()+1).padStart(2,"0"),n=String(t.getDate()).padStart(2,"0"),o=String(t.getHours()).padStart(2,"0"),E=String(t.getMinutes()).padStart(2,"0"),s=String(t.getSeconds()).padStart(2,"0"),T=String(t.getMilliseconds()).padStart(3,"0");return`${r}-${e}-${n} ${o}:${E}:${s}.${T}`}log(t,r,e,n,o){if(t<this.getLevel())return;let E=this.formatTimestamp(new Date),s=S[t].padEnd(5),T=r.padEnd(6),a="";n?.correlationId?a=`[${n.correlationId}] `:n?.sessionId&&(a=`[session-${n.sessionId}] `);let c="";o!=null&&(this.getLevel()===0&&typeof o=="object"?c=`
|
||||
`+JSON.stringify(o,null,2):c=" "+this.formatData(o));let u="";if(n){let{sessionId:D,sdkSessionId:Z,correlationId:tt,...m}=n;Object.keys(m).length>0&&(u=` {${Object.entries(m).map(([P,k])=>`${P}=${k}`).join(", ")}}`)}let C=`[${E}] [${s}] [${T}] ${a}${e}${u}${c}`;if(this.logFilePath)try{x(this.logFilePath,C+`
|
||||
`,"utf8")}catch(D){process.stderr.write(`[LOGGER] Failed to write to log file: ${D}
|
||||
`)}else process.stderr.write(C+`
|
||||
`)}debug(t,r,e,n){this.log(0,t,r,e,n)}info(t,r,e,n){this.log(1,t,r,e,n)}warn(t,r,e,n){this.log(2,t,r,e,n)}error(t,r,e,n){this.log(3,t,r,e,n)}dataIn(t,r,e,n){this.info(t,`\u2192 ${r}`,e,n)}dataOut(t,r,e,n){this.info(t,`\u2190 ${r}`,e,n)}success(t,r,e,n){this.info(t,`\u2713 ${r}`,e,n)}failure(t,r,e,n){this.error(t,`\u2717 ${r}`,e,n)}timing(t,r,e,n){this.info(t,`\u23F1 ${r}`,n,{duration:`${e}ms`})}happyPathError(t,r,e,n,o=""){let a=((new Error().stack||"").split(`
|
||||
`)[2]||"").match(/at\s+(?:.*\s+)?\(?([^:]+):(\d+):(\d+)\)?/),c=a?`${a[1].split("/").pop()}:${a[2]}`:"unknown",u={...e,location:c};return this.warn(t,`[HAPPY-PATH] ${r}`,u,n),o}},l=new p;var A={DEFAULT:3e5,HEALTH_CHECK:3e4,WORKER_STARTUP_WAIT:1e3,WORKER_STARTUP_RETRIES:300,PRE_RESTART_SETTLE_DELAY:2e3,WINDOWS_MULTIPLIER:1.5},h={SUCCESS:0,FAILURE:1,USER_MESSAGE_ONLY:3};function d(i){return process.platform==="win32"?Math.round(i*A.WINDOWS_MULTIPLIER):i}function I(i={}){let{port:t,includeSkillFallback:r=!1,customPrefix:e,actualError:n}=i,o=e||"Worker service connection failed.",E=t?` (port ${t})`:"",s=`${o}${E}
|
||||
|
||||
`;return i+=`To restart the worker:
|
||||
`,i+=`1. Exit Claude Code completely
|
||||
`,i+=`2. Run: claude-mem restart
|
||||
`,i+="3. Restart Claude Code",r&&(i+=`
|
||||
`;return s+=`To restart the worker:
|
||||
`,s+=`1. Exit Claude Code completely
|
||||
`,s+=`2. Run: npm run worker:restart
|
||||
`,s+="3. Restart Claude Code",r&&(s+=`
|
||||
|
||||
If that doesn't work, try: /troubleshoot`),n&&(i=`Worker Error: ${n}
|
||||
If that doesn't work, try: /troubleshoot`),n&&(s=`Worker Error: ${n}
|
||||
|
||||
${i}`),i}var x=M.join(b(),".claude","plugins","marketplaces","thedotmack"),d=U(O.HEALTH_CHECK),S=null;function g(){if(S!==null)return S;let s=M.join(_.get("CLAUDE_MEM_DATA_DIR"),"settings.json"),t=_.loadFromFile(s);return S=parseInt(t.CLAUDE_MEM_WORKER_PORT,10),S}async function F(){let s=g();return(await fetch(`http://127.0.0.1:${s}/api/readiness`,{signal:AbortSignal.timeout(d)})).ok}function G(){let s=M.join(x,"package.json");return JSON.parse(H(s,"utf-8")).version}async function K(){let s=g(),t=await fetch(`http://127.0.0.1:${s}/api/version`,{signal:AbortSignal.timeout(d)});if(!t.ok)throw new Error(`Failed to get worker version: ${t.status}`);return(await t.json()).version}async function V(){let s=G(),t=await K();s!==t&&c.warn("SYSTEM","Worker version mismatch",{pluginVersion:s,workerVersion:t,hint:"Restart worker with: claude-mem worker restart"})}async function h(){for(let r=0;r<25;r++){try{if(await F()){await V();return}}catch{}await new Promise(e=>setTimeout(e,200))}throw new Error(R({port:g(),customPrefix:"Worker did not become ready within 5 seconds."}))}await h();var N=g(),j=X(process.cwd()),C=await fetch(`http://127.0.0.1:${N}/api/context/inject?project=${encodeURIComponent(j)}&colors=true`,{method:"GET",signal:AbortSignal.timeout(5e3)});if(!C.ok)throw new Error(`Failed to fetch context: ${C.status}`);var B=await C.text();console.error(`
|
||||
${s}`),s}var V=f.join(K(),".claude","plugins","marketplaces","thedotmack"),N=d(A.HEALTH_CHECK),M=null;function g(){if(M!==null)return M;let i=f.join(_.get("CLAUDE_MEM_DATA_DIR"),"settings.json"),t=_.loadFromFile(i);return M=parseInt(t.CLAUDE_MEM_WORKER_PORT,10),M}async function j(){let i=g();return(await fetch(`http://127.0.0.1:${i}/api/readiness`,{signal:AbortSignal.timeout(N)})).ok}function Y(){let i=f.join(V,"package.json");return JSON.parse(X(i,"utf-8")).version}async function B(){let i=g(),t=await fetch(`http://127.0.0.1:${i}/api/version`,{signal:AbortSignal.timeout(N)});if(!t.ok)throw new Error(`Failed to get worker version: ${t.status}`);return(await t.json()).version}async function J(){let i=Y(),t=await B();i!==t&&l.warn("SYSTEM","Worker version mismatch",{pluginVersion:i,workerVersion:t,hint:"Restart worker with: claude-mem worker restart"})}async function y(){for(let r=0;r<25;r++){try{if(await j()){await J();return}}catch{}await new Promise(e=>setTimeout(e,200))}throw new Error(I({port:g(),customPrefix:"Worker did not become ready within 5 seconds."}))}await y();var $=g(),q=z(process.cwd()),L=await fetch(`http://127.0.0.1:${$}/api/context/inject?project=${encodeURIComponent(q)}&colors=true`,{method:"GET",signal:AbortSignal.timeout(5e3)});if(!L.ok)throw new Error(`Failed to fetch context: ${L.status}`);var Q=await L.text();console.error(`
|
||||
|
||||
\u{1F4DD} Claude-Mem Context Loaded
|
||||
\u2139\uFE0F Note: This appears as stderr but is informational only
|
||||
|
||||
`+B+`
|
||||
`+Q+`
|
||||
|
||||
\u{1F4A1} New! Wrap all or part of any message with <private> ... </private> to prevent storing sensitive information in your observation history.
|
||||
|
||||
\u{1F4AC} Community https://discord.gg/J4wttp9vDu
|
||||
\u{1F4FA} Watch live in browser http://localhost:${N}/
|
||||
`);process.exit(I.USER_MESSAGE_ONLY);
|
||||
\u{1F4FA} Watch live in browser http://localhost:${$}/
|
||||
`);process.exit(h.USER_MESSAGE_ONLY);
|
||||
|
||||
File diff suppressed because one or more lines are too long
+120
-117
File diff suppressed because one or more lines are too long
Binary file not shown.
@@ -288,7 +288,7 @@ npm run worker:status
|
||||
If the worker is stopped, restart it:
|
||||
|
||||
```bash
|
||||
claude-mem restart
|
||||
npm run worker:restart
|
||||
```
|
||||
```
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ npm run worker:status
|
||||
```bash
|
||||
cd ~/.claude/plugins/marketplaces/thedotmack/ && \
|
||||
npm install && \
|
||||
claude-mem restart
|
||||
npm run worker:restart
|
||||
```
|
||||
|
||||
## Fix: Stale PID File
|
||||
@@ -70,7 +70,7 @@ curl -s http://127.0.0.1:37777/health
|
||||
mkdir -p ~/.claude-mem && \
|
||||
echo '{"CLAUDE_MEM_WORKER_PORT":"37778"}' > ~/.claude-mem/settings.json && \
|
||||
cd ~/.claude/plugins/marketplaces/thedotmack/ && \
|
||||
claude-mem restart && \
|
||||
npm run worker:restart && \
|
||||
sleep 2 && \
|
||||
curl -s http://127.0.0.1:37778/health
|
||||
```
|
||||
@@ -86,7 +86,7 @@ curl -s http://127.0.0.1:37778/health
|
||||
cp ~/.claude-mem/claude-mem.db ~/.claude-mem/claude-mem.db.backup && \
|
||||
sqlite3 ~/.claude-mem/claude-mem.db "PRAGMA integrity_check;" && \
|
||||
cd ~/.claude/plugins/marketplaces/thedotmack/ && \
|
||||
claude-mem restart
|
||||
npm run worker:restart
|
||||
```
|
||||
|
||||
**If integrity check fails, recreate database:**
|
||||
@@ -94,7 +94,7 @@ claude-mem restart
|
||||
# WARNING: This deletes all memory data
|
||||
mv ~/.claude-mem/claude-mem.db ~/.claude-mem/claude-mem.db.old && \
|
||||
cd ~/.claude/plugins/marketplaces/thedotmack/ && \
|
||||
claude-mem restart
|
||||
npm run worker:restart
|
||||
```
|
||||
|
||||
## Fix: Clean Reinstall
|
||||
@@ -135,7 +135,7 @@ find ~/.claude-mem/logs/ -name "worker-*.log" -mtime +7 -delete
|
||||
|
||||
# Restart worker for fresh log
|
||||
cd ~/.claude/plugins/marketplaces/thedotmack/
|
||||
claude-mem restart
|
||||
npm run worker:restart
|
||||
```
|
||||
|
||||
**Note:** Logs auto-rotate daily, manual cleanup rarely needed.
|
||||
|
||||
@@ -29,7 +29,7 @@ Quick fixes for frequently encountered claude-mem problems.
|
||||
3. Restart worker and start new session:
|
||||
```bash
|
||||
cd ~/.claude/plugins/marketplaces/thedotmack/
|
||||
claude-mem restart
|
||||
npm run worker:restart
|
||||
```
|
||||
|
||||
4. Create a test observation: `/skill version-bump` then cancel
|
||||
@@ -173,7 +173,7 @@ Quick fixes for frequently encountered claude-mem problems.
|
||||
4. If FTS5 out of sync, restart worker (triggers reindex):
|
||||
```bash
|
||||
cd ~/.claude/plugins/marketplaces/thedotmack/
|
||||
claude-mem restart
|
||||
npm run worker:restart
|
||||
```
|
||||
|
||||
## Issue: Port Conflicts
|
||||
@@ -194,7 +194,7 @@ Quick fixes for frequently encountered claude-mem problems.
|
||||
mkdir -p ~/.claude-mem
|
||||
echo '{"CLAUDE_MEM_WORKER_PORT":"37778"}' > ~/.claude-mem/settings.json
|
||||
cd ~/.claude/plugins/marketplaces/thedotmack/
|
||||
claude-mem restart
|
||||
npm run worker:restart
|
||||
```
|
||||
|
||||
## Issue: Database Corrupted
|
||||
@@ -219,7 +219,7 @@ Quick fixes for frequently encountered claude-mem problems.
|
||||
```bash
|
||||
rm ~/.claude-mem/claude-mem.db
|
||||
cd ~/.claude/plugins/marketplaces/thedotmack/
|
||||
claude-mem restart
|
||||
npm run worker:restart
|
||||
# Worker will create new database
|
||||
```
|
||||
|
||||
|
||||
@@ -173,7 +173,7 @@ If FTS5 counts don't match, triggers may have failed. Restart worker to rebuild:
|
||||
|
||||
```bash
|
||||
cd ~/.claude/plugins/marketplaces/thedotmack/
|
||||
claude-mem restart
|
||||
npm run worker:restart
|
||||
```
|
||||
|
||||
The worker will rebuild FTS5 indexes on startup if they're out of sync.
|
||||
@@ -263,7 +263,7 @@ sqlite3 ~/.claude-mem/claude-mem.db "SELECT COUNT(*) FROM observations;"
|
||||
```bash
|
||||
mv ~/.claude-mem/claude-mem.db ~/.claude-mem/claude-mem.db.archive
|
||||
cd ~/.claude/plugins/marketplaces/thedotmack/
|
||||
claude-mem restart
|
||||
npm run worker:restart
|
||||
```
|
||||
|
||||
## Database Recovery
|
||||
|
||||
@@ -13,7 +13,7 @@ npm run worker:status
|
||||
npm run worker:start
|
||||
|
||||
# Restart worker
|
||||
claude-mem restart
|
||||
npm run worker:restart
|
||||
|
||||
# Stop worker
|
||||
npm run worker:stop
|
||||
|
||||
@@ -152,7 +152,7 @@ npm run worker:start
|
||||
```bash
|
||||
# Restart worker (stops and starts)
|
||||
cd ~/.claude/plugins/marketplaces/thedotmack/
|
||||
claude-mem restart
|
||||
npm run worker:restart
|
||||
|
||||
# Or manually stop and start
|
||||
npm run worker:stop
|
||||
@@ -219,7 +219,7 @@ npm run worker:start
|
||||
**Port conflict:**
|
||||
```bash
|
||||
echo '{"CLAUDE_MEM_WORKER_PORT":"37778"}' > ~/.claude-mem/settings.json
|
||||
claude-mem restart
|
||||
npm run worker:restart
|
||||
```
|
||||
|
||||
**Stale PID file:**
|
||||
@@ -261,14 +261,14 @@ If fails, backup and recreate database.
|
||||
**Out of memory:**
|
||||
Check if database is too large or memory leak. Restart:
|
||||
```bash
|
||||
claude-mem restart
|
||||
npm run worker:restart
|
||||
```
|
||||
|
||||
**Port conflict race condition:**
|
||||
Another process grabbing port intermittently. Change port:
|
||||
```bash
|
||||
echo '{"CLAUDE_MEM_WORKER_PORT":"37778"}' > ~/.claude-mem/settings.json
|
||||
claude-mem restart
|
||||
npm run worker:restart
|
||||
```
|
||||
|
||||
## Worker Management Commands
|
||||
@@ -284,7 +284,7 @@ npm run worker:start
|
||||
npm run worker:stop
|
||||
|
||||
# Restart worker
|
||||
claude-mem restart
|
||||
npm run worker:restart
|
||||
|
||||
# View logs
|
||||
npm run worker:logs
|
||||
@@ -355,7 +355,7 @@ All should return appropriate responses (HTML for viewer, JSON for APIs).
|
||||
|---------|---------|----------------|
|
||||
| Check if running | `npm run worker:status` | Shows PID and uptime |
|
||||
| Worker not running | `npm run worker:start` | Worker starts successfully |
|
||||
| Worker crashed | `claude-mem restart` | Worker restarts |
|
||||
| Worker crashed | `npm run worker:restart` | Worker restarts |
|
||||
| View recent errors | `grep -i error ~/.claude-mem/logs/worker-$(date +%Y-%m-%d).log \| tail -20` | Shows recent errors |
|
||||
| Port in use | `lsof -i :37777` | Shows process using port |
|
||||
| Stale PID | `rm ~/.claude-mem/worker.pid && npm run worker:start` | Removes stale PID and starts |
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1817,6 +1817,49 @@
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid var(--modal-border);
|
||||
background: var(--modal-header-bg);
|
||||
}
|
||||
|
||||
.modal-footer .save-status {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.modal-footer .save-status .success {
|
||||
color: var(--success-color, #22c55e);
|
||||
}
|
||||
|
||||
.modal-footer .save-status .error {
|
||||
color: var(--error-color, #ef4444);
|
||||
}
|
||||
|
||||
.modal-footer .save-btn {
|
||||
padding: 8px 24px;
|
||||
background: var(--accent-color, #3b82f6);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.modal-footer .save-btn:hover:not(:disabled) {
|
||||
background: var(--accent-hover, #2563eb);
|
||||
}
|
||||
|
||||
.modal-footer .save-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Preview Column - Terminal Style */
|
||||
.preview-column {
|
||||
padding: 20px;
|
||||
|
||||
@@ -25,11 +25,6 @@ const WORKER_SERVICE = {
|
||||
source: 'src/services/worker-service.ts'
|
||||
};
|
||||
|
||||
const WORKER_WRAPPER = {
|
||||
name: 'worker-wrapper',
|
||||
source: 'src/services/worker-wrapper.ts'
|
||||
};
|
||||
|
||||
const MCP_SERVER = {
|
||||
name: 'mcp-server',
|
||||
source: 'src/servers/mcp-server.ts'
|
||||
@@ -40,11 +35,6 @@ const CONTEXT_GENERATOR = {
|
||||
source: 'src/services/context-generator.ts'
|
||||
};
|
||||
|
||||
const WORKER_CLI = {
|
||||
name: 'worker-cli',
|
||||
source: 'src/cli/worker-cli.ts'
|
||||
};
|
||||
|
||||
async function buildHooks() {
|
||||
console.log('🔨 Building claude-mem hooks and worker service...\n');
|
||||
|
||||
@@ -124,31 +114,6 @@ async function buildHooks() {
|
||||
const workerStats = fs.statSync(`${hooksDir}/${WORKER_SERVICE.name}.cjs`);
|
||||
console.log(`✓ worker-service built (${(workerStats.size / 1024).toFixed(2)} KB)`);
|
||||
|
||||
// Build worker wrapper (Windows zombie port fix)
|
||||
console.log(`\n🔧 Building worker wrapper...`);
|
||||
await build({
|
||||
entryPoints: [WORKER_WRAPPER.source],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
target: 'node18',
|
||||
format: 'cjs',
|
||||
outfile: `${hooksDir}/${WORKER_WRAPPER.name}.cjs`,
|
||||
minify: true,
|
||||
logLevel: 'error',
|
||||
external: ['bun:sqlite'],
|
||||
define: {
|
||||
'__DEFAULT_PACKAGE_VERSION__': `"${version}"`
|
||||
},
|
||||
banner: {
|
||||
js: '#!/usr/bin/env bun'
|
||||
}
|
||||
});
|
||||
|
||||
// Make worker wrapper executable
|
||||
fs.chmodSync(`${hooksDir}/${WORKER_WRAPPER.name}.cjs`, 0o755);
|
||||
const wrapperStats = fs.statSync(`${hooksDir}/${WORKER_WRAPPER.name}.cjs`);
|
||||
console.log(`✓ worker-wrapper built (${(wrapperStats.size / 1024).toFixed(2)} KB)`);
|
||||
|
||||
// Build MCP server
|
||||
console.log(`\n🔧 Building MCP server...`);
|
||||
await build({
|
||||
@@ -194,31 +159,6 @@ async function buildHooks() {
|
||||
const contextGenStats = fs.statSync(`${hooksDir}/${CONTEXT_GENERATOR.name}.cjs`);
|
||||
console.log(`✓ context-generator built (${(contextGenStats.size / 1024).toFixed(2)} KB)`);
|
||||
|
||||
// Build worker CLI
|
||||
console.log(`\n🔧 Building worker CLI...`);
|
||||
await build({
|
||||
entryPoints: [WORKER_CLI.source],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
target: 'node18',
|
||||
format: 'esm',
|
||||
outfile: `${hooksDir}/${WORKER_CLI.name}.js`,
|
||||
minify: true,
|
||||
logLevel: 'error',
|
||||
external: ['bun:sqlite'],
|
||||
define: {
|
||||
'__DEFAULT_PACKAGE_VERSION__': `"${version}"`
|
||||
},
|
||||
banner: {
|
||||
js: '#!/usr/bin/env bun'
|
||||
}
|
||||
});
|
||||
|
||||
// Make worker CLI executable
|
||||
fs.chmodSync(`${hooksDir}/${WORKER_CLI.name}.js`, 0o755);
|
||||
const workerCliStats = fs.statSync(`${hooksDir}/${WORKER_CLI.name}.js`);
|
||||
console.log(`✓ worker-cli built (${(workerCliStats.size / 1024).toFixed(2)} KB)`);
|
||||
|
||||
// Build each hook
|
||||
for (const hook of HOOKS) {
|
||||
console.log(`\n🔧 Building ${hook.name}...`);
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import { ProcessManager } from '../services/process/ProcessManager.js';
|
||||
import { getWorkerPort } from '../shared/worker-utils.js';
|
||||
import { stdin } from 'process';
|
||||
|
||||
const command = process.argv[2];
|
||||
const port = getWorkerPort();
|
||||
|
||||
const HOOK_STANDARD_RESPONSE = '{"continue": true, "suppressOutput": true}';
|
||||
const isManualRun = stdin.isTTY;
|
||||
|
||||
async function main() {
|
||||
switch (command) {
|
||||
case 'start': {
|
||||
const result = await ProcessManager.start(port);
|
||||
if (result.success) {
|
||||
if (isManualRun) {
|
||||
console.log(`Worker started (PID: ${result.pid})`);
|
||||
const date = new Date().toISOString().slice(0, 10);
|
||||
console.log(`Logs: ~/.claude-mem/logs/worker-${date}.log`);
|
||||
} else {
|
||||
console.log(HOOK_STANDARD_RESPONSE);
|
||||
}
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.error(`Failed to start: ${result.error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
case 'stop': {
|
||||
await ProcessManager.stop();
|
||||
if (isManualRun) {
|
||||
console.log('Worker stopped');
|
||||
} else {
|
||||
console.log(HOOK_STANDARD_RESPONSE);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
case 'restart': {
|
||||
const result = await ProcessManager.restart(port);
|
||||
if (result.success) {
|
||||
if (isManualRun) {
|
||||
console.log(`Worker restarted (PID: ${result.pid})`);
|
||||
} else {
|
||||
console.log(HOOK_STANDARD_RESPONSE);
|
||||
}
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.error(`Failed to restart: ${result.error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
case 'status': {
|
||||
const status = await ProcessManager.status();
|
||||
if (isManualRun) {
|
||||
if (status.running) {
|
||||
console.log('Worker is running');
|
||||
console.log(` PID: ${status.pid}`);
|
||||
console.log(` Port: ${status.port}`);
|
||||
console.log(` Uptime: ${status.uptime}`);
|
||||
} else {
|
||||
console.log('Worker is not running');
|
||||
}
|
||||
} else {
|
||||
console.log(HOOK_STANDARD_RESPONSE);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
default:
|
||||
console.log('Usage: worker-cli.js <start|stop|restart|status>');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import { stdin } from "process";
|
||||
import { ensureWorkerRunning, getWorkerPort } from "../shared/worker-utils.js";
|
||||
import { HOOK_TIMEOUTS } from "../shared/hook-constants.js";
|
||||
import { getProjectName } from "../utils/project-name.js";
|
||||
import { logger } from "../utils/logger.js";
|
||||
|
||||
export interface SessionStartInput {
|
||||
session_id: string;
|
||||
|
||||
+11
-2
@@ -2,6 +2,7 @@ import { stdin } from 'process';
|
||||
import { STANDARD_HOOK_RESPONSE } from './hook-response.js';
|
||||
import { ensureWorkerRunning, getWorkerPort } from '../shared/worker-utils.js';
|
||||
import { getProjectName } from '../utils/project-name.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
export interface UserPromptSubmitInput {
|
||||
session_id: string;
|
||||
@@ -24,8 +25,12 @@ async function newHook(input?: UserPromptSubmitInput): Promise<void> {
|
||||
const { session_id, cwd, prompt } = input;
|
||||
const project = getProjectName(cwd);
|
||||
|
||||
logger.info('HOOK', 'new-hook: Received hook input', { session_id, has_prompt: !!prompt, cwd });
|
||||
|
||||
const port = getWorkerPort();
|
||||
|
||||
logger.info('HOOK', 'new-hook: Calling /api/sessions/init', { claudeSessionId: session_id, project, prompt_length: prompt?.length });
|
||||
|
||||
// Initialize session via HTTP - handles DB operations and privacy checks
|
||||
const initResponse = await fetch(`http://127.0.0.1:${port}/api/sessions/init`, {
|
||||
method: 'POST',
|
||||
@@ -46,19 +51,23 @@ async function newHook(input?: UserPromptSubmitInput): Promise<void> {
|
||||
const sessionDbId = initResult.sessionDbId;
|
||||
const promptNumber = initResult.promptNumber;
|
||||
|
||||
logger.info('HOOK', 'new-hook: Received from /api/sessions/init', { sessionDbId, promptNumber, skipped: initResult.skipped });
|
||||
|
||||
// Check if prompt was entirely private (worker performs privacy check)
|
||||
if (initResult.skipped && initResult.reason === 'private') {
|
||||
console.error(`[new-hook] Session ${sessionDbId}, prompt #${promptNumber} (fully private - skipped)`);
|
||||
logger.info('HOOK', `new-hook: Session ${sessionDbId}, prompt #${promptNumber} (fully private - skipped)`);
|
||||
console.log(STANDARD_HOOK_RESPONSE);
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(`[new-hook] Session ${sessionDbId}, prompt #${promptNumber}`);
|
||||
logger.info('HOOK', `new-hook: Session ${sessionDbId}, prompt #${promptNumber}`);
|
||||
|
||||
// Strip leading slash from commands for memory agent
|
||||
// /review 101 → review 101 (more semantic for observations)
|
||||
const cleanedPrompt = prompt.startsWith('/') ? prompt.substring(1) : prompt;
|
||||
|
||||
logger.info('HOOK', 'new-hook: Calling /sessions/{sessionDbId}/init', { sessionDbId, promptNumber, userPrompt_length: cleanedPrompt?.length });
|
||||
|
||||
// Initialize SDK agent session via HTTP (starts the agent!)
|
||||
const response = await fetch(`http://127.0.0.1:${port}/sessions/${sessionDbId}/init`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { basename } from "path";
|
||||
import { ensureWorkerRunning, getWorkerPort } from "../shared/worker-utils.js";
|
||||
import { HOOK_EXIT_CODES } from "../shared/hook-constants.js";
|
||||
import { logger } from "../utils/logger.js";
|
||||
|
||||
// Ensure worker is running
|
||||
await ensureWorkerRunning();
|
||||
|
||||
@@ -6,11 +6,16 @@
|
||||
* Maintains MCP protocol handling and tool schemas
|
||||
*/
|
||||
|
||||
// CRITICAL: Redirect console.log to stderr BEFORE any imports
|
||||
// Import logger first
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
// CRITICAL: Redirect console to stderr BEFORE other imports
|
||||
// MCP uses stdio transport where stdout is reserved for JSON-RPC protocol messages.
|
||||
// Any logs to stdout break the protocol (Claude Desktop parses "[2025..." as JSON array).
|
||||
const _originalConsoleLog = console.log;
|
||||
console.log = (...args: any[]) => console.error(...args);
|
||||
const _originalLog = console['log'];
|
||||
console['log'] = (...args: any[]) => {
|
||||
logger.error('CONSOLE', 'Intercepted console output (MCP protocol protection)', undefined, { args });
|
||||
};
|
||||
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
@@ -18,7 +23,6 @@ import {
|
||||
CallToolRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { getWorkerPort, getWorkerHost } from '../shared/worker-utils.js';
|
||||
|
||||
/**
|
||||
@@ -496,7 +500,7 @@ async function main() {
|
||||
if (!workerAvailable) {
|
||||
logger.warn('SYSTEM', 'Worker not available', undefined, { workerUrl: WORKER_BASE_URL });
|
||||
logger.warn('SYSTEM', 'Tools will fail until Worker is started');
|
||||
logger.warn('SYSTEM', 'Start Worker with: claude-mem restart');
|
||||
logger.warn('SYSTEM', 'Start Worker with: npm run worker:restart');
|
||||
} else {
|
||||
logger.info('SYSTEM', 'Worker available', undefined, { workerUrl: WORKER_BASE_URL });
|
||||
}
|
||||
|
||||
@@ -229,7 +229,7 @@ export async function generateContext(input?: ContextInput, useColors: boolean =
|
||||
} catch (unlinkError) {
|
||||
// Marker might not exist
|
||||
}
|
||||
console.error('Native module rebuild needed - restart Claude Code to auto-fix');
|
||||
logger.error('SYSTEM', 'Native module rebuild needed - restart Claude Code to auto-fix');
|
||||
return '';
|
||||
}
|
||||
throw error;
|
||||
|
||||
@@ -1,433 +0,0 @@
|
||||
import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from 'fs';
|
||||
import { createWriteStream } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { spawn, spawnSync } from 'child_process';
|
||||
import { homedir } from 'os';
|
||||
import { DATA_DIR } from '../../shared/paths.js';
|
||||
import { getBunPath, isBunAvailable } from '../../utils/bun-path.js';
|
||||
import { SettingsDefaultsManager } from '../../shared/SettingsDefaultsManager.js';
|
||||
|
||||
const PID_FILE = join(DATA_DIR, 'worker.pid');
|
||||
const LOG_DIR = join(DATA_DIR, 'logs');
|
||||
const MARKETPLACE_ROOT = join(homedir(), '.claude', 'plugins', 'marketplaces', 'thedotmack');
|
||||
|
||||
interface PidInfo {
|
||||
pid: number;
|
||||
port: number;
|
||||
startedAt: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
export class ProcessManager {
|
||||
static async start(port: number): Promise<{ success: boolean; pid?: number; error?: string }> {
|
||||
// Validate port range
|
||||
if (isNaN(port) || port < 1024 || port > 65535) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Invalid port ${port}. Must be between 1024 and 65535`
|
||||
};
|
||||
}
|
||||
|
||||
// Check if already running
|
||||
if (await this.isRunning()) {
|
||||
const info = this.getPidInfo();
|
||||
return { success: true, pid: info?.pid };
|
||||
}
|
||||
|
||||
// Ensure log directory exists
|
||||
mkdirSync(LOG_DIR, { recursive: true });
|
||||
|
||||
// On Windows, use the wrapper script to solve zombie port problem
|
||||
// On Unix, use the worker directly
|
||||
const scriptName = process.platform === 'win32' ? 'worker-wrapper.cjs' : 'worker-service.cjs';
|
||||
const workerScript = join(MARKETPLACE_ROOT, 'plugin', 'scripts', scriptName);
|
||||
|
||||
if (!existsSync(workerScript)) {
|
||||
return { success: false, error: `Worker script not found at ${workerScript}` };
|
||||
}
|
||||
|
||||
const logFile = this.getLogFilePath();
|
||||
|
||||
// Use Bun on all platforms with PowerShell workaround for Windows console popups
|
||||
return this.startWithBun(workerScript, logFile, port);
|
||||
}
|
||||
|
||||
private static isBunAvailable(): boolean {
|
||||
return isBunAvailable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes a string for safe use in PowerShell single-quoted strings.
|
||||
* In PowerShell single quotes, the only special character is the single quote itself,
|
||||
* which must be doubled to escape it.
|
||||
*/
|
||||
private static escapePowerShellString(str: string): string {
|
||||
return str.replace(/'/g, "''");
|
||||
}
|
||||
|
||||
private static async startWithBun(script: string, logFile: string, port: number): Promise<{ success: boolean; pid?: number; error?: string }> {
|
||||
const bunPath = getBunPath();
|
||||
if (!bunPath) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Bun is required but not found in PATH or common installation paths. Install from https://bun.sh'
|
||||
};
|
||||
}
|
||||
try {
|
||||
const isWindows = process.platform === 'win32';
|
||||
|
||||
if (isWindows) {
|
||||
// Windows: Use PowerShell Start-Process with -WindowStyle Hidden
|
||||
// This properly hides the console window (affects both Bun and Node.js)
|
||||
// Note: windowsHide: true doesn't work with detached: true (Bun inherits Node.js process spawning semantics)
|
||||
// See: https://github.com/nodejs/node/issues/21825 and PR #315 for detailed testing
|
||||
//
|
||||
// On Windows, we start worker-wrapper.cjs which manages the actual worker-service.cjs.
|
||||
// This solves the zombie port problem: the wrapper has no sockets, so when it kills
|
||||
// and respawns the inner worker, the socket is properly released.
|
||||
//
|
||||
// Security: All paths (bunPath, script, MARKETPLACE_ROOT) are application-controlled system paths,
|
||||
// not user input. If an attacker could modify these paths, they would already have full filesystem
|
||||
// access including direct access to ~/.claude-mem/claude-mem.db. Nevertheless, we properly escape
|
||||
// all values for PowerShell to follow security best practices.
|
||||
const escapedBunPath = this.escapePowerShellString(bunPath);
|
||||
const escapedScript = this.escapePowerShellString(script);
|
||||
const escapedWorkDir = this.escapePowerShellString(MARKETPLACE_ROOT);
|
||||
const escapedLogFile = this.escapePowerShellString(logFile);
|
||||
const envVars = `$env:CLAUDE_MEM_WORKER_PORT='${port}'`;
|
||||
const psCommand = `${envVars}; Start-Process -FilePath '${escapedBunPath}' -ArgumentList '${escapedScript}' -WorkingDirectory '${escapedWorkDir}' -WindowStyle Hidden -RedirectStandardOutput '${escapedLogFile}' -RedirectStandardError '${escapedLogFile}.err' -PassThru | Select-Object -ExpandProperty Id`;
|
||||
|
||||
const result = spawnSync('powershell', ['-Command', psCommand], {
|
||||
stdio: 'pipe',
|
||||
timeout: 10000,
|
||||
windowsHide: true
|
||||
});
|
||||
|
||||
if (result.status !== 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: `PowerShell spawn failed: ${result.stderr?.toString() || 'unknown error'}`
|
||||
};
|
||||
}
|
||||
|
||||
const pid = parseInt(result.stdout.toString().trim(), 10);
|
||||
if (isNaN(pid)) {
|
||||
return { success: false, error: 'Failed to get PID from PowerShell' };
|
||||
}
|
||||
|
||||
// Write PID file
|
||||
this.writePidFile({
|
||||
pid,
|
||||
port,
|
||||
startedAt: new Date().toISOString(),
|
||||
version: process.env.npm_package_version || 'unknown'
|
||||
});
|
||||
|
||||
// Wait for health
|
||||
return this.waitForHealth(pid, port);
|
||||
} else {
|
||||
// Unix: Use standard spawn with detached
|
||||
const child = spawn(bunPath, [script], {
|
||||
detached: true,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: { ...process.env, CLAUDE_MEM_WORKER_PORT: String(port) },
|
||||
cwd: MARKETPLACE_ROOT
|
||||
});
|
||||
|
||||
// Write logs
|
||||
const logStream = createWriteStream(logFile, { flags: 'a' });
|
||||
child.stdout?.pipe(logStream);
|
||||
child.stderr?.pipe(logStream);
|
||||
|
||||
child.unref();
|
||||
|
||||
if (!child.pid) {
|
||||
return { success: false, error: 'Failed to get PID from spawned process' };
|
||||
}
|
||||
|
||||
// Write PID file
|
||||
this.writePidFile({
|
||||
pid: child.pid,
|
||||
port,
|
||||
startedAt: new Date().toISOString(),
|
||||
version: process.env.npm_package_version || 'unknown'
|
||||
});
|
||||
|
||||
// Wait for health
|
||||
return this.waitForHealth(child.pid, port);
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
static async stop(timeout: number = 5000): Promise<boolean> {
|
||||
const info = this.getPidInfo();
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
// Windows: Try graceful HTTP shutdown first - this works regardless of PID file state
|
||||
// because the worker shuts itself down from the inside (via wrapper IPC)
|
||||
const port = info?.port ?? this.getPortFromSettings();
|
||||
const httpShutdownSucceeded = await this.tryHttpShutdown(port);
|
||||
|
||||
if (httpShutdownSucceeded) {
|
||||
// HTTP shutdown succeeded - worker confirmed down, safe to remove PID file
|
||||
this.removePidFile();
|
||||
return true;
|
||||
}
|
||||
|
||||
// HTTP shutdown failed (worker not responding), fall back to taskkill
|
||||
if (!info) {
|
||||
// No PID file and HTTP failed - nothing more we can do
|
||||
return true;
|
||||
}
|
||||
|
||||
const { execSync } = await import('child_process');
|
||||
try {
|
||||
// Use taskkill /T /F to kill entire process tree
|
||||
// This ensures the wrapper AND all its children (inner worker, MCP, ChromaSync) are killed
|
||||
// which is necessary to properly release the socket and avoid zombie ports
|
||||
execSync(`taskkill /PID ${info.pid} /T /F`, { timeout: 10000, stdio: 'ignore' });
|
||||
} catch {
|
||||
// Process may already be dead
|
||||
}
|
||||
|
||||
// Wait for process to actually exit before removing PID file
|
||||
try {
|
||||
await this.waitForExit(info.pid, timeout);
|
||||
} catch {
|
||||
// Timeout waiting - process may still be alive
|
||||
}
|
||||
|
||||
// Only remove PID file if process is confirmed dead
|
||||
if (!this.isProcessAlive(info.pid)) {
|
||||
this.removePidFile();
|
||||
}
|
||||
return true;
|
||||
} else {
|
||||
// Unix: Use signals (unchanged behavior)
|
||||
if (!info) return true;
|
||||
|
||||
try {
|
||||
process.kill(info.pid, 'SIGTERM');
|
||||
await this.waitForExit(info.pid, timeout);
|
||||
} catch {
|
||||
try {
|
||||
process.kill(info.pid, 'SIGKILL');
|
||||
} catch {
|
||||
// Process already dead
|
||||
}
|
||||
}
|
||||
|
||||
this.removePidFile();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
static async restart(port: number): Promise<{ success: boolean; pid?: number; error?: string }> {
|
||||
await this.stop();
|
||||
return this.start(port);
|
||||
}
|
||||
|
||||
static async status(): Promise<{ running: boolean; pid?: number; port?: number; uptime?: string }> {
|
||||
const info = this.getPidInfo();
|
||||
if (!info) return { running: false };
|
||||
|
||||
const running = this.isProcessAlive(info.pid);
|
||||
return {
|
||||
running,
|
||||
pid: running ? info.pid : undefined,
|
||||
port: running ? info.port : undefined,
|
||||
uptime: running ? this.formatUptime(info.startedAt) : undefined
|
||||
};
|
||||
}
|
||||
|
||||
static async isRunning(): Promise<boolean> {
|
||||
const info = this.getPidInfo();
|
||||
if (!info) return false;
|
||||
const alive = this.isProcessAlive(info.pid);
|
||||
if (!alive) {
|
||||
this.removePidFile(); // Clean up stale PID file
|
||||
}
|
||||
return alive;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get worker port from settings file
|
||||
*/
|
||||
private static getPortFromSettings(): number {
|
||||
try {
|
||||
const settingsPath = join(DATA_DIR, 'settings.json');
|
||||
const settings = SettingsDefaultsManager.loadFromFile(settingsPath);
|
||||
return parseInt(settings.CLAUDE_MEM_WORKER_PORT, 10);
|
||||
} catch {
|
||||
return parseInt(SettingsDefaultsManager.get('CLAUDE_MEM_WORKER_PORT'), 10);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to shut down the worker via HTTP endpoint
|
||||
* Returns true if shutdown succeeded, false if worker not responding
|
||||
*/
|
||||
private static async tryHttpShutdown(port: number): Promise<boolean> {
|
||||
try {
|
||||
// Send shutdown request
|
||||
const response = await fetch(`http://127.0.0.1:${port}/api/admin/shutdown`, {
|
||||
method: 'POST',
|
||||
signal: AbortSignal.timeout(2000)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Wait for worker to actually stop responding
|
||||
return await this.waitForWorkerDown(port, 5000);
|
||||
} catch {
|
||||
// Worker not responding to HTTP - it may be dead or hung
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for worker to stop responding on the given port
|
||||
*/
|
||||
private static async waitForWorkerDown(port: number, timeout: number): Promise<boolean> {
|
||||
const startTime = Date.now();
|
||||
|
||||
while (Date.now() - startTime < timeout) {
|
||||
try {
|
||||
await fetch(`http://127.0.0.1:${port}/api/health`, {
|
||||
signal: AbortSignal.timeout(500)
|
||||
});
|
||||
// Still responding, wait and retry
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
} catch {
|
||||
// Worker stopped responding - success
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Timeout - worker still responding
|
||||
return false;
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
private static getPidInfo(): PidInfo | null {
|
||||
try {
|
||||
if (!existsSync(PID_FILE)) return null;
|
||||
const content = readFileSync(PID_FILE, 'utf-8');
|
||||
const parsed = JSON.parse(content);
|
||||
// Validate required fields have correct types
|
||||
if (typeof parsed.pid !== 'number' || typeof parsed.port !== 'number') {
|
||||
logger.warn('PROCESS', 'Malformed PID file: missing or invalid pid/port fields', {}, { parsed });
|
||||
return null;
|
||||
}
|
||||
return parsed as PidInfo;
|
||||
} catch (error) {
|
||||
logger.warn('PROCESS', 'Failed to read PID file', {}, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
path: PID_FILE
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static writePidFile(info: PidInfo): void {
|
||||
mkdirSync(DATA_DIR, { recursive: true });
|
||||
writeFileSync(PID_FILE, JSON.stringify(info, null, 2));
|
||||
}
|
||||
|
||||
private static removePidFile(): void {
|
||||
try {
|
||||
if (existsSync(PID_FILE)) {
|
||||
unlinkSync(PID_FILE);
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
private static isProcessAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static async waitForHealth(pid: number, port: number, timeoutMs: number = 10000): Promise<{ success: boolean; pid?: number; error?: string }> {
|
||||
const startTime = Date.now();
|
||||
const isWindows = process.platform === 'win32';
|
||||
// Increase timeout on Windows to account for slower process startup
|
||||
const adjustedTimeout = isWindows ? timeoutMs * 2 : timeoutMs;
|
||||
|
||||
while (Date.now() - startTime < adjustedTimeout) {
|
||||
// Check if process is still alive
|
||||
if (!this.isProcessAlive(pid)) {
|
||||
const errorMsg = isWindows
|
||||
? `Process died during startup\n\nTroubleshooting:\n1. Check Task Manager for zombie 'bun.exe' or 'node.exe' processes\n2. Verify port ${port} is not in use: netstat -ano | findstr ${port}\n3. Check worker logs in ~/.claude-mem/logs/\n4. See GitHub issues: #363, #367, #371, #373\n5. Docs: https://docs.claude-mem.ai/troubleshooting/windows-issues`
|
||||
: 'Process died during startup';
|
||||
return { success: false, error: errorMsg };
|
||||
}
|
||||
|
||||
// Try readiness check (changed from /health to /api/readiness)
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/api/readiness`, {
|
||||
signal: AbortSignal.timeout(1000)
|
||||
});
|
||||
if (response.ok) {
|
||||
return { success: true, pid };
|
||||
}
|
||||
} catch {
|
||||
// Not ready yet, continue polling
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
}
|
||||
|
||||
const timeoutMsg = isWindows
|
||||
? `Worker failed to start on Windows (readiness check timed out after ${adjustedTimeout}ms)\n\nTroubleshooting:\n1. Check Task Manager for zombie 'bun.exe' or 'node.exe' processes\n2. Verify port ${port} is not in use: netstat -ano | findstr ${port}\n3. Check worker logs in ~/.claude-mem/logs/\n4. See GitHub issues: #363, #367, #371, #373\n5. Docs: https://docs.claude-mem.ai/troubleshooting/windows-issues`
|
||||
: `Readiness check timed out after ${adjustedTimeout}ms`;
|
||||
|
||||
return { success: false, error: timeoutMsg };
|
||||
}
|
||||
|
||||
private static async waitForExit(pid: number, timeout: number): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
|
||||
while (Date.now() - startTime < timeout) {
|
||||
if (!this.isProcessAlive(pid)) {
|
||||
return;
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
throw new Error('Process did not exit within timeout');
|
||||
}
|
||||
|
||||
private static getLogFilePath(): string {
|
||||
const date = new Date().toISOString().slice(0, 10);
|
||||
return join(LOG_DIR, `worker-${date}.log`);
|
||||
}
|
||||
|
||||
private static formatUptime(startedAt: string): string {
|
||||
const startTime = new Date(startedAt).getTime();
|
||||
const now = Date.now();
|
||||
const diffMs = now - startTime;
|
||||
|
||||
const seconds = Math.floor(diffMs / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
|
||||
if (days > 0) return `${days}d ${hours % 24}h`;
|
||||
if (hours > 0) return `${hours}h ${minutes % 60}m`;
|
||||
if (minutes > 0) return `${minutes}m ${seconds % 60}s`;
|
||||
return `${seconds}s`;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { DATA_DIR, DB_PATH, ensureDir } from '../../shared/paths.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
|
||||
// SQLite configuration constants
|
||||
const SQLITE_MMAP_SIZE_BYTES = 256 * 1024 * 1024; // 256MB
|
||||
@@ -126,7 +127,7 @@ export class DatabaseManager {
|
||||
|
||||
for (const migration of this.migrations) {
|
||||
if (migration.version > maxApplied) {
|
||||
console.log(`Applying migration ${migration.version}...`);
|
||||
logger.info('DB', `Applying migration ${migration.version}`);
|
||||
|
||||
const transaction = this.db.transaction(() => {
|
||||
migration.up(this.db!);
|
||||
@@ -136,7 +137,7 @@ export class DatabaseManager {
|
||||
});
|
||||
|
||||
transaction();
|
||||
console.log(`Migration ${migration.version} applied successfully`);
|
||||
logger.info('DB', `Migration ${migration.version} applied successfully`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Database } from './sqlite-compat.js';
|
||||
import type { PendingMessage } from '../worker-types.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
|
||||
/**
|
||||
* Persistent pending message record from database
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { TableNameRow } from '../../types/database.js';
|
||||
import { DATA_DIR, DB_PATH, ensureDir } from '../../shared/paths.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import {
|
||||
ObservationSearchResult,
|
||||
SessionSummarySearchResult,
|
||||
@@ -44,9 +45,6 @@ export class SessionSearch {
|
||||
* - Tables maintained but search paths removed
|
||||
* - Triggers still fire to keep tables synchronized
|
||||
*
|
||||
* Note: Using console.log for migration messages since they run during constructor
|
||||
* before structured logger is available. Actual errors use console.error.
|
||||
*
|
||||
* TODO: Remove FTS5 infrastructure in future major version (v7.0.0)
|
||||
*/
|
||||
private ensureFTSTables(): void {
|
||||
@@ -59,7 +57,7 @@ export class SessionSearch {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[SessionSearch] Creating FTS5 tables...');
|
||||
logger.info('DB', 'Creating FTS5 tables');
|
||||
|
||||
// Create observations_fts virtual table
|
||||
this.db.run(`
|
||||
@@ -143,7 +141,7 @@ export class SessionSearch {
|
||||
END;
|
||||
`);
|
||||
|
||||
console.log('[SessionSearch] FTS5 tables created successfully');
|
||||
logger.info('DB', 'FTS5 tables created successfully');
|
||||
}
|
||||
|
||||
|
||||
@@ -270,7 +268,7 @@ export class SessionSearch {
|
||||
|
||||
// Vector search with query text should be handled by ChromaDB
|
||||
// This method only supports filter-only queries (query=undefined)
|
||||
console.warn('[SessionSearch] Text search not supported - use ChromaDB for vector search');
|
||||
logger.warn('DB', 'Text search not supported - use ChromaDB for vector search');
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -309,7 +307,7 @@ export class SessionSearch {
|
||||
|
||||
// Vector search with query text should be handled by ChromaDB
|
||||
// This method only supports filter-only queries (query=undefined)
|
||||
console.warn('[SessionSearch] Text search not supported - use ChromaDB for vector search');
|
||||
logger.warn('DB', 'Text search not supported - use ChromaDB for vector search');
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -495,7 +493,7 @@ export class SessionSearch {
|
||||
|
||||
// Vector search with query text should be handled by ChromaDB
|
||||
// This method only supports filter-only queries (query=undefined)
|
||||
console.warn('[SessionSearch] Text search not supported - use ChromaDB for vector search');
|
||||
logger.warn('DB', 'Text search not supported - use ChromaDB for vector search');
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
@@ -48,9 +48,6 @@ export class SessionStore {
|
||||
/**
|
||||
* Initialize database schema using migrations (migration004)
|
||||
* This runs the core SDK tables migration if no tables exist
|
||||
*
|
||||
* Note: Using console.log for migration messages since they run during constructor
|
||||
* before structured logger is available. Actual errors use console.error.
|
||||
*/
|
||||
private initializeSchema(): void {
|
||||
try {
|
||||
@@ -70,7 +67,7 @@ export class SessionStore {
|
||||
// Only run migration004 if no migrations have been applied
|
||||
// This creates the sdk_sessions, observations, and session_summaries tables
|
||||
if (maxApplied === 0) {
|
||||
console.log('[SessionStore] Initializing fresh database with migration004...');
|
||||
logger.info('DB', 'Initializing fresh database with migration004');
|
||||
|
||||
// Migration004: SDK agent architecture tables
|
||||
this.db.run(`
|
||||
@@ -134,10 +131,10 @@ export class SessionStore {
|
||||
// Record migration004 as applied
|
||||
this.db.prepare('INSERT INTO schema_versions (version, applied_at) VALUES (?, ?)').run(4, new Date().toISOString());
|
||||
|
||||
console.log('[SessionStore] Migration004 applied successfully');
|
||||
logger.info('DB', 'Migration004 applied successfully');
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('[SessionStore] Schema initialization error:', error.message);
|
||||
logger.error('DB', 'Schema initialization error', undefined, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -156,7 +153,7 @@ export class SessionStore {
|
||||
|
||||
if (!hasWorkerPort) {
|
||||
this.db.run('ALTER TABLE sdk_sessions ADD COLUMN worker_port INTEGER');
|
||||
console.log('[SessionStore] Added worker_port column to sdk_sessions table');
|
||||
logger.info('DB', 'Added worker_port column to sdk_sessions table');
|
||||
}
|
||||
|
||||
// Record migration
|
||||
@@ -177,7 +174,7 @@ export class SessionStore {
|
||||
|
||||
if (!hasPromptCounter) {
|
||||
this.db.run('ALTER TABLE sdk_sessions ADD COLUMN prompt_counter INTEGER DEFAULT 0');
|
||||
console.log('[SessionStore] Added prompt_counter column to sdk_sessions table');
|
||||
logger.info('DB', 'Added prompt_counter column to sdk_sessions table');
|
||||
}
|
||||
|
||||
// Check observations for prompt_number
|
||||
@@ -186,7 +183,7 @@ export class SessionStore {
|
||||
|
||||
if (!obsHasPromptNumber) {
|
||||
this.db.run('ALTER TABLE observations ADD COLUMN prompt_number INTEGER');
|
||||
console.log('[SessionStore] Added prompt_number column to observations table');
|
||||
logger.info('DB', 'Added prompt_number column to observations table');
|
||||
}
|
||||
|
||||
// Check session_summaries for prompt_number
|
||||
@@ -195,7 +192,7 @@ export class SessionStore {
|
||||
|
||||
if (!sumHasPromptNumber) {
|
||||
this.db.run('ALTER TABLE session_summaries ADD COLUMN prompt_number INTEGER');
|
||||
console.log('[SessionStore] Added prompt_number column to session_summaries table');
|
||||
logger.info('DB', 'Added prompt_number column to session_summaries table');
|
||||
}
|
||||
|
||||
// Record migration
|
||||
@@ -220,7 +217,7 @@ export class SessionStore {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[SessionStore] Removing UNIQUE constraint from session_summaries.sdk_session_id...');
|
||||
logger.info('DB', 'Removing UNIQUE constraint from session_summaries.sdk_session_id');
|
||||
|
||||
// Begin transaction
|
||||
this.db.run('BEGIN TRANSACTION');
|
||||
@@ -275,7 +272,7 @@ export class SessionStore {
|
||||
// Record migration
|
||||
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(7, new Date().toISOString());
|
||||
|
||||
console.log('[SessionStore] Successfully removed UNIQUE constraint from session_summaries.sdk_session_id');
|
||||
logger.info('DB', 'Successfully removed UNIQUE constraint from session_summaries.sdk_session_id');
|
||||
} catch (error: any) {
|
||||
// Rollback on error
|
||||
this.db.run('ROLLBACK');
|
||||
@@ -301,7 +298,7 @@ export class SessionStore {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[SessionStore] Adding hierarchical fields to observations table...');
|
||||
logger.info('DB', 'Adding hierarchical fields to observations table');
|
||||
|
||||
// Add new columns
|
||||
this.db.run(`
|
||||
@@ -317,7 +314,7 @@ export class SessionStore {
|
||||
// Record migration
|
||||
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(8, new Date().toISOString());
|
||||
|
||||
console.log('[SessionStore] Successfully added hierarchical fields to observations table');
|
||||
logger.info('DB', 'Successfully added hierarchical fields to observations table');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -339,7 +336,7 @@ export class SessionStore {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[SessionStore] Making observations.text nullable...');
|
||||
logger.info('DB', 'Making observations.text nullable');
|
||||
|
||||
// Begin transaction
|
||||
this.db.run('BEGIN TRANSACTION');
|
||||
@@ -396,7 +393,7 @@ export class SessionStore {
|
||||
// Record migration
|
||||
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(9, new Date().toISOString());
|
||||
|
||||
console.log('[SessionStore] Successfully made observations.text nullable');
|
||||
logger.info('DB', 'Successfully made observations.text nullable');
|
||||
} catch (error: any) {
|
||||
// Rollback on error
|
||||
this.db.run('ROLLBACK');
|
||||
@@ -420,7 +417,7 @@ export class SessionStore {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[SessionStore] Creating user_prompts table with FTS5 support...');
|
||||
logger.info('DB', 'Creating user_prompts table with FTS5 support');
|
||||
|
||||
// Begin transaction
|
||||
this.db.run('BEGIN TRANSACTION');
|
||||
@@ -479,7 +476,7 @@ export class SessionStore {
|
||||
// Record migration
|
||||
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(10, new Date().toISOString());
|
||||
|
||||
console.log('[SessionStore] Successfully created user_prompts table with FTS5 support');
|
||||
logger.info('DB', 'Successfully created user_prompts table with FTS5 support');
|
||||
} catch (error: any) {
|
||||
// Rollback on error
|
||||
this.db.run('ROLLBACK');
|
||||
@@ -504,7 +501,7 @@ export class SessionStore {
|
||||
|
||||
if (!obsHasDiscoveryTokens) {
|
||||
this.db.run('ALTER TABLE observations ADD COLUMN discovery_tokens INTEGER DEFAULT 0');
|
||||
console.log('[SessionStore] Added discovery_tokens column to observations table');
|
||||
logger.info('DB', 'Added discovery_tokens column to observations table');
|
||||
}
|
||||
|
||||
// Check if discovery_tokens column exists in session_summaries table
|
||||
@@ -513,13 +510,13 @@ export class SessionStore {
|
||||
|
||||
if (!sumHasDiscoveryTokens) {
|
||||
this.db.run('ALTER TABLE session_summaries ADD COLUMN discovery_tokens INTEGER DEFAULT 0');
|
||||
console.log('[SessionStore] Added discovery_tokens column to session_summaries table');
|
||||
logger.info('DB', 'Added discovery_tokens column to session_summaries table');
|
||||
}
|
||||
|
||||
// Record migration only after successful column verification/addition
|
||||
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(11, new Date().toISOString());
|
||||
} catch (error: any) {
|
||||
console.error('[SessionStore] Discovery tokens migration error:', error.message);
|
||||
logger.error('DB', 'Discovery tokens migration error', undefined, error);
|
||||
throw error; // Re-throw to prevent silent failures
|
||||
}
|
||||
}
|
||||
@@ -542,7 +539,7 @@ export class SessionStore {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[SessionStore] Creating pending_messages table...');
|
||||
logger.info('DB', 'Creating pending_messages table');
|
||||
|
||||
this.db.run(`
|
||||
CREATE TABLE pending_messages (
|
||||
@@ -572,9 +569,9 @@ export class SessionStore {
|
||||
|
||||
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(16, new Date().toISOString());
|
||||
|
||||
console.log('[SessionStore] pending_messages table created successfully');
|
||||
logger.info('DB', 'pending_messages table created successfully');
|
||||
} catch (error: any) {
|
||||
console.error('[SessionStore] Pending messages table migration error:', error.message);
|
||||
logger.error('DB', 'Pending messages table migration error', undefined, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -1387,7 +1384,7 @@ export class SessionStore {
|
||||
startEpoch = beforeRecords.length > 0 ? beforeRecords[beforeRecords.length - 1].created_at_epoch : anchorEpoch;
|
||||
endEpoch = afterRecords.length > 0 ? afterRecords[afterRecords.length - 1].created_at_epoch : anchorEpoch;
|
||||
} catch (err: any) {
|
||||
console.error('[SessionStore] Error getting boundary observations:', err.message, project ? `(project: ${project})` : '(all projects)');
|
||||
logger.error('DB', 'Error getting boundary observations', undefined, { error: err, project });
|
||||
return { observations: [], sessions: [], prompts: [] };
|
||||
}
|
||||
} else {
|
||||
@@ -1419,7 +1416,7 @@ export class SessionStore {
|
||||
startEpoch = beforeRecords.length > 0 ? beforeRecords[beforeRecords.length - 1].created_at_epoch : anchorEpoch;
|
||||
endEpoch = afterRecords.length > 0 ? afterRecords[afterRecords.length - 1].created_at_epoch : anchorEpoch;
|
||||
} catch (err: any) {
|
||||
console.error('[SessionStore] Error getting boundary timestamps:', err.message, project ? `(project: ${project})` : '(all projects)');
|
||||
logger.error('DB', 'Error getting boundary timestamps', undefined, { error: err, project });
|
||||
return { observations: [], sessions: [], prompts: [] };
|
||||
}
|
||||
}
|
||||
@@ -1475,7 +1472,7 @@ export class SessionStore {
|
||||
}))
|
||||
};
|
||||
} catch (err: any) {
|
||||
console.error('[SessionStore] Error querying timeline records:', err.message, project ? `(project: ${project})` : '(all projects)');
|
||||
logger.error('DB', 'Error querying timeline records', undefined, { error: err, project });
|
||||
return { observations: [], sessions: [], prompts: [] };
|
||||
}
|
||||
}
|
||||
|
||||
+414
-45
@@ -14,17 +14,203 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||
import { getWorkerPort, getWorkerHost } from '../shared/worker-utils.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { exec, execSync } from 'child_process';
|
||||
import { exec, execSync, spawn } from 'child_process';
|
||||
import { homedir } from 'os';
|
||||
import { existsSync, writeFileSync, readFileSync, unlinkSync, mkdirSync } from 'fs';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
// PID file management for self-spawn pattern
|
||||
const DATA_DIR = path.join(homedir(), '.claude-mem');
|
||||
const PID_FILE = path.join(DATA_DIR, 'worker.pid');
|
||||
const HOOK_RESPONSE = '{"continue": true, "suppressOutput": true}';
|
||||
|
||||
interface PidInfo {
|
||||
pid: number;
|
||||
port: number;
|
||||
startedAt: string;
|
||||
}
|
||||
|
||||
// PID file utility functions
|
||||
function writePidFile(info: PidInfo): void {
|
||||
mkdirSync(DATA_DIR, { recursive: true });
|
||||
writeFileSync(PID_FILE, JSON.stringify(info, null, 2));
|
||||
}
|
||||
|
||||
function readPidFile(): PidInfo | null {
|
||||
try {
|
||||
if (!existsSync(PID_FILE)) return null;
|
||||
return JSON.parse(readFileSync(PID_FILE, 'utf-8'));
|
||||
} catch (error) {
|
||||
logger.warn('SYSTEM', 'Failed to read PID file', { path: PID_FILE, error: (error as Error).message });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function removePidFile(): void {
|
||||
try {
|
||||
if (existsSync(PID_FILE)) unlinkSync(PID_FILE);
|
||||
} catch (error) {
|
||||
logger.warn('SYSTEM', 'Failed to remove PID file', { path: PID_FILE, error: (error as Error).message });
|
||||
}
|
||||
}
|
||||
|
||||
// Lockfile for CLI command mutual exclusion (prevents race conditions on Windows)
|
||||
const LOCK_FILE = path.join(DATA_DIR, 'worker.lock');
|
||||
const LOCK_STALE_MS = 120000; // Lock considered stale after 2 minutes
|
||||
|
||||
interface LockInfo {
|
||||
pid: number;
|
||||
command: string;
|
||||
startedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up stale lock from crashed processes
|
||||
*/
|
||||
function cleanupStaleLock(): void {
|
||||
try {
|
||||
if (!existsSync(LOCK_FILE)) return;
|
||||
const lockData = readFileSync(LOCK_FILE, 'utf-8');
|
||||
const lockInfo: LockInfo = JSON.parse(lockData);
|
||||
const lockAge = Date.now() - new Date(lockInfo.startedAt).getTime();
|
||||
if (lockAge > LOCK_STALE_MS) {
|
||||
logger.warn('SYSTEM', 'Removing stale lock', {
|
||||
lockAge: Math.round(lockAge / 1000) + 's',
|
||||
originalPid: lockInfo.pid,
|
||||
originalCommand: lockInfo.command
|
||||
});
|
||||
unlinkSync(LOCK_FILE);
|
||||
}
|
||||
} catch {
|
||||
// If we can't read the lock, it's likely corrupted - remove it
|
||||
try { unlinkSync(LOCK_FILE); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire exclusive lock for worker operations
|
||||
* Uses atomic file creation (O_EXCL) for cross-process safety
|
||||
*/
|
||||
function acquireLock(command: string): boolean {
|
||||
mkdirSync(DATA_DIR, { recursive: true });
|
||||
cleanupStaleLock();
|
||||
|
||||
const lockInfo: LockInfo = {
|
||||
pid: process.pid,
|
||||
command,
|
||||
startedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
try {
|
||||
// O_EXCL ensures atomic creation - fails if file exists
|
||||
const fd = fs.openSync(LOCK_FILE, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY);
|
||||
fs.writeSync(fd, JSON.stringify(lockInfo, null, 2));
|
||||
fs.closeSync(fd);
|
||||
return true;
|
||||
} catch (error: unknown) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
|
||||
return false;
|
||||
}
|
||||
logger.warn('SYSTEM', 'Lock acquisition error', { error: (error as Error).message });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Release lock file
|
||||
*/
|
||||
function releaseLock(): void {
|
||||
try {
|
||||
if (existsSync(LOCK_FILE)) unlinkSync(LOCK_FILE);
|
||||
} catch (error) {
|
||||
logger.warn('SYSTEM', 'Lock release error', { error: (error as Error).message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for lock with timeout
|
||||
*/
|
||||
async function waitForLock(command: string, timeoutMs: number): Promise<boolean> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
if (acquireLock(command)) return true;
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get platform-adjusted timeout (Windows socket cleanup is slower)
|
||||
*/
|
||||
function getPlatformTimeout(baseMs: number): number {
|
||||
const WINDOWS_MULTIPLIER = 2.0;
|
||||
return process.platform === 'win32' ? Math.round(baseMs * WINDOWS_MULTIPLIER) : baseMs;
|
||||
}
|
||||
|
||||
async function isPortInUse(port: number): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/api/health`, {
|
||||
signal: AbortSignal.timeout(2000)
|
||||
});
|
||||
return response.ok;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
async function waitForHealth(port: number, timeoutMs: number = 30000): Promise<boolean> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/api/readiness`, {
|
||||
signal: AbortSignal.timeout(2000)
|
||||
});
|
||||
if (response.ok) return true;
|
||||
} catch {
|
||||
// Not ready yet
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function httpShutdown(port: number): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/api/admin/shutdown`, {
|
||||
method: 'POST',
|
||||
signal: AbortSignal.timeout(5000)
|
||||
});
|
||||
if (!response.ok) {
|
||||
logger.warn('SYSTEM', 'Shutdown request returned error', { port, status: response.status });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
// Connection refused is expected if worker already stopped
|
||||
const isConnectionRefused = (error as Error).message?.includes('ECONNREFUSED');
|
||||
if (!isConnectionRefused) {
|
||||
logger.warn('SYSTEM', 'Shutdown request failed', { port, error: (error as Error).message });
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForPortFree(port: number, timeoutMs: number = 10000): Promise<boolean> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
if (!(await isPortInUse(port))) return true;
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Import composed service layer
|
||||
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 { GeminiAgent } from './worker/GeminiAgent.js';
|
||||
import { OpenRouterAgent } from './worker/OpenRouterAgent.js';
|
||||
import { PaginationHelper } from './worker/PaginationHelper.js';
|
||||
import { SettingsManager } from './worker/SettingsManager.js';
|
||||
import { SearchManager } from './worker/SearchManager.js';
|
||||
@@ -56,6 +242,7 @@ export class WorkerService {
|
||||
private sseBroadcaster: SSEBroadcaster;
|
||||
private sdkAgent: SDKAgent;
|
||||
private geminiAgent: GeminiAgent;
|
||||
private openRouterAgent: OpenRouterAgent;
|
||||
private paginationHelper: PaginationHelper;
|
||||
private settingsManager: SettingsManager;
|
||||
private sessionEventBroadcaster: SessionEventBroadcaster;
|
||||
@@ -86,6 +273,8 @@ export class WorkerService {
|
||||
this.sdkAgent = new SDKAgent(this.dbManager, this.sessionManager);
|
||||
this.geminiAgent = new GeminiAgent(this.dbManager, this.sessionManager);
|
||||
this.geminiAgent.setFallbackAgent(this.sdkAgent); // Enable fallback to Claude on Gemini API failure
|
||||
this.openRouterAgent = new OpenRouterAgent(this.dbManager, this.sessionManager);
|
||||
this.openRouterAgent.setFallbackAgent(this.sdkAgent); // Enable fallback to Claude on OpenRouter API failure
|
||||
this.paginationHelper = new PaginationHelper(this.dbManager);
|
||||
this.settingsManager = new SettingsManager(this.dbManager);
|
||||
this.sessionEventBroadcaster = new SessionEventBroadcaster(this.sseBroadcaster, this);
|
||||
@@ -103,7 +292,7 @@ export class WorkerService {
|
||||
|
||||
// Initialize route handlers (SearchRoutes will use MCP client initially, then switch to SearchManager after DB init)
|
||||
this.viewerRoutes = new ViewerRoutes(this.sseBroadcaster, this.dbManager, this.sessionManager);
|
||||
this.sessionRoutes = new SessionRoutes(this.sessionManager, this.dbManager, this.sdkAgent, this.geminiAgent, this.sessionEventBroadcaster, this);
|
||||
this.sessionRoutes = new SessionRoutes(this.sessionManager, this.dbManager, this.sdkAgent, this.geminiAgent, this.openRouterAgent, this.sessionEventBroadcaster, this);
|
||||
this.dataRoutes = new DataRoutes(this.paginationHelper, this.dbManager, this.sessionManager, this.sseBroadcaster, this, this.startTime);
|
||||
// SearchRoutes needs SearchManager which requires initialized DB - will be created in initializeBackground()
|
||||
this.searchRoutes = null;
|
||||
@@ -266,7 +455,7 @@ export class WorkerService {
|
||||
this.app.get('/api/context/inject', async (req, res, next) => {
|
||||
try {
|
||||
// Wait for initialization to complete (with timeout)
|
||||
const timeoutMs = 30000; // 30 second timeout
|
||||
const timeoutMs = 300000; // 5 minute timeout for slow systems
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Initialization timeout')), timeoutMs)
|
||||
);
|
||||
@@ -326,7 +515,7 @@ export class WorkerService {
|
||||
if (isWindows) {
|
||||
// Windows: Use PowerShell Get-CimInstance to find chroma-mcp processes
|
||||
const cmd = `powershell -Command "Get-CimInstance Win32_Process | Where-Object { $_.Name -like '*python*' -and $_.CommandLine -like '*chroma-mcp*' } | Select-Object -ExpandProperty ProcessId"`;
|
||||
const { stdout } = await execAsync(cmd, { timeout: 5000 });
|
||||
const { stdout } = await execAsync(cmd, { timeout: 60000 });
|
||||
|
||||
if (!stdout.trim()) {
|
||||
logger.debug('SYSTEM', 'No orphaned chroma-mcp processes found (Windows)');
|
||||
@@ -381,10 +570,20 @@ export class WorkerService {
|
||||
logger.warn('SYSTEM', 'Skipping invalid PID', { pid });
|
||||
continue;
|
||||
}
|
||||
execSync(`taskkill /PID ${pid} /T /F`, { timeout: 5000, stdio: 'ignore' });
|
||||
try {
|
||||
execSync(`taskkill /PID ${pid} /T /F`, { timeout: 60000, stdio: 'ignore' });
|
||||
} catch {
|
||||
// Process may have already exited - continue cleanup
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await execAsync(`kill ${pids.join(' ')}`);
|
||||
for (const pid of pids) {
|
||||
try {
|
||||
process.kill(pid, 'SIGKILL');
|
||||
} catch {
|
||||
// Process already exited - that's fine
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('SYSTEM', 'Orphaned processes cleaned up', { count: pids.length });
|
||||
@@ -463,11 +662,11 @@ export class WorkerService {
|
||||
env: process.env
|
||||
});
|
||||
|
||||
// Add timeout guard to prevent hanging on MCP connection (15 seconds)
|
||||
const MCP_INIT_TIMEOUT_MS = 15000;
|
||||
// Add timeout guard to prevent hanging on MCP connection (5 minutes for slow systems)
|
||||
const MCP_INIT_TIMEOUT_MS = 300000;
|
||||
const mcpConnectionPromise = this.mcpClient.connect(transport);
|
||||
const timeoutPromise = new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error('MCP connection timeout after 15s')), MCP_INIT_TIMEOUT_MS)
|
||||
setTimeout(() => reject(new Error('MCP connection timeout after 5 minutes')), MCP_INIT_TIMEOUT_MS)
|
||||
);
|
||||
|
||||
await Promise.race([mcpConnectionPromise, timeoutPromise]);
|
||||
@@ -651,13 +850,18 @@ export class WorkerService {
|
||||
return [];
|
||||
}
|
||||
|
||||
const cmd = `powershell -Command "Get-CimInstance Win32_Process | Where-Object { $_.ParentProcessId -eq ${parentPid} } | Select-Object -ExpandProperty ProcessId"`;
|
||||
const { stdout } = await execAsync(cmd, { timeout: 5000 });
|
||||
return stdout
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map(s => parseInt(s.trim(), 10))
|
||||
.filter(n => !isNaN(n) && Number.isInteger(n) && n > 0); // SECURITY: Validate each PID
|
||||
try {
|
||||
const cmd = `powershell -Command "Get-CimInstance Win32_Process | Where-Object { $_.ParentProcessId -eq ${parentPid} } | Select-Object -ExpandProperty ProcessId"`;
|
||||
const { stdout } = await execAsync(cmd, { timeout: 60000 });
|
||||
return stdout
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map(s => parseInt(s.trim(), 10))
|
||||
.filter(n => !isNaN(n) && Number.isInteger(n) && n > 0); // SECURITY: Validate each PID
|
||||
} catch (error) {
|
||||
logger.warn('SYSTEM', 'Failed to enumerate child processes', { parentPid, error: (error as Error).message });
|
||||
return []; // Fail safely - continue shutdown without child process cleanup
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -670,12 +874,17 @@ export class WorkerService {
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
// /T kills entire process tree, /F forces termination
|
||||
await execAsync(`taskkill /PID ${pid} /T /F`, { timeout: 5000 });
|
||||
try {
|
||||
if (process.platform === 'win32') {
|
||||
// /T kills entire process tree, /F forces termination
|
||||
await execAsync(`taskkill /PID ${pid} /T /F`, { timeout: 60000 });
|
||||
} else {
|
||||
process.kill(pid, 'SIGKILL');
|
||||
}
|
||||
logger.info('SYSTEM', 'Killed process', { pid });
|
||||
} else {
|
||||
process.kill(pid, 'SIGKILL');
|
||||
} catch {
|
||||
// Process may have already exited - continue shutdown
|
||||
logger.debug('SYSTEM', 'Process already exited during force kill', { pid });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -687,8 +896,12 @@ export class WorkerService {
|
||||
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const stillAlive = pids.filter(pid => {
|
||||
process.kill(pid, 0); // Signal 0 checks if process exists - throws if dead
|
||||
return true;
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (stillAlive.length === 0) {
|
||||
@@ -737,31 +950,187 @@ export class WorkerService {
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main Entry Point
|
||||
// CLI Entry Point
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Start the worker service (if running as main module)
|
||||
* Note: Using require.main check for CJS compatibility (build outputs CJS)
|
||||
*/
|
||||
if (require.main === module || !module.parent) {
|
||||
const worker = new WorkerService();
|
||||
async function main() {
|
||||
const command = process.argv[2];
|
||||
const port = getWorkerPort();
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGTERM', async () => {
|
||||
logger.info('SYSTEM', 'Received SIGTERM, shutting down gracefully');
|
||||
await worker.shutdown();
|
||||
process.exit(0);
|
||||
});
|
||||
switch (command) {
|
||||
case 'start': {
|
||||
// Acquire lock BEFORE checking port to prevent race condition
|
||||
// If we can't get lock, another session is spawning - wait for health instead
|
||||
if (!acquireLock('start')) {
|
||||
logger.info('SYSTEM', 'Another session is spawning worker, waiting for health');
|
||||
const healthy = await waitForHealth(port, getPlatformTimeout(30000));
|
||||
if (healthy) {
|
||||
logger.info('SYSTEM', 'Worker healthy, returning success');
|
||||
process.exit(0);
|
||||
}
|
||||
// Still not healthy after wait - try to acquire lock and spawn
|
||||
const gotLock = await waitForLock('start', 5000);
|
||||
if (!gotLock) {
|
||||
logger.error('SYSTEM', 'Failed to acquire lock after timeout');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
process.on('SIGINT', async () => {
|
||||
logger.info('SYSTEM', 'Received SIGINT, shutting down gracefully');
|
||||
await worker.shutdown();
|
||||
process.exit(0);
|
||||
});
|
||||
try {
|
||||
// Re-check port AFTER acquiring lock
|
||||
if (await isPortInUse(port)) {
|
||||
releaseLock();
|
||||
logger.info('SYSTEM', 'Port already in use, worker already running');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
worker.start().catch((error) => {
|
||||
logger.failure('SYSTEM', 'Worker failed to start', {}, error as Error);
|
||||
process.exit(1);
|
||||
});
|
||||
// Spawn self as daemon
|
||||
const child = spawn(process.execPath, [__filename, '--daemon'], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
env: { ...process.env, CLAUDE_MEM_WORKER_PORT: String(port) }
|
||||
});
|
||||
|
||||
if (child.pid === undefined) {
|
||||
releaseLock();
|
||||
logger.error('SYSTEM', 'Failed to spawn worker daemon');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
child.unref();
|
||||
|
||||
// Write PID file
|
||||
writePidFile({ pid: child.pid, port, startedAt: new Date().toISOString() });
|
||||
|
||||
// Wait for health with platform-adjusted timeout
|
||||
const healthy = await waitForHealth(port, getPlatformTimeout(30000));
|
||||
releaseLock();
|
||||
|
||||
if (!healthy) {
|
||||
removePidFile();
|
||||
logger.error('SYSTEM', 'Worker failed to start');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
logger.info('SYSTEM', 'Worker started successfully');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
releaseLock();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
case 'stop': {
|
||||
// Acquire lock for stop operation
|
||||
if (!acquireLock('stop')) {
|
||||
// Wait briefly for concurrent operation to complete
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
}
|
||||
|
||||
try {
|
||||
await httpShutdown(port);
|
||||
await waitForPortFree(port, getPlatformTimeout(15000));
|
||||
removePidFile();
|
||||
releaseLock();
|
||||
logger.info('SYSTEM', 'Worker stopped successfully');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
releaseLock();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
case 'restart': {
|
||||
// Acquire lock for restart operation
|
||||
if (!acquireLock('restart')) {
|
||||
// Another session is already restarting - wait for health
|
||||
logger.info('SYSTEM', 'Another session is restarting worker, waiting');
|
||||
const healthy = await waitForHealth(port, getPlatformTimeout(45000));
|
||||
if (healthy) {
|
||||
logger.info('SYSTEM', 'Worker healthy after restart');
|
||||
process.exit(0);
|
||||
}
|
||||
logger.error('SYSTEM', 'Worker failed to restart (concurrent operation)');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
await httpShutdown(port);
|
||||
await waitForPortFree(port, getPlatformTimeout(15000));
|
||||
removePidFile();
|
||||
|
||||
const child = spawn(process.execPath, [__filename, '--daemon'], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
env: { ...process.env, CLAUDE_MEM_WORKER_PORT: String(port) }
|
||||
});
|
||||
|
||||
if (child.pid === undefined) {
|
||||
releaseLock();
|
||||
logger.error('SYSTEM', 'Failed to spawn worker daemon during restart');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
child.unref();
|
||||
writePidFile({ pid: child.pid, port, startedAt: new Date().toISOString() });
|
||||
|
||||
const healthy = await waitForHealth(port, getPlatformTimeout(30000));
|
||||
releaseLock();
|
||||
|
||||
if (!healthy) {
|
||||
removePidFile();
|
||||
logger.error('SYSTEM', 'Worker failed to restart');
|
||||
process.exit(1);
|
||||
}
|
||||
logger.info('SYSTEM', 'Worker restarted successfully');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
releaseLock();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
case 'status': {
|
||||
const running = await isPortInUse(port);
|
||||
const pidInfo = readPidFile();
|
||||
if (running && pidInfo) {
|
||||
logger.info('SYSTEM', `Worker running (PID: ${pidInfo.pid}, Port: ${pidInfo.port})`);
|
||||
} else {
|
||||
logger.info('SYSTEM', 'Worker not running');
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
case '--daemon':
|
||||
default: {
|
||||
// Run server directly
|
||||
const worker = new WorkerService();
|
||||
|
||||
process.on('SIGTERM', async () => {
|
||||
logger.info('SYSTEM', 'Received SIGTERM');
|
||||
await worker.shutdown();
|
||||
removePidFile();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGINT', async () => {
|
||||
logger.info('SYSTEM', 'Received SIGINT');
|
||||
await worker.shutdown();
|
||||
removePidFile();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
worker.start().catch((error) => {
|
||||
logger.failure('SYSTEM', 'Worker failed to start', {}, error as Error);
|
||||
removePidFile();
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module || !module.parent) {
|
||||
main();
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ export interface ActiveSession {
|
||||
pendingProcessingIds: Set<number>; // Track ALL message IDs yielded but not yet processed
|
||||
earliestPendingTimestamp: number | null; // Original timestamp of earliest pending message (for accurate observation timestamps)
|
||||
conversationHistory: ConversationMessage[]; // Shared conversation history for provider switching
|
||||
currentProvider: 'claude' | 'gemini' | null; // Track which provider is currently running
|
||||
currentProvider: 'claude' | 'gemini' | 'openrouter' | null; // Track which provider is currently running
|
||||
}
|
||||
|
||||
export interface PendingMessage {
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
/**
|
||||
* Worker Wrapper - Manages worker process lifecycle
|
||||
*
|
||||
* This wrapper exists to solve the Windows zombie port problem.
|
||||
* The wrapper spawns the actual worker as a child process.
|
||||
* When shutdown is requested, the wrapper kills the child and exits.
|
||||
* The hooks will start a fresh wrapper+worker if needed.
|
||||
*
|
||||
* The wrapper itself has no sockets, so Bun's socket cleanup bug
|
||||
* doesn't affect it.
|
||||
*
|
||||
* NOTE: The wrapper does NOT auto-restart the worker on crash.
|
||||
* This is intentional - the hooks handle startup via ensureWorkerRunning().
|
||||
* Auto-restart would cause PID file mismatches and potential infinite loops.
|
||||
*/
|
||||
|
||||
import { spawn, ChildProcess, execSync } from 'child_process';
|
||||
import path from 'path';
|
||||
|
||||
const isWindows = process.platform === 'win32';
|
||||
|
||||
const SCRIPT_DIR = __dirname;
|
||||
const INNER_SCRIPT = path.join(SCRIPT_DIR, 'worker-service.cjs');
|
||||
|
||||
let inner: ChildProcess | null = null;
|
||||
let isShuttingDown = false;
|
||||
|
||||
function log(msg: string) {
|
||||
const timestamp = new Date().toISOString();
|
||||
console.log(`[${timestamp}] [wrapper] ${msg}`);
|
||||
}
|
||||
|
||||
function spawnInner() {
|
||||
log(`Spawning inner worker: ${INNER_SCRIPT}`);
|
||||
|
||||
inner = spawn(process.execPath, [INNER_SCRIPT], {
|
||||
stdio: ['inherit', 'inherit', 'inherit', 'ipc'],
|
||||
env: { ...process.env, CLAUDE_MEM_MANAGED: 'true' },
|
||||
cwd: path.dirname(INNER_SCRIPT),
|
||||
});
|
||||
|
||||
inner.on('message', async (msg: { type: string }) => {
|
||||
if (msg.type === 'restart' || msg.type === 'shutdown') {
|
||||
// Both restart and shutdown: kill inner and exit wrapper
|
||||
// The hooks will start a fresh wrapper+inner if needed
|
||||
log(`${msg.type} requested by inner`);
|
||||
isShuttingDown = true;
|
||||
await killInner();
|
||||
log('Exiting wrapper');
|
||||
process.exit(0);
|
||||
}
|
||||
});
|
||||
|
||||
inner.on('exit', (code, signal) => {
|
||||
log(`Inner exited with code=${code}, signal=${signal}`);
|
||||
inner = null;
|
||||
|
||||
// Don't auto-restart - let hooks handle it via ensureWorkerRunning()
|
||||
// Auto-restart causes PID file mismatches and potential infinite loops
|
||||
if (!isShuttingDown) {
|
||||
log('Inner exited unexpectedly, wrapper exiting (hooks will restart if needed)');
|
||||
process.exit(code ?? 1);
|
||||
}
|
||||
});
|
||||
|
||||
inner.on('error', (err) => {
|
||||
log(`Inner error: ${err.message}`);
|
||||
});
|
||||
}
|
||||
|
||||
async function killInner(): Promise<void> {
|
||||
if (!inner || !inner.pid) {
|
||||
log('No inner process to kill');
|
||||
return;
|
||||
}
|
||||
|
||||
const pid = inner.pid;
|
||||
log(`Killing inner process tree (pid=${pid})`);
|
||||
|
||||
if (isWindows) {
|
||||
// On Windows, use taskkill /T /F to kill entire process tree
|
||||
// This ensures all children (MCP server, ChromaSync, etc.) are killed
|
||||
// which is necessary to properly release the socket
|
||||
try {
|
||||
execSync(`taskkill /PID ${pid} /T /F`, { timeout: 10000, stdio: 'ignore' });
|
||||
log(`taskkill completed for pid=${pid}`);
|
||||
} catch (error) {
|
||||
// Process may already be dead
|
||||
log(`taskkill failed (process may be dead): ${error}`);
|
||||
}
|
||||
} else {
|
||||
// On Unix, SIGTERM then SIGKILL
|
||||
inner.kill('SIGTERM');
|
||||
|
||||
// Wait for exit with timeout
|
||||
const exitPromise = new Promise<void>(resolve => {
|
||||
if (!inner) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
inner.on('exit', () => resolve());
|
||||
});
|
||||
|
||||
const timeoutPromise = new Promise<void>(resolve =>
|
||||
setTimeout(() => resolve(), 5000)
|
||||
);
|
||||
|
||||
await Promise.race([exitPromise, timeoutPromise]);
|
||||
|
||||
// Force kill if still alive
|
||||
if (inner && !inner.killed) {
|
||||
log('Inner did not exit gracefully, force killing');
|
||||
inner.kill('SIGKILL');
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for the process to fully exit
|
||||
await waitForProcessExit(pid, 5000);
|
||||
|
||||
inner = null;
|
||||
log('Inner process terminated');
|
||||
}
|
||||
|
||||
async function waitForProcessExit(pid: number, timeoutMs: number): Promise<void> {
|
||||
const start = Date.now();
|
||||
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
process.kill(pid, 0); // Check if process exists
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
} catch {
|
||||
// Process is dead
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
log(`Timeout waiting for process ${pid} to exit`);
|
||||
}
|
||||
|
||||
// Handle wrapper signals
|
||||
process.on('SIGTERM', async () => {
|
||||
log('Wrapper received SIGTERM');
|
||||
isShuttingDown = true;
|
||||
await killInner();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGINT', async () => {
|
||||
log('Wrapper received SIGINT');
|
||||
isShuttingDown = true;
|
||||
await killInner();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Start the inner worker
|
||||
log('Wrapper starting');
|
||||
spawnInner();
|
||||
@@ -28,9 +28,9 @@ function isValidBranchName(branchName: string): boolean {
|
||||
return validBranchRegex.test(branchName) && !branchName.includes('..');
|
||||
}
|
||||
|
||||
// Timeout constants
|
||||
const GIT_COMMAND_TIMEOUT_MS = 30_000;
|
||||
const NPM_INSTALL_TIMEOUT_MS = 120_000;
|
||||
// Timeout constants (increased for slow systems)
|
||||
const GIT_COMMAND_TIMEOUT_MS = 300_000;
|
||||
const NPM_INSTALL_TIMEOUT_MS = 600_000;
|
||||
const DEFAULT_SHELL_TIMEOUT_MS = 60_000;
|
||||
|
||||
export interface BranchInfo {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import { ObservationSearchResult, SessionSummarySearchResult, UserPromptSearchResult } from '../sqlite/types.js';
|
||||
import { ModeManager } from '../domain/ModeManager.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
|
||||
// Token estimation constant (matches context-generator)
|
||||
const CHARS_PER_TOKEN_ESTIMATE = 4;
|
||||
|
||||
@@ -170,6 +170,11 @@ export class GeminiAgent {
|
||||
|
||||
// Process response (no original timestamp for init - not from queue)
|
||||
await this.processGeminiResponse(session, initResponse.content, worker, tokensUsed, null);
|
||||
} else {
|
||||
logger.warn('SDK', 'Empty Gemini init response - session may lack context', {
|
||||
sessionId: session.sessionDbId,
|
||||
model
|
||||
});
|
||||
}
|
||||
|
||||
// Process pending messages
|
||||
|
||||
@@ -0,0 +1,608 @@
|
||||
/**
|
||||
* OpenRouterAgent: OpenRouter-based observation extraction
|
||||
*
|
||||
* Alternative to SDKAgent that uses OpenRouter's unified API
|
||||
* for accessing 100+ models from different providers.
|
||||
*
|
||||
* Responsibility:
|
||||
* - Call OpenRouter REST API for observation extraction
|
||||
* - Parse XML responses (same format as Claude/Gemini)
|
||||
* - Sync to database and Chroma
|
||||
* - Support dynamic model selection across providers
|
||||
*/
|
||||
|
||||
import { DatabaseManager } from './DatabaseManager.js';
|
||||
import { SessionManager } from './SessionManager.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { parseObservations, parseSummary } from '../../sdk/parser.js';
|
||||
import { buildInitPrompt, buildObservationPrompt, buildSummaryPrompt, buildContinuationPrompt } from '../../sdk/prompts.js';
|
||||
import { SettingsDefaultsManager } from '../../shared/SettingsDefaultsManager.js';
|
||||
import { USER_SETTINGS_PATH } from '../../shared/paths.js';
|
||||
import type { ActiveSession, ConversationMessage } from '../worker-types.js';
|
||||
import { ModeManager } from '../domain/ModeManager.js';
|
||||
|
||||
// OpenRouter API endpoint
|
||||
const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions';
|
||||
|
||||
// Context window management constants (defaults, overridable via settings)
|
||||
const DEFAULT_MAX_CONTEXT_MESSAGES = 20; // Maximum messages to keep in conversation history
|
||||
const DEFAULT_MAX_ESTIMATED_TOKENS = 100000; // ~100k tokens max context (safety limit)
|
||||
const CHARS_PER_TOKEN_ESTIMATE = 4; // Conservative estimate: 1 token ≈ 4 chars
|
||||
|
||||
// OpenAI-compatible message format
|
||||
interface OpenAIMessage {
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface OpenRouterResponse {
|
||||
choices?: Array<{
|
||||
message?: {
|
||||
role?: string;
|
||||
content?: string;
|
||||
};
|
||||
finish_reason?: string;
|
||||
}>;
|
||||
usage?: {
|
||||
prompt_tokens?: number;
|
||||
completion_tokens?: number;
|
||||
total_tokens?: number;
|
||||
};
|
||||
error?: {
|
||||
message?: string;
|
||||
code?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Forward declaration for fallback agent type
|
||||
type FallbackAgent = {
|
||||
startSession(session: ActiveSession, worker?: any): Promise<void>;
|
||||
};
|
||||
|
||||
export class OpenRouterAgent {
|
||||
private dbManager: DatabaseManager;
|
||||
private sessionManager: SessionManager;
|
||||
private fallbackAgent: FallbackAgent | null = null;
|
||||
|
||||
constructor(dbManager: DatabaseManager, sessionManager: SessionManager) {
|
||||
this.dbManager = dbManager;
|
||||
this.sessionManager = sessionManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the fallback agent (Claude SDK) for when OpenRouter API fails
|
||||
* Must be set after construction to avoid circular dependency
|
||||
*/
|
||||
setFallbackAgent(agent: FallbackAgent): void {
|
||||
this.fallbackAgent = agent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error should trigger fallback to Claude
|
||||
*/
|
||||
private shouldFallbackToClaude(error: any): boolean {
|
||||
const message = error?.message || '';
|
||||
// Fall back on rate limit (429), server errors (5xx), or network issues
|
||||
return (
|
||||
message.includes('429') ||
|
||||
message.includes('500') ||
|
||||
message.includes('502') ||
|
||||
message.includes('503') ||
|
||||
message.includes('ECONNREFUSED') ||
|
||||
message.includes('ETIMEDOUT') ||
|
||||
message.includes('fetch failed')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start OpenRouter agent for a session
|
||||
* Uses multi-turn conversation to maintain context across messages
|
||||
*/
|
||||
async startSession(session: ActiveSession, worker?: any): Promise<void> {
|
||||
try {
|
||||
// Get OpenRouter configuration
|
||||
const { apiKey, model, siteUrl, appName } = this.getOpenRouterConfig();
|
||||
|
||||
if (!apiKey) {
|
||||
throw new Error('OpenRouter API key not configured. Set CLAUDE_MEM_OPENROUTER_API_KEY in settings or OPENROUTER_API_KEY environment variable.');
|
||||
}
|
||||
|
||||
// Load active mode
|
||||
const mode = ModeManager.getInstance().getActiveMode();
|
||||
|
||||
// Build initial prompt
|
||||
const initPrompt = session.lastPromptNumber === 1
|
||||
? buildInitPrompt(session.project, session.claudeSessionId, session.userPrompt, mode)
|
||||
: buildContinuationPrompt(session.userPrompt, session.lastPromptNumber, session.claudeSessionId, mode);
|
||||
|
||||
// Add to conversation history and query OpenRouter with full context
|
||||
session.conversationHistory.push({ role: 'user', content: initPrompt });
|
||||
const initResponse = await this.queryOpenRouterMultiTurn(session.conversationHistory, apiKey, model, siteUrl, appName);
|
||||
|
||||
if (initResponse.content) {
|
||||
// Add response to conversation history
|
||||
session.conversationHistory.push({ role: 'assistant', content: initResponse.content });
|
||||
|
||||
// Track token usage
|
||||
const tokensUsed = initResponse.tokensUsed || 0;
|
||||
session.cumulativeInputTokens += Math.floor(tokensUsed * 0.7); // Rough estimate
|
||||
session.cumulativeOutputTokens += Math.floor(tokensUsed * 0.3);
|
||||
|
||||
// Process response (no original timestamp for init - not from queue)
|
||||
await this.processOpenRouterResponse(session, initResponse.content, worker, tokensUsed, null);
|
||||
} else {
|
||||
logger.warn('SDK', 'Empty OpenRouter init response - session may lack context', {
|
||||
sessionId: session.sessionDbId,
|
||||
model
|
||||
});
|
||||
}
|
||||
|
||||
// Process pending messages
|
||||
for await (const message of this.sessionManager.getMessageIterator(session.sessionDbId)) {
|
||||
// Capture earliest timestamp BEFORE processing (will be cleared after)
|
||||
const originalTimestamp = session.earliestPendingTimestamp;
|
||||
|
||||
if (message.type === 'observation') {
|
||||
// Update last prompt number
|
||||
if (message.prompt_number !== undefined) {
|
||||
session.lastPromptNumber = message.prompt_number;
|
||||
}
|
||||
|
||||
// Build observation prompt
|
||||
const obsPrompt = buildObservationPrompt({
|
||||
id: 0,
|
||||
tool_name: message.tool_name!,
|
||||
tool_input: JSON.stringify(message.tool_input),
|
||||
tool_output: JSON.stringify(message.tool_response),
|
||||
created_at_epoch: originalTimestamp ?? Date.now(),
|
||||
cwd: message.cwd
|
||||
});
|
||||
|
||||
// Add to conversation history and query OpenRouter with full context
|
||||
session.conversationHistory.push({ role: 'user', content: obsPrompt });
|
||||
const obsResponse = await this.queryOpenRouterMultiTurn(session.conversationHistory, apiKey, model, siteUrl, appName);
|
||||
|
||||
if (obsResponse.content) {
|
||||
// Add response to conversation history
|
||||
session.conversationHistory.push({ role: 'assistant', content: obsResponse.content });
|
||||
|
||||
const tokensUsed = obsResponse.tokensUsed || 0;
|
||||
session.cumulativeInputTokens += Math.floor(tokensUsed * 0.7);
|
||||
session.cumulativeOutputTokens += Math.floor(tokensUsed * 0.3);
|
||||
await this.processOpenRouterResponse(session, obsResponse.content, worker, tokensUsed, originalTimestamp);
|
||||
} else {
|
||||
// Empty response - still mark messages as processed to avoid stuck state
|
||||
logger.warn('SDK', 'Empty OpenRouter response for observation, marking as processed', {
|
||||
sessionId: session.sessionDbId,
|
||||
toolName: message.tool_name
|
||||
});
|
||||
await this.markMessagesProcessed(session, worker);
|
||||
}
|
||||
|
||||
} else if (message.type === 'summarize') {
|
||||
// Build summary prompt
|
||||
const summaryPrompt = buildSummaryPrompt({
|
||||
id: session.sessionDbId,
|
||||
sdk_session_id: session.sdkSessionId,
|
||||
project: session.project,
|
||||
user_prompt: session.userPrompt,
|
||||
last_user_message: message.last_user_message || '',
|
||||
last_assistant_message: message.last_assistant_message || ''
|
||||
}, mode);
|
||||
|
||||
// Add to conversation history and query OpenRouter with full context
|
||||
session.conversationHistory.push({ role: 'user', content: summaryPrompt });
|
||||
const summaryResponse = await this.queryOpenRouterMultiTurn(session.conversationHistory, apiKey, model, siteUrl, appName);
|
||||
|
||||
if (summaryResponse.content) {
|
||||
// Add response to conversation history
|
||||
session.conversationHistory.push({ role: 'assistant', content: summaryResponse.content });
|
||||
|
||||
const tokensUsed = summaryResponse.tokensUsed || 0;
|
||||
session.cumulativeInputTokens += Math.floor(tokensUsed * 0.7);
|
||||
session.cumulativeOutputTokens += Math.floor(tokensUsed * 0.3);
|
||||
await this.processOpenRouterResponse(session, summaryResponse.content, worker, tokensUsed, originalTimestamp);
|
||||
} else {
|
||||
// Empty response - still mark messages as processed to avoid stuck state
|
||||
logger.warn('SDK', 'Empty OpenRouter response for summary, marking as processed', {
|
||||
sessionId: session.sessionDbId
|
||||
});
|
||||
await this.markMessagesProcessed(session, worker);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mark session complete
|
||||
const sessionDuration = Date.now() - session.startTime;
|
||||
logger.success('SDK', 'OpenRouter agent completed', {
|
||||
sessionId: session.sessionDbId,
|
||||
duration: `${(sessionDuration / 1000).toFixed(1)}s`,
|
||||
historyLength: session.conversationHistory.length,
|
||||
model
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
if (error.name === 'AbortError') {
|
||||
logger.warn('SDK', 'OpenRouter agent aborted', { sessionId: session.sessionDbId });
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Check if we should fall back to Claude
|
||||
if (this.shouldFallbackToClaude(error) && this.fallbackAgent) {
|
||||
logger.warn('SDK', 'OpenRouter API failed, falling back to Claude SDK', {
|
||||
sessionDbId: session.sessionDbId,
|
||||
error: error.message,
|
||||
historyLength: session.conversationHistory.length
|
||||
});
|
||||
|
||||
// Reset any 'processing' messages back to 'pending' so Claude can retry them
|
||||
const pendingStore = this.sessionManager.getPendingMessageStore();
|
||||
const resetCount = pendingStore.resetStuckMessages(0); // 0 = reset ALL processing messages
|
||||
if (resetCount > 0) {
|
||||
logger.info('SDK', 'Reset processing messages for fallback', {
|
||||
sessionDbId: session.sessionDbId,
|
||||
resetCount
|
||||
});
|
||||
}
|
||||
|
||||
// Fall back to Claude - it will use the same session with shared conversationHistory
|
||||
return this.fallbackAgent.startSession(session, worker);
|
||||
}
|
||||
|
||||
logger.failure('SDK', 'OpenRouter agent error', { sessionDbId: session.sessionDbId }, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate token count from text (conservative estimate)
|
||||
*/
|
||||
private estimateTokens(text: string): number {
|
||||
return Math.ceil(text.length / CHARS_PER_TOKEN_ESTIMATE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate conversation history to prevent runaway context costs
|
||||
* Keeps most recent messages within token budget
|
||||
*/
|
||||
private truncateHistory(history: ConversationMessage[]): ConversationMessage[] {
|
||||
const settings = SettingsDefaultsManager.loadFromFile(
|
||||
USER_SETTINGS_PATH
|
||||
);
|
||||
|
||||
const MAX_CONTEXT_MESSAGES = parseInt(settings.CLAUDE_MEM_OPENROUTER_MAX_CONTEXT_MESSAGES) || DEFAULT_MAX_CONTEXT_MESSAGES;
|
||||
const MAX_ESTIMATED_TOKENS = parseInt(settings.CLAUDE_MEM_OPENROUTER_MAX_TOKENS) || DEFAULT_MAX_ESTIMATED_TOKENS;
|
||||
|
||||
if (history.length <= MAX_CONTEXT_MESSAGES) {
|
||||
// Check token count even if message count is ok
|
||||
const totalTokens = history.reduce((sum, m) => sum + this.estimateTokens(m.content), 0);
|
||||
if (totalTokens <= MAX_ESTIMATED_TOKENS) {
|
||||
return history;
|
||||
}
|
||||
}
|
||||
|
||||
// Sliding window: keep most recent messages within limits
|
||||
const truncated: ConversationMessage[] = [];
|
||||
let tokenCount = 0;
|
||||
|
||||
// Process messages in reverse (most recent first)
|
||||
for (let i = history.length - 1; i >= 0; i--) {
|
||||
const msg = history[i];
|
||||
const msgTokens = this.estimateTokens(msg.content);
|
||||
|
||||
if (truncated.length >= MAX_CONTEXT_MESSAGES || tokenCount + msgTokens > MAX_ESTIMATED_TOKENS) {
|
||||
logger.warn('SDK', 'Context window truncated to prevent runaway costs', {
|
||||
originalMessages: history.length,
|
||||
keptMessages: truncated.length,
|
||||
droppedMessages: i + 1,
|
||||
estimatedTokens: tokenCount,
|
||||
tokenLimit: MAX_ESTIMATED_TOKENS
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
truncated.unshift(msg); // Add to beginning
|
||||
tokenCount += msgTokens;
|
||||
}
|
||||
|
||||
return truncated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert shared ConversationMessage array to OpenAI-compatible message format
|
||||
*/
|
||||
private conversationToOpenAIMessages(history: ConversationMessage[]): OpenAIMessage[] {
|
||||
return history.map(msg => ({
|
||||
role: msg.role === 'assistant' ? 'assistant' : 'user',
|
||||
content: msg.content
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Query OpenRouter via REST API with full conversation history (multi-turn)
|
||||
* Sends the entire conversation context for coherent responses
|
||||
*/
|
||||
private async queryOpenRouterMultiTurn(
|
||||
history: ConversationMessage[],
|
||||
apiKey: string,
|
||||
model: string,
|
||||
siteUrl?: string,
|
||||
appName?: string
|
||||
): Promise<{ content: string; tokensUsed?: number }> {
|
||||
// Truncate history to prevent runaway costs
|
||||
const truncatedHistory = this.truncateHistory(history);
|
||||
const messages = this.conversationToOpenAIMessages(truncatedHistory);
|
||||
const totalChars = truncatedHistory.reduce((sum, m) => sum + m.content.length, 0);
|
||||
const estimatedTokens = this.estimateTokens(truncatedHistory.map(m => m.content).join(''));
|
||||
|
||||
logger.debug('SDK', `Querying OpenRouter multi-turn (${model})`, {
|
||||
turns: truncatedHistory.length,
|
||||
totalChars,
|
||||
estimatedTokens
|
||||
});
|
||||
|
||||
const response = await fetch(OPENROUTER_API_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'HTTP-Referer': siteUrl || 'https://github.com/thedotmack/claude-mem',
|
||||
'X-Title': appName || 'claude-mem',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages,
|
||||
temperature: 0.3, // Lower temperature for structured extraction
|
||||
max_tokens: 4096,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`OpenRouter API error: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
const data = await response.json() as OpenRouterResponse;
|
||||
|
||||
// Check for API error in response body
|
||||
if (data.error) {
|
||||
throw new Error(`OpenRouter API error: ${data.error.code} - ${data.error.message}`);
|
||||
}
|
||||
|
||||
if (!data.choices?.[0]?.message?.content) {
|
||||
logger.warn('SDK', 'Empty response from OpenRouter');
|
||||
return { content: '' };
|
||||
}
|
||||
|
||||
const content = data.choices[0].message.content;
|
||||
const tokensUsed = data.usage?.total_tokens;
|
||||
|
||||
// Log actual token usage for cost tracking
|
||||
if (tokensUsed) {
|
||||
const inputTokens = data.usage?.prompt_tokens || 0;
|
||||
const outputTokens = data.usage?.completion_tokens || 0;
|
||||
// Token usage (cost varies by model - many OpenRouter models are free)
|
||||
const estimatedCost = (inputTokens / 1000000 * 3) + (outputTokens / 1000000 * 15);
|
||||
|
||||
logger.info('SDK', 'OpenRouter API usage', {
|
||||
model,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
totalTokens: tokensUsed,
|
||||
estimatedCostUSD: estimatedCost.toFixed(4),
|
||||
messagesInContext: truncatedHistory.length
|
||||
});
|
||||
|
||||
// Warn if costs are getting high
|
||||
if (tokensUsed > 50000) {
|
||||
logger.warn('SDK', 'High token usage detected - consider reducing context', {
|
||||
totalTokens: tokensUsed,
|
||||
estimatedCost: estimatedCost.toFixed(4)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { content, tokensUsed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Process OpenRouter response (same format as Claude/Gemini)
|
||||
* @param originalTimestamp - Original epoch when message was queued (for backlog processing accuracy)
|
||||
*/
|
||||
private async processOpenRouterResponse(
|
||||
session: ActiveSession,
|
||||
text: string,
|
||||
worker: any | undefined,
|
||||
discoveryTokens: number,
|
||||
originalTimestamp: number | null
|
||||
): Promise<void> {
|
||||
// Parse observations (same XML format)
|
||||
const observations = parseObservations(text, session.claudeSessionId);
|
||||
|
||||
// Store observations with original timestamp (if processing backlog) or current time
|
||||
for (const obs of observations) {
|
||||
const { id: obsId, createdAtEpoch } = this.dbManager.getSessionStore().storeObservation(
|
||||
session.claudeSessionId,
|
||||
session.project,
|
||||
obs,
|
||||
session.lastPromptNumber,
|
||||
discoveryTokens,
|
||||
originalTimestamp ?? undefined
|
||||
);
|
||||
|
||||
logger.info('SDK', 'OpenRouter observation saved', {
|
||||
sessionId: session.sessionDbId,
|
||||
obsId,
|
||||
type: obs.type,
|
||||
title: obs.title || '(untitled)'
|
||||
});
|
||||
|
||||
// Sync to Chroma
|
||||
this.dbManager.getChromaSync().syncObservation(
|
||||
obsId,
|
||||
session.claudeSessionId,
|
||||
session.project,
|
||||
obs,
|
||||
session.lastPromptNumber,
|
||||
createdAtEpoch,
|
||||
discoveryTokens
|
||||
).catch(err => {
|
||||
logger.warn('SDK', 'OpenRouter chroma sync failed', { obsId }, err);
|
||||
});
|
||||
|
||||
// Broadcast to SSE clients
|
||||
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: null,
|
||||
narrative: obs.narrative || null,
|
||||
facts: JSON.stringify(obs.facts || []),
|
||||
concepts: JSON.stringify(obs.concepts || []),
|
||||
files_read: JSON.stringify(obs.files_read || []),
|
||||
files_modified: JSON.stringify(obs.files_modified || []),
|
||||
project: session.project,
|
||||
prompt_number: session.lastPromptNumber,
|
||||
created_at_epoch: createdAtEpoch
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Parse summary
|
||||
const summary = parseSummary(text, session.sessionDbId);
|
||||
|
||||
if (summary) {
|
||||
// Convert nullable fields to empty strings for storeSummary
|
||||
const summaryForStore = {
|
||||
request: summary.request || '',
|
||||
investigated: summary.investigated || '',
|
||||
learned: summary.learned || '',
|
||||
completed: summary.completed || '',
|
||||
next_steps: summary.next_steps || '',
|
||||
notes: summary.notes
|
||||
};
|
||||
|
||||
const { id: summaryId, createdAtEpoch } = this.dbManager.getSessionStore().storeSummary(
|
||||
session.claudeSessionId,
|
||||
session.project,
|
||||
summaryForStore,
|
||||
session.lastPromptNumber,
|
||||
discoveryTokens,
|
||||
originalTimestamp ?? undefined
|
||||
);
|
||||
|
||||
logger.info('SDK', 'OpenRouter summary saved', {
|
||||
sessionId: session.sessionDbId,
|
||||
summaryId,
|
||||
request: summary.request || '(no request)'
|
||||
});
|
||||
|
||||
// Sync to Chroma
|
||||
this.dbManager.getChromaSync().syncSummary(
|
||||
summaryId,
|
||||
session.claudeSessionId,
|
||||
session.project,
|
||||
summaryForStore,
|
||||
session.lastPromptNumber,
|
||||
createdAtEpoch,
|
||||
discoveryTokens
|
||||
).catch(err => {
|
||||
logger.warn('SDK', 'OpenRouter chroma sync failed', { summaryId }, err);
|
||||
});
|
||||
|
||||
// Broadcast to SSE clients
|
||||
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
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Mark messages as processed
|
||||
await this.markMessagesProcessed(session, worker);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark pending messages as processed
|
||||
*/
|
||||
private async markMessagesProcessed(session: ActiveSession, worker: any | undefined): Promise<void> {
|
||||
const pendingMessageStore = this.sessionManager.getPendingMessageStore();
|
||||
if (session.pendingProcessingIds.size > 0) {
|
||||
for (const messageId of session.pendingProcessingIds) {
|
||||
pendingMessageStore.markProcessed(messageId);
|
||||
}
|
||||
logger.debug('SDK', 'OpenRouter messages marked as processed', {
|
||||
sessionId: session.sessionDbId,
|
||||
count: session.pendingProcessingIds.size
|
||||
});
|
||||
session.pendingProcessingIds.clear();
|
||||
|
||||
const deletedCount = pendingMessageStore.cleanupProcessed(100);
|
||||
if (deletedCount > 0) {
|
||||
logger.debug('SDK', 'OpenRouter cleaned up old processed messages', { deletedCount });
|
||||
}
|
||||
}
|
||||
|
||||
if (worker && typeof worker.broadcastProcessingStatus === 'function') {
|
||||
worker.broadcastProcessingStatus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get OpenRouter configuration from settings or environment
|
||||
*/
|
||||
private getOpenRouterConfig(): { apiKey: string; model: string; siteUrl?: string; appName?: string } {
|
||||
const settingsPath = USER_SETTINGS_PATH;
|
||||
const settings = SettingsDefaultsManager.loadFromFile(settingsPath);
|
||||
|
||||
// API key: check settings first, then environment variable
|
||||
const apiKey = settings.CLAUDE_MEM_OPENROUTER_API_KEY || process.env.OPENROUTER_API_KEY || '';
|
||||
|
||||
// Model: from settings or default
|
||||
const model = settings.CLAUDE_MEM_OPENROUTER_MODEL || 'xiaomi/mimo-v2-flash:free';
|
||||
|
||||
// Optional analytics headers
|
||||
const siteUrl = settings.CLAUDE_MEM_OPENROUTER_SITE_URL || '';
|
||||
const appName = settings.CLAUDE_MEM_OPENROUTER_APP_NAME || 'claude-mem';
|
||||
|
||||
return { apiKey, model, siteUrl, appName };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if OpenRouter is available (has API key configured)
|
||||
*/
|
||||
export function isOpenRouterAvailable(): boolean {
|
||||
const settingsPath = USER_SETTINGS_PATH;
|
||||
const settings = SettingsDefaultsManager.loadFromFile(settingsPath);
|
||||
return !!(settings.CLAUDE_MEM_OPENROUTER_API_KEY || process.env.OPENROUTER_API_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if OpenRouter is the selected provider
|
||||
*/
|
||||
export function isOpenRouterSelected(): boolean {
|
||||
const settingsPath = USER_SETTINGS_PATH;
|
||||
const settings = SettingsDefaultsManager.loadFromFile(settingsPath);
|
||||
return settings.CLAUDE_MEM_PROVIDER === 'openrouter';
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
*/
|
||||
|
||||
import { DatabaseManager } from './DatabaseManager.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import type { PaginatedResult, Observation, Summary, UserPrompt } from '../worker-types.js';
|
||||
|
||||
export class PaginationHelper {
|
||||
|
||||
@@ -64,11 +64,19 @@ export class SDKAgent {
|
||||
// Create message generator (event-driven)
|
||||
const messageGenerator = this.createMessageGenerator(session);
|
||||
|
||||
logger.info('SDK', 'Starting SDK query', {
|
||||
sessionDbId: session.sessionDbId,
|
||||
claudeSessionId: session.claudeSessionId,
|
||||
resume_parameter: session.claudeSessionId,
|
||||
lastPromptNumber: session.lastPromptNumber
|
||||
});
|
||||
|
||||
// Run Agent SDK query loop
|
||||
const queryResult = query({
|
||||
prompt: messageGenerator,
|
||||
options: {
|
||||
model: modelId,
|
||||
resume: session.claudeSessionId,
|
||||
disallowedTools,
|
||||
abortController: session.abortController,
|
||||
pathToClaudeCodeExecutable: claudePath
|
||||
@@ -196,7 +204,16 @@ export class SDKAgent {
|
||||
const mode = ModeManager.getInstance().getActiveMode();
|
||||
|
||||
// Build initial prompt
|
||||
const initPrompt = session.lastPromptNumber === 1
|
||||
const isInitPrompt = session.lastPromptNumber === 1;
|
||||
logger.info('SDK', 'Creating message generator', {
|
||||
sessionDbId: session.sessionDbId,
|
||||
claudeSessionId: session.claudeSessionId,
|
||||
lastPromptNumber: session.lastPromptNumber,
|
||||
isInitPrompt,
|
||||
promptType: isInitPrompt ? 'INIT' : 'CONTINUATION'
|
||||
});
|
||||
|
||||
const initPrompt = isInitPrompt
|
||||
? buildInitPrompt(session.project, session.claudeSessionId, session.userPrompt, mode)
|
||||
: buildContinuationPrompt(session.userPrompt, session.lastPromptNumber, session.claudeSessionId, mode);
|
||||
|
||||
|
||||
@@ -47,9 +47,21 @@ export class SessionManager {
|
||||
* Initialize a new session or return existing one
|
||||
*/
|
||||
initializeSession(sessionDbId: number, currentUserPrompt?: string, promptNumber?: number): ActiveSession {
|
||||
logger.info('SESSION', 'initializeSession called', {
|
||||
sessionDbId,
|
||||
promptNumber,
|
||||
has_currentUserPrompt: !!currentUserPrompt
|
||||
});
|
||||
|
||||
// Check if already active
|
||||
let session = this.sessions.get(sessionDbId);
|
||||
if (session) {
|
||||
logger.info('SESSION', 'Returning cached session', {
|
||||
sessionDbId,
|
||||
claudeSessionId: session.claudeSessionId,
|
||||
lastPromptNumber: session.lastPromptNumber
|
||||
});
|
||||
|
||||
// Refresh project from database in case it was updated by new-hook
|
||||
// This fixes the bug where sessions created with empty project get updated
|
||||
// in the database but the in-memory session still has the stale empty value
|
||||
@@ -86,6 +98,12 @@ export class SessionManager {
|
||||
// Fetch from database
|
||||
const dbSession = this.dbManager.getSessionById(sessionDbId);
|
||||
|
||||
logger.info('SESSION', 'Fetched session from database', {
|
||||
sessionDbId,
|
||||
claude_session_id: dbSession.claude_session_id,
|
||||
sdk_session_id: dbSession.sdk_session_id
|
||||
});
|
||||
|
||||
// Use currentUserPrompt if provided, otherwise fall back to database (first prompt)
|
||||
const userPrompt = currentUserPrompt || dbSession.user_prompt;
|
||||
|
||||
@@ -123,6 +141,12 @@ export class SessionManager {
|
||||
currentProvider: null // Will be set when generator starts
|
||||
};
|
||||
|
||||
logger.info('SESSION', 'Creating new session object', {
|
||||
sessionDbId,
|
||||
claudeSessionId: dbSession.claude_session_id,
|
||||
lastPromptNumber: promptNumber || this.dbManager.getSessionStore().getPromptNumberFromUserPrompts(dbSession.claude_session_id)
|
||||
});
|
||||
|
||||
this.sessions.set(sessionDbId, session);
|
||||
|
||||
// Create event emitter for queue notifications
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import { ObservationSearchResult, SessionSummarySearchResult, UserPromptSearchResult } from '../sqlite/types.js';
|
||||
import { ModeManager } from '../domain/ModeManager.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
|
||||
/**
|
||||
* Timeline item for unified chronological display
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import { SSEBroadcaster } from '../SSEBroadcaster.js';
|
||||
import type { WorkerService } from '../../worker-service.js';
|
||||
import { logger } from '../../../utils/logger.js';
|
||||
|
||||
export class SessionEventBroadcaster {
|
||||
constructor(
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import express, { Request, Response } from 'express';
|
||||
import path from 'path';
|
||||
import { readFileSync, statSync, existsSync } from 'fs';
|
||||
import { logger } from '../../../../utils/logger.js';
|
||||
import { homedir } from 'os';
|
||||
import { getPackageRoot } from '../../../../shared/paths.js';
|
||||
import { getWorkerPort } from '../../../../shared/worker-utils.js';
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import express, { Request, Response } from 'express';
|
||||
import { SearchManager } from '../../SearchManager.js';
|
||||
import { BaseRouteHandler } from '../BaseRouteHandler.js';
|
||||
import { logger } from '../../../../utils/logger.js';
|
||||
|
||||
export class SearchRoutes extends BaseRouteHandler {
|
||||
constructor(
|
||||
|
||||
@@ -13,6 +13,7 @@ import { SessionManager } from '../../SessionManager.js';
|
||||
import { DatabaseManager } from '../../DatabaseManager.js';
|
||||
import { SDKAgent } from '../../SDKAgent.js';
|
||||
import { GeminiAgent, isGeminiSelected, isGeminiAvailable } from '../../GeminiAgent.js';
|
||||
import { OpenRouterAgent, isOpenRouterSelected, isOpenRouterAvailable } from '../../OpenRouterAgent.js';
|
||||
import type { WorkerService } from '../../../worker-service.js';
|
||||
import { BaseRouteHandler } from '../BaseRouteHandler.js';
|
||||
import { SessionEventBroadcaster } from '../../events/SessionEventBroadcaster.js';
|
||||
@@ -29,6 +30,7 @@ export class SessionRoutes extends BaseRouteHandler {
|
||||
private dbManager: DatabaseManager,
|
||||
private sdkAgent: SDKAgent,
|
||||
private geminiAgent: GeminiAgent,
|
||||
private openRouterAgent: OpenRouterAgent,
|
||||
private eventBroadcaster: SessionEventBroadcaster,
|
||||
private workerService: WorkerService
|
||||
) {
|
||||
@@ -41,12 +43,20 @@ export class SessionRoutes extends BaseRouteHandler {
|
||||
|
||||
/**
|
||||
* Get the appropriate agent based on settings
|
||||
* Throws error if Gemini is selected but not configured (no silent fallback)
|
||||
* Throws error if provider is selected but not configured (no silent fallback)
|
||||
*
|
||||
* Note: Session linking via claudeSessionId allows provider switching mid-session.
|
||||
* The conversationHistory on ActiveSession maintains context across providers.
|
||||
*/
|
||||
private getActiveAgent(): SDKAgent | GeminiAgent {
|
||||
private getActiveAgent(): SDKAgent | GeminiAgent | OpenRouterAgent {
|
||||
if (isOpenRouterSelected()) {
|
||||
if (isOpenRouterAvailable()) {
|
||||
logger.debug('SESSION', 'Using OpenRouter agent');
|
||||
return this.openRouterAgent;
|
||||
} else {
|
||||
throw new Error('OpenRouter provider selected but no API key configured. Set CLAUDE_MEM_OPENROUTER_API_KEY in settings or OPENROUTER_API_KEY environment variable.');
|
||||
}
|
||||
}
|
||||
if (isGeminiSelected()) {
|
||||
if (isGeminiAvailable()) {
|
||||
logger.debug('SESSION', 'Using Gemini agent');
|
||||
@@ -61,7 +71,10 @@ export class SessionRoutes extends BaseRouteHandler {
|
||||
/**
|
||||
* Get the currently selected provider name
|
||||
*/
|
||||
private getSelectedProvider(): 'claude' | 'gemini' {
|
||||
private getSelectedProvider(): 'claude' | 'gemini' | 'openrouter' {
|
||||
if (isOpenRouterSelected() && isOpenRouterAvailable()) {
|
||||
return 'openrouter';
|
||||
}
|
||||
return (isGeminiSelected() && isGeminiAvailable()) ? 'gemini' : 'claude';
|
||||
}
|
||||
|
||||
@@ -104,13 +117,13 @@ export class SessionRoutes extends BaseRouteHandler {
|
||||
*/
|
||||
private startGeneratorWithProvider(
|
||||
session: ReturnType<typeof this.sessionManager.getSession>,
|
||||
provider: 'claude' | 'gemini',
|
||||
provider: 'claude' | 'gemini' | 'openrouter',
|
||||
source: string
|
||||
): void {
|
||||
if (!session) return;
|
||||
|
||||
const agent = provider === 'gemini' ? this.geminiAgent : this.sdkAgent;
|
||||
const agentName = provider === 'gemini' ? 'Gemini' : 'Claude SDK';
|
||||
const agent = provider === 'openrouter' ? this.openRouterAgent : (provider === 'gemini' ? this.geminiAgent : this.sdkAgent);
|
||||
const agentName = provider === 'openrouter' ? 'OpenRouter' : (provider === 'gemini' ? 'Gemini' : 'Claude SDK');
|
||||
|
||||
logger.info('SESSION', `Generator auto-starting (${source}) using ${agentName}`, {
|
||||
sessionId: session.sessionDbId,
|
||||
@@ -122,6 +135,13 @@ export class SessionRoutes extends BaseRouteHandler {
|
||||
session.currentProvider = provider;
|
||||
|
||||
session.generatorPromise = agent.startSession(session, this.workerService)
|
||||
.catch(error => {
|
||||
logger.error('SESSION', `Generator failed`, {
|
||||
sessionId: session.sessionDbId,
|
||||
provider: provider,
|
||||
error: error.message
|
||||
}, error);
|
||||
})
|
||||
.finally(() => {
|
||||
logger.info('SESSION', `Generator finished`, { sessionId: session.sessionDbId });
|
||||
session.generatorPromise = null;
|
||||
@@ -153,6 +173,12 @@ export class SessionRoutes extends BaseRouteHandler {
|
||||
if (sessionDbId === null) return;
|
||||
|
||||
const { userPrompt, promptNumber } = req.body;
|
||||
logger.info('HTTP', 'SessionRoutes: handleSessionInit called', {
|
||||
sessionDbId,
|
||||
promptNumber,
|
||||
has_userPrompt: !!userPrompt
|
||||
});
|
||||
|
||||
const session = this.sessionManager.initializeSession(sessionDbId, userPrompt, promptNumber);
|
||||
|
||||
// Get the latest user_prompt for this session to sync to Chroma
|
||||
@@ -462,6 +488,12 @@ export class SessionRoutes extends BaseRouteHandler {
|
||||
private handleSessionInitByClaudeId = this.wrapHandler((req: Request, res: Response): void => {
|
||||
const { claudeSessionId, project, prompt } = req.body;
|
||||
|
||||
logger.info('HTTP', 'SessionRoutes: handleSessionInitByClaudeId called', {
|
||||
claudeSessionId,
|
||||
project,
|
||||
prompt_length: prompt?.length
|
||||
});
|
||||
|
||||
// Validate required parameters
|
||||
if (!this.validateRequired(req, res, ['claudeSessionId', 'project', 'prompt'])) {
|
||||
return;
|
||||
@@ -472,10 +504,21 @@ export class SessionRoutes extends BaseRouteHandler {
|
||||
// Step 1: Create/get SDK session (idempotent INSERT OR IGNORE)
|
||||
const sessionDbId = store.createSDKSession(claudeSessionId, project, prompt);
|
||||
|
||||
logger.info('HTTP', 'SessionRoutes: createSDKSession returned', {
|
||||
sessionDbId,
|
||||
claudeSessionId
|
||||
});
|
||||
|
||||
// Step 2: Get next prompt number from user_prompts count
|
||||
const currentCount = store.getPromptNumberFromUserPrompts(claudeSessionId);
|
||||
const promptNumber = currentCount + 1;
|
||||
|
||||
logger.info('HTTP', 'SessionRoutes: Calculated promptNumber', {
|
||||
sessionDbId,
|
||||
promptNumber,
|
||||
currentCount
|
||||
});
|
||||
|
||||
// Step 3: Strip privacy tags from prompt
|
||||
const cleanedPrompt = stripMemoryTagsFromPrompt(prompt);
|
||||
|
||||
|
||||
@@ -71,7 +71,16 @@ export class SettingsRoutes extends BaseRouteHandler {
|
||||
|
||||
if (existsSync(settingsPath)) {
|
||||
const settingsData = readFileSync(settingsPath, 'utf-8');
|
||||
settings = JSON.parse(settingsData);
|
||||
try {
|
||||
settings = JSON.parse(settingsData);
|
||||
} catch (parseError) {
|
||||
logger.error('SETTINGS', 'Failed to parse settings file', { settingsPath }, parseError as Error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Settings file is corrupted. Delete ~/.claude-mem/settings.json to reset.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Update all settings from request body
|
||||
@@ -84,6 +93,14 @@ export class SettingsRoutes extends BaseRouteHandler {
|
||||
'CLAUDE_MEM_PROVIDER',
|
||||
'CLAUDE_MEM_GEMINI_API_KEY',
|
||||
'CLAUDE_MEM_GEMINI_MODEL',
|
||||
'CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED',
|
||||
// OpenRouter Configuration
|
||||
'CLAUDE_MEM_OPENROUTER_API_KEY',
|
||||
'CLAUDE_MEM_OPENROUTER_MODEL',
|
||||
'CLAUDE_MEM_OPENROUTER_SITE_URL',
|
||||
'CLAUDE_MEM_OPENROUTER_APP_NAME',
|
||||
'CLAUDE_MEM_OPENROUTER_MAX_CONTEXT_MESSAGES',
|
||||
'CLAUDE_MEM_OPENROUTER_MAX_TOKENS',
|
||||
// System Configuration
|
||||
'CLAUDE_MEM_DATA_DIR',
|
||||
'CLAUDE_MEM_LOG_LEVEL',
|
||||
@@ -216,9 +233,9 @@ export class SettingsRoutes extends BaseRouteHandler {
|
||||
private validateSettings(settings: any): { valid: boolean; error?: string } {
|
||||
// Validate CLAUDE_MEM_PROVIDER
|
||||
if (settings.CLAUDE_MEM_PROVIDER) {
|
||||
const validProviders = ['claude', 'gemini'];
|
||||
if (!validProviders.includes(settings.CLAUDE_MEM_PROVIDER)) {
|
||||
return { valid: false, error: 'CLAUDE_MEM_PROVIDER must be "claude" or "gemini"' };
|
||||
const validProviders = ['claude', 'gemini', 'openrouter'];
|
||||
if (!validProviders.includes(settings.CLAUDE_MEM_PROVIDER)) {
|
||||
return { valid: false, error: 'CLAUDE_MEM_PROVIDER must be "claude", "gemini", or "openrouter"' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,6 +328,31 @@ export class SettingsRoutes extends BaseRouteHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// Validate CLAUDE_MEM_OPENROUTER_MAX_CONTEXT_MESSAGES
|
||||
if (settings.CLAUDE_MEM_OPENROUTER_MAX_CONTEXT_MESSAGES) {
|
||||
const count = parseInt(settings.CLAUDE_MEM_OPENROUTER_MAX_CONTEXT_MESSAGES, 10);
|
||||
if (isNaN(count) || count < 1 || count > 100) {
|
||||
return { valid: false, error: 'CLAUDE_MEM_OPENROUTER_MAX_CONTEXT_MESSAGES must be between 1 and 100' };
|
||||
}
|
||||
}
|
||||
|
||||
// Validate CLAUDE_MEM_OPENROUTER_MAX_TOKENS
|
||||
if (settings.CLAUDE_MEM_OPENROUTER_MAX_TOKENS) {
|
||||
const tokens = parseInt(settings.CLAUDE_MEM_OPENROUTER_MAX_TOKENS, 10);
|
||||
if (isNaN(tokens) || tokens < 1000 || tokens > 1000000) {
|
||||
return { valid: false, error: 'CLAUDE_MEM_OPENROUTER_MAX_TOKENS must be between 1000 and 1000000' };
|
||||
}
|
||||
}
|
||||
|
||||
// Validate CLAUDE_MEM_OPENROUTER_SITE_URL if provided
|
||||
if (settings.CLAUDE_MEM_OPENROUTER_SITE_URL) {
|
||||
try {
|
||||
new URL(settings.CLAUDE_MEM_OPENROUTER_SITE_URL);
|
||||
} catch {
|
||||
return { valid: false, error: 'CLAUDE_MEM_OPENROUTER_SITE_URL must be a valid URL' };
|
||||
}
|
||||
}
|
||||
|
||||
// Skip observation types validation - any type string is valid since modes define their own types
|
||||
// The database accepts any TEXT value, and mode-specific validation happens at parse time
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import express, { Request, Response } from 'express';
|
||||
import path from 'path';
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { logger } from '../../../../utils/logger.js';
|
||||
import { getPackageRoot } from '../../../../shared/paths.js';
|
||||
import { SSEBroadcaster } from '../../SSEBroadcaster.js';
|
||||
import { DatabaseManager } from '../../DatabaseManager.js';
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
import { SessionManager } from '../SessionManager.js';
|
||||
import { SessionEventBroadcaster } from '../events/SessionEventBroadcaster.js';
|
||||
import { logger } from '../../../utils/logger.js';
|
||||
|
||||
export class SessionCompletionHandler {
|
||||
constructor(
|
||||
|
||||
@@ -18,10 +18,16 @@ export interface SettingsDefaults {
|
||||
CLAUDE_MEM_WORKER_HOST: string;
|
||||
CLAUDE_MEM_SKIP_TOOLS: string;
|
||||
// AI Provider Configuration
|
||||
CLAUDE_MEM_PROVIDER: string; // 'claude' | 'gemini'
|
||||
CLAUDE_MEM_PROVIDER: string; // 'claude' | 'gemini' | 'openrouter'
|
||||
CLAUDE_MEM_GEMINI_API_KEY: string;
|
||||
CLAUDE_MEM_GEMINI_MODEL: string; // 'gemini-2.5-flash-lite' | 'gemini-2.5-flash' | 'gemini-3-flash'
|
||||
CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED: string; // 'true' | 'false' - enable rate limiting for free tier
|
||||
CLAUDE_MEM_OPENROUTER_API_KEY: string;
|
||||
CLAUDE_MEM_OPENROUTER_MODEL: string;
|
||||
CLAUDE_MEM_OPENROUTER_SITE_URL: string;
|
||||
CLAUDE_MEM_OPENROUTER_APP_NAME: string;
|
||||
CLAUDE_MEM_OPENROUTER_MAX_CONTEXT_MESSAGES: string;
|
||||
CLAUDE_MEM_OPENROUTER_MAX_TOKENS: string;
|
||||
// System Configuration
|
||||
CLAUDE_MEM_DATA_DIR: string;
|
||||
CLAUDE_MEM_LOG_LEVEL: string;
|
||||
@@ -60,6 +66,12 @@ export class SettingsDefaultsManager {
|
||||
CLAUDE_MEM_GEMINI_API_KEY: '', // Empty by default, can be set via UI or env
|
||||
CLAUDE_MEM_GEMINI_MODEL: 'gemini-2.5-flash-lite', // Default Gemini model (highest free tier RPM)
|
||||
CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED: 'true', // Rate limiting ON by default for free tier users
|
||||
CLAUDE_MEM_OPENROUTER_API_KEY: '', // Empty by default, can be set via UI or env
|
||||
CLAUDE_MEM_OPENROUTER_MODEL: 'xiaomi/mimo-v2-flash:free', // Default OpenRouter model (free tier)
|
||||
CLAUDE_MEM_OPENROUTER_SITE_URL: '', // Optional: for OpenRouter analytics
|
||||
CLAUDE_MEM_OPENROUTER_APP_NAME: 'claude-mem', // App name for OpenRouter analytics
|
||||
CLAUDE_MEM_OPENROUTER_MAX_CONTEXT_MESSAGES: '20', // Max messages in context window
|
||||
CLAUDE_MEM_OPENROUTER_MAX_TOKENS: '100000', // Max estimated tokens (~100k safety limit)
|
||||
// System Configuration
|
||||
CLAUDE_MEM_DATA_DIR: join(homedir(), '.claude-mem'),
|
||||
CLAUDE_MEM_LOG_LEVEL: 'INFO',
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
export const HOOK_TIMEOUTS = {
|
||||
DEFAULT: 120000, // Standard HTTP timeout (up from 2000ms)
|
||||
HEALTH_CHECK: 1000, // Worker health check (up from 500ms)
|
||||
DEFAULT: 300000, // Standard HTTP timeout (5 min for slow systems)
|
||||
HEALTH_CHECK: 30000, // Worker health check (30s for slow systems)
|
||||
WORKER_STARTUP_WAIT: 1000,
|
||||
WORKER_STARTUP_RETRIES: 15,
|
||||
WORKER_STARTUP_RETRIES: 300,
|
||||
PRE_RESTART_SETTLE_DELAY: 2000, // Give files time to sync before restart
|
||||
WINDOWS_MULTIPLIER: 1.5 // Platform-specific adjustment
|
||||
} as const;
|
||||
|
||||
@@ -1817,6 +1817,49 @@
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid var(--modal-border);
|
||||
background: var(--modal-header-bg);
|
||||
}
|
||||
|
||||
.modal-footer .save-status {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.modal-footer .save-status .success {
|
||||
color: var(--success-color, #22c55e);
|
||||
}
|
||||
|
||||
.modal-footer .save-status .error {
|
||||
color: var(--error-color, #ef4444);
|
||||
}
|
||||
|
||||
.modal-footer .save-btn {
|
||||
padding: 8px 24px;
|
||||
background: var(--accent-color, #3b82f6);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.modal-footer .save-btn:hover:not(:disabled) {
|
||||
background: var(--accent-hover, #2563eb);
|
||||
}
|
||||
|
||||
.modal-footer .save-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Preview Column - Terminal Style */
|
||||
.preview-column {
|
||||
padding: 20px;
|
||||
|
||||
@@ -12,15 +12,6 @@ interface ContextSettingsModalProps {
|
||||
saveStatus: string;
|
||||
}
|
||||
|
||||
// Simple debounce helper
|
||||
function debounce<T extends (...args: any[]) => any>(fn: T, ms: number): T {
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
return ((...args: any[]) => {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(() => fn(...args), ms);
|
||||
}) as T;
|
||||
}
|
||||
|
||||
// Collapsible section component
|
||||
function CollapsibleSection({
|
||||
title,
|
||||
@@ -195,14 +186,6 @@ export function ContextSettingsModal({
|
||||
}: ContextSettingsModalProps) {
|
||||
const [formState, setFormState] = useState<Settings>(settings);
|
||||
|
||||
// Create debounced save function
|
||||
const debouncedSave = useCallback(
|
||||
debounce((newSettings: Settings) => {
|
||||
onSave(newSettings);
|
||||
}, 300),
|
||||
[onSave]
|
||||
);
|
||||
|
||||
// Update form state when settings prop changes
|
||||
useEffect(() => {
|
||||
setFormState(settings);
|
||||
@@ -214,8 +197,11 @@ export function ContextSettingsModal({
|
||||
const updateSetting = useCallback((key: keyof Settings, value: string) => {
|
||||
const newState = { ...formState, [key]: value };
|
||||
setFormState(newState);
|
||||
debouncedSave(newState);
|
||||
}, [formState, debouncedSave]);
|
||||
}, [formState]);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
onSave(formState);
|
||||
}, [formState, onSave]);
|
||||
|
||||
const toggleBoolean = useCallback((key: keyof Settings) => {
|
||||
const currentValue = formState[key];
|
||||
@@ -436,10 +422,11 @@ export function ContextSettingsModal({
|
||||
>
|
||||
<option value="claude">Claude (uses your Claude account)</option>
|
||||
<option value="gemini">Gemini (uses API key)</option>
|
||||
<option value="openrouter">OpenRouter (multi-model)</option>
|
||||
</select>
|
||||
</FormField>
|
||||
|
||||
{formState.CLAUDE_MEM_PROVIDER === 'claude' ? (
|
||||
{formState.CLAUDE_MEM_PROVIDER === 'claude' && (
|
||||
<FormField
|
||||
label="Claude Model"
|
||||
tooltip="Claude model used for generating observations"
|
||||
@@ -453,7 +440,9 @@ export function ContextSettingsModal({
|
||||
<option value="opus">opus (highest quality)</option>
|
||||
</select>
|
||||
</FormField>
|
||||
) : (
|
||||
)}
|
||||
|
||||
{formState.CLAUDE_MEM_PROVIDER === 'gemini' && (
|
||||
<>
|
||||
<FormField
|
||||
label="Gemini API Key"
|
||||
@@ -491,6 +480,55 @@ export function ContextSettingsModal({
|
||||
</>
|
||||
)}
|
||||
|
||||
{formState.CLAUDE_MEM_PROVIDER === 'openrouter' && (
|
||||
<>
|
||||
<FormField
|
||||
label="OpenRouter API Key"
|
||||
tooltip="Your OpenRouter API key from openrouter.ai (or set OPENROUTER_API_KEY env var)"
|
||||
>
|
||||
<input
|
||||
type="password"
|
||||
value={formState.CLAUDE_MEM_OPENROUTER_API_KEY || ''}
|
||||
onChange={(e) => updateSetting('CLAUDE_MEM_OPENROUTER_API_KEY', e.target.value)}
|
||||
placeholder="Enter OpenRouter API key..."
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label="OpenRouter Model"
|
||||
tooltip="Model identifier from OpenRouter (e.g., anthropic/claude-3.5-sonnet, google/gemini-2.0-flash-thinking-exp)"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={formState.CLAUDE_MEM_OPENROUTER_MODEL || 'xiaomi/mimo-v2-flash:free'}
|
||||
onChange={(e) => updateSetting('CLAUDE_MEM_OPENROUTER_MODEL', e.target.value)}
|
||||
placeholder="e.g., xiaomi/mimo-v2-flash:free"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label="Site URL (Optional)"
|
||||
tooltip="Your site URL for OpenRouter analytics (optional)"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={formState.CLAUDE_MEM_OPENROUTER_SITE_URL || ''}
|
||||
onChange={(e) => updateSetting('CLAUDE_MEM_OPENROUTER_SITE_URL', e.target.value)}
|
||||
placeholder="https://yoursite.com"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label="App Name (Optional)"
|
||||
tooltip="Your app name for OpenRouter analytics (optional)"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={formState.CLAUDE_MEM_OPENROUTER_APP_NAME || 'claude-mem'}
|
||||
onChange={(e) => updateSetting('CLAUDE_MEM_OPENROUTER_APP_NAME', e.target.value)}
|
||||
placeholder="claude-mem"
|
||||
/>
|
||||
</FormField>
|
||||
</>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
label="Worker Port"
|
||||
tooltip="Port for the background worker service"
|
||||
@@ -523,6 +561,20 @@ export function ContextSettingsModal({
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer with Save button */}
|
||||
<div className="modal-footer">
|
||||
<div className="save-status">
|
||||
{saveStatus && <span className={saveStatus.includes('✓') ? 'success' : saveStatus.includes('✗') ? 'error' : ''}>{saveStatus}</span>}
|
||||
</div>
|
||||
<button
|
||||
className="save-btn"
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -12,6 +12,10 @@ export const DEFAULT_SETTINGS = {
|
||||
CLAUDE_MEM_PROVIDER: 'claude',
|
||||
CLAUDE_MEM_GEMINI_API_KEY: '',
|
||||
CLAUDE_MEM_GEMINI_MODEL: 'gemini-2.5-flash-lite',
|
||||
CLAUDE_MEM_OPENROUTER_API_KEY: '',
|
||||
CLAUDE_MEM_OPENROUTER_MODEL: 'xiaomi/mimo-v2-flash:free',
|
||||
CLAUDE_MEM_OPENROUTER_SITE_URL: '',
|
||||
CLAUDE_MEM_OPENROUTER_APP_NAME: 'claude-mem',
|
||||
CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED: 'true',
|
||||
|
||||
// Token Economics (all true for backwards compatibility)
|
||||
|
||||
@@ -24,6 +24,13 @@ export function useSettings() {
|
||||
CLAUDE_MEM_PROVIDER: data.CLAUDE_MEM_PROVIDER || DEFAULT_SETTINGS.CLAUDE_MEM_PROVIDER,
|
||||
CLAUDE_MEM_GEMINI_API_KEY: data.CLAUDE_MEM_GEMINI_API_KEY || DEFAULT_SETTINGS.CLAUDE_MEM_GEMINI_API_KEY,
|
||||
CLAUDE_MEM_GEMINI_MODEL: data.CLAUDE_MEM_GEMINI_MODEL || DEFAULT_SETTINGS.CLAUDE_MEM_GEMINI_MODEL,
|
||||
CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED: data.CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED || DEFAULT_SETTINGS.CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED,
|
||||
|
||||
// OpenRouter Configuration
|
||||
CLAUDE_MEM_OPENROUTER_API_KEY: data.CLAUDE_MEM_OPENROUTER_API_KEY || DEFAULT_SETTINGS.CLAUDE_MEM_OPENROUTER_API_KEY,
|
||||
CLAUDE_MEM_OPENROUTER_MODEL: data.CLAUDE_MEM_OPENROUTER_MODEL || DEFAULT_SETTINGS.CLAUDE_MEM_OPENROUTER_MODEL,
|
||||
CLAUDE_MEM_OPENROUTER_SITE_URL: data.CLAUDE_MEM_OPENROUTER_SITE_URL || DEFAULT_SETTINGS.CLAUDE_MEM_OPENROUTER_SITE_URL,
|
||||
CLAUDE_MEM_OPENROUTER_APP_NAME: data.CLAUDE_MEM_OPENROUTER_APP_NAME || DEFAULT_SETTINGS.CLAUDE_MEM_OPENROUTER_APP_NAME,
|
||||
|
||||
// Token Economics Display
|
||||
CLAUDE_MEM_CONTEXT_SHOW_READ_TOKENS: data.CLAUDE_MEM_CONTEXT_SHOW_READ_TOKENS || DEFAULT_SETTINGS.CLAUDE_MEM_CONTEXT_SHOW_READ_TOKENS,
|
||||
|
||||
@@ -61,10 +61,14 @@ export interface Settings {
|
||||
CLAUDE_MEM_WORKER_HOST: string;
|
||||
|
||||
// AI Provider Configuration
|
||||
CLAUDE_MEM_PROVIDER?: string; // 'claude' | 'gemini'
|
||||
CLAUDE_MEM_PROVIDER?: string; // 'claude' | 'gemini' | 'openrouter'
|
||||
CLAUDE_MEM_GEMINI_API_KEY?: string;
|
||||
CLAUDE_MEM_GEMINI_MODEL?: string; // 'gemini-2.5-flash-lite' | 'gemini-2.5-flash' | 'gemini-3-flash'
|
||||
CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED?: string; // 'true' | 'false'
|
||||
CLAUDE_MEM_OPENROUTER_API_KEY?: string;
|
||||
CLAUDE_MEM_OPENROUTER_MODEL?: string;
|
||||
CLAUDE_MEM_OPENROUTER_SITE_URL?: string;
|
||||
CLAUDE_MEM_OPENROUTER_APP_NAME?: string;
|
||||
|
||||
// Token Economics Display
|
||||
CLAUDE_MEM_CONTEXT_SHOW_READ_TOKENS?: string;
|
||||
|
||||
@@ -31,7 +31,7 @@ export function getWorkerRestartInstructions(
|
||||
let message = `${prefix}${portInfo}\n\n`;
|
||||
message += `To restart the worker:\n`;
|
||||
message += `1. Exit Claude Code completely\n`;
|
||||
message += `2. Run: claude-mem restart\n`;
|
||||
message += `2. Run: npm run worker:restart\n`;
|
||||
message += `3. Restart Claude Code`;
|
||||
|
||||
if (includeSkillFallback) {
|
||||
|
||||
+51
-7
@@ -4,6 +4,8 @@
|
||||
*/
|
||||
|
||||
import { SettingsDefaultsManager } from '../shared/SettingsDefaultsManager.js';
|
||||
import { appendFileSync, existsSync, mkdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
export enum LogLevel {
|
||||
DEBUG = 0,
|
||||
@@ -25,19 +27,55 @@ interface LogContext {
|
||||
class Logger {
|
||||
private level: LogLevel | null = null;
|
||||
private useColor: boolean;
|
||||
private logFilePath: string | null = null;
|
||||
|
||||
constructor() {
|
||||
// Disable colors when output is not a TTY (e.g., PM2 logs)
|
||||
this.useColor = process.stdout.isTTY ?? false;
|
||||
this.initializeLogFile();
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazy-load log level from settings (breaks circular dependency with SettingsDefaultsManager)
|
||||
* Initialize log file path and ensure directory exists
|
||||
*/
|
||||
private initializeLogFile(): void {
|
||||
try {
|
||||
// Get data directory from settings
|
||||
const dataDir = SettingsDefaultsManager.get('CLAUDE_MEM_DATA_DIR');
|
||||
const logsDir = join(dataDir, 'logs');
|
||||
|
||||
// Ensure logs directory exists
|
||||
if (!existsSync(logsDir)) {
|
||||
mkdirSync(logsDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Create log file path with date
|
||||
const date = new Date().toISOString().split('T')[0];
|
||||
this.logFilePath = join(logsDir, `claude-mem-${date}.log`);
|
||||
} catch (error) {
|
||||
// If log file initialization fails, just log to console
|
||||
console.error('[LOGGER] Failed to initialize log file:', error);
|
||||
this.logFilePath = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazy-load log level from settings file (not hardcoded defaults!)
|
||||
*/
|
||||
private getLevel(): LogLevel {
|
||||
if (this.level === null) {
|
||||
const envLevel = SettingsDefaultsManager.get('CLAUDE_MEM_LOG_LEVEL').toUpperCase();
|
||||
this.level = LogLevel[envLevel as keyof typeof LogLevel] ?? LogLevel.INFO;
|
||||
try {
|
||||
// Load settings from file to get user's actual log level
|
||||
const dataDir = SettingsDefaultsManager.get('CLAUDE_MEM_DATA_DIR');
|
||||
const settingsPath = join(dataDir, 'settings.json');
|
||||
const settings = SettingsDefaultsManager.loadFromFile(settingsPath);
|
||||
const envLevel = settings.CLAUDE_MEM_LOG_LEVEL.toUpperCase();
|
||||
this.level = LogLevel[envLevel as keyof typeof LogLevel] ?? LogLevel.INFO;
|
||||
} catch (error) {
|
||||
// Fallback to INFO if settings can't be loaded
|
||||
console.error('[LOGGER] Failed to load settings, using INFO level:', error);
|
||||
this.level = LogLevel.INFO;
|
||||
}
|
||||
}
|
||||
return this.level;
|
||||
}
|
||||
@@ -219,11 +257,17 @@ class Logger {
|
||||
|
||||
const logLine = `[${timestamp}] [${levelStr}] [${componentStr}] ${correlationStr}${message}${contextStr}${dataStr}`;
|
||||
|
||||
// Output to appropriate stream
|
||||
if (level === LogLevel.ERROR) {
|
||||
console.error(logLine);
|
||||
// Output to log file ONLY (worker runs in background, console is useless)
|
||||
if (this.logFilePath) {
|
||||
try {
|
||||
appendFileSync(this.logFilePath, logLine + '\n', 'utf8');
|
||||
} catch (error) {
|
||||
// If file write fails, write to stderr as last resort
|
||||
process.stderr.write(`[LOGGER] Failed to write to log file: ${error}\n`);
|
||||
}
|
||||
} else {
|
||||
console.log(logLine);
|
||||
// If no log file available, write to stderr as fallback
|
||||
process.stderr.write(logLine + '\n');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { HOOK_TIMEOUTS, HOOK_EXIT_CODES, getTimeout } from '../src/shared/hook-constants.js';
|
||||
|
||||
describe('hook-constants', () => {
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original platform after each test
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: originalPlatform,
|
||||
writable: true,
|
||||
configurable: true
|
||||
});
|
||||
});
|
||||
|
||||
describe('HOOK_TIMEOUTS', () => {
|
||||
it('should define DEFAULT timeout', () => {
|
||||
expect(HOOK_TIMEOUTS.DEFAULT).toBe(300000);
|
||||
});
|
||||
|
||||
it('should define HEALTH_CHECK timeout', () => {
|
||||
expect(HOOK_TIMEOUTS.HEALTH_CHECK).toBe(30000);
|
||||
});
|
||||
|
||||
it('should define WORKER_STARTUP_WAIT', () => {
|
||||
expect(HOOK_TIMEOUTS.WORKER_STARTUP_WAIT).toBe(1000);
|
||||
});
|
||||
|
||||
it('should define WORKER_STARTUP_RETRIES', () => {
|
||||
expect(HOOK_TIMEOUTS.WORKER_STARTUP_RETRIES).toBe(300);
|
||||
});
|
||||
|
||||
it('should define PRE_RESTART_SETTLE_DELAY', () => {
|
||||
expect(HOOK_TIMEOUTS.PRE_RESTART_SETTLE_DELAY).toBe(2000);
|
||||
});
|
||||
|
||||
it('should define WINDOWS_MULTIPLIER', () => {
|
||||
expect(HOOK_TIMEOUTS.WINDOWS_MULTIPLIER).toBe(1.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('HOOK_EXIT_CODES', () => {
|
||||
it('should define SUCCESS exit code', () => {
|
||||
expect(HOOK_EXIT_CODES.SUCCESS).toBe(0);
|
||||
});
|
||||
|
||||
it('should define FAILURE exit code', () => {
|
||||
expect(HOOK_EXIT_CODES.FAILURE).toBe(1);
|
||||
});
|
||||
|
||||
it('should define USER_MESSAGE_ONLY exit code', () => {
|
||||
expect(HOOK_EXIT_CODES.USER_MESSAGE_ONLY).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTimeout', () => {
|
||||
it('should return base timeout on non-Windows platforms', () => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'darwin',
|
||||
writable: true,
|
||||
configurable: true
|
||||
});
|
||||
|
||||
expect(getTimeout(1000)).toBe(1000);
|
||||
expect(getTimeout(5000)).toBe(5000);
|
||||
});
|
||||
|
||||
it('should apply Windows multiplier on Windows platform', () => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'win32',
|
||||
writable: true,
|
||||
configurable: true
|
||||
});
|
||||
|
||||
expect(getTimeout(1000)).toBe(1500);
|
||||
expect(getTimeout(2000)).toBe(3000);
|
||||
});
|
||||
|
||||
it('should round Windows timeout to nearest integer', () => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'win32',
|
||||
writable: true,
|
||||
configurable: true
|
||||
});
|
||||
|
||||
// 333 * 1.5 = 499.5, should round to 500
|
||||
expect(getTimeout(333)).toBe(500);
|
||||
});
|
||||
|
||||
it('should return base timeout on Linux', () => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'linux',
|
||||
writable: true,
|
||||
configurable: true
|
||||
});
|
||||
|
||||
expect(getTimeout(1000)).toBe(1000);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import { readdir } from "fs/promises";
|
||||
import { join, relative } from "path";
|
||||
import { readFileSync } from "fs";
|
||||
|
||||
/**
|
||||
* Test suite to ensure consistent logger usage across the codebase.
|
||||
*
|
||||
* This test enforces logging standards by:
|
||||
* 1. Identifying files that should use logging
|
||||
* 2. Detecting console.log/console.error usage that should be replaced with logger
|
||||
* 3. Verifying logger import patterns
|
||||
* 4. Reporting coverage statistics
|
||||
*/
|
||||
|
||||
const PROJECT_ROOT = join(import.meta.dir, "..");
|
||||
const SRC_DIR = join(PROJECT_ROOT, "src");
|
||||
|
||||
// Files/directories that don't require logging
|
||||
const EXCLUDED_PATTERNS = [
|
||||
/types\//, // Type definition files
|
||||
/constants\//, // Pure constants
|
||||
/\.d\.ts$/, // Type declaration files
|
||||
/^ui\//, // UI components (separate logging context)
|
||||
/^bin\//, // CLI utilities (may use console.log for output)
|
||||
/index\.ts$/, // Re-export files
|
||||
/logger\.ts$/, // Logger itself
|
||||
/hook-response\.ts$/, // Pure data structure
|
||||
/hook-constants\.ts$/, // Pure constants
|
||||
/paths\.ts$/, // Path utilities
|
||||
/bun-path\.ts$/, // Path utilities
|
||||
/migrations\.ts$/, // Database migrations (console.log for migration output)
|
||||
];
|
||||
|
||||
// Files that should always use logger (core business logic)
|
||||
// Excludes UI files, type files, and pure utilities
|
||||
const HIGH_PRIORITY_PATTERNS = [
|
||||
/^services\/worker\/(?!.*types\.ts$)/, // Worker services (not type files)
|
||||
/^services\/sqlite\/(?!types\.ts$|index\.ts$)/, // SQLite services
|
||||
/^services\/sync\//,
|
||||
/^services\/context-generator\.ts$/,
|
||||
/^hooks\/(?!hook-response\.ts$)/, // All src/hooks/* except hook-response.ts (NOT ui/hooks)
|
||||
/^sdk\/(?!.*types?\.ts$)/, // SDK files (not type files)
|
||||
/^servers\/(?!.*types?\.ts$)/, // Server files (not type files)
|
||||
];
|
||||
|
||||
// Additional check: exclude UI files from high priority
|
||||
const isUIFile = (path: string) => /^ui\//.test(path);
|
||||
|
||||
interface FileAnalysis {
|
||||
path: string;
|
||||
relativePath: string;
|
||||
hasLoggerImport: boolean;
|
||||
usesConsoleLog: boolean;
|
||||
consoleLogLines: number[];
|
||||
loggerCallCount: number;
|
||||
isHighPriority: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively find all TypeScript files in a directory
|
||||
*/
|
||||
async function findTypeScriptFiles(dir: string): Promise<string[]> {
|
||||
const files: string[] = [];
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...(await findTypeScriptFiles(fullPath)));
|
||||
} else if (entry.isFile() && /\.ts$/.test(entry.name)) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file should be excluded from logger requirements
|
||||
*/
|
||||
function shouldExclude(filePath: string): boolean {
|
||||
const relativePath = relative(SRC_DIR, filePath);
|
||||
return EXCLUDED_PATTERNS.some(pattern => pattern.test(relativePath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file is high priority for logging
|
||||
*/
|
||||
function isHighPriority(filePath: string): boolean {
|
||||
const relativePath = relative(SRC_DIR, filePath);
|
||||
|
||||
// UI files are never high priority
|
||||
if (isUIFile(relativePath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return HIGH_PRIORITY_PATTERNS.some(pattern => pattern.test(relativePath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze a single TypeScript file for logger usage
|
||||
*/
|
||||
function analyzeFile(filePath: string): FileAnalysis {
|
||||
const content = readFileSync(filePath, "utf-8");
|
||||
const lines = content.split("\n");
|
||||
const relativePath = relative(PROJECT_ROOT, filePath);
|
||||
|
||||
// Check for logger import (handles both .ts and .js extensions in import paths)
|
||||
const hasLoggerImport = /import\s+.*logger.*from\s+['"].*logger(\.(js|ts))?['"]/.test(content);
|
||||
|
||||
// Find console.log/console.error usage with line numbers
|
||||
const consoleLogLines: number[] = [];
|
||||
lines.forEach((line, index) => {
|
||||
if (/console\.(log|error|warn|info|debug)/.test(line)) {
|
||||
consoleLogLines.push(index + 1);
|
||||
}
|
||||
});
|
||||
|
||||
// Count logger method calls
|
||||
const loggerCallMatches = content.match(/logger\.(debug|info|warn|error|success|failure|timing|dataIn|dataOut|happyPathError)\(/g);
|
||||
const loggerCallCount = loggerCallMatches ? loggerCallMatches.length : 0;
|
||||
|
||||
return {
|
||||
path: filePath,
|
||||
relativePath,
|
||||
hasLoggerImport,
|
||||
usesConsoleLog: consoleLogLines.length > 0,
|
||||
consoleLogLines,
|
||||
loggerCallCount,
|
||||
isHighPriority: isHighPriority(filePath),
|
||||
};
|
||||
}
|
||||
|
||||
describe("Logger Coverage", () => {
|
||||
let allFiles: FileAnalysis[] = [];
|
||||
let relevantFiles: FileAnalysis[] = [];
|
||||
|
||||
it("should scan all TypeScript files in src/", async () => {
|
||||
const files = await findTypeScriptFiles(SRC_DIR);
|
||||
allFiles = files.map(analyzeFile);
|
||||
relevantFiles = allFiles.filter(f => !shouldExclude(f.path));
|
||||
|
||||
expect(allFiles.length).toBeGreaterThan(0);
|
||||
expect(relevantFiles.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should NOT use console.log/console.error (these logs are invisible in background services)", () => {
|
||||
// Only hook files can use console.log for their final output response
|
||||
// Everything else (services, workers, sqlite, etc.) runs in background - console.log is USELESS there
|
||||
const filesWithConsole = relevantFiles.filter(f => {
|
||||
const isHookFile = /^src\/hooks\//.test(f.relativePath);
|
||||
return f.usesConsoleLog && !isHookFile;
|
||||
});
|
||||
|
||||
if (filesWithConsole.length > 0) {
|
||||
const report = filesWithConsole
|
||||
.map(f => ` ${f.relativePath}:${f.consoleLogLines.join(",")}`)
|
||||
.join("\n");
|
||||
|
||||
throw new Error(
|
||||
`❌ CRITICAL: Found console.log/console.error in ${filesWithConsole.length} background service file(s):\n${report}\n\n` +
|
||||
`These logs are INVISIBLE - they run in background processes where console output goes nowhere.\n` +
|
||||
`Replace with logger.debug/info/warn/error calls immediately.\n\n` +
|
||||
`Only hook files (src/hooks/*) should use console.log for their output response.`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("should have logger coverage in high-priority files", () => {
|
||||
const highPriorityFiles = relevantFiles.filter(f => f.isHighPriority);
|
||||
const withoutLogger = highPriorityFiles.filter(f => !f.hasLoggerImport);
|
||||
|
||||
if (withoutLogger.length > 0) {
|
||||
const report = withoutLogger
|
||||
.map(f => ` ${f.relativePath}`)
|
||||
.join("\n");
|
||||
|
||||
throw new Error(
|
||||
`High-priority files missing logger import (${withoutLogger.length}):\n${report}\n\n` +
|
||||
`These files should import and use logger for debugging and observability.`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("should report logger coverage statistics", () => {
|
||||
const withLogger = relevantFiles.filter(f => f.hasLoggerImport);
|
||||
const withoutLogger = relevantFiles.filter(f => !f.hasLoggerImport);
|
||||
const totalCalls = relevantFiles.reduce((sum, f) => sum + f.loggerCallCount, 0);
|
||||
|
||||
const coverage = ((withLogger.length / relevantFiles.length) * 100).toFixed(1);
|
||||
|
||||
console.log("\n📊 Logger Coverage Report:");
|
||||
console.log(` Total files analyzed: ${relevantFiles.length}`);
|
||||
console.log(` Files with logger: ${withLogger.length} (${coverage}%)`);
|
||||
console.log(` Files without logger: ${withoutLogger.length}`);
|
||||
console.log(` Total logger calls: ${totalCalls}`);
|
||||
console.log(` Excluded files: ${allFiles.length - relevantFiles.length}`);
|
||||
|
||||
if (withoutLogger.length > 0) {
|
||||
console.log("\n📝 Files without logger:");
|
||||
withoutLogger.forEach(f => {
|
||||
const priority = f.isHighPriority ? "🔴 HIGH" : " ";
|
||||
console.log(` ${priority} ${f.relativePath}`);
|
||||
});
|
||||
}
|
||||
|
||||
// This is an informational test - we expect some files won't need logging
|
||||
expect(withLogger.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,349 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll } from 'bun:test';
|
||||
import { spawn, execSync, ChildProcess } from 'child_process';
|
||||
import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync, rmSync } from 'fs';
|
||||
import { homedir } from 'os';
|
||||
import path from 'path';
|
||||
|
||||
// Test configuration
|
||||
const TEST_PORT = 37877; // Use different port than default to avoid conflicts
|
||||
const TEST_DATA_DIR = path.join(homedir(), '.claude-mem-test');
|
||||
const TEST_PID_FILE = path.join(TEST_DATA_DIR, 'worker.pid');
|
||||
const WORKER_SCRIPT = path.join(__dirname, '../plugin/scripts/worker-service.cjs');
|
||||
|
||||
// Timeout for health checks
|
||||
const HEALTH_TIMEOUT_MS = 5000;
|
||||
|
||||
interface PidInfo {
|
||||
pid: number;
|
||||
port: number;
|
||||
startedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to check if port is in use by attempting a health check
|
||||
*/
|
||||
async function isPortInUse(port: number): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/api/health`, {
|
||||
signal: AbortSignal.timeout(2000)
|
||||
});
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to wait for port to be healthy
|
||||
*/
|
||||
async function waitForHealth(port: number, timeoutMs: number = 30000): Promise<boolean> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
if (await isPortInUse(port)) return true;
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to wait for port to be free
|
||||
*/
|
||||
async function waitForPortFree(port: number, timeoutMs: number = 10000): Promise<boolean> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
if (!(await isPortInUse(port))) return true;
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to shut down worker via HTTP
|
||||
*/
|
||||
async function httpShutdown(port: number): Promise<boolean> {
|
||||
try {
|
||||
await fetch(`http://127.0.0.1:${port}/api/admin/shutdown`, {
|
||||
method: 'POST',
|
||||
signal: AbortSignal.timeout(5000)
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run worker CLI command and return stdout
|
||||
*/
|
||||
function runWorkerCommand(command: string, env: Record<string, string> = {}): string {
|
||||
const result = execSync(`bun "${WORKER_SCRIPT}" ${command}`, {
|
||||
env: { ...process.env, ...env },
|
||||
encoding: 'utf-8',
|
||||
timeout: 60000
|
||||
});
|
||||
return result.trim();
|
||||
}
|
||||
|
||||
describe('Worker Self-Spawn CLI', () => {
|
||||
beforeAll(async () => {
|
||||
// Clean up test data directory
|
||||
if (existsSync(TEST_DATA_DIR)) {
|
||||
rmSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Clean up test data directory
|
||||
if (existsSync(TEST_DATA_DIR)) {
|
||||
rmSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe('status command', () => {
|
||||
it('should report worker status in expected format', async () => {
|
||||
// The status command reads from settings file, not env vars
|
||||
// Just verify the output format is correct (running or not running)
|
||||
const output = runWorkerCommand('status');
|
||||
|
||||
// Should contain either "running" or "not running"
|
||||
const hasValidStatus = output.includes('running');
|
||||
expect(hasValidStatus).toBe(true);
|
||||
});
|
||||
|
||||
it('should include PID and port when running', async () => {
|
||||
const output = runWorkerCommand('status');
|
||||
|
||||
// If running, should include PID and port
|
||||
if (output.includes('Worker running')) {
|
||||
expect(output).toMatch(/PID: \d+/);
|
||||
expect(output).toMatch(/Port: \d+/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('PID file management', () => {
|
||||
it('should create PID file with correct structure', () => {
|
||||
// Create test directory
|
||||
mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
|
||||
const testPidInfo: PidInfo = {
|
||||
pid: 12345,
|
||||
port: TEST_PORT,
|
||||
startedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
writeFileSync(TEST_PID_FILE, JSON.stringify(testPidInfo, null, 2));
|
||||
|
||||
expect(existsSync(TEST_PID_FILE)).toBe(true);
|
||||
|
||||
const readInfo = JSON.parse(readFileSync(TEST_PID_FILE, 'utf-8')) as PidInfo;
|
||||
expect(readInfo.pid).toBe(12345);
|
||||
expect(readInfo.port).toBe(TEST_PORT);
|
||||
expect(readInfo.startedAt).toBe(testPidInfo.startedAt);
|
||||
});
|
||||
|
||||
it('should handle missing PID file gracefully', () => {
|
||||
const missingPath = path.join(TEST_DATA_DIR, 'nonexistent.pid');
|
||||
expect(existsSync(missingPath)).toBe(false);
|
||||
});
|
||||
|
||||
it('should remove PID file correctly', () => {
|
||||
mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
writeFileSync(TEST_PID_FILE, JSON.stringify({ pid: 1, port: 1, startedAt: '' }));
|
||||
|
||||
expect(existsSync(TEST_PID_FILE)).toBe(true);
|
||||
|
||||
unlinkSync(TEST_PID_FILE);
|
||||
|
||||
expect(existsSync(TEST_PID_FILE)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('health check utilities', () => {
|
||||
it('should return false for non-existent server', async () => {
|
||||
const unusedPort = 39999;
|
||||
const result = await isPortInUse(unusedPort);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should timeout appropriately for unreachable server', async () => {
|
||||
const start = Date.now();
|
||||
const result = await isPortInUse(39998);
|
||||
const elapsed = Date.now() - start;
|
||||
|
||||
expect(result).toBe(false);
|
||||
// Should not wait longer than the timeout (2s) + small buffer
|
||||
expect(elapsed).toBeLessThan(3000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hook response format', () => {
|
||||
it('should return valid JSON hook response', () => {
|
||||
const hookResponse = '{"continue": true, "suppressOutput": true}';
|
||||
const parsed = JSON.parse(hookResponse);
|
||||
|
||||
expect(parsed.continue).toBe(true);
|
||||
expect(parsed.suppressOutput).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Worker Health Endpoints', () => {
|
||||
let workerProcess: ChildProcess | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Skip if worker script doesn't exist (not built)
|
||||
if (!existsSync(WORKER_SCRIPT)) {
|
||||
console.log('Skipping worker health tests - worker script not built');
|
||||
return;
|
||||
}
|
||||
|
||||
// Start worker for health endpoint tests using default port
|
||||
// Note: These tests use the real worker, so they may be affected by existing worker state
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (workerProcess) {
|
||||
workerProcess.kill('SIGTERM');
|
||||
workerProcess = null;
|
||||
}
|
||||
});
|
||||
|
||||
describe('health endpoint contract', () => {
|
||||
it('should expect /api/health to return status ok', async () => {
|
||||
// This is a contract test - validates expected format
|
||||
const expectedHealthResponse = {
|
||||
status: 'ok',
|
||||
build: expect.any(String),
|
||||
managed: expect.any(Boolean),
|
||||
hasIpc: expect.any(Boolean),
|
||||
platform: expect.any(String),
|
||||
pid: expect.any(Number),
|
||||
initialized: expect.any(Boolean),
|
||||
mcpReady: expect.any(Boolean)
|
||||
};
|
||||
|
||||
// Verify the contract structure matches what the code returns
|
||||
const mockResponse = {
|
||||
status: 'ok',
|
||||
build: 'TEST-008-wrapper-ipc',
|
||||
managed: false,
|
||||
hasIpc: false,
|
||||
platform: 'darwin',
|
||||
pid: 12345,
|
||||
initialized: true,
|
||||
mcpReady: true
|
||||
};
|
||||
|
||||
expect(mockResponse.status).toBe('ok');
|
||||
expect(typeof mockResponse.build).toBe('string');
|
||||
expect(typeof mockResponse.pid).toBe('number');
|
||||
});
|
||||
|
||||
it('should expect /api/readiness to return status when ready', async () => {
|
||||
const expectedReadyResponse = {
|
||||
status: 'ready',
|
||||
mcpReady: true
|
||||
};
|
||||
|
||||
expect(expectedReadyResponse.status).toBe('ready');
|
||||
expect(expectedReadyResponse.mcpReady).toBe(true);
|
||||
});
|
||||
|
||||
it('should expect /api/readiness to return 503 when initializing', async () => {
|
||||
const expectedInitializingResponse = {
|
||||
status: 'initializing',
|
||||
message: 'Worker is still initializing, please retry'
|
||||
};
|
||||
|
||||
expect(expectedInitializingResponse.status).toBe('initializing');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Windows-specific behavior', () => {
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: originalPlatform,
|
||||
writable: true,
|
||||
configurable: true
|
||||
});
|
||||
});
|
||||
|
||||
it('should use different shutdown behavior on Windows', () => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'win32',
|
||||
writable: true,
|
||||
configurable: true
|
||||
});
|
||||
|
||||
// Windows uses IPC messages for managed workers
|
||||
const isWindowsManaged = process.platform === 'win32' &&
|
||||
process.env.CLAUDE_MEM_MANAGED === 'true' &&
|
||||
typeof process.send === 'function';
|
||||
|
||||
// In non-managed mode, this should be false
|
||||
expect(isWindowsManaged).toBe(false);
|
||||
});
|
||||
|
||||
it('should identify managed Windows worker correctly', () => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'win32',
|
||||
writable: true,
|
||||
configurable: true
|
||||
});
|
||||
|
||||
// Set managed environment
|
||||
process.env.CLAUDE_MEM_MANAGED = 'true';
|
||||
|
||||
const isWindows = process.platform === 'win32';
|
||||
const isManaged = process.env.CLAUDE_MEM_MANAGED === 'true';
|
||||
|
||||
expect(isWindows).toBe(true);
|
||||
expect(isManaged).toBe(true);
|
||||
|
||||
// Cleanup
|
||||
delete process.env.CLAUDE_MEM_MANAGED;
|
||||
});
|
||||
});
|
||||
|
||||
describe('CLI command parsing', () => {
|
||||
it('should recognize start command', () => {
|
||||
const args = ['node', 'worker-service.cjs', 'start'];
|
||||
const command = args[2];
|
||||
expect(command).toBe('start');
|
||||
});
|
||||
|
||||
it('should recognize stop command', () => {
|
||||
const args = ['node', 'worker-service.cjs', 'stop'];
|
||||
const command = args[2];
|
||||
expect(command).toBe('stop');
|
||||
});
|
||||
|
||||
it('should recognize restart command', () => {
|
||||
const args = ['node', 'worker-service.cjs', 'restart'];
|
||||
const command = args[2];
|
||||
expect(command).toBe('restart');
|
||||
});
|
||||
|
||||
it('should recognize status command', () => {
|
||||
const args = ['node', 'worker-service.cjs', 'status'];
|
||||
const command = args[2];
|
||||
expect(command).toBe('status');
|
||||
});
|
||||
|
||||
it('should recognize --daemon flag', () => {
|
||||
const args = ['node', 'worker-service.cjs', '--daemon'];
|
||||
const command = args[2];
|
||||
expect(command).toBe('--daemon');
|
||||
});
|
||||
|
||||
it('should default to daemon mode without command', () => {
|
||||
const args = ['node', 'worker-service.cjs'];
|
||||
const command = args[2]; // undefined
|
||||
// Default case in switch handles undefined by running as daemon
|
||||
expect(command).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user