Compare commits

...

16 Commits

Author SHA1 Message Date
Alex Newman 4282d03e9e chore: bump version to 8.2.0
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 21:51:01 -05:00
Alex Newman b680d33ad8 Merge pull request #426 from bigph00t/feature/gemini-provider
feat: add Gemini API as alternative AI provider
2025-12-25 21:49:10 -05:00
Alex Newman 51fa68c2a0 feat: Enhance timestamp handling for backlog messages in GeminiAgent
- Capture original timestamps for messages processed from the backlog to ensure accurate logging.
- Update processGeminiResponse to accept an original timestamp parameter, allowing for correct observation storage times.
- Modify observation and summary processing to utilize the original timestamp when available, improving data integrity.
2025-12-25 20:56:55 -05:00
Alex Newman 07036fe427 Refactor Gemini rate limiting configuration
- Removed billingEnabled setting and replaced it with rateLimitingEnabled in GeminiAgent.
- Updated enforceRateLimitForModel function to skip rate limiting based on rateLimitingEnabled.
- Adjusted getGeminiConfig to retrieve rateLimitingEnabled from settings.
- Changed settings management to reflect the new rate limiting logic in SettingsDefaultsManager and UI components.
- Updated ContextSettingsModal to toggle rate limiting instead of billing.
- Ensured default settings reflect the new rate limiting behavior for free tier users.
2025-12-25 20:47:39 -05:00
Alex Newman a47e57bb35 feat(GeminiAgent): expand available Gemini models and update RPM limits
- Added new Gemini model types: 'gemini-2.5-pro', 'gemini-2.0-flash', and 'gemini-2.0-flash-lite'.
- Updated RPM limits for existing and new models.
- Enhanced model selection logic to validate configured model against available options, with fallback to a default model.
2025-12-25 20:40:18 -05:00
Alex Newman 6d541d6b80 feat(gemini): handle empty responses by marking messages as processed to prevent stuck state 2025-12-25 20:30:41 -05:00
Alex Newman ee9a391d35 build 2025-12-25 20:25:11 -05:00
Alex Newman 8364af1e48 feat(gemini): update Gemini model to 2.5 versions and add billing toggle in settings 2025-12-25 19:47:41 -05:00
Alex Newman 7827226ed6 feat(gemini): update Gemini model types and implement rate limiting for free tier
- Changed Gemini model types to 'gemini-2.5-flash-lite', 'gemini-2.5-flash', and 'gemini-3-flash'.
- Introduced RPM limits for free tier models with a maximum of 10 RPM for 'gemini-2.5-flash-lite' and 5 RPM for the others.
- Added rate limiting enforcement in the GeminiAgent class, which waits based on the model's RPM limit.
- Updated getGeminiConfig to include billingEnabled setting, allowing users to skip rate limiting if billing is enabled.
- Modified ContextSettingsModal to reflect new model options and added a toggle for enabling billing.
- Updated default settings to use the new model and billing configuration.
2025-12-25 19:30:46 -05:00
Alex Newman b2b14a1b95 feat: add spinning favicon during processing and implement rate limiting for Gemini API requests
- Introduced a new hook `useSpinningFavicon` to animate the favicon when processing is ongoing.
- Updated the `Header` component to utilize the new spinning favicon feature.
- Added a rate limit delay of 100ms between requests to the Gemini API in `GeminiAgent`.
2025-12-25 19:03:29 -05:00
Alex Newman 954157e9e0 docs: add Gemini provider documentation
- New usage/gemini-provider.mdx with setup guide and free tier info
- Add Gemini settings to configuration.mdx
- Remove obsolete cleanup-hook.js references from docs

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 18:51:07 -05:00
Alex Newman 857ceb47b7 Merge main into feature/gemini-provider
Resolved conflicts to include both:
- Main's earliestPendingTimestamp for accurate observation timestamps
- PR's conversationHistory and currentProvider for Gemini provider switching

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 18:45:14 -05:00
Alex Newman 10bf6ff0a5 chore: update mem-search plugin binary 2025-12-25 18:34:35 -05:00
Alex Newman 4a1b34feb9 docs: update CHANGELOG.md from GitHub releases
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 15:43:51 -05:00
bigphoot f837a9eb77 feat: multi-turn conversations and Claude fallback for Gemini provider
Major improvements to Gemini provider:

**Shared Conversation History**
- Add ConversationMessage interface for provider-agnostic history
- Both Claude and Gemini agents read/write shared conversationHistory
- Context persists across provider switches via claudeSessionId linkage

**Multi-Turn Gemini API**
- Replace stateless single-query with full conversation context
- queryGeminiMultiTurn() sends entire history for coherent responses
- Maps 'assistant' role to 'model' for Gemini API compatibility

**Automatic Fallback to Claude**
- Detect rate limits (429), server errors (5xx), network failures
- Fall back to Claude SDK when Gemini API fails
- Reset 'processing' messages to 'pending' before fallback

**Mid-Session Provider Switching**
- Track currentProvider on ActiveSession
- Provider changes take effect after current generator finishes
- Avoids race conditions from aborting active generators

Files changed:
- worker-types.ts: Add ConversationMessage, currentProvider tracking
- GeminiAgent.ts: Multi-turn queries, fallback logic
- SDKAgent.ts: Capture messages to shared history
- SessionManager.ts: Initialize new session fields
- SessionRoutes.ts: Provider selection and switching logic
- worker-service.ts: Wire up fallback agent dependency

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 11:03:14 -08:00
bigphoot ec8dd08c32 feat: add Gemini API as alternative AI provider
Adds support for Google's Gemini API as an alternative to Claude Agent SDK
for observation extraction. Users can now choose between providers in the
settings UI.

Features:
- New GeminiAgent class using Gemini REST API
- Provider selection in Settings (Claude vs Gemini)
- Gemini API key configuration (via UI or GEMINI_API_KEY env var)
- Model selection: gemini-2.0-flash-exp, gemini-1.5-flash, gemini-1.5-pro
- Graceful fallback to Claude SDK if Gemini selected but no API key
- Seamless transition between providers without worker restart

Settings:
- CLAUDE_MEM_PROVIDER: 'claude' | 'gemini'
- CLAUDE_MEM_GEMINI_API_KEY: API key for Gemini
- CLAUDE_MEM_GEMINI_MODEL: Model selection

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 11:02:56 -08:00
33 changed files with 1682 additions and 222 deletions
+1 -1
View File
@@ -10,7 +10,7 @@
"plugins": [
{
"name": "claude-mem",
"version": "8.1.0",
"version": "8.2.0",
"source": "./plugin",
"description": "Persistent memory system for Claude Code - context compression across sessions"
}
+111
View File
@@ -4,6 +4,117 @@ 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
## Bug Fixes
**Observation Timestamps Fixed**
- Corrected timestamp handling throughout the observation lifecycle
- Fixed `created_at_epoch` preservation in database operations
**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
## Refactoring
**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
**Simplified Worker Utils**
- Removed 117 lines of legacy code
- Removed PM2 cleanup logic
- Streamlined `ensureWorkerRunning` function
**SDK Agent Improvements**
- Removed complex session creation retry logic
- Cleaner prompt number retrieval from SessionRoutes
## Documentation
**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
**Enhanced Troubleshooting** (`docs/public/troubleshooting.mdx`)
- Added 195 lines of manual recovery troubleshooting
- Queue state explanations
- Direct database inspection queries
**Updated Development Guide**
- Changed testing philosophy to emphasize real-world testing
- Added manual testing workflow documentation
- Queue health verification procedures
**Worker Service Docs Updated**
- Documented 22 HTTP endpoints (up from 20)
- Queue management endpoint documentation
## Dependencies
- Upgraded `@anthropic-ai/claude-agent-sdk` from `^0.1.67` to `^0.1.76`
## New Scripts
- `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
## Migration Guide
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
## [8.0.6] - 2025-12-24
## Bug Fixes
+11 -9
View File
@@ -13,12 +13,22 @@ 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 |
| `CLAUDE_MEM_MODEL` | `sonnet` | AI model for processing observations (when using Claude) |
| `CLAUDE_MEM_PROVIDER` | `claude` | AI provider: `claude` or `gemini` |
| `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 |
| `CLAUDE_MEM_SKIP_TOOLS` | `ListMcpResourcesTool,SlashCommand,Skill,TodoWrite,AskUserQuestion` | Comma-separated tools to exclude from observations |
### Gemini Provider Settings
| Setting | Default | Description |
|-------------------------------|---------------------------------|---------------------------------------|
| `CLAUDE_MEM_GEMINI_API_KEY` | — | Gemini API key ([get free key](https://aistudio.google.com/app/apikey)) |
| `CLAUDE_MEM_GEMINI_MODEL` | `gemini-2.5-flash-lite` | Gemini model: `gemini-2.5-flash-lite`, `gemini-2.5-flash`, `gemini-3-flash` |
See [Gemini Provider](usage/gemini-provider) for detailed configuration and free tier information.
### System Configuration
| Setting | Default | Description |
@@ -117,7 +127,6 @@ ${CLAUDE_PLUGIN_ROOT}/
│ ├── new-hook.js # Session creation hook
│ ├── save-hook.js # Observation capture hook
│ ├── summary-hook.js # Summary generation hook
│ ├── cleanup-hook.js # Session cleanup hook
│ ├── worker-service.cjs # Worker service (CJS)
│ └── mcp-server.cjs # MCP search server (CJS)
└── ui/
@@ -162,13 +171,6 @@ Hooks are configured in `plugin/hooks/hooks.json`:
"command": "node ${CLAUDE_PLUGIN_ROOT}/scripts/summary-hook.js",
"timeout": 120
}]
}],
"SessionEnd": [{
"hooks": [{
"type": "command",
"command": "node ${CLAUDE_PLUGIN_ROOT}/scripts/cleanup-hook.js",
"timeout": 120
}]
}]
}
}
+1
View File
@@ -36,6 +36,7 @@
"introduction",
"installation",
"usage/getting-started",
"usage/gemini-provider",
"usage/search-tools",
"usage/claude-desktop",
"usage/private-tags",
+172
View File
@@ -0,0 +1,172 @@
---
title: "Gemini Provider"
description: "Use Google's Gemini API as an alternative to Claude for observation extraction"
---
# Gemini Provider
Claude-mem supports Google's Gemini API as an alternative to the Claude Agent SDK for extracting observations from your sessions. This can significantly reduce costs since Gemini offers a generous free tier.
<Warning>
**Free Tier Rate Limits**: Without billing enabled, Gemini has strict rate limits (5-10 RPM). Enable billing on your Google Cloud project to unlock 1000-4000 RPM while still using the free quota.
</Warning>
## Why Use Gemini?
- **Cost savings**: The free tier covers most individual usage patterns
- **Same quality**: Gemini extracts observations using the same XML format as Claude
- **Seamless fallback**: Automatically falls back to Claude if Gemini is unavailable
- **Hot-swappable**: Switch providers without restarting the worker
## Getting a Free API Key
1. Go to the [Google AI Studio API Key page](https://aistudio.google.com/app/apikey)
2. Sign in with your Google account
3. Accept the Terms of Service and privacy policies
4. Click the **Create API key** button
5. Choose a Google Cloud project or create a new one
6. Copy and securely store the generated API key
<Tip>
**No billing required** to get started, but we recommend enabling billing to unlock higher rate limits (1000-4000 RPM vs 5-10 RPM) while still using the free quota.
</Tip>
## Configuration
### Settings
| Setting | Values | Default | Description |
|---------|--------|---------|-------------|
| `CLAUDE_MEM_PROVIDER` | `claude`, `gemini` | `claude` | AI provider for observation extraction |
| `CLAUDE_MEM_GEMINI_API_KEY` | string | — | Your Gemini API key |
| `CLAUDE_MEM_GEMINI_MODEL` | `gemini-2.5-flash-lite`, `gemini-2.5-flash`, `gemini-3-flash` | `gemini-2.5-flash-lite` | Gemini model to use |
| `CLAUDE_MEM_GEMINI_BILLING_ENABLED` | `true`, `false` | `false` | Skip rate limiting if billing is enabled on Google Cloud |
### 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 **Gemini**
4. Enter your Gemini 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": "gemini",
"CLAUDE_MEM_GEMINI_API_KEY": "your-api-key-here",
"CLAUDE_MEM_GEMINI_MODEL": "gemini-2.5-flash-lite",
"CLAUDE_MEM_GEMINI_BILLING_ENABLED": "true"
}
```
Alternatively, set the API key via environment variable:
```bash
export GEMINI_API_KEY="your-api-key-here"
```
The settings file takes precedence over the environment variable.
## Available Models
| Model | Free Tier RPM | Notes |
|-------|--------------|-------|
| `gemini-2.5-flash-lite` | 10 | Default, recommended for free tier (highest RPM) |
| `gemini-2.5-flash` | 5 | Higher capability, lower rate limit |
| `gemini-3-flash` | 5 | Latest model, lower rate limit |
## Provider Switching
You can switch between Claude and Gemini 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**: Both 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": "gemini"
}
```
## Fallback Behavior
If Gemini is selected but encounters errors, claude-mem automatically falls back to the Claude Agent SDK:
**Triggers fallback:**
- Rate limiting (HTTP 429)
- Server errors (HTTP 5xx)
- Network issues (connection refused, timeout)
**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
## Troubleshooting
### "Gemini API key not configured"
Either:
- Set `CLAUDE_MEM_GEMINI_API_KEY` in `~/.claude-mem/settings.json`, or
- Set the `GEMINI_API_KEY` environment variable
### Rate Limiting
Google has two rate limit tiers for free usage:
**Without billing (API key only):**
| Model | RPM | TPM |
|-------|-----|-----|
| gemini-2.5-flash-lite | 10 | 250K |
| gemini-2.5-flash | 5 | 250K |
| gemini-3-flash | 5 | 250K |
Claude-mem enforces these limits automatically with built-in delays between requests. Processing may be slower but stays within limits.
**With billing enabled (still free tier):**
| Model | RPM | TPM |
|-------|-----|-----|
| gemini-2.5-flash-lite | 4,000 | 4M |
| gemini-2.5-flash | 1,000 | 1M |
| gemini-3-flash | 1,000 | 1M |
<Tip>
**Recommended**: Enable billing on your Google Cloud project to unlock much higher rate limits. You won't be charged unless you exceed the generous free quota. This allows claude-mem to process observations instantly instead of waiting between requests.
</Tip>
If you hit rate limits:
- Claude-mem automatically falls back to Claude SDK
- Or switch back to Claude as your primary provider
### Observation Quality
If observations seem lower quality with Gemini:
- Note that Claude typically produces slightly higher quality observations
- Consider using Gemini for cost savings and Claude for important projects
## Next Steps
- [Configuration](../configuration) - Full settings reference
- [Getting Started](getting-started) - Basic usage guide
- [Troubleshooting](../troubleshooting) - Common issues
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "claude-mem",
"version": "8.1.0",
"version": "8.2.0",
"description": "Memory compression system for Claude Code - persist context across sessions",
"keywords": [
"claude",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "claude-mem",
"version": "8.1.0",
"version": "8.2.0",
"description": "Persistent memory system for Claude Code - seamlessly preserve context across sessions",
"author": {
"name": "Alex Newman"
File diff suppressed because one or more lines are too long
+10 -10
View File
@@ -1,14 +1,14 @@
#!/usr/bin/env bun
import{stdin as M}from"process";import C from"path";import{homedir as x}from"os";import{readFileSync as b}from"fs";import{readFileSync as $,writeFileSync as v,existsSync as w}from"fs";import{join as P}from"path";import{homedir as W}from"os";var A="bugfix,feature,refactor,discovery,decision,change",d="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_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:A,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=$(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"),E.info("SETTINGS","Migrated settings file from nested to flat schema",{settingsPath:t})}catch(i){E.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 E.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=_.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"),i=String(t.getMinutes()).padStart(2,"0"),a=String(t.getSeconds()).padStart(2,"0"),S=String(t.getMilliseconds()).padStart(3,"0");return`${r}-${e}-${n} ${s}:${i}:${a}.${S}`}log(t,r,e,n,s){if(t<this.getLevel())return;let i=this.formatTimestamp(new Date),a=T[t].padEnd(5),S=r.padEnd(6),c="";n?.correlationId?c=`[${n.correlationId}] `:n?.sessionId&&(c=`[session-${n.sessionId}] `);let l="";s!=null&&(this.getLevel()===0&&typeof s=="object"?l=`
`+JSON.stringify(s,null,2):l=" "+this.formatData(s));let p="";if(n){let{sessionId:B,sdkSessionId:Y,correlationId:J,...L}=n;Object.keys(L).length>0&&(p=` {${Object.entries(L).map(([N,y])=>`${N}=${y}`).join(", ")}}`)}let m=`[${i}] [${a}] [${S}] ${c}${e}${p}${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 c=((new Error().stack||"").split(`
`)[2]||"").match(/at\s+(?:.*\s+)?\(?([^:]+):(\d+):(\d+)\)?/),l=c?`${c[1].split("/").pop()}:${c[2]}`:"unknown",p={...e,location:l};return this.warn(t,`[HAPPY-PATH] ${r}`,p,n),s}},E=new O;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 D(o){return process.platform==="win32"?Math.round(o*u.WINDOWS_MULTIPLIER):o}function h(o={}){let{port:t,includeSkillFallback:r=!1,customPrefix:e,actualError:n}=o,s=e||"Worker service connection failed.",i=t?` (port ${t})`:"",a=`${s}${i}
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}
`;return a+=`To restart the worker:
`,a+=`1. Exit Claude Code completely
`,a+=`2. Run: claude-mem restart
`,a+="3. Restart Claude Code",r&&(a+=`
`;return E+=`To restart the worker:
`,E+=`1. Exit Claude Code completely
`,E+=`2. Run: claude-mem restart
`,E+="3. Restart Claude Code",r&&(E+=`
If that doesn't work, try: /troubleshoot`),n&&(a=`Worker Error: ${n}
If that doesn't work, try: /troubleshoot`),n&&(E=`Worker Error: ${n}
${a}`),a}var H=C.join(x(),".claude","plugins","marketplaces","thedotmack"),R=D(u.HEALTH_CHECK),f=null;function g(){if(f!==null)return f;let o=C.join(_.get("CLAUDE_MEM_DATA_DIR"),"settings.json"),t=_.loadFromFile(o);return f=parseInt(t.CLAUDE_MEM_WORKER_PORT,10),f}async function F(){let o=g();return(await fetch(`http://127.0.0.1:${o}/api/readiness`,{signal:AbortSignal.timeout(R)})).ok}function K(){let o=C.join(H,"package.json");return JSON.parse(b(o,"utf-8")).version}async function j(){let o=g(),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 X(){let o=K(),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 U(){for(let r=0;r<25;r++){try{if(await F()){await X();return}}catch{}await new Promise(e=>setTimeout(e,200))}throw new Error(h({port:g(),customPrefix:"Worker did not become ready within 5 seconds."}))}import V from"path";function I(o){if(!o||o.trim()==="")return E.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 E.info("PROJECT_NAME","Drive root detected",{cwd:o,projectName:s}),s}}return E.warn("PROJECT_NAME","Root directory detected, using fallback",{cwd:o}),"unknown-project"}return t}async function k(o){await U();let t=o?.cwd??process.cwd(),r=I(t),n=`http://127.0.0.1:${g()}/api/context/inject?project=${encodeURIComponent(r)}`,s=await fetch(n,{signal:AbortSignal.timeout(u.DEFAULT)});if(!s.ok)throw new Error(`Context generation failed: ${s.status}`);return(await s.text()).trim()}var G=process.argv.includes("--colors");if(M.isTTY||G)k(void 0).then(o=>{console.log(o),process.exit(0)});else{let o="";M.on("data",t=>o+=t),M.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 k(t);console.log(JSON.stringify({hookSpecificOutput:{hookEventName:"SessionStart",additionalContext:r}})),process.exit(0)})}
${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)})}
File diff suppressed because one or more lines are too long
+5 -5
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env bun
import{stdin as y}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_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"),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 s={...this.DEFAULTS};for(let a of Object.keys(this.DEFAULTS))n[a]!==void 0&&(s[a]=n[a]);return s}catch(r){return c.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"),a=String(t.getMinutes()).padStart(2,"0"),i=String(t.getSeconds()).padStart(2,"0"),_=String(t.getMilliseconds()).padStart(3,"0");return`${r}-${e}-${n} ${s}:${a}:${i}.${_}`}log(t,r,e,n,s){if(t<this.getLevel())return;let a=this.formatTimestamp(new Date),i=T[t].padEnd(5),_=r.padEnd(6),E="";n?.correlationId?E=`[${n.correlationId}] `:n?.sessionId&&(E=`[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:J,sdkSessionId:Y,correlationId:q,...L}=n;Object.keys(L).length>0&&(u=` {${Object.entries(L).map(([I,$])=>`${I}=${$}`).join(", ")}}`)}let M=`[${a}] [${i}] [${_}] ${E}${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 E=((new Error().stack||"").split(`
`)[2]||"").match(/at\s+(?:.*\s+)?\(?([^:]+):(\d+):(\d+)\)?/),l=E?`${E[1].split("/").pop()}:${E[2]}`:"unknown",u={...e,location:l};return this.warn(t,`[HAPPY-PATH] ${r}`,u,n),s}},c=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 h(o){return process.platform==="win32"?Math.round(o*m.WINDOWS_MULTIPLIER):o}function R(o={}){let{port:t,includeSkillFallback:r=!1,customPrefix:e,actualError:n}=o,s=e||"Worker service connection failed.",a=t?` (port ${t})`:"",i=`${s}${a}
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}
`;return i+=`To restart the worker:
`,i+=`1. Exit Claude Code completely
@@ -11,4 +11,4 @@ ${t.stack}`:t.message;if(Array.isArray(t))return`[${t.length} items]`;let r=Obje
If that doesn't work, try: /troubleshoot`),n&&(i=`Worker Error: ${n}
${i}`),i}var F=C.join(H(),".claude","plugins","marketplaces","thedotmack"),U=h(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 K(){let o=p();return(await fetch(`http://127.0.0.1:${o}/api/readiness`,{signal:AbortSignal.timeout(U)})).ok}function j(){let o=C.join(F,"package.json");return JSON.parse(x(o,"utf-8")).version}async function X(){let o=p(),t=await fetch(`http://127.0.0.1:${o}/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 V(){let o=j(),t=await X();o!==t&&c.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 K()){await V();return}}catch{}await new Promise(e=>setTimeout(e,200))}throw new Error(R({port:p(),customPrefix:"Worker did not become ready within 5 seconds."}))}import G from"path";function k(o){if(!o||o.trim()==="")return c.warn("PROJECT_NAME","Empty cwd provided, using fallback",{cwd:o}),"unknown-project";let t=G.basename(o);if(t===""){if(process.platform==="win32"){let e=o.match(/^([A-Z]):\\/i);if(e){let s=`drive-${e[1].toUpperCase()}`;return c.info("PROJECT_NAME","Drive root detected",{cwd:o,projectName:s}),s}}return c.warn("PROJECT_NAME","Root directory detected, using fallback",{cwd:o}),"unknown-project"}return t}async function B(o){if(await N(),!o)throw new Error("newHook requires input");let{session_id:t,cwd:r,prompt:e}=o,n=k(r),s=p(),a=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(!a.ok)throw new Error(`Session initialization failed: ${a.status}`);let i=await a.json(),_=i.sessionDbId,E=i.promptNumber;if(i.skipped&&i.reason==="private"){console.error(`[new-hook] Session ${_}, prompt #${E} (fully private - skipped)`),console.log(f);return}console.error(`[new-hook] Session ${_}, prompt #${E}`);let l=e.startsWith("/")?e.substring(1):e,u=await fetch(`http://127.0.0.1:${s}/sessions/${_}/init`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({userPrompt:l,promptNumber:E}),signal:AbortSignal.timeout(5e3)});if(!u.ok)throw new Error(`SDK agent start failed: ${u.status}`);console.log(f)}var A="";y.on("data",o=>A+=o);y.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)});
${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)});
+4 -4
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env bun
import{stdin as y}from"process";var m=JSON.stringify({continue:!0,suppressOutput:!0});import{readFileSync as k,writeFileSync as v,existsSync as P}from"fs";import{join as w}from"path";import{homedir as H}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 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_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: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(!P(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"),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}
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 T="";if(n){let{sessionId:j,sdkSessionId:B,correlationId:Y,...M}=n;Object.keys(M).length>0&&(T=` {${Object.entries(M).map(([N,$])=>`${N}=${$}`).join(", ")}}`)}let L=`[${i}] [${E}] [${_}] ${l}${e}${T}${u}`;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 l=((new Error().stack||"").split(`
`)[2]||"").match(/at\s+(?:.*\s+)?\(?([^:]+):(\d+):(\d+)\)?/),u=l?`${l[1].split("/").pop()}:${l[2]}`:"unknown",T={...e,location:u};return this.warn(t,`[HAPPY-PATH] ${r}`,T,n),o}},a=new p;import C 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 R(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}
`+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}
`;return E+=`To restart the worker:
`,E+=`1. Exit Claude Code completely
@@ -11,4 +11,4 @@ ${t.stack}`:t.message;if(Array.isArray(t))return`[${t.length} items]`;let r=Obje
If that doesn't work, try: /troubleshoot`),n&&(E=`Worker Error: ${n}
${E}`),E}var F=C.join(W(),".claude","plugins","marketplaces","thedotmack"),h=U(g.HEALTH_CHECK),O=null;function S(){if(O!==null)return O;let s=C.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=S();return(await fetch(`http://127.0.0.1:${s}/api/readiness`,{signal:AbortSignal.timeout(h)})).ok}function K(){let s=C.join(F,"package.json");return JSON.parse(b(s,"utf-8")).version}async function X(){let s=S(),t=await fetch(`http://127.0.0.1:${s}/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 s=K(),t=await X();s!==t&&a.warn("SYSTEM","Worker version mismatch",{pluginVersion:s,workerVersion:t,hint:"Restart worker with: claude-mem worker restart"})}async function I(){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(R({port:S(),customPrefix:"Worker did not become ready within 5 seconds."}))}async function G(s){if(await I(),!s)throw new Error("saveHook requires input");let{session_id:t,cwd:r,tool_name:e,tool_input:n,tool_response:o}=s,i=S(),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(m)}var A="";y.on("data",s=>A+=s);y.on("end",async()=>{let s;try{s=A?JSON.parse(A):void 0}catch(t){throw new Error(`Failed to parse hook input: ${t instanceof Error?t.message:String(t)}`)}await G(s)});
${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)});
+5 -5
View File
@@ -1,8 +1,8 @@
#!/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 W}from"path";import{homedir as x}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 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_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: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(!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}
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 L=`[${a}] [${i}] [${_}] ${E}${e}${S}${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 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 C from"path";import{homedir as b}from"os";import{readFileSync as P}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 y(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}
`+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}
`;return i+=`To restart the worker:
`,i+=`1. Exit Claude Code completely
@@ -11,8 +11,8 @@ ${t.stack}`:t.message;if(Array.isArray(t))return`[${t.length} items]`;let r=Obje
If that doesn't work, try: /troubleshoot`),n&&(i=`Worker Error: ${n}
${i}`),i}var F=C.join(b(),".claude","plugins","marketplaces","thedotmack"),U=y(u.HEALTH_CHECK),p=null;function f(){if(p!==null)return p;let s=C.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 X(){let s=C.join(F,"package.json");return JSON.parse(P(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 G(){let s=X(),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 I(){for(let r=0;r<25;r++){try{if(await K()){await G();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 M(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(`
${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,`
`).trim()),_}}if(!o)throw new Error(`No message found for role '${t}' in transcript: ${s}`);return""}async function Y(s){if(await I(),!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=M(s.transcript_path,"user"),n=M(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 A="";N.on("data",s=>A+=s);N.on("end",async()=>{let s;try{s=A?JSON.parse(A):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 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)});
+6 -6
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env bun
import{basename as V}from"path";import C from"path";import{homedir as P}from"os";import{readFileSync as H}from"fs";import{readFileSync as k,writeFileSync as v,existsSync as W}from"fs";import{join as w}from"path";import{homedir as b}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 _=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_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: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(!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}
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 S="";if(n){let{sessionId:Y,sdkSessionId:J,correlationId:q,...A}=n;Object.keys(A).length>0&&(S=` {${Object.entries(A).map(([$,N])=>`${$}=${N}`).join(", ")}}`)}let M=`[${E}] [${i}] [${u}] ${a}${e}${S}${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,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}},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},d={SUCCESS:0,FAILURE:1,USER_MESSAGE_ONLY:3};function h(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}
`+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}
`;return i+=`To restart the worker:
`,i+=`1. Exit Claude Code completely
@@ -11,7 +11,7 @@ ${t.stack}`:t.message;if(Array.isArray(t))return`[${t.length} items]`;let r=Obje
If that doesn't work, try: /troubleshoot`),n&&(i=`Worker Error: ${n}
${i}`),i}var x=C.join(P(),".claude","plugins","marketplaces","thedotmack"),U=h(O.HEALTH_CHECK),T=null;function g(){if(T!==null)return T;let s=C.join(_.get("CLAUDE_MEM_DATA_DIR"),"settings.json"),t=_.loadFromFile(s);return T=parseInt(t.CLAUDE_MEM_WORKER_PORT,10),T}async function F(){let s=g();return(await fetch(`http://127.0.0.1:${s}/api/readiness`,{signal:AbortSignal.timeout(U)})).ok}function K(){let s=C.join(x,"package.json");return JSON.parse(H(s,"utf-8")).version}async function X(){let s=g(),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 G(){let s=K(),t=await X();s!==t&&c.warn("SYSTEM","Worker version mismatch",{pluginVersion:s,workerVersion:t,hint:"Restart worker with: claude-mem worker restart"})}async function I(){for(let r=0;r<25;r++){try{if(await F()){await G();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 I();var y=g(),j=V(process.cwd()),L=await fetch(`http://127.0.0.1:${y}/api/context/inject?project=${encodeURIComponent(j)}&colors=true`,{method:"GET",signal:AbortSignal.timeout(5e3)});if(!L.ok)throw new Error(`Failed to fetch context: ${L.status}`);var B=await L.text();console.error(`
${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(`
\u{1F4DD} Claude-Mem Context Loaded
\u2139\uFE0F Note: This appears as stderr but is informational only
@@ -21,5 +21,5 @@ ${i}`),i}var x=C.join(P(),".claude","plugins","marketplaces","thedotmack"),U=h(O
\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:${y}/
`);process.exit(d.USER_MESSAGE_ONLY);
\u{1F4FA} Watch live in browser http://localhost:${N}/
`);process.exit(I.USER_MESSAGE_ONLY);
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
+5 -1
View File
@@ -24,6 +24,7 @@ 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 { PaginationHelper } from './worker/PaginationHelper.js';
import { SettingsManager } from './worker/SettingsManager.js';
import { SearchManager } from './worker/SearchManager.js';
@@ -54,6 +55,7 @@ export class WorkerService {
private sessionManager: SessionManager;
private sseBroadcaster: SSEBroadcaster;
private sdkAgent: SDKAgent;
private geminiAgent: GeminiAgent;
private paginationHelper: PaginationHelper;
private settingsManager: SettingsManager;
private sessionEventBroadcaster: SessionEventBroadcaster;
@@ -82,6 +84,8 @@ export class WorkerService {
this.sessionManager = new SessionManager(this.dbManager);
this.sseBroadcaster = new SSEBroadcaster();
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.paginationHelper = new PaginationHelper(this.dbManager);
this.settingsManager = new SettingsManager(this.dbManager);
this.sessionEventBroadcaster = new SessionEventBroadcaster(this.sseBroadcaster, this);
@@ -99,7 +103,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.sessionEventBroadcaster, this);
this.sessionRoutes = new SessionRoutes(this.sessionManager, this.dbManager, this.sdkAgent, this.geminiAgent, 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;
+11
View File
@@ -8,6 +8,15 @@ import type { Response } from 'express';
// Active Session Types
// ============================================================================
/**
* Provider-agnostic conversation message for shared history
* Used to maintain context across ClaudeGemini provider switches
*/
export interface ConversationMessage {
role: 'user' | 'assistant';
content: string;
}
export interface ActiveSession {
sessionDbId: number;
claudeSessionId: string;
@@ -23,6 +32,8 @@ export interface ActiveSession {
cumulativeOutputTokens: number; // Track output tokens for discovery cost
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
}
export interface PendingMessage {
+578
View File
@@ -0,0 +1,578 @@
/**
* GeminiAgent: Gemini-based observation extraction
*
* Alternative to SDKAgent that uses Google's Gemini API directly
* for extracting observations from tool usage.
*
* Responsibility:
* - Call Gemini REST API for observation extraction
* - Parse XML responses (same format as Claude)
* - Sync to database and Chroma
*/
import path from 'path';
import { homedir } from 'os';
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 type { ActiveSession, ConversationMessage } from '../worker-types.js';
import { ModeManager } from '../domain/ModeManager.js';
// Gemini API endpoint
const GEMINI_API_URL = 'https://generativelanguage.googleapis.com/v1beta/models';
// Gemini model types (available via API)
export type GeminiModel =
| 'gemini-2.5-flash-lite'
| 'gemini-2.5-flash'
| 'gemini-2.5-pro'
| 'gemini-2.0-flash'
| 'gemini-2.0-flash-lite';
// Free tier RPM limits by model (requests per minute)
const GEMINI_RPM_LIMITS: Record<GeminiModel, number> = {
'gemini-2.5-flash-lite': 10,
'gemini-2.5-flash': 10,
'gemini-2.5-pro': 5,
'gemini-2.0-flash': 15,
'gemini-2.0-flash-lite': 30,
};
// Track last request time for rate limiting
let lastRequestTime = 0;
/**
* Enforce RPM rate limit for Gemini free tier.
* Waits the required time between requests based on model's RPM limit + 100ms safety buffer.
* Skipped entirely if rate limiting is disabled (billing users with 1000+ RPM available).
*/
async function enforceRateLimitForModel(model: GeminiModel, rateLimitingEnabled: boolean): Promise<void> {
// Skip rate limiting if disabled (billing users with 1000+ RPM)
if (!rateLimitingEnabled) {
return;
}
const rpm = GEMINI_RPM_LIMITS[model] || 5;
const minimumDelayMs = Math.ceil(60000 / rpm) + 100; // (60s / RPM) + 100ms safety buffer
const now = Date.now();
const timeSinceLastRequest = now - lastRequestTime;
if (timeSinceLastRequest < minimumDelayMs) {
const waitTime = minimumDelayMs - timeSinceLastRequest;
logger.debug('SDK', `Rate limiting: waiting ${waitTime}ms before Gemini request`, { model, rpm });
await new Promise(resolve => setTimeout(resolve, waitTime));
}
lastRequestTime = Date.now();
}
interface GeminiResponse {
candidates?: Array<{
content?: {
parts?: Array<{
text?: string;
}>;
};
}>;
usageMetadata?: {
promptTokenCount?: number;
candidatesTokenCount?: number;
totalTokenCount?: number;
};
}
/**
* Gemini content message format
* role: "user" or "model" (Gemini uses "model" not "assistant")
*/
interface GeminiContent {
role: 'user' | 'model';
parts: Array<{ text: string }>;
}
// Forward declaration for fallback agent type
type FallbackAgent = {
startSession(session: ActiveSession, worker?: any): Promise<void>;
};
export class GeminiAgent {
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 Gemini 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 Gemini agent for a session
* Uses multi-turn conversation to maintain context across messages
*/
async startSession(session: ActiveSession, worker?: any): Promise<void> {
try {
// Get Gemini configuration
const { apiKey, model, rateLimitingEnabled } = this.getGeminiConfig();
if (!apiKey) {
throw new Error('Gemini API key not configured. Set CLAUDE_MEM_GEMINI_API_KEY in settings or GEMINI_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 Gemini with full context
session.conversationHistory.push({ role: 'user', content: initPrompt });
const initResponse = await this.queryGeminiMultiTurn(session.conversationHistory, apiKey, model, rateLimitingEnabled);
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.processGeminiResponse(session, initResponse.content, worker, tokensUsed, null);
}
// Process pending messages
for await (const message of this.sessionManager.getMessageIterator(session.sessionDbId)) {
// Capture earliest timestamp BEFORE processing (will be cleared after)
// This ensures backlog messages get their original timestamps, not current time
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 Gemini with full context
session.conversationHistory.push({ role: 'user', content: obsPrompt });
const obsResponse = await this.queryGeminiMultiTurn(session.conversationHistory, apiKey, model, rateLimitingEnabled);
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.processGeminiResponse(session, obsResponse.content, worker, tokensUsed, originalTimestamp);
} else {
// Empty response - still mark messages as processed to avoid stuck state
logger.warn('SDK', 'Empty Gemini 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 Gemini with full context
session.conversationHistory.push({ role: 'user', content: summaryPrompt });
const summaryResponse = await this.queryGeminiMultiTurn(session.conversationHistory, apiKey, model, rateLimitingEnabled);
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.processGeminiResponse(session, summaryResponse.content, worker, tokensUsed, originalTimestamp);
} else {
// Empty response - still mark messages as processed to avoid stuck state
logger.warn('SDK', 'Empty Gemini 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', 'Gemini agent completed', {
sessionId: session.sessionDbId,
duration: `${(sessionDuration / 1000).toFixed(1)}s`,
historyLength: session.conversationHistory.length
});
} catch (error: any) {
if (error.name === 'AbortError') {
logger.warn('SDK', 'Gemini agent aborted', { sessionId: session.sessionDbId });
throw error;
}
// Check if we should fall back to Claude
if (this.shouldFallbackToClaude(error) && this.fallbackAgent) {
logger.warn('SDK', 'Gemini 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
// This handles the case where Gemini failed mid-processing a message
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
// Note: Claude SDK will continue processing from current state
return this.fallbackAgent.startSession(session, worker);
}
logger.failure('SDK', 'Gemini agent error', { sessionDbId: session.sessionDbId }, error);
throw error;
}
}
/**
* Convert shared ConversationMessage array to Gemini's contents format
* Maps 'assistant' role to 'model' for Gemini API compatibility
*/
private conversationToGeminiContents(history: ConversationMessage[]): GeminiContent[] {
return history.map(msg => ({
role: msg.role === 'assistant' ? 'model' : 'user',
parts: [{ text: msg.content }]
}));
}
/**
* Query Gemini via REST API with full conversation history (multi-turn)
* Sends the entire conversation context for coherent responses
*/
private async queryGeminiMultiTurn(
history: ConversationMessage[],
apiKey: string,
model: GeminiModel,
rateLimitingEnabled: boolean
): Promise<{ content: string; tokensUsed?: number }> {
const contents = this.conversationToGeminiContents(history);
const totalChars = history.reduce((sum, m) => sum + m.content.length, 0);
logger.debug('SDK', `Querying Gemini multi-turn (${model})`, {
turns: history.length,
totalChars
});
const url = `${GEMINI_API_URL}/${model}:generateContent?key=${apiKey}`;
// Enforce RPM rate limit for free tier (skipped if rate limiting disabled)
await enforceRateLimitForModel(model, rateLimitingEnabled);
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
contents,
generationConfig: {
temperature: 0.3, // Lower temperature for structured extraction
maxOutputTokens: 4096,
},
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Gemini API error: ${response.status} - ${error}`);
}
const data = await response.json() as GeminiResponse;
if (!data.candidates?.[0]?.content?.parts?.[0]?.text) {
logger.warn('SDK', 'Empty response from Gemini');
return { content: '' };
}
const content = data.candidates[0].content.parts[0].text;
const tokensUsed = data.usageMetadata?.totalTokenCount;
return { content, tokensUsed };
}
/**
* Process Gemini response (same format as Claude)
* @param originalTimestamp - Original epoch when message was queued (for backlog processing accuracy)
*/
private async processGeminiResponse(
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', 'Gemini 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', 'Gemini 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', 'Gemini 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', 'Gemini 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', 'Gemini 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', 'Gemini cleaned up old processed messages', { deletedCount });
}
}
if (worker && typeof worker.broadcastProcessingStatus === 'function') {
worker.broadcastProcessingStatus();
}
}
/**
* Get Gemini configuration from settings or environment
*/
private getGeminiConfig(): { apiKey: string; model: GeminiModel; rateLimitingEnabled: boolean } {
const settingsPath = path.join(homedir(), '.claude-mem', 'settings.json');
const settings = SettingsDefaultsManager.loadFromFile(settingsPath);
// API key: check settings first, then environment variable
const apiKey = settings.CLAUDE_MEM_GEMINI_API_KEY || process.env.GEMINI_API_KEY || '';
// Model: from settings or default, with validation
const defaultModel: GeminiModel = 'gemini-2.5-flash';
const configuredModel = settings.CLAUDE_MEM_GEMINI_MODEL || defaultModel;
const validModels: GeminiModel[] = [
'gemini-2.5-flash-lite',
'gemini-2.5-flash',
'gemini-2.5-pro',
'gemini-2.0-flash',
'gemini-2.0-flash-lite',
];
let model: GeminiModel;
if (validModels.includes(configuredModel as GeminiModel)) {
model = configuredModel as GeminiModel;
} else {
logger.warn('SDK', `Invalid Gemini model "${configuredModel}", falling back to ${defaultModel}`, {
configured: configuredModel,
validModels,
});
model = defaultModel;
}
// Rate limiting: enabled by default for free tier users
const rateLimitingEnabled = settings.CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED !== 'false';
return { apiKey, model, rateLimitingEnabled };
}
}
/**
* Check if Gemini is available (has API key configured)
*/
export function isGeminiAvailable(): boolean {
const settingsPath = path.join(homedir(), '.claude-mem', 'settings.json');
const settings = SettingsDefaultsManager.loadFromFile(settingsPath);
return !!(settings.CLAUDE_MEM_GEMINI_API_KEY || process.env.GEMINI_API_KEY);
}
/**
* Check if Gemini is the selected provider
*/
export function isGeminiSelected(): boolean {
const settingsPath = path.join(homedir(), '.claude-mem', 'settings.json');
const settings = SettingsDefaultsManager.loadFromFile(settingsPath);
return settings.CLAUDE_MEM_PROVIDER === 'gemini';
}
+48 -19
View File
@@ -185,20 +185,31 @@ export class SDKAgent {
* - SessionManager.initializeSession already fetched this from database
* - Database row was created by new-hook's createSDKSession call
* - We just use the session_id we're given - simple and reliable
*
* SHARED CONVERSATION HISTORY:
* - Each user message is added to session.conversationHistory
* - This allows provider switching (ClaudeGemini) with full context
* - SDK manages its own internal state, but we mirror it for interop
*/
private async *createMessageGenerator(session: ActiveSession): AsyncIterableIterator<SDKUserMessage> {
// 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 shared conversation history for provider interop
session.conversationHistory.push({ role: 'user', content: initPrompt });
// Yield initial user prompt with context (or continuation if prompt #2+)
// CRITICAL: Both paths use session.claudeSessionId from the hook
yield {
type: 'user',
message: {
role: 'user',
content: session.lastPromptNumber === 1
? buildInitPrompt(session.project, session.claudeSessionId, session.userPrompt, mode)
: buildContinuationPrompt(session.userPrompt, session.lastPromptNumber, session.claudeSessionId, mode)
content: initPrompt
},
session_id: session.claudeSessionId,
parent_tool_use_id: null,
@@ -213,36 +224,46 @@ export class SDKAgent {
session.lastPromptNumber = message.prompt_number;
}
const obsPrompt = buildObservationPrompt({
id: 0, // Not used in prompt
tool_name: message.tool_name!,
tool_input: JSON.stringify(message.tool_input),
tool_output: JSON.stringify(message.tool_response),
created_at_epoch: Date.now(),
cwd: message.cwd
});
// Add to shared conversation history for provider interop
session.conversationHistory.push({ role: 'user', content: obsPrompt });
yield {
type: 'user',
message: {
role: 'user',
content: buildObservationPrompt({
id: 0, // Not used in prompt
tool_name: message.tool_name!,
tool_input: JSON.stringify(message.tool_input),
tool_output: JSON.stringify(message.tool_response),
created_at_epoch: Date.now(),
cwd: message.cwd
})
content: obsPrompt
},
session_id: session.claudeSessionId,
parent_tool_use_id: null,
isSynthetic: true
};
} else if (message.type === 'summarize') {
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 shared conversation history for provider interop
session.conversationHistory.push({ role: 'user', content: summaryPrompt });
yield {
type: 'user',
message: {
role: 'user',
content: 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)
content: summaryPrompt
},
session_id: session.claudeSessionId,
parent_tool_use_id: null,
@@ -256,8 +277,16 @@ export class SDKAgent {
* Process SDK response text (parse XML, save to database, sync to Chroma)
* @param discoveryTokens - Token cost for discovering this response (delta, not cumulative)
* @param originalTimestamp - Original epoch when message was queued (for backlog processing accuracy)
*
* Also captures assistant responses to shared conversation history for provider interop.
* This allows Gemini to see full context if provider is switched mid-session.
*/
private async processSDKResponse(session: ActiveSession, text: string, worker: any | undefined, discoveryTokens: number, originalTimestamp: number | null): Promise<void> {
// Add assistant response to shared conversation history for provider interop
if (text) {
session.conversationHistory.push({ role: 'assistant', content: text });
}
// Parse observations
const observations = parseObservations(text, session.claudeSessionId);
+3 -1
View File
@@ -118,7 +118,9 @@ export class SessionManager {
cumulativeInputTokens: 0,
cumulativeOutputTokens: 0,
pendingProcessingIds: new Set(),
earliestPendingTimestamp: null
earliestPendingTimestamp: null,
conversationHistory: [], // Initialize empty - will be populated by agents
currentProvider: null // Will be set when generator starts
};
this.sessions.set(sessionDbId, session);
@@ -12,6 +12,7 @@ import { stripMemoryTagsFromJson, stripMemoryTagsFromPrompt } from '../../../../
import { SessionManager } from '../../SessionManager.js';
import { DatabaseManager } from '../../DatabaseManager.js';
import { SDKAgent } from '../../SDKAgent.js';
import { GeminiAgent, isGeminiSelected, isGeminiAvailable } from '../../GeminiAgent.js';
import type { WorkerService } from '../../../worker-service.js';
import { BaseRouteHandler } from '../BaseRouteHandler.js';
import { SessionEventBroadcaster } from '../../events/SessionEventBroadcaster.js';
@@ -27,6 +28,7 @@ export class SessionRoutes extends BaseRouteHandler {
private sessionManager: SessionManager,
private dbManager: DatabaseManager,
private sdkAgent: SDKAgent,
private geminiAgent: GeminiAgent,
private eventBroadcaster: SessionEventBroadcaster,
private workerService: WorkerService
) {
@@ -38,24 +40,94 @@ export class SessionRoutes extends BaseRouteHandler {
}
/**
* Ensures SDK agent generator is running for a session
* Get the appropriate agent based on settings
* Throws error if Gemini 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 {
if (isGeminiSelected()) {
if (isGeminiAvailable()) {
logger.debug('SESSION', 'Using Gemini agent');
return this.geminiAgent;
} else {
throw new Error('Gemini provider selected but no API key configured. Set CLAUDE_MEM_GEMINI_API_KEY in settings or GEMINI_API_KEY environment variable.');
}
}
return this.sdkAgent;
}
/**
* Get the currently selected provider name
*/
private getSelectedProvider(): 'claude' | 'gemini' {
return (isGeminiSelected() && isGeminiAvailable()) ? 'gemini' : 'claude';
}
/**
* Ensures agent generator is running for a session
* Auto-starts if not already running to process pending queue
* Uses either Claude SDK or Gemini based on settings
*
* Provider switching: If provider setting changed while generator is running,
* we let the current generator finish naturally (max 5s linger timeout).
* The next generator will use the new provider with shared conversationHistory.
*/
private ensureGeneratorRunning(sessionDbId: number, source: string): void {
const session = this.sessionManager.getSession(sessionDbId);
if (session && !session.generatorPromise) {
logger.info('SESSION', `Generator auto-starting (${source})`, {
sessionId: sessionDbId,
queueDepth: session.pendingMessages.length
});
if (!session) return;
session.generatorPromise = this.sdkAgent.startSession(session, this.workerService)
.finally(() => {
logger.info('SESSION', `Generator finished`, { sessionId: sessionDbId });
session.generatorPromise = null;
this.workerService.broadcastProcessingStatus();
});
const selectedProvider = this.getSelectedProvider();
// Start generator if not running
if (!session.generatorPromise) {
this.startGeneratorWithProvider(session, selectedProvider, source);
return;
}
// Generator is running - check if provider changed
if (session.currentProvider && session.currentProvider !== selectedProvider) {
logger.info('SESSION', `Provider changed, will switch after current generator finishes`, {
sessionId: sessionDbId,
currentProvider: session.currentProvider,
selectedProvider,
historyLength: session.conversationHistory.length
});
// Let current generator finish naturally, next one will use new provider
// The shared conversationHistory ensures context is preserved
}
}
/**
* Start a generator with the specified provider
*/
private startGeneratorWithProvider(
session: ReturnType<typeof this.sessionManager.getSession>,
provider: 'claude' | 'gemini',
source: string
): void {
if (!session) return;
const agent = provider === 'gemini' ? this.geminiAgent : this.sdkAgent;
const agentName = provider === 'gemini' ? 'Gemini' : 'Claude SDK';
logger.info('SESSION', `Generator auto-starting (${source}) using ${agentName}`, {
sessionId: session.sessionDbId,
queueDepth: session.pendingMessages.length,
historyLength: session.conversationHistory.length
});
// Track which provider is running
session.currentProvider = provider;
session.generatorPromise = agent.startSession(session, this.workerService)
.finally(() => {
logger.info('SESSION', `Generator finished`, { sessionId: session.sessionDbId });
session.generatorPromise = null;
session.currentProvider = null;
this.workerService.broadcastProcessingStatus();
});
}
setupRoutes(app: express.Application): void {
@@ -125,21 +197,8 @@ export class SessionRoutes extends BaseRouteHandler {
});
}
// Start SDK agent in background (pass worker ref for spinner control)
logger.info('SESSION', 'Generator starting', {
sessionId: sessionDbId,
project: session.project,
promptNum: session.lastPromptNumber
});
session.generatorPromise = this.sdkAgent.startSession(session, this.workerService)
.finally(() => {
// Clear generator reference when completed
logger.info('SESSION', `Generator finished`, { sessionId: sessionDbId });
session.generatorPromise = null;
// Broadcast status change (generator finished, may stop spinner)
this.workerService.broadcastProcessingStatus();
});
// Start agent in background using the helper method
this.startGeneratorWithProvider(session, this.getSelectedProvider(), 'init');
// Broadcast session started event
this.eventBroadcaster.broadcastSessionStarted(sessionDbId, session.project);
@@ -80,6 +80,10 @@ export class SettingsRoutes extends BaseRouteHandler {
'CLAUDE_MEM_CONTEXT_OBSERVATIONS',
'CLAUDE_MEM_WORKER_PORT',
'CLAUDE_MEM_WORKER_HOST',
// AI Provider Configuration
'CLAUDE_MEM_PROVIDER',
'CLAUDE_MEM_GEMINI_API_KEY',
'CLAUDE_MEM_GEMINI_MODEL',
// System Configuration
'CLAUDE_MEM_DATA_DIR',
'CLAUDE_MEM_LOG_LEVEL',
@@ -210,6 +214,22 @@ export class SettingsRoutes extends BaseRouteHandler {
* Validate all settings from request body (single source of truth)
*/
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"' };
}
}
// Validate CLAUDE_MEM_GEMINI_MODEL
if (settings.CLAUDE_MEM_GEMINI_MODEL) {
const validGeminiModels = ['gemini-2.5-flash-lite', 'gemini-2.5-flash', 'gemini-3-flash'];
if (!validGeminiModels.includes(settings.CLAUDE_MEM_GEMINI_MODEL)) {
return { valid: false, error: 'CLAUDE_MEM_GEMINI_MODEL must be one of: gemini-2.5-flash-lite, gemini-2.5-flash, gemini-3-flash' };
}
}
// Validate CLAUDE_MEM_CONTEXT_OBSERVATIONS
if (settings.CLAUDE_MEM_CONTEXT_OBSERVATIONS) {
const obsCount = parseInt(settings.CLAUDE_MEM_CONTEXT_OBSERVATIONS, 10);
+10
View File
@@ -17,6 +17,11 @@ export interface SettingsDefaults {
CLAUDE_MEM_WORKER_PORT: string;
CLAUDE_MEM_WORKER_HOST: string;
CLAUDE_MEM_SKIP_TOOLS: string;
// AI Provider Configuration
CLAUDE_MEM_PROVIDER: string; // 'claude' | 'gemini'
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
// System Configuration
CLAUDE_MEM_DATA_DIR: string;
CLAUDE_MEM_LOG_LEVEL: string;
@@ -50,6 +55,11 @@ export class SettingsDefaultsManager {
CLAUDE_MEM_WORKER_PORT: '37777',
CLAUDE_MEM_WORKER_HOST: '127.0.0.1',
CLAUDE_MEM_SKIP_TOOLS: 'ListMcpResourcesTool,SlashCommand,Skill,TodoWrite,AskUserQuestion',
// AI Provider Configuration
CLAUDE_MEM_PROVIDER: 'claude', // Default to Claude
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
// System Configuration
CLAUDE_MEM_DATA_DIR: join(homedir(), '.claude-mem'),
CLAUDE_MEM_LOG_LEVEL: 'INFO',
@@ -423,24 +423,74 @@ export function ContextSettingsModal({
{/* Section 4: Advanced */}
<CollapsibleSection
title="Advanced"
description="Model selection and integrations"
description="AI provider and model selection"
defaultOpen={false}
>
<FormField
label="Model"
tooltip="AI model used for generating observations"
label="AI Provider"
tooltip="Choose between Claude (via Agent SDK) or Gemini (via REST API)"
>
<select
value={formState.CLAUDE_MEM_MODEL || 'haiku'}
onChange={(e) => updateSetting('CLAUDE_MEM_MODEL', e.target.value)}
value={formState.CLAUDE_MEM_PROVIDER || 'claude'}
onChange={(e) => updateSetting('CLAUDE_MEM_PROVIDER', e.target.value)}
>
{/* Shorthand names forward to latest model version */}
<option value="haiku">haiku (fastest)</option>
<option value="sonnet">sonnet (balanced)</option>
<option value="opus">opus (highest quality)</option>
<option value="claude">Claude (uses your Claude account)</option>
<option value="gemini">Gemini (uses API key)</option>
</select>
</FormField>
{formState.CLAUDE_MEM_PROVIDER === 'claude' ? (
<FormField
label="Claude Model"
tooltip="Claude model used for generating observations"
>
<select
value={formState.CLAUDE_MEM_MODEL || 'haiku'}
onChange={(e) => updateSetting('CLAUDE_MEM_MODEL', e.target.value)}
>
<option value="haiku">haiku (fastest)</option>
<option value="sonnet">sonnet (balanced)</option>
<option value="opus">opus (highest quality)</option>
</select>
</FormField>
) : (
<>
<FormField
label="Gemini API Key"
tooltip="Your Google AI Studio API key (or set GEMINI_API_KEY env var)"
>
<input
type="password"
value={formState.CLAUDE_MEM_GEMINI_API_KEY || ''}
onChange={(e) => updateSetting('CLAUDE_MEM_GEMINI_API_KEY', e.target.value)}
placeholder="Enter Gemini API key..."
/>
</FormField>
<FormField
label="Gemini Model"
tooltip="Gemini model used for generating observations"
>
<select
value={formState.CLAUDE_MEM_GEMINI_MODEL || 'gemini-2.5-flash-lite'}
onChange={(e) => updateSetting('CLAUDE_MEM_GEMINI_MODEL', e.target.value)}
>
<option value="gemini-2.5-flash-lite">gemini-2.5-flash-lite (10 RPM free)</option>
<option value="gemini-2.5-flash">gemini-2.5-flash (5 RPM free)</option>
<option value="gemini-3-flash">gemini-3-flash (5 RPM free)</option>
</select>
</FormField>
<div className="toggle-group" style={{ marginTop: '8px' }}>
<ToggleSwitch
id="gemini-rate-limiting"
label="Rate Limiting"
description="Enable for free tier (10-30 RPM). Disable if you have billing set up (1000+ RPM)."
checked={formState.CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED === 'true'}
onChange={(checked) => updateSetting('CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED', checked ? 'true' : 'false')}
/>
</div>
</>
)}
<FormField
label="Worker Port"
tooltip="Port for the background worker service"
+3
View File
@@ -2,6 +2,7 @@ import React from 'react';
import { ThemeToggle } from './ThemeToggle';
import { ThemePreference } from '../hooks/useTheme';
import { GitHubStarsButton } from './GitHubStarsButton';
import { useSpinningFavicon } from '../hooks/useSpinningFavicon';
interface HeaderProps {
isConnected: boolean;
@@ -26,6 +27,8 @@ export function Header({
onThemeChange,
onContextPreviewToggle
}: HeaderProps) {
useSpinningFavicon(isProcessing);
return (
<div className="header">
<h1>
+6
View File
@@ -8,6 +8,12 @@ export const DEFAULT_SETTINGS = {
CLAUDE_MEM_WORKER_PORT: '37777',
CLAUDE_MEM_WORKER_HOST: '127.0.0.1',
// AI Provider Configuration
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',
// Token Economics (all true for backwards compatibility)
CLAUDE_MEM_CONTEXT_SHOW_READ_TOKENS: 'true',
CLAUDE_MEM_CONTEXT_SHOW_WORK_TOKENS: 'true',
+5
View File
@@ -20,6 +20,11 @@ export function useSettings() {
CLAUDE_MEM_WORKER_PORT: data.CLAUDE_MEM_WORKER_PORT || DEFAULT_SETTINGS.CLAUDE_MEM_WORKER_PORT,
CLAUDE_MEM_WORKER_HOST: data.CLAUDE_MEM_WORKER_HOST || DEFAULT_SETTINGS.CLAUDE_MEM_WORKER_HOST,
// AI Provider Configuration
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,
// Token Economics Display
CLAUDE_MEM_CONTEXT_SHOW_READ_TOKENS: data.CLAUDE_MEM_CONTEXT_SHOW_READ_TOKENS || DEFAULT_SETTINGS.CLAUDE_MEM_CONTEXT_SHOW_READ_TOKENS,
CLAUDE_MEM_CONTEXT_SHOW_WORK_TOKENS: data.CLAUDE_MEM_CONTEXT_SHOW_WORK_TOKENS || DEFAULT_SETTINGS.CLAUDE_MEM_CONTEXT_SHOW_WORK_TOKENS,
+93
View File
@@ -0,0 +1,93 @@
import { useEffect, useRef } from 'react';
/**
* Hook that makes the browser tab favicon spin when isProcessing is true.
* Uses canvas to rotate the logo image and dynamically update the favicon.
*/
export function useSpinningFavicon(isProcessing: boolean) {
const animationRef = useRef<number | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const imageRef = useRef<HTMLImageElement | null>(null);
const rotationRef = useRef(0);
const originalFaviconRef = useRef<string | null>(null);
useEffect(() => {
// Create canvas once
if (!canvasRef.current) {
canvasRef.current = document.createElement('canvas');
canvasRef.current.width = 32;
canvasRef.current.height = 32;
}
// Load image once
if (!imageRef.current) {
imageRef.current = new Image();
imageRef.current.src = 'claude-mem-logomark.webp';
}
// Store original favicon
if (!originalFaviconRef.current) {
const link = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
if (link) {
originalFaviconRef.current = link.href;
}
}
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
const image = imageRef.current;
if (!ctx) return;
const updateFavicon = (dataUrl: string) => {
let link = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
if (!link) {
link = document.createElement('link');
link.rel = 'icon';
document.head.appendChild(link);
}
link.href = dataUrl;
};
const animate = () => {
if (!image.complete) {
animationRef.current = requestAnimationFrame(animate);
return;
}
// Rotate by ~4 degrees per frame (matches 1.5s for full rotation at 60fps)
rotationRef.current += (2 * Math.PI) / 90;
ctx.clearRect(0, 0, 32, 32);
ctx.save();
ctx.translate(16, 16);
ctx.rotate(rotationRef.current);
ctx.drawImage(image, -16, -16, 32, 32);
ctx.restore();
updateFavicon(canvas.toDataURL('image/png'));
animationRef.current = requestAnimationFrame(animate);
};
if (isProcessing) {
rotationRef.current = 0;
animate();
} else {
// Stop animation and restore original favicon
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
animationRef.current = null;
}
if (originalFaviconRef.current) {
updateFavicon(originalFaviconRef.current);
}
}
return () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
animationRef.current = null;
}
};
}, [isProcessing]);
}
+6
View File
@@ -60,6 +60,12 @@ export interface Settings {
CLAUDE_MEM_WORKER_PORT: string;
CLAUDE_MEM_WORKER_HOST: string;
// AI Provider Configuration
CLAUDE_MEM_PROVIDER?: string; // 'claude' | 'gemini'
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'
// Token Economics Display
CLAUDE_MEM_CONTEXT_SHOW_READ_TOKENS?: string;
CLAUDE_MEM_CONTEXT_SHOW_WORK_TOKENS?: string;
+298
View File
@@ -0,0 +1,298 @@
import { describe, it, expect, beforeEach, afterEach, spyOn, mock } from 'bun:test';
import { GeminiAgent } from '../src/services/worker/GeminiAgent';
import { DatabaseManager } from '../src/services/worker/DatabaseManager';
import { SessionManager } from '../src/services/worker/SessionManager';
import { ModeManager } from '../src/services/worker/domain/ModeManager';
import { SettingsDefaultsManager } from '../src/shared/SettingsDefaultsManager';
let billingEnabled = 'true';
// Mock SettingsDefaultsManager
mock.module('../src/shared/SettingsDefaultsManager', () => ({
SettingsDefaultsManager: {
loadFromFile: () => ({
CLAUDE_MEM_GEMINI_API_KEY: 'test-api-key',
CLAUDE_MEM_GEMINI_MODEL: 'gemini-2.5-flash-lite',
CLAUDE_MEM_GEMINI_BILLING_ENABLED: billingEnabled
}),
get: (key: string) => {
if (key === 'CLAUDE_MEM_LOG_LEVEL') return 'INFO';
return '';
}
}
}));
// Mock ModeManager
const mockMode = {
name: 'code',
prompts: {
init: 'init prompt',
observation: 'obs prompt',
summary: 'summary prompt'
},
observation_types: [{ id: 'discovery' }, { id: 'bugfix' }],
observation_concepts: []
};
mock.module('../src/services/domain/ModeManager', () => ({
ModeManager: {
getInstance: () => ({
getActiveMode: () => mockMode
})
}
}));
describe('GeminiAgent', () => {
let agent: GeminiAgent;
let originalFetch: typeof global.fetch;
// Mocks
let mockStoreObservation: any;
let mockStoreSummary: any;
let mockMarkSessionCompleted: any;
let mockSyncObservation: any;
let mockSyncSummary: any;
let mockMarkProcessed: any;
let mockCleanupProcessed: any;
let mockResetStuckMessages: any;
let mockDbManager: DatabaseManager;
let mockSessionManager: SessionManager;
beforeEach(() => {
// Reset billing for each test default
billingEnabled = 'true';
// Initialize mocks
mockStoreObservation = mock(() => ({ id: 1, createdAtEpoch: Date.now() }));
mockStoreSummary = mock(() => ({ id: 1, createdAtEpoch: Date.now() }));
mockMarkSessionCompleted = mock(() => {});
mockSyncObservation = mock(() => Promise.resolve());
mockSyncSummary = mock(() => Promise.resolve());
mockMarkProcessed = mock(() => {});
mockCleanupProcessed = mock(() => 0);
mockResetStuckMessages = mock(() => 0);
const mockSessionStore = {
storeObservation: mockStoreObservation,
storeSummary: mockStoreSummary,
markSessionCompleted: mockMarkSessionCompleted
};
const mockChromaSync = {
syncObservation: mockSyncObservation,
syncSummary: mockSyncSummary
};
mockDbManager = {
getSessionStore: () => mockSessionStore,
getChromaSync: () => mockChromaSync
} as unknown as DatabaseManager;
const mockPendingMessageStore = {
markProcessed: mockMarkProcessed,
cleanupProcessed: mockCleanupProcessed,
resetStuckMessages: mockResetStuckMessages
};
mockSessionManager = {
getMessageIterator: async function* () { yield* []; },
getPendingMessageStore: () => mockPendingMessageStore
} as unknown as SessionManager;
agent = new GeminiAgent(mockDbManager, mockSessionManager);
originalFetch = global.fetch;
});
afterEach(() => {
global.fetch = originalFetch;
mock.restore();
});
it('should initialize with correct config', async () => {
const session = {
sessionDbId: 1,
claudeSessionId: 'test-session',
sdkSessionId: 'test-sdk',
project: 'test-project',
userPrompt: 'test prompt',
conversationHistory: [],
lastPromptNumber: 1,
cumulativeInputTokens: 0,
cumulativeOutputTokens: 0,
pendingProcessingIds: new Set(),
startTime: Date.now()
} as any;
global.fetch = mock(() => Promise.resolve(new Response(JSON.stringify({
candidates: [{
content: {
parts: [{ text: '<observation><type>discovery</type><title>Test</title></observation>' }]
}
}],
usageMetadata: { totalTokenCount: 100 }
}))));
await agent.startSession(session);
expect(global.fetch).toHaveBeenCalledTimes(1);
const url = (global.fetch as any).mock.calls[0][0];
expect(url).toContain('https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:generateContent');
expect(url).toContain('key=test-api-key');
});
it('should handle multi-turn conversation', async () => {
const session = {
sessionDbId: 1,
claudeSessionId: 'test-session',
sdkSessionId: 'test-sdk',
project: 'test-project',
userPrompt: 'test prompt',
conversationHistory: [{ role: 'user', content: 'prev context' }, { role: 'assistant', content: 'prev response' }],
lastPromptNumber: 2,
cumulativeInputTokens: 0,
cumulativeOutputTokens: 0,
pendingProcessingIds: new Set(),
startTime: Date.now()
} as any;
global.fetch = mock(() => Promise.resolve(new Response(JSON.stringify({
candidates: [{ content: { parts: [{ text: 'response' }] } }]
}))));
await agent.startSession(session);
const body = JSON.parse((global.fetch as any).mock.calls[0][1].body);
expect(body.contents).toHaveLength(3);
expect(body.contents[0].role).toBe('user');
expect(body.contents[1].role).toBe('model');
expect(body.contents[2].role).toBe('user');
});
it('should process observations and store them', async () => {
const session = {
sessionDbId: 1,
claudeSessionId: 'test-session',
sdkSessionId: 'test-sdk',
project: 'test-project',
userPrompt: 'test prompt',
conversationHistory: [],
lastPromptNumber: 1,
cumulativeInputTokens: 0,
cumulativeOutputTokens: 0,
pendingProcessingIds: new Set(),
startTime: Date.now()
} as any;
const observationXml = `
<observation>
<type>discovery</type>
<title>Found bug</title>
<subtitle>Null pointer</subtitle>
<narrative>Found a null pointer in the code</narrative>
<facts><fact>Null check missing</fact></facts>
<concepts><concept>bug</concept></concepts>
<files_read><file>src/main.ts</file></files_read>
<files_modified></files_modified>
</observation>
`;
global.fetch = mock(() => Promise.resolve(new Response(JSON.stringify({
candidates: [{ content: { parts: [{ text: observationXml }] } }],
usageMetadata: { totalTokenCount: 50 }
}))));
await agent.startSession(session);
expect(mockStoreObservation).toHaveBeenCalled();
expect(mockSyncObservation).toHaveBeenCalled();
expect(session.cumulativeInputTokens).toBeGreaterThan(0);
});
it('should fallback to Claude on rate limit error', async () => {
const session = {
sessionDbId: 1,
claudeSessionId: 'test-session',
sdkSessionId: 'test-sdk',
project: 'test-project',
userPrompt: 'test prompt',
conversationHistory: [],
lastPromptNumber: 1,
cumulativeInputTokens: 0,
cumulativeOutputTokens: 0,
pendingProcessingIds: new Set(),
startTime: Date.now()
} as any;
global.fetch = mock(() => Promise.resolve(new Response('Resource has been exhausted (e.g. check quota).', { status: 429 })));
const fallbackAgent = {
startSession: mock(() => Promise.resolve())
};
agent.setFallbackAgent(fallbackAgent);
await agent.startSession(session);
expect(fallbackAgent.startSession).toHaveBeenCalledWith(session, undefined);
expect(mockResetStuckMessages).toHaveBeenCalled();
});
it('should NOT fallback on other errors', async () => {
const session = {
sessionDbId: 1,
claudeSessionId: 'test-session',
sdkSessionId: 'test-sdk',
project: 'test-project',
userPrompt: 'test prompt',
conversationHistory: [],
lastPromptNumber: 1,
cumulativeInputTokens: 0,
cumulativeOutputTokens: 0,
pendingProcessingIds: new Set(),
startTime: Date.now()
} as any;
global.fetch = mock(() => Promise.resolve(new Response('Invalid argument', { status: 400 })));
const fallbackAgent = {
startSession: mock(() => Promise.resolve())
};
agent.setFallbackAgent(fallbackAgent);
expect(agent.startSession(session)).rejects.toThrow('Gemini API error: 400 - Invalid argument');
expect(fallbackAgent.startSession).not.toHaveBeenCalled();
});
it('should respect rate limits when billing disabled', async () => {
billingEnabled = 'false';
const originalSetTimeout = global.setTimeout;
const mockSetTimeout = mock((cb: any) => cb());
global.setTimeout = mockSetTimeout as any;
try {
const session = {
sessionDbId: 1,
claudeSessionId: 'test-session',
sdkSessionId: 'test-sdk',
project: 'test-project',
userPrompt: 'test prompt',
conversationHistory: [],
lastPromptNumber: 1,
cumulativeInputTokens: 0,
cumulativeOutputTokens: 0,
pendingProcessingIds: new Set(),
startTime: Date.now()
} as any;
global.fetch = mock(() => Promise.resolve(new Response(JSON.stringify({
candidates: [{ content: { parts: [{ text: 'ok' }] } }]
}))));
await agent.startSession(session);
await agent.startSession(session);
expect(mockSetTimeout).toHaveBeenCalled();
} finally {
global.setTimeout = originalSetTimeout;
}
});
});