Compare commits

...

6 Commits

Author SHA1 Message Date
Alex Newman 93de6d97f5 chore: Bump version to 6.3.1
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 15:47:47 -05:00
Alex Newman 29e6e026b6 feat: Add beta channel for experimental features and update documentation 2025-11-25 15:44:52 -05:00
Alex Newman 1b394cdf4e docs: Update CHANGELOG.md for v6.3.0
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 14:14:25 -05:00
Alex Newman 6ffa4cfde5 chore: Bump version to 6.3.0
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 14:13:32 -05:00
Alex Newman 8caf159d99 feat: Add branch-based beta toggle for switching between stable and beta versions
Adds Version Channel section to Settings sidebar allowing users to:
- See current branch (main or beta/7.0) and stability status
- Switch to beta branch to access Endless Mode features
- Switch back to stable for production use
- Pull updates for current branch

Implementation:
- BranchManager.ts: Git operations for branch detection/switching
- worker-service.ts: /api/branch/* endpoints (status, switch, update)
- Sidebar.tsx: Version Channel UI with branch state and handlers

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 14:12:49 -05:00
Alex Newman e3a63c0294 docs: Update CHANGELOG.md for v6.2.1 2025-11-23 16:20:18 -05:00
16 changed files with 1348 additions and 108 deletions
+1 -1
View File
@@ -10,7 +10,7 @@
"plugins": [
{
"name": "claude-mem",
"version": "6.2.1",
"version": "6.3.1",
"source": "./plugin",
"description": "Persistent memory system for Claude Code - context compression across sessions"
}
+55
View File
@@ -4,6 +4,61 @@ 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/).
## [6.3.0] - 2025-11-25
## What's New
### Branch-Based Beta Toggle
Added Version Channel section to Settings sidebar allowing users to switch between stable and beta versions directly from the UI.
**Features:**
- See current branch (main or beta/7.0) and stability status
- Switch to beta branch to access Endless Mode features
- Switch back to stable for production use
- Pull updates for current branch
**Implementation:**
- `BranchManager.ts`: Git operations for branch detection/switching
- `worker-service.ts`: `/api/branch/*` endpoints (status, switch, update)
- `Sidebar.tsx`: Version Channel UI with branch state and handlers
## Installation
To update, restart Claude Code or run the plugin installer.
## [6.2.1] - 2025-11-23
## 🐛 Bug Fixes
### Critical: Empty Project Names Breaking Context Injection
**Problem:**
- Observations and summaries created with empty project names
- Context-hook couldn't find recent context (queries `WHERE project = 'claude-mem'`)
- Users saw no observations or summaries in SessionStart since Nov 22
**Root Causes:**
1. **Sessions:** `createSDKSession()` used `INSERT OR IGNORE` for idempotency, but never updated project field when session already existed
2. **In-Memory Cache:** `SessionManager` cached sessions with stale empty project values, even after database was updated
**Fixes:**
- `5d23c60` - fix: Update project name when session already exists in createSDKSession
- `54ef149` - fix: Refresh in-memory session project when updated in database
**Impact:**
- ✅ 364 observations backfilled with correct project names
- ✅ 13 summaries backfilled with correct project names
- ✅ Context injection now works (shows recent observations and summaries)
- ✅ Future sessions will always have correct project names
## 📦 Full Changelog
**Commits since v6.2.0:**
- `634033b` - chore: Bump version to 6.2.1
- `54ef149` - fix: Refresh in-memory session project when updated in database
- `5d23c60` - fix: Update project name when session already exists in createSDKSession
## [6.2.0] - 2025-11-22
## Major Features
+1 -1
View File
@@ -6,7 +6,7 @@
Claude-mem is a Claude Code plugin providing persistent memory across sessions. It captures tool usage, compresses observations using the Claude Agent SDK, and injects relevant context into future sessions.
**Current Version**: 6.2.1
**Current Version**: 6.3.1
## Architecture
+41 -1
View File
@@ -17,7 +17,7 @@
<img src="https://img.shields.io/badge/License-AGPL%203.0-blue.svg" alt="License">
</a>
<a href="package.json">
<img src="https://img.shields.io/badge/version-6.0.0-green.svg" alt="Version">
<img src="https://img.shields.io/badge/version-6.3.0-green.svg" alt="Version">
</a>
<a href="package.json">
<img src="https://img.shields.io/badge/node-%3E%3D18.0.0-brightgreen.svg" alt="Node">
@@ -73,6 +73,7 @@ Restart Claude Code. Context from previous sessions will automatically appear in
- 🖥️ **Web Viewer UI** - Real-time memory stream at http://localhost:37777
- 🤖 **Automatic Operation** - No manual intervention required
- 🔗 **Citations** - Reference past decisions with `claude-mem://` URIs
- 🧪 **Beta Channel** - Try experimental features like Endless Mode via version switching
---
@@ -92,6 +93,7 @@ npx mintlify dev
- **[Installation Guide](https://docs.claude-mem.ai/installation)** - Quick start & advanced installation
- **[Usage Guide](https://docs.claude-mem.ai/usage/getting-started)** - How Claude-Mem works automatically
- **[Search Tools](https://docs.claude-mem.ai/usage/search-tools)** - Query your project history with natural language
- **[Beta Features](https://docs.claude-mem.ai/beta-features)** - Try experimental features like Endless Mode
### Best Practices
@@ -189,6 +191,44 @@ See [Search Tools Guide](https://docs.claude-mem.ai/usage/search-tools) for deta
---
## Beta Features & Endless Mode
Claude-Mem offers a **beta channel** with experimental features. Switch between stable and beta versions directly from the web viewer UI.
### How to Try Beta
1. Open http://localhost:37777
2. Click Settings (gear icon)
3. In **Version Channel**, click "Try Beta (Endless Mode)"
4. Wait for the worker to restart
Your memory data is preserved when switching versions.
### Endless Mode (Beta)
The flagship beta feature is **Endless Mode** - a biomimetic memory architecture that dramatically extends session length:
**The Problem**: Standard Claude Code sessions hit context limits after ~50 tool uses. Each tool adds 1-10k+ tokens, and Claude re-synthesizes all previous outputs on every response (O(N²) complexity).
**The Solution**: Endless Mode compresses tool outputs into ~500-token observations and transforms the transcript in real-time:
```
Working Memory (Context): Compressed observations (~500 tokens each)
Archive Memory (Disk): Full tool outputs preserved for recall
```
**Expected Results**:
- ~95% token reduction in context window
- ~20x more tool uses before context exhaustion
- Linear O(N) scaling instead of quadratic O(N²)
- Full transcripts preserved for perfect recall
**Caveats**: Adds latency (60-90s per tool for observation generation), still experimental.
See [Beta Features Documentation](https://docs.claude-mem.ai/beta-features) for details.
---
## What's New in v6.0.0
**🚀 Major Session Management & Transcript Processing Improvements:**
+144
View File
@@ -0,0 +1,144 @@
---
title: "Beta Features"
description: "Try experimental features like Endless Mode before they're released"
---
# Beta Features
Claude-Mem offers a beta channel for users who want to try experimental features before they're released to the stable channel.
## Version Channel Switching
You can switch between stable and beta versions directly from the web viewer UI at http://localhost:37777.
### How to Access
1. Open the Claude-Mem viewer at http://localhost:37777
2. Click the **Settings** gear icon in the top-right
3. Find the **Version Channel** section
4. Click **Try Beta (Endless Mode)** to switch to beta, or **Switch to Stable** to return
### What Happens When You Switch
When switching versions:
1. **Local changes are discarded** - Any modifications in the plugin directory are reset
2. **Git fetch and checkout** - The installed plugin switches to the target branch
3. **Dependencies reinstall** - `npm install` runs to ensure correct dependencies
4. **Worker restarts automatically** - The background service restarts with the new version
**Your memory data is always preserved.** The database at `~/.claude-mem/claude-mem.db` is not affected by version switching. All your observations, sessions, and summaries remain intact.
### Version Indicators
The Version Channel section shows your current status:
- **Stable** (green badge) - You're running the production release
- **Beta** (orange badge) - You're running the beta with experimental features
You'll also see the exact branch name (e.g., `main` for stable, `beta/7.0` for beta).
## Endless Mode (Beta)
The flagship experimental feature in beta is **Endless Mode** - a biomimetic memory architecture that dramatically extends how long Claude can maintain context in a session.
### The Problem Endless Mode Solves
In standard Claude Code sessions:
- Tool outputs (file reads, bash output, search results) accumulate in the context window
- Each tool can add 1-10k+ tokens to the context
- After ~50 tool uses, the context window fills up (~200k tokens)
- You're forced to start a new session, losing conversational continuity
Worse, Claude **re-synthesizes all previous tool outputs** on every response. This is O(N²) complexity - quadratically growing both in tokens and compute.
### How Endless Mode Works
Endless Mode applies a biomimetic memory architecture inspired by how human memory works:
**Two-Tier Memory System:**
```
Working Memory (Context Window):
→ Compressed observations only (~500 tokens each)
→ Fast, efficient, manageable
Archive Memory (Transcript File):
→ Full tool outputs preserved on disk
→ Perfect recall, searchable
```
**The Key Innovation**: After each tool use, Endless Mode:
1. Waits for the worker to generate a compressed observation (blocking)
2. Transforms the transcript file on disk
3. Replaces the full tool output with the compressed observation
4. Claude resumes with the compressed context
This transforms O(N²) scaling into O(N) - linear instead of quadratic.
### Expected Results
Based on analysis of real sessions:
- **Token savings**: ~95% reduction in context window usage
- **Efficiency gain**: ~20x more tool uses before context exhaustion
- **Quality preservation**: Observations cache the synthesis result, so no information is lost
### Caveats
Endless Mode is experimental:
- **Adds latency** - Blocking hooks wait for observation generation (60-90s per tool use)
- **Requires working database** - Observations must save successfully for transformation
- **New architecture** - Less battle-tested than standard mode
### When to Use Beta
Consider switching to beta if you:
- Frequently hit context window limits
- Work on long, complex sessions with many tool uses
- Want to help test and provide feedback on new features
- Are comfortable with experimental software
### When to Stay on Stable
Stay on stable if you:
- Need maximum reliability for critical work
- Prefer battle-tested, production-ready features
- Don't frequently hit context limits
- Want the smoothest, fastest experience
## Checking for Updates
While on beta (or stable), you can check for updates:
1. Open Settings in the viewer
2. In the Version Channel section, click **Check for Updates**
3. The plugin will pull the latest changes and restart
## Switching Back
If you encounter issues on beta:
1. Open Settings in the viewer
2. Click **Switch to Stable**
3. Wait for the worker to restart
Your memory data is preserved, and you'll be back on the stable release.
## Providing Feedback
If you encounter bugs or have feedback about beta features:
- Open an issue at [GitHub Issues](https://github.com/thedotmack/claude-mem/issues)
- Include your branch (`beta/7.0` etc.) in the report
- Describe what you expected vs. what happened
## Next Steps
- [Configuration](configuration) - Customize other Claude-Mem settings
- [Troubleshooting](troubleshooting) - Common issues and solutions
- [Architecture Overview](architecture/overview) - Understand how Claude-Mem works
+20
View File
@@ -151,6 +151,26 @@ Search operations are now provided via:
- **HTTP API**: 10 endpoints on worker service port 37777
- **Progressive Disclosure**: Full instructions loaded on-demand only when needed
## Version Channel
Claude-Mem supports switching between stable and beta versions via the web viewer UI.
### Accessing Version Channel
1. Open the viewer at http://localhost:37777
2. Click the Settings gear icon
3. Find the **Version Channel** section
### Switching Versions
- **Try Beta**: Click "Try Beta (Endless Mode)" to switch to the beta branch with experimental features
- **Switch to Stable**: Click "Switch to Stable" to return to the production release
- **Check for Updates**: Pull the latest changes for your current branch
**Your memory data is preserved** when switching versions. Only the plugin code changes.
See [Beta Features](beta-features) for details on what's available in beta.
## PM2 Configuration
Worker service is managed by PM2 via `ecosystem.config.cjs`:
+2 -1
View File
@@ -36,7 +36,8 @@
"introduction",
"installation",
"usage/getting-started",
"usage/search-tools"
"usage/search-tools",
"beta-features"
]
},
{
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "claude-mem",
"version": "6.2.1",
"version": "6.3.1",
"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": "6.2.1",
"version": "6.3.1",
"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
File diff suppressed because one or more lines are too long
+410
View File
@@ -0,0 +1,410 @@
#!/usr/bin/env node
import fs from 'fs';
import Database from 'better-sqlite3';
import readline from 'readline';
import path from 'path';
import { homedir } from 'os';
import { globSync } from 'glob';
// =============================================================================
// TOOL REPLACEMENT DECISION TABLE
// =============================================================================
//
// KEY INSIGHT: Observations are the SEMANTIC SYNTHESIS of tool results.
// They contain what Claude LEARNED, which is what future Claude needs.
//
// Tool | Replace OUTPUT? | Reason
// ------------------|-----------------|----------------------------------------
// Read | ✅ YES | Observation = what was learned from file
// Bash | ✅ YES | Observation = what command revealed
// Grep | ✅ YES | Observation = what search found
// Task | ✅ YES | Observation = what agent discovered
// WebFetch | ✅ YES | Observation = what page contained
// Glob | ⚠️ MAYBE | File lists are often small already
// WebSearch | ⚠️ MAYBE | Results are moderate size
// Edit | ❌ NO | OUTPUT is tiny ("success"), INPUT is ground truth
// Write | ❌ NO | OUTPUT is tiny, INPUT is the file content
// NotebookEdit | ❌ NO | OUTPUT is tiny, INPUT is the code
// TodoWrite | ❌ NO | Both tiny
// AskUserQuestion | ❌ NO | Both small, user input matters
// mcp__* | ⚠️ MAYBE | Varies by tool
//
// NEVER REPLACE INPUT - it contains the action (diff, command, query, path)
// ONLY REPLACE OUTPUT - swap raw results for semantic synthesis (observation)
//
// REPLACEMENT FORMAT:
// Original output gets replaced with:
// "[Strategically Omitted by Claude-Mem to save tokens]
//
// [Observation: Title here]
// Facts: ...
// Concepts: ..."
// =============================================================================
// Configuration
const DB_PATH = path.join(homedir(), '.claude-mem', 'claude-mem.db');
const MAX_TRANSCRIPTS = parseInt(process.env.MAX_TRANSCRIPTS || '500', 10);
// Find transcript files (most recent first)
const TRANSCRIPT_DIR = path.join(homedir(), '.claude/projects/-Users-alexnewman-Scripts-claude-mem');
const allTranscriptFiles = globSync(path.join(TRANSCRIPT_DIR, '*.jsonl'));
// Sort by modification time (most recent first), take MAX_TRANSCRIPTS
const transcriptFiles = allTranscriptFiles
.map(f => ({ path: f, mtime: fs.statSync(f).mtime }))
.sort((a, b) => b.mtime - a.mtime)
.slice(0, MAX_TRANSCRIPTS)
.map(f => f.path);
console.log(`Config: MAX_TRANSCRIPTS=${MAX_TRANSCRIPTS}`);
console.log(`Using ${transcriptFiles.length} most recent transcript files (of ${allTranscriptFiles.length} total)\n`);
// Map to store original content from transcript (both inputs and outputs)
const originalContent = new Map();
// Track contaminated (already transformed) transcripts
let skippedTranscripts = 0;
// Marker for already-transformed content (endless mode replacement format)
const TRANSFORMATION_MARKER = '**Key Facts:**';
// Auto-discover agent transcripts linked to main session
async function discoverAgentFiles(mainTranscriptPath) {
console.log('Discovering linked agent transcripts...');
const agentIds = new Set();
const fileStream = fs.createReadStream(mainTranscriptPath);
const rl = readline.createInterface({
input: fileStream,
crlfDelay: Infinity
});
for await (const line of rl) {
if (!line.includes('agentId')) continue;
try {
const obj = JSON.parse(line);
// Check for agentId in toolUseResult
if (obj.toolUseResult?.agentId) {
agentIds.add(obj.toolUseResult.agentId);
}
} catch (e) {
// Skip malformed lines
}
}
// Build agent file paths
const directory = path.dirname(mainTranscriptPath);
const agentFiles = Array.from(agentIds).map(id =>
path.join(directory, `agent-${id}.jsonl`)
).filter(filePath => fs.existsSync(filePath));
console.log(` → Found ${agentIds.size} agent IDs`);
console.log(`${agentFiles.length} agent files exist on disk\n`);
return agentFiles;
}
// Parse transcript to get BOTH tool_use (inputs) and tool_result (outputs) content
// Returns true if transcript is clean, false if contaminated (already transformed)
async function loadOriginalContentFromFile(filePath, fileLabel) {
const fileStream = fs.createReadStream(filePath);
const rl = readline.createInterface({
input: fileStream,
crlfDelay: Infinity
});
let count = 0;
let isContaminated = false;
const toolUseIdsFromThisFile = new Set();
for await (const line of rl) {
if (!line.includes('toolu_')) continue;
try {
const obj = JSON.parse(line);
if (obj.message?.content) {
for (const item of obj.message.content) {
// Capture tool_use (inputs)
if (item.type === 'tool_use' && item.id) {
const existing = originalContent.get(item.id) || { input: '', output: '', name: '' };
existing.input = JSON.stringify(item.input || {});
existing.name = item.name;
originalContent.set(item.id, existing);
toolUseIdsFromThisFile.add(item.id);
count++;
}
// Capture tool_result (outputs)
if (item.type === 'tool_result' && item.tool_use_id) {
const content = typeof item.content === 'string' ? item.content : JSON.stringify(item.content);
// Check for transformation marker - if found, transcript is contaminated
if (content.includes(TRANSFORMATION_MARKER)) {
isContaminated = true;
}
const existing = originalContent.get(item.tool_use_id) || { input: '', output: '', name: '' };
existing.output = content;
originalContent.set(item.tool_use_id, existing);
toolUseIdsFromThisFile.add(item.tool_use_id);
}
}
}
} catch (e) {
// Skip malformed lines
}
}
// If contaminated, remove all data from this file and report
if (isContaminated) {
for (const id of toolUseIdsFromThisFile) {
originalContent.delete(id);
}
console.log(` ⚠️ Skipped ${fileLabel} (already transformed)`);
return false;
}
if (count > 0) {
console.log(` → Found ${count} tool uses in ${fileLabel}`);
}
return true;
}
async function loadOriginalContent() {
console.log('Loading original content from transcripts...');
console.log(` → Scanning ${transcriptFiles.length} transcript files...\n`);
let cleanTranscripts = 0;
// Load from all transcript files
for (const transcriptFile of transcriptFiles) {
const filename = path.basename(transcriptFile);
const isClean = await loadOriginalContentFromFile(transcriptFile, filename);
if (isClean) {
cleanTranscripts++;
} else {
skippedTranscripts++;
}
}
// Also check for any agent files not already included
for (const transcriptFile of transcriptFiles) {
if (transcriptFile.includes('agent-')) continue; // Already an agent file
const agentFiles = await discoverAgentFiles(transcriptFile);
for (const agentFile of agentFiles) {
if (transcriptFiles.includes(agentFile)) continue; // Already processed
const filename = path.basename(agentFile);
const isClean = await loadOriginalContentFromFile(agentFile, `agent transcript (${filename})`);
if (!isClean) {
skippedTranscripts++;
}
}
}
console.log(`\nTotal: Loaded original content for ${originalContent.size} tool uses (inputs + outputs)`);
if (skippedTranscripts > 0) {
console.log(`⚠️ Skipped ${skippedTranscripts} transcripts (already transformed with endless mode)`);
}
console.log();
}
// Strip __N suffix from tool_use_id to get base ID
function getBaseToolUseId(id) {
return id ? id.replace(/__\d+$/, '') : id;
}
// Query observations from database using tool_use_ids found in transcripts
// Handles suffixed IDs like toolu_abc__1, toolu_abc__2 matching transcript's toolu_abc
function queryObservations() {
// Get tool_use_ids from the loaded transcript content
const toolUseIds = Array.from(originalContent.keys());
if (toolUseIds.length === 0) {
console.log('No tool use IDs found in transcripts\n');
return [];
}
console.log(`Querying observations for ${toolUseIds.length} tool use IDs from transcripts...`);
const db = new Database(DB_PATH, { readonly: true });
// Build LIKE clauses to match both exact IDs and suffixed variants (toolu_abc, toolu_abc__1, etc)
const likeConditions = toolUseIds.map(() => 'tool_use_id LIKE ?').join(' OR ');
const likeParams = toolUseIds.map(id => `${id}%`);
const query = `
SELECT
id,
tool_use_id,
type,
narrative,
title,
facts,
concepts,
LENGTH(COALESCE(facts,'')) as facts_len,
LENGTH(COALESCE(title,'')) + LENGTH(COALESCE(facts,'')) as title_facts_len,
LENGTH(COALESCE(title,'')) + LENGTH(COALESCE(facts,'')) + LENGTH(COALESCE(concepts,'')) as compact_len,
LENGTH(COALESCE(narrative,'')) as narrative_len,
LENGTH(COALESCE(title,'')) + LENGTH(COALESCE(narrative,'')) + LENGTH(COALESCE(facts,'')) + LENGTH(COALESCE(concepts,'')) as full_obs_len
FROM observations
WHERE ${likeConditions}
ORDER BY created_at DESC
`;
const observations = db.prepare(query).all(...likeParams);
db.close();
console.log(`Found ${observations.length} observations matching tool use IDs (including suffixed variants)\n`);
return observations;
}
// Tools eligible for OUTPUT replacement (observation = semantic synthesis of result)
const REPLACEABLE_TOOLS = new Set(['Read', 'Bash', 'Grep', 'Task', 'WebFetch', 'Glob', 'WebSearch']);
// Analyze OUTPUT-only replacement for eligible tools
function analyzeTransformations(observations) {
console.log('='.repeat(110));
console.log('OUTPUT REPLACEMENT ANALYSIS (Eligible Tools Only)');
console.log('='.repeat(110));
console.log();
console.log('Eligible tools:', Array.from(REPLACEABLE_TOOLS).join(', '));
console.log();
// Group observations by BASE tool_use_id (strip __N suffix)
// This groups toolu_abc, toolu_abc__1, toolu_abc__2 together
const obsByToolId = new Map();
observations.forEach(obs => {
const baseId = getBaseToolUseId(obs.tool_use_id);
if (!obsByToolId.has(baseId)) {
obsByToolId.set(baseId, []);
}
obsByToolId.get(baseId).push(obs);
});
// Define strategies to test
const strategies = [
{ name: 'facts_only', field: 'facts_len', desc: 'Facts only (~400 chars)' },
{ name: 'title_facts', field: 'title_facts_len', desc: 'Title + Facts (~450 chars)' },
{ name: 'compact', field: 'compact_len', desc: 'Title + Facts + Concepts (~500 chars)' },
{ name: 'narrative', field: 'narrative_len', desc: 'Narrative only (~700 chars)' },
{ name: 'full', field: 'full_obs_len', desc: 'Full observation (~1200 chars)' }
];
// Track results per strategy
const results = {};
strategies.forEach(s => {
results[s.name] = {
transforms: 0,
noTransform: 0,
saved: 0,
totalOriginal: 0
};
});
// Track stats
let eligible = 0;
let ineligible = 0;
let noTranscript = 0;
const toolCounts = {};
// Analyze each tool use
obsByToolId.forEach((obsArray, toolUseId) => {
const original = originalContent.get(toolUseId);
const toolName = original?.name || 'unknown';
const outputLen = original?.output?.length || 0;
// Skip if no transcript data
if (!original || outputLen === 0) {
noTranscript++;
return;
}
// Skip if tool not eligible for replacement
if (!REPLACEABLE_TOOLS.has(toolName)) {
ineligible++;
return;
}
eligible++;
toolCounts[toolName] = (toolCounts[toolName] || 0) + 1;
// Sum lengths across ALL observations for this tool use (handles multiple obs per tool_use_id)
// Test each strategy - OUTPUT replacement only
strategies.forEach(strategy => {
const obsLen = obsArray.reduce((sum, obs) => sum + (obs[strategy.field] || 0), 0);
const r = results[strategy.name];
r.totalOriginal += outputLen;
if (obsLen > 0 && obsLen < outputLen) {
r.transforms++;
r.saved += (outputLen - obsLen);
} else {
r.noTransform++;
}
});
});
// Print results
console.log('TOOL BREAKDOWN:');
Object.entries(toolCounts).sort((a, b) => b[1] - a[1]).forEach(([tool, count]) => {
console.log(` ${tool}: ${count}`);
});
console.log();
console.log('-'.repeat(100));
console.log(`Eligible tool uses: ${eligible}`);
console.log(`Ineligible (Edit/Write/etc): ${ineligible}`);
console.log(`No transcript data: ${noTranscript}`);
console.log('-'.repeat(100));
console.log();
console.log('Strategy Transforms No Transform Chars Saved Original Size Savings %');
console.log('-'.repeat(100));
strategies.forEach(strategy => {
const r = results[strategy.name];
const pct = r.totalOriginal > 0 ? ((r.saved / r.totalOriginal) * 100).toFixed(1) : '0.0';
console.log(
`${strategy.desc.padEnd(35)} ${String(r.transforms).padStart(10)} ${String(r.noTransform).padStart(12)} ${String(r.saved.toLocaleString()).padStart(13)} ${String(r.totalOriginal.toLocaleString()).padStart(15)} ${pct.padStart(8)}%`
);
});
console.log('-'.repeat(100));
console.log();
// Find best strategy
let bestStrategy = null;
let bestSavings = 0;
strategies.forEach(strategy => {
if (results[strategy.name].saved > bestSavings) {
bestSavings = results[strategy.name].saved;
bestStrategy = strategy;
}
});
if (bestStrategy) {
const r = results[bestStrategy.name];
const pct = ((r.saved / r.totalOriginal) * 100).toFixed(1);
console.log(`BEST STRATEGY: ${bestStrategy.desc}`);
console.log(` - Transforms ${r.transforms} of ${eligible} eligible tool uses (${((r.transforms/eligible)*100).toFixed(1)}%)`);
console.log(` - Saves ${r.saved.toLocaleString()} of ${r.totalOriginal.toLocaleString()} chars (${pct}% reduction)`);
}
console.log();
}
// Main execution
async function main() {
await loadOriginalContent();
const observations = queryObservations();
analyzeTransformations(observations);
}
main().catch(error => {
console.error('Fatal error:', error);
process.exit(1);
});
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env node
/**
* Protected sync-marketplace script
*
* Prevents accidental rsync overwrite when installed plugin is on beta branch.
* If on beta, the user should use the UI to update instead.
*/
const { execSync } = require('child_process');
const { existsSync } = require('fs');
const path = require('path');
const os = require('os');
const INSTALLED_PATH = path.join(os.homedir(), '.claude', 'plugins', 'marketplaces', 'thedotmack');
function getCurrentBranch() {
try {
if (!existsSync(path.join(INSTALLED_PATH, '.git'))) {
return null;
}
return execSync('git rev-parse --abbrev-ref HEAD', {
cwd: INSTALLED_PATH,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe']
}).trim();
} catch {
return null;
}
}
const branch = getCurrentBranch();
if (branch && branch !== 'main') {
console.log('');
console.log('\x1b[33m%s\x1b[0m', `WARNING: Installed plugin is on beta branch: ${branch}`);
console.log('\x1b[33m%s\x1b[0m', 'Running rsync would overwrite beta code.');
console.log('');
console.log('Options:');
console.log(' 1. Use UI at http://localhost:37777 to update beta');
console.log(' 2. Switch to stable in UI first, then run sync');
console.log(' 3. Force rsync: npm run sync-marketplace:force');
console.log('');
process.exit(1);
}
// Normal rsync for main branch or fresh install
console.log('Syncing to marketplace...');
try {
execSync(
'rsync -av --delete --exclude=.git ./ ~/.claude/plugins/marketplaces/thedotmack/',
{ stdio: 'inherit' }
);
console.log('Running npm install in marketplace...');
execSync(
'cd ~/.claude/plugins/marketplaces/thedotmack/ && npm install',
{ stdio: 'inherit' }
);
console.log('\x1b[32m%s\x1b[0m', 'Sync complete!');
} catch (error) {
console.error('\x1b[31m%s\x1b[0m', 'Sync failed:', error.message);
process.exit(1);
}
+89
View File
@@ -29,6 +29,7 @@ import { SSEBroadcaster } from './worker/SSEBroadcaster.js';
import { SDKAgent } from './worker/SDKAgent.js';
import { PaginationHelper } from './worker/PaginationHelper.js';
import { SettingsManager } from './worker/SettingsManager.js';
import { getBranchInfo, switchBranch, pullUpdates, type BranchInfo, type SwitchResult } from './worker/BranchManager.js';
export class WorkerService {
private app: express.Application;
@@ -173,6 +174,11 @@ export class WorkerService {
this.app.get('/api/mcp/status', this.handleGetMcpStatus.bind(this));
this.app.post('/api/mcp/toggle', this.handleToggleMcp.bind(this));
// Branch switching (beta toggle)
this.app.get('/api/branch/status', this.handleGetBranchStatus.bind(this));
this.app.post('/api/branch/switch', this.handleSwitchBranch.bind(this));
this.app.post('/api/branch/update', this.handleUpdateBranch.bind(this));
// Search API endpoints (for skill-based search)
// Unified endpoints (new consolidated API)
this.app.get('/api/search', this.handleUnifiedSearch.bind(this));
@@ -1035,6 +1041,89 @@ export class WorkerService {
}
}
// ============================================================================
// Branch Switching Handlers (Beta Toggle)
// ============================================================================
/**
* GET /api/branch/status - Get current branch information
*/
private handleGetBranchStatus(req: Request, res: Response): void {
try {
const info = getBranchInfo();
res.json(info);
} catch (error) {
logger.failure('WORKER', 'Failed to get branch status', {}, error as Error);
res.status(500).json({ error: (error as Error).message });
}
}
/**
* POST /api/branch/switch - Switch to a different branch
* Body: { branch: "main" | "beta/7.0" }
*/
private async handleSwitchBranch(req: Request, res: Response): Promise<void> {
try {
const { branch } = req.body;
if (!branch) {
res.status(400).json({ success: false, error: 'Missing branch parameter' });
return;
}
// Validate branch name
const allowedBranches = ['main', 'beta/7.0'];
if (!allowedBranches.includes(branch)) {
res.status(400).json({
success: false,
error: `Invalid branch. Allowed: ${allowedBranches.join(', ')}`
});
return;
}
logger.info('WORKER', 'Branch switch requested', { branch });
const result = await switchBranch(branch);
if (result.success) {
// Schedule worker restart after response is sent
setTimeout(() => {
logger.info('WORKER', 'Restarting worker after branch switch');
process.exit(0); // PM2 will restart the worker
}, 1000);
}
res.json(result);
} catch (error) {
logger.failure('WORKER', 'Branch switch failed', {}, error as Error);
res.status(500).json({ success: false, error: (error as Error).message });
}
}
/**
* POST /api/branch/update - Pull latest updates for current branch
*/
private async handleUpdateBranch(req: Request, res: Response): Promise<void> {
try {
logger.info('WORKER', 'Branch update requested');
const result = await pullUpdates();
if (result.success) {
// Schedule worker restart after response is sent
setTimeout(() => {
logger.info('WORKER', 'Restarting worker after branch update');
process.exit(0); // PM2 will restart the worker
}, 1000);
}
res.json(result);
} catch (error) {
logger.failure('WORKER', 'Branch update failed', {}, error as Error);
res.status(500).json({ success: false, error: (error as Error).message });
}
}
// ============================================================================
// Search API Handlers (for skill-based search)
// ============================================================================
+247
View File
@@ -0,0 +1,247 @@
/**
* BranchManager: Git branch detection and switching for beta feature toggle
*
* Enables users to switch between stable (main) and beta branches via the UI.
* The installed plugin at ~/.claude/plugins/marketplaces/thedotmack/ is a git repo.
*/
import { execSync } from 'child_process';
import { existsSync, unlinkSync } from 'fs';
import { homedir } from 'os';
import { join } from 'path';
import { logger } from '../../utils/logger.js';
const INSTALLED_PLUGIN_PATH = join(homedir(), '.claude', 'plugins', 'marketplaces', 'thedotmack');
export interface BranchInfo {
branch: string | null;
isBeta: boolean;
isGitRepo: boolean;
isDirty: boolean;
canSwitch: boolean;
error?: string;
}
export interface SwitchResult {
success: boolean;
branch?: string;
message?: string;
error?: string;
}
/**
* Execute git command in installed plugin directory
*/
function execGit(command: string): string {
return execSync(`git ${command}`, {
cwd: INSTALLED_PLUGIN_PATH,
encoding: 'utf-8',
timeout: 30000
}).trim();
}
/**
* Execute shell command in installed plugin directory
*/
function execShell(command: string, timeoutMs: number = 60000): string {
return execSync(command, {
cwd: INSTALLED_PLUGIN_PATH,
encoding: 'utf-8',
timeout: timeoutMs
}).trim();
}
/**
* Get current branch information
*/
export function getBranchInfo(): BranchInfo {
// Check if git repo exists
const gitDir = join(INSTALLED_PLUGIN_PATH, '.git');
if (!existsSync(gitDir)) {
return {
branch: null,
isBeta: false,
isGitRepo: false,
isDirty: false,
canSwitch: false,
error: 'Installed plugin is not a git repository'
};
}
try {
// Get current branch
const branch = execGit('rev-parse --abbrev-ref HEAD');
// Check if dirty (has uncommitted changes)
const status = execGit('status --porcelain');
const isDirty = status.length > 0;
// Determine if on beta branch
const isBeta = branch.startsWith('beta');
return {
branch,
isBeta,
isGitRepo: true,
isDirty,
canSwitch: true // We can always switch (will discard local changes)
};
} catch (error) {
logger.error('BRANCH', 'Failed to get branch info', {}, error as Error);
return {
branch: null,
isBeta: false,
isGitRepo: true,
isDirty: false,
canSwitch: false,
error: (error as Error).message
};
}
}
/**
* Switch to a different branch
*
* Steps:
* 1. Discard local changes (from rsync syncs)
* 2. Fetch latest from origin
* 3. Checkout target branch
* 4. Pull latest
* 5. Clear install marker and run npm install
* 6. Restart worker (handled by caller after response)
*/
export async function switchBranch(targetBranch: string): Promise<SwitchResult> {
const info = getBranchInfo();
if (!info.isGitRepo) {
return {
success: false,
error: 'Installed plugin is not a git repository. Please reinstall.'
};
}
if (info.branch === targetBranch) {
return {
success: true,
branch: targetBranch,
message: `Already on branch ${targetBranch}`
};
}
try {
logger.info('BRANCH', 'Starting branch switch', {
from: info.branch,
to: targetBranch
});
// 1. Discard local changes (safe - user data is at ~/.claude-mem/)
logger.debug('BRANCH', 'Discarding local changes');
execGit('checkout -- .');
execGit('clean -fd'); // Remove untracked files too
// 2. Fetch latest
logger.debug('BRANCH', 'Fetching from origin');
execGit('fetch origin');
// 3. Checkout target branch
logger.debug('BRANCH', 'Checking out branch', { branch: targetBranch });
try {
execGit(`checkout ${targetBranch}`);
} catch {
// Branch might not exist locally, try tracking remote
execGit(`checkout -b ${targetBranch} origin/${targetBranch}`);
}
// 4. Pull latest
logger.debug('BRANCH', 'Pulling latest');
execGit(`pull origin ${targetBranch}`);
// 5. Clear install marker and run npm install
const installMarker = join(INSTALLED_PLUGIN_PATH, '.install-version');
if (existsSync(installMarker)) {
unlinkSync(installMarker);
}
logger.debug('BRANCH', 'Running npm install');
execShell('npm install', 120000); // 2 minute timeout for npm
logger.success('BRANCH', 'Branch switch complete', {
branch: targetBranch
});
return {
success: true,
branch: targetBranch,
message: `Switched to ${targetBranch}. Worker will restart automatically.`
};
} catch (error) {
logger.error('BRANCH', 'Branch switch failed', { targetBranch }, error as Error);
// Try to recover by checking out original branch
try {
if (info.branch) {
execGit(`checkout ${info.branch}`);
}
} catch {
// Recovery failed, user needs manual intervention
}
return {
success: false,
error: `Branch switch failed: ${(error as Error).message}`
};
}
}
/**
* Pull latest updates for current branch
*/
export async function pullUpdates(): Promise<SwitchResult> {
const info = getBranchInfo();
if (!info.isGitRepo || !info.branch) {
return {
success: false,
error: 'Cannot pull updates: not a git repository'
};
}
try {
logger.info('BRANCH', 'Pulling updates', { branch: info.branch });
// Discard local changes first
execGit('checkout -- .');
// Fetch and pull
execGit('fetch origin');
execGit(`pull origin ${info.branch}`);
// Clear install marker and reinstall
const installMarker = join(INSTALLED_PLUGIN_PATH, '.install-version');
if (existsSync(installMarker)) {
unlinkSync(installMarker);
}
execShell('npm install', 120000);
logger.success('BRANCH', 'Updates pulled', { branch: info.branch });
return {
success: true,
branch: info.branch,
message: `Updated ${info.branch}. Worker will restart automatically.`
};
} catch (error) {
logger.error('BRANCH', 'Pull failed', {}, error as Error);
return {
success: false,
error: `Pull failed: ${(error as Error).message}`
};
}
}
/**
* Get installed plugin path (for external use)
*/
export function getInstalledPluginPath(): string {
return INSTALLED_PLUGIN_PATH;
}
+170
View File
@@ -26,6 +26,19 @@ export function Sidebar({ isOpen, settings, stats, isSaving, saveStatus, isConne
const [mcpToggling, setMcpToggling] = useState(false);
const [mcpStatus, setMcpStatus] = useState('');
// Branch switching state
interface BranchInfo {
branch: string | null;
isBeta: boolean;
isGitRepo: boolean;
isDirty: boolean;
canSwitch: boolean;
error?: string;
}
const [branchInfo, setBranchInfo] = useState<BranchInfo | null>(null);
const [branchSwitching, setBranchSwitching] = useState(false);
const [branchStatus, setBranchStatus] = useState('');
// Update settings form state when settings change
useEffect(() => {
setModel(settings.CLAUDE_MEM_MODEL || DEFAULT_SETTINGS.CLAUDE_MEM_MODEL);
@@ -41,6 +54,14 @@ export function Sidebar({ isOpen, settings, stats, isSaving, saveStatus, isConne
.catch(error => console.error('Failed to load MCP status:', error));
}, []);
// Fetch branch status on mount
useEffect(() => {
fetch('/api/branch/status')
.then(res => res.json())
.then(data => setBranchInfo(data))
.catch(error => console.error('Failed to load branch status:', error));
}, []);
// Refresh stats when sidebar opens
useEffect(() => {
if (isOpen) {
@@ -85,6 +106,67 @@ export function Sidebar({ isOpen, settings, stats, isSaving, saveStatus, isConne
}
};
const handleBranchSwitch = async (targetBranch: string) => {
setBranchSwitching(true);
setBranchStatus('Switching branches...');
try {
const response = await fetch('/api/branch/switch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ branch: targetBranch })
});
const result = await response.json();
if (result.success) {
setBranchStatus(`${result.message}`);
// Worker will restart, page will refresh
setTimeout(() => {
setBranchStatus('Restarting worker...');
}, 1000);
} else {
setBranchStatus(`✗ Error: ${result.error}`);
setTimeout(() => setBranchStatus(''), 5000);
setBranchSwitching(false);
}
} catch (error) {
setBranchStatus(`✗ Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
setTimeout(() => setBranchStatus(''), 5000);
setBranchSwitching(false);
}
};
const handleBranchUpdate = async () => {
setBranchSwitching(true);
setBranchStatus('Checking for updates...');
try {
const response = await fetch('/api/branch/update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
const result = await response.json();
if (result.success) {
setBranchStatus(`${result.message}`);
// Worker will restart, page will refresh
setTimeout(() => {
setBranchStatus('Restarting worker...');
}, 1000);
} else {
setBranchStatus(`✗ Error: ${result.error}`);
setTimeout(() => setBranchStatus(''), 5000);
setBranchSwitching(false);
}
} catch (error) {
setBranchStatus(`✗ Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
setTimeout(() => setBranchStatus(''), 5000);
setBranchSwitching(false);
}
};
return (
<div className={`sidebar ${isOpen ? 'open' : ''}`}>
<div className="sidebar-header">
@@ -193,6 +275,94 @@ export function Sidebar({ isOpen, settings, stats, isSaving, saveStatus, isConne
</div>
</div>
<div className="settings-section">
<h3>Version Channel</h3>
<div className="form-group">
{branchInfo ? (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px' }}>
<span style={{
padding: '2px 8px',
borderRadius: '4px',
fontSize: '11px',
fontWeight: 600,
background: branchInfo.isBeta ? '#6b4500' : '#1a4d1a',
color: branchInfo.isBeta ? '#ffb84d' : '#4ade80',
border: `1px solid ${branchInfo.isBeta ? '#ffb84d' : '#4ade80'}`
}}>
{branchInfo.isBeta ? 'Beta' : 'Stable'}
</span>
<span style={{ fontSize: '12px', opacity: 0.7 }}>
{branchInfo.branch || 'main'}
</span>
</div>
{branchInfo.isBeta ? (
<>
<div className="setting-description" style={{ marginBottom: '12px' }}>
You're running the beta with Endless Mode. Your memory data is preserved when switching versions.
</div>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<button
onClick={() => handleBranchSwitch('main')}
disabled={branchSwitching}
style={{
background: '#2a2a2a',
border: '1px solid #404040',
padding: '8px 16px',
cursor: branchSwitching ? 'not-allowed' : 'pointer',
opacity: branchSwitching ? 0.5 : 1
}}
>
Switch to Stable
</button>
<button
onClick={handleBranchUpdate}
disabled={branchSwitching}
style={{
background: '#2a2a2a',
border: '1px solid #404040',
padding: '8px 16px',
cursor: branchSwitching ? 'not-allowed' : 'pointer',
opacity: branchSwitching ? 0.5 : 1
}}
>
Check for Updates
</button>
</div>
</>
) : (
<>
<div className="setting-description" style={{ marginBottom: '12px' }}>
Try the beta to access experimental features like Endless Mode. Your memory data is preserved when switching.
</div>
<button
onClick={() => handleBranchSwitch('beta/7.0')}
disabled={branchSwitching}
style={{
background: '#4a3500',
border: '1px solid #ffb84d',
color: '#ffb84d',
padding: '8px 16px',
cursor: branchSwitching ? 'not-allowed' : 'pointer',
opacity: branchSwitching ? 0.5 : 1
}}
>
Try Beta (Endless Mode)
</button>
</>
)}
{branchStatus && (
<div className="save-status" style={{ marginTop: '8px' }}>{branchStatus}</div>
)}
</>
) : (
<div style={{ fontSize: '12px', opacity: 0.5 }}>Loading branch info...</div>
)}
</div>
</div>
<div className="settings-section">
<h3>Worker Stats</h3>
<div className="stats-grid">