feat: Mode system with inheritance and multilingual support (#412)

* feat: add domain management system with support for multiple domain profiles

- Introduced DomainManager class for loading and managing domain profiles.
- Added support for a default domain ('code') and fallback mechanisms.
- Implemented domain configuration validation and error handling.
- Created types for domain configuration, observation types, and concepts.
- Added new directory for domain profiles and ensured its existence.
- Updated SettingsDefaultsManager to include CLAUDE_MEM_DOMAIN setting.

* Refactor domain management to mode management

- Removed DomainManager class and replaced it with ModeManager for better clarity and functionality.
- Updated types from DomainConfig to ModeConfig and DomainPrompts to ModePrompts.
- Changed references from domains to modes in the settings and paths.
- Ensured backward compatibility by maintaining the fallback mechanism to the 'code' mode.

* feat: add migration 008 to support mode-agnostic observations and refactor service layer references in documentation

* feat: add new modes for code development and email investigation with detailed observation types and concepts

* Refactor observation parsing and prompt generation to incorporate mode-specific configurations

- Updated `parseObservations` function to use 'observation' as a universal fallback type instead of 'change', utilizing active mode's valid observation types.
- Modified `buildInitPrompt` and `buildContinuationPrompt` functions to accept a `ModeConfig` parameter, allowing for dynamic prompt content based on the active mode.
- Enhanced `ModePrompts` interface to include additional guidance for observers, such as recording focus and skip guidance.
- Adjusted the SDKAgent to load the active mode and pass it to prompt generation functions, ensuring prompts are tailored to the current mode's context.

* fix: correct mode prompt injection to preserve exact wording and type list visibility

- Add script to extract prompts from main branch prompts.ts into code.yaml
- Fix prompts.ts to show type list in XML template (e.g., "[ bugfix | feature | ... ]")
- Keep 'change' as fallback type in parser.ts (maintain backwards compatibility)
- Regenerate code.yaml with exact wording from original hardcoded prompts
- Build succeeds with no TypeScript errors

* fix: update ModeManager to load JSON mode files and improve validation

- Changed ModeManager to load mode configurations from JSON files instead of YAML.
- Removed the requirement for an "observation" type and updated validation to require at least one observation type.
- Updated fallback behavior in the parser to use the first type from the active mode's type list.
- Added comprehensive tests for mode loading, prompt injection, and parser integration, ensuring correct behavior across different modes.
- Introduced new mode JSON files for "Code Development" and "Email Investigation" with detailed observation types and prompts.

* Add mode configuration loading and update licensing information for Ragtime

- Implemented loading of mode configuration in WorkerService before database initialization.
- Added PolyForm Noncommercial License 1.0.0 to Ragtime directory.
- Created README.md for Ragtime with licensing details and usage guidelines.

* fix: add datasets directory to .gitignore to prevent accidental commits

* refactor: remove unused plugin package.json file

* chore: add package.json for claude-mem plugin with version 7.4.5

* refactor: remove outdated tests and improve error handling

- Deleted tests for ChromaSync error handling, smart install, strip memory tags, and user prompt tag stripping due to redundancy or outdated logic.
- Removed vitest configuration as it is no longer needed.
- Added a comprehensive implementation plan for fixing the modes system, addressing critical issues and improving functionality.
- Created a detailed test analysis report highlighting the quality and effectiveness of the current test suite, identifying areas for improvement.
- Introduced a new plugin package.json for runtime dependencies related to claude-mem hooks.

* refactor: remove parser regression tests to streamline codebase

* docs: update CLAUDE.md to clarify test management and changelog generation

* refactor: remove migration008 for mode-agnostic observations

* Refactor observation type handling to use ModeManager for icons and emojis

- Removed direct mappings of observation types to icons and work emojis in context-generator, FormattingService, SearchManager, and TimelineService.
- Integrated ModeManager to dynamically retrieve icons and emojis based on the active mode.
- Improved maintainability by centralizing the logic for observation type representation.

* Refactor observation metadata constants and update context generator

- Removed the explicit declaration of OBSERVATION_TYPES and OBSERVATION_CONCEPTS from observation-metadata.ts.
- Introduced fallback default strings for DEFAULT_OBSERVATION_TYPES_STRING and DEFAULT_OBSERVATION_CONCEPTS_STRING.
- Updated context-generator.ts to utilize observation types and concepts from ModeManager instead of constants.

* refactor: remove intermediate error handling from hooks (Phase 1)

Apply "fail fast" error handling strategy - errors propagate and crash loud
instead of being caught, wrapped, and re-thrown at intermediate layers.

Changes:
- Remove try/catch around fetch calls in all hooks - let errors throw
- Add try/catch ONLY around JSON.parse at entry points
- Delete error-handler.ts and hook-error-handler.ts (no longer needed)
- Update worker-utils.ts: functions now throw instead of returning null
- Update transcript-parser.ts: throw on missing path, empty file, malformed JSON
- Remove all handleWorkerError, handleFetchError imports

Philosophy: If something breaks, we KNOW it broke. No silent failures.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: remove intermediate error handling from worker service (Phase 2)

Apply "fail fast" error handling strategy to worker service layer.

Changes:
- worker-service.ts: Remove try/catch from version endpoint, cleanup,
  MCP close, process enumeration, force kill, and isAlive check
- SessionRoutes.ts: Remove try/catch from JSON.stringify calls, remove
  .catch() from Chroma sync and SDK agent calls
- SettingsRoutes.ts: Remove try/catch from toggleMcp()
- DatabaseManager.ts: Remove .catch() from backfill and close operations
- SDKAgent.ts: Keep outer try/catch (top-level), remove .catch() from
  Chroma sync operations
- SSEBroadcaster.ts: Remove try/catch from broadcast and sendToClient

Philosophy: Errors propagate and crash loud. BaseRouteHandler.wrapHandler
provides top-level catching for HTTP routes.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: remove error swallowing from SQLite services (Phase 3)

Apply "fail fast" error handling strategy to database layer.

SessionStore.ts migrations:
- ensureWorkerPortColumn(): Remove outer try/catch, let it throw
- ensurePromptTrackingColumns(): Remove outer try/catch, let it throw
- removeSessionSummariesUniqueConstraint(): Keep inner transaction
  rollback, remove outer catch
- addObservationHierarchicalFields(): Remove outer try/catch
- makeObservationsTextNullable(): Keep inner transaction rollback,
  remove outer catch
- createUserPromptsTable(): Keep inner transaction rollback, remove
  outer catch
- getFilesForSession(): Remove try/catch around JSON.parse

SessionSearch.ts:
- ensureFTSTables(): Remove try/catch, let it throw

Philosophy: Migration errors that are swallowed mean we think the
database is fine when it's not. Keep only inner transaction rollback
try/catch blocks.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: remove error hiding from utilities (Phase 4)

Apply "fail fast" error handling strategy to utility layer.

logger.ts:
- formatTool(): Remove try/catch, let JSON.parse throw on malformed input

context-generator.ts:
- loadContextConfig(): Remove try/catch, let parseInt throw on invalid settings
- Transcript extraction: Remove try/catch, let file read errors propagate

ChromaSync.ts:
- close(): Remove nested try/catch blocks, let close errors propagate

Philosophy: No silent fallbacks or hidden defaults. If something breaks,
we know it broke immediately.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: serve static UI assets and update package root path

- Added middleware to serve static UI assets (JS, CSS, fonts, etc.) in ViewerRoutes.
- Updated getPackageRoot function to correctly return the package root directory as one level up from the current directory.

* feat: Enhance mode loading with inheritance support

- Introduced parseInheritance method to handle parent--override mode IDs.
- Added deepMerge method for recursively merging mode configurations.
- Updated loadMode method to support inheritance, loading parent modes and applying overrides.
- Improved error handling for missing mode files and logging for better traceability.

* fix(modes): correct inheritance file resolution and path handling

* Refactor code structure for improved readability and maintainability

* feat: Add mode configuration documentation and examples

* fix: Improve concurrency handling in translateReadme function

* Refactor SDK prompts to enhance clarity and structure

- Updated the `buildInitPrompt` and `buildContinuationPrompt` functions in `prompts.ts` to improve the organization of prompt components, including the addition of language instructions and footer messages.
- Removed redundant instructions and emphasized the importance of recording observations.
- Modified the `ModePrompts` interface in `types.ts` to include new properties for system identity, language instructions, and output format header, ensuring better flexibility and clarity in prompt generation.

* Enhance prompts with language instructions and XML formatting

- Updated `buildInitPrompt`, `buildSummaryPrompt`, and `buildContinuationPrompt` functions to include detailed language instructions in XML comments.
- Ensured that language instructions guide users to keep XML tags in English while writing content in the specified language.
- Modified the `buildSummaryPrompt` function to accept `mode` as a parameter for consistency.
- Adjusted the call to `buildSummaryPrompt` in `SDKAgent` to pass the `mode` argument.

* Refactor XML prompt generation in SDK

- Updated the buildInitPrompt, buildSummaryPrompt, and buildContinuationPrompt functions to use new placeholders for XML elements, improving maintainability and readability.
- Removed redundant language instructions in comments for clarity.
- Added new properties to ModePrompts interface for better structure and organization of XML placeholders and section headers.

* feat: Update observation prompts and structure across multiple languages

* chore: Remove planning docs and update Ragtime README

Remove ephemeral development artifacts:
- .claude/plans/modes-system-fixes.md
- .claude/test-analysis-report.md
- PROMPT_INJECTION_ANALYSIS.md

Update ragtime/README.md to explain:
- Feature is not yet implemented
- Dependency on modes system (now complete in PR #412)
- Ready to be scripted out in future release

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix: Move summary prompts to mode files for multilingual support

Summary prompts were hardcoded in English in prompts.ts, breaking
multilingual support. Now properly mode-based:

- Added summary_instruction, summary_context_label,
  summary_format_instruction, summary_footer to code.json
- Updated buildSummaryPrompt() to use mode fields instead of hardcoded text
- Added summary_footer with language instructions to all 10 language modes
- Language modes keep English prompts + language requirement footer

This fixes the gaslighting where we claimed full multilingual support
but summaries were still generated in English.

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* chore: Clean up README by removing local preview instructions and streamlining beta features section

* Add translated README files for Ukrainian, Vietnamese, and Chinese languages

* Add new language modes for code development in multiple languages

- Introduced JSON configurations for Code Development in Greek, Finnish, Hebrew, Hindi, Hungarian, Indonesian, Italian, Dutch, Norwegian, Polish, Brazilian Portuguese, Romanian, Swedish, Turkish, and Ukrainian.
- Each configuration includes prompts for observations, summaries, and instructions tailored to the respective language.
- Ensured that all prompts emphasize the importance of generating observations without referencing the agent's actions.

* Add multilingual support links to README files in various languages

- Updated README.id.md, README.it.md, README.ja.md, README.ko.md, README.nl.md, README.no.md, README.pl.md, README.pt-br.md, README.ro.md, README.ru.md, README.sv.md, README.th.md, README.tr.md, README.uk.md, README.vi.md, and README.zh.md to include links to other language versions.
- Each README now features a centered paragraph with flags and links for easy navigation between different language documents.

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Alex Newman
2025-12-22 20:14:18 -05:00
committed by GitHub
parent db02da148f
commit 3ea0b60b9f
141 changed files with 11834 additions and 6699 deletions
+35 -61
View File
@@ -9,12 +9,6 @@ import path from 'path';
import { homedir } from 'os';
import { existsSync, readFileSync, unlinkSync } from 'fs';
import { SessionStore } from './sqlite/SessionStore.js';
import {
OBSERVATION_TYPES,
OBSERVATION_CONCEPTS,
TYPE_ICON_MAP,
TYPE_WORK_EMOJI_MAP
} from '../constants/observation-metadata.js';
import { logger } from '../utils/logger.js';
import { SettingsDefaultsManager } from '../shared/SettingsDefaultsManager.js';
import {
@@ -26,6 +20,7 @@ import {
extractFirstFile
} from '../shared/timeline-formatting.js';
import { getProjectName } from '../utils/project-name.js';
import { ModeManager } from './domain/ModeManager.js';
// Version marker path - use homedir-based path that works in both CJS and ESM contexts
const VERSION_MARKER_PATH = path.join(homedir(), '.claude', 'plugins', 'marketplaces', 'thedotmack', 'plugin', '.install-version');
@@ -60,43 +55,24 @@ function loadContextConfig(): ContextConfig {
const settingsPath = path.join(homedir(), '.claude-mem', 'settings.json');
const settings = SettingsDefaultsManager.loadFromFile(settingsPath);
try {
return {
totalObservationCount: parseInt(settings.CLAUDE_MEM_CONTEXT_OBSERVATIONS, 10),
fullObservationCount: parseInt(settings.CLAUDE_MEM_CONTEXT_FULL_COUNT, 10),
sessionCount: parseInt(settings.CLAUDE_MEM_CONTEXT_SESSION_COUNT, 10),
showReadTokens: settings.CLAUDE_MEM_CONTEXT_SHOW_READ_TOKENS === 'true',
showWorkTokens: settings.CLAUDE_MEM_CONTEXT_SHOW_WORK_TOKENS === 'true',
showSavingsAmount: settings.CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_AMOUNT === 'true',
showSavingsPercent: settings.CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_PERCENT === 'true',
observationTypes: new Set(
settings.CLAUDE_MEM_CONTEXT_OBSERVATION_TYPES.split(',').map((t: string) => t.trim()).filter(Boolean)
),
observationConcepts: new Set(
settings.CLAUDE_MEM_CONTEXT_OBSERVATION_CONCEPTS.split(',').map((c: string) => c.trim()).filter(Boolean)
),
fullObservationField: settings.CLAUDE_MEM_CONTEXT_FULL_FIELD as 'narrative' | 'facts',
showLastSummary: settings.CLAUDE_MEM_CONTEXT_SHOW_LAST_SUMMARY === 'true',
showLastMessage: settings.CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE === 'true',
};
} catch (error) {
logger.warn('WORKER', 'Failed to load context settings, using defaults', {}, error as Error);
// Return defaults on error
return {
totalObservationCount: 50,
fullObservationCount: 5,
sessionCount: 10,
showReadTokens: true,
showWorkTokens: true,
showSavingsAmount: true,
showSavingsPercent: true,
observationTypes: new Set(OBSERVATION_TYPES),
observationConcepts: new Set(OBSERVATION_CONCEPTS),
fullObservationField: 'narrative' as const,
showLastSummary: true,
showLastMessage: false,
};
}
return {
totalObservationCount: parseInt(settings.CLAUDE_MEM_CONTEXT_OBSERVATIONS, 10),
fullObservationCount: parseInt(settings.CLAUDE_MEM_CONTEXT_FULL_COUNT, 10),
sessionCount: parseInt(settings.CLAUDE_MEM_CONTEXT_SESSION_COUNT, 10),
showReadTokens: settings.CLAUDE_MEM_CONTEXT_SHOW_READ_TOKENS === 'true',
showWorkTokens: settings.CLAUDE_MEM_CONTEXT_SHOW_WORK_TOKENS === 'true',
showSavingsAmount: settings.CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_AMOUNT === 'true',
showSavingsPercent: settings.CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_PERCENT === 'true',
observationTypes: new Set(
settings.CLAUDE_MEM_CONTEXT_OBSERVATION_TYPES.split(',').map((t: string) => t.trim()).filter(Boolean)
),
observationConcepts: new Set(
settings.CLAUDE_MEM_CONTEXT_OBSERVATION_CONCEPTS.split(',').map((c: string) => c.trim()).filter(Boolean)
),
fullObservationField: settings.CLAUDE_MEM_CONTEXT_FULL_FIELD as 'narrative' | 'facts',
showLastSummary: settings.CLAUDE_MEM_CONTEXT_SHOW_LAST_SUMMARY === 'true',
showLastMessage: settings.CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE === 'true',
};
}
// Configuration constants
@@ -280,20 +256,16 @@ export async function generateContext(input?: ContextInput, useColors: boolean =
let priorAssistantMessage = '';
if (config.showLastMessage && observations.length > 0) {
try {
const currentSessionId = input?.session_id;
const priorSessionObs = observations.find(obs => obs.sdk_session_id !== currentSessionId);
const currentSessionId = input?.session_id;
const priorSessionObs = observations.find(obs => obs.sdk_session_id !== currentSessionId);
if (priorSessionObs) {
const priorSessionId = priorSessionObs.sdk_session_id;
const dashedCwd = cwdToDashed(cwd);
const transcriptPath = path.join(homedir(), '.claude', 'projects', dashedCwd, `${priorSessionId}.jsonl`);
const messages = extractPriorMessages(transcriptPath);
priorUserMessage = messages.userMessage;
priorAssistantMessage = messages.assistantMessage;
}
} catch (error) {
// Expected: Transcript file may not exist or be readable
if (priorSessionObs) {
const priorSessionId = priorSessionObs.sdk_session_id;
const dashedCwd = cwdToDashed(cwd);
const transcriptPath = path.join(homedir(), '.claude', 'projects', dashedCwd, `${priorSessionId}.jsonl`);
const messages = extractPriorMessages(transcriptPath);
priorUserMessage = messages.userMessage;
priorAssistantMessage = messages.assistantMessage;
}
}
@@ -325,11 +297,13 @@ export async function generateContext(input?: ContextInput, useColors: boolean =
// Chronological Timeline
if (timelineObs.length > 0) {
// Legend
// Legend - generate dynamically from active mode
const mode = ModeManager.getInstance().getActiveMode();
const typeLegendItems = mode.observation_types.map(t => `${t.emoji} ${t.id}`).join(' | ');
if (useColors) {
output.push(`${colors.dim}Legend: 🎯 session-request | 🔴 bugfix | 🟣 feature | 🔄 refactor | ✅ change | 🔵 discovery | ⚖️ decision${colors.reset}`);
output.push(`${colors.dim}Legend: 🎯 session-request | ${typeLegendItems}${colors.reset}`);
} else {
output.push(`**Legend:** 🎯 session-request | 🔴 bugfix | 🟣 feature | 🔄 refactor | ✅ change | 🔵 discovery | ⚖️ decision`);
output.push(`**Legend:** 🎯 session-request | ${typeLegendItems}`);
}
output.push('');
@@ -536,7 +510,7 @@ export async function generateContext(input?: ContextInput, useColors: boolean =
const time = formatTime(obs.created_at);
const title = obs.title || 'Untitled';
const icon = TYPE_ICON_MAP[obs.type as keyof typeof TYPE_ICON_MAP] || '•';
const icon = ModeManager.getInstance().getTypeIcon(obs.type);
const obsSize = (obs.title?.length || 0) +
(obs.subtitle?.length || 0) +
@@ -544,7 +518,7 @@ export async function generateContext(input?: ContextInput, useColors: boolean =
JSON.stringify(obs.facts || []).length;
const readTokens = Math.ceil(obsSize / CHARS_PER_TOKEN_ESTIMATE);
const discoveryTokens = obs.discovery_tokens || 0;
const workEmoji = TYPE_WORK_EMOJI_MAP[obs.type as keyof typeof TYPE_WORK_EMOJI_MAP] || '🔍';
const workEmoji = ModeManager.getInstance().getWorkEmoji(obs.type);
const discoveryDisplay = discoveryTokens > 0 ? `${workEmoji} ${discoveryTokens.toLocaleString()}` : '-';
const showTime = time !== lastTime;
+254
View File
@@ -0,0 +1,254 @@
/**
* ModeManager - Singleton for loading and managing mode profiles
*
* Mode profiles define observation types, concepts, and prompts for different use cases.
* Default mode is 'code' (software development). Other modes like 'email-investigation'
* can be selected via CLAUDE_MEM_MODE setting.
*/
import { readFileSync, existsSync } from 'fs';
import { join } from 'path';
import type { ModeConfig, ObservationType, ObservationConcept } from './types.js';
import { logger } from '../../utils/logger.js';
import { getPackageRoot } from '../../shared/paths.js';
export class ModeManager {
private static instance: ModeManager | null = null;
private activeMode: ModeConfig | null = null;
private modesDir: string;
private constructor() {
// Modes are in plugin/modes/
// getPackageRoot() points to plugin/ in production and src/ in development
// We want to ensure we find the modes directory which is at the project root/plugin/modes
const packageRoot = getPackageRoot();
// Check for plugin/modes relative to package root (covers both dev and prod if paths are right)
const possiblePaths = [
join(packageRoot, 'modes'), // Production (plugin/modes)
join(packageRoot, '..', 'plugin', 'modes'), // Development (src/../plugin/modes)
];
const foundPath = possiblePaths.find(p => existsSync(p));
this.modesDir = foundPath || possiblePaths[0];
}
/**
* Get singleton instance
*/
static getInstance(): ModeManager {
if (!ModeManager.instance) {
ModeManager.instance = new ModeManager();
}
return ModeManager.instance;
}
/**
* Parse mode ID for inheritance pattern (parent--override)
*/
private parseInheritance(modeId: string): {
hasParent: boolean;
parentId: string;
overrideId: string;
} {
const parts = modeId.split('--');
if (parts.length === 1) {
return { hasParent: false, parentId: '', overrideId: '' };
}
// Support only one level: code--ko, not code--ko--verbose
if (parts.length > 2) {
throw new Error(
`Invalid mode inheritance: ${modeId}. Only one level of inheritance supported (parent--override)`
);
}
return {
hasParent: true,
parentId: parts[0],
overrideId: modeId // Use the full modeId (e.g., code--es) to find the override file
};
}
/**
* Check if value is a plain object (not array, not null)
*/
private isPlainObject(value: unknown): boolean {
return (
value !== null &&
typeof value === 'object' &&
!Array.isArray(value)
);
}
/**
* Deep merge two objects
* - Recursively merge nested objects
* - Replace arrays completely (no merging)
* - Override primitives
*/
private deepMerge<T>(base: T, override: Partial<T>): T {
const result = { ...base } as T;
for (const key in override) {
const overrideValue = override[key];
const baseValue = base[key];
if (this.isPlainObject(overrideValue) && this.isPlainObject(baseValue)) {
// Recursively merge nested objects
result[key] = this.deepMerge(baseValue, overrideValue as any);
} else {
// Replace arrays and primitives completely
result[key] = overrideValue as T[Extract<keyof T, string>];
}
}
return result;
}
/**
* Load a mode file from disk without inheritance processing
*/
private loadModeFile(modeId: string): ModeConfig {
const modePath = join(this.modesDir, `${modeId}.json`);
if (!existsSync(modePath)) {
throw new Error(`Mode file not found: ${modePath}`);
}
const jsonContent = readFileSync(modePath, 'utf-8');
return JSON.parse(jsonContent) as ModeConfig;
}
/**
* Load a mode profile by ID with inheritance support
* Caches the result for subsequent calls
*
* Supports inheritance via parent--override pattern (e.g., code--ko)
* - Loads parent mode recursively
* - Loads override file from modes directory
* - Deep merges override onto parent
*/
loadMode(modeId: string): ModeConfig {
const inheritance = this.parseInheritance(modeId);
// No inheritance - load file directly (existing behavior)
if (!inheritance.hasParent) {
try {
const mode = this.loadModeFile(modeId);
this.activeMode = mode;
logger.debug('SYSTEM', `Loaded mode: ${mode.name} (${modeId})`, undefined, {
types: mode.observation_types.map(t => t.id),
concepts: mode.observation_concepts.map(c => c.id)
});
return mode;
} catch (error) {
logger.warn('SYSTEM', `Mode file not found: ${modeId}, falling back to 'code'`);
// If we're already trying to load 'code', throw to prevent infinite recursion
if (modeId === 'code') {
throw new Error('Critical: code.json mode file missing');
}
return this.loadMode('code');
}
}
// Has inheritance - load parent and merge with override
const { parentId, overrideId } = inheritance;
// Load parent mode recursively
let parentMode: ModeConfig;
try {
parentMode = this.loadMode(parentId);
} catch (error) {
logger.warn('SYSTEM', `Parent mode '${parentId}' not found for ${modeId}, falling back to 'code'`);
parentMode = this.loadMode('code');
}
// Load override file
let overrideConfig: Partial<ModeConfig>;
try {
overrideConfig = this.loadModeFile(overrideId);
logger.debug('SYSTEM', `Loaded override file: ${overrideId} for parent ${parentId}`);
} catch (error) {
logger.warn('SYSTEM', `Override file '${overrideId}' not found, using parent mode '${parentId}' only`);
this.activeMode = parentMode;
return parentMode;
}
// Validate override file loaded successfully
if (!overrideConfig) {
logger.warn('SYSTEM', `Invalid override file: ${overrideId}, using parent mode '${parentId}' only`);
this.activeMode = parentMode;
return parentMode;
}
// Deep merge override onto parent
const mergedMode = this.deepMerge(parentMode, overrideConfig);
this.activeMode = mergedMode;
logger.debug('SYSTEM', `Loaded mode with inheritance: ${mergedMode.name} (${modeId} = ${parentId} + ${overrideId})`, undefined, {
parent: parentId,
override: overrideId,
types: mergedMode.observation_types.map(t => t.id),
concepts: mergedMode.observation_concepts.map(c => c.id)
});
return mergedMode;
}
/**
* Get currently active mode
*/
getActiveMode(): ModeConfig {
if (!this.activeMode) {
throw new Error('No mode loaded. Call loadMode() first.');
}
return this.activeMode;
}
/**
* Get all observation types from active mode
*/
getObservationTypes(): ObservationType[] {
return this.getActiveMode().observation_types;
}
/**
* Get all observation concepts from active mode
*/
getObservationConcepts(): ObservationConcept[] {
return this.getActiveMode().observation_concepts;
}
/**
* Get icon for a specific observation type
*/
getTypeIcon(typeId: string): string {
const type = this.getObservationTypes().find(t => t.id === typeId);
return type?.emoji || '📝';
}
/**
* Get work emoji for a specific observation type
*/
getWorkEmoji(typeId: string): string {
const type = this.getObservationTypes().find(t => t.id === typeId);
return type?.work_emoji || '📝';
}
/**
* Validate that a type ID exists in the active mode
*/
validateType(typeId: string): boolean {
return this.getObservationTypes().some(t => t.id === typeId);
}
/**
* Get label for a specific observation type
*/
getTypeLabel(typeId: string): string {
const type = this.getObservationTypes().find(t => t.id === typeId);
return type?.label || typeId;
}
}
+72
View File
@@ -0,0 +1,72 @@
/**
* TypeScript interfaces for mode configuration system
*/
export interface ObservationType {
id: string;
label: string;
description: string;
emoji: string;
work_emoji: string;
}
export interface ObservationConcept {
id: string;
label: string;
description: string;
}
export interface ModePrompts {
system_identity: string; // Base persona and role definition
language_instruction?: string; // Optional language constraints (e.g., "Write in Korean")
spatial_awareness: string; // Working directory context guidance
observer_role: string; // What the observer's job is in this mode
recording_focus: string; // What to record and how to think about it
skip_guidance: string; // What to skip recording
type_guidance: string; // Valid observation types for this mode
concept_guidance: string; // Valid concept categories for this mode
field_guidance: string; // Guidance for facts/files fields
output_format_header: string; // Text introducing the XML schema
format_examples: string; // Optional additional XML examples (empty string if not needed)
footer: string; // Closing instructions and encouragement
// Observation XML placeholders
xml_title_placeholder: string; // e.g., "[**title**: Short title capturing the core action or topic]"
xml_subtitle_placeholder: string; // e.g., "[**subtitle**: One sentence explanation (max 24 words)]"
xml_fact_placeholder: string; // e.g., "[Concise, self-contained statement]"
xml_narrative_placeholder: string; // e.g., "[**narrative**: Full context: What was done, how it works, why it matters]"
xml_concept_placeholder: string; // e.g., "[knowledge-type-category]"
xml_file_placeholder: string; // e.g., "[path/to/file]"
// Summary XML placeholders
xml_summary_request_placeholder: string; // e.g., "[Short title capturing the user's request AND...]"
xml_summary_investigated_placeholder: string; // e.g., "[What has been explored so far? What was examined?]"
xml_summary_learned_placeholder: string; // e.g., "[What have you learned about how things work?]"
xml_summary_completed_placeholder: string; // e.g., "[What work has been completed so far? What has shipped or changed?]"
xml_summary_next_steps_placeholder: string; // e.g., "[What are you actively working on or planning to work on next in this session?]"
xml_summary_notes_placeholder: string; // e.g., "[Additional insights or observations about the current progress]"
// Section headers (with separator lines)
header_memory_start: string; // e.g., "MEMORY PROCESSING START\n======================="
header_memory_continued: string; // e.g., "MEMORY PROCESSING CONTINUED\n==========================="
header_summary_checkpoint: string; // e.g., "PROGRESS SUMMARY CHECKPOINT\n==========================="
// Continuation prompts
continuation_greeting: string; // e.g., "Hello memory agent, you are continuing to observe the primary Claude session."
continuation_instruction: string; // e.g., "IMPORTANT: Continue generating observations from tool use messages using the XML structure below."
// Summary prompts
summary_instruction: string; // Instructions for writing progress summary
summary_context_label: string; // Label for Claude's response section (e.g., "Claude's Full Response to User:")
summary_format_instruction: string; // Instruction to use XML format (e.g., "Respond in this XML format:")
summary_footer: string; // Footer with closing instructions and language requirement
}
export interface ModeConfig {
name: string;
description: string;
version: string;
observation_types: ObservationType[];
observation_concepts: ObservationConcept[];
prompts: ModePrompts;
}
+92 -96
View File
@@ -50,104 +50,100 @@ export class SessionSearch {
* TODO: Remove FTS5 infrastructure in future major version (v7.0.0)
*/
private ensureFTSTables(): void {
try {
// Check if FTS tables already exist
const tables = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE '%_fts'").all() as TableNameRow[];
const hasFTS = tables.some(t => t.name === 'observations_fts' || t.name === 'session_summaries_fts');
// Check if FTS tables already exist
const tables = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE '%_fts'").all() as TableNameRow[];
const hasFTS = tables.some(t => t.name === 'observations_fts' || t.name === 'session_summaries_fts');
if (hasFTS) {
// Already migrated
return;
}
console.log('[SessionSearch] Creating FTS5 tables...');
// Create observations_fts virtual table
this.db.run(`
CREATE VIRTUAL TABLE IF NOT EXISTS observations_fts USING fts5(
title,
subtitle,
narrative,
text,
facts,
concepts,
content='observations',
content_rowid='id'
);
`);
// Populate with existing data
this.db.run(`
INSERT INTO observations_fts(rowid, title, subtitle, narrative, text, facts, concepts)
SELECT id, title, subtitle, narrative, text, facts, concepts
FROM observations;
`);
// Create triggers for observations
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;
`);
// Create session_summaries_fts virtual table
this.db.run(`
CREATE VIRTUAL TABLE IF NOT EXISTS session_summaries_fts USING fts5(
request,
investigated,
learned,
completed,
next_steps,
notes,
content='session_summaries',
content_rowid='id'
);
`);
// Populate with existing data
this.db.run(`
INSERT INTO session_summaries_fts(rowid, request, investigated, learned, completed, next_steps, notes)
SELECT id, request, investigated, learned, completed, next_steps, notes
FROM session_summaries;
`);
// Create triggers for session_summaries
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;
`);
console.log('[SessionSearch] FTS5 tables created successfully');
} catch (error: any) {
console.error('[SessionSearch] FTS migration error:', error.message);
if (hasFTS) {
// Already migrated
return;
}
console.log('[SessionSearch] Creating FTS5 tables...');
// Create observations_fts virtual table
this.db.run(`
CREATE VIRTUAL TABLE IF NOT EXISTS observations_fts USING fts5(
title,
subtitle,
narrative,
text,
facts,
concepts,
content='observations',
content_rowid='id'
);
`);
// Populate with existing data
this.db.run(`
INSERT INTO observations_fts(rowid, title, subtitle, narrative, text, facts, concepts)
SELECT id, title, subtitle, narrative, text, facts, concepts
FROM observations;
`);
// Create triggers for observations
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;
`);
// Create session_summaries_fts virtual table
this.db.run(`
CREATE VIRTUAL TABLE IF NOT EXISTS session_summaries_fts USING fts5(
request,
investigated,
learned,
completed,
next_steps,
notes,
content='session_summaries',
content_rowid='id'
);
`);
// Populate with existing data
this.db.run(`
INSERT INTO session_summaries_fts(rowid, request, investigated, learned, completed, next_steps, notes)
SELECT id, request, investigated, learned, completed, next_steps, notes
FROM session_summaries;
`);
// Create triggers for session_summaries
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;
`);
console.log('[SessionSearch] FTS5 tables created successfully');
}
+273 -305
View File
@@ -144,152 +144,140 @@ export class SessionStore {
* Ensure worker_port column exists (migration 5)
*/
private ensureWorkerPortColumn(): void {
try {
// Check if migration already applied
const applied = this.db.prepare('SELECT version FROM schema_versions WHERE version = ?').get(5) as SchemaVersion | undefined;
if (applied) return;
// Check if migration already applied
const applied = this.db.prepare('SELECT version FROM schema_versions WHERE version = ?').get(5) as SchemaVersion | undefined;
if (applied) return;
// Check if column exists
const tableInfo = this.db.query('PRAGMA table_info(sdk_sessions)').all() as TableColumnInfo[];
const hasWorkerPort = tableInfo.some(col => col.name === 'worker_port');
// Check if column exists
const tableInfo = this.db.query('PRAGMA table_info(sdk_sessions)').all() as TableColumnInfo[];
const hasWorkerPort = tableInfo.some(col => col.name === 'worker_port');
if (!hasWorkerPort) {
this.db.run('ALTER TABLE sdk_sessions ADD COLUMN worker_port INTEGER');
console.log('[SessionStore] Added worker_port column to sdk_sessions table');
}
// Record migration
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(5, new Date().toISOString());
} catch (error: any) {
console.error('[SessionStore] Migration error:', error.message);
if (!hasWorkerPort) {
this.db.run('ALTER TABLE sdk_sessions ADD COLUMN worker_port INTEGER');
console.log('[SessionStore] Added worker_port column to sdk_sessions table');
}
// Record migration
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(5, new Date().toISOString());
}
/**
* Ensure prompt tracking columns exist (migration 6)
*/
private ensurePromptTrackingColumns(): void {
try {
// Check if migration already applied
const applied = this.db.prepare('SELECT version FROM schema_versions WHERE version = ?').get(6) as SchemaVersion | undefined;
if (applied) return;
// Check if migration already applied
const applied = this.db.prepare('SELECT version FROM schema_versions WHERE version = ?').get(6) as SchemaVersion | undefined;
if (applied) return;
// Check sdk_sessions for prompt_counter
const sessionsInfo = this.db.query('PRAGMA table_info(sdk_sessions)').all() as TableColumnInfo[];
const hasPromptCounter = sessionsInfo.some(col => col.name === 'prompt_counter');
// Check sdk_sessions for prompt_counter
const sessionsInfo = this.db.query('PRAGMA table_info(sdk_sessions)').all() as TableColumnInfo[];
const hasPromptCounter = sessionsInfo.some(col => col.name === 'prompt_counter');
if (!hasPromptCounter) {
this.db.run('ALTER TABLE sdk_sessions ADD COLUMN prompt_counter INTEGER DEFAULT 0');
console.log('[SessionStore] Added prompt_counter column to sdk_sessions table');
}
// Check observations for prompt_number
const observationsInfo = this.db.query('PRAGMA table_info(observations)').all() as TableColumnInfo[];
const obsHasPromptNumber = observationsInfo.some(col => col.name === 'prompt_number');
if (!obsHasPromptNumber) {
this.db.run('ALTER TABLE observations ADD COLUMN prompt_number INTEGER');
console.log('[SessionStore] Added prompt_number column to observations table');
}
// Check session_summaries for prompt_number
const summariesInfo = this.db.query('PRAGMA table_info(session_summaries)').all() as TableColumnInfo[];
const sumHasPromptNumber = summariesInfo.some(col => col.name === 'prompt_number');
if (!sumHasPromptNumber) {
this.db.run('ALTER TABLE session_summaries ADD COLUMN prompt_number INTEGER');
console.log('[SessionStore] Added prompt_number column to session_summaries table');
}
// Record migration
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(6, new Date().toISOString());
} catch (error: any) {
console.error('[SessionStore] Prompt tracking migration error:', error.message);
if (!hasPromptCounter) {
this.db.run('ALTER TABLE sdk_sessions ADD COLUMN prompt_counter INTEGER DEFAULT 0');
console.log('[SessionStore] Added prompt_counter column to sdk_sessions table');
}
// Check observations for prompt_number
const observationsInfo = this.db.query('PRAGMA table_info(observations)').all() as TableColumnInfo[];
const obsHasPromptNumber = observationsInfo.some(col => col.name === 'prompt_number');
if (!obsHasPromptNumber) {
this.db.run('ALTER TABLE observations ADD COLUMN prompt_number INTEGER');
console.log('[SessionStore] Added prompt_number column to observations table');
}
// Check session_summaries for prompt_number
const summariesInfo = this.db.query('PRAGMA table_info(session_summaries)').all() as TableColumnInfo[];
const sumHasPromptNumber = summariesInfo.some(col => col.name === 'prompt_number');
if (!sumHasPromptNumber) {
this.db.run('ALTER TABLE session_summaries ADD COLUMN prompt_number INTEGER');
console.log('[SessionStore] Added prompt_number column to session_summaries table');
}
// Record migration
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(6, new Date().toISOString());
}
/**
* Remove UNIQUE constraint from session_summaries.sdk_session_id (migration 7)
*/
private removeSessionSummariesUniqueConstraint(): void {
// Check if migration already applied
const applied = this.db.prepare('SELECT version FROM schema_versions WHERE version = ?').get(7) as SchemaVersion | undefined;
if (applied) return;
// Check if UNIQUE constraint exists
const summariesIndexes = this.db.query('PRAGMA index_list(session_summaries)').all() as IndexInfo[];
const hasUniqueConstraint = summariesIndexes.some(idx => idx.unique === 1);
if (!hasUniqueConstraint) {
// Already migrated (no constraint exists)
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(7, new Date().toISOString());
return;
}
console.log('[SessionStore] Removing UNIQUE constraint from session_summaries.sdk_session_id...');
// Begin transaction
this.db.run('BEGIN TRANSACTION');
try {
// Check if migration already applied
const applied = this.db.prepare('SELECT version FROM schema_versions WHERE version = ?').get(7) as SchemaVersion | undefined;
if (applied) return;
// Create new table without UNIQUE constraint
this.db.run(`
CREATE TABLE session_summaries_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sdk_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,
created_at TEXT NOT NULL,
created_at_epoch INTEGER NOT NULL,
FOREIGN KEY(sdk_session_id) REFERENCES sdk_sessions(sdk_session_id) ON DELETE CASCADE
)
`);
// Check if UNIQUE constraint exists
const summariesIndexes = this.db.query('PRAGMA index_list(session_summaries)').all() as IndexInfo[];
const hasUniqueConstraint = summariesIndexes.some(idx => idx.unique === 1);
// Copy data from old table
this.db.run(`
INSERT INTO session_summaries_new
SELECT id, sdk_session_id, project, request, investigated, learned,
completed, next_steps, files_read, files_edited, notes,
prompt_number, created_at, created_at_epoch
FROM session_summaries
`);
if (!hasUniqueConstraint) {
// Already migrated (no constraint exists)
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(7, new Date().toISOString());
return;
}
// Drop old table
this.db.run('DROP TABLE session_summaries');
console.log('[SessionStore] Removing UNIQUE constraint from session_summaries.sdk_session_id...');
// Rename new table
this.db.run('ALTER TABLE session_summaries_new RENAME TO session_summaries');
// Begin transaction
this.db.run('BEGIN TRANSACTION');
// Recreate indexes
this.db.run(`
CREATE INDEX idx_session_summaries_sdk_session ON session_summaries(sdk_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);
`);
try {
// Create new table without UNIQUE constraint
this.db.run(`
CREATE TABLE session_summaries_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sdk_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,
created_at TEXT NOT NULL,
created_at_epoch INTEGER NOT NULL,
FOREIGN KEY(sdk_session_id) REFERENCES sdk_sessions(sdk_session_id) ON DELETE CASCADE
)
`);
// Commit transaction
this.db.run('COMMIT');
// Copy data from old table
this.db.run(`
INSERT INTO session_summaries_new
SELECT id, sdk_session_id, project, request, investigated, learned,
completed, next_steps, files_read, files_edited, notes,
prompt_number, created_at, created_at_epoch
FROM session_summaries
`);
// Record migration
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(7, new Date().toISOString());
// Drop old table
this.db.run('DROP TABLE session_summaries');
// Rename new table
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(sdk_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);
`);
// Commit transaction
this.db.run('COMMIT');
// Record migration
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(7, new Date().toISOString());
console.log('[SessionStore] Successfully removed UNIQUE constraint from session_summaries.sdk_session_id');
} catch (error: any) {
// Rollback on error
this.db.run('ROLLBACK');
throw error;
}
console.log('[SessionStore] Successfully removed UNIQUE constraint from session_summaries.sdk_session_id');
} catch (error: any) {
console.error('[SessionStore] Migration error (remove UNIQUE constraint):', error.message);
// Rollback on error
this.db.run('ROLLBACK');
throw error;
}
}
@@ -297,41 +285,37 @@ export class SessionStore {
* Add hierarchical fields to observations table (migration 8)
*/
private addObservationHierarchicalFields(): void {
try {
// Check if migration already applied
const applied = this.db.prepare('SELECT version FROM schema_versions WHERE version = ?').get(8) as SchemaVersion | undefined;
if (applied) return;
// Check if migration already applied
const applied = this.db.prepare('SELECT version FROM schema_versions WHERE version = ?').get(8) as SchemaVersion | undefined;
if (applied) return;
// Check if new fields already exist
const tableInfo = this.db.query('PRAGMA table_info(observations)').all() as TableColumnInfo[];
const hasTitle = tableInfo.some(col => col.name === 'title');
// Check if new fields already exist
const tableInfo = this.db.query('PRAGMA table_info(observations)').all() as TableColumnInfo[];
const hasTitle = tableInfo.some(col => col.name === 'title');
if (hasTitle) {
// Already migrated
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(8, new Date().toISOString());
return;
}
console.log('[SessionStore] Adding hierarchical fields to observations table...');
// Add new columns
this.db.run(`
ALTER TABLE observations ADD COLUMN title TEXT;
ALTER TABLE observations ADD COLUMN subtitle TEXT;
ALTER TABLE observations ADD COLUMN facts TEXT;
ALTER TABLE observations ADD COLUMN narrative TEXT;
ALTER TABLE observations ADD COLUMN concepts TEXT;
ALTER TABLE observations ADD COLUMN files_read TEXT;
ALTER TABLE observations ADD COLUMN files_modified TEXT;
`);
// Record migration
if (hasTitle) {
// Already migrated
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(8, new Date().toISOString());
console.log('[SessionStore] Successfully added hierarchical fields to observations table');
} catch (error: any) {
console.error('[SessionStore] Migration error (add hierarchical fields):', error.message);
return;
}
console.log('[SessionStore] Adding hierarchical fields to observations table...');
// Add new columns
this.db.run(`
ALTER TABLE observations ADD COLUMN title TEXT;
ALTER TABLE observations ADD COLUMN subtitle TEXT;
ALTER TABLE observations ADD COLUMN facts TEXT;
ALTER TABLE observations ADD COLUMN narrative TEXT;
ALTER TABLE observations ADD COLUMN concepts TEXT;
ALTER TABLE observations ADD COLUMN files_read TEXT;
ALTER TABLE observations ADD COLUMN files_modified TEXT;
`);
// Record migration
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(8, new Date().toISOString());
console.log('[SessionStore] Successfully added hierarchical fields to observations table');
}
/**
@@ -339,86 +323,82 @@ export class SessionStore {
* The text field is deprecated in favor of structured fields (title, subtitle, narrative, etc.)
*/
private makeObservationsTextNullable(): void {
// Check if migration already applied
const applied = this.db.prepare('SELECT version FROM schema_versions WHERE version = ?').get(9) as SchemaVersion | undefined;
if (applied) return;
// Check if text column is already nullable
const tableInfo = this.db.query('PRAGMA table_info(observations)').all() as TableColumnInfo[];
const textColumn = tableInfo.find(col => col.name === 'text');
if (!textColumn || textColumn.notnull === 0) {
// Already migrated or text column doesn't exist
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(9, new Date().toISOString());
return;
}
console.log('[SessionStore] Making observations.text nullable...');
// Begin transaction
this.db.run('BEGIN TRANSACTION');
try {
// Check if migration already applied
const applied = this.db.prepare('SELECT version FROM schema_versions WHERE version = ?').get(9) as SchemaVersion | undefined;
if (applied) return;
// Create new table with text as nullable
this.db.run(`
CREATE TABLE observations_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sdk_session_id TEXT NOT NULL,
project TEXT NOT NULL,
text TEXT,
type TEXT NOT NULL CHECK(type IN ('decision', 'bugfix', 'feature', 'refactor', 'discovery', 'change')),
title TEXT,
subtitle TEXT,
facts TEXT,
narrative TEXT,
concepts TEXT,
files_read TEXT,
files_modified TEXT,
prompt_number INTEGER,
created_at TEXT NOT NULL,
created_at_epoch INTEGER NOT NULL,
FOREIGN KEY(sdk_session_id) REFERENCES sdk_sessions(sdk_session_id) ON DELETE CASCADE
)
`);
// Check if text column is already nullable
const tableInfo = this.db.query('PRAGMA table_info(observations)').all() as TableColumnInfo[];
const textColumn = tableInfo.find(col => col.name === 'text');
// Copy data from old table (all existing columns)
this.db.run(`
INSERT INTO observations_new
SELECT id, sdk_session_id, project, text, type, title, subtitle, facts,
narrative, concepts, files_read, files_modified, prompt_number,
created_at, created_at_epoch
FROM observations
`);
if (!textColumn || textColumn.notnull === 0) {
// Already migrated or text column doesn't exist
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(9, new Date().toISOString());
return;
}
// Drop old table
this.db.run('DROP TABLE observations');
console.log('[SessionStore] Making observations.text nullable...');
// Rename new table
this.db.run('ALTER TABLE observations_new RENAME TO observations');
// Begin transaction
this.db.run('BEGIN TRANSACTION');
// Recreate indexes
this.db.run(`
CREATE INDEX idx_observations_sdk_session ON observations(sdk_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);
`);
try {
// Create new table with text as nullable
this.db.run(`
CREATE TABLE observations_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sdk_session_id TEXT NOT NULL,
project TEXT NOT NULL,
text TEXT,
type TEXT NOT NULL CHECK(type IN ('decision', 'bugfix', 'feature', 'refactor', 'discovery', 'change')),
title TEXT,
subtitle TEXT,
facts TEXT,
narrative TEXT,
concepts TEXT,
files_read TEXT,
files_modified TEXT,
prompt_number INTEGER,
created_at TEXT NOT NULL,
created_at_epoch INTEGER NOT NULL,
FOREIGN KEY(sdk_session_id) REFERENCES sdk_sessions(sdk_session_id) ON DELETE CASCADE
)
`);
// Commit transaction
this.db.run('COMMIT');
// Copy data from old table (all existing columns)
this.db.run(`
INSERT INTO observations_new
SELECT id, sdk_session_id, project, text, type, title, subtitle, facts,
narrative, concepts, files_read, files_modified, prompt_number,
created_at, created_at_epoch
FROM observations
`);
// Record migration
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(9, new Date().toISOString());
// Drop old table
this.db.run('DROP TABLE observations');
// Rename new table
this.db.run('ALTER TABLE observations_new RENAME TO observations');
// Recreate indexes
this.db.run(`
CREATE INDEX idx_observations_sdk_session ON observations(sdk_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);
`);
// Commit transaction
this.db.run('COMMIT');
// Record migration
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(9, new Date().toISOString());
console.log('[SessionStore] Successfully made observations.text nullable');
} catch (error: any) {
// Rollback on error
this.db.run('ROLLBACK');
throw error;
}
console.log('[SessionStore] Successfully made observations.text nullable');
} catch (error: any) {
console.error('[SessionStore] Migration error (make text nullable):', error.message);
// Rollback on error
this.db.run('ROLLBACK');
throw error;
}
}
@@ -426,86 +406,82 @@ export class SessionStore {
* Create user_prompts table with FTS5 support (migration 10)
*/
private createUserPromptsTable(): void {
// Check if migration already applied
const applied = this.db.prepare('SELECT version FROM schema_versions WHERE version = ?').get(10) as SchemaVersion | undefined;
if (applied) return;
// Check if table already exists
const tableInfo = this.db.query('PRAGMA table_info(user_prompts)').all() as TableColumnInfo[];
if (tableInfo.length > 0) {
// Already migrated
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(10, new Date().toISOString());
return;
}
console.log('[SessionStore] Creating user_prompts table with FTS5 support...');
// Begin transaction
this.db.run('BEGIN TRANSACTION');
try {
// Check if migration already applied
const applied = this.db.prepare('SELECT version FROM schema_versions WHERE version = ?').get(10) as SchemaVersion | undefined;
if (applied) return;
// Create main table (using claude_session_id since sdk_session_id is set asynchronously by worker)
this.db.run(`
CREATE TABLE user_prompts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
claude_session_id TEXT NOT NULL,
prompt_number INTEGER NOT NULL,
prompt_text TEXT NOT NULL,
created_at TEXT NOT NULL,
created_at_epoch INTEGER NOT NULL,
FOREIGN KEY(claude_session_id) REFERENCES sdk_sessions(claude_session_id) ON DELETE CASCADE
);
// Check if table already exists
const tableInfo = this.db.query('PRAGMA table_info(user_prompts)').all() as TableColumnInfo[];
if (tableInfo.length > 0) {
// Already migrated
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(10, new Date().toISOString());
return;
}
CREATE INDEX idx_user_prompts_claude_session ON user_prompts(claude_session_id);
CREATE INDEX idx_user_prompts_created ON user_prompts(created_at_epoch DESC);
CREATE INDEX idx_user_prompts_prompt_number ON user_prompts(prompt_number);
CREATE INDEX idx_user_prompts_lookup ON user_prompts(claude_session_id, prompt_number);
`);
console.log('[SessionStore] Creating user_prompts table with FTS5 support...');
// Create FTS5 virtual table
this.db.run(`
CREATE VIRTUAL TABLE user_prompts_fts USING fts5(
prompt_text,
content='user_prompts',
content_rowid='id'
);
`);
// Begin transaction
this.db.run('BEGIN TRANSACTION');
// Create triggers to sync FTS5
this.db.run(`
CREATE TRIGGER user_prompts_ai AFTER INSERT ON user_prompts BEGIN
INSERT INTO user_prompts_fts(rowid, prompt_text)
VALUES (new.id, new.prompt_text);
END;
try {
// Create main table (using claude_session_id since sdk_session_id is set asynchronously by worker)
this.db.run(`
CREATE TABLE user_prompts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
claude_session_id TEXT NOT NULL,
prompt_number INTEGER NOT NULL,
prompt_text TEXT NOT NULL,
created_at TEXT NOT NULL,
created_at_epoch INTEGER NOT NULL,
FOREIGN KEY(claude_session_id) REFERENCES sdk_sessions(claude_session_id) ON DELETE CASCADE
);
CREATE TRIGGER user_prompts_ad AFTER DELETE ON user_prompts BEGIN
INSERT INTO user_prompts_fts(user_prompts_fts, rowid, prompt_text)
VALUES('delete', old.id, old.prompt_text);
END;
CREATE INDEX idx_user_prompts_claude_session ON user_prompts(claude_session_id);
CREATE INDEX idx_user_prompts_created ON user_prompts(created_at_epoch DESC);
CREATE INDEX idx_user_prompts_prompt_number ON user_prompts(prompt_number);
CREATE INDEX idx_user_prompts_lookup ON user_prompts(claude_session_id, prompt_number);
`);
CREATE TRIGGER user_prompts_au AFTER UPDATE ON user_prompts BEGIN
INSERT INTO user_prompts_fts(user_prompts_fts, rowid, prompt_text)
VALUES('delete', old.id, old.prompt_text);
INSERT INTO user_prompts_fts(rowid, prompt_text)
VALUES (new.id, new.prompt_text);
END;
`);
// Create FTS5 virtual table
this.db.run(`
CREATE VIRTUAL TABLE user_prompts_fts USING fts5(
prompt_text,
content='user_prompts',
content_rowid='id'
);
`);
// Commit transaction
this.db.run('COMMIT');
// Create triggers to sync FTS5
this.db.run(`
CREATE TRIGGER user_prompts_ai AFTER INSERT ON user_prompts BEGIN
INSERT INTO user_prompts_fts(rowid, prompt_text)
VALUES (new.id, new.prompt_text);
END;
// Record migration
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(10, new Date().toISOString());
CREATE TRIGGER user_prompts_ad AFTER DELETE ON user_prompts BEGIN
INSERT INTO user_prompts_fts(user_prompts_fts, rowid, prompt_text)
VALUES('delete', old.id, old.prompt_text);
END;
CREATE TRIGGER user_prompts_au AFTER UPDATE ON user_prompts BEGIN
INSERT INTO user_prompts_fts(user_prompts_fts, rowid, prompt_text)
VALUES('delete', old.id, old.prompt_text);
INSERT INTO user_prompts_fts(rowid, prompt_text)
VALUES (new.id, new.prompt_text);
END;
`);
// Commit transaction
this.db.run('COMMIT');
// Record migration
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(10, new Date().toISOString());
console.log('[SessionStore] Successfully created user_prompts table with FTS5 support');
} catch (error: any) {
// Rollback on error
this.db.run('ROLLBACK');
throw error;
}
console.log('[SessionStore] Successfully created user_prompts table with FTS5 support');
} catch (error: any) {
console.error('[SessionStore] Migration error (create user_prompts table):', error.message);
// Rollback on error
this.db.run('ROLLBACK');
throw error;
}
}
@@ -990,25 +966,17 @@ export class SessionStore {
for (const row of rows) {
// Parse files_read
if (row.files_read) {
try {
const files = JSON.parse(row.files_read);
if (Array.isArray(files)) {
files.forEach(f => filesReadSet.add(f));
}
} catch {
// Skip invalid JSON
const files = JSON.parse(row.files_read);
if (Array.isArray(files)) {
files.forEach(f => filesReadSet.add(f));
}
}
// Parse files_modified
if (row.files_modified) {
try {
const files = JSON.parse(row.files_modified);
if (Array.isArray(files)) {
files.forEach(f => filesModifiedSet.add(f));
}
} catch {
// Skip invalid JSON
const files = JSON.parse(row.files_modified);
if (Array.isArray(files)) {
files.forEach(f => filesModifiedSet.add(f));
}
}
}
+1
View File
@@ -495,6 +495,7 @@ export const migration007: Migration = {
}
};
/**
* All migrations in order
*/
+15 -25
View File
@@ -847,31 +847,21 @@ export class ChromaSync {
return;
}
try {
// Close client first
if (this.client) {
try {
await this.client.close();
} catch (error) {
logger.warn('CHROMA_SYNC', 'Error closing Chroma client', { project: this.project }, error as Error);
}
}
// Explicitly close transport to kill subprocess
if (this.transport) {
try {
await this.transport.close();
} catch (error) {
logger.warn('CHROMA_SYNC', 'Error closing transport', { project: this.project }, error as Error);
}
}
logger.info('CHROMA_SYNC', 'Chroma client and subprocess closed', { project: this.project });
} finally {
// Always reset state, even if errors occurred
this.connected = false;
this.client = null;
this.transport = null;
// Close client first
if (this.client) {
await this.client.close();
}
// Explicitly close transport to kill subprocess
if (this.transport) {
await this.transport.close();
}
logger.info('CHROMA_SYNC', 'Chroma client and subprocess closed', { project: this.project });
// Always reset state
this.connected = false;
this.client = null;
this.transport = null;
}
}
+91 -118
View File
@@ -2,7 +2,7 @@
* Worker Service - Slim Orchestrator
*
* Refactored from 2000-line monolith to ~150-line orchestrator.
* Routes organized by domain in http/routes/*.ts
* Routes organized by feature area in http/routes/*.ts
* See src/services/worker/README.md for architecture details.
*/
@@ -19,7 +19,7 @@ import { promisify } from 'util';
const execAsync = promisify(exec);
// Import composed domain services
// Import composed service layer
import { DatabaseManager } from './worker/DatabaseManager.js';
import { SessionManager } from './worker/SessionManager.js';
import { SSEBroadcaster } from './worker/SSEBroadcaster.js';
@@ -49,7 +49,7 @@ export class WorkerService {
private mcpReady: boolean = false;
private initializationCompleteFlag: boolean = false;
// Domain services
// Service layer
private dbManager: DatabaseManager;
private sessionManager: SessionManager;
private sseBroadcaster: SSEBroadcaster;
@@ -77,7 +77,7 @@ export class WorkerService {
this.resolveInitialization = resolve;
});
// Initialize domain services
// Initialize service layer
this.dbManager = new DatabaseManager();
this.sessionManager = new SessionManager(this.dbManager);
this.sseBroadcaster = new SSEBroadcaster();
@@ -160,19 +160,9 @@ export class WorkerService {
const marketplaceRoot = path.join(homedir(), '.claude', 'plugins', 'marketplaces', 'thedotmack');
const packageJsonPath = path.join(marketplaceRoot, 'package.json');
try {
// Read version from marketplace package.json
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
res.status(200).json({ version: packageJson.version });
} catch (error) {
logger.error('SYSTEM', 'Failed to read version', {
packagePath: packageJsonPath
}, error as Error);
res.status(500).json({
error: 'Failed to read version',
path: packageJsonPath
});
}
// Read version from marketplace package.json
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
res.status(200).json({ version: packageJson.version });
});
// Instructions endpoint - loads SKILL.md sections on-demand for progressive instruction loading
@@ -326,83 +316,74 @@ export class WorkerService {
* Prevents process accumulation and memory leaks
*/
private async cleanupOrphanedProcesses(): Promise<void> {
try {
const isWindows = process.platform === 'win32';
const pids: number[] = [];
const isWindows = process.platform === 'win32';
const pids: number[] = [];
if (isWindows) {
// Windows: Use PowerShell Get-CimInstance to find chroma-mcp processes
const cmd = `powershell -Command "Get-CimInstance Win32_Process | Where-Object { $_.Name -like '*python*' -and $_.CommandLine -like '*chroma-mcp*' } | Select-Object -ExpandProperty ProcessId"`;
const { stdout } = await execAsync(cmd, { timeout: 5000 });
if (isWindows) {
// Windows: Use PowerShell Get-CimInstance to find chroma-mcp processes
const cmd = `powershell -Command "Get-CimInstance Win32_Process | Where-Object { $_.Name -like '*python*' -and $_.CommandLine -like '*chroma-mcp*' } | Select-Object -ExpandProperty ProcessId"`;
const { stdout } = await execAsync(cmd, { timeout: 5000 });
if (!stdout.trim()) {
logger.debug('SYSTEM', 'No orphaned chroma-mcp processes found (Windows)');
return;
if (!stdout.trim()) {
logger.debug('SYSTEM', 'No orphaned chroma-mcp processes found (Windows)');
return;
}
const pidStrings = stdout.trim().split('\n');
for (const pidStr of pidStrings) {
const pid = parseInt(pidStr.trim(), 10);
// SECURITY: Validate PID is positive integer before adding to list
if (!isNaN(pid) && Number.isInteger(pid) && pid > 0) {
pids.push(pid);
}
}
} else {
// Unix: Use ps aux | grep
const { stdout } = await execAsync('ps aux | grep "chroma-mcp" | grep -v grep || true');
const pidStrings = stdout.trim().split('\n');
for (const pidStr of pidStrings) {
const pid = parseInt(pidStr.trim(), 10);
if (!stdout.trim()) {
logger.debug('SYSTEM', 'No orphaned chroma-mcp 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);
}
}
} else {
// Unix: Use ps aux | grep
const { stdout } = await execAsync('ps aux | grep "chroma-mcp" | grep -v grep || true');
if (!stdout.trim()) {
logger.debug('SYSTEM', 'No orphaned chroma-mcp 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);
}
}
}
}
if (pids.length === 0) {
return;
}
logger.info('SYSTEM', 'Cleaning up orphaned chroma-mcp processes', {
platform: isWindows ? 'Windows' : 'Unix',
count: pids.length,
pids
});
// Kill all found processes
if (isWindows) {
for (const pid of pids) {
// SECURITY: Double-check PID validation before using in taskkill command
if (!Number.isInteger(pid) || pid <= 0) {
logger.warn('SYSTEM', 'Skipping invalid PID', { pid });
continue;
}
try {
execSync(`taskkill /PID ${pid} /T /F`, { timeout: 5000, stdio: 'ignore' });
} catch (error) {
logger.warn('SYSTEM', 'Failed to kill orphaned process', { pid }, error as Error);
}
}
} else {
await execAsync(`kill ${pids.join(' ')}`);
}
logger.info('SYSTEM', 'Orphaned processes cleaned up', { count: pids.length });
} catch (error) {
// Non-fatal - log and continue
logger.warn('SYSTEM', 'Failed to cleanup orphaned processes', {}, error as Error);
}
if (pids.length === 0) {
return;
}
logger.info('SYSTEM', 'Cleaning up orphaned chroma-mcp processes', {
platform: isWindows ? 'Windows' : 'Unix',
count: pids.length,
pids
});
// Kill all found processes
if (isWindows) {
for (const pid of pids) {
// SECURITY: Double-check PID validation before using in taskkill command
if (!Number.isInteger(pid) || pid <= 0) {
logger.warn('SYSTEM', 'Skipping invalid PID', { pid });
continue;
}
execSync(`taskkill /PID ${pid} /T /F`, { timeout: 5000, stdio: 'ignore' });
}
} else {
await execAsync(`kill ${pids.join(' ')}`);
}
logger.info('SYSTEM', 'Orphaned processes cleaned up', { count: pids.length });
}
/**
@@ -433,6 +414,16 @@ export class WorkerService {
// Clean up any orphaned chroma-mcp processes BEFORE starting our own
await this.cleanupOrphanedProcesses();
// Load mode configuration (must happen before database to set observation types)
const { ModeManager } = await import('./domain/ModeManager.js');
const { SettingsDefaultsManager } = await import('../shared/SettingsDefaultsManager.js');
const { USER_SETTINGS_PATH } = await import('../shared/paths.js');
const settings = SettingsDefaultsManager.loadFromFile(USER_SETTINGS_PATH);
const modeId = settings.CLAUDE_MEM_MODE;
ModeManager.getInstance().loadMode(modeId);
logger.info('SYSTEM', `Mode loaded: ${modeId}`);
// Initialize database (once, stays open)
await this.dbManager.initialize();
@@ -538,12 +529,8 @@ export class WorkerService {
// STEP 4: Close MCP client connection (signals child to exit gracefully)
if (this.mcpClient) {
try {
await this.mcpClient.close();
logger.info('SYSTEM', 'MCP client closed');
} catch (error) {
logger.error('SYSTEM', 'Failed to close MCP client', {}, error as Error);
}
await this.mcpClient.close();
logger.info('SYSTEM', 'MCP client closed');
}
// STEP 5: Close database connection (includes ChromaSync cleanup)
@@ -576,18 +563,13 @@ export class WorkerService {
return [];
}
try {
const cmd = `powershell -Command "Get-CimInstance Win32_Process | Where-Object { $_.ParentProcessId -eq ${parentPid} } | Select-Object -ExpandProperty ProcessId"`;
const { stdout } = await execAsync(cmd, { timeout: 5000 });
return stdout
.trim()
.split('\n')
.map(s => parseInt(s.trim(), 10))
.filter(n => !isNaN(n) && Number.isInteger(n) && n > 0); // SECURITY: Validate each PID
} catch (error) {
logger.warn('SYSTEM', 'Failed to enumerate child processes', {}, error as Error);
return [];
}
const cmd = `powershell -Command "Get-CimInstance Win32_Process | Where-Object { $_.ParentProcessId -eq ${parentPid} } | Select-Object -ExpandProperty ProcessId"`;
const { stdout } = await execAsync(cmd, { timeout: 5000 });
return stdout
.trim()
.split('\n')
.map(s => parseInt(s.trim(), 10))
.filter(n => !isNaN(n) && Number.isInteger(n) && n > 0); // SECURITY: Validate each PID
}
/**
@@ -600,17 +582,12 @@ export class WorkerService {
return;
}
try {
if (process.platform === 'win32') {
// /T kills entire process tree, /F forces termination
await execAsync(`taskkill /PID ${pid} /T /F`, { timeout: 5000 });
logger.info('SYSTEM', 'Killed process', { pid });
} else {
process.kill(pid, 'SIGKILL');
}
} catch (error) {
// Process may already be dead, which is fine
logger.debug('SYSTEM', 'Process already dead or kill failed', { pid });
if (process.platform === 'win32') {
// /T kills entire process tree, /F forces termination
await execAsync(`taskkill /PID ${pid} /T /F`, { timeout: 5000 });
logger.info('SYSTEM', 'Killed process', { pid });
} else {
process.kill(pid, 'SIGKILL');
}
}
@@ -622,12 +599,8 @@ export class WorkerService {
while (Date.now() - start < timeoutMs) {
const stillAlive = pids.filter(pid => {
try {
process.kill(pid, 0); // Signal 0 checks if process exists
return true;
} catch {
return false;
}
process.kill(pid, 0); // Signal 0 checks if process exists - throws if dead
return true;
});
if (stillAlive.length === 0) {
+5 -11
View File
@@ -30,10 +30,8 @@ export class DatabaseManager {
// Initialize ChromaSync
this.chromaSync = new ChromaSync('claude-mem');
// Start background backfill (fire-and-forget, with error logging)
this.chromaSync.ensureBackfilled().catch((error) => {
logger.error('DB', 'Chroma backfill failed (non-fatal)', {}, error);
});
// Start background backfill (fire-and-forget)
this.chromaSync.ensureBackfilled();
logger.info('DB', 'Database initialized');
}
@@ -44,14 +42,10 @@ export class DatabaseManager {
async close(): Promise<void> {
// Close ChromaSync first (terminates uvx/python processes)
if (this.chromaSync) {
try {
await this.chromaSync.close();
this.chromaSync = null;
} catch (error) {
logger.error('DB', 'Failed to close ChromaSync', {}, error as Error);
}
await this.chromaSync.close();
this.chromaSync = null;
}
if (this.sessionStore) {
this.sessionStore.close();
this.sessionStore = null;
+4 -4
View File
@@ -4,7 +4,7 @@
*/
import { ObservationSearchResult, SessionSummarySearchResult, UserPromptSearchResult } from '../sqlite/types.js';
import { TYPE_ICON_MAP, TYPE_WORK_EMOJI_MAP } from '../../constants/observation-metadata.js';
import { ModeManager } from '../domain/ModeManager.js';
// Token estimation constant (matches context-generator)
const CHARS_PER_TOKEN_ESTIMATE = 4;
@@ -55,10 +55,10 @@ Tips:
formatObservationIndex(obs: ObservationSearchResult, _index: number): string {
const id = `#${obs.id}`;
const time = this.formatTime(obs.created_at_epoch);
const icon = TYPE_ICON_MAP[obs.type as keyof typeof TYPE_ICON_MAP] || '•';
const icon = ModeManager.getInstance().getTypeIcon(obs.type);
const title = obs.title || 'Untitled';
const readTokens = this.estimateReadTokens(obs);
const workEmoji = TYPE_WORK_EMOJI_MAP[obs.type as keyof typeof TYPE_WORK_EMOJI_MAP] || '🔍';
const workEmoji = ModeManager.getInstance().getWorkEmoji(obs.type);
const workTokens = obs.discovery_tokens || 0;
const workDisplay = workTokens > 0 ? `${workEmoji} ${workTokens}` : '-';
@@ -116,7 +116,7 @@ Tips:
formatObservationSearchRow(obs: ObservationSearchResult, lastTime: string): { row: string; time: string } {
const id = `#${obs.id}`;
const time = this.formatTime(obs.created_at_epoch);
const icon = TYPE_ICON_MAP[obs.type as keyof typeof TYPE_ICON_MAP] || '•';
const icon = ModeManager.getInstance().getTypeIcon(obs.type);
const title = obs.title || 'Untitled';
const readTokens = this.estimateReadTokens(obs);
+10 -10
View File
@@ -10,7 +10,7 @@ The Worker Service is an Express HTTP server that handles all claude-mem operati
Hook (plugin/scripts/*-hook.js)
→ HTTP Request to Worker (localhost:37777)
→ Route Handler (http/routes/*.ts)
→ MCP Server Tool (for search) OR Domain Service (for session/data)
→ MCP Server Tool (for search) OR Service Layer (for session/data)
→ Database (SQLite3 + Chroma vector DB)
```
@@ -22,13 +22,13 @@ src/services/worker/
├── WorkerService.ts # Slim orchestrator (~150 lines)
├── http/ # HTTP layer
│ ├── middleware.ts # Shared middleware (logging, CORS, etc.)
│ └── routes/ # Route handlers organized by domain
│ └── routes/ # Route handlers organized by feature area
│ ├── SessionRoutes.ts # Session lifecycle (init, observations, summarize, complete)
│ ├── DataRoutes.ts # Data retrieval (get observations, summaries, prompts, stats)
│ ├── SearchRoutes.ts # Search/MCP proxy (all search endpoints)
│ ├── SettingsRoutes.ts # Settings, MCP toggle, branch switching
│ └── ViewerRoutes.ts # Health check, viewer UI, SSE stream
└── domain/ # Business logic (existing services, NO CHANGES in Phase 1)
└── services/ # Business logic services (existing, NO CHANGES in Phase 1)
├── DatabaseManager.ts # SQLite connection management
├── SessionManager.ts # Session state tracking
├── SDKAgent.ts # Claude Agent SDK for observations/summaries
@@ -46,7 +46,7 @@ src/services/worker/
- `GET /stream` - SSE stream for real-time updates
### SessionRoutes.ts
Session lifecycle operations (use domain services directly):
Session lifecycle operations (use service layer directly):
- `POST /sessions/init` - Initialize new session
- `POST /sessions/:sessionId/observations` - Add tool usage observations
- `POST /sessions/:sessionId/summarize` - Trigger session summary
@@ -58,7 +58,7 @@ Session lifecycle operations (use domain services directly):
- `POST /sessions/claude-id/:claudeId/complete` - Complete by claude_id
### DataRoutes.ts
Data retrieval operations (use domain services directly):
Data retrieval operations (use service layer directly):
- `GET /observations` - List observations (paginated)
- `GET /summaries` - List session summaries (paginated)
- `GET /prompts` - List user prompts (paginated)
@@ -91,7 +91,7 @@ All search operations (proxy to MCP server):
- `GET /search/help` - Search help
### SettingsRoutes.ts
Settings and configuration (use domain services directly):
Settings and configuration (use service layer directly):
- `GET /settings` - Get user settings
- `POST /settings` - Update user settings
- `GET /mcp/status` - Get MCP server status
@@ -109,14 +109,14 @@ Settings and configuration (use domain services directly):
**MCP vs Direct DB Split** (inherited, not changed in Phase 1):
- Search operations → MCP server (mem-search)
- Session/data operations → Direct DB access via domain services
- Session/data operations → Direct DB access via service layer
## Future Phase 2
Phase 2 will unify the architecture:
1. Expand MCP server to handle ALL operations (not just search)
2. Convert all route handlers to proxy through MCP
3. Move database logic from domain services into MCP tools
3. Move database logic from service layer into MCP tools
4. Result: Worker becomes pure HTTP → MCP proxy for maximum portability
This separation allows the worker to be deployed anywhere (as a CLI tool, cloud service, etc.) without carrying database dependencies.
@@ -126,7 +126,7 @@ This separation allows the worker to be deployed anywhere (as a CLI tool, cloud
1. Choose the appropriate route file based on the endpoint's purpose
2. Add the route handler method to the class
3. Register the route in the `setupRoutes()` method
4. Import any needed domain services in the constructor
4. Import any needed services in the constructor
5. Follow the existing patterns for error handling and logging
Example:
@@ -149,7 +149,7 @@ app.get('/foo', this.handleGetFoo.bind(this));
## Key Design Principles
1. **Progressive Disclosure**: Navigate from high-level (WorkerService.ts) to specific routes to implementation details
2. **Single Responsibility**: Each route class handles one domain area
2. **Single Responsibility**: Each route class handles one feature area
3. **Dependency Injection**: Route classes receive only the services they need
4. **Consistent Error Handling**: All handlers use try/catch with logger.failure()
5. **Bound Methods**: All route handlers use `.bind(this)` to preserve context
+9 -18
View File
@@ -19,6 +19,7 @@ import { buildInitPrompt, buildObservationPrompt, buildSummaryPrompt, buildConti
import { SettingsDefaultsManager } from '../../shared/SettingsDefaultsManager.js';
import { USER_SETTINGS_PATH } from '../../shared/paths.js';
import type { ActiveSession, SDKUserMessage, PendingMessage } from '../worker-types.js';
import { ModeManager } from '../domain/ModeManager.js';
// Import Agent SDK (assumes it's installed)
// @ts-ignore - Agent SDK types may not be available
@@ -185,6 +186,9 @@ export class SDKAgent {
* - We just use the session_id we're given - simple and reliable
*/
private async *createMessageGenerator(session: ActiveSession): AsyncIterableIterator<SDKUserMessage> {
// Load active mode
const mode = ModeManager.getInstance().getActiveMode();
// Yield initial user prompt with context (or continuation if prompt #2+)
// CRITICAL: Both paths use session.claudeSessionId from the hook
yield {
@@ -192,8 +196,8 @@ export class SDKAgent {
message: {
role: 'user',
content: session.lastPromptNumber === 1
? buildInitPrompt(session.project, session.claudeSessionId, session.userPrompt)
: buildContinuationPrompt(session.userPrompt, session.lastPromptNumber, session.claudeSessionId)
? buildInitPrompt(session.project, session.claudeSessionId, session.userPrompt, mode)
: buildContinuationPrompt(session.userPrompt, session.lastPromptNumber, session.claudeSessionId, mode)
},
session_id: session.claudeSessionId,
parent_tool_use_id: null,
@@ -237,7 +241,7 @@ export class SDKAgent {
user_prompt: session.userPrompt,
last_user_message: message.last_user_message || '',
last_assistant_message: message.last_assistant_message || ''
})
}, mode)
},
session_id: session.claudeSessionId,
parent_tool_use_id: null,
@@ -276,7 +280,7 @@ export class SDKAgent {
concepts: obs.concepts?.length ?? 0
});
// Sync to Chroma with error logging
// Sync to Chroma
const chromaStart = Date.now();
const obsType = obs.type;
const obsTitle = obs.title || '(untitled)';
@@ -296,13 +300,6 @@ export class SDKAgent {
type: obsType,
title: obsTitle
});
}).catch(err => {
logger.error('CHROMA', 'Failed to sync observation', {
obsId,
sessionId: session.sessionDbId,
type: obsType,
title: obsTitle
}, err);
});
// Broadcast to SSE clients (for web UI)
@@ -352,7 +349,7 @@ export class SDKAgent {
hasNextSteps: !!summary.next_steps
});
// Sync to Chroma with error logging
// Sync to Chroma
const chromaStart = Date.now();
const summaryRequest = summary.request || '(no request)';
this.dbManager.getChromaSync().syncSummary(
@@ -370,12 +367,6 @@ export class SDKAgent {
duration: `${chromaDuration}ms`,
request: summaryRequest
});
}).catch(err => {
logger.error('CHROMA', 'Failed to sync summary', {
summaryId,
sessionId: session.sessionDbId,
request: summaryRequest
}, err);
});
// Broadcast to SSE clients (for web UI)
+3 -13
View File
@@ -53,15 +53,9 @@ export class SSEBroadcaster {
logger.debug('WORKER', 'SSE broadcast sent', { eventType: event.type, clients: this.sseClients.size });
// Single-pass write + cleanup
// Single-pass write
for (const client of this.sseClients) {
try {
client.write(data);
} catch (err) {
// Remove failed client immediately
this.sseClients.delete(client);
logger.debug('WORKER', 'Client removed due to write error');
}
client.write(data);
}
}
@@ -77,10 +71,6 @@ export class SSEBroadcaster {
*/
private sendToClient(res: Response, event: SSEEvent): void {
const data = `data: ${JSON.stringify(event)}\n\n`;
try {
res.write(data);
} catch (err) {
this.sseClients.delete(res);
}
res.write(data);
}
}
+4 -27
View File
@@ -15,6 +15,7 @@ import { TimelineService, TimelineItem } from './TimelineService.js';
import { ObservationSearchResult, SessionSummarySearchResult, UserPromptSearchResult } from '../sqlite/types.js';
import { logger } from '../../utils/logger.js';
import { formatDate, formatTime, formatDateTime, extractFirstFile, groupByDate, estimateTokens } from '../../shared/timeline-formatting.js';
import { ModeManager } from '../domain/ModeManager.js';
const COLLECTION_NAME = 'cm__claude-mem';
const RECENCY_WINDOW_DAYS = 90;
@@ -590,15 +591,7 @@ export class SearchManager {
lastTime = '';
}
let icon = '•';
switch (obs.type) {
case 'bugfix': icon = '🔴'; break;
case 'feature': icon = '🟣'; break;
case 'refactor': icon = '🔄'; break;
case 'change': icon = '✅'; break;
case 'discovery': icon = '🔵'; break;
case 'decision': icon = '🧠'; break;
}
const icon = ModeManager.getInstance().getTypeIcon(obs.type);
const time = formatTime(item.epoch);
const title = obs.title || 'Untitled';
@@ -1675,15 +1668,7 @@ export class SearchManager {
}
// Map observation type to emoji
let icon = '•';
switch (obs.type) {
case 'bugfix': icon = '🔴'; break;
case 'feature': icon = '🟣'; break;
case 'refactor': icon = '🔄'; break;
case 'change': icon = '✅'; break;
case 'discovery': icon = '🔵'; break;
case 'decision': icon = '🧠'; break;
}
const icon = ModeManager.getInstance().getTypeIcon(obs.type);
const time = formatTime(item.epoch);
const title = obs.title || 'Untitled';
@@ -1927,15 +1912,7 @@ export class SearchManager {
}
// Map observation type to emoji
let icon = '•';
switch (obs.type) {
case 'bugfix': icon = '🔴'; break;
case 'feature': icon = '🟣'; break;
case 'refactor': icon = '🔄'; break;
case 'change': icon = '✅'; break;
case 'discovery': icon = '🔵'; break;
case 'decision': icon = '🧠'; break;
}
const icon = ModeManager.getInstance().getTypeIcon(obs.type);
const time = formatTime(item.epoch);
const title = obs.title || 'Untitled';
+2 -9
View File
@@ -4,6 +4,7 @@
*/
import { ObservationSearchResult, SessionSummarySearchResult, UserPromptSearchResult } from '../sqlite/types.js';
import { ModeManager } from '../domain/ModeManager.js';
/**
* Timeline item for unified chronological display
@@ -210,15 +211,7 @@ export class TimelineService {
* Get icon for observation type
*/
private getTypeIcon(type: string): string {
switch (type) {
case 'bugfix': return '🔴';
case 'feature': return '🟣';
case 'refactor': return '🔄';
case 'change': return '✅';
case 'discovery': return '🔵';
case 'decision': return '🧠';
default: return '•';
}
return ModeManager.getInstance().getTypeIcon(type);
}
/**
@@ -2,7 +2,7 @@
* Data Routes
*
* Handles data retrieval operations: observations, summaries, prompts, stats, processing status.
* All endpoints use direct database access via domain services.
* All endpoints use direct database access via service layer.
*/
import express, { Request, Response } from 'express';
@@ -51,9 +51,6 @@ export class SessionRoutes extends BaseRouteHandler {
});
session.generatorPromise = this.sdkAgent.startSession(session, this.workerService)
.catch(err => {
logger.failure('SDK', 'SDK agent error', { sessionId: sessionDbId }, err);
})
.finally(() => {
logger.info('SESSION', `Generator finished`, { sessionId: sessionDbId });
session.generatorPromise = null;
@@ -102,7 +99,7 @@ export class SessionRoutes extends BaseRouteHandler {
created_at_epoch: latestPrompt.created_at_epoch
});
// Sync user prompt to Chroma with error logging
// Sync user prompt to Chroma
const chromaStart = Date.now();
const promptText = latestPrompt.prompt_text;
this.dbManager.getChromaSync().syncUserPrompt(
@@ -122,11 +119,6 @@ export class SessionRoutes extends BaseRouteHandler {
duration: `${chromaDuration}ms`,
prompt: truncatedPrompt
});
}).catch(err => {
logger.error('CHROMA', 'Failed to sync user_prompt', {
promptId: latestPrompt.id,
sessionId: sessionDbId
}, err);
});
}
@@ -138,9 +130,6 @@ export class SessionRoutes extends BaseRouteHandler {
});
session.generatorPromise = this.sdkAgent.startSession(session, this.workerService)
.catch(err => {
logger.failure('SDK', 'SDK agent error', { sessionId: sessionDbId }, err);
})
.finally(() => {
// Clear generator reference when completed
logger.info('SESSION', `Generator finished`, { sessionId: sessionDbId });
@@ -309,26 +298,13 @@ export class SessionRoutes extends BaseRouteHandler {
}
// Strip memory tags from tool_input and tool_response
let cleanedToolInput = '{}';
let cleanedToolResponse = '{}';
const cleanedToolInput = tool_input !== undefined
? stripMemoryTagsFromJson(JSON.stringify(tool_input))
: '{}';
try {
cleanedToolInput = tool_input !== undefined
? stripMemoryTagsFromJson(JSON.stringify(tool_input))
: '{}';
} catch (error) {
logger.debug('SESSION', 'Failed to serialize tool_input', { sessionDbId }, error);
cleanedToolInput = '{"error": "Failed to serialize tool_input"}';
}
try {
cleanedToolResponse = tool_response !== undefined
? stripMemoryTagsFromJson(JSON.stringify(tool_response))
: '{}';
} catch (error) {
logger.debug('SESSION', 'Failed to serialize tool_result', { sessionDbId }, error);
cleanedToolResponse = '{"error": "Failed to serialize tool_response"}';
}
const cleanedToolResponse = tool_response !== undefined
? stripMemoryTagsFromJson(JSON.stringify(tool_response))
: '{}';
// Queue observation
this.sessionManager.queueObservation(sessionDbId, {
@@ -13,12 +13,7 @@ import { getPackageRoot } from '../../../../shared/paths.js';
import { logger } from '../../../../utils/logger.js';
import { SettingsManager } from '../../SettingsManager.js';
import { getBranchInfo, switchBranch, pullUpdates } from '../../BranchManager.js';
import {
OBSERVATION_TYPES,
OBSERVATION_CONCEPTS,
ObservationType,
ObservationConcept
} from '../../../../constants/observation-metadata.js';
import { ModeManager } from '../../domain/ModeManager.js';
import { BaseRouteHandler } from '../BaseRouteHandler.js';
import { SettingsDefaultsManager } from '../../../../shared/SettingsDefaultsManager.js';
import { clearPortCache } from '../../../../shared/worker-utils.js';
@@ -296,25 +291,11 @@ export class SettingsRoutes extends BaseRouteHandler {
}
}
// Validate observation types
if (settings.CLAUDE_MEM_CONTEXT_OBSERVATION_TYPES) {
const types = settings.CLAUDE_MEM_CONTEXT_OBSERVATION_TYPES.split(',').map((t: string) => t.trim());
for (const type of types) {
if (type && !OBSERVATION_TYPES.includes(type as ObservationType)) {
return { valid: false, error: `Invalid observation type: ${type}. Valid types: ${OBSERVATION_TYPES.join(', ')}` };
}
}
}
// Skip observation types validation - any type string is valid since modes define their own types
// The database accepts any TEXT value, and mode-specific validation happens at parse time
// Validate observation concepts
if (settings.CLAUDE_MEM_CONTEXT_OBSERVATION_CONCEPTS) {
const concepts = settings.CLAUDE_MEM_CONTEXT_OBSERVATION_CONCEPTS.split(',').map((c: string) => c.trim());
for (const concept of concepts) {
if (concept && !OBSERVATION_CONCEPTS.includes(concept as ObservationConcept)) {
return { valid: false, error: `Invalid observation concept: ${concept}. Valid concepts: ${OBSERVATION_CONCEPTS.join(', ')}` };
}
}
}
// Skip observation concepts validation - any concept string is valid since modes define their own concepts
// The database accepts any TEXT value, and mode-specific validation happens at parse time
return { valid: true };
}
@@ -332,25 +313,20 @@ export class SettingsRoutes extends BaseRouteHandler {
* Toggle MCP search server (rename .mcp.json <-> .mcp.json.disabled)
*/
private toggleMcp(enabled: boolean): void {
try {
const packageRoot = getPackageRoot();
const mcpPath = path.join(packageRoot, 'plugin', '.mcp.json');
const mcpDisabledPath = path.join(packageRoot, 'plugin', '.mcp.json.disabled');
const packageRoot = getPackageRoot();
const mcpPath = path.join(packageRoot, 'plugin', '.mcp.json');
const mcpDisabledPath = path.join(packageRoot, 'plugin', '.mcp.json.disabled');
if (enabled && existsSync(mcpDisabledPath)) {
// Enable: rename .mcp.json.disabled -> .mcp.json
renameSync(mcpDisabledPath, mcpPath);
logger.info('WORKER', 'MCP search server enabled');
} else if (!enabled && existsSync(mcpPath)) {
// Disable: rename .mcp.json -> .mcp.json.disabled
renameSync(mcpPath, mcpDisabledPath);
logger.info('WORKER', 'MCP search server disabled');
} else {
logger.debug('WORKER', 'MCP toggle no-op (already in desired state)', { enabled });
}
} catch (error) {
logger.failure('WORKER', 'Failed to toggle MCP', { enabled }, error as Error);
throw error;
if (enabled && existsSync(mcpDisabledPath)) {
// Enable: rename .mcp.json.disabled -> .mcp.json
renameSync(mcpDisabledPath, mcpPath);
logger.info('WORKER', 'MCP search server enabled');
} else if (!enabled && existsSync(mcpPath)) {
// Disable: rename .mcp.json -> .mcp.json.disabled
renameSync(mcpPath, mcpDisabledPath);
logger.info('WORKER', 'MCP search server disabled');
} else {
logger.debug('WORKER', 'MCP toggle no-op (already in desired state)', { enabled });
}
}
@@ -24,6 +24,10 @@ export class ViewerRoutes extends BaseRouteHandler {
}
setupRoutes(app: express.Application): void {
// Serve static UI assets (JS, CSS, fonts, etc.)
const packageRoot = getPackageRoot();
app.use(express.static(path.join(packageRoot, 'ui')));
app.get('/health', this.handleHealth.bind(this));
app.get('/', this.handleViewerUI.bind(this));
app.get('/stream', this.handleSSEStream.bind(this));