Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a70bcecc5 | |||
| 4e5913611a | |||
| 73982dc709 | |||
| 93de6d97f5 | |||
| 29e6e026b6 | |||
| 1b394cdf4e | |||
| 6ffa4cfde5 | |||
| 8caf159d99 | |||
| e3a63c0294 |
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:**
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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`:
|
||||
|
||||
@@ -36,7 +36,8 @@
|
||||
"introduction",
|
||||
"installation",
|
||||
"usage/getting-started",
|
||||
"usage/search-tools"
|
||||
"usage/search-tools",
|
||||
"beta-features"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
+1
-1
@@ -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,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
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
// ============================================================================
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user