Compare commits

...

9 Commits

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 16:38:06 -05:00
Alex Newman 4e5913611a feat(search-server): enhance decision search with optional semantic query support
- Updated the 'decisions' tool to accept an optional 'query' parameter for semantic filtering.
- Implemented logic to handle semantic search using Chroma when a query is provided.
- Preserved ranking order of results based on Chroma's output.
- Added fallback to metadata-first search when no query is present.
2025-11-25 16:37:08 -05:00
Alex Newman 73982dc709 docs: Update CHANGELOG.md for v6.3.1
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 15:48:37 -05:00
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
19 changed files with 1489 additions and 151 deletions
+1 -1
View File
@@ -10,7 +10,7 @@
"plugins": [
{
"name": "claude-mem",
"version": "6.2.1",
"version": "6.3.2",
"source": "./plugin",
"description": "Persistent memory system for Claude Code - context compression across sessions"
}
+61
View File
@@ -4,6 +4,67 @@ 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.1] - 2025-11-25
## What's New
- Add script to help estimate token savings from on-the-fly replacements
## [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.2
## 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:**
+77
View File
@@ -0,0 +1,77 @@
@everyone
**Endless Mode: Breaking Claude's Context Limits**
## The Problem
Ever hit 67% context usage mid-session and had to restart Claude Code? Context window limits are the #1 killer of long coding sessions. When you're deep in a complex refactor or debugging session, the last thing you want is to lose all that built-up context.
## The Solution: Endless Mode
Endless Mode compresses tool outputs **in real-time** as you work. Instead of storing the full 500-line file you just read, it stores a compact observation like:
> "Read package.json - found 47 dependencies including React 18, TypeScript 5.2, and custom build scripts"
**The result: 70-84% token reduction** on tool outputs, letting you work indefinitely without hitting context limits.
## The Numbers (Real Test Results)
We analyzed **500 transcripts** containing **1,884 tool uses**:
| Metric | Value |
|--------|-------|
| Tool uses analyzed | 1,884 |
| Observations matched | 868 |
| Eligible for compression | 406 |
| Compression rate (facts-only) | **84%** |
| Characters saved | 887,783 of 1,056,285 |
**Which tools benefit most:**
- **Bash output**: 236 compressible (command outputs -> facts)
- **Read file contents**: 98 compressible (file contents -> summaries)
- **Grep results**: 42 compressible (search results -> key matches)
**Key insight**: We only compress tool **outputs**, never inputs. Inputs contain semantic meaning (the actual diff, the query, the code you wrote). Outputs are verbose results that can be summarized without losing meaning.
## The Journey (69 observations over 10 days)
**Nov 16 - The Vision**
Decided to build Endless Mode as an *optional* feature to avoid mandatory architectural refactoring. The idea: let users opt-in to experimental compression without breaking anything for those who don't.
**Nov 19-20 - Implementation Begins**
Hit our first bug immediately: duplicate observations appearing on the 2nd prompt of each session. Classic regression - the endless mode changes broke something that was already working. Fixed it, kept going.
**Nov 21 - The Big Switch**
Made a critical architectural change: switched from **deferred** (async, 5-second timeout) to **synchronous** transformation (blocking, 90-second timeout). Endless Mode needs to wait for compression to complete before continuing - otherwise you'd read uncompressed data.
Multiple rounds of experimental release preparation. Documented all dependencies. Critical bugs kept appearing.
**Nov 22 - Validation**
Endpoints verified. Toggle working. Documentation reviewed. Things looking stable.
**Nov 23 - The Setback**
**Disabled endless mode.** It was causing everything to hang. The 90-second synchronous blocking was too aggressive - when compression took too long, the whole system locked up. Had to prioritize stability.
25 sessions had successfully used it before this point.
**Nov 25 - The Solution**
Created a **beta branch strategy**: Endless Mode lives on `beta/7.0`, isolated from main. Added Version Channel UI so users can safely try it without affecting stable users. Easy rollback if issues occur.
Built analysis scripts to measure *actual* compression rates instead of theoretical. Validated 84% savings on real transcripts.
## How to Try It
**v6.3.1** added a Version Channel switcher:
1. Open http://localhost:37777
2. Find **"Version Channel"** in Settings sidebar
3. Click **"Try Beta (Endless Mode)"**
4. Refresh the UI after switching
**Safe to try**: Your memory data lives in `~/.claude-mem/` - completely separate from the plugin code. Switching branches won't touch your data. Easy rollback with "Switch to Stable" button.
**Current beta branch**: `beta/7.0`
---
This has been a real engineering journey - vision, implementation, bugs, setbacks, and creative solutions. The beta branch approach lets us keep iterating on stability while giving adventurous users access to the feature.
+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.2",
"description": "Memory compression system for Claude Code - persist context across sessions",
"keywords": [
"claude",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "claude-mem",
"version": "6.2.1",
"version": "6.3.2",
"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
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);
}
+32 -17
View File
@@ -930,8 +930,9 @@ const tools = [
},
{
name: 'decisions',
description: 'Semantic shortcut to find decision-type observations. Returns observations where important architectural, technical, or process decisions were made. Equivalent to find_by_type with type="decision".',
description: 'Semantic shortcut to find decision-type observations. Returns observations where important architectural, technical, or process decisions were made. Supports optional semantic search query to filter decisions by relevance.',
inputSchema: z.object({
query: z.string().optional().describe('Search query to filter decisions semantically'),
format: z.enum(['index', 'full']).default('index').describe('Output format: "index" for titles/dates only (default), "full" for complete details'),
project: z.string().optional().describe('Filter by project name'),
dateRange: z.object({
@@ -944,33 +945,47 @@ const tools = [
}),
handler: async (args: any) => {
try {
const { format = 'index', ...filters } = args;
const { query, format = 'index', ...filters } = args;
let results: ObservationSearchResult[] = [];
// Search for decision-type observations
if (chromaClient) {
try {
console.error('[search-server] Using metadata-first + semantic ranking for decisions');
const metadataResults = search.findByType('decision', filters);
if (query) {
// Semantic search filtered to decision type
console.error('[search-server] Using Chroma semantic search with type=decision filter');
const chromaResults = await queryChroma(query, Math.min((filters.limit || 20) * 2, 100), { type: 'decision' });
const obsIds = chromaResults.ids;
if (metadataResults.length > 0) {
const ids = metadataResults.map(obs => obs.id);
const chromaResults = await queryChroma('decision', Math.min(ids.length, 100));
const rankedIds: number[] = [];
for (const chromaId of chromaResults.ids) {
if (ids.includes(chromaId) && !rankedIds.includes(chromaId)) {
rankedIds.push(chromaId);
}
if (obsIds.length > 0) {
results = store.getObservationsByIds(obsIds, { ...filters, type: 'decision' });
// Preserve Chroma ranking order
results.sort((a, b) => obsIds.indexOf(a.id) - obsIds.indexOf(b.id));
}
} else {
// No query: get all decisions, rank by "decision" keyword
console.error('[search-server] Using metadata-first + semantic ranking for decisions');
const metadataResults = search.findByType('decision', filters);
if (rankedIds.length > 0) {
results = store.getObservationsByIds(rankedIds, { limit: filters.limit || 20 });
results.sort((a, b) => rankedIds.indexOf(a.id) - rankedIds.indexOf(b.id));
if (metadataResults.length > 0) {
const ids = metadataResults.map(obs => obs.id);
const chromaResults = await queryChroma('decision', Math.min(ids.length, 100));
const rankedIds: number[] = [];
for (const chromaId of chromaResults.ids) {
if (ids.includes(chromaId) && !rankedIds.includes(chromaId)) {
rankedIds.push(chromaId);
}
}
if (rankedIds.length > 0) {
results = store.getObservationsByIds(rankedIds, { limit: filters.limit || 20 });
results.sort((a, b) => rankedIds.indexOf(a.id) - rankedIds.indexOf(b.id));
}
}
}
} catch (chromaError: any) {
console.error('[search-server] Chroma ranking failed, using SQLite order:', chromaError.message);
console.error('[search-server] Chroma search failed, using SQLite fallback:', chromaError.message);
}
}
+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">