Merge main into feat/chroma-http-server
Resolve conflicts between Chroma HTTP server PR and main branch changes (folder CLAUDE.md, exclusion settings, Zscaler SSL, transport cleanup). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,3 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
*No recent activity*
|
||||
</claude-mem-context>
|
||||
@@ -1,60 +0,0 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Jan 13, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #40137 | 11:48 PM | ⚖️ | User Requests Reversion: Restore Token Creators to Bottom of Page | ~412 |
|
||||
| #40136 | " | 🟣 | Token Creators Added as Circular Avatars in Token Details Card | ~469 |
|
||||
| #40135 | 11:47 PM | 🔵 | Token Details Card Still Uses Creator Profile Picture as Fallback | ~397 |
|
||||
| #40134 | " | 🔄 | Reverted Token Creators From Title Bar Back to Original Ticker Layout | ~418 |
|
||||
| #40133 | " | 🔵 | User Clarification: Title Bar Refers to Token Details Card Component | ~376 |
|
||||
| #40132 | " | 🔵 | User Seeking Location Context: What Comes After Header Component | ~370 |
|
||||
| #40131 | " | 🔵 | Critical Clarification: User Wants Creators in Browser Window Title Bar Area | ~373 |
|
||||
| #40130 | 11:46 PM | 🔵 | User Clarification: Title Bar Refers to TokenTicker Component, Not Page Header | ~346 |
|
||||
| #40129 | " | ✅ | Build Verification Passed After Reverting Header Changes | ~368 |
|
||||
| #40128 | " | 🟣 | Token Creators Relocated to Title Bar Next to Ticker | ~500 |
|
||||
| #40127 | 11:45 PM | 🔄 | Reverted Token Creator Circles from Header - Removed Title Bar Implementation | ~376 |
|
||||
| #40125 | 11:44 PM | ✅ | Production Build Successfully Compiled After UI Refactoring | ~338 |
|
||||
| #40124 | " | 🔄 | Removed Unused User Icon Import from lucide-react | ~328 |
|
||||
| #40123 | " | 🔄 | Identified Unused User Icon Import After Creator Section Removal | ~311 |
|
||||
| #40122 | 11:43 PM | 🔄 | Removed Duplicate Token Creators Section from Bottom of Page | ~340 |
|
||||
| #40121 | " | 🟣 | Token Creators Relocated to Title Bar as Circular Avatars | ~472 |
|
||||
| #40120 | " | 🔵 | Token Creator Display Structure Located | ~332 |
|
||||
| #40118 | 11:41 PM | 🔴 | Pause-on-Hover Styling Not Applied | ~326 |
|
||||
| #40117 | " | ✅ | Ticker Repositioned to Top of Page Outside Container | ~304 |
|
||||
| #40116 | 11:40 PM | ✅ | Ticker Repositioned to Top of Page | ~326 |
|
||||
| #40112 | 11:39 PM | 🔴 | Marquee Animations Fixed by Moving Outside Tailwind Theme Block | ~373 |
|
||||
| #40107 | 11:35 PM | 🟣 | Token Ticker Integrated into Dashboard | ~362 |
|
||||
| #40106 | " | 🟣 | Token Selection Handler for Ticker Clicks | ~306 |
|
||||
| #40105 | 11:34 PM | ✅ | TokenTicker Import Added to Homepage | ~145 |
|
||||
| #40094 | 11:33 PM | 🟣 | Marquee Animation Keyframes Added to Global CSS | ~319 |
|
||||
| #40092 | " | 🔵 | Tailwind CSS 4 Configuration in Global Styles | ~288 |
|
||||
| #40082 | 11:32 PM | 🔵 | Existing Bagalytics Token Analytics Dashboard | ~394 |
|
||||
| #40053 | 11:13 PM | 🔵 | Complete Codebase Exploration for Caching Implementation | ~550 |
|
||||
| #40051 | 11:12 PM | 🔵 | Main Dashboard Component Architecture | ~503 |
|
||||
| #40044 | 11:08 PM | ✅ | Production build successfully compiled with UI improvements | ~260 |
|
||||
| #40043 | 11:07 PM | 🔴 | Replaced simulated fee history with real hourly data from API | ~377 |
|
||||
| #40042 | " | 🔴 | Removed simulated fee history generator function and state | ~353 |
|
||||
| #40041 | " | 🔄 | Added HourlyFee interface and hourlyFees field to TokenData for real historical data support | ~297 |
|
||||
| #40032 | 10:52 PM | 🟣 | Added manual refresh button and last updated timestamp to Fee Projections card | ~335 |
|
||||
| #40031 | " | 🟣 | Added timestamp tracking for data refresh updates | ~256 |
|
||||
| #40030 | " | ✅ | Replaced DollarSign icon component with money bag emoji in header logo | ~243 |
|
||||
| #40029 | " | 🟣 | Added timestamp tracking state for data updates | ~236 |
|
||||
| #40028 | " | ✅ | Added horizontal padding to token address input field | ~237 |
|
||||
| #40027 | 10:49 PM | 🟣 | Added "24h Stats" label to trading activity metrics section | ~267 |
|
||||
| #40026 | 10:48 PM | ✅ | Replaced misleading price volatility alert with factual trading activity metrics | ~390 |
|
||||
| #40025 | 10:46 PM | 🟣 | Replaced misleading price volatility alert with comprehensive trading activity metrics | ~391 |
|
||||
| #40024 | " | 🔵 | Identified misleading price volatility alert in UI | ~358 |
|
||||
| #40021 | 10:39 PM | 🔄 | Restructured Fees Chart Card to Remove CardContent Wrapper | ~361 |
|
||||
| #40020 | " | 🔄 | Simplified Fees Chart Card by Removing CardHeader and CardTitle Components | ~365 |
|
||||
| #40019 | 10:38 PM | ✅ | Standardized Fee Projections Card Padding | ~288 |
|
||||
| #40018 | " | ✅ | Standardized Token Details Card Padding | ~285 |
|
||||
| #40017 | " | 🔄 | Simplified MetricCard Component by Removing CardHeader and CardContent Wrappers | ~370 |
|
||||
| #40016 | " | 🔴 | Fixed 24h Fees Card Border and Cleaned Up Unnecessary Classes | ~354 |
|
||||
| #40015 | " | ✅ | Finalized Lifetime Fees Card with Explicit Border and Unified Hover State | ~368 |
|
||||
| #40014 | " | 🔴 | Added Missing Border Utility to Token Creators Card | ~321 |
|
||||
</claude-mem-context>
|
||||
@@ -1,187 +0,0 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Nov 21, 2025
|
||||
|
||||
**cleanup-duplicates.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #13458 | 4:05 PM | 🔵 | Comments already document multiple observations per tool_use_id design | ~394 |
|
||||
|
||||
**restore-endless-mode.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #13229 | 1:36 PM | 🔵 | Dead Code Analysis: Deferred Transformation Experiment | ~613 |
|
||||
| #13228 | 1:33 PM | 🔵 | Endless Mode Restoration CLI Tool | ~601 |
|
||||
|
||||
### Nov 22, 2025
|
||||
|
||||
**restore-endless-mode.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #14203 | 1:05 AM | 🔵 | Endless Mode Feature Branch Contains Major Additions | ~566 |
|
||||
|
||||
### Dec 5, 2025
|
||||
|
||||
**run.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #20355 | 6:49 PM | 🔵 | Runtime launcher added for dynamic Bun/Node selection | ~260 |
|
||||
| #20290 | 6:06 PM | 🔵 | Runtime Launcher Script for Dynamic Execution | ~269 |
|
||||
| #20140 | 4:08 PM | 🔄 | Eliminated code duplication in run.ts by importing from runtime.ts | ~376 |
|
||||
| #20135 | 4:03 PM | ⚖️ | Proposed Refactoring: 77% Code Reduction While Maintaining Functionality | ~411 |
|
||||
| #20133 | 4:02 PM | ⚖️ | Runtime Implementation Analysis: Four Categories of Issues Identified | ~406 |
|
||||
| #20130 | " | ⚖️ | Code Duplication Deemed Unnecessary Due to Bundling | ~359 |
|
||||
| #20127 | 4:01 PM | 🔵 | Dual Purpose Runtime System Revealed | ~335 |
|
||||
| #20126 | " | 🔵 | Invalid Justification for Duplication Analysis | ~295 |
|
||||
| #20125 | " | 🔵 | Code Duplication Issue in Runtime Implementation | ~317 |
|
||||
| #20123 | " | 🔵 | Source TypeScript Runtime Launcher Implementation | ~313 |
|
||||
| #20120 | 3:58 PM | 🔵 | PR 169 Changes Overview | ~314 |
|
||||
| #20110 | 3:55 PM | 🔵 | PR 169 adds Bun runtime support with automatic detection | ~461 |
|
||||
|
||||
### Dec 8, 2025
|
||||
|
||||
**restore-endless-mode.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #21893 | 3:30 PM | 🔵 | Endless Mode Transcript Restoration CLI Tool | ~345 |
|
||||
|
||||
### Dec 9, 2025
|
||||
|
||||
**import-xml-observations.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #22929 | 3:04 PM | ⚖️ | Silent Failure Pattern Conversion Strategy | ~471 |
|
||||
| #22928 | 3:03 PM | 🔵 | Silent Failure Pattern Audit Results | ~372 |
|
||||
| #22927 | 3:01 PM | 🔵 | Silent Failure Pattern Detection Across Codebase | ~352 |
|
||||
|
||||
### Dec 10, 2025
|
||||
|
||||
**import-xml-observations.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #23755 | 9:05 PM | 🔵 | Import XML Observations Utility Uses SessionStore Directly | ~280 |
|
||||
|
||||
**cleanup-duplicates.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #23754 | 9:05 PM | 🔵 | Cleanup Duplicates Utility Uses SessionStore Directly | ~234 |
|
||||
|
||||
### Dec 11, 2025
|
||||
|
||||
**import-xml-observations.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #23959 | 1:58 PM | 🔵 | TypeScript Codebase Architecture Mapped | ~337 |
|
||||
|
||||
### Dec 13, 2025
|
||||
|
||||
**import-xml-observations.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #25321 | 9:12 PM | 🔵 | Console.error Usage Found in 29 Files | ~366 |
|
||||
|
||||
### Dec 16, 2025
|
||||
|
||||
**import-xml-observations.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #27502 | 4:19 PM | 🔵 | Observation Storage Architecture Located | ~403 |
|
||||
|
||||
### Dec 18, 2025
|
||||
|
||||
**import-xml-observations.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #29773 | 7:01 PM | 🔵 | Observation Type Definitions Across Codebase | ~362 |
|
||||
|
||||
### Dec 19, 2025
|
||||
|
||||
**cleanup-duplicates.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #29926 | 6:25 PM | 🔵 | cleanup-duplicates.ts Only Deletes From SQLite, Not Chroma | ~340 |
|
||||
|
||||
### Dec 21, 2025
|
||||
|
||||
**cleanup-duplicates.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #31603 | 8:21 PM | 🔵 | Complete Console.* Statement Audit Across Codebase | ~813 |
|
||||
| #31599 | 8:19 PM | 🔵 | 136 console logging statements found in TypeScript source files | ~538 |
|
||||
|
||||
### Dec 24, 2025
|
||||
|
||||
**import-xml-observations.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #32184 | 7:20 PM | 🔄 | Direct SQL replaced method call for SDK session ID update | ~259 |
|
||||
| #32183 | " | 🔄 | Simplified database update in XML import script | ~254 |
|
||||
| #32100 | 5:08 PM | 🔵 | storeObservation method usage spans three TypeScript files | ~237 |
|
||||
|
||||
### Dec 25, 2025
|
||||
|
||||
**import-xml-observations.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #32558 | 8:18 PM | 🔵 | Identified files containing 'summary' or 'Summary' | ~167 |
|
||||
| #32456 | 5:41 PM | ✅ | Completed merge of main branch into feature/titans-phase1-3 | ~354 |
|
||||
|
||||
### Dec 27, 2025
|
||||
|
||||
**import-xml-observations.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #33082 | 6:45 PM | 🔵 | User directory path patterns in codebase | ~362 |
|
||||
|
||||
### Dec 28, 2025
|
||||
|
||||
**cleanup-duplicates.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #33636 | 11:35 PM | ✅ | Major Documentation and Code Cleanup Removed 4,929 Lines | ~381 |
|
||||
| #33590 | 11:11 PM | 🔵 | Database Migration Renamed sdk_session_id to memory_session_id | ~387 |
|
||||
|
||||
**import-xml-observations.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #33439 | 10:15 PM | 🔄 | Extended Session ID Renaming to Additional Codebase Components | ~352 |
|
||||
|
||||
### Dec 29, 2025
|
||||
|
||||
**cleanup-duplicates.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #33675 | 12:02 AM | 🔄 | Major Documentation and Code Cleanup in MCP Clarity Branch | ~491 |
|
||||
|
||||
### Jan 1, 2026
|
||||
|
||||
**import-xml-observations.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #35553 | 9:50 PM | 🔵 | storeObservation method usage across codebase | ~315 |
|
||||
| #35515 | 9:14 PM | ✅ | Wave 1 Extended Fixes Committed: 5 Critical Error Handling Issues Resolved | ~409 |
|
||||
| #35501 | 9:10 PM | 🔵 | Wave 1 Verification Issue: Anti-Pattern Detector Not Recognizing Fixes | ~497 |
|
||||
| #35500 | 9:09 PM | 🟣 | Wave 1 Complete: All 4 Empty Catch Blocks Fixed | ~511 |
|
||||
| #35493 | 9:08 PM | 🔴 | Wave 1 Fix 1/4: XML Importer Empty Catch Block Fixed | ~392 |
|
||||
| #35492 | " | ✅ | Wave 1 Fix 1/4: Added Logger Import to XML Importer | ~248 |
|
||||
| #35488 | 9:07 PM | 🔵 | Wave 1 Target File: XML Observation Importer Structure | ~424 |
|
||||
| #35485 | 9:06 PM | ⚖️ | Comprehensive error handling remediation plan completed and submitted for approval | ~555 |
|
||||
| #35465 | 9:01 PM | 🔵 | Empty catch block in XML observations import script | ~281 |
|
||||
|
||||
### Jan 2, 2026
|
||||
|
||||
**import-xml-observations.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #35985 | 5:16 PM | 🔵 | Alignment logging implemented across session lifecycle touchpoints | ~377 |
|
||||
|
||||
### Jan 3, 2026
|
||||
|
||||
**import-xml-observations.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #36296 | 8:04 PM | 🔵 | TypeScript Compilation Check: Pre-Existing Errors Unrelated to Refactoring | ~621 |
|
||||
</claude-mem-context>
|
||||
@@ -1,8 +1,6 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Dec 10, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
*No recent activity*
|
||||
</claude-mem-context>
|
||||
@@ -0,0 +1,540 @@
|
||||
/**
|
||||
* CLAUDE.md Generation and Cleanup Commands
|
||||
*
|
||||
* Shared module for CLAUDE.md file management that can be invoked from:
|
||||
* - CLI: `claude-mem generate` / `claude-mem clean`
|
||||
* - Worker service API endpoints
|
||||
*
|
||||
* Provides two main operations:
|
||||
* - generateClaudeMd: Regenerate CLAUDE.md files for folders with observations
|
||||
* - cleanClaudeMd: Remove auto-generated content from CLAUDE.md files
|
||||
*/
|
||||
|
||||
import { Database } from 'bun:sqlite';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import {
|
||||
existsSync,
|
||||
writeFileSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
unlinkSync,
|
||||
readdirSync
|
||||
} from 'fs';
|
||||
import { execSync } from 'child_process';
|
||||
import { SettingsDefaultsManager } from '../shared/SettingsDefaultsManager.js';
|
||||
import { formatTime, groupByDate } from '../shared/timeline-formatting.js';
|
||||
import { isDirectChild } from '../shared/path-utils.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
const DB_PATH = path.join(os.homedir(), '.claude-mem', 'claude-mem.db');
|
||||
const SETTINGS_PATH = path.join(os.homedir(), '.claude-mem', 'settings.json');
|
||||
|
||||
interface ObservationRow {
|
||||
id: number;
|
||||
title: string | null;
|
||||
subtitle: string | null;
|
||||
narrative: string | null;
|
||||
facts: string | null;
|
||||
type: string;
|
||||
created_at: string;
|
||||
created_at_epoch: number;
|
||||
files_modified: string | null;
|
||||
files_read: string | null;
|
||||
project: string;
|
||||
discovery_tokens: number | null;
|
||||
}
|
||||
|
||||
// Type icon map (matches ModeManager)
|
||||
const TYPE_ICONS: Record<string, string> = {
|
||||
'bugfix': '🔴',
|
||||
'feature': '🟣',
|
||||
'refactor': '🔄',
|
||||
'change': '✅',
|
||||
'discovery': '🔵',
|
||||
'decision': '⚖️',
|
||||
'session': '🎯',
|
||||
'prompt': '💬'
|
||||
};
|
||||
|
||||
function getTypeIcon(type: string): string {
|
||||
return TYPE_ICONS[type] || '📝';
|
||||
}
|
||||
|
||||
function estimateTokens(obs: ObservationRow): number {
|
||||
const size = (obs.title?.length || 0) +
|
||||
(obs.subtitle?.length || 0) +
|
||||
(obs.narrative?.length || 0) +
|
||||
(obs.facts?.length || 0);
|
||||
return Math.ceil(size / 4);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tracked folders using git ls-files.
|
||||
* Respects .gitignore and only returns folders within the project.
|
||||
*/
|
||||
function getTrackedFolders(workingDir: string): Set<string> {
|
||||
const folders = new Set<string>();
|
||||
|
||||
try {
|
||||
const output = execSync('git ls-files', {
|
||||
cwd: workingDir,
|
||||
encoding: 'utf-8',
|
||||
maxBuffer: 50 * 1024 * 1024
|
||||
});
|
||||
|
||||
const files = output.trim().split('\n').filter(f => f);
|
||||
|
||||
for (const file of files) {
|
||||
const absPath = path.join(workingDir, file);
|
||||
let dir = path.dirname(absPath);
|
||||
|
||||
while (dir.length > workingDir.length && dir.startsWith(workingDir)) {
|
||||
folders.add(dir);
|
||||
dir = path.dirname(dir);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('CLAUDE_MD', 'git ls-files failed, falling back to directory walk', { error: String(error) });
|
||||
walkDirectoriesWithIgnore(workingDir, folders);
|
||||
}
|
||||
|
||||
return folders;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback directory walker that skips common ignored patterns.
|
||||
*/
|
||||
function walkDirectoriesWithIgnore(dir: string, folders: Set<string>, depth: number = 0): void {
|
||||
if (depth > 10) return;
|
||||
|
||||
const ignorePatterns = [
|
||||
'node_modules', '.git', '.next', 'dist', 'build', '.cache',
|
||||
'__pycache__', '.venv', 'venv', '.idea', '.vscode', 'coverage',
|
||||
'.claude-mem', '.open-next', '.turbo'
|
||||
];
|
||||
|
||||
try {
|
||||
const entries = readdirSync(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (ignorePatterns.includes(entry.name)) continue;
|
||||
if (entry.name.startsWith('.') && entry.name !== '.claude') continue;
|
||||
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
folders.add(fullPath);
|
||||
walkDirectoriesWithIgnore(fullPath, folders, depth + 1);
|
||||
}
|
||||
} catch {
|
||||
// Ignore permission errors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an observation has any files that are direct children of the folder.
|
||||
*/
|
||||
function hasDirectChildFile(obs: ObservationRow, folderPath: string): boolean {
|
||||
const checkFiles = (filesJson: string | null): boolean => {
|
||||
if (!filesJson) return false;
|
||||
try {
|
||||
const files = JSON.parse(filesJson);
|
||||
if (Array.isArray(files)) {
|
||||
return files.some(f => isDirectChild(f, folderPath));
|
||||
}
|
||||
} catch {}
|
||||
return false;
|
||||
};
|
||||
|
||||
return checkFiles(obs.files_modified) || checkFiles(obs.files_read);
|
||||
}
|
||||
|
||||
/**
|
||||
* Query observations for a specific folder.
|
||||
* Only returns observations with files directly in the folder (not in subfolders).
|
||||
*/
|
||||
function findObservationsByFolder(db: Database, relativeFolderPath: string, project: string, limit: number): ObservationRow[] {
|
||||
const queryLimit = limit * 3;
|
||||
|
||||
const sql = `
|
||||
SELECT o.*, o.discovery_tokens
|
||||
FROM observations o
|
||||
WHERE o.project = ?
|
||||
AND (o.files_modified LIKE ? OR o.files_read LIKE ?)
|
||||
ORDER BY o.created_at_epoch DESC
|
||||
LIMIT ?
|
||||
`;
|
||||
|
||||
// Database stores paths with forward slashes (git-normalized)
|
||||
const normalizedFolderPath = relativeFolderPath.split(path.sep).join('/');
|
||||
const likePattern = `%"${normalizedFolderPath}/%`;
|
||||
const allMatches = db.prepare(sql).all(project, likePattern, likePattern, queryLimit) as ObservationRow[];
|
||||
|
||||
return allMatches.filter(obs => hasDirectChildFile(obs, relativeFolderPath)).slice(0, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract relevant file from an observation for display.
|
||||
* Only returns files that are direct children of the folder.
|
||||
*/
|
||||
function extractRelevantFile(obs: ObservationRow, relativeFolder: string): string {
|
||||
if (obs.files_modified) {
|
||||
try {
|
||||
const modified = JSON.parse(obs.files_modified);
|
||||
if (Array.isArray(modified)) {
|
||||
for (const file of modified) {
|
||||
if (isDirectChild(file, relativeFolder)) {
|
||||
return path.basename(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (obs.files_read) {
|
||||
try {
|
||||
const read = JSON.parse(obs.files_read);
|
||||
if (Array.isArray(read)) {
|
||||
for (const file of read) {
|
||||
if (isDirectChild(file, relativeFolder)) {
|
||||
return path.basename(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return 'General';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format observations for CLAUDE.md content.
|
||||
*/
|
||||
function formatObservationsForClaudeMd(observations: ObservationRow[], folderPath: string): string {
|
||||
const lines: string[] = [];
|
||||
lines.push('# Recent Activity');
|
||||
lines.push('');
|
||||
lines.push('<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->');
|
||||
lines.push('');
|
||||
|
||||
if (observations.length === 0) {
|
||||
lines.push('*No recent activity*');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
const byDate = groupByDate(observations, obs => obs.created_at);
|
||||
|
||||
for (const [day, dayObs] of byDate) {
|
||||
lines.push(`### ${day}`);
|
||||
lines.push('');
|
||||
|
||||
const byFile = new Map<string, ObservationRow[]>();
|
||||
for (const obs of dayObs) {
|
||||
const file = extractRelevantFile(obs, folderPath);
|
||||
if (!byFile.has(file)) byFile.set(file, []);
|
||||
byFile.get(file)!.push(obs);
|
||||
}
|
||||
|
||||
for (const [file, fileObs] of byFile) {
|
||||
lines.push(`**${file}**`);
|
||||
lines.push('| ID | Time | T | Title | Read |');
|
||||
lines.push('|----|------|---|-------|------|');
|
||||
|
||||
let lastTime = '';
|
||||
for (const obs of fileObs) {
|
||||
const time = formatTime(obs.created_at_epoch);
|
||||
const timeDisplay = time === lastTime ? '"' : time;
|
||||
lastTime = time;
|
||||
|
||||
const icon = getTypeIcon(obs.type);
|
||||
const title = obs.title || 'Untitled';
|
||||
const tokens = estimateTokens(obs);
|
||||
|
||||
lines.push(`| #${obs.id} | ${timeDisplay} | ${icon} | ${title} | ~${tokens} |`);
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Write CLAUDE.md file with tagged content preservation.
|
||||
* Only writes to folders that exist — never creates directories.
|
||||
*/
|
||||
function writeClaudeMdToFolder(folderPath: string, newContent: string): void {
|
||||
const claudeMdPath = path.join(folderPath, 'CLAUDE.md');
|
||||
const tempFile = `${claudeMdPath}.tmp`;
|
||||
|
||||
if (!existsSync(folderPath)) {
|
||||
throw new Error(`Folder does not exist: ${folderPath}`);
|
||||
}
|
||||
|
||||
let existingContent = '';
|
||||
if (existsSync(claudeMdPath)) {
|
||||
existingContent = readFileSync(claudeMdPath, 'utf-8');
|
||||
}
|
||||
|
||||
const startTag = '<claude-mem-context>';
|
||||
const endTag = '</claude-mem-context>';
|
||||
|
||||
let finalContent: string;
|
||||
if (!existingContent) {
|
||||
finalContent = `${startTag}\n${newContent}\n${endTag}`;
|
||||
} else {
|
||||
const startIdx = existingContent.indexOf(startTag);
|
||||
const endIdx = existingContent.indexOf(endTag);
|
||||
|
||||
if (startIdx !== -1 && endIdx !== -1) {
|
||||
finalContent = existingContent.substring(0, startIdx) +
|
||||
`${startTag}\n${newContent}\n${endTag}` +
|
||||
existingContent.substring(endIdx + endTag.length);
|
||||
} else {
|
||||
finalContent = existingContent + `\n\n${startTag}\n${newContent}\n${endTag}`;
|
||||
}
|
||||
}
|
||||
|
||||
writeFileSync(tempFile, finalContent);
|
||||
renameSync(tempFile, claudeMdPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Regenerate CLAUDE.md for a single folder.
|
||||
*/
|
||||
function regenerateFolder(
|
||||
db: Database,
|
||||
absoluteFolder: string,
|
||||
relativeFolder: string,
|
||||
project: string,
|
||||
dryRun: boolean,
|
||||
workingDir: string,
|
||||
observationLimit: number
|
||||
): { success: boolean; observationCount: number; error?: string } {
|
||||
try {
|
||||
if (!existsSync(absoluteFolder)) {
|
||||
return { success: false, observationCount: 0, error: 'Folder no longer exists' };
|
||||
}
|
||||
|
||||
// Validate folder is within project root (prevent path traversal)
|
||||
const resolvedFolder = path.resolve(absoluteFolder);
|
||||
const resolvedWorkingDir = path.resolve(workingDir);
|
||||
if (!resolvedFolder.startsWith(resolvedWorkingDir + path.sep)) {
|
||||
return { success: false, observationCount: 0, error: 'Path escapes project root' };
|
||||
}
|
||||
|
||||
const observations = findObservationsByFolder(db, relativeFolder, project, observationLimit);
|
||||
|
||||
if (observations.length === 0) {
|
||||
return { success: false, observationCount: 0, error: 'No observations for folder' };
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
return { success: true, observationCount: observations.length };
|
||||
}
|
||||
|
||||
const formatted = formatObservationsForClaudeMd(observations, relativeFolder);
|
||||
writeClaudeMdToFolder(absoluteFolder, formatted);
|
||||
|
||||
return { success: true, observationCount: observations.length };
|
||||
} catch (error) {
|
||||
return { success: false, observationCount: 0, error: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate CLAUDE.md files for all folders with observations.
|
||||
*
|
||||
* @param dryRun - If true, only report what would be done without writing files
|
||||
* @returns Exit code (0 for success, 1 for error)
|
||||
*/
|
||||
export async function generateClaudeMd(dryRun: boolean): Promise<number> {
|
||||
try {
|
||||
const workingDir = process.cwd();
|
||||
const settings = SettingsDefaultsManager.loadFromFile(SETTINGS_PATH);
|
||||
const observationLimit = parseInt(settings.CLAUDE_MEM_CONTEXT_OBSERVATIONS, 10) || 50;
|
||||
|
||||
logger.info('CLAUDE_MD', 'Starting CLAUDE.md generation', {
|
||||
workingDir,
|
||||
dryRun,
|
||||
observationLimit
|
||||
});
|
||||
|
||||
const project = path.basename(workingDir);
|
||||
const trackedFolders = getTrackedFolders(workingDir);
|
||||
|
||||
if (trackedFolders.size === 0) {
|
||||
logger.info('CLAUDE_MD', 'No folders found in project');
|
||||
return 0;
|
||||
}
|
||||
|
||||
logger.info('CLAUDE_MD', `Found ${trackedFolders.size} folders in project`);
|
||||
|
||||
if (!existsSync(DB_PATH)) {
|
||||
logger.info('CLAUDE_MD', 'Database not found, no observations to process');
|
||||
return 0;
|
||||
}
|
||||
|
||||
const db = new Database(DB_PATH, { readonly: true, create: false });
|
||||
|
||||
let successCount = 0;
|
||||
let skipCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
const foldersArray = Array.from(trackedFolders).sort();
|
||||
|
||||
for (const absoluteFolder of foldersArray) {
|
||||
const relativeFolder = path.relative(workingDir, absoluteFolder);
|
||||
|
||||
const result = regenerateFolder(
|
||||
db,
|
||||
absoluteFolder,
|
||||
relativeFolder,
|
||||
project,
|
||||
dryRun,
|
||||
workingDir,
|
||||
observationLimit
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
logger.debug('CLAUDE_MD', `Processed folder: ${relativeFolder}`, {
|
||||
observationCount: result.observationCount
|
||||
});
|
||||
successCount++;
|
||||
} else if (result.error?.includes('No observations')) {
|
||||
skipCount++;
|
||||
} else {
|
||||
logger.warn('CLAUDE_MD', `Error processing folder: ${relativeFolder}`, {
|
||||
error: result.error
|
||||
});
|
||||
errorCount++;
|
||||
}
|
||||
}
|
||||
|
||||
db.close();
|
||||
|
||||
logger.info('CLAUDE_MD', 'CLAUDE.md generation complete', {
|
||||
totalFolders: foldersArray.length,
|
||||
withObservations: successCount,
|
||||
noObservations: skipCount,
|
||||
errors: errorCount,
|
||||
dryRun
|
||||
});
|
||||
|
||||
return 0;
|
||||
} catch (error) {
|
||||
logger.error('CLAUDE_MD', 'Fatal error during CLAUDE.md generation', {
|
||||
error: String(error)
|
||||
});
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up auto-generated CLAUDE.md files.
|
||||
*
|
||||
* For each file with <claude-mem-context> tags:
|
||||
* - Strip the tagged section
|
||||
* - If empty after stripping, delete the file
|
||||
* - If has remaining content, save the stripped version
|
||||
*
|
||||
* @param dryRun - If true, only report what would be done without modifying files
|
||||
* @returns Exit code (0 for success, 1 for error)
|
||||
*/
|
||||
export async function cleanClaudeMd(dryRun: boolean): Promise<number> {
|
||||
try {
|
||||
const workingDir = process.cwd();
|
||||
|
||||
logger.info('CLAUDE_MD', 'Starting CLAUDE.md cleanup', {
|
||||
workingDir,
|
||||
dryRun
|
||||
});
|
||||
|
||||
const filesToProcess: string[] = [];
|
||||
|
||||
function walkForClaudeMd(dir: string): void {
|
||||
const ignorePatterns = [
|
||||
'node_modules', '.git', '.next', 'dist', 'build', '.cache',
|
||||
'__pycache__', '.venv', 'venv', '.idea', '.vscode', 'coverage',
|
||||
'.claude-mem', '.open-next', '.turbo'
|
||||
];
|
||||
|
||||
try {
|
||||
const entries = readdirSync(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
if (!ignorePatterns.includes(entry.name)) {
|
||||
walkForClaudeMd(fullPath);
|
||||
}
|
||||
} else if (entry.name === 'CLAUDE.md') {
|
||||
try {
|
||||
const content = readFileSync(fullPath, 'utf-8');
|
||||
if (content.includes('<claude-mem-context>')) {
|
||||
filesToProcess.push(fullPath);
|
||||
}
|
||||
} catch {
|
||||
// Skip files we can't read
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore permission errors
|
||||
}
|
||||
}
|
||||
|
||||
walkForClaudeMd(workingDir);
|
||||
|
||||
if (filesToProcess.length === 0) {
|
||||
logger.info('CLAUDE_MD', 'No CLAUDE.md files with auto-generated content found');
|
||||
return 0;
|
||||
}
|
||||
|
||||
logger.info('CLAUDE_MD', `Found ${filesToProcess.length} CLAUDE.md files with auto-generated content`);
|
||||
|
||||
let deletedCount = 0;
|
||||
let cleanedCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
for (const file of filesToProcess) {
|
||||
const relativePath = path.relative(workingDir, file);
|
||||
|
||||
try {
|
||||
const content = readFileSync(file, 'utf-8');
|
||||
const stripped = content.replace(/<claude-mem-context>[\s\S]*?<\/claude-mem-context>/g, '').trim();
|
||||
|
||||
if (stripped === '') {
|
||||
if (!dryRun) {
|
||||
unlinkSync(file);
|
||||
}
|
||||
logger.debug('CLAUDE_MD', `${dryRun ? '[DRY-RUN] Would delete' : 'Deleted'} (empty): ${relativePath}`);
|
||||
deletedCount++;
|
||||
} else {
|
||||
if (!dryRun) {
|
||||
writeFileSync(file, stripped);
|
||||
}
|
||||
logger.debug('CLAUDE_MD', `${dryRun ? '[DRY-RUN] Would clean' : 'Cleaned'}: ${relativePath}`);
|
||||
cleanedCount++;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('CLAUDE_MD', `Error processing ${relativePath}`, { error: String(error) });
|
||||
errorCount++;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('CLAUDE_MD', 'CLAUDE.md cleanup complete', {
|
||||
deleted: deletedCount,
|
||||
cleaned: cleanedCount,
|
||||
errors: errorCount,
|
||||
dryRun
|
||||
});
|
||||
|
||||
return 0;
|
||||
} catch (error) {
|
||||
logger.error('CLAUDE_MD', 'Fatal error during CLAUDE.md cleanup', {
|
||||
error: String(error)
|
||||
});
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,3 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
*No recent activity*
|
||||
</claude-mem-context>
|
||||
+40
-14
@@ -8,11 +8,23 @@
|
||||
import type { EventHandler, NormalizedHookInput, HookResult } from '../types.js';
|
||||
import { ensureWorkerRunning, getWorkerPort } from '../../shared/worker-utils.js';
|
||||
import { getProjectContext } from '../../utils/project-name.js';
|
||||
import { HOOK_EXIT_CODES } from '../../shared/hook-constants.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
|
||||
export const contextHandler: EventHandler = {
|
||||
async execute(input: NormalizedHookInput): Promise<HookResult> {
|
||||
// Ensure worker is running before any other logic
|
||||
await ensureWorkerRunning();
|
||||
const workerReady = await ensureWorkerRunning();
|
||||
if (!workerReady) {
|
||||
// Worker not available - return empty context gracefully
|
||||
return {
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'SessionStart',
|
||||
additionalContext: ''
|
||||
},
|
||||
exitCode: HOOK_EXIT_CODES.SUCCESS
|
||||
};
|
||||
}
|
||||
|
||||
const cwd = input.cwd ?? process.cwd();
|
||||
const context = getProjectContext(cwd);
|
||||
@@ -24,20 +36,34 @@ export const contextHandler: EventHandler = {
|
||||
|
||||
// Note: Removed AbortSignal.timeout due to Windows Bun cleanup issue (libuv assertion)
|
||||
// Worker service has its own timeouts, so client-side timeout is redundant
|
||||
const response = await fetch(url);
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Context generation failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.text();
|
||||
const additionalContext = result.trim();
|
||||
|
||||
return {
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'SessionStart',
|
||||
additionalContext
|
||||
if (!response.ok) {
|
||||
// Log but don't throw — context fetch failure should not block session start
|
||||
logger.warn('HOOK', 'Context generation failed, returning empty', { status: response.status });
|
||||
return {
|
||||
hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: '' },
|
||||
exitCode: HOOK_EXIT_CODES.SUCCESS
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const result = await response.text();
|
||||
const additionalContext = result.trim();
|
||||
|
||||
return {
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'SessionStart',
|
||||
additionalContext
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
// Worker unreachable — return empty context gracefully
|
||||
logger.warn('HOOK', 'Context fetch error, returning empty', { error: error instanceof Error ? error.message : String(error) });
|
||||
return {
|
||||
hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: '' },
|
||||
exitCode: HOOK_EXIT_CODES.SUCCESS
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -8,11 +8,16 @@
|
||||
import type { EventHandler, NormalizedHookInput, HookResult } from '../types.js';
|
||||
import { ensureWorkerRunning, getWorkerPort } from '../../shared/worker-utils.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { HOOK_EXIT_CODES } from '../../shared/hook-constants.js';
|
||||
|
||||
export const fileEditHandler: EventHandler = {
|
||||
async execute(input: NormalizedHookInput): Promise<HookResult> {
|
||||
// Ensure worker is running before any other logic
|
||||
await ensureWorkerRunning();
|
||||
const workerReady = await ensureWorkerRunning();
|
||||
if (!workerReady) {
|
||||
// Worker not available - skip file edit observation gracefully
|
||||
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
|
||||
}
|
||||
|
||||
const { sessionId, cwd, filePath, edits } = input;
|
||||
|
||||
@@ -34,25 +39,33 @@ export const fileEditHandler: EventHandler = {
|
||||
|
||||
// Send to worker as an observation with file edit metadata
|
||||
// The observation handler on the worker will process this appropriately
|
||||
const response = await fetch(`http://127.0.0.1:${port}/api/sessions/observations`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
contentSessionId: sessionId,
|
||||
tool_name: 'write_file',
|
||||
tool_input: { filePath, edits },
|
||||
tool_response: { success: true },
|
||||
cwd
|
||||
})
|
||||
// Note: Removed signal to avoid Windows Bun cleanup issue (libuv assertion)
|
||||
});
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/api/sessions/observations`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
contentSessionId: sessionId,
|
||||
tool_name: 'write_file',
|
||||
tool_input: { filePath, edits },
|
||||
tool_response: { success: true },
|
||||
cwd
|
||||
})
|
||||
// Note: Removed signal to avoid Windows Bun cleanup issue (libuv assertion)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`File edit observation storage failed: ${response.status}`);
|
||||
if (!response.ok) {
|
||||
// Log but don't throw — file edit observation failure should not block editing
|
||||
logger.warn('HOOK', 'File edit observation storage failed, skipping', { status: response.status, filePath });
|
||||
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
|
||||
}
|
||||
|
||||
logger.debug('HOOK', 'File edit observation sent successfully', { filePath });
|
||||
} catch (error) {
|
||||
// Worker unreachable — skip file edit observation gracefully
|
||||
logger.warn('HOOK', 'File edit observation fetch error, skipping', { error: error instanceof Error ? error.message : String(error) });
|
||||
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
|
||||
}
|
||||
|
||||
logger.debug('HOOK', 'File edit observation sent successfully', { filePath });
|
||||
|
||||
return { continue: true, suppressOutput: true };
|
||||
}
|
||||
};
|
||||
|
||||
+24
-11
@@ -5,26 +5,30 @@
|
||||
*/
|
||||
|
||||
import type { EventHandler } from '../types.js';
|
||||
import { HOOK_EXIT_CODES } from '../../shared/hook-constants.js';
|
||||
import { contextHandler } from './context.js';
|
||||
import { sessionInitHandler } from './session-init.js';
|
||||
import { observationHandler } from './observation.js';
|
||||
import { summarizeHandler } from './summarize.js';
|
||||
import { userMessageHandler } from './user-message.js';
|
||||
import { fileEditHandler } from './file-edit.js';
|
||||
import { sessionCompleteHandler } from './session-complete.js';
|
||||
|
||||
export type EventType =
|
||||
| 'context' // SessionStart - inject context
|
||||
| 'session-init' // UserPromptSubmit - initialize session
|
||||
| 'observation' // PostToolUse - save observation
|
||||
| 'summarize' // Stop - generate summary
|
||||
| 'user-message' // SessionStart (parallel) - display to user
|
||||
| 'file-edit'; // Cursor afterFileEdit
|
||||
| 'context' // SessionStart - inject context
|
||||
| 'session-init' // UserPromptSubmit - initialize session
|
||||
| 'observation' // PostToolUse - save observation
|
||||
| 'summarize' // Stop - generate summary (phase 1)
|
||||
| 'session-complete' // Stop - complete session (phase 2) - fixes #842
|
||||
| 'user-message' // SessionStart (parallel) - display to user
|
||||
| 'file-edit'; // Cursor afterFileEdit
|
||||
|
||||
const handlers: Record<EventType, EventHandler> = {
|
||||
'context': contextHandler,
|
||||
'session-init': sessionInitHandler,
|
||||
'observation': observationHandler,
|
||||
'summarize': summarizeHandler,
|
||||
'session-complete': sessionCompleteHandler,
|
||||
'user-message': userMessageHandler,
|
||||
'file-edit': fileEditHandler
|
||||
};
|
||||
@@ -32,14 +36,22 @@ const handlers: Record<EventType, EventHandler> = {
|
||||
/**
|
||||
* Get the event handler for a given event type.
|
||||
*
|
||||
* Returns a no-op handler for unknown event types instead of throwing (fix #984).
|
||||
* Claude Code may send new event types that the plugin doesn't handle yet —
|
||||
* throwing would surface as a BLOCKING_ERROR to the user.
|
||||
*
|
||||
* @param eventType The type of event to handle
|
||||
* @returns The appropriate EventHandler
|
||||
* @throws Error if event type is not recognized
|
||||
* @returns The appropriate EventHandler, or a no-op handler for unknown types
|
||||
*/
|
||||
export function getEventHandler(eventType: EventType): EventHandler {
|
||||
const handler = handlers[eventType];
|
||||
export function getEventHandler(eventType: string): EventHandler {
|
||||
const handler = handlers[eventType as EventType];
|
||||
if (!handler) {
|
||||
throw new Error(`Unknown event type: ${eventType}`);
|
||||
console.error(`[claude-mem] Unknown event type: ${eventType}, returning no-op`);
|
||||
return {
|
||||
async execute() {
|
||||
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
|
||||
}
|
||||
};
|
||||
}
|
||||
return handler;
|
||||
}
|
||||
@@ -51,3 +63,4 @@ export { observationHandler } from './observation.js';
|
||||
export { summarizeHandler } from './summarize.js';
|
||||
export { userMessageHandler } from './user-message.js';
|
||||
export { fileEditHandler } from './file-edit.js';
|
||||
export { sessionCompleteHandler } from './session-complete.js';
|
||||
|
||||
@@ -7,11 +7,19 @@
|
||||
import type { EventHandler, NormalizedHookInput, HookResult } from '../types.js';
|
||||
import { ensureWorkerRunning, getWorkerPort } from '../../shared/worker-utils.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { HOOK_EXIT_CODES } from '../../shared/hook-constants.js';
|
||||
import { isProjectExcluded } from '../../utils/project-filter.js';
|
||||
import { SettingsDefaultsManager } from '../../shared/SettingsDefaultsManager.js';
|
||||
import { USER_SETTINGS_PATH } from '../../shared/paths.js';
|
||||
|
||||
export const observationHandler: EventHandler = {
|
||||
async execute(input: NormalizedHookInput): Promise<HookResult> {
|
||||
// Ensure worker is running before any other logic
|
||||
await ensureWorkerRunning();
|
||||
const workerReady = await ensureWorkerRunning();
|
||||
if (!workerReady) {
|
||||
// Worker not available - skip observation gracefully
|
||||
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
|
||||
}
|
||||
|
||||
const { sessionId, cwd, toolName, toolInput, toolResponse } = input;
|
||||
|
||||
@@ -32,25 +40,40 @@ export const observationHandler: EventHandler = {
|
||||
throw new Error(`Missing cwd in PostToolUse hook input for session ${sessionId}, tool ${toolName}`);
|
||||
}
|
||||
|
||||
// Send to worker - worker handles privacy check and database operations
|
||||
const response = await fetch(`http://127.0.0.1:${port}/api/sessions/observations`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
contentSessionId: sessionId,
|
||||
tool_name: toolName,
|
||||
tool_input: toolInput,
|
||||
tool_response: toolResponse,
|
||||
cwd
|
||||
})
|
||||
// Note: Removed signal to avoid Windows Bun cleanup issue (libuv assertion)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Observation storage failed: ${response.status}`);
|
||||
// Check if project is excluded from tracking
|
||||
const settings = SettingsDefaultsManager.loadFromFile(USER_SETTINGS_PATH);
|
||||
if (isProjectExcluded(cwd, settings.CLAUDE_MEM_EXCLUDED_PROJECTS)) {
|
||||
logger.debug('HOOK', 'Project excluded from tracking, skipping observation', { cwd, toolName });
|
||||
return { continue: true, suppressOutput: true };
|
||||
}
|
||||
|
||||
logger.debug('HOOK', 'Observation sent successfully', { toolName });
|
||||
// Send to worker - worker handles privacy check and database operations
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/api/sessions/observations`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
contentSessionId: sessionId,
|
||||
tool_name: toolName,
|
||||
tool_input: toolInput,
|
||||
tool_response: toolResponse,
|
||||
cwd
|
||||
})
|
||||
// Note: Removed signal to avoid Windows Bun cleanup issue (libuv assertion)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// Log but don't throw — observation storage failure should not block tool use
|
||||
logger.warn('HOOK', 'Observation storage failed, skipping', { status: response.status, toolName });
|
||||
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
|
||||
}
|
||||
|
||||
logger.debug('HOOK', 'Observation sent successfully', { toolName });
|
||||
} catch (error) {
|
||||
// Worker unreachable — skip observation gracefully
|
||||
logger.warn('HOOK', 'Observation fetch error, skipping', { error: error instanceof Error ? error.message : String(error) });
|
||||
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
|
||||
}
|
||||
|
||||
return { continue: true, suppressOutput: true };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Session Complete Handler - Stop (Phase 2)
|
||||
*
|
||||
* Completes the session after summarize has been queued.
|
||||
* This removes the session from the active sessions map, allowing
|
||||
* the orphan reaper to clean up any remaining subprocess.
|
||||
*
|
||||
* Fixes Issue #842: Orphan reaper starts but never reaps because
|
||||
* sessions stay in the active sessions map forever.
|
||||
*/
|
||||
|
||||
import type { EventHandler, NormalizedHookInput, HookResult } from '../types.js';
|
||||
import { ensureWorkerRunning, getWorkerPort } from '../../shared/worker-utils.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
|
||||
export const sessionCompleteHandler: EventHandler = {
|
||||
async execute(input: NormalizedHookInput): Promise<HookResult> {
|
||||
// Ensure worker is running
|
||||
const workerReady = await ensureWorkerRunning();
|
||||
if (!workerReady) {
|
||||
// Worker not available — skip session completion gracefully
|
||||
return { continue: true, suppressOutput: true };
|
||||
}
|
||||
|
||||
const { sessionId } = input;
|
||||
const port = getWorkerPort();
|
||||
|
||||
if (!sessionId) {
|
||||
logger.warn('HOOK', 'session-complete: Missing sessionId, skipping');
|
||||
return { continue: true, suppressOutput: true };
|
||||
}
|
||||
|
||||
logger.info('HOOK', '→ session-complete: Removing session from active map', {
|
||||
workerPort: port,
|
||||
contentSessionId: sessionId
|
||||
});
|
||||
|
||||
try {
|
||||
// Call the session complete endpoint by contentSessionId
|
||||
const response = await fetch(`http://127.0.0.1:${port}/api/sessions/complete`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
contentSessionId: sessionId
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
logger.warn('HOOK', 'session-complete: Failed to complete session', {
|
||||
status: response.status,
|
||||
body: text
|
||||
});
|
||||
} else {
|
||||
logger.info('HOOK', 'Session completed successfully', { contentSessionId: sessionId });
|
||||
}
|
||||
} catch (error) {
|
||||
// Log but don't fail - session may already be gone
|
||||
logger.warn('HOOK', 'session-complete: Error completing session', {
|
||||
error: (error as Error).message
|
||||
});
|
||||
}
|
||||
|
||||
return { continue: true, suppressOutput: true };
|
||||
}
|
||||
};
|
||||
@@ -8,18 +8,33 @@ import type { EventHandler, NormalizedHookInput, HookResult } from '../types.js'
|
||||
import { ensureWorkerRunning, getWorkerPort } from '../../shared/worker-utils.js';
|
||||
import { getProjectName } from '../../utils/project-name.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { HOOK_EXIT_CODES } from '../../shared/hook-constants.js';
|
||||
import { isProjectExcluded } from '../../utils/project-filter.js';
|
||||
import { SettingsDefaultsManager } from '../../shared/SettingsDefaultsManager.js';
|
||||
import { USER_SETTINGS_PATH } from '../../shared/paths.js';
|
||||
|
||||
export const sessionInitHandler: EventHandler = {
|
||||
async execute(input: NormalizedHookInput): Promise<HookResult> {
|
||||
// Ensure worker is running before any other logic
|
||||
await ensureWorkerRunning();
|
||||
|
||||
const { sessionId, cwd, prompt } = input;
|
||||
|
||||
if (!prompt) {
|
||||
throw new Error('sessionInitHandler requires prompt');
|
||||
const workerReady = await ensureWorkerRunning();
|
||||
if (!workerReady) {
|
||||
// Worker not available - skip session init gracefully
|
||||
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
|
||||
}
|
||||
|
||||
const { sessionId, cwd, prompt: rawPrompt } = input;
|
||||
|
||||
// Check if project is excluded from tracking
|
||||
const settings = SettingsDefaultsManager.loadFromFile(USER_SETTINGS_PATH);
|
||||
if (cwd && isProjectExcluded(cwd, settings.CLAUDE_MEM_EXCLUDED_PROJECTS)) {
|
||||
logger.info('HOOK', 'Project excluded from tracking', { cwd });
|
||||
return { continue: true, suppressOutput: true };
|
||||
}
|
||||
|
||||
// Handle image-only prompts (where text prompt is empty/undefined)
|
||||
// Use placeholder so sessions still get created and tracked for memory
|
||||
const prompt = (!rawPrompt || !rawPrompt.trim()) ? '[media prompt]' : rawPrompt;
|
||||
|
||||
const project = getProjectName(cwd);
|
||||
const port = getWorkerPort();
|
||||
|
||||
@@ -38,7 +53,9 @@ export const sessionInitHandler: EventHandler = {
|
||||
});
|
||||
|
||||
if (!initResponse.ok) {
|
||||
throw new Error(`Session initialization failed: ${initResponse.status}`);
|
||||
// Log but don't throw - a worker 500 should not block the user's prompt
|
||||
logger.failure('HOOK', `Session initialization failed: ${initResponse.status}`, { contentSessionId: sessionId, project });
|
||||
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
|
||||
}
|
||||
|
||||
const initResult = await initResponse.json() as {
|
||||
@@ -81,7 +98,8 @@ export const sessionInitHandler: EventHandler = {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`SDK agent start failed: ${response.status}`);
|
||||
// Log but don't throw - SDK agent failure should not block the user's prompt
|
||||
logger.failure('HOOK', `SDK agent start failed: ${response.status}`, { sessionDbId, promptNumber });
|
||||
}
|
||||
} else if (input.platform === 'cursor') {
|
||||
logger.debug('HOOK', 'session-init: Skipping SDK agent init for Cursor platform', { sessionDbId, promptNumber });
|
||||
|
||||
@@ -7,14 +7,21 @@
|
||||
*/
|
||||
|
||||
import type { EventHandler, NormalizedHookInput, HookResult } from '../types.js';
|
||||
import { ensureWorkerRunning, getWorkerPort } from '../../shared/worker-utils.js';
|
||||
import { ensureWorkerRunning, getWorkerPort, fetchWithTimeout } from '../../shared/worker-utils.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { extractLastMessage } from '../../shared/transcript-parser.js';
|
||||
import { HOOK_EXIT_CODES, HOOK_TIMEOUTS, getTimeout } from '../../shared/hook-constants.js';
|
||||
|
||||
const SUMMARIZE_TIMEOUT_MS = getTimeout(HOOK_TIMEOUTS.DEFAULT);
|
||||
|
||||
export const summarizeHandler: EventHandler = {
|
||||
async execute(input: NormalizedHookInput): Promise<HookResult> {
|
||||
// Ensure worker is running before any other logic
|
||||
await ensureWorkerRunning();
|
||||
const workerReady = await ensureWorkerRunning();
|
||||
if (!workerReady) {
|
||||
// Worker not available - skip summary gracefully
|
||||
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
|
||||
}
|
||||
|
||||
const { sessionId, transcriptPath } = input;
|
||||
|
||||
@@ -36,15 +43,18 @@ export const summarizeHandler: EventHandler = {
|
||||
});
|
||||
|
||||
// Send to worker - worker handles privacy check and database operations
|
||||
const response = await fetch(`http://127.0.0.1:${port}/api/sessions/summarize`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
contentSessionId: sessionId,
|
||||
last_assistant_message: lastAssistantMessage
|
||||
})
|
||||
// Note: Removed signal to avoid Windows Bun cleanup issue (libuv assertion)
|
||||
});
|
||||
const response = await fetchWithTimeout(
|
||||
`http://127.0.0.1:${port}/api/sessions/summarize`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
contentSessionId: sessionId,
|
||||
last_assistant_message: lastAssistantMessage
|
||||
}),
|
||||
},
|
||||
SUMMARIZE_TIMEOUT_MS
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
// Return standard response even on failure (matches original behavior)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* User Message Handler - SessionStart (parallel)
|
||||
*
|
||||
* Extracted from user-message-hook.ts - displays context info to user via stderr.
|
||||
* Uses exit code 3 to show user message without injecting into Claude's context.
|
||||
* Displays context info to user via stderr.
|
||||
* Uses exit code 0 (SUCCESS) - stderr is not shown to Claude with exit 0.
|
||||
*/
|
||||
|
||||
import { basename } from 'path';
|
||||
@@ -13,34 +13,46 @@ import { HOOK_EXIT_CODES } from '../../shared/hook-constants.js';
|
||||
export const userMessageHandler: EventHandler = {
|
||||
async execute(input: NormalizedHookInput): Promise<HookResult> {
|
||||
// Ensure worker is running
|
||||
await ensureWorkerRunning();
|
||||
const workerReady = await ensureWorkerRunning();
|
||||
if (!workerReady) {
|
||||
// Worker not available — skip user message gracefully
|
||||
return { exitCode: HOOK_EXIT_CODES.SUCCESS };
|
||||
}
|
||||
|
||||
const port = getWorkerPort();
|
||||
const project = basename(input.cwd ?? process.cwd());
|
||||
|
||||
// Fetch formatted context directly from worker API
|
||||
// Note: Removed AbortSignal.timeout to avoid Windows Bun cleanup issue (libuv assertion)
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:${port}/api/context/inject?project=${encodeURIComponent(project)}&colors=true`,
|
||||
{ method: 'GET' }
|
||||
);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:${port}/api/context/inject?project=${encodeURIComponent(project)}&colors=true`,
|
||||
{ method: 'GET' }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch context: ${response.status}`);
|
||||
if (!response.ok) {
|
||||
// Don't throw - context fetch failure should not block the user's prompt
|
||||
return { exitCode: HOOK_EXIT_CODES.SUCCESS };
|
||||
}
|
||||
|
||||
const output = await response.text();
|
||||
|
||||
// Write to stderr for user visibility
|
||||
// Note: Using process.stderr.write instead of console.error to avoid
|
||||
// Claude Code treating this as a hook error. The actual hook output
|
||||
// goes to stdout via hook-command.ts JSON serialization.
|
||||
process.stderr.write(
|
||||
"\n\n" + String.fromCodePoint(0x1F4DD) + " Claude-Mem Context Loaded\n\n" +
|
||||
output +
|
||||
"\n\n" + String.fromCodePoint(0x1F4A1) + " Wrap any message with <private> ... </private> to prevent storing sensitive information.\n" +
|
||||
"\n" + String.fromCodePoint(0x1F4AC) + " Community https://discord.gg/J4wttp9vDu" +
|
||||
`\n` + String.fromCodePoint(0x1F4FA) + ` Watch live in browser http://localhost:${port}/\n`
|
||||
);
|
||||
} catch (error) {
|
||||
// Worker unreachable — skip user message gracefully
|
||||
// User message context error is non-critical — skip gracefully
|
||||
}
|
||||
|
||||
const output = await response.text();
|
||||
|
||||
// Write to stderr for user visibility (Claude Code UI shows stderr)
|
||||
console.error(
|
||||
"\n\n" + String.fromCodePoint(0x1F4DD) + " Claude-Mem Context Loaded\n" +
|
||||
" " + String.fromCodePoint(0x2139, 0xFE0F) + " Note: This appears as stderr but is informational only\n\n" +
|
||||
output +
|
||||
"\n\n" + String.fromCodePoint(0x1F4A1) + " New! Wrap all or part of any message with <private> ... </private> to prevent storing sensitive information in your observation history.\n" +
|
||||
"\n" + String.fromCodePoint(0x1F4AC) + " Community https://discord.gg/J4wttp9vDu" +
|
||||
`\n` + String.fromCodePoint(0x1F4FA) + ` Watch live in browser http://localhost:${port}/\n`
|
||||
);
|
||||
|
||||
return { exitCode: HOOK_EXIT_CODES.USER_MESSAGE_ONLY };
|
||||
return { exitCode: HOOK_EXIT_CODES.SUCCESS };
|
||||
}
|
||||
};
|
||||
|
||||
+81
-5
@@ -3,7 +3,68 @@ import { getPlatformAdapter } from './adapters/index.js';
|
||||
import { getEventHandler } from './handlers/index.js';
|
||||
import { HOOK_EXIT_CODES } from '../shared/hook-constants.js';
|
||||
|
||||
export async function hookCommand(platform: string, event: string): Promise<void> {
|
||||
export interface HookCommandOptions {
|
||||
/** If true, don't call process.exit() - let caller handle process lifecycle */
|
||||
skipExit?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify whether an error indicates the worker is unavailable (graceful degradation)
|
||||
* vs a handler/client bug (blocking error that developers need to see).
|
||||
*
|
||||
* Exit 0 (graceful degradation):
|
||||
* - Transport failures: ECONNREFUSED, ECONNRESET, EPIPE, ETIMEDOUT, fetch failed
|
||||
* - Timeout errors: timed out, timeout
|
||||
* - Server errors: HTTP 5xx status codes
|
||||
*
|
||||
* Exit 2 (blocking error — handler/client bug):
|
||||
* - HTTP 4xx status codes (bad request, not found, validation error)
|
||||
* - Programming errors (TypeError, ReferenceError, SyntaxError)
|
||||
* - All other unexpected errors
|
||||
*/
|
||||
export function isWorkerUnavailableError(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const lower = message.toLowerCase();
|
||||
|
||||
// Transport failures — worker unreachable
|
||||
const transportPatterns = [
|
||||
'econnrefused',
|
||||
'econnreset',
|
||||
'epipe',
|
||||
'etimedout',
|
||||
'enotfound',
|
||||
'econnaborted',
|
||||
'enetunreach',
|
||||
'ehostunreach',
|
||||
'fetch failed',
|
||||
'unable to connect',
|
||||
'socket hang up',
|
||||
];
|
||||
if (transportPatterns.some(p => lower.includes(p))) return true;
|
||||
|
||||
// Timeout errors — worker didn't respond in time
|
||||
if (lower.includes('timed out') || lower.includes('timeout')) return true;
|
||||
|
||||
// HTTP 5xx server errors — worker has internal problems
|
||||
if (/failed:\s*5\d{2}/.test(message) || /status[:\s]+5\d{2}/.test(message)) return true;
|
||||
|
||||
// HTTP 429 (rate limit) — treat as transient unavailability, not a bug
|
||||
if (/failed:\s*429/.test(message) || /status[:\s]+429/.test(message)) return true;
|
||||
|
||||
// HTTP 4xx client errors — our bug, NOT worker unavailability
|
||||
if (/failed:\s*4\d{2}/.test(message) || /status[:\s]+4\d{2}/.test(message)) return false;
|
||||
|
||||
// Programming errors — code bugs, not worker unavailability
|
||||
// Note: TypeError('fetch failed') already handled by transport patterns above
|
||||
if (error instanceof TypeError || error instanceof ReferenceError || error instanceof SyntaxError) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Default: treat unknown errors as blocking (conservative — surface bugs)
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function hookCommand(platform: string, event: string, options: HookCommandOptions = {}): Promise<number> {
|
||||
try {
|
||||
const adapter = getPlatformAdapter(platform);
|
||||
const handler = getEventHandler(event);
|
||||
@@ -15,11 +76,26 @@ export async function hookCommand(platform: string, event: string): Promise<void
|
||||
const output = adapter.formatOutput(result);
|
||||
|
||||
console.log(JSON.stringify(output));
|
||||
process.exit(result.exitCode ?? HOOK_EXIT_CODES.SUCCESS);
|
||||
const exitCode = result.exitCode ?? HOOK_EXIT_CODES.SUCCESS;
|
||||
if (!options.skipExit) {
|
||||
process.exit(exitCode);
|
||||
}
|
||||
return exitCode;
|
||||
} catch (error) {
|
||||
if (isWorkerUnavailableError(error)) {
|
||||
// Worker unavailable — degrade gracefully, don't block the user
|
||||
console.error(`[claude-mem] Worker unavailable, skipping hook: ${error instanceof Error ? error.message : error}`);
|
||||
if (!options.skipExit) {
|
||||
process.exit(HOOK_EXIT_CODES.SUCCESS); // = 0 (graceful)
|
||||
}
|
||||
return HOOK_EXIT_CODES.SUCCESS;
|
||||
}
|
||||
|
||||
// Handler/client bug — show as blocking error so developers see it
|
||||
console.error(`Hook error: ${error}`);
|
||||
// Use exit code 2 (blocking error) so users see the error message
|
||||
// Exit code 1 only shows in verbose mode per Claude Code docs
|
||||
process.exit(HOOK_EXIT_CODES.BLOCKING_ERROR); // = 2
|
||||
if (!options.skipExit) {
|
||||
process.exit(HOOK_EXIT_CODES.BLOCKING_ERROR); // = 2
|
||||
}
|
||||
return HOOK_EXIT_CODES.BLOCKING_ERROR;
|
||||
}
|
||||
}
|
||||
|
||||
+170
-8
@@ -1,16 +1,178 @@
|
||||
// Stdin reading utility extracted from hook patterns
|
||||
// See src/hooks/save-hook.ts for the original pattern
|
||||
// Stdin reading utility for Claude Code hooks
|
||||
//
|
||||
// Problem: Claude Code doesn't close stdin after writing hook input,
|
||||
// so stdin.on('end') never fires and hooks hang indefinitely (#727).
|
||||
//
|
||||
// Solution: JSON is self-delimiting. We detect complete JSON by attempting
|
||||
// to parse after each chunk. Once we have valid JSON, we resolve immediately
|
||||
// without waiting for EOF. This is the proper fix, not a timeout workaround.
|
||||
|
||||
/**
|
||||
* Check if stdin is available and readable.
|
||||
*
|
||||
* Bun has a bug where accessing process.stdin can crash with EINVAL
|
||||
* if Claude Code doesn't provide a valid stdin file descriptor (#646).
|
||||
* This function safely checks if stdin is usable.
|
||||
*/
|
||||
function isStdinAvailable(): boolean {
|
||||
try {
|
||||
const stdin = process.stdin;
|
||||
|
||||
// If stdin is a TTY, we're running interactively (not from Claude Code hook)
|
||||
if (stdin.isTTY) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Accessing stdin.readable triggers Bun's lazy initialization.
|
||||
// If we get here without throwing, stdin is available.
|
||||
// Note: We don't check the value since Node/Bun don't reliably set it to false.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
stdin.readable;
|
||||
return true;
|
||||
} catch {
|
||||
// Bun crashed trying to access stdin (EINVAL from fstat)
|
||||
// This is expected when Claude Code doesn't provide valid stdin
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to parse the accumulated input as JSON.
|
||||
* Returns the parsed value if successful, undefined if incomplete/invalid.
|
||||
*/
|
||||
function tryParseJson(input: string): { success: true; value: unknown } | { success: false } {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) {
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
try {
|
||||
const value = JSON.parse(trimmed);
|
||||
return { success: true, value };
|
||||
} catch {
|
||||
// JSON is incomplete or invalid
|
||||
return { success: false };
|
||||
}
|
||||
}
|
||||
|
||||
// Safety timeout - only kicks in if JSON never completes (malformed input).
|
||||
// This should rarely/never be hit in normal operation since we detect complete JSON.
|
||||
const SAFETY_TIMEOUT_MS = 30000;
|
||||
|
||||
// Short delay after last data chunk to try parsing
|
||||
// This handles the case where JSON arrives in multiple chunks
|
||||
const PARSE_DELAY_MS = 50;
|
||||
|
||||
export async function readJsonFromStdin(): Promise<unknown> {
|
||||
// First, check if stdin is even available
|
||||
// This catches the Bun EINVAL crash from issue #646
|
||||
if (!isStdinAvailable()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let input = '';
|
||||
process.stdin.on('data', (chunk) => input += chunk);
|
||||
process.stdin.on('end', () => {
|
||||
let resolved = false;
|
||||
let parseDelayId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const cleanup = () => {
|
||||
try {
|
||||
resolve(input.trim() ? JSON.parse(input) : undefined);
|
||||
} catch (e) {
|
||||
reject(new Error(`Failed to parse hook input: ${e}`));
|
||||
process.stdin.removeAllListeners('data');
|
||||
process.stdin.removeAllListeners('end');
|
||||
process.stdin.removeAllListeners('error');
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const resolveWith = (value: unknown) => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
if (parseDelayId) clearTimeout(parseDelayId);
|
||||
clearTimeout(safetyTimeoutId);
|
||||
cleanup();
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
const rejectWith = (error: Error) => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
if (parseDelayId) clearTimeout(parseDelayId);
|
||||
clearTimeout(safetyTimeoutId);
|
||||
cleanup();
|
||||
reject(error);
|
||||
};
|
||||
|
||||
const tryResolveWithJson = () => {
|
||||
const result = tryParseJson(input);
|
||||
if (result.success) {
|
||||
resolveWith(result.value);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Safety timeout - fallback if JSON never completes
|
||||
const safetyTimeoutId = setTimeout(() => {
|
||||
if (!resolved) {
|
||||
// Try one final parse attempt
|
||||
if (!tryResolveWithJson()) {
|
||||
// If we have data but it's not valid JSON, that's an error
|
||||
if (input.trim()) {
|
||||
rejectWith(new Error(`Incomplete JSON after ${SAFETY_TIMEOUT_MS}ms: ${input.slice(0, 100)}...`));
|
||||
} else {
|
||||
// No data received - resolve with undefined
|
||||
resolveWith(undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, SAFETY_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
process.stdin.on('data', (chunk) => {
|
||||
input += chunk;
|
||||
|
||||
// Clear any pending parse delay
|
||||
if (parseDelayId) {
|
||||
clearTimeout(parseDelayId);
|
||||
parseDelayId = null;
|
||||
}
|
||||
|
||||
// Try to parse immediately - if JSON is complete, resolve now
|
||||
if (tryResolveWithJson()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If immediate parse failed, set a short delay and try again
|
||||
// This handles multi-chunk delivery where the last chunk completes the JSON
|
||||
parseDelayId = setTimeout(() => {
|
||||
tryResolveWithJson();
|
||||
}, PARSE_DELAY_MS);
|
||||
});
|
||||
|
||||
process.stdin.on('end', () => {
|
||||
// stdin closed - parse whatever we have
|
||||
if (!resolved) {
|
||||
if (!tryResolveWithJson()) {
|
||||
// Empty or invalid - resolve with undefined
|
||||
resolveWith(input.trim() ? undefined : undefined);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
process.stdin.on('error', () => {
|
||||
if (!resolved) {
|
||||
// Don't reject on stdin errors - just return undefined
|
||||
// This is more graceful for hook execution
|
||||
resolveWith(undefined);
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
// If attaching listeners fails (Bun stdin issue), resolve with undefined
|
||||
resolved = true;
|
||||
clearTimeout(safetyTimeoutId);
|
||||
cleanup();
|
||||
resolve(undefined);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Nov 25, 2025
|
||||
|
||||
**api.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #15545 | 8:37 PM | 🔵 | API Constants File Contains Single Comment Reference | ~227 |
|
||||
|
||||
### Dec 7, 2025
|
||||
|
||||
**observation-metadata.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #21685 | 9:48 PM | 🔵 | Configuration Defaults and Environment Variables | ~558 |
|
||||
|
||||
### Dec 9, 2025
|
||||
|
||||
**observation-metadata.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #22672 | 12:10 PM | 🔵 | Observation Type System with Six Types and Seven Concepts | ~505 |
|
||||
|
||||
### Dec 11, 2025
|
||||
|
||||
**observation-metadata.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #23959 | 1:58 PM | 🔵 | TypeScript Codebase Architecture Mapped | ~337 |
|
||||
|
||||
### Dec 18, 2025
|
||||
|
||||
**observation-metadata.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #29773 | 7:01 PM | 🔵 | Observation Type Definitions Across Codebase | ~362 |
|
||||
| #29248 | 12:15 AM | ⚖️ | RAGTIME domain-agnostic architecture design for claude-mem | ~590 |
|
||||
| #29229 | 12:08 AM | 🔵 | Claude-Mem Observation Type System Architecture Mapped | ~552 |
|
||||
| #29220 | 12:04 AM | 🔵 | Observation Type and Concept Taxonomy | ~355 |
|
||||
|
||||
### Dec 21, 2025
|
||||
|
||||
**observation-metadata.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #31747 | 10:43 PM | 🔵 | PR #412 Code Review Identifies Two Critical Bugs in Mode System | ~545 |
|
||||
| #31433 | 6:58 PM | 🔄 | Simplified observation-metadata.ts to use hardcoded defaults | ~330 |
|
||||
| #31429 | 6:57 PM | 🔄 | Removed unused emoji mapping constants from observation metadata | ~245 |
|
||||
| #31423 | 6:50 PM | 🔵 | Observation Metadata Constants File Structure | ~327 |
|
||||
| #31329 | 5:45 PM | 🔵 | Observation Metadata Integration Across Services and UI | ~403 |
|
||||
| #31328 | " | 🔵 | Settings Defaults Manager Uses Observation Metadata Constants | ~286 |
|
||||
| #31327 | " | 🔵 | Observation Metadata Constants - Core Type and Concept Definitions | ~369 |
|
||||
|
||||
### Dec 25, 2025
|
||||
|
||||
**observation-metadata.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #32701 | 9:00 PM | 🔵 | Test Coverage Report Generated | ~471 |
|
||||
|
||||
### Jan 2, 2026
|
||||
|
||||
**observation-metadata.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #35875 | 2:39 PM | 🔵 | Logging UI Architecture Mapped | ~599 |
|
||||
| #35836 | 2:30 PM | 🔵 | Observation metadata constants for types and concepts | ~280 |
|
||||
</claude-mem-context>
|
||||
@@ -1,105 +0,0 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Dec 9, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #23126 | 6:40 PM | ✅ | Removed SKIP_TOOLS Check from saveHook Function | ~288 |
|
||||
| #23125 | " | 🔵 | SKIP_TOOLS Reference Still Present in saveHook Function | ~224 |
|
||||
| #23124 | " | ✅ | Removed SKIP_TOOLS Constant from save-hook.ts | ~297 |
|
||||
| #23123 | 6:39 PM | 🔵 | Current SKIP_TOOLS Implementation in save-hook.ts | ~397 |
|
||||
| #23122 | " | 🔴 | Hardened Spinner Stop Mechanism with Timeout and Logging | ~361 |
|
||||
| #23121 | " | 🔵 | Current Spinner Stop Implementation in summary-hook.ts | ~348 |
|
||||
| #23118 | 6:38 PM | ✅ | Phase 6: StopInput Interface Type Safety Restored | ~248 |
|
||||
| #23117 | 6:37 PM | ✅ | Phase 6: PostToolUseInput Interface Type Safety Restored | ~222 |
|
||||
| #23116 | " | ✅ | Phase 6: UserPromptSubmitInput Interface Type Safety Restored | ~216 |
|
||||
| #23115 | " | ✅ | Phase 6: SessionStartInput Interface Type Safety Restored | ~341 |
|
||||
| #23114 | " | 🔵 | Current State of context-hook.ts Interface | ~409 |
|
||||
| #23113 | " | 🔵 | Current State of summary-hook.ts Interface and Spinner Stop | ~397 |
|
||||
| #23112 | " | 🔵 | Current State of save-hook.ts Interface and SKIP_TOOLS | ~395 |
|
||||
| #23111 | " | 🔵 | Current State of new-hook.ts Interface | ~381 |
|
||||
| #23076 | 6:27 PM | ✅ | Added Comment Explaining Exit Code 3 in user-message-hook.ts | ~245 |
|
||||
| #23075 | 6:26 PM | ✅ | Deleted Expired Announcement Code from user-message-hook.ts | ~354 |
|
||||
| #23074 | " | ✅ | Replaced Verbose Manual Mode Help with Error in cleanup-hook.ts | ~222 |
|
||||
| #23073 | " | ✅ | Removed cwd from cleanup-hook Debug Logging | ~177 |
|
||||
|
||||
### Dec 10, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #23407 | 2:14 PM | 🔵 | New Hook Implementation Structure | ~264 |
|
||||
|
||||
### Dec 13, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #25389 | 9:30 PM | 🔴 | Save Hook Error Logging Enhanced With Tool Context | ~361 |
|
||||
| #25388 | " | 🔴 | New Hook Now Logs SDK Agent Start Errors | ~344 |
|
||||
| #25387 | 9:29 PM | 🔴 | New Hook Now Logs Session Initialization Errors | ~350 |
|
||||
| #25386 | " | 🔴 | Context Hook Now Logs Error Text Before Throwing | ~338 |
|
||||
| #25385 | " | ✅ | Added Logger Import to New Hook | ~249 |
|
||||
| #25384 | " | ✅ | Added Logger Import to Context Hook | ~223 |
|
||||
| #25383 | " | 🔵 | New Hook Has Two Silent Failure Points | ~392 |
|
||||
| #25382 | 9:28 PM | 🔵 | Save Hook Has Partial Error Logging | ~351 |
|
||||
| #25381 | " | 🔵 | Summary Hook Has Partial Error Logging | ~345 |
|
||||
| #25380 | " | 🔵 | Context Hook Silent Failure Pattern Confirmed | ~354 |
|
||||
|
||||
### Dec 14, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #26730 | 11:24 PM | 🔵 | Context Hook TypeScript Source Shows EnsureWorkerRunning as First Action | ~441 |
|
||||
| #26729 | " | 🔵 | Context Hook TypeScript Source Calls ensureWorkerRunning Before API Requests | ~411 |
|
||||
| #26260 | 8:32 PM | 🔵 | User Message Hook Calls Context Inject with colors=true Parameter | ~300 |
|
||||
| #26244 | 8:29 PM | 🔵 | Context Hook Delegates to Worker API Context Endpoint | ~260 |
|
||||
| #25692 | 4:24 PM | 🔵 | Summary hook extracts last user and assistant messages from transcript file before sending to worker | ~465 |
|
||||
|
||||
### Dec 17, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #28449 | 4:23 PM | 🔵 | New Hook Session Initialization Flow | ~385 |
|
||||
|
||||
### Dec 19, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #30105 | 8:11 PM | 🔵 | Hook Response Utility Standardizes Hook Output Format | ~387 |
|
||||
| #30103 | 8:10 PM | 🔵 | Context Hook Injects Mode-Based Memory Context During SessionStart | ~460 |
|
||||
|
||||
### Dec 20, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #31085 | 7:59 PM | 🔵 | Summary Hook Uses session_id from Hook Input | ~315 |
|
||||
|
||||
### Dec 27, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #33216 | 9:07 PM | 🔵 | UserPromptSubmit Hook (new-hook.ts) Initializes Session and Starts SDK Agent via Two HTTP Endpoints | ~735 |
|
||||
| #33211 | 9:04 PM | 🔵 | User Message Hook Displays Context Info via stderr in Parallel with Context Injection | ~476 |
|
||||
| #33210 | 9:03 PM | 🔵 | Summary Hook (summary-hook.ts) Extracts Messages and Triggers Summarization | ~479 |
|
||||
| #33209 | " | 🔵 | SessionStart Hook (context-hook.ts) Fetches Context Injection via HTTP | ~520 |
|
||||
|
||||
### Jan 7, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #38235 | 7:42 PM | ✅ | Deprecated User Message Hook Source File | ~399 |
|
||||
| #38176 | 7:26 PM | ⚖️ | Plan Created to Merge User Message into Context Hook JSON Output | ~536 |
|
||||
| #38175 | " | 🔵 | Complete Claude-Mem Hook Output Architecture Documented | ~530 |
|
||||
| #38174 | " | 🔵 | UserPromptSubmit Hook Initializes Sessions and Strips Slash Commands | ~480 |
|
||||
| #38173 | 7:25 PM | 🔵 | Standard Hook Response Pattern for Non-SessionStart Hooks | ~343 |
|
||||
| #38172 | 7:22 PM | 🔵 | Claude Code Hook Output Architecture Clarified - Exit Code Pattern is Correct for User-Only Display | ~523 |
|
||||
| #38170 | 7:21 PM | 🔵 | User-Message-Hook TypeScript Source Shows Exit Code 1 Strategy for User-Only Display | ~203 |
|
||||
|
||||
### Jan 9, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #38783 | 4:59 PM | 🔵 | Standard Hook Response Pattern | ~263 |
|
||||
</claude-mem-context>
|
||||
@@ -1,21 +0,0 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Jan 5, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #37703 | 6:01 PM | 🔵 | ParsedObservation files_read and files_modified are string arrays parsed from XML | ~330 |
|
||||
| #37701 | " | 🔵 | Complete cwd data flow traced from hooks through observation processing | ~447 |
|
||||
|
||||
### Jan 7, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #38467 | 10:29 PM | ⚖️ | Log Level Audit Strategy: Tighten ERROR Messages for Runtime Issue Discovery | ~464 |
|
||||
| #38454 | 10:26 PM | 🔵 | happyPathError usage pattern in summary prompt generation | ~421 |
|
||||
| #38405 | 10:07 PM | ⚖️ | DEBUG Log Level Analysis - One Message Requires WARN Promotion | ~819 |
|
||||
| #38404 | 10:06 PM | ⚖️ | Log Level Audit Analysis - WARN to ERROR Promotion Criteria Established | ~769 |
|
||||
</claude-mem-context>
|
||||
+14
-6
@@ -162,9 +162,13 @@ export function parseSummary(text: string, sessionId?: number): ParsedSummary |
|
||||
/**
|
||||
* Extract a simple field value from XML content
|
||||
* Returns null for missing or empty/whitespace-only fields
|
||||
*
|
||||
* Uses non-greedy match to handle nested tags and code snippets (Issue #798)
|
||||
*/
|
||||
function extractField(content: string, fieldName: string): string | null {
|
||||
const regex = new RegExp(`<${fieldName}>([^<]*)</${fieldName}>`);
|
||||
// Use [\s\S]*? to match any character including newlines, non-greedily
|
||||
// This handles nested XML tags like <item>...</item> inside the field
|
||||
const regex = new RegExp(`<${fieldName}>([\\s\\S]*?)</${fieldName}>`);
|
||||
const match = regex.exec(content);
|
||||
if (!match) return null;
|
||||
|
||||
@@ -174,12 +178,13 @@ function extractField(content: string, fieldName: string): string | null {
|
||||
|
||||
/**
|
||||
* Extract array of elements from XML content
|
||||
* Handles nested tags and code snippets (Issue #798)
|
||||
*/
|
||||
function extractArrayElements(content: string, arrayName: string, elementName: string): string[] {
|
||||
const elements: string[] = [];
|
||||
|
||||
// Match the array block
|
||||
const arrayRegex = new RegExp(`<${arrayName}>(.*?)</${arrayName}>`, 's');
|
||||
// Match the array block using [\s\S]*? for nested content
|
||||
const arrayRegex = new RegExp(`<${arrayName}>([\\s\\S]*?)</${arrayName}>`);
|
||||
const arrayMatch = arrayRegex.exec(content);
|
||||
|
||||
if (!arrayMatch) {
|
||||
@@ -188,11 +193,14 @@ function extractArrayElements(content: string, arrayName: string, elementName: s
|
||||
|
||||
const arrayContent = arrayMatch[1];
|
||||
|
||||
// Extract individual elements
|
||||
const elementRegex = new RegExp(`<${elementName}>([^<]+)</${elementName}>`, 'g');
|
||||
// Extract individual elements using [\s\S]*? for nested content
|
||||
const elementRegex = new RegExp(`<${elementName}>([\\s\\S]*?)</${elementName}>`, 'g');
|
||||
let elementMatch;
|
||||
while ((elementMatch = elementRegex.exec(arrayContent)) !== null) {
|
||||
elements.push(elementMatch[1].trim());
|
||||
const trimmed = elementMatch[1].trim();
|
||||
if (trimmed) {
|
||||
elements.push(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
return elements;
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Nov 6, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #4185 | 10:25 PM | 🔴 | Prefixed unused id parameters with underscore in filter callbacks | ~299 |
|
||||
|
||||
### Nov 8, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #5539 | 10:20 PM | 🔵 | Harsh critical audit of context-hook reveals systematic anti-patterns | ~3154 |
|
||||
| #5497 | 9:29 PM | 🔵 | Harsh critical audit of context-hook reveals systematic anti-patterns | ~2815 |
|
||||
|
||||
### Nov 9, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #5757 | 5:16 PM | 🔵 | MCP search server exposes 9 tools consuming ~2,000-3,000 tokens per session | ~421 |
|
||||
| #5754 | 5:14 PM | 🔵 | MCP search server provides 9 search tools with hybrid semantic/FTS5 | ~402 |
|
||||
|
||||
### Nov 10, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #6250 | 12:54 PM | 🔵 | MCP Search Server Connection Failure Reported | ~329 |
|
||||
|
||||
### Nov 17, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #10744 | 11:47 PM | ✅ | Search Query Parameter Made Optional for Filter-Only Queries | ~373 |
|
||||
| #10572 | 7:47 PM | 🟣 | Unified cross-type search with search_everything tool | ~501 |
|
||||
| #10571 | 7:46 PM | 🔵 | Search server architecture and hybrid search implementation | ~553 |
|
||||
|
||||
### Nov 18, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #11462 | 7:55 PM | 🔵 | Ready to Apply Fix to Contextualize Handler | ~261 |
|
||||
| #11460 | " | 🔴 | Identified Root Cause of Contextualize Endpoint Bug | ~413 |
|
||||
| #11454 | 7:54 PM | 🔵 | Unified Search Handler Shows Correct Pattern for Filter-Only Queries | ~334 |
|
||||
| #11447 | " | 🔵 | Contextualize Handler Calls Search Methods with Query='*' | ~279 |
|
||||
| #11432 | 7:52 PM | 🔵 | Contextualize Handler Formats Results with Sections | ~286 |
|
||||
| #11431 | 7:51 PM | 🔵 | Confirmed Empty Results Trigger in Contextualize Handler | ~289 |
|
||||
| #11430 | " | 🔵 | Contextualize Handler Implementation Uses Search Methods | ~424 |
|
||||
| #11429 | " | 🔵 | Search Server Defines Six Main Search Tools | ~358 |
|
||||
| #11428 | " | 🔵 | Contextualize Tool Definition Found in Search Server | ~357 |
|
||||
| #11332 | 3:55 PM | 🔵 | Comprehensive FTS5 Removal Audit Completed for Architecture Migration | ~792 |
|
||||
| #11206 | 3:01 PM | 🔵 | mem-search skill architecture and migration details retrieved in full format | ~538 |
|
||||
| #11181 | 4:09 AM | 🔵 | Store methods for ID-based lookups exist but not exposed as MCP tools | ~495 |
|
||||
| #11013 | 2:12 AM | 🔵 | Search Server Implements Three-Path Query Strategy with ChromaDB Primary and FTS5 Fallback | ~462 |
|
||||
|
||||
### Nov 28, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #16711 | 4:34 PM | 🟣 | include_inactive Parameter Extracted in Search Handler | ~369 |
|
||||
| #16710 | " | 🔵 | Search Tool Schema Definition with Type and Filter Parameters | ~527 |
|
||||
| #16708 | " | 🔵 | Search Server MCP Tool Architecture and ChromaDB Integration | ~491 |
|
||||
| #16682 | 4:10 PM | 🔵 | Comprehensive Exploration Task Completed on Observation System | ~601 |
|
||||
|
||||
### Dec 14, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #26238 | 8:28 PM | 🔵 | MCP Server Architecture Maps Tools to Worker API Endpoints | ~355 |
|
||||
| #26138 | 7:55 PM | ✅ | Updated Comment to Reference progressive_description Tool | ~238 |
|
||||
| #26137 | " | ✅ | Completed Tool Description Minimization - All 9 Tools Updated | ~335 |
|
||||
| #26136 | " | ✅ | Minimized Get Session Tool Description | ~218 |
|
||||
| #26135 | " | ✅ | Minimized Get Batch Observations Tool Description | ~258 |
|
||||
| #26134 | " | ✅ | Minimized Get Observation Tool Description | ~228 |
|
||||
| #26133 | " | ✅ | Minimized Get Context Timeline Tool Description | ~245 |
|
||||
| #26132 | 7:54 PM | ✅ | Minimized Get Recent Context Tool Description | ~214 |
|
||||
| #26131 | " | ✅ | Minimized Timeline Tool Description | ~232 |
|
||||
| #26130 | " | ✅ | Minimized Search Tool Description | ~235 |
|
||||
| #26129 | " | ✅ | Renamed progressive_ix Tool to progressive_description with Minimized Description | ~296 |
|
||||
| #26128 | " | ✅ | Renamed Tool Endpoint Mapping from progressive_ix to progressive_description | ~229 |
|
||||
| #26127 | " | ✅ | Completed Format Parameter Removal from All Four MCP Tools | ~318 |
|
||||
| #26126 | 7:53 PM | ✅ | Removed Format Parameter from Get Recent Context Tool Schema | ~244 |
|
||||
| #26125 | " | ✅ | Removed Format Parameter from Timeline Tool Schema | ~248 |
|
||||
| #26124 | " | ✅ | Removed Format Parameter from Search Tool Schema | ~283 |
|
||||
| #26123 | " | 🔵 | Current MCP Server Tool Schema Analysis | ~337 |
|
||||
| #25815 | 5:31 PM | 🔵 | Comprehensive MCP Server and SKILL.md Structure Analysis | ~575 |
|
||||
| #25807 | 5:30 PM | 🔵 | MCP Server Architecture with 14 HTTP-Delegating Tools | ~545 |
|
||||
| #25788 | 5:15 PM | 🔵 | MCP Server Capabilities and Request Handlers | ~256 |
|
||||
|
||||
### Dec 17, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #29078 | 10:16 PM | ✅ | Updated get_recent_context tool schema to accept dynamic parameters | ~318 |
|
||||
| #29077 | 10:15 PM | ✅ | Updated timeline tool schema to accept dynamic parameters | ~292 |
|
||||
| #29076 | " | ✅ | Updated search tool schema to accept dynamic parameters | ~315 |
|
||||
| #28923 | 7:28 PM | 🔵 | MCP Server Architecture: Thin HTTP Wrapper Pattern | ~402 |
|
||||
</claude-mem-context>
|
||||
@@ -233,6 +233,31 @@ NEVER fetch full details without filtering first. 10x token savings.`,
|
||||
handler: async (args: any) => {
|
||||
return await callWorkerAPIPost('/api/observations/batch', args);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'save_memory',
|
||||
description: 'Save a manual memory/observation for semantic search. Use this to remember important information.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
text: {
|
||||
type: 'string',
|
||||
description: 'Content to remember (required)'
|
||||
},
|
||||
title: {
|
||||
type: 'string',
|
||||
description: 'Short title (auto-generated from text if omitted)'
|
||||
},
|
||||
project: {
|
||||
type: 'string',
|
||||
description: 'Project name (uses "claude-mem" if omitted)'
|
||||
}
|
||||
},
|
||||
required: ['text']
|
||||
},
|
||||
handler: async (args: any) => {
|
||||
return await callWorkerAPIPost('/api/memory/save', args);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Dec 10, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
*No recent activity*
|
||||
</claude-mem-context>
|
||||
@@ -5,10 +5,10 @@
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import { homedir } from 'os';
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { SessionStore } from '../sqlite/SessionStore.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { CLAUDE_CONFIG_DIR } from '../../shared/paths.js';
|
||||
import type {
|
||||
ContextConfig,
|
||||
Observation,
|
||||
@@ -203,7 +203,8 @@ export function getPriorSessionMessages(
|
||||
|
||||
const priorSessionId = priorSessionObs.memory_session_id;
|
||||
const dashedCwd = cwdToDashed(cwd);
|
||||
const transcriptPath = path.join(homedir(), '.claude', 'projects', dashedCwd, `${priorSessionId}.jsonl`);
|
||||
// Use CLAUDE_CONFIG_DIR to support custom Claude config directories
|
||||
const transcriptPath = path.join(CLAUDE_CONFIG_DIR, 'projects', dashedCwd, `${priorSessionId}.jsonl`);
|
||||
return extractPriorMessages(transcriptPath);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Jan 3, 2026
|
||||
|
||||
**MarkdownFormatter.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #36562 | 9:49 PM | 🟣 | Phase 4 Context Generation Tests Completed | ~524 |
|
||||
| #36561 | " | 🟣 | Phase 4 Context Generation Test Suite Completion | ~606 |
|
||||
| #36557 | 9:47 PM | 🟣 | MarkdownFormatter Test Suite Created | ~520 |
|
||||
| #36553 | 9:43 PM | 🔵 | MarkdownFormatter Rendering Functions | ~445 |
|
||||
| #36552 | " | 🔵 | Context Generation API Documentation for Phase 4 | ~496 |
|
||||
| #36292 | 8:04 PM | 🔄 | Phase 4 Module Inventory: 12 Files Created in Context Architecture | ~571 |
|
||||
|
||||
**ColorFormatter.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #36390 | 8:50 PM | 🔄 | Comprehensive Monolith Refactor with Modular Architecture | ~724 |
|
||||
|
||||
### Jan 4, 2026
|
||||
|
||||
**ColorFormatter.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #36949 | 2:45 AM | 🟣 | Added Timestamp to Empty State Context Header | ~268 |
|
||||
| #36947 | 2:44 AM | 🔵 | ColorFormatter Header Rendering Location Found | ~235 |
|
||||
| #36946 | " | 🟣 | Context Header Timestamp Display | ~322 |
|
||||
| #36944 | " | 🔵 | ColorFormatter Architecture - Terminal Context Display | ~374 |
|
||||
|
||||
**MarkdownFormatter.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #36948 | 2:44 AM | 🔴 | Add Timestamp to Empty State Context Header | ~270 |
|
||||
| #36945 | " | 🟣 | Context Header Now Displays Current Date and Time | ~303 |
|
||||
| #36943 | 2:43 AM | 🔵 | MarkdownFormatter Structure for Context Injection | ~346 |
|
||||
| #36942 | " | 🔵 | Recent Context Feature Architecture | ~300 |
|
||||
|
||||
### Jan 5, 2026
|
||||
|
||||
**ColorFormatter.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #38048 | 9:45 PM | 🔴 | PR #558 - Comprehensive Bug Fix and Test Quality Improvement | ~585 |
|
||||
| #37582 | 4:53 PM | 🔴 | Updated ColorFormatter Second mem-search Reference - Phase 2 Complete | ~398 |
|
||||
| #37581 | " | 🔴 | Updated ColorFormatter First mem-search Reference | ~362 |
|
||||
| #37577 | 4:52 PM | 🔵 | ColorFormatter Contains Outdated mem-search References | ~395 |
|
||||
| #37530 | 4:43 PM | 🔵 | Issue #544 Confirmed in ColorFormatter Second Location | ~344 |
|
||||
|
||||
**MarkdownFormatter.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #37617 | 5:32 PM | ⚖️ | PR #558 Review Requirements Categorized by Priority | ~637 |
|
||||
| #37613 | 5:31 PM | 🔵 | PR #558 Review Feedback Analysis | ~544 |
|
||||
| #37586 | 4:54 PM | 🔴 | Phase 2 Committed - mem-search Hint Messages Fixed | ~375 |
|
||||
| #37583 | 4:53 PM | 🔴 | Phase 2 Complete - All mem-search References Updated | ~394 |
|
||||
| #37580 | " | 🔴 | Updated MarkdownFormatter Second mem-search Reference | ~360 |
|
||||
| #37579 | " | 🔴 | Updated MarkdownFormatter First mem-search Reference | ~350 |
|
||||
| #37576 | 4:52 PM | 🔵 | MarkdownFormatter Contains Outdated mem-search References | ~372 |
|
||||
| #37555 | 4:49 PM | 🔵 | Issue #544 Message Locations and Fix Pattern Documented | ~463 |
|
||||
| #37545 | 4:47 PM | ✅ | Issue #544 Analysis Report Created for mem-search Skill Messaging Problem | ~480 |
|
||||
| #37529 | 4:42 PM | 🔵 | Issue #544 Misleading mem-search Skill Reference Located | ~368 |
|
||||
</claude-mem-context>
|
||||
@@ -1,26 +0,0 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Jan 3, 2026
|
||||
|
||||
**FooterRenderer.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #36390 | 8:50 PM | 🔄 | Comprehensive Monolith Refactor with Modular Architecture | ~724 |
|
||||
| #36283 | 8:02 PM | 🔄 | Phase 4: FooterRenderer Extracted with Conditional Display Logic | ~464 |
|
||||
|
||||
**TimelineRenderer.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #36292 | 8:04 PM | 🔄 | Phase 4 Module Inventory: 12 Files Created in Context Architecture | ~571 |
|
||||
| #36281 | 8:01 PM | 🔄 | Phase 4: TimelineRenderer Extracted with Dual Format Support | ~531 |
|
||||
|
||||
### Jan 5, 2026
|
||||
|
||||
**FooterRenderer.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #37545 | 4:47 PM | ✅ | Issue #544 Analysis Report Created for mem-search Skill Messaging Problem | ~480 |
|
||||
</claude-mem-context>
|
||||
@@ -1,37 +0,0 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Jan 4, 2026
|
||||
|
||||
**FolderDiscovery.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #37021 | 4:59 PM | ✅ | Deleted Redundant Folder Index Service Directory | ~299 |
|
||||
| #37011 | 4:50 PM | 🔵 | FolderDiscovery extracts folders from observations and applies depth, exclusion, and activity filters | ~433 |
|
||||
|
||||
**ClaudeMdGenerator.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #37012 | 4:51 PM | 🔵 | ClaudeMdGenerator writes tag-wrapped timeline markdown while preserving manual content | ~446 |
|
||||
| #36981 | 4:25 PM | 🔵 | ClaudeMdGenerator creates and updates CLAUDE.md files with timeline content | ~336 |
|
||||
|
||||
**types.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #37010 | 4:50 PM | 🔵 | Type definitions specify folder-index configuration schema and timeline data structures | ~349 |
|
||||
|
||||
**FolderTimelineCompiler.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #37009 | 4:50 PM | 🔵 | FolderTimelineCompiler queries database and groups activity chronologically by date | ~419 |
|
||||
| #37002 | 4:45 PM | 🔴 | Fixed session file deduplication and summary selection in FolderTimelineCompiler | ~306 |
|
||||
| #37001 | " | 🔴 | Fixed FolderTimelineCompiler to generate concise summaries and deduplicate files | ~284 |
|
||||
|
||||
**FolderIndexOrchestrator.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #37008 | 4:50 PM | 🔵 | FolderIndexOrchestrator implements event-driven regeneration triggered by observation saves | ~418 |
|
||||
| #36983 | 4:26 PM | 🔵 | FolderIndexOrchestrator coordinates automatic CLAUDE.md regeneration after observation saves | ~367 |
|
||||
</claude-mem-context>
|
||||
@@ -1,8 +1,6 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Jan 4, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|
||||
@@ -10,9 +10,9 @@
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import { homedir } from 'os';
|
||||
import { readFileSync } from 'fs';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { MARKETPLACE_ROOT } from '../../shared/paths.js';
|
||||
|
||||
/**
|
||||
* Check if a port is in use by querying the health endpoint
|
||||
@@ -29,17 +29,21 @@ export async function isPortInUse(port: number): Promise<boolean> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the worker to become fully ready (passes readiness check)
|
||||
* Wait for the worker HTTP server to become responsive (liveness check)
|
||||
* Uses /api/health instead of /api/readiness because:
|
||||
* - /api/health returns 200 as soon as HTTP server is listening
|
||||
* - /api/readiness waits for full initialization (MCP connection can take 5+ minutes)
|
||||
* See: https://github.com/thedotmack/claude-mem/issues/811
|
||||
* @param port Worker port to check
|
||||
* @param timeoutMs Maximum time to wait in milliseconds
|
||||
* @returns true if worker became ready, false if timeout
|
||||
* @returns true if worker became responsive, false if timeout
|
||||
*/
|
||||
export async function waitForHealth(port: number, timeoutMs: number = 30000): Promise<boolean> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
// Note: Removed AbortSignal.timeout to avoid Windows Bun cleanup issue (libuv assertion)
|
||||
const response = await fetch(`http://127.0.0.1:${port}/api/readiness`);
|
||||
const response = await fetch(`http://127.0.0.1:${port}/api/health`);
|
||||
if (response.ok) return true;
|
||||
} catch (error) {
|
||||
// [ANTI-PATTERN IGNORED]: Retry loop - expected failures during startup, will retry
|
||||
@@ -96,8 +100,7 @@ export async function httpShutdown(port: number): Promise<boolean> {
|
||||
* This is the "expected" version that should be running
|
||||
*/
|
||||
export function getInstalledPluginVersion(): string {
|
||||
const marketplaceRoot = path.join(homedir(), '.claude', 'plugins', 'marketplaces', 'thedotmack');
|
||||
const packageJsonPath = path.join(marketplaceRoot, 'package.json');
|
||||
const packageJsonPath = path.join(MARKETPLACE_ROOT, 'package.json');
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
|
||||
return packageJson.version;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,17 @@ const execAsync = promisify(exec);
|
||||
const DATA_DIR = path.join(homedir(), '.claude-mem');
|
||||
const PID_FILE = path.join(DATA_DIR, 'worker.pid');
|
||||
|
||||
// Orphaned process cleanup patterns and thresholds
|
||||
// These are claude-mem processes that can accumulate if not properly terminated
|
||||
const ORPHAN_PROCESS_PATTERNS = [
|
||||
'mcp-server.cjs', // Main MCP server process
|
||||
'worker-service.cjs', // Background worker daemon
|
||||
'chroma-mcp' // ChromaDB MCP subprocess
|
||||
];
|
||||
|
||||
// Only kill processes older than this to avoid killing the current session
|
||||
const ORPHAN_MAX_AGE_MINUTES = 30;
|
||||
|
||||
export interface PidInfo {
|
||||
pid: number;
|
||||
port: number;
|
||||
@@ -66,7 +77,11 @@ export function removePidFile(): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get platform-adjusted timeout (Windows socket cleanup is slower)
|
||||
* Get platform-adjusted timeout for worker-side socket operations (2.0x on Windows).
|
||||
*
|
||||
* Note: Two platform multiplier functions exist intentionally:
|
||||
* - getTimeout() in hook-constants.ts uses 1.5x for hook-side operations (fast path)
|
||||
* - getPlatformTimeout() here uses 2.0x for worker-side socket operations (slower path)
|
||||
*/
|
||||
export function getPlatformTimeout(baseMs: number): number {
|
||||
const WINDOWS_MULTIPLIER = 2.0;
|
||||
@@ -90,7 +105,7 @@ export async function getChildProcesses(parentPid: number): Promise<number[]> {
|
||||
|
||||
try {
|
||||
// PowerShell Get-Process instead of WMIC (deprecated in Windows 11)
|
||||
const cmd = `powershell -NoProfile -NonInteractive -Command "Get-Process | Where-Object { \\$_.ParentProcessId -eq ${parentPid} } | Select-Object -ExpandProperty Id"`;
|
||||
const cmd = `powershell -NoProfile -NonInteractive -Command "Get-Process | Where-Object { $_.ParentProcessId -eq ${parentPid} } | Select-Object -ExpandProperty Id"`;
|
||||
const { stdout } = await execAsync(cmd, { timeout: HOOK_TIMEOUTS.POWERSHELL_COMMAND });
|
||||
// PowerShell outputs just numbers (one per line), simpler than WMIC's "ProcessId=1234" format
|
||||
return stdout
|
||||
@@ -162,55 +177,119 @@ export async function waitForProcessesExit(pids: number[], timeoutMs: number): P
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up orphaned chroma-mcp processes from previous worker sessions
|
||||
* Prevents process accumulation and memory leaks
|
||||
* Parse process elapsed time from ps etime format: [[DD-]HH:]MM:SS
|
||||
* Returns age in minutes, or -1 if parsing fails
|
||||
*/
|
||||
export function parseElapsedTime(etime: string): number {
|
||||
if (!etime || etime.trim() === '') return -1;
|
||||
|
||||
const cleaned = etime.trim();
|
||||
let totalMinutes = 0;
|
||||
|
||||
// DD-HH:MM:SS format
|
||||
const dayMatch = cleaned.match(/^(\d+)-(\d+):(\d+):(\d+)$/);
|
||||
if (dayMatch) {
|
||||
totalMinutes = parseInt(dayMatch[1], 10) * 24 * 60 +
|
||||
parseInt(dayMatch[2], 10) * 60 +
|
||||
parseInt(dayMatch[3], 10);
|
||||
return totalMinutes;
|
||||
}
|
||||
|
||||
// HH:MM:SS format
|
||||
const hourMatch = cleaned.match(/^(\d+):(\d+):(\d+)$/);
|
||||
if (hourMatch) {
|
||||
totalMinutes = parseInt(hourMatch[1], 10) * 60 + parseInt(hourMatch[2], 10);
|
||||
return totalMinutes;
|
||||
}
|
||||
|
||||
// MM:SS format
|
||||
const minMatch = cleaned.match(/^(\d+):(\d+)$/);
|
||||
if (minMatch) {
|
||||
return parseInt(minMatch[1], 10);
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up orphaned claude-mem processes from previous worker sessions
|
||||
*
|
||||
* Targets mcp-server.cjs, worker-service.cjs, and chroma-mcp processes
|
||||
* that survived a previous daemon crash. Only kills processes older than
|
||||
* ORPHAN_MAX_AGE_MINUTES to avoid killing the current session.
|
||||
*
|
||||
* The periodic ProcessRegistry reaper handles in-session orphans;
|
||||
* this function handles cross-session orphans at startup.
|
||||
*/
|
||||
export async function cleanupOrphanedProcesses(): Promise<void> {
|
||||
const isWindows = process.platform === 'win32';
|
||||
const pids: number[] = [];
|
||||
const currentPid = process.pid;
|
||||
const pidsToKill: number[] = [];
|
||||
|
||||
try {
|
||||
if (isWindows) {
|
||||
// Windows: Use PowerShell Get-CimInstance instead of WMIC (deprecated in Windows 11)
|
||||
const cmd = `powershell -NoProfile -NonInteractive -Command "Get-CimInstance Win32_Process | Where-Object { \\$_.Name -like '*python*' -and \\$_.CommandLine -like '*chroma-mcp*' } | Select-Object -ExpandProperty ProcessId"`;
|
||||
// Windows: Use PowerShell Get-CimInstance with JSON output for age filtering
|
||||
const patternConditions = ORPHAN_PROCESS_PATTERNS
|
||||
.map(p => `$_.CommandLine -like '*${p}*'`)
|
||||
.join(' -or ');
|
||||
|
||||
const cmd = `powershell -NoProfile -NonInteractive -Command "Get-CimInstance Win32_Process | Where-Object { (${patternConditions}) -and $_.ProcessId -ne ${currentPid} } | Select-Object ProcessId, CreationDate | ConvertTo-Json"`;
|
||||
const { stdout } = await execAsync(cmd, { timeout: HOOK_TIMEOUTS.POWERSHELL_COMMAND });
|
||||
|
||||
if (!stdout.trim()) {
|
||||
logger.debug('SYSTEM', 'No orphaned chroma-mcp processes found (Windows)');
|
||||
if (!stdout.trim() || stdout.trim() === 'null') {
|
||||
logger.debug('SYSTEM', 'No orphaned claude-mem processes found (Windows)');
|
||||
return;
|
||||
}
|
||||
|
||||
// PowerShell outputs just numbers (one per line), simpler than WMIC's "ProcessId=1234" format
|
||||
const lines = stdout
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.length > 0 && /^\d+$/.test(line));
|
||||
const processes = JSON.parse(stdout);
|
||||
const processList = Array.isArray(processes) ? processes : [processes];
|
||||
const now = Date.now();
|
||||
|
||||
for (const line of lines) {
|
||||
const pid = parseInt(line, 10);
|
||||
// SECURITY: Validate PID is positive integer before adding to list
|
||||
if (!isNaN(pid) && Number.isInteger(pid) && pid > 0) {
|
||||
pids.push(pid);
|
||||
for (const proc of processList) {
|
||||
const pid = proc.ProcessId;
|
||||
// SECURITY: Validate PID is positive integer and not current process
|
||||
if (!Number.isInteger(pid) || pid <= 0 || pid === currentPid) continue;
|
||||
|
||||
// Parse Windows WMI date format: /Date(1234567890123)/
|
||||
const creationMatch = proc.CreationDate?.match(/\/Date\((\d+)\)\//);
|
||||
if (creationMatch) {
|
||||
const creationTime = parseInt(creationMatch[1], 10);
|
||||
const ageMinutes = (now - creationTime) / (1000 * 60);
|
||||
|
||||
if (ageMinutes >= ORPHAN_MAX_AGE_MINUTES) {
|
||||
pidsToKill.push(pid);
|
||||
logger.debug('SYSTEM', 'Found orphaned process', { pid, ageMinutes: Math.round(ageMinutes) });
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Unix: Use ps aux | grep
|
||||
const { stdout } = await execAsync('ps aux | grep "chroma-mcp" | grep -v grep || true');
|
||||
// Unix: Use ps with elapsed time for age-based filtering
|
||||
const patternRegex = ORPHAN_PROCESS_PATTERNS.join('|');
|
||||
const { stdout } = await execAsync(
|
||||
`ps -eo pid,etime,command | grep -E "${patternRegex}" | grep -v grep || true`
|
||||
);
|
||||
|
||||
if (!stdout.trim()) {
|
||||
logger.debug('SYSTEM', 'No orphaned chroma-mcp processes found (Unix)');
|
||||
logger.debug('SYSTEM', 'No orphaned claude-mem processes found (Unix)');
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = stdout.trim().split('\n');
|
||||
for (const line of lines) {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
if (parts.length > 1) {
|
||||
const pid = parseInt(parts[1], 10);
|
||||
// SECURITY: Validate PID is positive integer before adding to list
|
||||
if (!isNaN(pid) && Number.isInteger(pid) && pid > 0) {
|
||||
pids.push(pid);
|
||||
}
|
||||
// Parse: " 1234 01:23:45 /path/to/process"
|
||||
const match = line.trim().match(/^(\d+)\s+(\S+)\s+(.*)$/);
|
||||
if (!match) continue;
|
||||
|
||||
const pid = parseInt(match[1], 10);
|
||||
const etime = match[2];
|
||||
|
||||
// SECURITY: Validate PID is positive integer and not current process
|
||||
if (!Number.isInteger(pid) || pid <= 0 || pid === currentPid) continue;
|
||||
|
||||
const ageMinutes = parseElapsedTime(etime);
|
||||
if (ageMinutes >= ORPHAN_MAX_AGE_MINUTES) {
|
||||
pidsToKill.push(pid);
|
||||
logger.debug('SYSTEM', 'Found orphaned process', { pid, ageMinutes, command: match[3].substring(0, 80) });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -220,19 +299,20 @@ export async function cleanupOrphanedProcesses(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (pids.length === 0) {
|
||||
if (pidsToKill.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('SYSTEM', 'Cleaning up orphaned chroma-mcp processes', {
|
||||
logger.info('SYSTEM', 'Cleaning up orphaned claude-mem processes', {
|
||||
platform: isWindows ? 'Windows' : 'Unix',
|
||||
count: pids.length,
|
||||
pids
|
||||
count: pidsToKill.length,
|
||||
pids: pidsToKill,
|
||||
maxAgeMinutes: ORPHAN_MAX_AGE_MINUTES
|
||||
});
|
||||
|
||||
// Kill all found processes
|
||||
if (isWindows) {
|
||||
for (const pid of pids) {
|
||||
for (const pid of pidsToKill) {
|
||||
// SECURITY: Double-check PID validation before using in taskkill command
|
||||
if (!Number.isInteger(pid) || pid <= 0) {
|
||||
logger.warn('SYSTEM', 'Skipping invalid PID', { pid });
|
||||
@@ -246,7 +326,7 @@ export async function cleanupOrphanedProcesses(): Promise<void> {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const pid of pids) {
|
||||
for (const pid of pidsToKill) {
|
||||
try {
|
||||
process.kill(pid, 'SIGKILL');
|
||||
} catch (error) {
|
||||
@@ -256,16 +336,16 @@ export async function cleanupOrphanedProcesses(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('SYSTEM', 'Orphaned processes cleaned up', { count: pids.length });
|
||||
logger.info('SYSTEM', 'Orphaned processes cleaned up', { count: pidsToKill.length });
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn a detached daemon process
|
||||
* Returns the child PID or undefined if spawn failed
|
||||
*
|
||||
* On Windows, uses WMIC to spawn a truly independent process that
|
||||
* survives parent exit without console popups. WMIC creates processes
|
||||
* that are not associated with the parent's console.
|
||||
* On Windows, uses PowerShell Start-Process with -WindowStyle Hidden to spawn
|
||||
* a truly independent process without console popups. Unlike WMIC, PowerShell
|
||||
* inherits environment variables from the parent process.
|
||||
*
|
||||
* On Unix, uses standard detached spawn.
|
||||
*
|
||||
@@ -285,28 +365,46 @@ export function spawnDaemon(
|
||||
};
|
||||
|
||||
if (isWindows) {
|
||||
// Use WMIC to spawn a process that's independent of the parent console
|
||||
// This avoids the console popup that occurs with detached: true
|
||||
// Paths must be individually quoted for WMIC when they contain spaces
|
||||
// Use PowerShell Start-Process to spawn a hidden, independent process
|
||||
// Unlike WMIC, PowerShell inherits environment variables from parent
|
||||
// -WindowStyle Hidden prevents console popup
|
||||
const execPath = process.execPath;
|
||||
const script = scriptPath;
|
||||
// WMIC command format: wmic process call create "\"path1\" \"path2\" args"
|
||||
const command = `wmic process call create "\\"${execPath}\\" \\"${script}\\" --daemon"`;
|
||||
const psCommand = `Start-Process -FilePath '${execPath}' -ArgumentList '${script}','--daemon' -WindowStyle Hidden`;
|
||||
|
||||
try {
|
||||
execSync(command, {
|
||||
execSync(`powershell -NoProfile -Command "${psCommand}"`, {
|
||||
stdio: 'ignore',
|
||||
windowsHide: true
|
||||
windowsHide: true,
|
||||
env
|
||||
});
|
||||
// WMIC returns immediately, we can't get the spawned PID easily
|
||||
// Worker will write its own PID file after listen()
|
||||
return 0;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Unix: standard detached spawn
|
||||
// Unix: Use setsid to create a new session, fully detaching from the
|
||||
// controlling terminal. This prevents SIGHUP from reaching the daemon
|
||||
// even if the in-process SIGHUP handler somehow fails (belt-and-suspenders).
|
||||
// Fall back to standard detached spawn if setsid is not available.
|
||||
const setsidPath = '/usr/bin/setsid';
|
||||
if (existsSync(setsidPath)) {
|
||||
const child = spawn(setsidPath, [process.execPath, scriptPath, '--daemon'], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
env
|
||||
});
|
||||
|
||||
if (child.pid === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
child.unref();
|
||||
return child.pid;
|
||||
}
|
||||
|
||||
// Fallback: standard detached spawn (macOS, systems without setsid)
|
||||
const child = spawn(process.execPath, [scriptPath, '--daemon'], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
@@ -322,6 +420,56 @@ export function spawnDaemon(
|
||||
return child.pid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a process with the given PID is alive.
|
||||
*
|
||||
* Uses the process.kill(pid, 0) idiom: signal 0 doesn't send a signal,
|
||||
* it just checks if the process exists and is reachable.
|
||||
*
|
||||
* EPERM is treated as "alive" because it means the process exists but
|
||||
* belongs to a different user/session (common in multi-user setups).
|
||||
* PID 0 (Windows WMIC sentinel for unknown PID) is treated as alive.
|
||||
*/
|
||||
export function isProcessAlive(pid: number): boolean {
|
||||
// PID 0 is the Windows WMIC sentinel value — process was spawned but PID unknown
|
||||
if (pid === 0) return true;
|
||||
|
||||
// Invalid PIDs are not alive
|
||||
if (!Number.isInteger(pid) || pid < 0) return false;
|
||||
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (error: unknown) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
// EPERM = process exists but different user/session — treat as alive
|
||||
if (code === 'EPERM') return true;
|
||||
// ESRCH = no such process — it's dead
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the PID file and remove it if the recorded process is dead (stale).
|
||||
*
|
||||
* This is a cheap operation: one filesystem read + one signal-0 check.
|
||||
* Called at the top of ensureWorkerStarted() to clean up after WSL2
|
||||
* hibernate, OOM kills, or other ungraceful worker deaths.
|
||||
*/
|
||||
export function cleanStalePidFile(): void {
|
||||
const pidInfo = readPidFile();
|
||||
if (!pidInfo) return;
|
||||
|
||||
if (!isProcessAlive(pidInfo.pid)) {
|
||||
logger.info('SYSTEM', 'Removing stale PID file (worker process is dead)', {
|
||||
pid: pidInfo.pid,
|
||||
port: pidInfo.port,
|
||||
startedAt: pidInfo.startedAt
|
||||
});
|
||||
removePidFile();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create signal handler factory for graceful shutdown
|
||||
* Returns a handler function that can be passed to process.on('SIGTERM') etc.
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
*No recent activity*
|
||||
</claude-mem-context>
|
||||
@@ -16,6 +16,7 @@ import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { getWorkerPort } from '../../shared/worker-utils.js';
|
||||
import { DATA_DIR, MARKETPLACE_ROOT, CLAUDE_CONFIG_DIR } from '../../shared/paths.js';
|
||||
import {
|
||||
readCursorRegistry as readCursorRegistryFromFile,
|
||||
writeCursorRegistry as writeCursorRegistryToFile,
|
||||
@@ -27,7 +28,6 @@ import type { CursorInstallTarget, CursorHooksJson, CursorMcpConfig, Platform }
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
// Standard paths
|
||||
const DATA_DIR = path.join(homedir(), '.claude-mem');
|
||||
const CURSOR_REGISTRY_FILE = path.join(DATA_DIR, 'cursor-projects.json');
|
||||
|
||||
// ============================================================================
|
||||
@@ -128,12 +128,12 @@ export async function updateCursorContextForProject(projectName: string, port: n
|
||||
/**
|
||||
* Find cursor-hooks directory
|
||||
* Searches in order: marketplace install, source repo
|
||||
* Checks for both bash (common.sh) and PowerShell (common.ps1) scripts
|
||||
* Checks for hooks.json (unified CLI mode) or legacy shell scripts
|
||||
*/
|
||||
export function findCursorHooksDir(): string | null {
|
||||
const possiblePaths = [
|
||||
// Marketplace install location
|
||||
path.join(homedir(), '.claude', 'plugins', 'marketplaces', 'thedotmack', 'cursor-hooks'),
|
||||
path.join(MARKETPLACE_ROOT, 'cursor-hooks'),
|
||||
// Development/source location (relative to built worker-service.cjs in plugin/scripts/)
|
||||
path.join(path.dirname(__filename), '..', '..', 'cursor-hooks'),
|
||||
// Alternative dev location
|
||||
@@ -141,8 +141,10 @@ export function findCursorHooksDir(): string | null {
|
||||
];
|
||||
|
||||
for (const p of possiblePaths) {
|
||||
// Check for either bash or PowerShell common script
|
||||
if (existsSync(path.join(p, 'common.sh')) || existsSync(path.join(p, 'common.ps1'))) {
|
||||
// Check for hooks.json (unified CLI mode) or legacy shell scripts
|
||||
if (existsSync(path.join(p, 'hooks.json')) ||
|
||||
existsSync(path.join(p, 'common.sh')) ||
|
||||
existsSync(path.join(p, 'common.ps1'))) {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
@@ -156,7 +158,7 @@ export function findCursorHooksDir(): string | null {
|
||||
export function findMcpServerPath(): string | null {
|
||||
const possiblePaths = [
|
||||
// Marketplace install location
|
||||
path.join(homedir(), '.claude', 'plugins', 'marketplaces', 'thedotmack', 'plugin', 'scripts', 'mcp-server.cjs'),
|
||||
path.join(MARKETPLACE_ROOT, 'plugin', 'scripts', 'mcp-server.cjs'),
|
||||
// Development/source location (relative to built worker-service.cjs in plugin/scripts/)
|
||||
path.join(path.dirname(__filename), 'mcp-server.cjs'),
|
||||
// Alternative dev location
|
||||
@@ -178,7 +180,7 @@ export function findMcpServerPath(): string | null {
|
||||
export function findWorkerServicePath(): string | null {
|
||||
const possiblePaths = [
|
||||
// Marketplace install location
|
||||
path.join(homedir(), '.claude', 'plugins', 'marketplaces', 'thedotmack', 'plugin', 'scripts', 'worker-service.cjs'),
|
||||
path.join(MARKETPLACE_ROOT, 'plugin', 'scripts', 'worker-service.cjs'),
|
||||
// Development/source location (relative to built worker-service.cjs in plugin/scripts/)
|
||||
path.join(path.dirname(__filename), 'worker-service.cjs'),
|
||||
// Alternative dev location
|
||||
@@ -193,6 +195,37 @@ export function findWorkerServicePath(): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the Bun executable path
|
||||
* Required because worker-service.cjs uses bun:sqlite which is Bun-specific
|
||||
* Searches common installation locations across platforms
|
||||
*/
|
||||
export function findBunPath(): string {
|
||||
const possiblePaths = [
|
||||
// Standard user install location (most common)
|
||||
path.join(homedir(), '.bun', 'bin', 'bun'),
|
||||
// Global install locations
|
||||
'/usr/local/bin/bun',
|
||||
'/usr/bin/bun',
|
||||
// Windows locations
|
||||
...(process.platform === 'win32' ? [
|
||||
path.join(homedir(), '.bun', 'bin', 'bun.exe'),
|
||||
path.join(process.env.LOCALAPPDATA || '', 'bun', 'bun.exe'),
|
||||
] : []),
|
||||
];
|
||||
|
||||
for (const p of possiblePaths) {
|
||||
if (p && existsSync(p)) {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to 'bun' and hope it's in PATH
|
||||
// This allows the installation to proceed even if we can't find bun
|
||||
// The user will get a clear error when the hook runs if bun isn't available
|
||||
return 'bun';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the target directory for Cursor hooks based on install target
|
||||
*/
|
||||
@@ -312,15 +345,21 @@ export async function installCursorHooks(_sourceDir: string, target: CursorInsta
|
||||
// Generate hooks.json with unified CLI commands
|
||||
const hooksJsonPath = path.join(targetDir, 'hooks.json');
|
||||
|
||||
// Find bun executable - required because worker-service.cjs uses bun:sqlite
|
||||
const bunPath = findBunPath();
|
||||
const escapedBunPath = bunPath.replace(/\\/g, '\\\\');
|
||||
|
||||
// Use the absolute path to worker-service.cjs
|
||||
// Escape backslashes for JSON on Windows
|
||||
const escapedWorkerPath = workerServicePath.replace(/\\/g, '\\\\');
|
||||
|
||||
// Helper to create hook command using unified CLI
|
||||
// Helper to create hook command using unified CLI with bun runtime
|
||||
const makeHookCommand = (command: string) => {
|
||||
return `node "${escapedWorkerPath}" hook cursor ${command}`;
|
||||
return `"${escapedBunPath}" "${escapedWorkerPath}" hook cursor ${command}`;
|
||||
};
|
||||
|
||||
console.log(` Using Bun runtime: ${bunPath}`);
|
||||
|
||||
const hooksJson: CursorHooksJson = {
|
||||
version: 1,
|
||||
hooks: {
|
||||
@@ -356,7 +395,7 @@ export async function installCursorHooks(_sourceDir: string, target: CursorInsta
|
||||
Installation complete!
|
||||
|
||||
Hooks installed to: ${targetDir}/hooks.json
|
||||
Using unified CLI: node worker-service.cjs hook cursor <command>
|
||||
Using unified CLI: bun worker-service.cjs hook cursor <command>
|
||||
|
||||
Next steps:
|
||||
1. Start claude-mem worker: claude-mem start
|
||||
@@ -532,7 +571,7 @@ export function checkCursorHooksStatus(): number {
|
||||
const firstCommand = hooksContent?.hooks?.beforeSubmitPrompt?.[0]?.command || '';
|
||||
|
||||
if (firstCommand.includes('worker-service.cjs') && firstCommand.includes('hook cursor')) {
|
||||
console.log(` Mode: Unified CLI (node worker-service.cjs)`);
|
||||
console.log(` Mode: Unified CLI (bun worker-service.cjs)`);
|
||||
} else {
|
||||
// Detect legacy shell scripts
|
||||
const bashScripts = ['session-init.sh', 'context-inject.sh', 'save-observation.sh'];
|
||||
@@ -596,8 +635,8 @@ export async function detectClaudeCode(): Promise<boolean> {
|
||||
logger.debug('SYSTEM', 'Claude CLI not in PATH', {}, error as Error);
|
||||
}
|
||||
|
||||
// Check for Claude Code plugin directory
|
||||
const pluginDir = path.join(homedir(), '.claude', 'plugins');
|
||||
// Check for Claude Code plugin directory (respects CLAUDE_CONFIG_DIR)
|
||||
const pluginDir = path.join(CLAUDE_CONFIG_DIR, 'plugins');
|
||||
if (existsSync(pluginDir)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Dec 17, 2025
|
||||
|
||||
**ProcessManager.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #28932 | 7:30 PM | 🔵 | ProcessManager Architecture and Platform-Specific Process Spawning | ~523 |
|
||||
| #28929 | " | 🔵 | ProcessManager Usage Across Codebase | ~319 |
|
||||
| #28747 | 6:25 PM | 🔵 | Branch Diff Analysis - 26 Files Modified | ~374 |
|
||||
| #28730 | 6:21 PM | 🔵 | Worker Wrapper Solves Windows Zombie Port Problem | ~416 |
|
||||
| #28729 | " | 🔵 | Windows Worker Wrapper Architecture | ~222 |
|
||||
| #28721 | 6:18 PM | 🔵 | Final Solution - Worker Wrapper Architecture Successfully Deployed | ~474 |
|
||||
| #28719 | " | 🔵 | Initial Windows Worker Problem Analysis - Three Interconnected Issues | ~564 |
|
||||
| #28714 | 6:15 PM | 🔴 | Windows Zombie Port Problem Resolved with Wrapper Process Architecture | ~421 |
|
||||
| #28711 | 6:13 PM | 🔵 | Overview of Changes Between main and HEAD Branch | ~347 |
|
||||
| #28660 | 5:31 PM | 🔵 | Branch Modifies 26 Files with Net Addition of 346 Lines | ~445 |
|
||||
| #28644 | 5:24 PM | ✅ | Modified 27 files with 693 additions and 239 deletions for Windows support | ~447 |
|
||||
|
||||
### Dec 18, 2025
|
||||
|
||||
**ProcessManager.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #29622 | 5:41 PM | 🔵 | Validation Patterns Across HTTP Routes and Core Services | ~488 |
|
||||
|
||||
### Dec 20, 2025
|
||||
|
||||
**ProcessManager.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #31066 | 7:53 PM | 🔵 | Comprehensive KISS Principle Audit of Hooks and Worker Services | ~788 |
|
||||
| #31020 | 7:28 PM | 🔄 | Inlined single-use timeout constants in ProcessManager | ~390 |
|
||||
| #31013 | 7:27 PM | 🔵 | Comment Analysis Identified Stale FTS5 References and Documentation Gaps | ~681 |
|
||||
| #31012 | 7:25 PM | 🔴 | Silent Failure Review Identified Regression in getWorkerPort() Error Handling | ~659 |
|
||||
| #31010 | " | ⚖️ | PR #400 Approved After Comprehensive Code Review | ~594 |
|
||||
| #31000 | 7:22 PM | 🔄 | ProcessManager timeout constants inlined to literal values | ~356 |
|
||||
| #30993 | 7:20 PM | 🔴 | ProcessManager getPidInfo() enhanced with error logging | ~290 |
|
||||
| #30990 | 7:19 PM | 🔵 | PR 400 achieves net deletion of 395 lines across 31 files | ~338 |
|
||||
| #30988 | " | 🔵 | PR 400 modifies 31 files across hooks, services, and utilities | ~316 |
|
||||
| #30986 | " | 🔵 | PR #400 File Scope: 31 Files Across Hooks, Services, and Utilities | ~526 |
|
||||
| #30953 | 7:02 PM | 🔄 | Removed Single-Use Timeout Constants in ProcessManager | ~306 |
|
||||
| #30949 | " | 🔴 | Fixed undefined constant in ProcessManager waitForExit | ~245 |
|
||||
| #30948 | 7:01 PM | 🔵 | Windows Process Shutdown Strategy in ProcessManager | ~302 |
|
||||
| #30907 | 6:46 PM | 🔴 | ProcessManager PID File Corruption Now Logs Warnings | ~326 |
|
||||
| #30905 | 6:45 PM | 🔴 | ProcessManager getPidInfo Error Visibility | ~333 |
|
||||
| #30902 | " | 🔴 | Added logging to PID file error handling in ProcessManager | ~260 |
|
||||
| #30901 | 6:44 PM | 🔵 | Windows Graceful Shutdown via HTTP and Wrapper IPC | ~269 |
|
||||
| #30900 | " | 🔵 | Platform-Specific Worker Script Selection | ~262 |
|
||||
| #30899 | " | 🔵 | getPidInfo Usage Pattern in ProcessManager | ~206 |
|
||||
| #30898 | " | 🔵 | ProcessManager PID File Management Implementation | ~249 |
|
||||
| #30774 | 5:58 PM | 🔵 | ProcessManager Handles Cross-Platform Worker Lifecycle with Windows Workarounds | ~559 |
|
||||
| #32307 | 5:56 PM | 🔵 | Worker Service Code Audit: 14 Issues Found Across Validation, Data Structures, and Complexity | ~793 |
|
||||
| #30673 | 5:08 PM | 🔴 | Windows Worker Stop/Restart Reliability Improvements | ~376 |
|
||||
| #30663 | 5:07 PM | 🔵 | Cross-Platform Support Across 12 Files | ~307 |
|
||||
|
||||
### Dec 24, 2025
|
||||
|
||||
**ProcessManager.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #32071 | 3:24 PM | ⚖️ | Worker Startup Architecture Redesigned | ~380 |
|
||||
| #32070 | " | 🔵 | ProcessManager Worker Spawning Architecture | ~428 |
|
||||
| #32059 | 3:17 PM | ⚖️ | Worker Startup Refactored with File-Based Locking for Concurrent Hooks | ~552 |
|
||||
|
||||
### Dec 26, 2025
|
||||
|
||||
**ProcessManager.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #32855 | 7:04 PM | 🔄 | Consolidated worker process management into single service | ~322 |
|
||||
| #32837 | 6:25 PM | 🔵 | Deleted ProcessManager.ts contained comprehensive PID file infrastructure | ~430 |
|
||||
| #32814 | 6:05 PM | ✅ | Increased All Timeout Limits to Maximum Values for Slow Systems | ~385 |
|
||||
|
||||
### Dec 28, 2025
|
||||
|
||||
**ProcessManager.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #33370 | 3:47 PM | 🔵 | ToxMox Wrapper Architecture Deleted December 26, Six Days After Implementation | ~506 |
|
||||
| #33369 | 3:46 PM | 🔵 | ToxMox December 17 Commit Introduced Wrapper Architecture to Fix Windows Zombie Port Bug | ~632 |
|
||||
| #33368 | 3:45 PM | 🔵 | ToxMox December 20 Commit Improved Windows Worker Restart Reliability and Logging | ~487 |
|
||||
| #33294 | 3:08 PM | ✅ | ToxMox Contributions Documented in Comprehensive Markdown Report | ~603 |
|
||||
| #33284 | 3:07 PM | 🔄 | Consolidated Worker Lifecycle Management (-580 Lines) | ~327 |
|
||||
| #33270 | 2:59 PM | ⚖️ | Self-Spawn Pattern Chosen for Worker Lifecycle | ~418 |
|
||||
|
||||
### Jan 6, 2026
|
||||
|
||||
**ProcessManager.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #38108 | 12:15 AM | 🔵 | Complete Windows Zombie Port Bug Technical Deep Dive | ~935 |
|
||||
| #38105 | 12:14 AM | 🔵 | Windows Console Popup Flash Issue Documented and Fixed | ~455 |
|
||||
</claude-mem-context>
|
||||
@@ -1,7 +0,0 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
*No recent activity*
|
||||
</claude-mem-context>
|
||||
@@ -3,6 +3,15 @@ import { PendingMessageStore, PersistentPendingMessage } from '../sqlite/Pending
|
||||
import type { PendingMessageWithId } from '../worker-types.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
|
||||
const IDLE_TIMEOUT_MS = 3 * 60 * 1000; // 3 minutes
|
||||
|
||||
export interface CreateIteratorOptions {
|
||||
sessionDbId: number;
|
||||
signal: AbortSignal;
|
||||
/** Called when idle timeout occurs - should trigger abort to kill subprocess */
|
||||
onIdleTimeout?: () => void;
|
||||
}
|
||||
|
||||
export class SessionQueueProcessor {
|
||||
constructor(
|
||||
private store: PendingMessageStore,
|
||||
@@ -14,8 +23,15 @@ export class SessionQueueProcessor {
|
||||
* Uses atomic claim-and-delete to prevent duplicates.
|
||||
* The queue is a pure buffer: claim it, delete it, process in memory.
|
||||
* Waits for 'message' event when queue is empty.
|
||||
*
|
||||
* CRITICAL: Calls onIdleTimeout callback after 3 minutes of inactivity.
|
||||
* The callback should trigger abortController.abort() to kill the SDK subprocess.
|
||||
* Just returning from the iterator is NOT enough - the subprocess stays alive!
|
||||
*/
|
||||
async *createIterator(sessionDbId: number, signal: AbortSignal): AsyncIterableIterator<PendingMessageWithId> {
|
||||
async *createIterator(options: CreateIteratorOptions): AsyncIterableIterator<PendingMessageWithId> {
|
||||
const { sessionDbId, signal, onIdleTimeout } = options;
|
||||
let lastActivityTime = Date.now();
|
||||
|
||||
while (!signal.aborted) {
|
||||
try {
|
||||
// Atomically claim AND DELETE next message from DB
|
||||
@@ -23,11 +39,29 @@ export class SessionQueueProcessor {
|
||||
const persistentMessage = this.store.claimAndDelete(sessionDbId);
|
||||
|
||||
if (persistentMessage) {
|
||||
// Reset activity time when we successfully yield a message
|
||||
lastActivityTime = Date.now();
|
||||
// Yield the message for processing (it's already deleted from queue)
|
||||
yield this.toPendingMessageWithId(persistentMessage);
|
||||
} else {
|
||||
// Queue empty - wait for wake-up event
|
||||
await this.waitForMessage(signal);
|
||||
// Queue empty - wait for wake-up event or timeout
|
||||
const receivedMessage = await this.waitForMessage(signal, IDLE_TIMEOUT_MS);
|
||||
|
||||
if (!receivedMessage && !signal.aborted) {
|
||||
// Timeout occurred - check if we've been idle too long
|
||||
const idleDuration = Date.now() - lastActivityTime;
|
||||
if (idleDuration >= IDLE_TIMEOUT_MS) {
|
||||
logger.info('SESSION', 'Idle timeout reached, triggering abort to kill subprocess', {
|
||||
sessionDbId,
|
||||
idleDurationMs: idleDuration,
|
||||
thresholdMs: IDLE_TIMEOUT_MS
|
||||
});
|
||||
onIdleTimeout?.();
|
||||
return;
|
||||
}
|
||||
// Reset timer on spurious wakeup - queue is empty but duration check failed
|
||||
lastActivityTime = Date.now();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (signal.aborted) return;
|
||||
@@ -47,25 +81,42 @@ export class SessionQueueProcessor {
|
||||
};
|
||||
}
|
||||
|
||||
private waitForMessage(signal: AbortSignal): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
/**
|
||||
* Wait for a message event or timeout.
|
||||
* @param signal - AbortSignal to cancel waiting
|
||||
* @param timeoutMs - Maximum time to wait before returning
|
||||
* @returns true if a message was received, false if timeout occurred
|
||||
*/
|
||||
private waitForMessage(signal: AbortSignal, timeoutMs: number = IDLE_TIMEOUT_MS): Promise<boolean> {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const onMessage = () => {
|
||||
cleanup();
|
||||
resolve();
|
||||
resolve(true); // Message received
|
||||
};
|
||||
|
||||
const onAbort = () => {
|
||||
cleanup();
|
||||
resolve(); // Resolve to let the loop check signal.aborted and exit
|
||||
resolve(false); // Aborted, let loop check signal.aborted
|
||||
};
|
||||
|
||||
const onTimeout = () => {
|
||||
cleanup();
|
||||
resolve(false); // Timeout occurred
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
if (timeoutId !== undefined) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
this.events.off('message', onMessage);
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
};
|
||||
|
||||
this.events.once('message', onMessage);
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
timeoutId = setTimeout(onTimeout, timeoutMs);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
*No recent activity*
|
||||
</claude-mem-context>
|
||||
@@ -30,6 +30,19 @@ export interface RouteHandler {
|
||||
setupRoutes(app: Application): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* AI provider status for health endpoint
|
||||
*/
|
||||
export interface AiStatus {
|
||||
provider: string;
|
||||
authMethod: string;
|
||||
lastInteraction: {
|
||||
timestamp: number;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for initializing the server
|
||||
*/
|
||||
@@ -42,6 +55,10 @@ export interface ServerOptions {
|
||||
onShutdown: () => Promise<void>;
|
||||
/** Restart function for admin endpoints */
|
||||
onRestart: () => Promise<void>;
|
||||
/** Filesystem path to the worker entry point */
|
||||
workerPath: string;
|
||||
/** Callback to get current AI provider status */
|
||||
getAiStatus: () => AiStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -140,20 +157,20 @@ export class Server {
|
||||
* Setup core system routes (health, readiness, version, admin)
|
||||
*/
|
||||
private setupCoreRoutes(): void {
|
||||
// Test build ID for debugging which build is running
|
||||
const TEST_BUILD_ID = 'TEST-008-wrapper-ipc';
|
||||
|
||||
// Health check endpoint - always responds, even during initialization
|
||||
this.app.get('/api/health', (_req: Request, res: Response) => {
|
||||
res.status(200).json({
|
||||
status: 'ok',
|
||||
build: TEST_BUILD_ID,
|
||||
version: BUILT_IN_VERSION,
|
||||
workerPath: this.options.workerPath,
|
||||
uptime: Date.now() - this.startTime,
|
||||
managed: process.env.CLAUDE_MEM_MANAGED === 'true',
|
||||
hasIpc: typeof process.send === 'function',
|
||||
platform: process.platform,
|
||||
pid: process.pid,
|
||||
initialized: this.options.getInitializationComplete(),
|
||||
mcpReady: this.options.getMcpReady(),
|
||||
ai: this.options.getAiStatus(),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,93 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
### Dec 8, 2025
|
||||
|
||||
*No recent activity*
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #22310 | 9:46 PM | 🟣 | Complete Hook Lifecycle Documentation Generated | ~603 |
|
||||
| #22305 | 9:45 PM | 🔵 | Session Summary Storage and Status Lifecycle | ~472 |
|
||||
| #22304 | " | 🔵 | Session Creation Idempotency and Observation Storage | ~481 |
|
||||
| #22303 | " | 🔵 | SessionStore CRUD Operations for Hook Integration | ~392 |
|
||||
| #22300 | 9:44 PM | 🔵 | SessionStore Database Management and Schema Migrations | ~455 |
|
||||
| #22299 | " | 🔵 | Database Schema and Entity Types | ~460 |
|
||||
| #21976 | 5:24 PM | 🟣 | storeObservation Saves tool_use_id to Database | ~298 |
|
||||
|
||||
### Dec 10, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #23808 | 10:42 PM | 🔵 | migrations.ts Already Migrated to bun:sqlite | ~312 |
|
||||
| #23807 | " | 🔵 | SessionSearch.ts Already Migrated to bun:sqlite | ~321 |
|
||||
| #23805 | " | 🔵 | Database.ts Already Migrated to bun:sqlite | ~290 |
|
||||
| #23784 | 9:59 PM | ✅ | SessionStore.ts db.pragma() Converted to db.query().all() Pattern | ~198 |
|
||||
| #23783 | 9:58 PM | ✅ | SessionStore.ts Migration004 Multi-Statement db.exec() Converted to db.run() | ~220 |
|
||||
| #23782 | " | ✅ | SessionStore.ts initializeSchema() db.exec() Converted to db.run() | ~197 |
|
||||
| #23781 | " | ✅ | SessionStore.ts Constructor PRAGMA Calls Converted to db.run() | ~215 |
|
||||
| #23780 | " | ✅ | SessionStore.ts Type Annotation Updated | ~183 |
|
||||
| #23779 | " | ✅ | SessionStore.ts Import Updated to bun:sqlite | ~237 |
|
||||
| #23778 | 9:57 PM | ✅ | Database.ts Import Updated to bun:sqlite | ~177 |
|
||||
| #23777 | " | 🔵 | SessionStore.ts Current Implementation - better-sqlite3 Import and API Usage | ~415 |
|
||||
| #23776 | " | 🔵 | migrations.ts Current Implementation - better-sqlite3 Import | ~285 |
|
||||
| #23775 | " | 🔵 | Database.ts Current Implementation - better-sqlite3 Import | ~286 |
|
||||
| #23774 | " | 🔵 | SessionSearch.ts Current Implementation - better-sqlite3 Import | ~309 |
|
||||
| #23671 | 8:36 PM | 🔵 | getUserPromptsByIds Method Implementation with Filtering and Ordering | ~326 |
|
||||
| #23670 | " | 🔵 | getUserPromptsByIds Method Location in SessionStore | ~145 |
|
||||
| #23635 | 8:10 PM | 🔴 | Fixed SessionStore.ts Concepts Filter SQL Parameter Bug | ~297 |
|
||||
| #23634 | " | 🔵 | SessionStore.ts Concepts Filter Bug Confirmed at Line 849 | ~356 |
|
||||
| #23522 | 5:27 PM | 🔵 | Complete TypeScript Type Definitions for Database Entities | ~433 |
|
||||
| #23521 | " | 🔵 | Database Schema Structure with 7 Migration Versions | ~461 |
|
||||
|
||||
### Dec 18, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #29868 | 8:19 PM | 🔵 | SessionStore Architecture Review for Mode Metadata Addition | ~350 |
|
||||
| #29243 | 12:13 AM | 🔵 | Observations Table Schema Migration: Text Field Made Nullable | ~496 |
|
||||
| #29241 | 12:12 AM | 🔵 | Migration001: Core Schema for Sessions, Memories, Overviews, Diagnostics, Transcripts | ~555 |
|
||||
| #29238 | 12:11 AM | 🔵 | Observation Type Schema Evolution: Five to Six Types | ~331 |
|
||||
| #29237 | " | 🔵 | SQLite SessionStore with Schema Migrations and WAL Mode | ~520 |
|
||||
|
||||
### Dec 21, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #31622 | 8:26 PM | 🔄 | Completed SessionStore logging standardization | ~270 |
|
||||
| #31621 | " | 🔄 | Standardized error logging for boundary timestamps query | ~253 |
|
||||
| #31620 | " | 🔄 | Standardized error logging in getTimelineAroundObservation | ~252 |
|
||||
| #31619 | " | 🔄 | Replaced console.log with logger.debug in SessionStore | ~263 |
|
||||
|
||||
### Dec 27, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #33213 | 9:04 PM | 🔵 | SessionStore Implements KISS Session ID Threading via INSERT OR IGNORE Pattern | ~673 |
|
||||
|
||||
### Dec 28, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #33548 | 10:59 PM | ✅ | Reverted memory_session_id NULL Initialization to contentSessionId Placeholder | ~421 |
|
||||
| #33546 | 10:57 PM | 🔴 | Fixed createSDKSession to Initialize memory_session_id as NULL | ~406 |
|
||||
| #33545 | " | 🔵 | createSDKSession Sets memory_session_id Equal to content_session_id Initially | ~378 |
|
||||
| #33544 | " | 🔵 | SessionStore Migration 17 Already Renamed Session ID Columns | ~451 |
|
||||
|
||||
### Jan 2, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #36028 | 9:20 PM | 🔄 | Try-Catch Block Removed from Database Migration | ~291 |
|
||||
|
||||
### Jan 3, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #36653 | 11:03 PM | 🔵 | storeObservation Method Signature Shows Parameter Named memorySessionId | ~474 |
|
||||
| #36652 | " | 🔵 | createSDKSession Implementation Confirms NULL Initialization With Security Rationale | ~488 |
|
||||
| #36650 | 11:02 PM | 🔵 | Phase 1 Analysis Reveals Implementation-Test Mismatch on NULL vs Placeholder Initialization | ~687 |
|
||||
| #36649 | " | 🔵 | SessionStore Implementation Reveals NULL-Based Memory Session ID Initialization Pattern | ~770 |
|
||||
| #36175 | 6:52 PM | ✅ | MigrationRunner Re-exported from Migrations.ts | ~405 |
|
||||
| #36172 | " | 🔵 | Migrations.ts Contains Legacy Migration System | ~650 |
|
||||
| #36163 | 6:48 PM | 🔵 | SessionStore Method Inventory and Extraction Boundaries | ~692 |
|
||||
| #36162 | 6:47 PM | 🔵 | SessionStore Architecture and Migration History | ~593 |
|
||||
</claude-mem-context>
|
||||
@@ -77,12 +77,13 @@ export class PendingMessageStore {
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically claim and DELETE the next pending message.
|
||||
* Finds oldest pending -> returns it -> deletes from queue.
|
||||
* The queue is a pure buffer: claim it, delete it, process in memory.
|
||||
* Atomically claim the next pending message by marking it as 'processing'.
|
||||
* CRITICAL FIX: Does NOT delete - message stays in DB until confirmProcessed() is called.
|
||||
* This prevents message loss if the generator crashes mid-processing.
|
||||
* Uses a transaction to prevent race conditions.
|
||||
*/
|
||||
claimAndDelete(sessionDbId: number): PersistentPendingMessage | null {
|
||||
const now = Date.now();
|
||||
const claimTx = this.db.transaction((sessionId: number) => {
|
||||
const peekStmt = this.db.prepare(`
|
||||
SELECT * FROM pending_messages
|
||||
@@ -93,9 +94,14 @@ export class PendingMessageStore {
|
||||
const msg = peekStmt.get(sessionId) as PersistentPendingMessage | null;
|
||||
|
||||
if (msg) {
|
||||
// Delete immediately - no "processing" state needed
|
||||
const deleteStmt = this.db.prepare('DELETE FROM pending_messages WHERE id = ?');
|
||||
deleteStmt.run(msg.id);
|
||||
// CRITICAL FIX: Mark as 'processing' instead of deleting
|
||||
// Message will be deleted by confirmProcessed() after successful store
|
||||
const updateStmt = this.db.prepare(`
|
||||
UPDATE pending_messages
|
||||
SET status = 'processing', started_processing_at_epoch = ?
|
||||
WHERE id = ?
|
||||
`);
|
||||
updateStmt.run(now, msg.id);
|
||||
|
||||
// Log claim with minimal info (avoid logging full payload)
|
||||
logger.info('QUEUE', `CLAIMED | sessionDbId=${sessionId} | messageId=${msg.id} | type=${msg.message_type}`, {
|
||||
@@ -108,6 +114,39 @@ export class PendingMessageStore {
|
||||
return claimTx(sessionDbId) as PersistentPendingMessage | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm a message was successfully processed - DELETE it from the queue.
|
||||
* CRITICAL: Only call this AFTER the observation/summary has been stored to DB.
|
||||
* This prevents message loss on generator crash.
|
||||
*/
|
||||
confirmProcessed(messageId: number): void {
|
||||
const stmt = this.db.prepare('DELETE FROM pending_messages WHERE id = ?');
|
||||
const result = stmt.run(messageId);
|
||||
if (result.changes > 0) {
|
||||
logger.debug('QUEUE', `CONFIRMED | messageId=${messageId} | deleted from queue`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset stale 'processing' messages back to 'pending' for retry.
|
||||
* Called on worker startup and periodically to recover from crashes.
|
||||
* @param thresholdMs Messages processing longer than this are considered stale (default: 5 minutes)
|
||||
* @returns Number of messages reset
|
||||
*/
|
||||
resetStaleProcessingMessages(thresholdMs: number = 5 * 60 * 1000): number {
|
||||
const cutoff = Date.now() - thresholdMs;
|
||||
const stmt = this.db.prepare(`
|
||||
UPDATE pending_messages
|
||||
SET status = 'pending', started_processing_at_epoch = NULL
|
||||
WHERE status = 'processing' AND started_processing_at_epoch < ?
|
||||
`);
|
||||
const result = stmt.run(cutoff);
|
||||
if (result.changes > 0) {
|
||||
logger.info('QUEUE', `RESET_STALE | count=${result.changes} | thresholdMs=${thresholdMs}`);
|
||||
}
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all pending messages for session (ordered by creation time)
|
||||
*/
|
||||
@@ -204,6 +243,23 @@ export class PendingMessageStore {
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark all pending and processing messages for a session as failed (abandoned).
|
||||
* Used when SDK session is terminated and no fallback agent is available:
|
||||
* prevents the session from appearing in getSessionsWithPendingMessages forever.
|
||||
* @returns Number of messages marked failed
|
||||
*/
|
||||
markAllSessionMessagesAbandoned(sessionDbId: number): number {
|
||||
const now = Date.now();
|
||||
const stmt = this.db.prepare(`
|
||||
UPDATE pending_messages
|
||||
SET status = 'failed', failed_at_epoch = ?
|
||||
WHERE session_db_id = ? AND status IN ('pending', 'processing')
|
||||
`);
|
||||
const result = stmt.run(now, sessionDbId);
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort a specific message (delete from queue)
|
||||
*/
|
||||
|
||||
@@ -47,6 +47,7 @@ export class SessionStore {
|
||||
this.renameSessionIdColumns();
|
||||
this.repairSessionIdColumnRename();
|
||||
this.addFailedAtEpochColumn();
|
||||
this.addOnUpdateCascadeToForeignKeys();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -98,10 +99,10 @@ export class SessionStore {
|
||||
memory_session_id TEXT NOT NULL,
|
||||
project TEXT NOT NULL,
|
||||
text TEXT NOT NULL,
|
||||
type TEXT NOT NULL CHECK(type IN ('decision', 'bugfix', 'feature', 'refactor', 'discovery')),
|
||||
type TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
created_at_epoch INTEGER NOT NULL,
|
||||
FOREIGN KEY(memory_session_id) REFERENCES sdk_sessions(memory_session_id) ON DELETE CASCADE
|
||||
FOREIGN KEY(memory_session_id) REFERENCES sdk_sessions(memory_session_id) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_observations_sdk_session ON observations(memory_session_id);
|
||||
@@ -123,7 +124,7 @@ export class SessionStore {
|
||||
notes TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
created_at_epoch INTEGER NOT NULL,
|
||||
FOREIGN KEY(memory_session_id) REFERENCES sdk_sessions(memory_session_id) ON DELETE CASCADE
|
||||
FOREIGN KEY(memory_session_id) REFERENCES sdk_sessions(memory_session_id) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_session_summaries_sdk_session ON session_summaries(memory_session_id);
|
||||
@@ -341,7 +342,7 @@ export class SessionStore {
|
||||
memory_session_id TEXT NOT NULL,
|
||||
project TEXT NOT NULL,
|
||||
text TEXT,
|
||||
type TEXT NOT NULL CHECK(type IN ('decision', 'bugfix', 'feature', 'refactor', 'discovery', 'change')),
|
||||
type TEXT NOT NULL,
|
||||
title TEXT,
|
||||
subtitle TEXT,
|
||||
facts TEXT,
|
||||
@@ -645,11 +646,191 @@ export class SessionStore {
|
||||
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(20, new Date().toISOString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Add ON UPDATE CASCADE to FK constraints on observations and session_summaries (migration 21)
|
||||
*
|
||||
* Both tables have FK(memory_session_id) -> sdk_sessions(memory_session_id) with ON DELETE CASCADE
|
||||
* but missing ON UPDATE CASCADE. This causes FK constraint violations when code updates
|
||||
* sdk_sessions.memory_session_id while child rows still reference the old value.
|
||||
*
|
||||
* SQLite doesn't support ALTER TABLE for FK changes, so we recreate both tables.
|
||||
*/
|
||||
private addOnUpdateCascadeToForeignKeys(): void {
|
||||
const applied = this.db.prepare('SELECT version FROM schema_versions WHERE version = ?').get(21) as SchemaVersion | undefined;
|
||||
if (applied) return;
|
||||
|
||||
logger.debug('DB', 'Adding ON UPDATE CASCADE to FK constraints on observations and session_summaries');
|
||||
|
||||
// PRAGMA foreign_keys must be set outside a transaction
|
||||
this.db.run('PRAGMA foreign_keys = OFF');
|
||||
this.db.run('BEGIN TRANSACTION');
|
||||
|
||||
try {
|
||||
// ==========================================
|
||||
// 1. Recreate observations table
|
||||
// ==========================================
|
||||
|
||||
// Drop FTS triggers first (they reference the observations table)
|
||||
this.db.run('DROP TRIGGER IF EXISTS observations_ai');
|
||||
this.db.run('DROP TRIGGER IF EXISTS observations_ad');
|
||||
this.db.run('DROP TRIGGER IF EXISTS observations_au');
|
||||
|
||||
this.db.run(`
|
||||
CREATE TABLE observations_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
memory_session_id TEXT NOT NULL,
|
||||
project TEXT NOT NULL,
|
||||
text TEXT,
|
||||
type TEXT NOT NULL,
|
||||
title TEXT,
|
||||
subtitle TEXT,
|
||||
facts TEXT,
|
||||
narrative TEXT,
|
||||
concepts TEXT,
|
||||
files_read TEXT,
|
||||
files_modified TEXT,
|
||||
prompt_number INTEGER,
|
||||
discovery_tokens INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
created_at_epoch INTEGER NOT NULL,
|
||||
FOREIGN KEY(memory_session_id) REFERENCES sdk_sessions(memory_session_id) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
this.db.run(`
|
||||
INSERT INTO observations_new
|
||||
SELECT id, memory_session_id, project, text, type, title, subtitle, facts,
|
||||
narrative, concepts, files_read, files_modified, prompt_number,
|
||||
discovery_tokens, created_at, created_at_epoch
|
||||
FROM observations
|
||||
`);
|
||||
|
||||
this.db.run('DROP TABLE observations');
|
||||
this.db.run('ALTER TABLE observations_new RENAME TO observations');
|
||||
|
||||
// Recreate indexes
|
||||
this.db.run(`
|
||||
CREATE INDEX idx_observations_sdk_session ON observations(memory_session_id);
|
||||
CREATE INDEX idx_observations_project ON observations(project);
|
||||
CREATE INDEX idx_observations_type ON observations(type);
|
||||
CREATE INDEX idx_observations_created ON observations(created_at_epoch DESC);
|
||||
`);
|
||||
|
||||
// Recreate FTS triggers only if observations_fts exists
|
||||
// (SessionSearch.ensureFTSTables creates it on first use with IF NOT EXISTS)
|
||||
const hasFTS = (this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='observations_fts'").all() as { name: string }[]).length > 0;
|
||||
if (hasFTS) {
|
||||
this.db.run(`
|
||||
CREATE TRIGGER IF NOT EXISTS observations_ai AFTER INSERT ON observations BEGIN
|
||||
INSERT INTO observations_fts(rowid, title, subtitle, narrative, text, facts, concepts)
|
||||
VALUES (new.id, new.title, new.subtitle, new.narrative, new.text, new.facts, new.concepts);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS observations_ad AFTER DELETE ON observations BEGIN
|
||||
INSERT INTO observations_fts(observations_fts, rowid, title, subtitle, narrative, text, facts, concepts)
|
||||
VALUES('delete', old.id, old.title, old.subtitle, old.narrative, old.text, old.facts, old.concepts);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS observations_au AFTER UPDATE ON observations BEGIN
|
||||
INSERT INTO observations_fts(observations_fts, rowid, title, subtitle, narrative, text, facts, concepts)
|
||||
VALUES('delete', old.id, old.title, old.subtitle, old.narrative, old.text, old.facts, old.concepts);
|
||||
INSERT INTO observations_fts(rowid, title, subtitle, narrative, text, facts, concepts)
|
||||
VALUES (new.id, new.title, new.subtitle, new.narrative, new.text, new.facts, new.concepts);
|
||||
END;
|
||||
`);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 2. Recreate session_summaries table
|
||||
// ==========================================
|
||||
|
||||
this.db.run(`
|
||||
CREATE TABLE session_summaries_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
memory_session_id TEXT NOT NULL,
|
||||
project TEXT NOT NULL,
|
||||
request TEXT,
|
||||
investigated TEXT,
|
||||
learned TEXT,
|
||||
completed TEXT,
|
||||
next_steps TEXT,
|
||||
files_read TEXT,
|
||||
files_edited TEXT,
|
||||
notes TEXT,
|
||||
prompt_number INTEGER,
|
||||
discovery_tokens INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
created_at_epoch INTEGER NOT NULL,
|
||||
FOREIGN KEY(memory_session_id) REFERENCES sdk_sessions(memory_session_id) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
this.db.run(`
|
||||
INSERT INTO session_summaries_new
|
||||
SELECT id, memory_session_id, project, request, investigated, learned,
|
||||
completed, next_steps, files_read, files_edited, notes,
|
||||
prompt_number, discovery_tokens, created_at, created_at_epoch
|
||||
FROM session_summaries
|
||||
`);
|
||||
|
||||
// Drop session_summaries FTS triggers before dropping the table
|
||||
this.db.run('DROP TRIGGER IF EXISTS session_summaries_ai');
|
||||
this.db.run('DROP TRIGGER IF EXISTS session_summaries_ad');
|
||||
this.db.run('DROP TRIGGER IF EXISTS session_summaries_au');
|
||||
|
||||
this.db.run('DROP TABLE session_summaries');
|
||||
this.db.run('ALTER TABLE session_summaries_new RENAME TO session_summaries');
|
||||
|
||||
// Recreate indexes
|
||||
this.db.run(`
|
||||
CREATE INDEX idx_session_summaries_sdk_session ON session_summaries(memory_session_id);
|
||||
CREATE INDEX idx_session_summaries_project ON session_summaries(project);
|
||||
CREATE INDEX idx_session_summaries_created ON session_summaries(created_at_epoch DESC);
|
||||
`);
|
||||
|
||||
// Recreate session_summaries FTS triggers if FTS table exists
|
||||
const hasSummariesFTS = (this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='session_summaries_fts'").all() as { name: string }[]).length > 0;
|
||||
if (hasSummariesFTS) {
|
||||
this.db.run(`
|
||||
CREATE TRIGGER IF NOT EXISTS session_summaries_ai AFTER INSERT ON session_summaries BEGIN
|
||||
INSERT INTO session_summaries_fts(rowid, request, investigated, learned, completed, next_steps, notes)
|
||||
VALUES (new.id, new.request, new.investigated, new.learned, new.completed, new.next_steps, new.notes);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS session_summaries_ad AFTER DELETE ON session_summaries BEGIN
|
||||
INSERT INTO session_summaries_fts(session_summaries_fts, rowid, request, investigated, learned, completed, next_steps, notes)
|
||||
VALUES('delete', old.id, old.request, old.investigated, old.learned, old.completed, old.next_steps, old.notes);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS session_summaries_au AFTER UPDATE ON session_summaries BEGIN
|
||||
INSERT INTO session_summaries_fts(session_summaries_fts, rowid, request, investigated, learned, completed, next_steps, notes)
|
||||
VALUES('delete', old.id, old.request, old.investigated, old.learned, old.completed, old.next_steps, old.notes);
|
||||
INSERT INTO session_summaries_fts(rowid, request, investigated, learned, completed, next_steps, notes)
|
||||
VALUES (new.id, new.request, new.investigated, new.learned, new.completed, new.next_steps, new.notes);
|
||||
END;
|
||||
`);
|
||||
}
|
||||
|
||||
// Record migration
|
||||
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(21, new Date().toISOString());
|
||||
|
||||
this.db.run('COMMIT');
|
||||
this.db.run('PRAGMA foreign_keys = ON');
|
||||
|
||||
logger.debug('DB', 'Successfully added ON UPDATE CASCADE to FK constraints');
|
||||
} catch (error) {
|
||||
this.db.run('ROLLBACK');
|
||||
this.db.run('PRAGMA foreign_keys = ON');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the memory session ID for a session
|
||||
* Called by SDKAgent when it captures the session ID from the first SDK message
|
||||
* Also used to RESET to null on stale resume failures (worker-service.ts)
|
||||
*/
|
||||
updateMemorySessionId(sessionDbId: number, memorySessionId: string): void {
|
||||
updateMemorySessionId(sessionDbId: number, memorySessionId: string | null): void {
|
||||
this.db.prepare(`
|
||||
UPDATE sdk_sessions
|
||||
SET memory_session_id = ?
|
||||
@@ -657,6 +838,37 @@ export class SessionStore {
|
||||
`).run(memorySessionId, sessionDbId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures memory_session_id is registered in sdk_sessions before FK-constrained INSERT.
|
||||
* This fixes Issue #846 where observations fail after worker restart because the
|
||||
* SDK generates a new memory_session_id but it's not registered in the parent table
|
||||
* before child records try to reference it.
|
||||
*
|
||||
* @param sessionDbId - The database ID of the session
|
||||
* @param memorySessionId - The memory session ID to ensure is registered
|
||||
*/
|
||||
ensureMemorySessionIdRegistered(sessionDbId: number, memorySessionId: string): void {
|
||||
const session = this.db.prepare(`
|
||||
SELECT id, memory_session_id FROM sdk_sessions WHERE id = ?
|
||||
`).get(sessionDbId) as { id: number; memory_session_id: string | null } | undefined;
|
||||
|
||||
if (!session) {
|
||||
throw new Error(`Session ${sessionDbId} not found in sdk_sessions`);
|
||||
}
|
||||
|
||||
if (session.memory_session_id !== memorySessionId) {
|
||||
this.db.prepare(`
|
||||
UPDATE sdk_sessions SET memory_session_id = ? WHERE id = ?
|
||||
`).run(memorySessionId, sessionDbId);
|
||||
|
||||
logger.info('DB', 'Registered memory_session_id before storage (FK fix)', {
|
||||
sessionDbId,
|
||||
oldId: session.memory_session_id,
|
||||
newId: memorySessionId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent session summaries for a project
|
||||
*/
|
||||
@@ -1151,31 +1363,40 @@ export class SessionStore {
|
||||
* - Prompt #2+: session_id exists → INSERT ignored, fetch existing ID
|
||||
* - Result: Same database ID returned for all prompts in conversation
|
||||
*
|
||||
* WHY THIS MATTERS:
|
||||
* - NO "does session exist?" checks needed anywhere
|
||||
* - NO risk of creating duplicate sessions
|
||||
* - ALL hooks automatically connected via session_id
|
||||
* - SAVE hook observations go to correct session (same session_id)
|
||||
* - SDKAgent continuation prompt has correct context (same session_id)
|
||||
*
|
||||
* This is KISS in action: Trust the database UNIQUE constraint and
|
||||
* INSERT OR IGNORE to handle both creation and lookup elegantly.
|
||||
* Pure get-or-create: never modifies memory_session_id.
|
||||
* Multi-terminal isolation is handled by ON UPDATE CASCADE at the schema level.
|
||||
*/
|
||||
createSDKSession(contentSessionId: string, project: string, userPrompt: string): number {
|
||||
const now = new Date();
|
||||
const nowEpoch = now.getTime();
|
||||
|
||||
// Pure INSERT OR IGNORE - no updates, no complexity
|
||||
// Session reuse: Return existing session ID if already created for this contentSessionId.
|
||||
const existing = this.db.prepare(`
|
||||
SELECT id FROM sdk_sessions WHERE content_session_id = ?
|
||||
`).get(contentSessionId) as { id: number } | undefined;
|
||||
|
||||
if (existing) {
|
||||
// Backfill project if session was created by another hook with empty project
|
||||
if (project) {
|
||||
this.db.prepare(`
|
||||
UPDATE sdk_sessions SET project = ?
|
||||
WHERE content_session_id = ? AND (project IS NULL OR project = '')
|
||||
`).run(project, contentSessionId);
|
||||
}
|
||||
return existing.id;
|
||||
}
|
||||
|
||||
// New session - insert fresh row
|
||||
// NOTE: memory_session_id starts as NULL. It is captured by SDKAgent from the first SDK
|
||||
// response and stored via updateMemorySessionId(). CRITICAL: memory_session_id must NEVER
|
||||
// equal contentSessionId - that would inject memory messages into the user's transcript!
|
||||
// response and stored via ensureMemorySessionIdRegistered(). CRITICAL: memory_session_id
|
||||
// must NEVER equal contentSessionId - that would inject memory messages into the user's transcript!
|
||||
this.db.prepare(`
|
||||
INSERT OR IGNORE INTO sdk_sessions
|
||||
INSERT INTO sdk_sessions
|
||||
(content_session_id, memory_session_id, project, user_prompt, started_at, started_at_epoch, status)
|
||||
VALUES (?, NULL, ?, ?, ?, ?, 'active')
|
||||
`).run(contentSessionId, project, userPrompt, now.toISOString(), nowEpoch);
|
||||
|
||||
// Return existing or new ID
|
||||
// Return new ID
|
||||
const row = this.db.prepare('SELECT id FROM sdk_sessions WHERE content_session_id = ?')
|
||||
.get(contentSessionId) as { id: number };
|
||||
return row.id;
|
||||
@@ -1907,6 +2128,34 @@ export class SessionStore {
|
||||
return stmt.get(id) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a manual session for storing user-created observations
|
||||
* Manual sessions use a predictable ID format: "manual-{project}"
|
||||
*/
|
||||
getOrCreateManualSession(project: string): string {
|
||||
const memorySessionId = `manual-${project}`;
|
||||
const contentSessionId = `manual-content-${project}`;
|
||||
|
||||
const existing = this.db.prepare(
|
||||
'SELECT memory_session_id FROM sdk_sessions WHERE memory_session_id = ?'
|
||||
).get(memorySessionId) as { memory_session_id: string } | undefined;
|
||||
|
||||
if (existing) {
|
||||
return memorySessionId;
|
||||
}
|
||||
|
||||
// Create new manual session
|
||||
const now = new Date();
|
||||
this.db.prepare(`
|
||||
INSERT INTO sdk_sessions (memory_session_id, content_session_id, project, started_at, started_at_epoch, status)
|
||||
VALUES (?, ?, ?, ?, ?, 'active')
|
||||
`).run(memorySessionId, contentSessionId, project, now.toISOString(), now.getTime());
|
||||
|
||||
logger.info('SESSION', 'Created manual session', { memorySessionId, project });
|
||||
|
||||
return memorySessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the database connection
|
||||
*/
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Jan 3, 2026
|
||||
|
||||
**bulk.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #36670 | 11:37 PM | ✅ | Resolved merge conflicts by accepting branch changes for 39 files | ~435 |
|
||||
| #36469 | 9:04 PM | 🔵 | Bulk Import with Duplicate Detection | ~451 |
|
||||
| #36390 | 8:50 PM | 🔄 | Comprehensive Monolith Refactor with Modular Architecture | ~724 |
|
||||
</claude-mem-context>
|
||||
@@ -259,7 +259,7 @@ export const migration004: Migration = {
|
||||
memory_session_id TEXT NOT NULL,
|
||||
project TEXT NOT NULL,
|
||||
text TEXT NOT NULL,
|
||||
type TEXT NOT NULL CHECK(type IN ('decision', 'bugfix', 'feature', 'refactor', 'discovery')),
|
||||
type TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
created_at_epoch INTEGER NOT NULL,
|
||||
FOREIGN KEY(memory_session_id) REFERENCES sdk_sessions(memory_session_id) ON DELETE CASCADE
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Jan 3, 2026
|
||||
|
||||
**runner.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #36487 | 9:13 PM | 🔴 | Fixed Foreign Key Constraint Issues in Observations Test Suite | ~677 |
|
||||
| #36390 | 8:50 PM | 🔄 | Comprehensive Monolith Refactor with Modular Architecture | ~724 |
|
||||
| #36353 | 8:42 PM | 🔵 | Multiple observation table definitions found across codebase | ~280 |
|
||||
| #36323 | 8:25 PM | 🔵 | Message Queue Architecture Scope Expanded | ~302 |
|
||||
</claude-mem-context>
|
||||
@@ -82,7 +82,7 @@ export class MigrationRunner {
|
||||
memory_session_id TEXT NOT NULL,
|
||||
project TEXT NOT NULL,
|
||||
text TEXT NOT NULL,
|
||||
type TEXT NOT NULL CHECK(type IN ('decision', 'bugfix', 'feature', 'refactor', 'discovery')),
|
||||
type TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
created_at_epoch INTEGER NOT NULL,
|
||||
FOREIGN KEY(memory_session_id) REFERENCES sdk_sessions(memory_session_id) ON DELETE CASCADE
|
||||
@@ -325,7 +325,7 @@ export class MigrationRunner {
|
||||
memory_session_id TEXT NOT NULL,
|
||||
project TEXT NOT NULL,
|
||||
text TEXT,
|
||||
type TEXT NOT NULL CHECK(type IN ('decision', 'bugfix', 'feature', 'refactor', 'discovery', 'change')),
|
||||
type TEXT NOT NULL,
|
||||
title TEXT,
|
||||
subtitle TEXT,
|
||||
facts TEXT,
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Jan 3, 2026
|
||||
|
||||
**files.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #36670 | 11:37 PM | ✅ | Resolved merge conflicts by accepting branch changes for 39 files | ~435 |
|
||||
| #36453 | 9:02 PM | 🔵 | Session File Aggregation | ~384 |
|
||||
| #36390 | 8:50 PM | 🔄 | Comprehensive Monolith Refactor with Modular Architecture | ~724 |
|
||||
|
||||
**store.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #36483 | 9:11 PM | 🟣 | Observations Module Test Suite Implemented | ~716 |
|
||||
| #36445 | 9:01 PM | 🔵 | Observation Storage with Timestamp Override | ~444 |
|
||||
|
||||
**types.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #36470 | 9:06 PM | 🔵 | SQLite Module API Documentation Verified for Test Implementation | ~765 |
|
||||
| #36447 | 9:02 PM | 🔵 | Observation Type Definitions | ~459 |
|
||||
|
||||
### Jan 4, 2026
|
||||
|
||||
**types.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #36770 | 12:42 AM | 🔵 | Export Script Type Duplication Analysis Complete | ~555 |
|
||||
</claude-mem-context>
|
||||
@@ -1,32 +0,0 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Jan 3, 2026
|
||||
|
||||
**get.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #36670 | 11:37 PM | ✅ | Resolved merge conflicts by accepting branch changes for 39 files | ~435 |
|
||||
| #36464 | 9:04 PM | 🔵 | User Prompt Retrieval Functions | ~471 |
|
||||
| #36390 | 8:50 PM | 🔄 | Comprehensive Monolith Refactor with Modular Architecture | ~724 |
|
||||
|
||||
**store.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #36485 | 9:12 PM | 🟣 | Prompts Module Test Suite Implemented | ~680 |
|
||||
| #36466 | 9:04 PM | 🔵 | User Prompt Storage | ~363 |
|
||||
|
||||
**types.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #36470 | 9:06 PM | 🔵 | SQLite Module API Documentation Verified for Test Implementation | ~765 |
|
||||
|
||||
### Jan 4, 2026
|
||||
|
||||
**types.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #36770 | 12:42 AM | 🔵 | Export Script Type Duplication Analysis Complete | ~555 |
|
||||
</claude-mem-context>
|
||||
@@ -1,7 +0,0 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
*No recent activity*
|
||||
</claude-mem-context>
|
||||
@@ -14,12 +14,8 @@ import { logger } from '../../../utils/logger.js';
|
||||
* - Prompt #2+: session_id exists -> INSERT ignored, fetch existing ID
|
||||
* - Result: Same database ID returned for all prompts in conversation
|
||||
*
|
||||
* WHY THIS MATTERS:
|
||||
* - NO "does session exist?" checks needed anywhere
|
||||
* - NO risk of creating duplicate sessions
|
||||
* - ALL hooks automatically connected via session_id
|
||||
* - SAVE hook observations go to correct session (same session_id)
|
||||
* - SDKAgent continuation prompt has correct context (same session_id)
|
||||
* Pure get-or-create: never modifies memory_session_id.
|
||||
* Multi-terminal isolation is handled by ON UPDATE CASCADE at the schema level.
|
||||
*/
|
||||
export function createSDKSession(
|
||||
db: Database,
|
||||
@@ -30,17 +26,33 @@ export function createSDKSession(
|
||||
const now = new Date();
|
||||
const nowEpoch = now.getTime();
|
||||
|
||||
// Pure INSERT OR IGNORE - no updates, no complexity
|
||||
// Check for existing session
|
||||
const existing = db.prepare(`
|
||||
SELECT id FROM sdk_sessions WHERE content_session_id = ?
|
||||
`).get(contentSessionId) as { id: number } | undefined;
|
||||
|
||||
if (existing) {
|
||||
// Backfill project if session was created by another hook with empty project
|
||||
if (project) {
|
||||
db.prepare(`
|
||||
UPDATE sdk_sessions SET project = ?
|
||||
WHERE content_session_id = ? AND (project IS NULL OR project = '')
|
||||
`).run(project, contentSessionId);
|
||||
}
|
||||
return existing.id;
|
||||
}
|
||||
|
||||
// New session - insert fresh row
|
||||
// NOTE: memory_session_id starts as NULL. It is captured by SDKAgent from the first SDK
|
||||
// response and stored via updateMemorySessionId(). CRITICAL: memory_session_id must NEVER
|
||||
// equal contentSessionId - that would inject memory messages into the user's transcript!
|
||||
// response and stored via ensureMemorySessionIdRegistered(). CRITICAL: memory_session_id
|
||||
// must NEVER equal contentSessionId - that would inject memory messages into the user's transcript!
|
||||
db.prepare(`
|
||||
INSERT OR IGNORE INTO sdk_sessions
|
||||
INSERT INTO sdk_sessions
|
||||
(content_session_id, memory_session_id, project, user_prompt, started_at, started_at_epoch, status)
|
||||
VALUES (?, NULL, ?, ?, ?, ?, 'active')
|
||||
`).run(contentSessionId, project, userPrompt, now.toISOString(), nowEpoch);
|
||||
|
||||
// Return existing or new ID
|
||||
// Return new ID
|
||||
const row = db.prepare('SELECT id FROM sdk_sessions WHERE content_session_id = ?')
|
||||
.get(contentSessionId) as { id: number };
|
||||
return row.id;
|
||||
@@ -49,11 +61,12 @@ export function createSDKSession(
|
||||
/**
|
||||
* Update the memory session ID for a session
|
||||
* Called by SDKAgent when it captures the session ID from the first SDK message
|
||||
* Also used to RESET to null on stale resume failures (worker-service.ts)
|
||||
*/
|
||||
export function updateMemorySessionId(
|
||||
db: Database,
|
||||
sessionDbId: number,
|
||||
memorySessionId: string
|
||||
memorySessionId: string | null
|
||||
): void {
|
||||
db.prepare(`
|
||||
UPDATE sdk_sessions
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Jan 3, 2026
|
||||
|
||||
**get.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #36670 | 11:37 PM | ✅ | Resolved merge conflicts by accepting branch changes for 39 files | ~435 |
|
||||
| #36390 | 8:50 PM | 🔄 | Comprehensive Monolith Refactor with Modular Architecture | ~724 |
|
||||
|
||||
**store.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #36484 | 9:11 PM | 🟣 | Summaries Module Test Suite Implemented | ~708 |
|
||||
| #36461 | 9:03 PM | 🔵 | Summary Storage with Timestamp Override | ~439 |
|
||||
|
||||
**types.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #36470 | 9:06 PM | 🔵 | SQLite Module API Documentation Verified for Test Implementation | ~765 |
|
||||
| #36457 | 9:03 PM | 🔵 | Summary Type Hierarchy | ~426 |
|
||||
|
||||
### Jan 4, 2026
|
||||
|
||||
**types.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #36770 | 12:42 AM | 🔵 | Export Script Type Duplication Analysis Complete | ~555 |
|
||||
</claude-mem-context>
|
||||
@@ -1,7 +0,0 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
*No recent activity*
|
||||
</claude-mem-context>
|
||||
@@ -1,68 +0,0 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Nov 3, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #3465 | 6:26 PM | ⚖️ | PR preparation for hybrid search feature ready for submission | ~521 |
|
||||
| #3460 | 6:18 PM | ✅ | Suppressed stderr output from Chroma MCP transport | ~231 |
|
||||
| #3350 | 3:33 PM | ✅ | Document splitting strategy improves semantic search precision by vectorizing field-level content | ~701 |
|
||||
| #3346 | " | 🟣 | ChromaSync service provides automatic real-time vector database synchronization | ~699 |
|
||||
| #3345 | " | 🟣 | Completed ChromaDB hybrid search integration with semantic search across all content types | ~762 |
|
||||
| #3323 | 3:01 PM | 🟣 | Integrated user prompt backfill into ChromaSync.backfill() | ~257 |
|
||||
| #3322 | " | 🟣 | Implemented real-time user prompt sync to ChromaDB | ~275 |
|
||||
| #3321 | " | ✅ | Added StoredUserPrompt interface to ChromaSync | ~179 |
|
||||
|
||||
### Nov 4, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #3645 | 3:03 PM | 🔵 | Observation Counter Removal Validated Safe for Chroma Integration | ~504 |
|
||||
| #3643 | " | 🔵 | Chroma Document ID Structure and Granular Field Splitting | ~410 |
|
||||
| #3642 | " | 🔵 | Observation Counter Independence from Chroma Import Process | ~440 |
|
||||
|
||||
### Nov 11, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #6992 | 6:28 PM | ⚖️ | Comprehensive Windows Issue Investigation and Fix Strategy | ~631 |
|
||||
| #6986 | 6:26 PM | 🔵 | ChromaSync UVX Connection Configuration Analysis | ~333 |
|
||||
| #6953 | 5:49 PM | 🔵 | ChromaSync Relies on uvx Python Package Runner Instead of npx | ~326 |
|
||||
| #6952 | 5:48 PM | 🔵 | ChromaSync Uses uvx Command for MCP Server on All Platforms | ~368 |
|
||||
|
||||
### Dec 5, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #20401 | 7:18 PM | 🔵 | ChromaSync service synchronizes observations and summaries to vector database for semantic search | ~521 |
|
||||
|
||||
### Dec 13, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #25190 | 8:04 PM | 🔴 | Enhanced close() Method to Terminate Transport Subprocess | ~417 |
|
||||
| #25189 | 8:03 PM | 🔄 | Store Transport Reference in ensureConnection Method | ~284 |
|
||||
| #25188 | " | 🔄 | Added Transport Reference to ChromaSync Class | ~268 |
|
||||
| #25187 | " | 🔵 | ChromaSync Has close() Method But May Not Be Called | ~277 |
|
||||
| #25186 | " | 🔵 | ChromaSync Process Spawning via StdioClientTransport | ~355 |
|
||||
| #25117 | 7:39 PM | 🟣 | Automatic Collection Migration for Embedding Function Changes | ~493 |
|
||||
| #25116 | " | 🔄 | Collection Name Changed to Lazy Initialization | ~126 |
|
||||
| #25115 | " | 🔵 | ChromaSync Service Current Implementation Analysis | ~454 |
|
||||
| #25092 | 7:20 PM | 🟣 | ChromaSync Now Reads Embedding Function from Settings | ~394 |
|
||||
| #25090 | 7:19 PM | 🔵 | Located Hardcoded Embedding Function in ChromaSync | ~345 |
|
||||
|
||||
### Dec 17, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #28547 | 4:49 PM | 🔴 | Fixed Windows subprocess zombie process issue in ChromaSync | ~368 |
|
||||
| #28546 | " | ✅ | Added child_process import to ChromaSync | ~215 |
|
||||
| #28545 | 4:48 PM | 🟣 | Subprocess PID Extraction for Windows Process Management | ~385 |
|
||||
| #28544 | " | ✅ | Child Process PID Tracking Added to ChromaSync | ~239 |
|
||||
| #28543 | " | 🔵 | ChromaSync Service Architecture | ~337 |
|
||||
| #28542 | " | 🟣 | Windows Console Window Hiding for Chroma MCP Transport | ~308 |
|
||||
| #28468 | 4:25 PM | 🔵 | ChromaSync Fail-Fast MCP Vector Database Integration | ~501 |
|
||||
</claude-mem-context>
|
||||
@@ -0,0 +1,65 @@
|
||||
import { DEFAULT_CONFIG_PATH, DEFAULT_STATE_PATH, expandHomePath, loadTranscriptWatchConfig, writeSampleConfig } from './config.js';
|
||||
import { TranscriptWatcher } from './watcher.js';
|
||||
|
||||
function getArgValue(args: string[], name: string): string | null {
|
||||
const index = args.indexOf(name);
|
||||
if (index === -1) return null;
|
||||
return args[index + 1] ?? null;
|
||||
}
|
||||
|
||||
export async function runTranscriptCommand(subcommand: string | undefined, args: string[]): Promise<number> {
|
||||
switch (subcommand) {
|
||||
case 'init': {
|
||||
const configPath = getArgValue(args, '--config') ?? DEFAULT_CONFIG_PATH;
|
||||
writeSampleConfig(configPath);
|
||||
console.log(`Created sample config: ${expandHomePath(configPath)}`);
|
||||
return 0;
|
||||
}
|
||||
case 'watch': {
|
||||
const configPath = getArgValue(args, '--config') ?? DEFAULT_CONFIG_PATH;
|
||||
let config;
|
||||
try {
|
||||
config = loadTranscriptWatchConfig(configPath);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('not found')) {
|
||||
writeSampleConfig(configPath);
|
||||
console.log(`Created sample config: ${expandHomePath(configPath)}`);
|
||||
config = loadTranscriptWatchConfig(configPath);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const statePath = expandHomePath(config.stateFile ?? DEFAULT_STATE_PATH);
|
||||
const watcher = new TranscriptWatcher(config, statePath);
|
||||
await watcher.start();
|
||||
console.log('Transcript watcher running. Press Ctrl+C to stop.');
|
||||
|
||||
const shutdown = () => {
|
||||
watcher.stop();
|
||||
process.exit(0);
|
||||
};
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
return await new Promise(() => undefined);
|
||||
}
|
||||
case 'validate': {
|
||||
const configPath = getArgValue(args, '--config') ?? DEFAULT_CONFIG_PATH;
|
||||
try {
|
||||
loadTranscriptWatchConfig(configPath);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('not found')) {
|
||||
writeSampleConfig(configPath);
|
||||
console.log(`Created sample config: ${expandHomePath(configPath)}`);
|
||||
loadTranscriptWatchConfig(configPath);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
console.log(`Config OK: ${expandHomePath(configPath)}`);
|
||||
return 0;
|
||||
}
|
||||
default:
|
||||
console.log('Usage: claude-mem transcript <init|watch|validate> [--config <path>]');
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
||||
import { homedir } from 'os';
|
||||
import { join, dirname } from 'path';
|
||||
import type { TranscriptSchema, TranscriptWatchConfig } from './types.js';
|
||||
|
||||
export const DEFAULT_CONFIG_PATH = join(homedir(), '.claude-mem', 'transcript-watch.json');
|
||||
export const DEFAULT_STATE_PATH = join(homedir(), '.claude-mem', 'transcript-watch-state.json');
|
||||
|
||||
const CODEX_SAMPLE_SCHEMA: TranscriptSchema = {
|
||||
name: 'codex',
|
||||
version: '0.2',
|
||||
description: 'Schema for Codex session JSONL files under ~/.codex/sessions.',
|
||||
events: [
|
||||
{
|
||||
name: 'session-meta',
|
||||
match: { path: 'type', equals: 'session_meta' },
|
||||
action: 'session_context',
|
||||
fields: {
|
||||
sessionId: 'payload.id',
|
||||
cwd: 'payload.cwd'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'turn-context',
|
||||
match: { path: 'type', equals: 'turn_context' },
|
||||
action: 'session_context',
|
||||
fields: {
|
||||
cwd: 'payload.cwd'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'user-message',
|
||||
match: { path: 'payload.type', equals: 'user_message' },
|
||||
action: 'session_init',
|
||||
fields: {
|
||||
prompt: 'payload.message'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'assistant-message',
|
||||
match: { path: 'payload.type', equals: 'agent_message' },
|
||||
action: 'assistant_message',
|
||||
fields: {
|
||||
message: 'payload.message'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'tool-use',
|
||||
match: { path: 'payload.type', in: ['function_call', 'custom_tool_call', 'web_search_call'] },
|
||||
action: 'tool_use',
|
||||
fields: {
|
||||
toolId: 'payload.call_id',
|
||||
toolName: {
|
||||
coalesce: [
|
||||
'payload.name',
|
||||
{ value: 'web_search' }
|
||||
]
|
||||
},
|
||||
toolInput: {
|
||||
coalesce: [
|
||||
'payload.arguments',
|
||||
'payload.input',
|
||||
'payload.action'
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'tool-result',
|
||||
match: { path: 'payload.type', in: ['function_call_output', 'custom_tool_call_output'] },
|
||||
action: 'tool_result',
|
||||
fields: {
|
||||
toolId: 'payload.call_id',
|
||||
toolResponse: 'payload.output'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'session-end',
|
||||
match: { path: 'payload.type', equals: 'turn_aborted' },
|
||||
action: 'session_end'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export const SAMPLE_CONFIG: TranscriptWatchConfig = {
|
||||
version: 1,
|
||||
schemas: {
|
||||
codex: CODEX_SAMPLE_SCHEMA
|
||||
},
|
||||
watches: [
|
||||
{
|
||||
name: 'codex',
|
||||
path: '~/.codex/sessions/**/*.jsonl',
|
||||
schema: 'codex',
|
||||
startAtEnd: true,
|
||||
context: {
|
||||
mode: 'agents',
|
||||
path: '~/.codex/AGENTS.md',
|
||||
updateOn: ['session_start', 'session_end']
|
||||
}
|
||||
}
|
||||
],
|
||||
stateFile: DEFAULT_STATE_PATH
|
||||
};
|
||||
|
||||
export function expandHomePath(inputPath: string): string {
|
||||
if (!inputPath) return inputPath;
|
||||
if (inputPath.startsWith('~')) {
|
||||
return join(homedir(), inputPath.slice(1));
|
||||
}
|
||||
return inputPath;
|
||||
}
|
||||
|
||||
export function loadTranscriptWatchConfig(path = DEFAULT_CONFIG_PATH): TranscriptWatchConfig {
|
||||
const resolvedPath = expandHomePath(path);
|
||||
if (!existsSync(resolvedPath)) {
|
||||
throw new Error(`Transcript watch config not found: ${resolvedPath}`);
|
||||
}
|
||||
const raw = readFileSync(resolvedPath, 'utf-8');
|
||||
const parsed = JSON.parse(raw) as TranscriptWatchConfig;
|
||||
if (!parsed.version || !parsed.watches) {
|
||||
throw new Error(`Invalid transcript watch config: ${resolvedPath}`);
|
||||
}
|
||||
if (!parsed.stateFile) {
|
||||
parsed.stateFile = DEFAULT_STATE_PATH;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function writeSampleConfig(path = DEFAULT_CONFIG_PATH): void {
|
||||
const resolvedPath = expandHomePath(path);
|
||||
const dir = dirname(resolvedPath);
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
writeFileSync(resolvedPath, JSON.stringify(SAMPLE_CONFIG, null, 2));
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import type { FieldSpec, MatchRule, TranscriptSchema, WatchTarget } from './types.js';
|
||||
|
||||
interface ResolveContext {
|
||||
watch: WatchTarget;
|
||||
schema: TranscriptSchema;
|
||||
session?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function parsePath(path: string): Array<string | number> {
|
||||
const cleaned = path.trim().replace(/^\$\.?/, '');
|
||||
if (!cleaned) return [];
|
||||
|
||||
const tokens: Array<string | number> = [];
|
||||
const parts = cleaned.split('.');
|
||||
|
||||
for (const part of parts) {
|
||||
const regex = /([^[\]]+)|\[(\d+)\]/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = regex.exec(part)) !== null) {
|
||||
if (match[1]) {
|
||||
tokens.push(match[1]);
|
||||
} else if (match[2]) {
|
||||
tokens.push(parseInt(match[2], 10));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
export function getValueByPath(input: unknown, path: string): unknown {
|
||||
if (!path) return undefined;
|
||||
const tokens = parsePath(path);
|
||||
let current: any = input;
|
||||
|
||||
for (const token of tokens) {
|
||||
if (current === null || current === undefined) return undefined;
|
||||
current = current[token as any];
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
function isEmptyValue(value: unknown): boolean {
|
||||
return value === undefined || value === null || value === '';
|
||||
}
|
||||
|
||||
function resolveFromContext(path: string, ctx: ResolveContext): unknown {
|
||||
if (path.startsWith('$watch.')) {
|
||||
const key = path.slice('$watch.'.length);
|
||||
return (ctx.watch as any)[key];
|
||||
}
|
||||
if (path.startsWith('$schema.')) {
|
||||
const key = path.slice('$schema.'.length);
|
||||
return (ctx.schema as any)[key];
|
||||
}
|
||||
if (path.startsWith('$session.')) {
|
||||
const key = path.slice('$session.'.length);
|
||||
return ctx.session ? (ctx.session as any)[key] : undefined;
|
||||
}
|
||||
if (path === '$cwd') return ctx.watch.workspace;
|
||||
if (path === '$project') return ctx.watch.project;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function resolveFieldSpec(
|
||||
spec: FieldSpec | undefined,
|
||||
entry: unknown,
|
||||
ctx: ResolveContext
|
||||
): unknown {
|
||||
if (spec === undefined) return undefined;
|
||||
|
||||
if (typeof spec === 'string') {
|
||||
const fromContext = resolveFromContext(spec, ctx);
|
||||
if (fromContext !== undefined) return fromContext;
|
||||
return getValueByPath(entry, spec);
|
||||
}
|
||||
|
||||
if (spec.coalesce && Array.isArray(spec.coalesce)) {
|
||||
for (const candidate of spec.coalesce) {
|
||||
const value = resolveFieldSpec(candidate, entry, ctx);
|
||||
if (!isEmptyValue(value)) return value;
|
||||
}
|
||||
}
|
||||
|
||||
if (spec.path) {
|
||||
const fromContext = resolveFromContext(spec.path, ctx);
|
||||
if (fromContext !== undefined) return fromContext;
|
||||
const value = getValueByPath(entry, spec.path);
|
||||
if (!isEmptyValue(value)) return value;
|
||||
}
|
||||
|
||||
if (spec.value !== undefined) return spec.value;
|
||||
|
||||
if (spec.default !== undefined) return spec.default;
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function resolveFields(
|
||||
fields: Record<string, FieldSpec> | undefined,
|
||||
entry: unknown,
|
||||
ctx: ResolveContext
|
||||
): Record<string, unknown> {
|
||||
const resolved: Record<string, unknown> = {};
|
||||
if (!fields) return resolved;
|
||||
|
||||
for (const [key, spec] of Object.entries(fields)) {
|
||||
resolved[key] = resolveFieldSpec(spec, entry, ctx);
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export function matchesRule(
|
||||
entry: unknown,
|
||||
rule: MatchRule | undefined,
|
||||
schema: TranscriptSchema
|
||||
): boolean {
|
||||
if (!rule) return true;
|
||||
|
||||
const path = rule.path || schema.eventTypePath || 'type';
|
||||
const value = path ? getValueByPath(entry, path) : undefined;
|
||||
|
||||
if (rule.exists) {
|
||||
if (value === undefined || value === null || value === '') return false;
|
||||
}
|
||||
|
||||
if (rule.equals !== undefined) {
|
||||
return value === rule.equals;
|
||||
}
|
||||
|
||||
if (rule.in && Array.isArray(rule.in)) {
|
||||
return rule.in.includes(value);
|
||||
}
|
||||
|
||||
if (rule.contains !== undefined) {
|
||||
return typeof value === 'string' && value.includes(rule.contains);
|
||||
}
|
||||
|
||||
if (rule.regex) {
|
||||
try {
|
||||
const regex = new RegExp(rule.regex);
|
||||
return regex.test(String(value ?? ''));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
import { sessionInitHandler } from '../../cli/handlers/session-init.js';
|
||||
import { observationHandler } from '../../cli/handlers/observation.js';
|
||||
import { fileEditHandler } from '../../cli/handlers/file-edit.js';
|
||||
import { sessionCompleteHandler } from '../../cli/handlers/session-complete.js';
|
||||
import { ensureWorkerRunning, getWorkerPort } from '../../shared/worker-utils.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { getProjectContext, getProjectName } from '../../utils/project-name.js';
|
||||
import { writeAgentsMd } from '../../utils/agents-md-utils.js';
|
||||
import { resolveFieldSpec, resolveFields, matchesRule } from './field-utils.js';
|
||||
import { expandHomePath } from './config.js';
|
||||
import type { TranscriptSchema, WatchTarget, SchemaEvent } from './types.js';
|
||||
|
||||
interface SessionState {
|
||||
sessionId: string;
|
||||
cwd?: string;
|
||||
project?: string;
|
||||
lastUserMessage?: string;
|
||||
lastAssistantMessage?: string;
|
||||
pendingTools: Map<string, { name?: string; input?: unknown }>;
|
||||
}
|
||||
|
||||
interface PendingTool {
|
||||
id?: string;
|
||||
name?: string;
|
||||
input?: unknown;
|
||||
response?: unknown;
|
||||
}
|
||||
|
||||
export class TranscriptEventProcessor {
|
||||
private sessions = new Map<string, SessionState>();
|
||||
|
||||
async processEntry(
|
||||
entry: unknown,
|
||||
watch: WatchTarget,
|
||||
schema: TranscriptSchema,
|
||||
sessionIdOverride?: string | null
|
||||
): Promise<void> {
|
||||
for (const event of schema.events) {
|
||||
if (!matchesRule(entry, event.match, schema)) continue;
|
||||
await this.handleEvent(entry, watch, schema, event, sessionIdOverride ?? undefined);
|
||||
}
|
||||
}
|
||||
|
||||
private getSessionKey(watch: WatchTarget, sessionId: string): string {
|
||||
return `${watch.name}:${sessionId}`;
|
||||
}
|
||||
|
||||
private getOrCreateSession(watch: WatchTarget, sessionId: string): SessionState {
|
||||
const key = this.getSessionKey(watch, sessionId);
|
||||
let session = this.sessions.get(key);
|
||||
if (!session) {
|
||||
session = {
|
||||
sessionId,
|
||||
pendingTools: new Map()
|
||||
};
|
||||
this.sessions.set(key, session);
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
private resolveSessionId(
|
||||
entry: unknown,
|
||||
watch: WatchTarget,
|
||||
schema: TranscriptSchema,
|
||||
event: SchemaEvent,
|
||||
sessionIdOverride?: string
|
||||
): string | null {
|
||||
const ctx = { watch, schema } as any;
|
||||
const fieldSpec = event.fields?.sessionId ?? (schema.sessionIdPath ? { path: schema.sessionIdPath } : undefined);
|
||||
const resolved = resolveFieldSpec(fieldSpec, entry, ctx);
|
||||
if (typeof resolved === 'string' && resolved.trim()) return resolved;
|
||||
if (typeof resolved === 'number') return String(resolved);
|
||||
if (sessionIdOverride && sessionIdOverride.trim()) return sessionIdOverride;
|
||||
return null;
|
||||
}
|
||||
|
||||
private resolveCwd(
|
||||
entry: unknown,
|
||||
watch: WatchTarget,
|
||||
schema: TranscriptSchema,
|
||||
event: SchemaEvent,
|
||||
session: SessionState
|
||||
): string | undefined {
|
||||
const ctx = { watch, schema, session } as any;
|
||||
const fieldSpec = event.fields?.cwd ?? (schema.cwdPath ? { path: schema.cwdPath } : undefined);
|
||||
const resolved = resolveFieldSpec(fieldSpec, entry, ctx);
|
||||
if (typeof resolved === 'string' && resolved.trim()) return resolved;
|
||||
if (watch.workspace) return watch.workspace;
|
||||
return session.cwd;
|
||||
}
|
||||
|
||||
private resolveProject(
|
||||
entry: unknown,
|
||||
watch: WatchTarget,
|
||||
schema: TranscriptSchema,
|
||||
event: SchemaEvent,
|
||||
session: SessionState
|
||||
): string | undefined {
|
||||
const ctx = { watch, schema, session } as any;
|
||||
const fieldSpec = event.fields?.project ?? (schema.projectPath ? { path: schema.projectPath } : undefined);
|
||||
const resolved = resolveFieldSpec(fieldSpec, entry, ctx);
|
||||
if (typeof resolved === 'string' && resolved.trim()) return resolved;
|
||||
if (watch.project) return watch.project;
|
||||
if (session.cwd) return getProjectName(session.cwd);
|
||||
return session.project;
|
||||
}
|
||||
|
||||
private async handleEvent(
|
||||
entry: unknown,
|
||||
watch: WatchTarget,
|
||||
schema: TranscriptSchema,
|
||||
event: SchemaEvent,
|
||||
sessionIdOverride?: string
|
||||
): Promise<void> {
|
||||
const sessionId = this.resolveSessionId(entry, watch, schema, event, sessionIdOverride);
|
||||
if (!sessionId) {
|
||||
logger.debug('TRANSCRIPT', 'Skipping event without sessionId', { event: event.name, watch: watch.name });
|
||||
return;
|
||||
}
|
||||
|
||||
const session = this.getOrCreateSession(watch, sessionId);
|
||||
const cwd = this.resolveCwd(entry, watch, schema, event, session);
|
||||
if (cwd) session.cwd = cwd;
|
||||
const project = this.resolveProject(entry, watch, schema, event, session);
|
||||
if (project) session.project = project;
|
||||
|
||||
const fields = resolveFields(event.fields, entry, { watch, schema, session });
|
||||
|
||||
switch (event.action) {
|
||||
case 'session_context':
|
||||
this.applySessionContext(session, fields);
|
||||
break;
|
||||
case 'session_init':
|
||||
await this.handleSessionInit(session, fields);
|
||||
if (watch.context?.updateOn?.includes('session_start')) {
|
||||
await this.updateContext(session, watch);
|
||||
}
|
||||
break;
|
||||
case 'user_message':
|
||||
if (typeof fields.message === 'string') session.lastUserMessage = fields.message;
|
||||
if (typeof fields.prompt === 'string') session.lastUserMessage = fields.prompt;
|
||||
break;
|
||||
case 'assistant_message':
|
||||
if (typeof fields.message === 'string') session.lastAssistantMessage = fields.message;
|
||||
break;
|
||||
case 'tool_use':
|
||||
await this.handleToolUse(session, fields);
|
||||
break;
|
||||
case 'tool_result':
|
||||
await this.handleToolResult(session, fields);
|
||||
break;
|
||||
case 'observation':
|
||||
await this.sendObservation(session, fields);
|
||||
break;
|
||||
case 'file_edit':
|
||||
await this.sendFileEdit(session, fields);
|
||||
break;
|
||||
case 'session_end':
|
||||
await this.handleSessionEnd(session, watch);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private applySessionContext(session: SessionState, fields: Record<string, unknown>): void {
|
||||
const cwd = typeof fields.cwd === 'string' ? fields.cwd : undefined;
|
||||
const project = typeof fields.project === 'string' ? fields.project : undefined;
|
||||
if (cwd) session.cwd = cwd;
|
||||
if (project) session.project = project;
|
||||
}
|
||||
|
||||
private async handleSessionInit(session: SessionState, fields: Record<string, unknown>): Promise<void> {
|
||||
const prompt = typeof fields.prompt === 'string' ? fields.prompt : '';
|
||||
const cwd = session.cwd ?? process.cwd();
|
||||
if (prompt) {
|
||||
session.lastUserMessage = prompt;
|
||||
}
|
||||
|
||||
await sessionInitHandler.execute({
|
||||
sessionId: session.sessionId,
|
||||
cwd,
|
||||
prompt,
|
||||
platform: 'transcript'
|
||||
});
|
||||
}
|
||||
|
||||
private async handleToolUse(session: SessionState, fields: Record<string, unknown>): Promise<void> {
|
||||
const toolId = typeof fields.toolId === 'string' ? fields.toolId : undefined;
|
||||
const toolName = typeof fields.toolName === 'string' ? fields.toolName : undefined;
|
||||
const toolInput = this.maybeParseJson(fields.toolInput);
|
||||
const toolResponse = this.maybeParseJson(fields.toolResponse);
|
||||
|
||||
const pending: PendingTool = { id: toolId, name: toolName, input: toolInput, response: toolResponse };
|
||||
|
||||
if (toolId) {
|
||||
session.pendingTools.set(toolId, { name: pending.name, input: pending.input });
|
||||
}
|
||||
|
||||
if (toolName === 'apply_patch' && typeof toolInput === 'string') {
|
||||
const files = this.parseApplyPatchFiles(toolInput);
|
||||
for (const filePath of files) {
|
||||
await this.sendFileEdit(session, {
|
||||
filePath,
|
||||
edits: [{ type: 'apply_patch', patch: toolInput }]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (toolResponse !== undefined && toolName) {
|
||||
await this.sendObservation(session, {
|
||||
toolName,
|
||||
toolInput,
|
||||
toolResponse
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async handleToolResult(session: SessionState, fields: Record<string, unknown>): Promise<void> {
|
||||
const toolId = typeof fields.toolId === 'string' ? fields.toolId : undefined;
|
||||
const toolName = typeof fields.toolName === 'string' ? fields.toolName : undefined;
|
||||
const toolResponse = this.maybeParseJson(fields.toolResponse);
|
||||
|
||||
let toolInput: unknown = this.maybeParseJson(fields.toolInput);
|
||||
let name = toolName;
|
||||
|
||||
if (toolId && session.pendingTools.has(toolId)) {
|
||||
const pending = session.pendingTools.get(toolId)!;
|
||||
toolInput = pending.input ?? toolInput;
|
||||
name = name ?? pending.name;
|
||||
session.pendingTools.delete(toolId);
|
||||
}
|
||||
|
||||
if (name) {
|
||||
await this.sendObservation(session, {
|
||||
toolName: name,
|
||||
toolInput,
|
||||
toolResponse
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async sendObservation(session: SessionState, fields: Record<string, unknown>): Promise<void> {
|
||||
const toolName = typeof fields.toolName === 'string' ? fields.toolName : undefined;
|
||||
if (!toolName) return;
|
||||
|
||||
await observationHandler.execute({
|
||||
sessionId: session.sessionId,
|
||||
cwd: session.cwd ?? process.cwd(),
|
||||
toolName,
|
||||
toolInput: this.maybeParseJson(fields.toolInput),
|
||||
toolResponse: this.maybeParseJson(fields.toolResponse),
|
||||
platform: 'transcript'
|
||||
});
|
||||
}
|
||||
|
||||
private async sendFileEdit(session: SessionState, fields: Record<string, unknown>): Promise<void> {
|
||||
const filePath = typeof fields.filePath === 'string' ? fields.filePath : undefined;
|
||||
if (!filePath) return;
|
||||
|
||||
await fileEditHandler.execute({
|
||||
sessionId: session.sessionId,
|
||||
cwd: session.cwd ?? process.cwd(),
|
||||
filePath,
|
||||
edits: Array.isArray(fields.edits) ? fields.edits : undefined,
|
||||
platform: 'transcript'
|
||||
});
|
||||
}
|
||||
|
||||
private maybeParseJson(value: unknown): unknown {
|
||||
if (typeof value !== 'string') return value;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return value;
|
||||
if (!(trimmed.startsWith('{') || trimmed.startsWith('['))) return value;
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
private parseApplyPatchFiles(patch: string): string[] {
|
||||
const files: string[] = [];
|
||||
const lines = patch.split('\n');
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith('*** Update File: ')) {
|
||||
files.push(trimmed.replace('*** Update File: ', '').trim());
|
||||
} else if (trimmed.startsWith('*** Add File: ')) {
|
||||
files.push(trimmed.replace('*** Add File: ', '').trim());
|
||||
} else if (trimmed.startsWith('*** Delete File: ')) {
|
||||
files.push(trimmed.replace('*** Delete File: ', '').trim());
|
||||
} else if (trimmed.startsWith('*** Move to: ')) {
|
||||
files.push(trimmed.replace('*** Move to: ', '').trim());
|
||||
} else if (trimmed.startsWith('+++ ')) {
|
||||
const path = trimmed.replace('+++ ', '').replace(/^b\//, '').trim();
|
||||
if (path && path !== '/dev/null') files.push(path);
|
||||
}
|
||||
}
|
||||
return Array.from(new Set(files));
|
||||
}
|
||||
|
||||
private async handleSessionEnd(session: SessionState, watch: WatchTarget): Promise<void> {
|
||||
await this.queueSummary(session);
|
||||
await sessionCompleteHandler.execute({
|
||||
sessionId: session.sessionId,
|
||||
cwd: session.cwd ?? process.cwd(),
|
||||
platform: 'transcript'
|
||||
});
|
||||
await this.updateContext(session, watch);
|
||||
session.pendingTools.clear();
|
||||
const key = this.getSessionKey(watch, session.sessionId);
|
||||
this.sessions.delete(key);
|
||||
}
|
||||
|
||||
private async queueSummary(session: SessionState): Promise<void> {
|
||||
const workerReady = await ensureWorkerRunning();
|
||||
if (!workerReady) return;
|
||||
|
||||
const port = getWorkerPort();
|
||||
const lastAssistantMessage = session.lastAssistantMessage ?? '';
|
||||
|
||||
try {
|
||||
await fetch(`http://127.0.0.1:${port}/api/sessions/summarize`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
contentSessionId: session.sessionId,
|
||||
last_assistant_message: lastAssistantMessage
|
||||
})
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn('TRANSCRIPT', 'Summary request failed', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async updateContext(session: SessionState, watch: WatchTarget): Promise<void> {
|
||||
if (!watch.context) return;
|
||||
if (watch.context.mode !== 'agents') return;
|
||||
|
||||
const workerReady = await ensureWorkerRunning();
|
||||
if (!workerReady) return;
|
||||
|
||||
const cwd = session.cwd ?? watch.workspace;
|
||||
if (!cwd) return;
|
||||
|
||||
const context = getProjectContext(cwd);
|
||||
const projectsParam = context.allProjects.join(',');
|
||||
const port = getWorkerPort();
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:${port}/api/context/inject?projects=${encodeURIComponent(projectsParam)}`
|
||||
);
|
||||
if (!response.ok) return;
|
||||
|
||||
const content = (await response.text()).trim();
|
||||
if (!content) return;
|
||||
|
||||
const agentsPath = expandHomePath(watch.context.path ?? `${cwd}/AGENTS.md`);
|
||||
writeAgentsMd(agentsPath, content);
|
||||
logger.debug('TRANSCRIPT', 'Updated AGENTS.md context', { agentsPath, watch: watch.name });
|
||||
} catch (error) {
|
||||
logger.warn('TRANSCRIPT', 'Failed to update AGENTS.md context', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
||||
import { dirname } from 'path';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
|
||||
export interface TranscriptWatchState {
|
||||
offsets: Record<string, number>;
|
||||
}
|
||||
|
||||
export function loadWatchState(statePath: string): TranscriptWatchState {
|
||||
try {
|
||||
if (!existsSync(statePath)) {
|
||||
return { offsets: {} };
|
||||
}
|
||||
const raw = readFileSync(statePath, 'utf-8');
|
||||
const parsed = JSON.parse(raw) as TranscriptWatchState;
|
||||
if (!parsed.offsets) return { offsets: {} };
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
logger.warn('TRANSCRIPT', 'Failed to load watch state, starting fresh', {
|
||||
statePath,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
return { offsets: {} };
|
||||
}
|
||||
}
|
||||
|
||||
export function saveWatchState(statePath: string, state: TranscriptWatchState): void {
|
||||
try {
|
||||
const dir = dirname(statePath);
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
writeFileSync(statePath, JSON.stringify(state, null, 2));
|
||||
} catch (error) {
|
||||
logger.warn('TRANSCRIPT', 'Failed to save watch state', {
|
||||
statePath,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
export type FieldSpec =
|
||||
| string
|
||||
| {
|
||||
path?: string;
|
||||
value?: unknown;
|
||||
coalesce?: FieldSpec[];
|
||||
default?: unknown;
|
||||
};
|
||||
|
||||
export interface MatchRule {
|
||||
path?: string;
|
||||
equals?: unknown;
|
||||
in?: unknown[];
|
||||
contains?: string;
|
||||
exists?: boolean;
|
||||
regex?: string;
|
||||
}
|
||||
|
||||
export type EventAction =
|
||||
| 'session_init'
|
||||
| 'session_context'
|
||||
| 'user_message'
|
||||
| 'assistant_message'
|
||||
| 'tool_use'
|
||||
| 'tool_result'
|
||||
| 'observation'
|
||||
| 'file_edit'
|
||||
| 'session_end';
|
||||
|
||||
export interface SchemaEvent {
|
||||
name: string;
|
||||
match?: MatchRule;
|
||||
action: EventAction;
|
||||
fields?: Record<string, FieldSpec>;
|
||||
}
|
||||
|
||||
export interface TranscriptSchema {
|
||||
name: string;
|
||||
version?: string;
|
||||
description?: string;
|
||||
eventTypePath?: string;
|
||||
sessionIdPath?: string;
|
||||
cwdPath?: string;
|
||||
projectPath?: string;
|
||||
events: SchemaEvent[];
|
||||
}
|
||||
|
||||
export interface WatchContextConfig {
|
||||
mode: 'agents';
|
||||
path?: string;
|
||||
updateOn?: Array<'session_start' | 'session_end'>;
|
||||
}
|
||||
|
||||
export interface WatchTarget {
|
||||
name: string;
|
||||
path: string;
|
||||
schema: string | TranscriptSchema;
|
||||
workspace?: string;
|
||||
project?: string;
|
||||
context?: WatchContextConfig;
|
||||
rescanIntervalMs?: number;
|
||||
startAtEnd?: boolean;
|
||||
}
|
||||
|
||||
export interface TranscriptWatchConfig {
|
||||
version: 1;
|
||||
schemas?: Record<string, TranscriptSchema>;
|
||||
watches: WatchTarget[];
|
||||
stateFile?: string;
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import { existsSync, statSync, watch as fsWatch, createReadStream } from 'fs';
|
||||
import { basename, join } from 'path';
|
||||
import { globSync } from 'glob';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { expandHomePath } from './config.js';
|
||||
import { loadWatchState, saveWatchState, type TranscriptWatchState } from './state.js';
|
||||
import type { TranscriptWatchConfig, TranscriptSchema, WatchTarget } from './types.js';
|
||||
import { TranscriptEventProcessor } from './processor.js';
|
||||
|
||||
interface TailState {
|
||||
offset: number;
|
||||
partial: string;
|
||||
}
|
||||
|
||||
class FileTailer {
|
||||
private watcher: ReturnType<typeof fsWatch> | null = null;
|
||||
private tailState: TailState;
|
||||
|
||||
constructor(
|
||||
private filePath: string,
|
||||
initialOffset: number,
|
||||
private onLine: (line: string) => Promise<void>,
|
||||
private onOffset: (offset: number) => void
|
||||
) {
|
||||
this.tailState = { offset: initialOffset, partial: '' };
|
||||
}
|
||||
|
||||
start(): void {
|
||||
this.readNewData().catch(() => undefined);
|
||||
this.watcher = fsWatch(this.filePath, { persistent: true }, () => {
|
||||
this.readNewData().catch(() => undefined);
|
||||
});
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.watcher?.close();
|
||||
this.watcher = null;
|
||||
}
|
||||
|
||||
private async readNewData(): Promise<void> {
|
||||
if (!existsSync(this.filePath)) return;
|
||||
|
||||
let size = 0;
|
||||
try {
|
||||
size = statSync(this.filePath).size;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (size < this.tailState.offset) {
|
||||
this.tailState.offset = 0;
|
||||
}
|
||||
|
||||
if (size === this.tailState.offset) return;
|
||||
|
||||
const stream = createReadStream(this.filePath, {
|
||||
start: this.tailState.offset,
|
||||
end: size - 1,
|
||||
encoding: 'utf8'
|
||||
});
|
||||
|
||||
let data = '';
|
||||
for await (const chunk of stream) {
|
||||
data += chunk as string;
|
||||
}
|
||||
|
||||
this.tailState.offset = size;
|
||||
this.onOffset(this.tailState.offset);
|
||||
|
||||
const combined = this.tailState.partial + data;
|
||||
const lines = combined.split('\n');
|
||||
this.tailState.partial = lines.pop() ?? '';
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
await this.onLine(trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class TranscriptWatcher {
|
||||
private processor = new TranscriptEventProcessor();
|
||||
private tailers = new Map<string, FileTailer>();
|
||||
private state: TranscriptWatchState;
|
||||
private rescanTimers: Array<NodeJS.Timeout> = [];
|
||||
|
||||
constructor(private config: TranscriptWatchConfig, private statePath: string) {
|
||||
this.state = loadWatchState(statePath);
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
for (const watch of this.config.watches) {
|
||||
await this.setupWatch(watch);
|
||||
}
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
for (const tailer of this.tailers.values()) {
|
||||
tailer.close();
|
||||
}
|
||||
this.tailers.clear();
|
||||
for (const timer of this.rescanTimers) {
|
||||
clearInterval(timer);
|
||||
}
|
||||
this.rescanTimers = [];
|
||||
}
|
||||
|
||||
private async setupWatch(watch: WatchTarget): Promise<void> {
|
||||
const schema = this.resolveSchema(watch);
|
||||
if (!schema) {
|
||||
logger.warn('TRANSCRIPT', 'Missing schema for watch', { watch: watch.name });
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvedPath = expandHomePath(watch.path);
|
||||
const files = this.resolveWatchFiles(resolvedPath);
|
||||
|
||||
for (const filePath of files) {
|
||||
await this.addTailer(filePath, watch, schema);
|
||||
}
|
||||
|
||||
const rescanIntervalMs = watch.rescanIntervalMs ?? 5000;
|
||||
const timer = setInterval(async () => {
|
||||
const newFiles = this.resolveWatchFiles(resolvedPath);
|
||||
for (const filePath of newFiles) {
|
||||
if (!this.tailers.has(filePath)) {
|
||||
await this.addTailer(filePath, watch, schema);
|
||||
}
|
||||
}
|
||||
}, rescanIntervalMs);
|
||||
this.rescanTimers.push(timer);
|
||||
}
|
||||
|
||||
private resolveSchema(watch: WatchTarget): TranscriptSchema | null {
|
||||
if (typeof watch.schema === 'string') {
|
||||
return this.config.schemas?.[watch.schema] ?? null;
|
||||
}
|
||||
return watch.schema;
|
||||
}
|
||||
|
||||
private resolveWatchFiles(inputPath: string): string[] {
|
||||
if (this.hasGlob(inputPath)) {
|
||||
return globSync(inputPath, { nodir: true, absolute: true });
|
||||
}
|
||||
|
||||
if (existsSync(inputPath)) {
|
||||
try {
|
||||
const stat = statSync(inputPath);
|
||||
if (stat.isDirectory()) {
|
||||
const pattern = join(inputPath, '**', '*.jsonl');
|
||||
return globSync(pattern, { nodir: true, absolute: true });
|
||||
}
|
||||
return [inputPath];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private hasGlob(inputPath: string): boolean {
|
||||
return /[*?[\]{}()]/.test(inputPath);
|
||||
}
|
||||
|
||||
private async addTailer(filePath: string, watch: WatchTarget, schema: TranscriptSchema): Promise<void> {
|
||||
if (this.tailers.has(filePath)) return;
|
||||
|
||||
const sessionIdOverride = this.extractSessionIdFromPath(filePath);
|
||||
|
||||
let offset = this.state.offsets[filePath] ?? 0;
|
||||
if (offset === 0 && watch.startAtEnd) {
|
||||
try {
|
||||
offset = statSync(filePath).size;
|
||||
} catch {
|
||||
offset = 0;
|
||||
}
|
||||
}
|
||||
|
||||
const tailer = new FileTailer(
|
||||
filePath,
|
||||
offset,
|
||||
async (line: string) => {
|
||||
await this.handleLine(line, watch, schema, filePath, sessionIdOverride);
|
||||
},
|
||||
(newOffset: number) => {
|
||||
this.state.offsets[filePath] = newOffset;
|
||||
saveWatchState(this.statePath, this.state);
|
||||
}
|
||||
);
|
||||
|
||||
tailer.start();
|
||||
this.tailers.set(filePath, tailer);
|
||||
logger.info('TRANSCRIPT', 'Watching transcript file', {
|
||||
file: filePath,
|
||||
watch: watch.name,
|
||||
schema: schema.name
|
||||
});
|
||||
}
|
||||
|
||||
private async handleLine(
|
||||
line: string,
|
||||
watch: WatchTarget,
|
||||
schema: TranscriptSchema,
|
||||
filePath: string,
|
||||
sessionIdOverride?: string | null
|
||||
): Promise<void> {
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
await this.processor.processEntry(entry, watch, schema, sessionIdOverride ?? undefined);
|
||||
} catch (error) {
|
||||
logger.debug('TRANSCRIPT', 'Failed to parse transcript line', {
|
||||
watch: watch.name,
|
||||
file: basename(filePath)
|
||||
}, error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
private extractSessionIdFromPath(filePath: string): string | null {
|
||||
const match = filePath.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i);
|
||||
return match ? match[0] : null;
|
||||
}
|
||||
}
|
||||
+519
-81
@@ -10,12 +10,54 @@
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import { existsSync, writeFileSync, unlinkSync, statSync } from 'fs';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||
import { getWorkerPort, getWorkerHost } from '../shared/worker-utils.js';
|
||||
import { HOOK_TIMEOUTS } from '../shared/hook-constants.js';
|
||||
import { SettingsDefaultsManager } from '../shared/SettingsDefaultsManager.js';
|
||||
import { getAuthMethodDescription } from '../shared/EnvManager.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { ChromaServerManager } from './sync/ChromaServerManager.js';
|
||||
|
||||
// Windows: avoid repeated spawn popups when startup fails (issue #921)
|
||||
const WINDOWS_SPAWN_COOLDOWN_MS = 2 * 60 * 1000;
|
||||
|
||||
function getWorkerSpawnLockPath(): string {
|
||||
return path.join(SettingsDefaultsManager.get('CLAUDE_MEM_DATA_DIR'), '.worker-start-attempted');
|
||||
}
|
||||
|
||||
function shouldSkipSpawnOnWindows(): boolean {
|
||||
if (process.platform !== 'win32') return false;
|
||||
const lockPath = getWorkerSpawnLockPath();
|
||||
if (!existsSync(lockPath)) return false;
|
||||
try {
|
||||
const modifiedTimeMs = statSync(lockPath).mtimeMs;
|
||||
return Date.now() - modifiedTimeMs < WINDOWS_SPAWN_COOLDOWN_MS;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function markWorkerSpawnAttempted(): void {
|
||||
if (process.platform !== 'win32') return;
|
||||
try {
|
||||
writeFileSync(getWorkerSpawnLockPath(), '', 'utf-8');
|
||||
} catch {
|
||||
// Best-effort lock file — failure to write shouldn't block startup
|
||||
}
|
||||
}
|
||||
|
||||
function clearWorkerSpawnAttempted(): void {
|
||||
if (process.platform !== 'win32') return;
|
||||
try {
|
||||
const lockPath = getWorkerSpawnLockPath();
|
||||
if (existsSync(lockPath)) unlinkSync(lockPath);
|
||||
} catch {
|
||||
// Best-effort cleanup
|
||||
}
|
||||
}
|
||||
|
||||
// Version injected at build time by esbuild define
|
||||
declare const __DEFAULT_PACKAGE_VERSION__: string;
|
||||
const packageVersion = typeof __DEFAULT_PACKAGE_VERSION__ !== 'undefined' ? __DEFAULT_PACKAGE_VERSION__ : '0.0.0-dev';
|
||||
@@ -27,6 +69,7 @@ import {
|
||||
removePidFile,
|
||||
getPlatformTimeout,
|
||||
cleanupOrphanedProcesses,
|
||||
cleanStalePidFile,
|
||||
spawnDaemon,
|
||||
createSignalHandler
|
||||
} from './infrastructure/ProcessManager.js';
|
||||
@@ -53,8 +96,8 @@ import { DatabaseManager } from './worker/DatabaseManager.js';
|
||||
import { SessionManager } from './worker/SessionManager.js';
|
||||
import { SSEBroadcaster } from './worker/SSEBroadcaster.js';
|
||||
import { SDKAgent } from './worker/SDKAgent.js';
|
||||
import { GeminiAgent } from './worker/GeminiAgent.js';
|
||||
import { OpenRouterAgent } from './worker/OpenRouterAgent.js';
|
||||
import { GeminiAgent, isGeminiSelected, isGeminiAvailable } from './worker/GeminiAgent.js';
|
||||
import { OpenRouterAgent, isOpenRouterSelected, isOpenRouterAvailable } from './worker/OpenRouterAgent.js';
|
||||
import { PaginationHelper } from './worker/PaginationHelper.js';
|
||||
import { SettingsManager } from './worker/SettingsManager.js';
|
||||
import { SearchManager } from './worker/SearchManager.js';
|
||||
@@ -69,6 +112,7 @@ import { DataRoutes } from './worker/http/routes/DataRoutes.js';
|
||||
import { SearchRoutes } from './worker/http/routes/SearchRoutes.js';
|
||||
import { SettingsRoutes } from './worker/http/routes/SettingsRoutes.js';
|
||||
import { LogsRoutes } from './worker/http/routes/LogsRoutes.js';
|
||||
import { MemoryRoutes } from './worker/http/routes/MemoryRoutes.js';
|
||||
|
||||
// Process management for zombie cleanup (Issue #737)
|
||||
import { startOrphanReaper, reapOrphanedProcesses } from './worker/ProcessRegistry.js';
|
||||
@@ -131,6 +175,14 @@ export class WorkerService {
|
||||
// Orphan reaper cleanup function (Issue #737)
|
||||
private stopOrphanReaper: (() => void) | null = null;
|
||||
|
||||
// AI interaction tracking for health endpoint
|
||||
private lastAiInteraction: {
|
||||
timestamp: number;
|
||||
success: boolean;
|
||||
provider: string;
|
||||
error?: string;
|
||||
} | null = null;
|
||||
|
||||
constructor() {
|
||||
// Initialize the promise that will resolve when background initialization completes
|
||||
this.initializationComplete = new Promise((resolve) => {
|
||||
@@ -154,6 +206,7 @@ export class WorkerService {
|
||||
this.broadcastProcessingStatus();
|
||||
});
|
||||
|
||||
|
||||
// Initialize MCP client
|
||||
// Empty capabilities object: this client only calls tools, doesn't expose any
|
||||
this.mcpClient = new Client({
|
||||
@@ -166,7 +219,24 @@ export class WorkerService {
|
||||
getInitializationComplete: () => this.initializationCompleteFlag,
|
||||
getMcpReady: () => this.mcpReady,
|
||||
onShutdown: () => this.shutdown(),
|
||||
onRestart: () => this.shutdown()
|
||||
onRestart: () => this.shutdown(),
|
||||
workerPath: __filename,
|
||||
getAiStatus: () => {
|
||||
let provider = 'claude';
|
||||
if (isOpenRouterSelected() && isOpenRouterAvailable()) provider = 'openrouter';
|
||||
else if (isGeminiSelected() && isGeminiAvailable()) provider = 'gemini';
|
||||
return {
|
||||
provider,
|
||||
authMethod: getAuthMethodDescription(),
|
||||
lastInteraction: this.lastAiInteraction
|
||||
? {
|
||||
timestamp: this.lastAiInteraction.timestamp,
|
||||
success: this.lastAiInteraction.success,
|
||||
...(this.lastAiInteraction.error && { error: this.lastAiInteraction.error }),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// Register route handlers
|
||||
@@ -191,35 +261,74 @@ export class WorkerService {
|
||||
this.isShuttingDown = shutdownRef.value;
|
||||
handler('SIGINT');
|
||||
});
|
||||
|
||||
// SIGHUP: sent by kernel when controlling terminal closes.
|
||||
// Daemon mode: ignore it (survive parent shell exit).
|
||||
// Interactive mode: treat like SIGTERM (graceful shutdown).
|
||||
if (process.platform !== 'win32') {
|
||||
if (process.argv.includes('--daemon')) {
|
||||
process.on('SIGHUP', () => {
|
||||
logger.debug('SYSTEM', 'Ignoring SIGHUP in daemon mode');
|
||||
});
|
||||
} else {
|
||||
process.on('SIGHUP', () => {
|
||||
this.isShuttingDown = shutdownRef.value;
|
||||
handler('SIGHUP');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register all route handlers with the server
|
||||
*/
|
||||
private registerRoutes(): void {
|
||||
// Standard routes
|
||||
this.server.registerRoutes(new ViewerRoutes(this.sseBroadcaster, this.dbManager, this.sessionManager));
|
||||
this.server.registerRoutes(new SessionRoutes(this.sessionManager, this.dbManager, this.sdkAgent, this.geminiAgent, this.openRouterAgent, this.sessionEventBroadcaster, this));
|
||||
this.server.registerRoutes(new DataRoutes(this.paginationHelper, this.dbManager, this.sessionManager, this.sseBroadcaster, this, this.startTime));
|
||||
this.server.registerRoutes(new SettingsRoutes(this.settingsManager));
|
||||
this.server.registerRoutes(new LogsRoutes());
|
||||
// IMPORTANT: Middleware must be registered BEFORE routes (Express processes in order)
|
||||
|
||||
// Early handler for /api/context/inject to avoid 404 during startup
|
||||
// Early handler for /api/context/inject — fail open if not yet initialized
|
||||
this.server.app.get('/api/context/inject', async (req, res, next) => {
|
||||
const timeoutMs = 300000; // 5 minute timeout for slow systems
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Initialization timeout')), timeoutMs)
|
||||
);
|
||||
|
||||
await Promise.race([this.initializationComplete, timeoutPromise]);
|
||||
|
||||
if (!this.searchRoutes) {
|
||||
res.status(503).json({ error: 'Search routes not initialized' });
|
||||
if (!this.initializationCompleteFlag || !this.searchRoutes) {
|
||||
logger.warn('SYSTEM', 'Context requested before initialization complete, returning empty');
|
||||
res.status(200).json({ content: [{ type: 'text', text: '' }] });
|
||||
return;
|
||||
}
|
||||
|
||||
next(); // Delegate to SearchRoutes handler
|
||||
});
|
||||
|
||||
// Guard ALL /api/* routes during initialization — wait for DB with timeout
|
||||
// Exceptions: /api/health, /api/readiness, /api/version (handled by Server.ts core routes)
|
||||
// and /api/context/inject (handled above with fail-open)
|
||||
this.server.app.use('/api', async (req, res, next) => {
|
||||
if (this.initializationCompleteFlag) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutMs = 30000;
|
||||
const timeoutPromise = new Promise<void>((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Database initialization timeout')), timeoutMs)
|
||||
);
|
||||
|
||||
try {
|
||||
await Promise.race([this.initializationComplete, timeoutPromise]);
|
||||
next();
|
||||
} catch (error) {
|
||||
logger.error('HTTP', `Request to ${req.method} ${req.path} rejected — DB not initialized`, {}, error as Error);
|
||||
res.status(503).json({
|
||||
error: 'Service initializing',
|
||||
message: 'Database is still initializing, please retry'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Standard routes (registered AFTER guard middleware)
|
||||
this.server.registerRoutes(new ViewerRoutes(this.sseBroadcaster, this.dbManager, this.sessionManager));
|
||||
this.server.registerRoutes(new SessionRoutes(this.sessionManager, this.dbManager, this.sdkAgent, this.geminiAgent, this.openRouterAgent, this.sessionEventBroadcaster, this));
|
||||
this.server.registerRoutes(new DataRoutes(this.paginationHelper, this.dbManager, this.sessionManager, this.sseBroadcaster, this, this.startTime));
|
||||
this.server.registerRoutes(new SettingsRoutes(this.settingsManager));
|
||||
this.server.registerRoutes(new LogsRoutes());
|
||||
this.server.registerRoutes(new MemoryRoutes(this.dbManager, 'claude-mem'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -293,13 +402,12 @@ export class WorkerService {
|
||||
|
||||
await this.dbManager.initialize();
|
||||
|
||||
// Recover stuck messages from previous crashes
|
||||
// Reset any messages that were processing when worker died
|
||||
const { PendingMessageStore } = await import('./sqlite/PendingMessageStore.js');
|
||||
const pendingStore = new PendingMessageStore(this.dbManager.getSessionStore().db, 3);
|
||||
const STUCK_THRESHOLD_MS = 5 * 60 * 1000;
|
||||
const resetCount = pendingStore.resetStuckMessages(STUCK_THRESHOLD_MS);
|
||||
const resetCount = pendingStore.resetStaleProcessingMessages(0); // 0 = reset ALL processing
|
||||
if (resetCount > 0) {
|
||||
logger.info('SYSTEM', `Recovered ${resetCount} stuck messages from previous session`, { thresholdMinutes: 5 });
|
||||
logger.info('SYSTEM', `Reset ${resetCount} stale processing messages to pending`);
|
||||
}
|
||||
|
||||
// Initialize search services
|
||||
@@ -366,8 +474,24 @@ export class WorkerService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate agent based on provider settings.
|
||||
* Same logic as SessionRoutes.getActiveAgent() for consistency.
|
||||
*/
|
||||
private getActiveAgent(): SDKAgent | GeminiAgent | OpenRouterAgent {
|
||||
if (isOpenRouterSelected() && isOpenRouterAvailable()) {
|
||||
return this.openRouterAgent;
|
||||
}
|
||||
if (isGeminiSelected() && isGeminiAvailable()) {
|
||||
return this.geminiAgent;
|
||||
}
|
||||
return this.sdkAgent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a session processor
|
||||
* On SDK resume failure (terminated session), falls back to Gemini/OpenRouter if available,
|
||||
* otherwise marks messages abandoned and removes session so queue does not grow unbounded.
|
||||
*/
|
||||
private startSessionProcessor(
|
||||
session: ReturnType<typeof this.sessionManager.getSession>,
|
||||
@@ -376,21 +500,203 @@ export class WorkerService {
|
||||
if (!session) return;
|
||||
|
||||
const sid = session.sessionDbId;
|
||||
logger.info('SYSTEM', `Starting generator (${source})`, { sessionId: sid });
|
||||
const agent = this.getActiveAgent();
|
||||
const providerName = agent.constructor.name;
|
||||
|
||||
session.generatorPromise = this.sdkAgent.startSession(session, this)
|
||||
.catch(error => {
|
||||
// Before starting generator, check if AbortController is already aborted
|
||||
// This can happen after a previous generator was aborted but the session still has pending work
|
||||
if (session.abortController.signal.aborted) {
|
||||
logger.debug('SYSTEM', 'Replacing aborted AbortController before starting generator', {
|
||||
sessionId: session.sessionDbId
|
||||
});
|
||||
session.abortController = new AbortController();
|
||||
}
|
||||
|
||||
// Track whether generator failed with an unrecoverable error to prevent infinite restart loops
|
||||
let hadUnrecoverableError = false;
|
||||
let sessionFailed = false;
|
||||
|
||||
logger.info('SYSTEM', `Starting generator (${source}) using ${providerName}`, { sessionId: sid });
|
||||
|
||||
session.generatorPromise = agent.startSession(session, this)
|
||||
.catch(async (error: unknown) => {
|
||||
const errorMessage = (error as Error)?.message || '';
|
||||
|
||||
// Detect unrecoverable errors that should NOT trigger restart
|
||||
// These errors will fail immediately on retry, causing infinite loops
|
||||
const unrecoverablePatterns = [
|
||||
'Claude executable not found',
|
||||
'CLAUDE_CODE_PATH',
|
||||
'ENOENT',
|
||||
'spawn',
|
||||
'Invalid API key',
|
||||
];
|
||||
if (unrecoverablePatterns.some(pattern => errorMessage.includes(pattern))) {
|
||||
hadUnrecoverableError = true;
|
||||
this.lastAiInteraction = {
|
||||
timestamp: Date.now(),
|
||||
success: false,
|
||||
provider: providerName,
|
||||
error: errorMessage,
|
||||
};
|
||||
logger.error('SDK', 'Unrecoverable generator error - will NOT restart', {
|
||||
sessionId: session.sessionDbId,
|
||||
project: session.project,
|
||||
errorMessage
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback for terminated SDK sessions (provider abstraction)
|
||||
if (this.isSessionTerminatedError(error)) {
|
||||
logger.warn('SDK', 'SDK resume failed, falling back to standalone processing', {
|
||||
sessionId: session.sessionDbId,
|
||||
project: session.project,
|
||||
reason: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
return this.runFallbackForTerminatedSession(session, error);
|
||||
}
|
||||
|
||||
// Detect stale resume failures - SDK session context was lost
|
||||
if ((errorMessage.includes('aborted by user') || errorMessage.includes('No conversation found'))
|
||||
&& session.memorySessionId) {
|
||||
logger.warn('SDK', 'Detected stale resume failure, clearing memorySessionId for fresh start', {
|
||||
sessionId: session.sessionDbId,
|
||||
memorySessionId: session.memorySessionId,
|
||||
errorMessage
|
||||
});
|
||||
// Clear stale memorySessionId and force fresh init on next attempt
|
||||
this.dbManager.getSessionStore().updateMemorySessionId(session.sessionDbId, null);
|
||||
session.memorySessionId = null;
|
||||
session.forceInit = true;
|
||||
}
|
||||
logger.error('SDK', 'Session generator failed', {
|
||||
sessionId: session.sessionDbId,
|
||||
project: session.project
|
||||
project: session.project,
|
||||
provider: providerName
|
||||
}, error as Error);
|
||||
sessionFailed = true;
|
||||
this.lastAiInteraction = {
|
||||
timestamp: Date.now(),
|
||||
success: false,
|
||||
provider: providerName,
|
||||
error: errorMessage,
|
||||
};
|
||||
throw error;
|
||||
})
|
||||
.finally(() => {
|
||||
session.generatorPromise = null;
|
||||
|
||||
// Record successful AI interaction if no error occurred
|
||||
if (!sessionFailed && !hadUnrecoverableError) {
|
||||
this.lastAiInteraction = {
|
||||
timestamp: Date.now(),
|
||||
success: true,
|
||||
provider: providerName,
|
||||
};
|
||||
}
|
||||
|
||||
// Do NOT restart after unrecoverable errors - prevents infinite loops
|
||||
if (hadUnrecoverableError) {
|
||||
logger.warn('SYSTEM', 'Skipping restart due to unrecoverable error', {
|
||||
sessionId: session.sessionDbId
|
||||
});
|
||||
this.broadcastProcessingStatus();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if there's pending work that needs processing with a fresh AbortController
|
||||
const { PendingMessageStore } = require('./sqlite/PendingMessageStore.js');
|
||||
const pendingStore = new PendingMessageStore(this.dbManager.getSessionStore().db, 3);
|
||||
const pendingCount = pendingStore.getPendingCount(session.sessionDbId);
|
||||
|
||||
if (pendingCount > 0) {
|
||||
logger.info('SYSTEM', 'Pending work remains after generator exit, restarting with fresh AbortController', {
|
||||
sessionId: session.sessionDbId,
|
||||
pendingCount
|
||||
});
|
||||
// Reset AbortController for restart
|
||||
session.abortController = new AbortController();
|
||||
// Restart processor
|
||||
this.startSessionProcessor(session, 'pending-work-restart');
|
||||
}
|
||||
|
||||
this.broadcastProcessingStatus();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Match errors that indicate the Claude Code process/session is gone (resume impossible).
|
||||
* Used to trigger graceful fallback instead of leaving pending messages stuck forever.
|
||||
*/
|
||||
private isSessionTerminatedError(error: unknown): boolean {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
const normalized = msg.toLowerCase();
|
||||
return (
|
||||
normalized.includes('process aborted by user') ||
|
||||
normalized.includes('processtransport') ||
|
||||
normalized.includes('not ready for writing') ||
|
||||
normalized.includes('session generator failed') ||
|
||||
normalized.includes('claude code process')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* When SDK resume fails due to terminated session: try Gemini then OpenRouter to drain
|
||||
* pending messages; if no fallback available, mark messages abandoned and remove session.
|
||||
*/
|
||||
private async runFallbackForTerminatedSession(
|
||||
session: ReturnType<typeof this.sessionManager.getSession>,
|
||||
_originalError: unknown
|
||||
): Promise<void> {
|
||||
if (!session) return;
|
||||
|
||||
const sessionDbId = session.sessionDbId;
|
||||
|
||||
// Fallback agents need memorySessionId for storeObservations
|
||||
if (!session.memorySessionId) {
|
||||
const syntheticId = `fallback-${sessionDbId}-${Date.now()}`;
|
||||
session.memorySessionId = syntheticId;
|
||||
this.dbManager.getSessionStore().updateMemorySessionId(sessionDbId, syntheticId);
|
||||
}
|
||||
|
||||
if (isGeminiAvailable()) {
|
||||
try {
|
||||
await this.geminiAgent.startSession(session, this);
|
||||
return;
|
||||
} catch (e) {
|
||||
logger.warn('SDK', 'Fallback Gemini failed, trying OpenRouter', {
|
||||
sessionId: sessionDbId,
|
||||
error: e instanceof Error ? e.message : String(e)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (isOpenRouterAvailable()) {
|
||||
try {
|
||||
await this.openRouterAgent.startSession(session, this);
|
||||
return;
|
||||
} catch (e) {
|
||||
logger.warn('SDK', 'Fallback OpenRouter failed', {
|
||||
sessionId: sessionDbId,
|
||||
error: e instanceof Error ? e.message : String(e)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// No fallback or both failed: mark messages abandoned and remove session so queue doesn't grow
|
||||
const pendingStore = this.sessionManager.getPendingMessageStore();
|
||||
const abandoned = pendingStore.markAllSessionMessagesAbandoned(sessionDbId);
|
||||
if (abandoned > 0) {
|
||||
logger.warn('SDK', 'No fallback available; marked pending messages abandoned', {
|
||||
sessionId: sessionDbId,
|
||||
abandoned
|
||||
});
|
||||
}
|
||||
this.sessionManager.removeSessionImmediate(sessionDbId);
|
||||
this.sessionEventBroadcaster.broadcastSessionCompleted(sessionDbId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process pending session queues
|
||||
*/
|
||||
@@ -402,6 +708,46 @@ export class WorkerService {
|
||||
}> {
|
||||
const { PendingMessageStore } = await import('./sqlite/PendingMessageStore.js');
|
||||
const pendingStore = new PendingMessageStore(this.dbManager.getSessionStore().db, 3);
|
||||
const sessionStore = this.dbManager.getSessionStore();
|
||||
|
||||
// Clean up stale 'active' sessions before processing
|
||||
// Sessions older than 6 hours without activity are likely orphaned
|
||||
const STALE_SESSION_THRESHOLD_MS = 6 * 60 * 60 * 1000;
|
||||
const staleThreshold = Date.now() - STALE_SESSION_THRESHOLD_MS;
|
||||
|
||||
try {
|
||||
const staleSessionIds = sessionStore.db.prepare(`
|
||||
SELECT id FROM sdk_sessions
|
||||
WHERE status = 'active' AND started_at_epoch < ?
|
||||
`).all(staleThreshold) as { id: number }[];
|
||||
|
||||
if (staleSessionIds.length > 0) {
|
||||
const ids = staleSessionIds.map(r => r.id);
|
||||
const placeholders = ids.map(() => '?').join(',');
|
||||
|
||||
sessionStore.db.prepare(`
|
||||
UPDATE sdk_sessions
|
||||
SET status = 'failed', completed_at_epoch = ?
|
||||
WHERE id IN (${placeholders})
|
||||
`).run(Date.now(), ...ids);
|
||||
|
||||
logger.info('SYSTEM', `Marked ${ids.length} stale sessions as failed`);
|
||||
|
||||
const msgResult = sessionStore.db.prepare(`
|
||||
UPDATE pending_messages
|
||||
SET status = 'failed', failed_at_epoch = ?
|
||||
WHERE status = 'pending'
|
||||
AND session_db_id IN (${placeholders})
|
||||
`).run(Date.now(), ...ids);
|
||||
|
||||
if (msgResult.changes > 0) {
|
||||
logger.info('SYSTEM', `Marked ${msgResult.changes} pending messages from stale sessions as failed`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('SYSTEM', 'Failed to clean up stale sessions', {}, error as Error);
|
||||
}
|
||||
|
||||
const orphanedSessionIds = pendingStore.getSessionsWithPendingMessages();
|
||||
|
||||
const result = {
|
||||
@@ -486,6 +832,86 @@ export class WorkerService {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Reusable Worker Startup Logic
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Ensures the worker is started and healthy.
|
||||
* This function can be called by both 'start' and 'hook' commands.
|
||||
*
|
||||
* @param port - The port the worker should run on
|
||||
* @returns true if worker is healthy (existing or newly started), false on failure
|
||||
*/
|
||||
async function ensureWorkerStarted(port: number): Promise<boolean> {
|
||||
// Clean stale PID file first (cheap: 1 fs read + 1 signal-0 check)
|
||||
cleanStalePidFile();
|
||||
|
||||
// Check if worker is already running and healthy
|
||||
if (await waitForHealth(port, 1000)) {
|
||||
const versionCheck = await checkVersionMatch(port);
|
||||
if (!versionCheck.matches) {
|
||||
logger.info('SYSTEM', 'Worker version mismatch detected - auto-restarting', {
|
||||
pluginVersion: versionCheck.pluginVersion,
|
||||
workerVersion: versionCheck.workerVersion
|
||||
});
|
||||
|
||||
await httpShutdown(port);
|
||||
const freed = await waitForPortFree(port, getPlatformTimeout(HOOK_TIMEOUTS.PORT_IN_USE_WAIT));
|
||||
if (!freed) {
|
||||
logger.error('SYSTEM', 'Port did not free up after shutdown for version mismatch restart', { port });
|
||||
return false;
|
||||
}
|
||||
removePidFile();
|
||||
} else {
|
||||
logger.info('SYSTEM', 'Worker already running and healthy');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if port is in use by something else
|
||||
const portInUse = await isPortInUse(port);
|
||||
if (portInUse) {
|
||||
logger.info('SYSTEM', 'Port in use, waiting for worker to become healthy');
|
||||
const healthy = await waitForHealth(port, getPlatformTimeout(HOOK_TIMEOUTS.PORT_IN_USE_WAIT));
|
||||
if (healthy) {
|
||||
logger.info('SYSTEM', 'Worker is now healthy');
|
||||
return true;
|
||||
}
|
||||
logger.error('SYSTEM', 'Port in use but worker not responding to health checks');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Windows: skip spawn if a recent attempt already failed (prevents repeated bun.exe popups, issue #921)
|
||||
if (shouldSkipSpawnOnWindows()) {
|
||||
logger.warn('SYSTEM', 'Worker unavailable on Windows — skipping spawn (recent attempt failed within cooldown)');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Spawn new worker daemon
|
||||
logger.info('SYSTEM', 'Starting worker daemon');
|
||||
markWorkerSpawnAttempted();
|
||||
const pid = spawnDaemon(__filename, port);
|
||||
if (pid === undefined) {
|
||||
logger.error('SYSTEM', 'Failed to spawn worker daemon');
|
||||
return false;
|
||||
}
|
||||
|
||||
// PID file is written by the worker itself after listen() succeeds
|
||||
// This is race-free and works correctly on Windows where cmd.exe PID is useless
|
||||
|
||||
const healthy = await waitForHealth(port, getPlatformTimeout(HOOK_TIMEOUTS.POST_SPAWN_WAIT));
|
||||
if (!healthy) {
|
||||
removePidFile();
|
||||
logger.error('SYSTEM', 'Worker failed to start (health check timeout)');
|
||||
return false;
|
||||
}
|
||||
|
||||
clearWorkerSpawnAttempted();
|
||||
logger.info('SYSTEM', 'Worker started successfully');
|
||||
return true;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CLI Entry Point
|
||||
// ============================================================================
|
||||
@@ -504,58 +930,12 @@ async function main() {
|
||||
|
||||
switch (command) {
|
||||
case 'start': {
|
||||
if (await waitForHealth(port, 1000)) {
|
||||
const versionCheck = await checkVersionMatch(port);
|
||||
if (!versionCheck.matches) {
|
||||
logger.info('SYSTEM', 'Worker version mismatch detected - auto-restarting', {
|
||||
pluginVersion: versionCheck.pluginVersion,
|
||||
workerVersion: versionCheck.workerVersion
|
||||
});
|
||||
|
||||
await httpShutdown(port);
|
||||
const freed = await waitForPortFree(port, getPlatformTimeout(15000));
|
||||
if (!freed) {
|
||||
logger.error('SYSTEM', 'Port did not free up after shutdown for version mismatch restart', { port });
|
||||
exitWithStatus('error', 'Port did not free after version mismatch restart');
|
||||
}
|
||||
removePidFile();
|
||||
} else {
|
||||
logger.info('SYSTEM', 'Worker already running and healthy');
|
||||
exitWithStatus('ready');
|
||||
}
|
||||
const success = await ensureWorkerStarted(port);
|
||||
if (success) {
|
||||
exitWithStatus('ready');
|
||||
} else {
|
||||
exitWithStatus('error', 'Failed to start worker');
|
||||
}
|
||||
|
||||
const portInUse = await isPortInUse(port);
|
||||
if (portInUse) {
|
||||
logger.info('SYSTEM', 'Port in use, waiting for worker to become healthy');
|
||||
const healthy = await waitForHealth(port, getPlatformTimeout(15000));
|
||||
if (healthy) {
|
||||
logger.info('SYSTEM', 'Worker is now healthy');
|
||||
exitWithStatus('ready');
|
||||
}
|
||||
logger.error('SYSTEM', 'Port in use but worker not responding to health checks');
|
||||
exitWithStatus('error', 'Port in use but worker not responding');
|
||||
}
|
||||
|
||||
logger.info('SYSTEM', 'Starting worker daemon');
|
||||
const pid = spawnDaemon(__filename, port);
|
||||
if (pid === undefined) {
|
||||
logger.error('SYSTEM', 'Failed to spawn worker daemon');
|
||||
exitWithStatus('error', 'Failed to spawn worker daemon');
|
||||
}
|
||||
|
||||
// PID file is written by the worker itself after listen() succeeds
|
||||
// This is race-free and works correctly on Windows where cmd.exe PID is useless
|
||||
|
||||
const healthy = await waitForHealth(port, getPlatformTimeout(30000));
|
||||
if (!healthy) {
|
||||
removePidFile();
|
||||
logger.error('SYSTEM', 'Worker failed to start (health check timeout)');
|
||||
exitWithStatus('error', 'Worker failed to start (health check timeout)');
|
||||
}
|
||||
|
||||
logger.info('SYSTEM', 'Worker started successfully');
|
||||
exitWithStatus('ready');
|
||||
}
|
||||
|
||||
case 'stop': {
|
||||
@@ -592,7 +972,7 @@ async function main() {
|
||||
// PID file is written by the worker itself after listen() succeeds
|
||||
// This is race-free and works correctly on Windows where cmd.exe PID is useless
|
||||
|
||||
const healthy = await waitForHealth(port, getPlatformTimeout(30000));
|
||||
const healthy = await waitForHealth(port, getPlatformTimeout(HOOK_TIMEOUTS.POST_SPAWN_WAIT));
|
||||
if (!healthy) {
|
||||
removePidFile();
|
||||
logger.error('SYSTEM', 'Worker failed to restart');
|
||||
@@ -626,21 +1006,79 @@ async function main() {
|
||||
}
|
||||
|
||||
case 'hook': {
|
||||
// Auto-start worker if not running
|
||||
const workerReady = await ensureWorkerStarted(port);
|
||||
if (!workerReady) {
|
||||
logger.warn('SYSTEM', 'Worker failed to start before hook, handler will retry');
|
||||
}
|
||||
|
||||
// Existing logic unchanged
|
||||
const platform = process.argv[3];
|
||||
const event = process.argv[4];
|
||||
if (!platform || !event) {
|
||||
console.error('Usage: claude-mem hook <platform> <event>');
|
||||
console.error('Platforms: claude-code, cursor, raw');
|
||||
console.error('Events: context, session-init, observation, summarize, user-message');
|
||||
console.error('Events: context, session-init, observation, summarize, session-complete');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Check if worker is already running on port
|
||||
const portInUse = await isPortInUse(port);
|
||||
let startedWorkerInProcess = false;
|
||||
|
||||
if (!portInUse) {
|
||||
// Port free - start worker IN THIS PROCESS (no spawn!)
|
||||
// This process becomes the worker and stays alive
|
||||
try {
|
||||
logger.info('SYSTEM', 'Starting worker in-process for hook', { event });
|
||||
const worker = new WorkerService();
|
||||
await worker.start();
|
||||
startedWorkerInProcess = true;
|
||||
// Worker is now running in this process on the port
|
||||
} catch (error) {
|
||||
logger.failure('SYSTEM', 'Worker failed to start in hook', {}, error as Error);
|
||||
removePidFile();
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
// If port in use, we'll use HTTP to the existing worker
|
||||
|
||||
const { hookCommand } = await import('../cli/hook-command.js');
|
||||
await hookCommand(platform, event);
|
||||
// If we started the worker in this process, skip process.exit() so we stay alive as the worker
|
||||
await hookCommand(platform, event, { skipExit: startedWorkerInProcess });
|
||||
// Note: if we started worker in-process, this process stays alive as the worker
|
||||
// The break allows the event loop to continue serving requests
|
||||
break;
|
||||
}
|
||||
|
||||
case 'generate': {
|
||||
const dryRun = process.argv.includes('--dry-run');
|
||||
const { generateClaudeMd } = await import('../cli/claude-md-commands.js');
|
||||
const result = await generateClaudeMd(dryRun);
|
||||
process.exit(result);
|
||||
}
|
||||
|
||||
case 'clean': {
|
||||
const dryRun = process.argv.includes('--dry-run');
|
||||
const { cleanClaudeMd } = await import('../cli/claude-md-commands.js');
|
||||
const result = await cleanClaudeMd(dryRun);
|
||||
process.exit(result);
|
||||
}
|
||||
|
||||
case '--daemon':
|
||||
default: {
|
||||
// Prevent daemon from dying silently on unhandled errors.
|
||||
// The HTTP server can continue serving even if a background task throws.
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
logger.error('SYSTEM', 'Unhandled rejection in daemon', {
|
||||
reason: reason instanceof Error ? reason.message : String(reason)
|
||||
});
|
||||
});
|
||||
process.on('uncaughtException', (error) => {
|
||||
logger.error('SYSTEM', 'Uncaught exception in daemon', {}, error as Error);
|
||||
// Don't exit — keep the HTTP server running
|
||||
});
|
||||
|
||||
const worker = new WorkerService();
|
||||
worker.start().catch((error) => {
|
||||
logger.failure('SYSTEM', 'Worker failed to start', {}, error as Error);
|
||||
|
||||
@@ -33,6 +33,11 @@ export interface ActiveSession {
|
||||
earliestPendingTimestamp: number | null; // Original timestamp of earliest pending message (for accurate observation timestamps)
|
||||
conversationHistory: ConversationMessage[]; // Shared conversation history for provider switching
|
||||
currentProvider: 'claude' | 'gemini' | 'openrouter' | null; // Track which provider is currently running
|
||||
consecutiveRestarts: number; // Track consecutive restart attempts to prevent infinite loops
|
||||
forceInit?: boolean; // Force fresh SDK session (skip resume)
|
||||
// CLAIM-CONFIRM FIX: Track IDs of messages currently being processed
|
||||
// These IDs will be confirmed (deleted) after successful storage
|
||||
processingMessageIds: number[];
|
||||
}
|
||||
|
||||
export interface PendingMessage {
|
||||
|
||||
@@ -7,11 +7,12 @@
|
||||
|
||||
import { execSync, spawnSync } from 'child_process';
|
||||
import { existsSync, unlinkSync } from 'fs';
|
||||
import { homedir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { MARKETPLACE_ROOT } from '../../shared/paths.js';
|
||||
|
||||
const INSTALLED_PLUGIN_PATH = join(homedir(), '.claude', 'plugins', 'marketplaces', 'thedotmack');
|
||||
// Alias for code clarity - this is the installed plugin path
|
||||
const INSTALLED_PLUGIN_PATH = MARKETPLACE_ROOT;
|
||||
|
||||
/**
|
||||
* Validate branch name to prevent command injection
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Dec 10, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|
||||
@@ -17,6 +17,7 @@ import { SessionManager } from './SessionManager.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { buildInitPrompt, buildObservationPrompt, buildSummaryPrompt, buildContinuationPrompt } from '../../sdk/prompts.js';
|
||||
import { SettingsDefaultsManager } from '../../shared/SettingsDefaultsManager.js';
|
||||
import { getCredential } from '../../shared/EnvManager.js';
|
||||
import type { ActiveSession, ConversationMessage } from '../worker-types.js';
|
||||
import { ModeManager } from '../domain/ModeManager.js';
|
||||
import {
|
||||
@@ -37,7 +38,7 @@ export type GeminiModel =
|
||||
| 'gemini-2.5-pro'
|
||||
| 'gemini-2.0-flash'
|
||||
| 'gemini-2.0-flash-lite'
|
||||
| 'gemini-3-flash';
|
||||
| 'gemini-3-flash-preview';
|
||||
|
||||
// Free tier RPM limits by model (requests per minute)
|
||||
const GEMINI_RPM_LIMITS: Record<GeminiModel, number> = {
|
||||
@@ -46,7 +47,7 @@ const GEMINI_RPM_LIMITS: Record<GeminiModel, number> = {
|
||||
'gemini-2.5-pro': 5,
|
||||
'gemini-2.0-flash': 15,
|
||||
'gemini-2.0-flash-lite': 30,
|
||||
'gemini-3-flash': 5,
|
||||
'gemini-3-flash-preview': 5,
|
||||
};
|
||||
|
||||
// Track last request time for rate limiting
|
||||
@@ -133,6 +134,14 @@ export class GeminiAgent {
|
||||
throw new Error('Gemini API key not configured. Set CLAUDE_MEM_GEMINI_API_KEY in settings or GEMINI_API_KEY environment variable.');
|
||||
}
|
||||
|
||||
// Generate synthetic memorySessionId (Gemini is stateless, doesn't return session IDs)
|
||||
if (!session.memorySessionId) {
|
||||
const syntheticMemorySessionId = `gemini-${session.contentSessionId}-${Date.now()}`;
|
||||
session.memorySessionId = syntheticMemorySessionId;
|
||||
this.dbManager.getSessionStore().updateMemorySessionId(session.sessionDbId, syntheticMemorySessionId);
|
||||
logger.info('SESSION', `MEMORY_ID_GENERATED | sessionDbId=${session.sessionDbId} | provider=Gemini`);
|
||||
}
|
||||
|
||||
// Load active mode
|
||||
const mode = ModeManager.getInstance().getActiveMode();
|
||||
|
||||
@@ -177,6 +186,10 @@ export class GeminiAgent {
|
||||
let lastCwd: string | undefined;
|
||||
|
||||
for await (const message of this.sessionManager.getMessageIterator(session.sessionDbId)) {
|
||||
// CLAIM-CONFIRM: Track message ID for confirmProcessed() after successful storage
|
||||
// The message is now in 'processing' status in DB until ResponseProcessor calls confirmProcessed()
|
||||
session.processingMessageIds.push(message._persistentId);
|
||||
|
||||
// Capture cwd from each message for worktree support
|
||||
if (message.cwd) {
|
||||
lastCwd = message.cwd;
|
||||
@@ -191,6 +204,12 @@ export class GeminiAgent {
|
||||
session.lastPromptNumber = message.prompt_number;
|
||||
}
|
||||
|
||||
// CRITICAL: Check memorySessionId BEFORE making expensive LLM call
|
||||
// This prevents wasting tokens when we won't be able to store the result anyway
|
||||
if (!session.memorySessionId) {
|
||||
throw new Error('Cannot process observations: memorySessionId not yet captured. This session may need to be reinitialized.');
|
||||
}
|
||||
|
||||
// Build observation prompt
|
||||
const obsPrompt = buildObservationPrompt({
|
||||
id: 0,
|
||||
@@ -229,6 +248,11 @@ export class GeminiAgent {
|
||||
);
|
||||
|
||||
} else if (message.type === 'summarize') {
|
||||
// CRITICAL: Check memorySessionId BEFORE making expensive LLM call
|
||||
if (!session.memorySessionId) {
|
||||
throw new Error('Cannot process summary: memorySessionId not yet captured. This session may need to be reinitialized.');
|
||||
}
|
||||
|
||||
// Build summary prompt
|
||||
const summaryPrompt = buildSummaryPrompt({
|
||||
id: session.sessionDbId,
|
||||
@@ -367,13 +391,15 @@ export class GeminiAgent {
|
||||
|
||||
/**
|
||||
* Get Gemini configuration from settings or environment
|
||||
* Issue #733: Uses centralized ~/.claude-mem/.env for credentials, not random project .env files
|
||||
*/
|
||||
private getGeminiConfig(): { apiKey: string; model: GeminiModel; rateLimitingEnabled: boolean } {
|
||||
const settingsPath = path.join(homedir(), '.claude-mem', 'settings.json');
|
||||
const settings = SettingsDefaultsManager.loadFromFile(settingsPath);
|
||||
|
||||
// API key: check settings first, then environment variable
|
||||
const apiKey = settings.CLAUDE_MEM_GEMINI_API_KEY || process.env.GEMINI_API_KEY || '';
|
||||
// API key: check settings first, then centralized claude-mem .env (NOT process.env)
|
||||
// This prevents Issue #733 where random project .env files could interfere
|
||||
const apiKey = settings.CLAUDE_MEM_GEMINI_API_KEY || getCredential('GEMINI_API_KEY') || '';
|
||||
|
||||
// Model: from settings or default, with validation
|
||||
const defaultModel: GeminiModel = 'gemini-2.5-flash';
|
||||
@@ -384,7 +410,7 @@ export class GeminiAgent {
|
||||
'gemini-2.5-pro',
|
||||
'gemini-2.0-flash',
|
||||
'gemini-2.0-flash-lite',
|
||||
'gemini-3-flash',
|
||||
'gemini-3-flash-preview',
|
||||
];
|
||||
|
||||
let model: GeminiModel;
|
||||
@@ -407,11 +433,12 @@ export class GeminiAgent {
|
||||
|
||||
/**
|
||||
* Check if Gemini is available (has API key configured)
|
||||
* Issue #733: Uses centralized ~/.claude-mem/.env, not random project .env files
|
||||
*/
|
||||
export function isGeminiAvailable(): boolean {
|
||||
const settingsPath = path.join(homedir(), '.claude-mem', 'settings.json');
|
||||
const settings = SettingsDefaultsManager.loadFromFile(settingsPath);
|
||||
return !!(settings.CLAUDE_MEM_GEMINI_API_KEY || process.env.GEMINI_API_KEY);
|
||||
return !!(settings.CLAUDE_MEM_GEMINI_API_KEY || getCredential('GEMINI_API_KEY'));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,6 +17,7 @@ import { logger } from '../../utils/logger.js';
|
||||
import { buildInitPrompt, buildObservationPrompt, buildSummaryPrompt, buildContinuationPrompt } from '../../sdk/prompts.js';
|
||||
import { SettingsDefaultsManager } from '../../shared/SettingsDefaultsManager.js';
|
||||
import { USER_SETTINGS_PATH } from '../../shared/paths.js';
|
||||
import { getCredential } from '../../shared/EnvManager.js';
|
||||
import type { ActiveSession, ConversationMessage } from '../worker-types.js';
|
||||
import { ModeManager } from '../domain/ModeManager.js';
|
||||
import {
|
||||
@@ -91,6 +92,14 @@ export class OpenRouterAgent {
|
||||
throw new Error('OpenRouter API key not configured. Set CLAUDE_MEM_OPENROUTER_API_KEY in settings or OPENROUTER_API_KEY environment variable.');
|
||||
}
|
||||
|
||||
// Generate synthetic memorySessionId (OpenRouter is stateless, doesn't return session IDs)
|
||||
if (!session.memorySessionId) {
|
||||
const syntheticMemorySessionId = `openrouter-${session.contentSessionId}-${Date.now()}`;
|
||||
session.memorySessionId = syntheticMemorySessionId;
|
||||
this.dbManager.getSessionStore().updateMemorySessionId(session.sessionDbId, syntheticMemorySessionId);
|
||||
logger.info('SESSION', `MEMORY_ID_GENERATED | sessionDbId=${session.sessionDbId} | provider=OpenRouter`);
|
||||
}
|
||||
|
||||
// Load active mode
|
||||
const mode = ModeManager.getInstance().getActiveMode();
|
||||
|
||||
@@ -136,6 +145,10 @@ export class OpenRouterAgent {
|
||||
|
||||
// Process pending messages
|
||||
for await (const message of this.sessionManager.getMessageIterator(session.sessionDbId)) {
|
||||
// CLAIM-CONFIRM: Track message ID for confirmProcessed() after successful storage
|
||||
// The message is now in 'processing' status in DB until ResponseProcessor calls confirmProcessed()
|
||||
session.processingMessageIds.push(message._persistentId);
|
||||
|
||||
// Capture cwd from messages for proper worktree support
|
||||
if (message.cwd) {
|
||||
lastCwd = message.cwd;
|
||||
@@ -149,6 +162,12 @@ export class OpenRouterAgent {
|
||||
session.lastPromptNumber = message.prompt_number;
|
||||
}
|
||||
|
||||
// CRITICAL: Check memorySessionId BEFORE making expensive LLM call
|
||||
// This prevents wasting tokens when we won't be able to store the result anyway
|
||||
if (!session.memorySessionId) {
|
||||
throw new Error('Cannot process observations: memorySessionId not yet captured. This session may need to be reinitialized.');
|
||||
}
|
||||
|
||||
// Build observation prompt
|
||||
const obsPrompt = buildObservationPrompt({
|
||||
id: 0,
|
||||
@@ -187,6 +206,11 @@ export class OpenRouterAgent {
|
||||
);
|
||||
|
||||
} else if (message.type === 'summarize') {
|
||||
// CRITICAL: Check memorySessionId BEFORE making expensive LLM call
|
||||
if (!session.memorySessionId) {
|
||||
throw new Error('Cannot process summary: memorySessionId not yet captured. This session may need to be reinitialized.');
|
||||
}
|
||||
|
||||
// Build summary prompt
|
||||
const summaryPrompt = buildSummaryPrompt({
|
||||
id: session.sessionDbId,
|
||||
@@ -409,13 +433,15 @@ export class OpenRouterAgent {
|
||||
|
||||
/**
|
||||
* Get OpenRouter configuration from settings or environment
|
||||
* Issue #733: Uses centralized ~/.claude-mem/.env for credentials, not random project .env files
|
||||
*/
|
||||
private getOpenRouterConfig(): { apiKey: string; model: string; siteUrl?: string; appName?: string } {
|
||||
const settingsPath = USER_SETTINGS_PATH;
|
||||
const settings = SettingsDefaultsManager.loadFromFile(settingsPath);
|
||||
|
||||
// API key: check settings first, then environment variable
|
||||
const apiKey = settings.CLAUDE_MEM_OPENROUTER_API_KEY || process.env.OPENROUTER_API_KEY || '';
|
||||
// API key: check settings first, then centralized claude-mem .env (NOT process.env)
|
||||
// This prevents Issue #733 where random project .env files could interfere
|
||||
const apiKey = settings.CLAUDE_MEM_OPENROUTER_API_KEY || getCredential('OPENROUTER_API_KEY') || '';
|
||||
|
||||
// Model: from settings or default
|
||||
const model = settings.CLAUDE_MEM_OPENROUTER_MODEL || 'xiaomi/mimo-v2-flash:free';
|
||||
@@ -430,11 +456,12 @@ export class OpenRouterAgent {
|
||||
|
||||
/**
|
||||
* Check if OpenRouter is available (has API key configured)
|
||||
* Issue #733: Uses centralized ~/.claude-mem/.env, not random project .env files
|
||||
*/
|
||||
export function isOpenRouterAvailable(): boolean {
|
||||
const settingsPath = USER_SETTINGS_PATH;
|
||||
const settings = SettingsDefaultsManager.loadFromFile(settingsPath);
|
||||
return !!(settings.CLAUDE_MEM_OPENROUTER_API_KEY || process.env.OPENROUTER_API_KEY);
|
||||
return !!(settings.CLAUDE_MEM_OPENROUTER_API_KEY || getCredential('OPENROUTER_API_KEY'));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -121,6 +121,86 @@ export async function ensureProcessExit(tracked: TrackedProcess, timeoutMs: numb
|
||||
unregisterProcess(pid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill idle daemon children (claude processes spawned by worker-service)
|
||||
*
|
||||
* These are SDK-spawned claude processes that completed their work but
|
||||
* didn't terminate properly. They remain as children of the worker-service
|
||||
* daemon, consuming memory without doing useful work.
|
||||
*
|
||||
* Criteria for cleanup:
|
||||
* - Process name is "claude"
|
||||
* - Parent PID is the worker-service daemon (this process)
|
||||
* - Process has 0% CPU (idle)
|
||||
* - Process has been running for more than 2 minutes
|
||||
*/
|
||||
async function killIdleDaemonChildren(): Promise<number> {
|
||||
if (process.platform === 'win32') {
|
||||
// Windows: Different process model, skip for now
|
||||
return 0;
|
||||
}
|
||||
|
||||
const daemonPid = process.pid;
|
||||
let killed = 0;
|
||||
|
||||
try {
|
||||
const { stdout } = await execAsync(
|
||||
'ps -eo pid,ppid,%cpu,etime,comm 2>/dev/null | grep "claude$" || true'
|
||||
);
|
||||
|
||||
for (const line of stdout.trim().split('\n')) {
|
||||
if (!line) continue;
|
||||
|
||||
const parts = line.trim().split(/\s+/);
|
||||
if (parts.length < 5) continue;
|
||||
|
||||
const [pidStr, ppidStr, cpuStr, etime] = parts;
|
||||
const pid = parseInt(pidStr, 10);
|
||||
const ppid = parseInt(ppidStr, 10);
|
||||
const cpu = parseFloat(cpuStr);
|
||||
|
||||
// Skip if not a child of this daemon
|
||||
if (ppid !== daemonPid) continue;
|
||||
|
||||
// Skip if actively using CPU
|
||||
if (cpu > 0) continue;
|
||||
|
||||
// Parse elapsed time to minutes
|
||||
// Formats: MM:SS, HH:MM:SS, D-HH:MM:SS
|
||||
let minutes = 0;
|
||||
const dayMatch = etime.match(/^(\d+)-(\d+):(\d+):(\d+)$/);
|
||||
const hourMatch = etime.match(/^(\d+):(\d+):(\d+)$/);
|
||||
const minMatch = etime.match(/^(\d+):(\d+)$/);
|
||||
|
||||
if (dayMatch) {
|
||||
minutes = parseInt(dayMatch[1], 10) * 24 * 60 +
|
||||
parseInt(dayMatch[2], 10) * 60 +
|
||||
parseInt(dayMatch[3], 10);
|
||||
} else if (hourMatch) {
|
||||
minutes = parseInt(hourMatch[1], 10) * 60 +
|
||||
parseInt(hourMatch[2], 10);
|
||||
} else if (minMatch) {
|
||||
minutes = parseInt(minMatch[1], 10);
|
||||
}
|
||||
|
||||
// Kill if idle for more than 2 minutes
|
||||
if (minutes >= 2) {
|
||||
logger.info('PROCESS', `Killing idle daemon child PID ${pid} (idle ${minutes}m)`, { pid, minutes });
|
||||
try {
|
||||
process.kill(pid, 'SIGKILL');
|
||||
killed++;
|
||||
} catch {
|
||||
// Already dead or permission denied
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// No matches or command error
|
||||
}
|
||||
|
||||
return killed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill system-level orphans (ppid=1 on Unix)
|
||||
* These are Claude processes whose parent died unexpectedly
|
||||
@@ -179,6 +259,9 @@ export async function reapOrphanedProcesses(activeSessionIds: Set<number>): Prom
|
||||
// System-level: find ppid=1 orphans
|
||||
killed += await killSystemOrphans();
|
||||
|
||||
// Daemon children: find idle SDK processes that didn't terminate
|
||||
killed += await killIdleDaemonChildren();
|
||||
|
||||
return killed;
|
||||
}
|
||||
|
||||
@@ -187,6 +270,9 @@ export async function reapOrphanedProcesses(activeSessionIds: Set<number>): Prom
|
||||
*
|
||||
* The SDK's spawnClaudeCodeProcess option allows us to intercept subprocess
|
||||
* creation and capture the PID before the SDK hides it.
|
||||
*
|
||||
* NOTE: Session isolation is handled via the `cwd` option in SDKAgent.ts,
|
||||
* NOT via CLAUDE_CONFIG_DIR (which breaks authentication).
|
||||
*/
|
||||
export function createPidCapturingSpawn(sessionDbId: number) {
|
||||
return (spawnOptions: {
|
||||
|
||||
@@ -16,7 +16,8 @@ import { SessionManager } from './SessionManager.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { buildInitPrompt, buildObservationPrompt, buildSummaryPrompt, buildContinuationPrompt } from '../../sdk/prompts.js';
|
||||
import { SettingsDefaultsManager } from '../../shared/SettingsDefaultsManager.js';
|
||||
import { USER_SETTINGS_PATH } from '../../shared/paths.js';
|
||||
import { USER_SETTINGS_PATH, OBSERVER_SESSIONS_DIR, ensureDir } from '../../shared/paths.js';
|
||||
import { buildIsolatedEnv, getAuthMethodDescription } from '../../shared/EnvManager.js';
|
||||
import type { ActiveSession, SDKUserMessage } from '../worker-types.js';
|
||||
import { ModeManager } from '../domain/ModeManager.js';
|
||||
import { processAgentResponse, type WorkerRef } from './agents/index.js';
|
||||
@@ -71,24 +72,42 @@ export class SDKAgent {
|
||||
// CRITICAL: Only resume if:
|
||||
// 1. memorySessionId exists (was captured from a previous SDK response)
|
||||
// 2. lastPromptNumber > 1 (this is a continuation within the same SDK session)
|
||||
// 3. forceInit is NOT set (stale session recovery clears this)
|
||||
// On worker restart or crash recovery, memorySessionId may exist from a previous
|
||||
// SDK session but we must NOT resume because the SDK context was lost.
|
||||
// NEVER use contentSessionId for resume - that would inject messages into the user's transcript!
|
||||
const hasRealMemorySessionId = !!session.memorySessionId;
|
||||
const shouldResume = hasRealMemorySessionId && session.lastPromptNumber > 1 && !session.forceInit;
|
||||
|
||||
// Clear forceInit after using it
|
||||
if (session.forceInit) {
|
||||
logger.info('SDK', 'forceInit flag set, starting fresh SDK session', {
|
||||
sessionDbId: session.sessionDbId,
|
||||
previousMemorySessionId: session.memorySessionId
|
||||
});
|
||||
session.forceInit = false;
|
||||
}
|
||||
|
||||
// Build isolated environment from ~/.claude-mem/.env
|
||||
// This prevents Issue #733: random ANTHROPIC_API_KEY from project .env files
|
||||
// being used instead of the configured auth method (CLI subscription or explicit API key)
|
||||
const isolatedEnv = buildIsolatedEnv();
|
||||
const authMethod = getAuthMethodDescription();
|
||||
|
||||
logger.info('SDK', 'Starting SDK query', {
|
||||
sessionDbId: session.sessionDbId,
|
||||
contentSessionId: session.contentSessionId,
|
||||
memorySessionId: session.memorySessionId,
|
||||
hasRealMemorySessionId,
|
||||
resume_parameter: hasRealMemorySessionId ? session.memorySessionId : '(none - fresh start)',
|
||||
lastPromptNumber: session.lastPromptNumber
|
||||
shouldResume,
|
||||
resume_parameter: shouldResume ? session.memorySessionId : '(none - fresh start)',
|
||||
lastPromptNumber: session.lastPromptNumber,
|
||||
authMethod
|
||||
});
|
||||
|
||||
// Debug-level alignment logs for detailed tracing
|
||||
if (session.lastPromptNumber > 1) {
|
||||
const willResume = hasRealMemorySessionId;
|
||||
logger.debug('SDK', `[ALIGNMENT] Resume Decision | contentSessionId=${session.contentSessionId} | memorySessionId=${session.memorySessionId} | prompt#=${session.lastPromptNumber} | hasRealMemorySessionId=${hasRealMemorySessionId} | willResume=${willResume} | resumeWith=${willResume ? session.memorySessionId : 'NONE'}`);
|
||||
logger.debug('SDK', `[ALIGNMENT] Resume Decision | contentSessionId=${session.contentSessionId} | memorySessionId=${session.memorySessionId} | prompt#=${session.lastPromptNumber} | hasRealMemorySessionId=${hasRealMemorySessionId} | shouldResume=${shouldResume} | resumeWith=${shouldResume ? session.memorySessionId : 'NONE'}`);
|
||||
} else {
|
||||
// INIT prompt - never resume even if memorySessionId exists (stale from previous session)
|
||||
const hasStaleMemoryId = hasRealMemorySessionId;
|
||||
@@ -101,39 +120,58 @@ export class SDKAgent {
|
||||
// Run Agent SDK query loop
|
||||
// Only resume if we have a captured memory session ID
|
||||
// Use custom spawn to capture PIDs for zombie process cleanup (Issue #737)
|
||||
// Use dedicated cwd to isolate observer sessions from user's `claude --resume` list
|
||||
ensureDir(OBSERVER_SESSIONS_DIR);
|
||||
// CRITICAL: Pass isolated env to prevent Issue #733 (API key pollution from project .env files)
|
||||
const queryResult = query({
|
||||
prompt: messageGenerator,
|
||||
options: {
|
||||
model: modelId,
|
||||
// Only resume if BOTH: (1) we have a memorySessionId AND (2) this isn't the first prompt
|
||||
// On worker restart, memorySessionId may exist from a previous SDK session but we
|
||||
// need to start fresh since the SDK context was lost
|
||||
...(hasRealMemorySessionId && session.lastPromptNumber > 1 && { resume: session.memorySessionId }),
|
||||
// Isolate observer sessions - they'll appear under project "observer-sessions"
|
||||
// instead of polluting user's actual project resume lists
|
||||
cwd: OBSERVER_SESSIONS_DIR,
|
||||
// Only resume if shouldResume is true (memorySessionId exists, not first prompt, not forceInit)
|
||||
...(shouldResume && { resume: session.memorySessionId }),
|
||||
disallowedTools,
|
||||
abortController: session.abortController,
|
||||
pathToClaudeCodeExecutable: claudePath,
|
||||
// Custom spawn function captures PIDs to fix zombie process accumulation
|
||||
spawnClaudeCodeProcess: createPidCapturingSpawn(session.sessionDbId)
|
||||
spawnClaudeCodeProcess: createPidCapturingSpawn(session.sessionDbId),
|
||||
env: isolatedEnv // Use isolated credentials from ~/.claude-mem/.env, not process.env
|
||||
}
|
||||
});
|
||||
|
||||
// Process SDK messages
|
||||
for await (const message of queryResult) {
|
||||
// Capture memory session ID from first SDK message (any type has session_id)
|
||||
// This enables resume for subsequent generator starts within the same user session
|
||||
if (!session.memorySessionId && message.session_id) {
|
||||
// Capture or update memory session ID from SDK message
|
||||
// IMPORTANT: The SDK may return a DIFFERENT session_id on resume than what we sent!
|
||||
// We must always sync the DB to match what the SDK actually uses.
|
||||
//
|
||||
// MULTI-TERMINAL COLLISION FIX (FK constraint bug):
|
||||
// Use ensureMemorySessionIdRegistered() instead of updateMemorySessionId() because:
|
||||
// 1. It's idempotent - safe to call multiple times
|
||||
// 2. It verifies the update happened (SELECT before UPDATE)
|
||||
// 3. Consistent with ResponseProcessor's usage pattern
|
||||
// This ensures FK constraint compliance BEFORE any observations are stored.
|
||||
if (message.session_id && message.session_id !== session.memorySessionId) {
|
||||
const previousId = session.memorySessionId;
|
||||
session.memorySessionId = message.session_id;
|
||||
// Persist to database for cross-restart recovery
|
||||
this.dbManager.getSessionStore().updateMemorySessionId(
|
||||
// Persist to database IMMEDIATELY for FK constraint compliance
|
||||
// This must happen BEFORE any observations referencing this ID are stored
|
||||
this.dbManager.getSessionStore().ensureMemorySessionIdRegistered(
|
||||
session.sessionDbId,
|
||||
message.session_id
|
||||
);
|
||||
// Verify the update by reading back from DB
|
||||
const verification = this.dbManager.getSessionStore().getSessionById(session.sessionDbId);
|
||||
const dbVerified = verification?.memory_session_id === message.session_id;
|
||||
logger.info('SESSION', `MEMORY_ID_CAPTURED | sessionDbId=${session.sessionDbId} | memorySessionId=${message.session_id} | dbVerified=${dbVerified}`, {
|
||||
const logMessage = previousId
|
||||
? `MEMORY_ID_CHANGED | sessionDbId=${session.sessionDbId} | from=${previousId} | to=${message.session_id} | dbVerified=${dbVerified}`
|
||||
: `MEMORY_ID_CAPTURED | sessionDbId=${session.sessionDbId} | memorySessionId=${message.session_id} | dbVerified=${dbVerified}`;
|
||||
logger.info('SESSION', logMessage, {
|
||||
sessionId: session.sessionDbId,
|
||||
memorySessionId: message.session_id
|
||||
memorySessionId: message.session_id,
|
||||
previousId
|
||||
});
|
||||
if (!dbVerified) {
|
||||
logger.error('SESSION', `MEMORY_ID_MISMATCH | sessionDbId=${session.sessionDbId} | expected=${message.session_id} | got=${verification?.memory_session_id}`, {
|
||||
@@ -141,7 +179,7 @@ export class SDKAgent {
|
||||
});
|
||||
}
|
||||
// Debug-level alignment log for detailed tracing
|
||||
logger.debug('SDK', `[ALIGNMENT] Captured | contentSessionId=${session.contentSessionId} → memorySessionId=${message.session_id} | Future prompts will resume with this ID`);
|
||||
logger.debug('SDK', `[ALIGNMENT] ${previousId ? 'Updated' : 'Captured'} | contentSessionId=${session.contentSessionId} → memorySessionId=${message.session_id} | Future prompts will resume with this ID`);
|
||||
}
|
||||
|
||||
// Handle assistant messages
|
||||
@@ -151,6 +189,14 @@ export class SDKAgent {
|
||||
? content.filter((c: any) => c.type === 'text').map((c: any) => c.text).join('\n')
|
||||
: typeof content === 'string' ? content : '';
|
||||
|
||||
// Check for context overflow - prevents infinite retry loops
|
||||
if (textContent.includes('prompt is too long') ||
|
||||
textContent.includes('context window')) {
|
||||
logger.error('SDK', 'Context overflow detected - terminating session');
|
||||
session.abortController.abort();
|
||||
return;
|
||||
}
|
||||
|
||||
const responseSize = textContent.length;
|
||||
|
||||
// Capture token state BEFORE updating (for delta calculation)
|
||||
@@ -195,6 +241,17 @@ export class SDKAgent {
|
||||
}, truncatedResponse);
|
||||
}
|
||||
|
||||
// Detect fatal context overflow and terminate gracefully (issue #870)
|
||||
if (typeof textContent === 'string' && textContent.includes('Prompt is too long')) {
|
||||
throw new Error('Claude session context overflow: prompt is too long');
|
||||
}
|
||||
|
||||
// Detect invalid API key — SDK returns this as response text, not an error.
|
||||
// Throw so it surfaces in health endpoint and prevents silent failures.
|
||||
if (typeof textContent === 'string' && textContent.includes('Invalid API key')) {
|
||||
throw new Error('Invalid API key: check your API key configuration in ~/.claude-mem/settings.json or ~/.claude-mem/.env');
|
||||
}
|
||||
|
||||
// Parse and process response using shared ResponseProcessor
|
||||
await processAgentResponse(
|
||||
textContent,
|
||||
@@ -297,6 +354,10 @@ export class SDKAgent {
|
||||
|
||||
// Consume pending messages from SessionManager (event-driven, no polling)
|
||||
for await (const message of this.sessionManager.getMessageIterator(session.sessionDbId)) {
|
||||
// CLAIM-CONFIRM: Track message ID for confirmProcessed() after successful storage
|
||||
// The message is now in 'processing' status in DB until ResponseProcessor calls confirmProcessed()
|
||||
session.processingMessageIds.push(message._persistentId);
|
||||
|
||||
// Capture cwd from each message for worktree support
|
||||
if (message.cwd) {
|
||||
cwdTracker.lastCwd = message.cwd;
|
||||
|
||||
@@ -106,6 +106,15 @@ export class SessionManager {
|
||||
memory_session_id: dbSession.memory_session_id
|
||||
});
|
||||
|
||||
// Log warning if we're discarding a stale memory_session_id (Issue #817)
|
||||
if (dbSession.memory_session_id) {
|
||||
logger.warn('SESSION', `Discarding stale memory_session_id from previous worker instance (Issue #817)`, {
|
||||
sessionDbId,
|
||||
staleMemorySessionId: dbSession.memory_session_id,
|
||||
reason: 'SDK context lost on worker restart - will capture new ID'
|
||||
});
|
||||
}
|
||||
|
||||
// Use currentUserPrompt if provided, otherwise fall back to database (first prompt)
|
||||
const userPrompt = currentUserPrompt || dbSession.user_prompt;
|
||||
|
||||
@@ -124,11 +133,15 @@ export class SessionManager {
|
||||
}
|
||||
|
||||
// Create active session
|
||||
// Load memorySessionId from database if previously captured (enables resume across restarts)
|
||||
// CRITICAL: Do NOT load memorySessionId from database here (Issue #817)
|
||||
// When creating a new in-memory session, any database memory_session_id is STALE
|
||||
// because the SDK context was lost when the worker restarted. The SDK agent will
|
||||
// capture a new memorySessionId on the first response and persist it.
|
||||
// Loading stale memory_session_id causes "No conversation found" crashes on resume.
|
||||
session = {
|
||||
sessionDbId,
|
||||
contentSessionId: dbSession.content_session_id,
|
||||
memorySessionId: dbSession.memory_session_id || null,
|
||||
memorySessionId: null, // Always start fresh - SDK will capture new ID
|
||||
project: dbSession.project,
|
||||
userPrompt,
|
||||
pendingMessages: [],
|
||||
@@ -140,13 +153,16 @@ export class SessionManager {
|
||||
cumulativeOutputTokens: 0,
|
||||
earliestPendingTimestamp: null,
|
||||
conversationHistory: [], // Initialize empty - will be populated by agents
|
||||
currentProvider: null // Will be set when generator starts
|
||||
currentProvider: null, // Will be set when generator starts
|
||||
consecutiveRestarts: 0, // Track consecutive restart attempts to prevent infinite loops
|
||||
processingMessageIds: [] // CLAIM-CONFIRM: Track message IDs for confirmProcessed()
|
||||
};
|
||||
|
||||
logger.debug('SESSION', 'Creating new session object', {
|
||||
logger.debug('SESSION', 'Creating new session object (memorySessionId cleared to prevent stale resume)', {
|
||||
sessionDbId,
|
||||
contentSessionId: dbSession.content_session_id,
|
||||
memorySessionId: dbSession.memory_session_id || '(none - fresh session)',
|
||||
dbMemorySessionId: dbSession.memory_session_id || '(none in DB)',
|
||||
memorySessionId: '(cleared - will capture fresh from SDK)',
|
||||
lastPromptNumber: promptNumber || this.dbManager.getSessionStore().getPromptNumberFromUserPrompts(dbSession.content_session_id)
|
||||
});
|
||||
|
||||
@@ -303,6 +319,28 @@ export class SessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove session from in-memory maps and notify without awaiting generator.
|
||||
* Used when SDK resume fails and we give up (no fallback): avoids deadlock
|
||||
* from deleteSession() awaiting the same generator promise we're inside.
|
||||
*/
|
||||
removeSessionImmediate(sessionDbId: number): void {
|
||||
const session = this.sessions.get(sessionDbId);
|
||||
if (!session) return;
|
||||
|
||||
this.sessions.delete(sessionDbId);
|
||||
this.sessionQueues.delete(sessionDbId);
|
||||
|
||||
logger.info('SESSION', 'Session removed (orphaned after SDK termination)', {
|
||||
sessionId: sessionDbId,
|
||||
project: session.project
|
||||
});
|
||||
|
||||
if (this.onSessionDeletedCallback) {
|
||||
this.onSessionDeletedCallback();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown all active sessions
|
||||
*/
|
||||
@@ -378,7 +416,16 @@ export class SessionManager {
|
||||
const processor = new SessionQueueProcessor(this.getPendingStore(), emitter);
|
||||
|
||||
// Use the robust iterator - messages are deleted on claim (no tracking needed)
|
||||
for await (const message of processor.createIterator(sessionDbId, session.abortController.signal)) {
|
||||
// CRITICAL: Pass onIdleTimeout callback that triggers abort to kill the subprocess
|
||||
// Without this, the iterator returns but the Claude subprocess stays alive as a zombie
|
||||
for await (const message of processor.createIterator({
|
||||
sessionDbId,
|
||||
signal: session.abortController.signal,
|
||||
onIdleTimeout: () => {
|
||||
logger.info('SESSION', 'Triggering abort due to idle timeout to kill subprocess', { sessionDbId });
|
||||
session.abortController.abort();
|
||||
}
|
||||
})) {
|
||||
// Track earliest timestamp for accurate observation timestamps
|
||||
// This ensures backlog messages get their original timestamps, not current time
|
||||
if (session.earliestPendingTimestamp === null) {
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
*No recent activity*
|
||||
</claude-mem-context>
|
||||
@@ -16,6 +16,8 @@ import { parseObservations, parseSummary, type ParsedObservation, type ParsedSum
|
||||
import { updateCursorContextForProject } from '../../integrations/CursorHooksInstaller.js';
|
||||
import { updateFolderClaudeMdFiles } from '../../../utils/claude-md-utils.js';
|
||||
import { getWorkerPort } from '../../../shared/worker-utils.js';
|
||||
import { SettingsDefaultsManager } from '../../../shared/SettingsDefaultsManager.js';
|
||||
import { USER_SETTINGS_PATH } from '../../../shared/paths.js';
|
||||
import type { ActiveSession } from '../../worker-types.js';
|
||||
import type { DatabaseManager } from '../DatabaseManager.js';
|
||||
import type { SessionManager } from '../SessionManager.js';
|
||||
@@ -74,6 +76,14 @@ export async function processAgentResponse(
|
||||
throw new Error('Cannot store observations: memorySessionId not yet captured');
|
||||
}
|
||||
|
||||
// SAFETY NET (Issue #846 / Multi-terminal FK fix):
|
||||
// The PRIMARY fix is in SDKAgent.ts where ensureMemorySessionIdRegistered() is called
|
||||
// immediately when the SDK returns a memory_session_id. This call is a defensive safety net
|
||||
// in case the DB was somehow not updated (race condition, crash, etc.).
|
||||
// In multi-terminal scenarios, createSDKSession() now resets memory_session_id to NULL
|
||||
// for each new generator, ensuring clean isolation.
|
||||
sessionStore.ensureMemorySessionIdRegistered(session.sessionDbId, session.memorySessionId);
|
||||
|
||||
// Log pre-storage with session ID chain for verification
|
||||
logger.info('DB', `STORING | sessionDbId=${session.sessionDbId} | memorySessionId=${session.memorySessionId} | obsCount=${observations.length} | hasSummary=${!!summaryForStore}`, {
|
||||
sessionId: session.sessionDbId,
|
||||
@@ -98,6 +108,18 @@ export async function processAgentResponse(
|
||||
memorySessionId: session.memorySessionId
|
||||
});
|
||||
|
||||
// CLAIM-CONFIRM: Now that storage succeeded, confirm all processing messages (delete from queue)
|
||||
// This is the critical step that prevents message loss on generator crash
|
||||
const pendingStore = sessionManager.getPendingMessageStore();
|
||||
for (const messageId of session.processingMessageIds) {
|
||||
pendingStore.confirmProcessed(messageId);
|
||||
}
|
||||
if (session.processingMessageIds.length > 0) {
|
||||
logger.debug('QUEUE', `CONFIRMED_BATCH | sessionDbId=${session.sessionDbId} | count=${session.processingMessageIds.length} | ids=[${session.processingMessageIds.join(',')}]`);
|
||||
}
|
||||
// Clear the tracking array after confirmation
|
||||
session.processingMessageIds = [];
|
||||
|
||||
// AFTER transaction commits - async operations (can fail safely without data loss)
|
||||
await syncAndBroadcastObservations(
|
||||
observations,
|
||||
@@ -215,21 +237,29 @@ async function syncAndBroadcastObservations(
|
||||
|
||||
// Update folder CLAUDE.md files for touched folders (fire-and-forget)
|
||||
// This runs per-observation batch to ensure folders are updated as work happens
|
||||
const allFilePaths: string[] = [];
|
||||
for (const obs of observations) {
|
||||
allFilePaths.push(...(obs.files_modified || []));
|
||||
allFilePaths.push(...(obs.files_read || []));
|
||||
}
|
||||
// Only runs if CLAUDE_MEM_FOLDER_CLAUDEMD_ENABLED is true (default: false)
|
||||
const settings = SettingsDefaultsManager.loadFromFile(USER_SETTINGS_PATH);
|
||||
// Handle both string 'true' and boolean true from JSON settings
|
||||
const settingValue = settings.CLAUDE_MEM_FOLDER_CLAUDEMD_ENABLED;
|
||||
const folderClaudeMdEnabled = settingValue === 'true' || settingValue === true;
|
||||
|
||||
if (allFilePaths.length > 0) {
|
||||
updateFolderClaudeMdFiles(
|
||||
allFilePaths,
|
||||
session.project,
|
||||
getWorkerPort(),
|
||||
projectRoot
|
||||
).catch(error => {
|
||||
logger.warn('FOLDER_INDEX', 'CLAUDE.md update failed (non-critical)', { project: session.project }, error as Error);
|
||||
});
|
||||
if (folderClaudeMdEnabled) {
|
||||
const allFilePaths: string[] = [];
|
||||
for (const obs of observations) {
|
||||
allFilePaths.push(...(obs.files_modified || []));
|
||||
allFilePaths.push(...(obs.files_read || []));
|
||||
}
|
||||
|
||||
if (allFilePaths.length > 0) {
|
||||
updateFolderClaudeMdFiles(
|
||||
allFilePaths,
|
||||
session.project,
|
||||
getWorkerPort(),
|
||||
projectRoot
|
||||
).catch(error => {
|
||||
logger.warn('FOLDER_INDEX', 'CLAUDE.md update failed (non-critical)', { project: session.project }, error as Error);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
*No recent activity*
|
||||
</claude-mem-context>
|
||||
@@ -1,7 +0,0 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
*No recent activity*
|
||||
</claude-mem-context>
|
||||
@@ -24,8 +24,21 @@ export function createMiddleware(
|
||||
// JSON parsing with 50mb limit
|
||||
middlewares.push(express.json({ limit: '50mb' }));
|
||||
|
||||
// CORS
|
||||
middlewares.push(cors());
|
||||
// CORS - restrict to localhost origins only
|
||||
middlewares.push(cors({
|
||||
origin: (origin, callback) => {
|
||||
// Allow: requests without Origin header (hooks, curl, CLI tools)
|
||||
// Allow: localhost and 127.0.0.1 origins
|
||||
if (!origin ||
|
||||
origin.startsWith('http://localhost:') ||
|
||||
origin.startsWith('http://127.0.0.1:')) {
|
||||
callback(null, true);
|
||||
} else {
|
||||
callback(new Error('CORS not allowed'));
|
||||
}
|
||||
},
|
||||
credentials: false
|
||||
}));
|
||||
|
||||
// HTTP request/response logging
|
||||
middlewares.push((req: Request, res: Response, next: NextFunction) => {
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Dec 5, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #20734 | 9:08 PM | 🔵 | SearchRoutes Context Injection Endpoint with Dynamic Import | ~614 |
|
||||
| #20548 | 8:21 PM | 🔵 | Context generator imported from services directory in worker | ~334 |
|
||||
| #20547 | " | 🔵 | Context injection route implementation in SearchRoutes.ts | ~289 |
|
||||
|
||||
### Dec 7, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #21742 | 10:16 PM | 🔵 | SessionRoutes Analysis: Identified 10+ Scattered Broadcast Calls | ~540 |
|
||||
|
||||
### Dec 8, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #22301 | 9:44 PM | 🔵 | Privacy Validation in Observation Processing | ~399 |
|
||||
| #22296 | 9:43 PM | 🔵 | SessionRoutes HTTP Endpoints and SDK Agent Lifecycle | ~442 |
|
||||
| #22222 | 8:29 PM | 🔵 | Found waiting logic in SessionRoutes but it may not be working correctly | ~359 |
|
||||
| #22005 | 5:40 PM | 🔵 | handleObservationsByClaudeId Current Implementation | ~443 |
|
||||
| #22004 | " | 🔵 | Legacy Observation Handling Pattern Identified | ~337 |
|
||||
| #22003 | " | 🔵 | SessionRoutes Architecture Confirmed | ~354 |
|
||||
| #21969 | 5:22 PM | 🟣 | Worker Routes Pass tool_use_id to SessionManager Queue | ~290 |
|
||||
| #21968 | " | ✅ | Worker Endpoint Extracts toolUseId from Observation Request | ~243 |
|
||||
| #21962 | 5:21 PM | 🟣 | Implemented handleGetObservationsForToolUse Endpoint Handler | ~325 |
|
||||
| #21961 | " | 🟣 | Added GET Endpoint for Fetching Observations by Tool Use ID | ~272 |
|
||||
| #21951 | 5:18 PM | 🔵 | Worker SessionRoutes Architecture and Endpoints Reviewed | ~418 |
|
||||
| #21948 | 5:09 PM | 🟣 | Implemented PreToolUse Endpoint Handler | ~334 |
|
||||
| #21947 | 5:07 PM | 🟣 | Added PreToolUse Route Registration | ~287 |
|
||||
|
||||
### Dec 9, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #23143 | 6:42 PM | ✅ | Updated Skip Tools Logic to Use USER_SETTINGS_PATH Constant | ~150 |
|
||||
| #23142 | " | ✅ | Fixed Settings Path Import in SessionRoutes | ~148 |
|
||||
| #23140 | 6:41 PM | 🟣 | Implemented Skip Tools Filtering in Observations Endpoint | ~386 |
|
||||
| #23138 | " | ✅ | Added SettingsDefaultsManager and Paths Imports to SessionRoutes | ~222 |
|
||||
| #23136 | " | 🔵 | SessionRoutes handleObservationsByClaudeId Handler Structure | ~329 |
|
||||
| #23007 | 4:02 PM | 🔵 | Settings Write Implementation Using Nested Schema | ~398 |
|
||||
| #22859 | 2:28 PM | 🔴 | Fixed Python Version Validation to Support 3.10+ | ~322 |
|
||||
| #22854 | 2:27 PM | 🔵 | Located Python Version Validation Regex in SettingsRoutes | ~316 |
|
||||
|
||||
### Dec 10, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #23593 | 5:52 PM | 🔵 | SearchRoutes Handler Pattern | ~268 |
|
||||
| #23588 | 5:51 PM | 🔵 | Search Routes HTTP API Integration | ~281 |
|
||||
|
||||
### Dec 14, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #26253 | 8:31 PM | 🔵 | SearchRoutes Confirms Context Endpoints Use generateContext, Search Uses SearchManager | ~397 |
|
||||
| #25689 | 4:23 PM | 🔵 | SessionRoutes queueSummarize receives messages but doesn't persist them to database | ~496 |
|
||||
|
||||
### Dec 15, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #27043 | 6:04 PM | 🔵 | Subagent confirms no version switcher UI exists, only orphaned backend infrastructure | ~539 |
|
||||
| #27041 | 6:03 PM | 🔵 | Branch switching code isolated to two backend files, no frontend UI components | ~473 |
|
||||
| #27037 | 6:02 PM | 🔵 | Branch switching functionality exists in SettingsRoutes with UI switcher removal intent | ~463 |
|
||||
|
||||
### Dec 16, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #27414 | 3:25 PM | 🔵 | Batch Observations Endpoint Already Implemented | ~330 |
|
||||
|
||||
### Dec 19, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #30077 | 8:05 PM | 🔵 | SessionRoutes HTTP API Manages SDK Agent Lifecycle and Message Queue | ~516 |
|
||||
|
||||
### Dec 26, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #32949 | 10:55 PM | 🔵 | Complete settings persistence flow for Xiaomi MIMO v2 Flash model | ~320 |
|
||||
| #32939 | 10:53 PM | 🔵 | Settings API routes handle model configuration persistence | ~288 |
|
||||
|
||||
### Dec 30, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #34491 | 2:28 PM | 🔵 | SessionRoutes Implements Multi-Provider Agent Management | ~635 |
|
||||
</claude-mem-context>
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Memory Routes
|
||||
*
|
||||
* Handles manual memory/observation saving.
|
||||
* POST /api/memory/save - Save a manual memory observation
|
||||
*/
|
||||
|
||||
import express, { Request, Response } from 'express';
|
||||
import { BaseRouteHandler } from '../BaseRouteHandler.js';
|
||||
import { logger } from '../../../../utils/logger.js';
|
||||
import type { DatabaseManager } from '../../DatabaseManager.js';
|
||||
|
||||
export class MemoryRoutes extends BaseRouteHandler {
|
||||
constructor(
|
||||
private dbManager: DatabaseManager,
|
||||
private defaultProject: string
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
setupRoutes(app: express.Application): void {
|
||||
app.post('/api/memory/save', this.handleSaveMemory.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/memory/save - Save a manual memory/observation
|
||||
* Body: { text: string, title?: string, project?: string }
|
||||
*/
|
||||
private handleSaveMemory = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
|
||||
const { text, title, project } = req.body;
|
||||
const targetProject = project || this.defaultProject;
|
||||
|
||||
if (!text || typeof text !== 'string' || text.trim().length === 0) {
|
||||
this.badRequest(res, 'text is required and must be non-empty');
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionStore = this.dbManager.getSessionStore();
|
||||
const chromaSync = this.dbManager.getChromaSync();
|
||||
|
||||
// 1. Get or create manual session for project
|
||||
const memorySessionId = sessionStore.getOrCreateManualSession(targetProject);
|
||||
|
||||
// 2. Build observation
|
||||
const observation = {
|
||||
type: 'discovery', // Use existing valid type
|
||||
title: title || text.substring(0, 60).trim() + (text.length > 60 ? '...' : ''),
|
||||
subtitle: 'Manual memory',
|
||||
facts: [] as string[],
|
||||
narrative: text,
|
||||
concepts: [] as string[],
|
||||
files_read: [] as string[],
|
||||
files_modified: [] as string[]
|
||||
};
|
||||
|
||||
// 3. Store to SQLite
|
||||
const result = sessionStore.storeObservation(
|
||||
memorySessionId,
|
||||
targetProject,
|
||||
observation,
|
||||
0, // promptNumber
|
||||
0 // discoveryTokens
|
||||
);
|
||||
|
||||
logger.info('HTTP', 'Manual observation saved', {
|
||||
id: result.id,
|
||||
project: targetProject,
|
||||
title: observation.title
|
||||
});
|
||||
|
||||
// 4. Sync to ChromaDB (async, fire-and-forget)
|
||||
chromaSync.syncObservation(
|
||||
result.id,
|
||||
memorySessionId,
|
||||
targetProject,
|
||||
observation,
|
||||
0,
|
||||
result.createdAtEpoch,
|
||||
0
|
||||
).catch(err => {
|
||||
logger.error('CHROMA', 'ChromaDB sync failed', { id: result.id }, err as Error);
|
||||
});
|
||||
|
||||
// 5. Return success
|
||||
res.json({
|
||||
success: true,
|
||||
id: result.id,
|
||||
title: observation.title,
|
||||
project: targetProject,
|
||||
message: `Memory saved as observation #${result.id}`
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -24,6 +24,8 @@ import { USER_SETTINGS_PATH } from '../../../../shared/paths.js';
|
||||
|
||||
export class SessionRoutes extends BaseRouteHandler {
|
||||
private completionHandler: SessionCompletionHandler;
|
||||
private spawnInProgress = new Map<number, boolean>();
|
||||
private crashRecoveryScheduled = new Set<number>();
|
||||
|
||||
constructor(
|
||||
private sessionManager: SessionManager,
|
||||
@@ -91,10 +93,17 @@ export class SessionRoutes extends BaseRouteHandler {
|
||||
const session = this.sessionManager.getSession(sessionDbId);
|
||||
if (!session) return;
|
||||
|
||||
// GUARD: Prevent duplicate spawns
|
||||
if (this.spawnInProgress.get(sessionDbId)) {
|
||||
logger.debug('SESSION', 'Spawn already in progress, skipping', { sessionDbId, source });
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedProvider = this.getSelectedProvider();
|
||||
|
||||
// Start generator if not running
|
||||
if (!session.generatorPromise) {
|
||||
this.spawnInProgress.set(sessionDbId, true);
|
||||
this.startGeneratorWithProvider(session, selectedProvider, source);
|
||||
return;
|
||||
}
|
||||
@@ -122,12 +131,26 @@ export class SessionRoutes extends BaseRouteHandler {
|
||||
): void {
|
||||
if (!session) return;
|
||||
|
||||
// Reset AbortController if it was previously aborted
|
||||
// This fixes the bug where a session gets stuck in an infinite "Generator aborted" loop
|
||||
// after its AbortController was aborted (e.g., from a previous generator exit)
|
||||
if (session.abortController.signal.aborted) {
|
||||
logger.debug('SESSION', 'Resetting aborted AbortController before starting generator', {
|
||||
sessionId: session.sessionDbId
|
||||
});
|
||||
session.abortController = new AbortController();
|
||||
}
|
||||
|
||||
const agent = provider === 'openrouter' ? this.openRouterAgent : (provider === 'gemini' ? this.geminiAgent : this.sdkAgent);
|
||||
const agentName = provider === 'openrouter' ? 'OpenRouter' : (provider === 'gemini' ? 'Gemini' : 'Claude SDK');
|
||||
|
||||
// Use database count for accurate telemetry (in-memory array is always empty due to FK constraint fix)
|
||||
const pendingStore = this.sessionManager.getPendingMessageStore();
|
||||
const actualQueueDepth = pendingStore.getPendingCount(session.sessionDbId);
|
||||
|
||||
logger.info('SESSION', `Generator auto-starting (${source}) using ${agentName}`, {
|
||||
sessionId: session.sessionDbId,
|
||||
queueDepth: session.pendingMessages.length,
|
||||
queueDepth: actualQueueDepth,
|
||||
historyLength: session.conversationHistory.length
|
||||
});
|
||||
|
||||
@@ -163,6 +186,7 @@ export class SessionRoutes extends BaseRouteHandler {
|
||||
})
|
||||
.finally(() => {
|
||||
const sessionDbId = session.sessionDbId;
|
||||
this.spawnInProgress.delete(sessionDbId);
|
||||
const wasAborted = session.abortController.signal.aborted;
|
||||
|
||||
if (wasAborted) {
|
||||
@@ -175,16 +199,43 @@ export class SessionRoutes extends BaseRouteHandler {
|
||||
session.currentProvider = null;
|
||||
this.workerService.broadcastProcessingStatus();
|
||||
|
||||
// Crash recovery: If not aborted and still has work, restart
|
||||
// Crash recovery: If not aborted and still has work, restart (with limit)
|
||||
if (!wasAborted) {
|
||||
try {
|
||||
const pendingStore = this.sessionManager.getPendingMessageStore();
|
||||
const pendingCount = pendingStore.getPendingCount(sessionDbId);
|
||||
|
||||
// CRITICAL: Limit consecutive restarts to prevent infinite loops
|
||||
// This prevents runaway API costs when there's a persistent error (e.g., memorySessionId not captured)
|
||||
const MAX_CONSECUTIVE_RESTARTS = 3;
|
||||
|
||||
if (pendingCount > 0) {
|
||||
// GUARD: Prevent duplicate crash recovery spawns
|
||||
if (this.crashRecoveryScheduled.has(sessionDbId)) {
|
||||
logger.debug('SESSION', 'Crash recovery already scheduled', { sessionDbId });
|
||||
return;
|
||||
}
|
||||
|
||||
session.consecutiveRestarts = (session.consecutiveRestarts || 0) + 1;
|
||||
|
||||
if (session.consecutiveRestarts > MAX_CONSECUTIVE_RESTARTS) {
|
||||
logger.error('SESSION', `CRITICAL: Generator restart limit exceeded - stopping to prevent runaway costs`, {
|
||||
sessionId: sessionDbId,
|
||||
pendingCount,
|
||||
consecutiveRestarts: session.consecutiveRestarts,
|
||||
maxRestarts: MAX_CONSECUTIVE_RESTARTS,
|
||||
action: 'Generator will NOT restart. Check logs for root cause. Messages remain in pending state.'
|
||||
});
|
||||
// Don't restart - abort to prevent further API calls
|
||||
session.abortController.abort();
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('SESSION', `Restarting generator after crash/exit with pending work`, {
|
||||
sessionId: sessionDbId,
|
||||
pendingCount
|
||||
pendingCount,
|
||||
consecutiveRestarts: session.consecutiveRestarts,
|
||||
maxRestarts: MAX_CONSECUTIVE_RESTARTS
|
||||
});
|
||||
|
||||
// Abort OLD controller before replacing to prevent child process leaks
|
||||
@@ -192,16 +243,24 @@ export class SessionRoutes extends BaseRouteHandler {
|
||||
session.abortController = new AbortController();
|
||||
oldController.abort();
|
||||
|
||||
// Small delay before restart
|
||||
this.crashRecoveryScheduled.add(sessionDbId);
|
||||
|
||||
// Exponential backoff: 1s, 2s, 4s for subsequent restarts
|
||||
const backoffMs = Math.min(1000 * Math.pow(2, session.consecutiveRestarts - 1), 8000);
|
||||
|
||||
// Delay before restart with exponential backoff
|
||||
setTimeout(() => {
|
||||
this.crashRecoveryScheduled.delete(sessionDbId);
|
||||
const stillExists = this.sessionManager.getSession(sessionDbId);
|
||||
if (stillExists && !stillExists.generatorPromise) {
|
||||
this.startGeneratorWithProvider(stillExists, this.getSelectedProvider(), 'crash-recovery');
|
||||
}
|
||||
}, 1000);
|
||||
}, backoffMs);
|
||||
} else {
|
||||
// No pending work - abort to kill the child process
|
||||
session.abortController.abort();
|
||||
// Reset restart counter on successful completion
|
||||
session.consecutiveRestarts = 0;
|
||||
logger.debug('SESSION', 'Aborted controller after natural completion', {
|
||||
sessionId: sessionDbId
|
||||
});
|
||||
@@ -231,6 +290,7 @@ export class SessionRoutes extends BaseRouteHandler {
|
||||
app.post('/api/sessions/init', this.handleSessionInitByClaudeId.bind(this));
|
||||
app.post('/api/sessions/observations', this.handleObservationsByClaudeId.bind(this));
|
||||
app.post('/api/sessions/summarize', this.handleSummarizeByClaudeId.bind(this));
|
||||
app.post('/api/sessions/complete', this.handleCompleteByClaudeId.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -291,8 +351,8 @@ export class SessionRoutes extends BaseRouteHandler {
|
||||
});
|
||||
}
|
||||
|
||||
// Start agent in background using the helper method
|
||||
this.startGeneratorWithProvider(session, this.getSelectedProvider(), 'init');
|
||||
// Idempotent: ensure generator is running (matches handleObservations / handleSummarize)
|
||||
this.ensureGeneratorRunning(sessionDbId, 'init');
|
||||
|
||||
// Broadcast session started event
|
||||
this.eventBroadcaster.broadcastSessionStarted(sessionDbId, session.project);
|
||||
@@ -362,11 +422,15 @@ export class SessionRoutes extends BaseRouteHandler {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use database count for accurate queue length (in-memory array is always empty due to FK constraint fix)
|
||||
const pendingStore = this.sessionManager.getPendingMessageStore();
|
||||
const queueLength = pendingStore.getPendingCount(sessionDbId);
|
||||
|
||||
res.json({
|
||||
status: 'active',
|
||||
sessionDbId,
|
||||
project: session.project,
|
||||
queueLength: session.pendingMessages.length,
|
||||
queueLength,
|
||||
uptime: Date.now() - session.startTime
|
||||
});
|
||||
});
|
||||
@@ -531,6 +595,54 @@ export class SessionRoutes extends BaseRouteHandler {
|
||||
res.json({ status: 'queued' });
|
||||
});
|
||||
|
||||
/**
|
||||
* Complete session by contentSessionId (session-complete hook uses this)
|
||||
* POST /api/sessions/complete
|
||||
* Body: { contentSessionId }
|
||||
*
|
||||
* Removes session from active sessions map, allowing orphan reaper to
|
||||
* clean up any remaining subprocesses.
|
||||
*
|
||||
* Fixes Issue #842: Sessions stay in map forever, reaper thinks all active.
|
||||
*/
|
||||
private handleCompleteByClaudeId = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
|
||||
const { contentSessionId } = req.body;
|
||||
|
||||
logger.info('HTTP', '→ POST /api/sessions/complete', { contentSessionId });
|
||||
|
||||
if (!contentSessionId) {
|
||||
return this.badRequest(res, 'Missing contentSessionId');
|
||||
}
|
||||
|
||||
const store = this.dbManager.getSessionStore();
|
||||
|
||||
// Look up sessionDbId from contentSessionId (createSDKSession is idempotent)
|
||||
// Pass empty strings - we only need the ID lookup, not to create a new session
|
||||
const sessionDbId = store.createSDKSession(contentSessionId, '', '');
|
||||
|
||||
// Check if session is in the active sessions map
|
||||
const activeSession = this.sessionManager.getSession(sessionDbId);
|
||||
if (!activeSession) {
|
||||
// Session may not be in memory (already completed or never initialized)
|
||||
logger.debug('SESSION', 'session-complete: Session not in active map', {
|
||||
contentSessionId,
|
||||
sessionDbId
|
||||
});
|
||||
res.json({ status: 'skipped', reason: 'not_active' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Complete the session (removes from active sessions map)
|
||||
await this.completionHandler.completeByDbId(sessionDbId);
|
||||
|
||||
logger.info('SESSION', 'Session completed via API', {
|
||||
contentSessionId,
|
||||
sessionDbId
|
||||
});
|
||||
|
||||
res.json({ status: 'completed', sessionDbId });
|
||||
});
|
||||
|
||||
/**
|
||||
* Initialize session by contentSessionId (new-hook uses this)
|
||||
* POST /api/sessions/init
|
||||
|
||||
@@ -121,6 +121,7 @@ export class SettingsRoutes extends BaseRouteHandler {
|
||||
// Feature Toggles
|
||||
'CLAUDE_MEM_CONTEXT_SHOW_LAST_SUMMARY',
|
||||
'CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE',
|
||||
'CLAUDE_MEM_FOLDER_CLAUDEMD_ENABLED',
|
||||
];
|
||||
|
||||
for (const key of settingKeys) {
|
||||
@@ -241,9 +242,9 @@ export class SettingsRoutes extends BaseRouteHandler {
|
||||
|
||||
// Validate CLAUDE_MEM_GEMINI_MODEL
|
||||
if (settings.CLAUDE_MEM_GEMINI_MODEL) {
|
||||
const validGeminiModels = ['gemini-2.5-flash-lite', 'gemini-2.5-flash', 'gemini-3-flash'];
|
||||
const validGeminiModels = ['gemini-2.5-flash-lite', 'gemini-2.5-flash', 'gemini-3-flash-preview'];
|
||||
if (!validGeminiModels.includes(settings.CLAUDE_MEM_GEMINI_MODEL)) {
|
||||
return { valid: false, error: 'CLAUDE_MEM_GEMINI_MODEL must be one of: gemini-2.5-flash-lite, gemini-2.5-flash, gemini-3-flash' };
|
||||
return { valid: false, error: 'CLAUDE_MEM_GEMINI_MODEL must be one of: gemini-2.5-flash-lite, gemini-2.5-flash, gemini-3-flash-preview' };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
*No recent activity*
|
||||
</claude-mem-context>
|
||||
@@ -1,21 +0,0 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Jan 3, 2026
|
||||
|
||||
**DateFilter.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #36670 | 11:37 PM | ✅ | Resolved merge conflicts by accepting branch changes for 39 files | ~435 |
|
||||
| #36523 | 9:34 PM | 🔴 | Fixed TypeScript Type Import Issues in Worker Services | ~386 |
|
||||
| #36519 | " | 🔴 | Fixed Type Import Issues Preventing Worker Tests | ~308 |
|
||||
| #36516 | 9:33 PM | 🔴 | Fixed TypeScript Type Import Issues in Worker Search Modules | ~377 |
|
||||
| #36390 | 8:50 PM | 🔄 | Comprehensive Monolith Refactor with Modular Architecture | ~724 |
|
||||
|
||||
**ProjectFilter.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #36529 | 9:34 PM | 🔵 | Search Module Architecture Discovery | ~302 |
|
||||
</claude-mem-context>
|
||||
@@ -1,7 +0,0 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
*No recent activity*
|
||||
</claude-mem-context>
|
||||
@@ -167,6 +167,13 @@ export class ChromaSearchStrategy extends BaseSearchStrategy implements SearchSt
|
||||
|
||||
/**
|
||||
* Filter results by recency (90-day window)
|
||||
*
|
||||
* IMPORTANT: ChromaSync.queryChroma() returns deduplicated `ids` (unique sqlite_ids)
|
||||
* but the `metadatas` array may contain multiple entries per sqlite_id (e.g., one
|
||||
* observation can have narrative + multiple facts as separate Chroma documents).
|
||||
*
|
||||
* This method iterates over the deduplicated `ids` and finds the first matching
|
||||
* metadata for each ID to avoid array misalignment issues.
|
||||
*/
|
||||
private filterByRecency(chromaResults: {
|
||||
ids: number[];
|
||||
@@ -174,10 +181,19 @@ export class ChromaSearchStrategy extends BaseSearchStrategy implements SearchSt
|
||||
}): Array<{ id: number; meta: ChromaMetadata }> {
|
||||
const cutoff = Date.now() - SEARCH_CONSTANTS.RECENCY_WINDOW_MS;
|
||||
|
||||
return chromaResults.metadatas
|
||||
.map((meta, idx) => ({
|
||||
id: chromaResults.ids[idx],
|
||||
meta
|
||||
// Build a map from sqlite_id to first metadata for efficient lookup
|
||||
const metadataByIdMap = new Map<number, ChromaMetadata>();
|
||||
for (const meta of chromaResults.metadatas) {
|
||||
if (meta?.sqlite_id !== undefined && !metadataByIdMap.has(meta.sqlite_id)) {
|
||||
metadataByIdMap.set(meta.sqlite_id, meta);
|
||||
}
|
||||
}
|
||||
|
||||
// Iterate over deduplicated ids and get corresponding metadata
|
||||
return chromaResults.ids
|
||||
.map(id => ({
|
||||
id,
|
||||
meta: metadataByIdMap.get(id) as ChromaMetadata
|
||||
}))
|
||||
.filter(item => item.meta && item.meta.created_at_epoch > cutoff);
|
||||
}
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Dec 7, 2025
|
||||
|
||||
**SessionCompletionHandler.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #21829 | 11:05 PM | 🔄 | Massive refactor adds 8,671 lines and removes 5,585 lines across 60 files | ~619 |
|
||||
| #21825 | 11:00 PM | 🔵 | SessionCompletionHandler methods called 3 times in SessionRoutes | ~342 |
|
||||
| #21824 | 10:59 PM | 🔵 | SessionEventBroadcaster methods called 7 times across SessionRoutes and SessionCompletionHandler | ~398 |
|
||||
| #21822 | " | 🔵 | SessionEventBroadcaster instantiated in WorkerService and injected into routes and handlers | ~372 |
|
||||
| #21818 | 10:58 PM | 🔵 | SessionCompletionHandler is instantiated in SessionRoutes | ~282 |
|
||||
| #21817 | " | 🔵 | SessionCompletionHandler consolidates session completion logic | ~414 |
|
||||
| #21807 | 10:49 PM | ⚖️ | KISS Audit Identified 587 Lines of Ceremonial Complexity | ~699 |
|
||||
| #21794 | 10:46 PM | 🔵 | SessionCompletionHandler Consolidates Duplicate Completion Logic | ~341 |
|
||||
| #21764 | 10:23 PM | ✅ | Phase 4 Build and Deployment Successful | ~376 |
|
||||
| #21759 | 10:21 PM | 🟣 | SessionCompletionHandler Service Created | ~426 |
|
||||
|
||||
### Dec 11, 2025
|
||||
|
||||
**SessionCompletionHandler.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #23962 | 1:59 PM | 🔵 | Services Layer Implements Full Backend Architecture | ~490 |
|
||||
|
||||
### Dec 14, 2025
|
||||
|
||||
**SessionCompletionHandler.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #26088 | 7:32 PM | 🔵 | API Endpoint Architecture Discovery | ~416 |
|
||||
|
||||
### Dec 20, 2025
|
||||
|
||||
**SessionCompletionHandler.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #30725 | 5:12 PM | 🔵 | Revealed extensive work-in-progress changes across hook and worker systems | ~479 |
|
||||
| #30569 | 4:56 PM | 🔄 | SessionCompletionHandler Broadcasting Implementation | ~264 |
|
||||
| #30568 | " | 🔄 | SessionCompletionHandler Event Broadcasting Refactor | ~282 |
|
||||
| #30566 | " | 🔵 | Session Completion Handler Consolidation | ~323 |
|
||||
|
||||
### Dec 24, 2025
|
||||
|
||||
**SessionCompletionHandler.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #32350 | 8:42 PM | 🔵 | Detailed Cleanup Hook Evolution Documentation Retrieved | ~597 |
|
||||
| #32316 | 8:41 PM | 🔄 | Removed markSessionComplete method from DatabaseManager | ~251 |
|
||||
| #32194 | 7:42 PM | 🔵 | Session completion handler implementation analysis | ~329 |
|
||||
| #32193 | " | 🔵 | Session completion endpoint usage across codebase | ~278 |
|
||||
| #32182 | 7:15 PM | 🔄 | Removed markSessionComplete database call from session completion flow | ~316 |
|
||||
| #32179 | 7:11 PM | 🔄 | SessionCompletionHandler switched to direct SQL query | ~273 |
|
||||
| #32153 | 6:40 PM | 🔵 | Session Identifier Architecture Across Codebase | ~529 |
|
||||
|
||||
### Dec 25, 2025
|
||||
|
||||
**SessionCompletionHandler.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #32597 | 8:40 PM | 🔵 | Identified session completion mechanism and potential method discrepancy | ~470 |
|
||||
| #32456 | 5:41 PM | ✅ | Completed merge of main branch into feature/titans-phase1-3 | ~354 |
|
||||
| #32198 | 7:41 PM | 🔄 | Removed redundant SessionEnd cleanup hook | ~317 |
|
||||
|
||||
### Dec 27, 2025
|
||||
|
||||
**SessionCompletionHandler.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #33099 | 7:10 PM | 🔵 | SessionCompletionHandler Manual Session Termination Flow | ~348 |
|
||||
|
||||
### Dec 28, 2025
|
||||
|
||||
**SessionCompletionHandler.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #33328 | 3:10 PM | 🟣 | Merged centralized logger and session continuity diagnostics to main | ~397 |
|
||||
| #33280 | 3:07 PM | 🔄 | Logger coverage refactor for background services | ~428 |
|
||||
|
||||
### Dec 30, 2025
|
||||
|
||||
**SessionCompletionHandler.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #34388 | 1:40 PM | 🔵 | SessionCompletionHandler Relies on SessionManager Abort Without Process Cleanup | ~309 |
|
||||
|
||||
### Dec 31, 2025
|
||||
|
||||
**SessionCompletionHandler.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #34707 | 4:45 PM | 🔵 | SessionCompletionHandler Aborts SDK Agent During Cleanup | ~291 |
|
||||
|
||||
### Jan 2, 2026
|
||||
|
||||
**SessionCompletionHandler.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #35951 | 4:42 PM | 🔵 | Multi-Layer Service Architecture Discovery | ~395 |
|
||||
</claude-mem-context>
|
||||
@@ -1,77 +0,0 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Dec 7, 2025
|
||||
|
||||
**PrivacyCheckValidator.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #21829 | 11:05 PM | 🔄 | Massive refactor adds 8,671 lines and removes 5,585 lines across 60 files | ~619 |
|
||||
| #21820 | 10:59 PM | 🔵 | PrivacyCheckValidator used twice in SessionRoutes for observation and summarize endpoints | ~303 |
|
||||
| #21814 | 10:58 PM | 🔵 | PrivacyCheckValidator centralizes user prompt privacy validation | ~359 |
|
||||
| #21807 | 10:49 PM | ⚖️ | KISS Audit Identified 587 Lines of Ceremonial Complexity | ~699 |
|
||||
| #21797 | 10:46 PM | 🔵 | PrivacyCheckValidator Implements Single Validation Method | ~349 |
|
||||
| #21770 | 10:36 PM | 🟣 | Implemented PrivacyCheckValidator for Centralized Privacy Validation | ~318 |
|
||||
|
||||
### Dec 8, 2025
|
||||
|
||||
**PrivacyCheckValidator.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #22274 | 9:22 PM | 🔵 | Event-Driven Architecture for SDK Response Coordination Fully Mapped | ~1136 |
|
||||
| #22270 | 9:12 PM | 🔵 | DRY violations identified in endless-mode-v7.1 branch | ~553 |
|
||||
|
||||
### Dec 9, 2025
|
||||
|
||||
**PrivacyCheckValidator.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #22808 | 2:01 PM | 🔵 | Logger Utility Pattern Identified | ~300 |
|
||||
| #22750 | 1:27 PM | 🔵 | PrivacyCheckValidator Centralizes Privacy Logic | ~450 |
|
||||
|
||||
### Dec 11, 2025
|
||||
|
||||
**PrivacyCheckValidator.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #23962 | 1:59 PM | 🔵 | Services Layer Implements Full Backend Architecture | ~490 |
|
||||
|
||||
### Dec 20, 2025
|
||||
|
||||
**PrivacyCheckValidator.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #30609 | 5:01 PM | 🔄 | Phase 4: Eliminated Over-Engineering in Hook/Worker System | ~504 |
|
||||
| #30598 | 5:00 PM | 🔄 | Removed PrivacyCheckValidator module | ~201 |
|
||||
| #30549 | 4:53 PM | 🔵 | PrivacyCheckValidator for User Prompt Filtering | ~325 |
|
||||
|
||||
### Dec 24, 2025
|
||||
|
||||
**PrivacyCheckValidator.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #32153 | 6:40 PM | 🔵 | Session Identifier Architecture Across Codebase | ~529 |
|
||||
|
||||
### Dec 25, 2025
|
||||
|
||||
**PrivacyCheckValidator.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #32580 | 8:22 PM | 🔵 | Grep for resetStuckMessages and processing | ~242 |
|
||||
|
||||
### Dec 28, 2025
|
||||
|
||||
**PrivacyCheckValidator.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #33439 | 10:15 PM | 🔄 | Extended Session ID Renaming to Additional Codebase Components | ~352 |
|
||||
|
||||
### Jan 2, 2026
|
||||
|
||||
**PrivacyCheckValidator.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #35951 | 4:42 PM | 🔵 | Multi-Layer Service Architecture Discovery | ~395 |
|
||||
</claude-mem-context>
|
||||
@@ -1,8 +1,6 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Nov 10, 2025
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* EnvManager - Centralized environment variable management for claude-mem
|
||||
*
|
||||
* Provides isolated credential storage in ~/.claude-mem/.env
|
||||
* This ensures claude-mem uses its own configured credentials,
|
||||
* not random ANTHROPIC_API_KEY values from project .env files.
|
||||
*
|
||||
* Issue #733: SDK was auto-discovering API keys from user's shell environment,
|
||||
* causing memory operations to bill personal API accounts instead of CLI subscription.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
// Path to claude-mem's centralized .env file
|
||||
const DATA_DIR = join(homedir(), '.claude-mem');
|
||||
export const ENV_FILE_PATH = join(DATA_DIR, '.env');
|
||||
|
||||
// Environment variables to STRIP from subprocess environment (blocklist approach)
|
||||
// Only ANTHROPIC_API_KEY is stripped because it's the specific variable that causes
|
||||
// Issue #733: project .env files set ANTHROPIC_API_KEY which the SDK auto-discovers,
|
||||
// causing memory operations to bill personal API accounts instead of CLI subscription.
|
||||
//
|
||||
// All other env vars (ANTHROPIC_AUTH_TOKEN, ANTHROPIC_BASE_URL, system vars, etc.)
|
||||
// are passed through to avoid breaking CLI authentication, proxies, and platform features.
|
||||
const BLOCKED_ENV_VARS = [
|
||||
'ANTHROPIC_API_KEY', // Issue #733: Prevent auto-discovery from project .env files
|
||||
];
|
||||
|
||||
// Credential keys that claude-mem manages
|
||||
export const MANAGED_CREDENTIAL_KEYS = [
|
||||
'ANTHROPIC_API_KEY',
|
||||
'GEMINI_API_KEY',
|
||||
'OPENROUTER_API_KEY',
|
||||
];
|
||||
|
||||
export interface ClaudeMemEnv {
|
||||
// Credentials (optional - empty means use CLI billing for Claude)
|
||||
ANTHROPIC_API_KEY?: string;
|
||||
GEMINI_API_KEY?: string;
|
||||
OPENROUTER_API_KEY?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a .env file content into key-value pairs
|
||||
*/
|
||||
function parseEnvFile(content: string): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
|
||||
for (const line of content.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
// Skip empty lines and comments
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
|
||||
// Parse KEY=value format
|
||||
const eqIndex = trimmed.indexOf('=');
|
||||
if (eqIndex === -1) continue;
|
||||
|
||||
const key = trimmed.slice(0, eqIndex).trim();
|
||||
let value = trimmed.slice(eqIndex + 1).trim();
|
||||
|
||||
// Remove surrounding quotes if present
|
||||
if ((value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
|
||||
if (key) {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize key-value pairs to .env file format
|
||||
*/
|
||||
function serializeEnvFile(env: Record<string, string>): string {
|
||||
const lines: string[] = [
|
||||
'# claude-mem credentials',
|
||||
'# This file stores API keys for claude-mem memory agent',
|
||||
'# Edit this file or use claude-mem settings to configure',
|
||||
'',
|
||||
];
|
||||
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
if (value) {
|
||||
// Quote values that contain spaces or special characters
|
||||
const needsQuotes = /[\s#=]/.test(value);
|
||||
lines.push(`${key}=${needsQuotes ? `"${value}"` : value}`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n') + '\n';
|
||||
}
|
||||
|
||||
/**
|
||||
* Load credentials from ~/.claude-mem/.env
|
||||
* Returns empty object if file doesn't exist (means use CLI billing)
|
||||
*/
|
||||
export function loadClaudeMemEnv(): ClaudeMemEnv {
|
||||
if (!existsSync(ENV_FILE_PATH)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(ENV_FILE_PATH, 'utf-8');
|
||||
const parsed = parseEnvFile(content);
|
||||
|
||||
// Only return managed credential keys
|
||||
const result: ClaudeMemEnv = {};
|
||||
if (parsed.ANTHROPIC_API_KEY) result.ANTHROPIC_API_KEY = parsed.ANTHROPIC_API_KEY;
|
||||
if (parsed.GEMINI_API_KEY) result.GEMINI_API_KEY = parsed.GEMINI_API_KEY;
|
||||
if (parsed.OPENROUTER_API_KEY) result.OPENROUTER_API_KEY = parsed.OPENROUTER_API_KEY;
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.warn('ENV', 'Failed to load .env file', { path: ENV_FILE_PATH }, error as Error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save credentials to ~/.claude-mem/.env
|
||||
*/
|
||||
export function saveClaudeMemEnv(env: ClaudeMemEnv): void {
|
||||
try {
|
||||
// Ensure directory exists
|
||||
if (!existsSync(DATA_DIR)) {
|
||||
mkdirSync(DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// Load existing to preserve any extra keys
|
||||
const existing = existsSync(ENV_FILE_PATH)
|
||||
? parseEnvFile(readFileSync(ENV_FILE_PATH, 'utf-8'))
|
||||
: {};
|
||||
|
||||
// Update with new values
|
||||
const updated: Record<string, string> = { ...existing };
|
||||
|
||||
// Only update managed keys
|
||||
if (env.ANTHROPIC_API_KEY !== undefined) {
|
||||
if (env.ANTHROPIC_API_KEY) {
|
||||
updated.ANTHROPIC_API_KEY = env.ANTHROPIC_API_KEY;
|
||||
} else {
|
||||
delete updated.ANTHROPIC_API_KEY;
|
||||
}
|
||||
}
|
||||
if (env.GEMINI_API_KEY !== undefined) {
|
||||
if (env.GEMINI_API_KEY) {
|
||||
updated.GEMINI_API_KEY = env.GEMINI_API_KEY;
|
||||
} else {
|
||||
delete updated.GEMINI_API_KEY;
|
||||
}
|
||||
}
|
||||
if (env.OPENROUTER_API_KEY !== undefined) {
|
||||
if (env.OPENROUTER_API_KEY) {
|
||||
updated.OPENROUTER_API_KEY = env.OPENROUTER_API_KEY;
|
||||
} else {
|
||||
delete updated.OPENROUTER_API_KEY;
|
||||
}
|
||||
}
|
||||
|
||||
writeFileSync(ENV_FILE_PATH, serializeEnvFile(updated), 'utf-8');
|
||||
} catch (error) {
|
||||
logger.error('ENV', 'Failed to save .env file', { path: ENV_FILE_PATH }, error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a clean environment for spawning SDK subprocesses
|
||||
*
|
||||
* Uses a BLOCKLIST approach: inherits the full process environment but strips
|
||||
* only ANTHROPIC_API_KEY to prevent Issue #733 (accidental billing from project .env files).
|
||||
*
|
||||
* All other variables pass through, including:
|
||||
* - ANTHROPIC_AUTH_TOKEN (CLI subscription auth)
|
||||
* - ANTHROPIC_BASE_URL (custom proxy endpoints)
|
||||
* - Platform-specific vars (USERPROFILE, XDG_*, etc.)
|
||||
*
|
||||
* If claude-mem has an explicit ANTHROPIC_API_KEY in ~/.claude-mem/.env, it's re-injected
|
||||
* after stripping, so the managed credential takes precedence over any ambient value.
|
||||
*
|
||||
* @param includeCredentials - Whether to include API keys from ~/.claude-mem/.env (default: true)
|
||||
*/
|
||||
export function buildIsolatedEnv(includeCredentials: boolean = true): Record<string, string> {
|
||||
// 1. Start with full process environment
|
||||
const isolatedEnv: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (value !== undefined && !BLOCKED_ENV_VARS.includes(key)) {
|
||||
isolatedEnv[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Override SDK entrypoint marker
|
||||
isolatedEnv.CLAUDE_CODE_ENTRYPOINT = 'sdk-ts';
|
||||
|
||||
// 3. Re-inject managed credentials from claude-mem's .env file
|
||||
if (includeCredentials) {
|
||||
const credentials = loadClaudeMemEnv();
|
||||
|
||||
// Only add ANTHROPIC_API_KEY if explicitly configured in claude-mem
|
||||
// If not configured, CLI billing will be used (via ANTHROPIC_AUTH_TOKEN passthrough)
|
||||
if (credentials.ANTHROPIC_API_KEY) {
|
||||
isolatedEnv.ANTHROPIC_API_KEY = credentials.ANTHROPIC_API_KEY;
|
||||
}
|
||||
// Note: GEMINI_API_KEY and OPENROUTER_API_KEY pass through from process.env,
|
||||
// but claude-mem's .env takes precedence if configured
|
||||
if (credentials.GEMINI_API_KEY) {
|
||||
isolatedEnv.GEMINI_API_KEY = credentials.GEMINI_API_KEY;
|
||||
}
|
||||
if (credentials.OPENROUTER_API_KEY) {
|
||||
isolatedEnv.OPENROUTER_API_KEY = credentials.OPENROUTER_API_KEY;
|
||||
}
|
||||
|
||||
// 4. Pass through Claude CLI's OAuth token if available (fallback for CLI subscription billing)
|
||||
// When no ANTHROPIC_API_KEY is configured, the spawned CLI uses subscription billing
|
||||
// which requires either ~/.claude/.credentials.json or CLAUDE_CODE_OAUTH_TOKEN.
|
||||
// The worker inherits this token from the Claude Code session that started it.
|
||||
if (!isolatedEnv.ANTHROPIC_API_KEY && process.env.CLAUDE_CODE_OAUTH_TOKEN) {
|
||||
isolatedEnv.CLAUDE_CODE_OAUTH_TOKEN = process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||
}
|
||||
}
|
||||
|
||||
return isolatedEnv;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific credential from claude-mem's .env
|
||||
* Returns undefined if not set (which means use default/CLI billing)
|
||||
*/
|
||||
export function getCredential(key: keyof ClaudeMemEnv): string | undefined {
|
||||
const env = loadClaudeMemEnv();
|
||||
return env[key];
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a specific credential in claude-mem's .env
|
||||
* Pass empty string to remove the credential
|
||||
*/
|
||||
export function setCredential(key: keyof ClaudeMemEnv, value: string): void {
|
||||
const env = loadClaudeMemEnv();
|
||||
env[key] = value || undefined;
|
||||
saveClaudeMemEnv(env);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if claude-mem has an Anthropic API key configured
|
||||
* If false, it means CLI billing should be used
|
||||
*/
|
||||
export function hasAnthropicApiKey(): boolean {
|
||||
const env = loadClaudeMemEnv();
|
||||
return !!env.ANTHROPIC_API_KEY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get auth method description for logging
|
||||
*/
|
||||
export function getAuthMethodDescription(): string {
|
||||
if (hasAnthropicApiKey()) {
|
||||
return 'API key (from ~/.claude-mem/.env)';
|
||||
}
|
||||
if (process.env.CLAUDE_CODE_OAUTH_TOKEN) {
|
||||
return 'Claude Code OAuth token (from parent process)';
|
||||
}
|
||||
return 'Claude Code CLI (subscription billing)';
|
||||
}
|
||||
@@ -20,8 +20,9 @@ export interface SettingsDefaults {
|
||||
CLAUDE_MEM_SKIP_TOOLS: string;
|
||||
// AI Provider Configuration
|
||||
CLAUDE_MEM_PROVIDER: string; // 'claude' | 'gemini' | 'openrouter'
|
||||
CLAUDE_MEM_CLAUDE_AUTH_METHOD: string; // 'cli' | 'api' - how Claude provider authenticates
|
||||
CLAUDE_MEM_GEMINI_API_KEY: string;
|
||||
CLAUDE_MEM_GEMINI_MODEL: string; // 'gemini-2.5-flash-lite' | 'gemini-2.5-flash' | 'gemini-3-flash'
|
||||
CLAUDE_MEM_GEMINI_MODEL: string; // 'gemini-2.5-flash-lite' | 'gemini-2.5-flash' | 'gemini-3-flash-preview'
|
||||
CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED: string; // 'true' | 'false' - enable rate limiting for free tier
|
||||
CLAUDE_MEM_OPENROUTER_API_KEY: string;
|
||||
CLAUDE_MEM_OPENROUTER_MODEL: string;
|
||||
@@ -50,6 +51,10 @@ export interface SettingsDefaults {
|
||||
// Feature Toggles
|
||||
CLAUDE_MEM_CONTEXT_SHOW_LAST_SUMMARY: string;
|
||||
CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE: string;
|
||||
CLAUDE_MEM_FOLDER_CLAUDEMD_ENABLED: string;
|
||||
// Exclusion Settings
|
||||
CLAUDE_MEM_EXCLUDED_PROJECTS: string; // Comma-separated glob patterns for excluded project paths
|
||||
CLAUDE_MEM_FOLDER_MD_EXCLUDE: string; // JSON array of folder paths to exclude from CLAUDE.md generation
|
||||
// Chroma Vector Database Configuration
|
||||
CLAUDE_MEM_CHROMA_MODE: string; // 'local' | 'remote'
|
||||
CLAUDE_MEM_CHROMA_HOST: string;
|
||||
@@ -73,6 +78,7 @@ export class SettingsDefaultsManager {
|
||||
CLAUDE_MEM_SKIP_TOOLS: 'ListMcpResourcesTool,SlashCommand,Skill,TodoWrite,AskUserQuestion',
|
||||
// AI Provider Configuration
|
||||
CLAUDE_MEM_PROVIDER: 'claude', // Default to Claude
|
||||
CLAUDE_MEM_CLAUDE_AUTH_METHOD: 'cli', // Default to CLI subscription billing (not API key)
|
||||
CLAUDE_MEM_GEMINI_API_KEY: '', // Empty by default, can be set via UI or env
|
||||
CLAUDE_MEM_GEMINI_MODEL: 'gemini-2.5-flash-lite', // Default Gemini model (highest free tier RPM)
|
||||
CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED: 'true', // Rate limiting ON by default for free tier users
|
||||
@@ -103,6 +109,10 @@ export class SettingsDefaultsManager {
|
||||
// Feature Toggles
|
||||
CLAUDE_MEM_CONTEXT_SHOW_LAST_SUMMARY: 'true',
|
||||
CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE: 'false',
|
||||
CLAUDE_MEM_FOLDER_CLAUDEMD_ENABLED: 'false',
|
||||
// Exclusion Settings
|
||||
CLAUDE_MEM_EXCLUDED_PROJECTS: '', // Comma-separated glob patterns for excluded project paths
|
||||
CLAUDE_MEM_FOLDER_MD_EXCLUDE: '[]', // JSON array of folder paths to exclude from CLAUDE.md generation
|
||||
// Chroma Vector Database Configuration
|
||||
CLAUDE_MEM_CHROMA_MODE: 'local', // 'local' starts npx chroma run, 'remote' connects to existing server
|
||||
CLAUDE_MEM_CHROMA_HOST: '127.0.0.1',
|
||||
@@ -138,16 +148,36 @@ export class SettingsDefaultsManager {
|
||||
|
||||
/**
|
||||
* Get a boolean default value
|
||||
* Handles both string 'true' and boolean true from JSON
|
||||
*/
|
||||
static getBool(key: keyof SettingsDefaults): boolean {
|
||||
const value = this.get(key);
|
||||
return value === 'true';
|
||||
return value === 'true' || value === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply environment variable overrides to settings
|
||||
* Environment variables take highest priority over file and defaults
|
||||
*/
|
||||
private static applyEnvOverrides(settings: SettingsDefaults): SettingsDefaults {
|
||||
const result = { ...settings };
|
||||
for (const key of Object.keys(this.DEFAULTS) as Array<keyof SettingsDefaults>) {
|
||||
if (process.env[key] !== undefined) {
|
||||
result[key] = process.env[key]!;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load settings from file with fallback to defaults
|
||||
* Returns merged settings with defaults as fallback
|
||||
* Handles all errors (missing file, corrupted JSON, permissions) by returning defaults
|
||||
* Returns merged settings with proper priority: process.env > settings file > defaults
|
||||
* Handles all errors (missing file, corrupted JSON, permissions) gracefully
|
||||
*
|
||||
* Configuration Priority:
|
||||
* 1. Environment variables (highest priority)
|
||||
* 2. Settings file (~/.claude-mem/settings.json)
|
||||
* 3. Default values (lowest priority)
|
||||
*/
|
||||
static loadFromFile(settingsPath: string): SettingsDefaults {
|
||||
try {
|
||||
@@ -164,7 +194,8 @@ export class SettingsDefaultsManager {
|
||||
} catch (error) {
|
||||
console.warn('[SETTINGS] Failed to create settings file, using in-memory defaults:', settingsPath, error);
|
||||
}
|
||||
return defaults;
|
||||
// Still apply env var overrides even when file doesn't exist
|
||||
return this.applyEnvOverrides(defaults);
|
||||
}
|
||||
|
||||
const settingsData = readFileSync(settingsPath, 'utf-8');
|
||||
@@ -194,10 +225,12 @@ export class SettingsDefaultsManager {
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
// Apply environment variable overrides (highest priority)
|
||||
return this.applyEnvOverrides(result);
|
||||
} catch (error) {
|
||||
console.warn('[SETTINGS] Failed to load settings, using defaults:', settingsPath, error);
|
||||
return this.getAllDefaults();
|
||||
// Still apply env var overrides even on error
|
||||
return this.applyEnvOverrides(this.getAllDefaults());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
export const HOOK_TIMEOUTS = {
|
||||
DEFAULT: 300000, // Standard HTTP timeout (5 min for slow systems)
|
||||
HEALTH_CHECK: 30000, // Worker health check (30s for slow systems)
|
||||
HEALTH_CHECK: 3000, // Worker health check (3s — healthy worker responds in <100ms)
|
||||
POST_SPAWN_WAIT: 5000, // Wait for daemon to start after spawn (starts in <1s on Linux)
|
||||
PORT_IN_USE_WAIT: 3000, // Wait when port occupied but health failing
|
||||
WORKER_STARTUP_WAIT: 1000,
|
||||
WORKER_STARTUP_RETRIES: 300,
|
||||
PRE_RESTART_SETTLE_DELAY: 2000, // Give files time to sync before restart
|
||||
POWERSHELL_COMMAND: 10000, // PowerShell process enumeration (10s - typically completes in <1s)
|
||||
WINDOWS_MULTIPLIER: 1.5 // Platform-specific adjustment
|
||||
WINDOWS_MULTIPLIER: 1.5 // Platform-specific adjustment for hook-side operations
|
||||
} as const;
|
||||
|
||||
/**
|
||||
@@ -21,6 +22,8 @@ export const HOOK_EXIT_CODES = {
|
||||
FAILURE: 1,
|
||||
/** Blocking error - for SessionStart, shows stderr to user only */
|
||||
BLOCKING_ERROR: 2,
|
||||
/** Show stderr to user only, don't inject into context. Used by user-message handler (Cursor). */
|
||||
USER_MESSAGE_ONLY: 3,
|
||||
} as const;
|
||||
|
||||
export function getTimeout(baseTimeout: number): number {
|
||||
|
||||
@@ -28,6 +28,9 @@ export const DATA_DIR = SettingsDefaultsManager.get('CLAUDE_MEM_DATA_DIR');
|
||||
// Note: CLAUDE_CONFIG_DIR is a Claude Code setting, not claude-mem, so leave as env var
|
||||
export const CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude');
|
||||
|
||||
// Plugin installation directory - respects CLAUDE_CONFIG_DIR for users with custom Claude locations
|
||||
export const MARKETPLACE_ROOT = join(CLAUDE_CONFIG_DIR, 'plugins', 'marketplaces', 'thedotmack');
|
||||
|
||||
// Data subdirectories
|
||||
export const ARCHIVES_DIR = join(DATA_DIR, 'archives');
|
||||
export const LOGS_DIR = join(DATA_DIR, 'logs');
|
||||
@@ -38,6 +41,10 @@ export const USER_SETTINGS_PATH = join(DATA_DIR, 'settings.json');
|
||||
export const DB_PATH = join(DATA_DIR, 'claude-mem.db');
|
||||
export const VECTOR_DB_DIR = join(DATA_DIR, 'vector-db');
|
||||
|
||||
// Observer sessions directory - used as cwd for SDK queries
|
||||
// Sessions here won't appear in user's `claude --resume` for their actual projects
|
||||
export const OBSERVER_SESSIONS_DIR = join(DATA_DIR, 'observer-sessions');
|
||||
|
||||
// Claude integration paths
|
||||
export const CLAUDE_SETTINGS_PATH = join(CLAUDE_CONFIG_DIR, 'settings.json');
|
||||
export const CLAUDE_COMMANDS_DIR = join(CLAUDE_CONFIG_DIR, 'commands');
|
||||
|
||||
@@ -58,7 +58,7 @@ export function extractLastMessage(
|
||||
|
||||
// If we searched the whole transcript and didn't find any message of this role
|
||||
if (!foundMatchingRole) {
|
||||
throw new Error(`No message found for role '${role}' in transcript: ${transcriptPath}`);
|
||||
return '';
|
||||
}
|
||||
|
||||
return '';
|
||||
|
||||
+102
-46
@@ -1,15 +1,45 @@
|
||||
import path from "path";
|
||||
import { homedir } from "os";
|
||||
import { readFileSync } from "fs";
|
||||
import { logger } from "../utils/logger.js";
|
||||
import { HOOK_TIMEOUTS, getTimeout } from "./hook-constants.js";
|
||||
import { SettingsDefaultsManager } from "./SettingsDefaultsManager.js";
|
||||
import { getWorkerRestartInstructions } from "../utils/error-messages.js";
|
||||
|
||||
const MARKETPLACE_ROOT = path.join(homedir(), '.claude', 'plugins', 'marketplaces', 'thedotmack');
|
||||
import { MARKETPLACE_ROOT } from "./paths.js";
|
||||
|
||||
// Named constants for health checks
|
||||
const HEALTH_CHECK_TIMEOUT_MS = getTimeout(HOOK_TIMEOUTS.HEALTH_CHECK);
|
||||
// Allow env var override for users on slow systems (e.g., CLAUDE_MEM_HEALTH_TIMEOUT_MS=10000)
|
||||
const HEALTH_CHECK_TIMEOUT_MS = (() => {
|
||||
const envVal = process.env.CLAUDE_MEM_HEALTH_TIMEOUT_MS;
|
||||
if (envVal) {
|
||||
const parsed = parseInt(envVal, 10);
|
||||
if (Number.isFinite(parsed) && parsed >= 500 && parsed <= 300000) {
|
||||
return parsed;
|
||||
}
|
||||
// Invalid env var — log once and use default
|
||||
logger.warn('SYSTEM', 'Invalid CLAUDE_MEM_HEALTH_TIMEOUT_MS, using default', {
|
||||
value: envVal, min: 500, max: 300000
|
||||
});
|
||||
}
|
||||
return getTimeout(HOOK_TIMEOUTS.HEALTH_CHECK);
|
||||
})();
|
||||
|
||||
/**
|
||||
* Fetch with a timeout using Promise.race instead of AbortSignal.
|
||||
* AbortSignal.timeout() causes a libuv assertion crash in Bun on Windows,
|
||||
* so we use a racing setTimeout pattern that avoids signal cleanup entirely.
|
||||
* The orphaned fetch is harmless since the process exits shortly after.
|
||||
*/
|
||||
export function fetchWithTimeout(url: string, init: RequestInit = {}, timeoutMs: number): Promise<Response> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeoutId = setTimeout(
|
||||
() => reject(new Error(`Request timed out after ${timeoutMs}ms`)),
|
||||
timeoutMs
|
||||
);
|
||||
fetch(url, init).then(
|
||||
response => { clearTimeout(timeoutId); resolve(response); },
|
||||
err => { clearTimeout(timeoutId); reject(err); }
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Cache to avoid repeated settings file reads
|
||||
let cachedPort: number | null = null;
|
||||
@@ -57,23 +87,38 @@ export function clearPortCache(): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if worker is responsive and fully initialized by trying the readiness endpoint
|
||||
* Changed from /health to /api/readiness to ensure MCP initialization is complete
|
||||
* Check if worker HTTP server is responsive
|
||||
* Uses /api/health (liveness) instead of /api/readiness because:
|
||||
* - Hooks have 15-second timeout, but full initialization can take 5+ minutes (MCP connection)
|
||||
* - /api/health returns 200 as soon as HTTP server is up (sufficient for hook communication)
|
||||
* - /api/readiness returns 503 until full initialization completes (too slow for hooks)
|
||||
* See: https://github.com/thedotmack/claude-mem/issues/811
|
||||
*/
|
||||
async function isWorkerHealthy(): Promise<boolean> {
|
||||
const port = getWorkerPort();
|
||||
// Note: Removed AbortSignal.timeout to avoid Windows Bun cleanup issue (libuv assertion)
|
||||
const response = await fetch(`http://127.0.0.1:${port}/api/readiness`);
|
||||
const response = await fetchWithTimeout(
|
||||
`http://127.0.0.1:${port}/api/health`, {}, HEALTH_CHECK_TIMEOUT_MS
|
||||
);
|
||||
return response.ok;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current plugin version from package.json
|
||||
* Get the current plugin version from package.json.
|
||||
* Returns 'unknown' on ENOENT/EBUSY (shutdown race condition, fix #1042).
|
||||
*/
|
||||
function getPluginVersion(): string {
|
||||
const packageJsonPath = path.join(MARKETPLACE_ROOT, 'package.json');
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
|
||||
return packageJson.version;
|
||||
try {
|
||||
const packageJsonPath = path.join(MARKETPLACE_ROOT, 'package.json');
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
|
||||
return packageJson.version;
|
||||
} catch (error: unknown) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code === 'ENOENT' || code === 'EBUSY') {
|
||||
logger.debug('SYSTEM', 'Could not read plugin version (shutdown race)', { code });
|
||||
return 'unknown';
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -81,8 +126,9 @@ function getPluginVersion(): string {
|
||||
*/
|
||||
async function getWorkerVersion(): Promise<string> {
|
||||
const port = getWorkerPort();
|
||||
// Note: Removed AbortSignal.timeout to avoid Windows Bun cleanup issue (libuv assertion)
|
||||
const response = await fetch(`http://127.0.0.1:${port}/api/version`);
|
||||
const response = await fetchWithTimeout(
|
||||
`http://127.0.0.1:${port}/api/version`, {}, HEALTH_CHECK_TIMEOUT_MS
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get worker version: ${response.status}`);
|
||||
}
|
||||
@@ -93,18 +139,33 @@ async function getWorkerVersion(): Promise<string> {
|
||||
/**
|
||||
* Check if worker version matches plugin version
|
||||
* Note: Auto-restart on version mismatch is now handled in worker-service.ts start command (issue #484)
|
||||
* This function logs for informational purposes only
|
||||
* This function logs for informational purposes only.
|
||||
* Skips comparison when either version is 'unknown' (fix #1042 — avoids restart loops).
|
||||
*/
|
||||
async function checkWorkerVersion(): Promise<void> {
|
||||
const pluginVersion = getPluginVersion();
|
||||
const workerVersion = await getWorkerVersion();
|
||||
try {
|
||||
const pluginVersion = getPluginVersion();
|
||||
|
||||
if (pluginVersion !== workerVersion) {
|
||||
// Just log debug info - auto-restart handles the mismatch in worker-service.ts
|
||||
logger.debug('SYSTEM', 'Version check', {
|
||||
pluginVersion,
|
||||
workerVersion,
|
||||
note: 'Mismatch will be auto-restarted by worker-service start command'
|
||||
// Skip version check if plugin version couldn't be read (shutdown race)
|
||||
if (pluginVersion === 'unknown') return;
|
||||
|
||||
const workerVersion = await getWorkerVersion();
|
||||
|
||||
// Skip version check if worker version is 'unknown' (avoids restart loops)
|
||||
if (workerVersion === 'unknown') return;
|
||||
|
||||
if (pluginVersion !== workerVersion) {
|
||||
// Just log debug info - auto-restart handles the mismatch in worker-service.ts
|
||||
logger.debug('SYSTEM', 'Version check', {
|
||||
pluginVersion,
|
||||
workerVersion,
|
||||
note: 'Mismatch will be auto-restarted by worker-service start command'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// Version check is informational — don't fail the hook
|
||||
logger.debug('SYSTEM', 'Version check failed', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -112,30 +173,25 @@ async function checkWorkerVersion(): Promise<void> {
|
||||
|
||||
/**
|
||||
* Ensure worker service is running
|
||||
* Polls until worker is ready (assumes worker-service.cjs start was called by hooks.json)
|
||||
* Quick health check - returns false if worker not healthy (doesn't block)
|
||||
* Port might be in use by another process, or worker might not be started yet
|
||||
*/
|
||||
export async function ensureWorkerRunning(): Promise<void> {
|
||||
const maxRetries = 75; // 15 seconds total
|
||||
const pollInterval = 200;
|
||||
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
try {
|
||||
if (await isWorkerHealthy()) {
|
||||
await checkWorkerVersion(); // logs warning on mismatch, doesn't restart
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
logger.debug('SYSTEM', 'Worker health check failed, will retry', {
|
||||
attempt: i + 1,
|
||||
maxRetries,
|
||||
error: e instanceof Error ? e.message : String(e)
|
||||
});
|
||||
export async function ensureWorkerRunning(): Promise<boolean> {
|
||||
// Quick health check (single attempt, no polling)
|
||||
try {
|
||||
if (await isWorkerHealthy()) {
|
||||
await checkWorkerVersion(); // logs warning on mismatch, doesn't restart
|
||||
return true; // Worker healthy
|
||||
}
|
||||
await new Promise(r => setTimeout(r, pollInterval));
|
||||
} catch (e) {
|
||||
// Not healthy - log for debugging
|
||||
logger.debug('SYSTEM', 'Worker health check failed', {
|
||||
error: e instanceof Error ? e.message : String(e)
|
||||
});
|
||||
}
|
||||
|
||||
throw new Error(getWorkerRestartInstructions({
|
||||
port: getWorkerPort(),
|
||||
customPrefix: 'Worker did not become ready within 15 seconds.'
|
||||
}));
|
||||
// Port might be in use by something else, or worker not started
|
||||
// Return false but don't throw - let caller decide how to handle
|
||||
logger.warn('SYSTEM', 'Worker not healthy, hook will proceed gracefully');
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Nov 21, 2025
|
||||
|
||||
**transcript.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #13598 | 5:39 PM | 🔵 | Full transcript schema structure with all content type definitions revealed | ~493 |
|
||||
| #13593 | 5:38 PM | 🔵 | Transcript schema defines five transcript entry types with comprehensive structures | ~610 |
|
||||
|
||||
### Nov 23, 2025
|
||||
|
||||
**transcript.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #14665 | 7:25 PM | 🔵 | Tool Use ID Now Available in Hook Input Schema | ~395 |
|
||||
| #14613 | 6:07 PM | 🔵 | Transcript Transformation System Analyzed for Test Creation | ~632 |
|
||||
| #14580 | 5:37 PM | 🔵 | AssistantMessage type definition includes all required schema fields | ~395 |
|
||||
|
||||
### Nov 25, 2025
|
||||
|
||||
**transcript.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #15387 | 5:35 PM | 🔵 | Existing Transcript Processing Infrastructure with TypeScript Parser | ~430 |
|
||||
| #15022 | 2:37 PM | ✅ | README.md Updated with Empirically Validated 50-60% Token Reduction | ~336 |
|
||||
| #15003 | 2:28 PM | 🔵 | Comprehensive Transcript Processing Infrastructure | ~309 |
|
||||
|
||||
### Dec 7, 2025
|
||||
|
||||
**database.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #21829 | 11:05 PM | 🔄 | Massive refactor adds 8,671 lines and removes 5,585 lines across 60 files | ~619 |
|
||||
| #21659 | 9:35 PM | 🟣 | Database Type Definitions Created for Type Safety | ~355 |
|
||||
|
||||
### Dec 8, 2025
|
||||
|
||||
**transcript.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #22273 | 9:22 PM | 🔵 | Transcript Modification Flow in Endless Mode Fully Traced | ~1061 |
|
||||
| #22035 | 6:00 PM | 🔵 | Identified tool_use_id tracking across 13 TypeScript modules | ~329 |
|
||||
|
||||
**database.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #22047 | 6:03 PM | 🔵 | Database types define schema interfaces for SQLite query results with better-sqlite3 type safety | ~620 |
|
||||
| #21925 | 4:55 PM | 🔵 | Database Schema Types Review | ~350 |
|
||||
|
||||
### Dec 10, 2025
|
||||
|
||||
**database.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #23734 | 9:03 PM | 🔵 | Database Type Definitions Identified | ~290 |
|
||||
|
||||
### Dec 11, 2025
|
||||
|
||||
**database.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #23959 | 1:58 PM | 🔵 | TypeScript Codebase Architecture Mapped | ~337 |
|
||||
|
||||
### Dec 12, 2025
|
||||
|
||||
**database.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #24394 | 7:24 PM | 🟣 | Phase 6 Agent Completed Git Commit and PR Update | ~444 |
|
||||
| #24391 | " | ✅ | Committed Complete Migration from better-sqlite3 to bun:sqlite | ~436 |
|
||||
| #24390 | 7:23 PM | ✅ | Staged All 19 Modified Files for Git Commit | ~301 |
|
||||
| #24389 | " | 🟣 | Phase 5 Agent Completed Full System Verification | ~726 |
|
||||
| #24388 | 7:22 PM | 🟣 | Phase 5 Complete - All Verification Passed for Production Deployment | ~600 |
|
||||
| #24387 | " | 🔵 | Uncommitted Changes Identified Across Documentation and Core Services | ~368 |
|
||||
|
||||
### Dec 14, 2025
|
||||
|
||||
**database.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #26543 | 10:21 PM | 🔵 | Comprehensive Schema Migration from Legacy to SDK Architecture | ~902 |
|
||||
| #26527 | 10:18 PM | 🔵 | Database Type Definitions with Schema and Record Interfaces | ~564 |
|
||||
| #26523 | 10:17 PM | 🔵 | Shared Database Type Definitions | ~399 |
|
||||
| #25904 | 6:21 PM | 🔵 | Database Type Definitions for SQLite Records | ~396 |
|
||||
|
||||
### Dec 16, 2025
|
||||
|
||||
**transcript.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #28038 | 8:05 PM | 🔵 | Transcript Type Definitions | ~412 |
|
||||
| #28027 | 8:04 PM | 🔵 | Transcript Type Definitions | ~395 |
|
||||
|
||||
### Dec 18, 2025
|
||||
|
||||
**database.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #29773 | 7:01 PM | 🔵 | Observation Type Definitions Across Codebase | ~362 |
|
||||
|
||||
### Dec 20, 2025
|
||||
|
||||
**transcript.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #30785 | 6:01 PM | 🔵 | Transcript Type System Models Claude Code JSONL Structure | ~563 |
|
||||
|
||||
**database.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #30784 | 6:01 PM | 🔵 | Database Type System Defines SQLite Schema Contracts | ~538 |
|
||||
| #30272 | 3:23 PM | 🟣 | Added UserPromptWithContext TypeScript interface | ~230 |
|
||||
|
||||
**bun-sqlite.d.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #30332 | 3:39 PM | 🟣 | Created PR #399 for TypeScript Compilation Fixes | ~293 |
|
||||
| #30330 | 3:38 PM | 🔴 | TypeScript errors fixed across 21 files with type definitions added | ~378 |
|
||||
| #30321 | 3:34 PM | ✅ | Removed Ragtime Script and Staged Bun-SQLite Types | ~229 |
|
||||
| #30315 | 3:33 PM | 🔴 | Comprehensive TypeScript Error Resolution | ~560 |
|
||||
| #30302 | 3:30 PM | 🔴 | Corrected Bun SQLite Transaction Method Signature | ~333 |
|
||||
| #30294 | 3:27 PM | 🔴 | Fixed bun:sqlite type declarations to match actual runtime API | ~313 |
|
||||
| #30268 | 3:22 PM | 🔴 | Added TypeScript declarations for bun:sqlite module | ~276 |
|
||||
|
||||
### Dec 22, 2025
|
||||
|
||||
**mode-config.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #31951 | 8:17 PM | 🟣 | Mode System with Inheritance and Multilingual Support | ~776 |
|
||||
|
||||
### Dec 24, 2025
|
||||
|
||||
**database.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #32158 | 6:42 PM | 🔵 | Database Type Definitions and Identifier Patterns | ~479 |
|
||||
| #32157 | 6:41 PM | 🔵 | Shared Type Definitions Use SDK Session ID | ~300 |
|
||||
|
||||
### Dec 25, 2025
|
||||
|
||||
**database.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #32558 | 8:18 PM | 🔵 | Identified files containing 'summary' or 'Summary' | ~167 |
|
||||
|
||||
### Dec 28, 2025
|
||||
|
||||
**database.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #33438 | 10:15 PM | 🔄 | Bulk Rename Session ID Fields Across Entire Codebase | ~384 |
|
||||
|
||||
### Jan 4, 2026
|
||||
|
||||
**database.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #36824 | 1:00 AM | 🔵 | Database Types Define Canonical Record Interfaces | ~654 |
|
||||
| #36775 | 12:43 AM | 🔵 | Database Record Type Definitions | ~542 |
|
||||
| #36770 | 12:42 AM | 🔵 | Export Script Type Duplication Analysis Complete | ~555 |
|
||||
| #36761 | 12:36 AM | ✅ | Created Implementation Plans for Four GitHub Issues | ~507 |
|
||||
| #36760 | 12:34 AM | ✅ | Created Issue #531 Report: Export Script Type Duplication | ~430 |
|
||||
| #36758 | " | 🔵 | Issue #531 Root Cause - 73 Lines of Duplicated Export Type Definitions | ~529 |
|
||||
</claude-mem-context>
|
||||
@@ -1,39 +0,0 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Dec 25, 2025
|
||||
|
||||
**viewer-template.html**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #32516 | 6:58 PM | 🟣 | Spinning favicon animation during processing | ~347 |
|
||||
|
||||
### Dec 26, 2025
|
||||
|
||||
**viewer-template.html**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #32977 | 11:13 PM | 🔵 | User questions Save button placement in settings modal | ~172 |
|
||||
| #32976 | 11:12 PM | 🔵 | UI button placement questioned in settings modal | ~166 |
|
||||
|
||||
### Jan 1, 2026
|
||||
|
||||
**viewer-template.html**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #35609 | 10:53 PM | ✅ | Removed logs-btn from Mobile Responsive Styles | ~221 |
|
||||
| #35608 | " | ✅ | Removed logs-icon Class from Icon Sizing Styles | ~173 |
|
||||
| #35607 | " | ✅ | Removed Legacy Logs Button Styles from CSS | ~217 |
|
||||
| #35606 | " | 🟣 | Complete CSS Implementation for Chrome DevTools-Style Console Drawer | ~650 |
|
||||
|
||||
### Jan 2, 2026
|
||||
|
||||
**viewer-template.html**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #35901 | 2:49 PM | 🔵 | PR #525 File Changes Summary | ~376 |
|
||||
| #35878 | 2:40 PM | 🔵 | Console Drawer Styling and Structure in HTML Template | ~531 |
|
||||
| #35814 | 2:25 PM | 🟣 | Console Filter Bar and Log Line Styling Added | ~328 |
|
||||
</claude-mem-context>
|
||||
@@ -1,138 +0,0 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Dec 15, 2025
|
||||
|
||||
**App.tsx**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #27281 | 11:00 PM | 🟣 | Queue Monitoring System Extracted to Separate Branch | ~452 |
|
||||
| #27280 | 10:59 PM | 🔵 | Queue Infrastructure Changes Staged in Branch | ~313 |
|
||||
| #27270 | 10:57 PM | ✅ | Extracted additional queue integration files from PR-335 | ~271 |
|
||||
|
||||
### Dec 16, 2025
|
||||
|
||||
**App.tsx**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #28084 | 8:11 PM | 🔵 | Viewer App Component Architecture | ~461 |
|
||||
| #28076 | 8:10 PM | 🔵 | Viewer App Component Architecture | ~477 |
|
||||
|
||||
### Dec 18, 2025
|
||||
|
||||
**types.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #29773 | 7:01 PM | 🔵 | Observation Type Definitions Across Codebase | ~362 |
|
||||
|
||||
### Dec 20, 2025
|
||||
|
||||
**types.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #30330 | 3:38 PM | 🔴 | TypeScript errors fixed across 21 files with type definitions added | ~378 |
|
||||
| #30288 | 3:26 PM | 🔴 | Added queueDepth property to StreamEvent interface | ~229 |
|
||||
| #30287 | " | 🔵 | Complete type definitions for viewer data models and events | ~409 |
|
||||
| #30286 | " | 🔵 | StreamEvent interface defined in viewer types | ~188 |
|
||||
|
||||
### Dec 21, 2025
|
||||
|
||||
**types.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #31422 | 6:50 PM | 🔵 | Observation Metadata Constants Usage Across Codebase | ~366 |
|
||||
| #31329 | 5:45 PM | 🔵 | Observation Metadata Integration Across Services and UI | ~403 |
|
||||
|
||||
### Dec 22, 2025
|
||||
|
||||
**App.tsx**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #31790 | 4:39 PM | 🔵 | Identified files that interact with ModeManager and prompts | ~332 |
|
||||
|
||||
### Dec 24, 2025
|
||||
|
||||
**types.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #32156 | 6:41 PM | 🔵 | UI Layer Session Identifier Usage | ~256 |
|
||||
|
||||
### Dec 25, 2025
|
||||
|
||||
**types.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #32789 | 9:49 PM | 🟣 | Gemini AI Provider Integration Merged to Main | ~409 |
|
||||
| #32640 | 8:46 PM | 🟣 | Renamed "Billing Enabled" setting to "Rate Limiting" with inverted logic | ~546 |
|
||||
| #32627 | 8:44 PM | ✅ | Renamed billing setting in UI `Settings` interface | ~214 |
|
||||
| #32621 | " | ✅ | Added `CLAUDE_MEM_GEMINI_RATE_LIMITING_ENABLED` to UI Settings interface | ~214 |
|
||||
| #32614 | 8:43 PM | 🔵 | Confirmed absence of billing setting in UI `Settings` interface | ~233 |
|
||||
| #32605 | 8:42 PM | 🔵 | Identified UI `Settings` interface and absence of specific billing toggle | ~265 |
|
||||
| #32603 | " | 🔵 | Identified widespread use of "Gemini" across application components | ~312 |
|
||||
| #32580 | 8:22 PM | 🔵 | Grep for resetStuckMessages and processing | ~242 |
|
||||
| #32559 | 8:18 PM | 🔵 | Listed files changed in the current branch | ~169 |
|
||||
| #32558 | " | 🔵 | Identified files containing 'summary' or 'Summary' | ~167 |
|
||||
|
||||
### Dec 26, 2025
|
||||
|
||||
**types.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #32925 | 10:26 PM | 🔵 | OpenRouter Provider Integration Proposed in PR 448 | ~543 |
|
||||
| #32923 | 10:18 PM | 🔵 | OpenRouter Integration Architecture in claude-mem | ~610 |
|
||||
| #32913 | 10:05 PM | 🔵 | PR #448 Code Review: OpenRouter Provider Integration | ~523 |
|
||||
| #32910 | 10:01 PM | 🔵 | Viewer types define UI data structures with SSE event types and unified feed items | ~501 |
|
||||
|
||||
### Dec 28, 2025
|
||||
|
||||
**types.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #33439 | 10:15 PM | 🔄 | Extended Session ID Renaming to Additional Codebase Components | ~352 |
|
||||
|
||||
### Jan 1, 2026
|
||||
|
||||
**App.tsx**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #35605 | 10:52 PM | 🟣 | Added Floating Console Toggle Button | ~339 |
|
||||
| #35604 | " | ✅ | Removed onLogsToggle Prop from Header Invocation | ~231 |
|
||||
| #35600 | 10:51 PM | ✅ | JSX Component Usage Updated to LogsDrawer | ~231 |
|
||||
| #35599 | " | ✅ | App Component Import Updated for LogsDrawer | ~206 |
|
||||
|
||||
### Jan 2, 2026
|
||||
|
||||
**App.tsx**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #35875 | 2:39 PM | 🔵 | Logging UI Architecture Mapped | ~599 |
|
||||
| #35819 | 2:28 PM | 🔵 | LogsDrawer component integrated in App.tsx | ~235 |
|
||||
|
||||
**types.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #35824 | 2:29 PM | 🔵 | Type definitions for viewer data models | ~267 |
|
||||
|
||||
### Jan 4, 2026
|
||||
|
||||
**types.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #36757 | 12:33 AM | 🔵 | Issue #511 Root Cause Identified - Gemini-3-Flash Configuration Mismatch | ~416 |
|
||||
| #36754 | " | 🔵 | Gemini-3-Flash Model Already Supported | ~301 |
|
||||
|
||||
### Jan 5, 2026
|
||||
|
||||
**CLAUDE.md**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #38078 | 9:54 PM | ✅ | CLAUDE.md Documentation Cleanup - 1,233 Lines Removed Across 18 Files | ~590 |
|
||||
|
||||
**types.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #37995 | 9:01 PM | 🔵 | CLAUDE_MEM_WORKER_HOST setting implementation pattern | ~304 |
|
||||
| #37990 | 9:00 PM | 🔵 | CLAUDE_MEM_WORKER_HOST setting used across 19 files | ~289 |
|
||||
</claude-mem-context>
|
||||
@@ -1,117 +0,0 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Dec 25, 2025
|
||||
|
||||
**ContextSettingsModal.tsx**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #32789 | 9:49 PM | 🟣 | Gemini AI Provider Integration Merged to Main | ~409 |
|
||||
| #32640 | 8:46 PM | 🟣 | Renamed "Billing Enabled" setting to "Rate Limiting" with inverted logic | ~546 |
|
||||
| #32630 | 8:45 PM | ✅ | Updated UI toggle for rate limiting in `ContextSettingsModal` | ~345 |
|
||||
| #32622 | 8:44 PM | ✅ | Updated UI toggle for rate limiting in `ContextSettingsModal` | ~375 |
|
||||
| #32615 | 8:43 PM | 🔵 | Confirmed UI text for "Billing Enabled" toggle | ~244 |
|
||||
| #32613 | " | 🔵 | Comprehensive identification of `CLAUDE_MEM_GEMINI_BILLING_ENABLED` usage | ~407 |
|
||||
| #32608 | 8:42 PM | 🔵 | Identified UI component for "Billing Enabled" toggle | ~348 |
|
||||
| #32604 | " | 🔵 | Identified "billing" setting and its direct link to rate limiting | ~405 |
|
||||
| #32603 | " | 🔵 | Identified widespread use of "Gemini" across application components | ~312 |
|
||||
| #32559 | 8:18 PM | 🔵 | Listed files changed in the current branch | ~169 |
|
||||
| #32550 | 7:31 PM | 🟣 | Added Gemini Billing Enabled toggle in UI | ~189 |
|
||||
|
||||
### Dec 26, 2025
|
||||
|
||||
**ContextSettingsModal.tsx**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #32977 | 11:13 PM | 🔵 | User questions Save button placement in settings modal | ~172 |
|
||||
| #32976 | 11:12 PM | 🔵 | UI button placement questioned in settings modal | ~166 |
|
||||
| #32978 | 11:04 PM | 🔵 | ContextSettingsModal component structure examined | ~199 |
|
||||
| #32979 | " | 🔵 | Save button implemented in modal footer section | ~195 |
|
||||
| #32980 | " | 🔄 | Save button moved inside modal body structure | ~182 |
|
||||
| #32925 | 10:26 PM | 🔵 | OpenRouter Provider Integration Proposed in PR 448 | ~543 |
|
||||
| #32923 | 10:18 PM | 🔵 | OpenRouter Integration Architecture in claude-mem | ~610 |
|
||||
| #32922 | 10:17 PM | 🔵 | OpenRouter Integration in Settings UI | ~362 |
|
||||
| #32913 | 10:05 PM | 🔵 | PR #448 Code Review: OpenRouter Provider Integration | ~523 |
|
||||
| #32909 | 10:01 PM | 🔵 | ContextSettingsModal provides rich UI for settings with live preview and provider configuration | ~611 |
|
||||
|
||||
### Dec 28, 2025
|
||||
|
||||
**ContextSettingsModal.tsx**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #33279 | 3:07 PM | ✅ | Changed Default OpenRouter Model to Free Tier Option | ~285 |
|
||||
|
||||
### Dec 30, 2025
|
||||
|
||||
**ContextSettingsModal.tsx**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #34384 | 1:39 PM | 🔵 | UI Provides "haiku" as Model Selection Option | ~296 |
|
||||
|
||||
### Jan 1, 2026
|
||||
|
||||
**LogsModal.tsx**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #35668 | 11:40 PM | ✅ | Main branch updated with major feature additions | ~377 |
|
||||
| #35598 | 10:51 PM | 🔄 | Logs UI Refactored from Modal to Bottom Drawer | ~505 |
|
||||
| #35597 | 10:29 PM | 🟣 | Logs Viewer Modal Component Created | ~299 |
|
||||
|
||||
**Header.tsx**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #35603 | 10:52 PM | 🟣 | Removed Logs Button from Header UI | ~274 |
|
||||
| #35602 | " | ✅ | Removed onLogsToggle from Header Destructuring | ~215 |
|
||||
| #35601 | " | 🔄 | Removed onLogsToggle Prop from Header Interface | ~191 |
|
||||
|
||||
### Jan 2, 2026
|
||||
|
||||
**LogsModal.tsx**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #35999 | 5:32 PM | 🟣 | Alignment Quick Filter Added to Console Logs UI | ~308 |
|
||||
| #35982 | 5:09 PM | ✅ | Built and deployed claude-mem version 8.5.4 with LogsModal UI component | ~295 |
|
||||
| #35981 | 5:08 PM | 🟣 | Implemented alignment quick filter UI in LogsModal | ~281 |
|
||||
| #35980 | " | 🟣 | Alignment-only filter added to LogsModal | ~323 |
|
||||
| #35979 | " | 🟣 | Added alignment filter state to LogsModal component | ~219 |
|
||||
| #35978 | " | 🔵 | LogsModal viewer component with filtering and parsing capabilities | ~447 |
|
||||
| #35904 | 2:49 PM | 🟣 | Implemented Structured Log Parsing in LogsModal | ~400 |
|
||||
| #35901 | " | 🔵 | PR #525 File Changes Summary | ~376 |
|
||||
| #35879 | 2:41 PM | 🟣 | Enhanced LogsModal with Parsing, Filtering, Colors, Icons, and Auto-Scroll | ~726 |
|
||||
| #35877 | 2:40 PM | 🔵 | LogsDrawer Component Current Implementation | ~426 |
|
||||
| #35820 | 2:28 PM | 🔵 | Current LogsDrawer implementation analyzed | ~289 |
|
||||
|
||||
**Header.tsx**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #35875 | 2:39 PM | 🔵 | Logging UI Architecture Mapped | ~599 |
|
||||
| #35839 | 2:30 PM | 🔵 | Header component with project filter dropdown | ~245 |
|
||||
|
||||
**SummaryCard.tsx**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #35834 | 2:30 PM | 🔵 | SummaryCard demonstrates icon usage pattern | ~281 |
|
||||
|
||||
**Feed.tsx**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #35832 | 2:29 PM | 🔵 | Feed component with intersection observer for infinite scroll | ~300 |
|
||||
|
||||
**ObservationCard.tsx**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #35828 | 2:29 PM | 🔵 | ObservationCard styling and interaction patterns | ~275 |
|
||||
|
||||
### Jan 4, 2026
|
||||
|
||||
**ContextSettingsModal.tsx**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #36781 | 12:45 AM | 🔵 | Complete GeminiAgent Model Configuration Gap Analysis | ~552 |
|
||||
| #36773 | 12:43 AM | 🔵 | Gemini Model Options in Settings UI | ~490 |
|
||||
| #36757 | 12:33 AM | 🔵 | Issue #511 Root Cause Identified - Gemini-3-Flash Configuration Mismatch | ~416 |
|
||||
| #36756 | " | 🔵 | UI Component Also Lists gemini-3-flash | ~339 |
|
||||
| #36754 | " | 🔵 | Gemini-3-Flash Model Already Supported | ~301 |
|
||||
</claude-mem-context>
|
||||
@@ -465,7 +465,7 @@ export function ContextSettingsModal({
|
||||
>
|
||||
<option value="gemini-2.5-flash-lite">gemini-2.5-flash-lite (10 RPM free)</option>
|
||||
<option value="gemini-2.5-flash">gemini-2.5-flash (5 RPM free)</option>
|
||||
<option value="gemini-3-flash">gemini-3-flash (5 RPM free)</option>
|
||||
<option value="gemini-3-flash-preview">gemini-3-flash-preview (5 RPM free)</option>
|
||||
</select>
|
||||
</FormField>
|
||||
<div className="toggle-group" style={{ marginTop: '8px' }}>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useMemo, useRef, useLayoutEffect, useState } from 'react';
|
||||
import AnsiToHtml from 'ansi-to-html';
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
interface TerminalPreviewProps {
|
||||
content: string;
|
||||
@@ -26,7 +27,12 @@ export function TerminalPreview({ content, isLoading = false, className = '' }:
|
||||
scrollTopRef.current = preRef.current.scrollTop;
|
||||
}
|
||||
if (!content) return '';
|
||||
return ansiConverter.toHtml(content);
|
||||
const convertedHtml = ansiConverter.toHtml(content);
|
||||
return DOMPurify.sanitize(convertedHtml, {
|
||||
ALLOWED_TAGS: ['span', 'div', 'br'],
|
||||
ALLOWED_ATTR: ['style', 'class'],
|
||||
ALLOW_DATA_ATTR: false
|
||||
});
|
||||
}, [content]);
|
||||
|
||||
// Restore scroll position after render
|
||||
|
||||
@@ -1,197 +1,9 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Nov 17, 2025
|
||||
|
||||
**api.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #10372 | 3:32 PM | 🔵 | Existing API endpoint naming conventions in api.ts constants | ~340 |
|
||||
|
||||
### Nov 21, 2025
|
||||
|
||||
**ui.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #13110 | 1:05 AM | 🔵 | Magic Numbers Identified Across Codebase | ~327 |
|
||||
|
||||
### Nov 22, 2025
|
||||
|
||||
**settings.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #14203 | 1:05 AM | 🔵 | Endless Mode Feature Branch Contains Major Additions | ~566 |
|
||||
|
||||
### Nov 23, 2025
|
||||
|
||||
**api.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #14555 | 5:21 PM | 🔵 | Missing Stats Fields Causing UI Errors | ~288 |
|
||||
|
||||
### Nov 26, 2025
|
||||
|
||||
**settings.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #16173 | 10:06 PM | 🔵 | Configuration System Analysis for Domain Profile Support | ~621 |
|
||||
|
||||
### Dec 2, 2025
|
||||
|
||||
**api.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #19210 | 5:57 PM | 🔵 | Viewer UI Technology Stack Assessment | ~534 |
|
||||
|
||||
### Dec 7, 2025
|
||||
|
||||
**settings.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #21733 | 10:10 PM | 🔵 | UI Viewer Contains Duplicate DEFAULT_SETTINGS Object | ~375 |
|
||||
| #21686 | 9:49 PM | 🔵 | DRY Audit Reveals 600+ Lines of Duplicated Code in Worker Service | ~605 |
|
||||
| #21685 | 9:48 PM | 🔵 | Configuration Defaults and Environment Variables | ~558 |
|
||||
|
||||
### Dec 9, 2025
|
||||
|
||||
**settings.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #22637 | 11:46 AM | 🔵 | Comprehensive inventory of CLAUDE_MEM_ environment variable usage across codebase | ~619 |
|
||||
| #22606 | 11:33 AM | 🔵 | Hardcoded port 37777 found across multiple codebase locations | ~371 |
|
||||
|
||||
### Dec 11, 2025
|
||||
|
||||
**settings.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #23959 | 1:58 PM | 🔵 | TypeScript Codebase Architecture Mapped | ~337 |
|
||||
| #23947 | 1:40 PM | 🔵 | Comprehensive Port Configuration Audit Complete | ~532 |
|
||||
| #23940 | 1:38 PM | 🔵 | UI Constants Duplicate Settings Defaults | ~357 |
|
||||
| #23935 | 1:37 PM | 🔵 | CLAUDE_MEM_WORKER_PORT Environment Variable Usage Pattern | ~456 |
|
||||
| #23933 | 1:36 PM | 🔵 | Comprehensive Port 37777 References Across Documentation and Code | ~427 |
|
||||
|
||||
### Dec 12, 2025
|
||||
|
||||
**settings.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #24602 | 10:20 PM | 🟣 | Completed PR #236 Security Fix Implementation | ~463 |
|
||||
| #24596 | 10:18 PM | 🟣 | Merged Localhost-Only Binding Security Feature to Main | ~413 |
|
||||
|
||||
### Dec 13, 2025
|
||||
|
||||
**settings.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #25075 | 7:09 PM | ⚖️ | Complete System Analysis for Embedding Function Configuration | ~497 |
|
||||
| #25074 | " | 🔵 | Complete Settings System Architecture via Exploration Agent | ~532 |
|
||||
| #25045 | 7:02 PM | 🔵 | UI Viewer Settings Constants | ~296 |
|
||||
|
||||
### Dec 14, 2025
|
||||
|
||||
**settings.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #26202 | 8:17 PM | ✅ | UI viewer constants updated to match Sonnet default model | ~301 |
|
||||
| #26199 | " | 🔵 | UI viewer default settings configuration examined | ~358 |
|
||||
| #26198 | " | 🔵 | CLAUDE_MEM_MODEL configuration found throughout codebase | ~403 |
|
||||
|
||||
**api.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #26088 | 7:32 PM | 🔵 | API Endpoint Architecture Discovery | ~416 |
|
||||
|
||||
### Dec 16, 2025
|
||||
|
||||
**api.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #28083 | 8:10 PM | 🔵 | API Endpoint Constants | ~360 |
|
||||
|
||||
**settings.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #27992 | 8:01 PM | 🔵 | UI Default Settings Constants | ~376 |
|
||||
|
||||
### Dec 18, 2025
|
||||
|
||||
**settings.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #29229 | 12:08 AM | 🔵 | Claude-Mem Observation Type System Architecture Mapped | ~552 |
|
||||
| #29226 | 12:06 AM | 🔵 | Default Settings Configuration | ~395 |
|
||||
|
||||
### Dec 21, 2025
|
||||
|
||||
**settings.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #31422 | 6:50 PM | 🔵 | Observation Metadata Constants Usage Across Codebase | ~366 |
|
||||
| #31329 | 5:45 PM | 🔵 | Observation Metadata Integration Across Services and UI | ~403 |
|
||||
|
||||
### Dec 25, 2025
|
||||
|
||||
**settings.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #32789 | 9:49 PM | 🟣 | Gemini AI Provider Integration Merged to Main | ~409 |
|
||||
| #32640 | 8:46 PM | 🟣 | Renamed "Billing Enabled" setting to "Rate Limiting" with inverted logic | ~546 |
|
||||
| #32620 | 8:44 PM | ✅ | Renamed billing setting and updated default in UI constants | ~233 |
|
||||
| #32613 | 8:43 PM | 🔵 | Comprehensive identification of `CLAUDE_MEM_GEMINI_BILLING_ENABLED` usage | ~407 |
|
||||
| #32606 | 8:42 PM | 🔵 | Default value for `CLAUDE_MEM_GEMINI_BILLING_ENABLED` identified | ~216 |
|
||||
| #32603 | " | 🔵 | Identified widespread use of "Gemini" across application components | ~312 |
|
||||
| #32602 | " | 🔵 | Identified potential settings configuration files | ~224 |
|
||||
| #32559 | 8:18 PM | 🔵 | Listed files changed in the current branch | ~169 |
|
||||
|
||||
**api.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #32580 | 8:22 PM | 🔵 | Grep for resetStuckMessages and processing | ~242 |
|
||||
|
||||
### Dec 26, 2025
|
||||
|
||||
**settings.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #32925 | 10:26 PM | 🔵 | OpenRouter Provider Integration Proposed in PR 448 | ~543 |
|
||||
| #32923 | 10:18 PM | 🔵 | OpenRouter Integration Architecture in claude-mem | ~610 |
|
||||
| #32921 | 10:16 PM | ✅ | Updated UI default settings for OpenRouter model | ~214 |
|
||||
| #32913 | 10:05 PM | 🔵 | PR #448 Code Review: OpenRouter Provider Integration | ~523 |
|
||||
|
||||
### Dec 28, 2025
|
||||
|
||||
**settings.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #33279 | 3:07 PM | ✅ | Changed Default OpenRouter Model to Free Tier Option | ~285 |
|
||||
|
||||
### Jan 2, 2026
|
||||
|
||||
**ui.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #35822 | 2:29 PM | 🔵 | UI constants structure examined | ~191 |
|
||||
|
||||
### Jan 4, 2026
|
||||
|
||||
**settings.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #36781 | 12:45 AM | 🔵 | Complete GeminiAgent Model Configuration Gap Analysis | ~552 |
|
||||
|
||||
### Jan 5, 2026
|
||||
|
||||
**CLAUDE.md**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #38078 | 9:54 PM | ✅ | CLAUDE.md Documentation Cleanup - 1,233 Lines Removed Across 18 Files | ~590 |
|
||||
|
||||
**settings.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #37995 | 9:01 PM | 🔵 | CLAUDE_MEM_WORKER_HOST setting implementation pattern | ~304 |
|
||||
| #37990 | 9:00 PM | 🔵 | CLAUDE_MEM_WORKER_HOST setting used across 19 files | ~289 |
|
||||
| #32982 | 11:04 PM | 🔵 | Read default settings configuration file | ~233 |
|
||||
</claude-mem-context>
|
||||
@@ -36,4 +36,8 @@ export const DEFAULT_SETTINGS = {
|
||||
// Feature Toggles
|
||||
CLAUDE_MEM_CONTEXT_SHOW_LAST_SUMMARY: 'true',
|
||||
CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE: 'false',
|
||||
|
||||
// Exclusion Settings
|
||||
CLAUDE_MEM_EXCLUDED_PROJECTS: '',
|
||||
CLAUDE_MEM_FOLDER_MD_EXCLUDE: '[]',
|
||||
} as const;
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Dec 13, 2025
|
||||
|
||||
**useSettings.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #25321 | 9:12 PM | 🔵 | Console.error Usage Found in 29 Files | ~366 |
|
||||
| #25075 | 7:09 PM | ⚖️ | Complete System Analysis for Embedding Function Configuration | ~497 |
|
||||
| #25059 | 7:05 PM | 🔵 | UI Settings Hook Pattern | ~320 |
|
||||
|
||||
**useGitHubStars.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #25319 | 9:12 PM | 🔵 | Error Throw Locations Identified Across Codebase | ~302 |
|
||||
|
||||
### Dec 14, 2025
|
||||
|
||||
**useSettings.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #26198 | 8:17 PM | 🔵 | CLAUDE_MEM_MODEL configuration found throughout codebase | ~403 |
|
||||
|
||||
### Dec 15, 2025
|
||||
|
||||
**useNotifications.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #27281 | 11:00 PM | 🟣 | Queue Monitoring System Extracted to Separate Branch | ~452 |
|
||||
| #27280 | 10:59 PM | 🔵 | Queue Infrastructure Changes Staged in Branch | ~313 |
|
||||
| #27279 | " | ✅ | Queue UI Components Built Successfully | ~277 |
|
||||
|
||||
**useQueue.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #27272 | 10:58 PM | ✅ | Queue UI Components Extracted to Working Directory | ~235 |
|
||||
| #27263 | 10:55 PM | ✅ | Extracted queue-specific files from PR-335 | ~284 |
|
||||
| #27247 | 10:34 PM | 🔵 | useQueue Hook API Integration | ~370 |
|
||||
|
||||
**useSSE.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #27248 | 10:34 PM | 🔵 | Server-Sent Events Hook for Real-Time Queue Updates | ~406 |
|
||||
|
||||
### Dec 19, 2025
|
||||
|
||||
**useContextPreview.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #29947 | 7:03 PM | 🔵 | Context injection endpoint usage across system | ~387 |
|
||||
|
||||
### Dec 20, 2025
|
||||
|
||||
**useContextPreview.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #30330 | 3:38 PM | 🔴 | TypeScript errors fixed across 21 files with type definitions added | ~378 |
|
||||
| #30278 | 3:24 PM | 🔴 | Added TypeScript type assertion for projects API response | ~205 |
|
||||
|
||||
**usePagination.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #30312 | 3:32 PM | 🔴 | Added explicit type arguments to usePaginationFor calls | ~314 |
|
||||
| #30311 | " | 🔴 | Updated JSON response type assertion to use generic type | ~317 |
|
||||
| #30310 | 3:31 PM | 🔴 | Added generic type parameter to usePaginationFor hook | ~356 |
|
||||
|
||||
**useStats.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #30291 | 3:26 PM | 🔴 | Added TypeScript type assertion for stats API response | ~239 |
|
||||
| #30290 | " | 🔵 | useStats hook fetches worker and database statistics | ~288 |
|
||||
|
||||
**useSSE.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #30289 | 3:26 PM | 🔴 | Added non-null assertion for data.observation in new_observation handler | ~264 |
|
||||
| #30285 | 3:25 PM | 🔵 | useSSE hook manages EventSource connection with auto-reconnect capability | ~282 |
|
||||
| #30284 | " | 🔵 | useSSE hook handles multiple server-sent event types | ~288 |
|
||||
|
||||
**useSettings.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #30283 | 3:25 PM | 🔴 | Added TypeScript type assertion for saveSettings API response | ~249 |
|
||||
| #30282 | " | 🔴 | Added TypeScript type annotation for settings API response | ~82 |
|
||||
| #30281 | " | 🔵 | useSettings hook lacks type safety for API responses | ~245 |
|
||||
|
||||
**useGitHubStars.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #30280 | 3:25 PM | ✅ | Standardized TypeScript type assertion syntax in useGitHubStars | ~204 |
|
||||
| #30279 | 3:24 PM | 🔵 | useGitHubStars hook already has proper TypeScript typing | ~249 |
|
||||
|
||||
### Dec 21, 2025
|
||||
|
||||
**useContextPreview.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #31603 | 8:21 PM | 🔵 | Complete Console.* Statement Audit Across Codebase | ~813 |
|
||||
| #31599 | 8:19 PM | 🔵 | 136 console logging statements found in TypeScript source files | ~538 |
|
||||
|
||||
**useSettings.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #31422 | 6:50 PM | 🔵 | Observation Metadata Constants Usage Across Codebase | ~366 |
|
||||
| #31329 | 5:45 PM | 🔵 | Observation Metadata Integration Across Services and UI | ~403 |
|
||||
|
||||
### Dec 22, 2025
|
||||
|
||||
**useXFollowers.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #31969 | 9:32 PM | 🟣 | X (Twitter) Follower Count Hook Created | ~210 |
|
||||
|
||||
### Dec 25, 2025
|
||||
|
||||
**useSettings.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #32789 | 9:49 PM | 🟣 | Gemini AI Provider Integration Merged to Main | ~409 |
|
||||
| #32559 | 8:18 PM | 🔵 | Listed files changed in the current branch | ~169 |
|
||||
|
||||
**useSpinningFavicon.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #32646 | 8:48 PM | 🔵 | Existing Spinning Favicon Implementation | ~297 |
|
||||
| #32516 | 6:58 PM | 🟣 | Spinning favicon animation during processing | ~347 |
|
||||
|
||||
**useSSE.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #32580 | 8:22 PM | 🔵 | Grep for resetStuckMessages and processing | ~242 |
|
||||
| #32558 | 8:18 PM | 🔵 | Identified files containing 'summary' or 'Summary' | ~167 |
|
||||
|
||||
### Jan 1, 2026
|
||||
|
||||
**useContextPreview.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #35688 | 11:49 PM | 🔵 | Context Preview Hook with Project Selection | ~472 |
|
||||
|
||||
**useSettings.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #35686 | 11:49 PM | 🔵 | Settings Management Hook Implementation | ~529 |
|
||||
| #35485 | 9:06 PM | ⚖️ | Comprehensive error handling remediation plan completed and submitted for approval | ~555 |
|
||||
| #35469 | 9:02 PM | 🔵 | Proper error handling in settings save function | ~268 |
|
||||
|
||||
**useSSE.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #35684 | 11:49 PM | 🔵 | Server-Sent Events Hook Implementation | ~484 |
|
||||
|
||||
**usePagination.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #35683 | 11:48 PM | 🔵 | Pagination Hook Implementation Structure | ~439 |
|
||||
|
||||
### Jan 2, 2026
|
||||
|
||||
**usePagination.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #35875 | 2:39 PM | 🔵 | Logging UI Architecture Mapped | ~599 |
|
||||
| #35838 | 2:30 PM | 🔵 | Pagination hook pattern with offset tracking and filter reset | ~272 |
|
||||
|
||||
### Jan 5, 2026
|
||||
|
||||
**CLAUDE.md**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #38078 | 9:54 PM | ✅ | CLAUDE.md Documentation Cleanup - 1,233 Lines Removed Across 18 Files | ~590 |
|
||||
|
||||
**useSettings.ts**
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #37995 | 9:01 PM | 🔵 | CLAUDE_MEM_WORKER_HOST setting implementation pattern | ~304 |
|
||||
| #37990 | 9:00 PM | 🔵 | CLAUDE_MEM_WORKER_HOST setting used across 19 files | ~289 |
|
||||
</claude-mem-context>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user