refactor: decompose monolithic services into modular architecture (#534)

* docs: add monolith refactor report with system breakdown

Comprehensive analysis of codebase identifying:
- 14 files over 500 lines requiring refactoring
- 3 critical monoliths (SessionStore, SearchManager, worker-service)
- 80% code duplication across agent files
- 5-phase refactoring roadmap with domain-based architecture

* fix: prevent memory_session_id from equaling content_session_id

The bug: memory_session_id was initialized to contentSessionId as a
"placeholder for FK purposes". This caused the SDK resume logic to
inject memory agent messages into the USER's Claude Code transcript,
corrupting their conversation history.

Root cause:
- SessionStore.createSDKSession initialized memory_session_id = contentSessionId
- SDKAgent checked memorySessionId !== contentSessionId but this check
  only worked if the session was fetched fresh from DB

The fix:
- SessionStore: Initialize memory_session_id as NULL, not contentSessionId
- SDKAgent: Simple truthy check !!session.memorySessionId (NULL = fresh start)
- Database migration: Ran UPDATE to set memory_session_id = NULL for 1807
  existing sessions that had the bug

Also adds [ALIGNMENT] logging across the session lifecycle to help debug
session continuity issues:
- Hook entry: contentSessionId + promptNumber
- DB lookup: contentSessionId → memorySessionId mapping proof
- Resume decision: shows which memorySessionId will be used for resume
- Capture: logs when memorySessionId is captured from first SDK response

UI: Added "Alignment" quick filter button in LogsModal to show only
alignment logs for debugging session continuity.

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

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

* refactor: improve error handling in worker-service.ts

- Fix GENERIC_CATCH anti-patterns by logging full error objects instead of just messages
- Add [ANTI-PATTERN IGNORED] markers for legitimate cases (cleanup, hot paths)
- Simplify error handling comments to be more concise
- Improve httpShutdown() error discrimination for ECONNREFUSED
- Reduce LARGE_TRY_BLOCK issues in initialization code

Part of anti-pattern cleanup plan (132 total issues)

* refactor: improve error logging in SearchManager.ts

- Pass full error objects to logger instead of just error.message
- Fixes PARTIAL_ERROR_LOGGING anti-patterns (10 instances)
- Better debugging visibility when Chroma queries fail

Part of anti-pattern cleanup (133 remaining)

* refactor: improve error logging across SessionStore and mcp-server

- SessionStore.ts: Fix error logging in column rename utility
- mcp-server.ts: Log full error objects instead of just error.message
- Improve error handling in Worker API calls and tool execution

Part of anti-pattern cleanup (133 remaining)

* Refactor hooks to streamline error handling and loading states

- Simplified error handling in useContextPreview by removing try-catch and directly checking response status.
- Refactored usePagination to eliminate try-catch, improving readability and maintaining error handling through response checks.
- Cleaned up useSSE by removing unnecessary try-catch around JSON parsing, ensuring clarity in message handling.
- Enhanced useSettings by streamlining the saving process, removing try-catch, and directly checking the result for success.

* refactor: add error handling back to SearchManager Chroma calls

- Wrap queryChroma calls in try-catch to prevent generator crashes
- Log Chroma errors as warnings and fall back gracefully
- Fixes generator failures when Chroma has issues
- Part of anti-pattern cleanup recovery

* feat: Add generator failure investigation report and observation duplication regression report

- Created a comprehensive investigation report detailing the root cause of generator failures during anti-pattern cleanup, including the impact, investigation process, and implemented fixes.
- Documented the critical regression causing observation duplication due to race conditions in the SDK agent, outlining symptoms, root cause analysis, and proposed fixes.

* fix: address PR #528 review comments - atomic cleanup and detector improvements

This commit addresses critical review feedback from PR #528:

## 1. Atomic Message Cleanup (Fix Race Condition)

**Problem**: SessionRoutes.ts generator error handler had race condition
- Queried messages then marked failed in loop
- If crash during loop → partial marking → inconsistent state

**Solution**:
- Added `markSessionMessagesFailed()` to PendingMessageStore.ts
- Single atomic UPDATE statement replaces loop
- Follows existing pattern from `resetProcessingToPending()`

**Files**:
- src/services/sqlite/PendingMessageStore.ts (new method)
- src/services/worker/http/routes/SessionRoutes.ts (use new method)

## 2. Anti-Pattern Detector Improvements

**Problem**: Detector didn't recognize logger.failure() method
- Lines 212 & 335 already included "failure"
- Lines 112-113 (PARTIAL_ERROR_LOGGING detection) did not

**Solution**: Updated regex patterns to include "failure" for consistency

**Files**:
- scripts/anti-pattern-test/detect-error-handling-antipatterns.ts

## 3. Documentation

**PR Comment**: Added clarification on memory_session_id fix location
- Points to SessionStore.ts:1155
- Explains why NULL initialization prevents message injection bug

## Review Response

Addresses "Must Address Before Merge" items from review:
 Clarified memory_session_id bug fix location (via PR comment)
 Made generator error handler message cleanup atomic
 Deferred comprehensive test suite to follow-up PR (keeps PR focused)

## Testing

- Build passes with no errors
- Anti-pattern detector runs successfully
- Atomic cleanup follows proven pattern from existing methods

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

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

* fix: FOREIGN KEY constraint and missing failed_at_epoch column

Two critical bugs fixed:

1. Missing failed_at_epoch column in pending_messages table
   - Added migration 20 to create the column
   - Fixes error when trying to mark messages as failed

2. FOREIGN KEY constraint failed when storing observations
   - All three agents (SDK, Gemini, OpenRouter) were passing
     session.contentSessionId instead of session.memorySessionId
   - storeObservationsAndMarkComplete expects memorySessionId
   - Added null check and clear error message

However, observations still not saving - see investigation report.

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

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

* Refactor hook input parsing to improve error handling

- Added a nested try-catch block in new-hook.ts, save-hook.ts, and summary-hook.ts to handle JSON parsing errors more gracefully.
- Replaced direct error throwing with logging of the error details using logger.error.
- Ensured that the process exits cleanly after handling input in all three hooks.

* docs: update monolith report post session-logging merge

- SessionStore grew to 2,011 lines (49 methods) - highest priority
- SearchManager reduced to 1,778 lines (improved)
- Agent files reduced by ~45 lines combined
- Added trend indicators and post-merge observations
- Core refactoring proposal remains valid

* refactor(sqlite): decompose SessionStore into modular architecture

Extract the 2011-line SessionStore.ts monolith into focused, single-responsibility
modules following grep-optimized progressive disclosure pattern:

New module structure:
- sessions/ - Session creation and retrieval (create.ts, get.ts, types.ts)
- observations/ - Observation storage and queries (store.ts, get.ts, recent.ts, files.ts, types.ts)
- summaries/ - Summary storage and queries (store.ts, get.ts, recent.ts, types.ts)
- prompts/ - User prompt management (store.ts, get.ts, types.ts)
- timeline/ - Cross-entity timeline queries (queries.ts)
- import/ - Bulk import operations (bulk.ts)
- migrations/ - Database migrations (runner.ts)

New coordinator files:
- Database.ts - ClaudeMemDatabase class with re-exports
- transactions.ts - Atomic cross-entity transactions
- Named re-export facades (Sessions.ts, Observations.ts, etc.)

Key design decisions:
- All functions take `db: Database` as first parameter (functional style)
- Named re-exports instead of index.ts for grep-friendliness
- SessionStore retained as backward-compatible wrapper
- Target file size: 50-150 lines (60% compliance)

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

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

* refactor(agents): extract shared logic into modular architecture

Consolidate duplicate code across SDKAgent, GeminiAgent, and OpenRouterAgent
into focused utility modules. Total reduction: 500 lines (29%).

New modules in src/services/worker/agents/:
- ResponseProcessor.ts: Atomic DB transactions, Chroma sync, SSE broadcast
- ObservationBroadcaster.ts: SSE event formatting and dispatch
- SessionCleanupHelper.ts: Session state cleanup and stuck message reset
- FallbackErrorHandler.ts: Provider error detection for fallback logic
- types.ts: Shared interfaces (WorkerRef, SSE payloads, StorageResult)

Bug fix: SDKAgent was incorrectly using obs.files instead of obs.files_read
and hardcoding files_modified to empty array.

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

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

* refactor(search): extract search strategies into modular architecture

Decompose SearchManager into focused strategy pattern with:
- SearchOrchestrator: Coordinates strategy selection and fallback
- ChromaSearchStrategy: Vector semantic search via ChromaDB
- SQLiteSearchStrategy: Filter-only queries for date/project/type
- HybridSearchStrategy: Metadata filtering + semantic ranking
- ResultFormatter: Markdown table formatting for results
- TimelineBuilder: Chronological timeline construction
- Filter modules: DateFilter, ProjectFilter, TypeFilter

SearchManager now delegates to new infrastructure while maintaining
full backward compatibility with existing public API.

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

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

* refactor(context): decompose context-generator into modular architecture

Extract 660-line monolith into focused components:
- ContextBuilder: Main orchestrator (~160 lines)
- ContextConfigLoader: Configuration loading
- TokenCalculator: Token budget calculations
- ObservationCompiler: Data retrieval and query building
- MarkdownFormatter/ColorFormatter: Output formatting
- Section renderers: Header, Timeline, Summary, Footer

Maintains full backward compatibility - context-generator.ts now
delegates to new ContextBuilder while preserving public API.

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

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

* refactor(worker): decompose worker-service into modular infrastructure

Split 2000+ line monolith into focused modules:

Infrastructure:
- ProcessManager: PID files, signal handlers, child process cleanup
- HealthMonitor: Port checks, health polling, version matching
- GracefulShutdown: Coordinated cleanup on exit

Server:
- Server: Express app setup, core routes, route registration
- Middleware: Re-exports from existing middleware
- ErrorHandler: Centralized error handling with AppError class

Integrations:
- CursorHooksInstaller: Full Cursor IDE integration (registry, hooks, MCP)

WorkerService now acts as thin coordinator wiring all components together.
Maintains full backward compatibility with existing public API.

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

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

* Refactor session queue processing and database interactions

- Implement claim-and-delete pattern in SessionQueueProcessor to simplify message handling and eliminate duplicate processing.
- Update PendingMessageStore to support atomic claim-and-delete operations, removing the need for intermediate processing states.
- Introduce storeObservations method in SessionStore for simplified observation and summary storage without message tracking.
- Remove deprecated methods and clean up session state management in worker agents.
- Adjust response processing to accommodate new storage patterns, ensuring atomic transactions for observations and summaries.
- Remove unnecessary reset logic for stuck messages due to the new queue handling approach.

* Add duplicate observation cleanup script

Script to clean up duplicate observations created by the batching bug
where observations were stored once per message ID instead of once per
observation. Includes safety checks to always keep at least one copy.

Usage:
  bun scripts/cleanup-duplicates.ts           # Dry run
  bun scripts/cleanup-duplicates.ts --execute # Delete duplicates
  bun scripts/cleanup-duplicates.ts --aggressive # Ignore time window

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Alex Newman
2026-01-03 21:22:27 -05:00
committed by GitHub
parent 7748e62387
commit 2fc4153bef
87 changed files with 9933 additions and 3492 deletions
+14 -655
View File
@@ -1,659 +1,18 @@
/**
* Context Generator - generates context injection for SessionStart
* Context Generator - DEPRECATED
*
* This module contains all the logic for building the context injection string.
* It's used by the worker service and called via HTTP from the context-hook.
* This file is maintained for backward compatibility.
* New code should import from './Context.js' or './context/index.js'.
*
* The context generation logic has been restructured into:
* - src/services/context/ContextBuilder.ts - Main orchestrator
* - src/services/context/ContextConfigLoader.ts - Configuration loading
* - src/services/context/TokenCalculator.ts - Token economics
* - src/services/context/ObservationCompiler.ts - Data retrieval
* - src/services/context/formatters/ - Output formatting
* - src/services/context/sections/ - Section rendering
*/
import path from 'path';
import { homedir } from 'os';
import { existsSync, readFileSync, unlinkSync } from 'fs';
import { SessionStore } from './sqlite/SessionStore.js';
import { logger } from '../utils/logger.js';
import { SettingsDefaultsManager } from '../shared/SettingsDefaultsManager.js';
import {
parseJsonArray,
formatDateTime,
formatTime,
formatDate,
toRelativePath,
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');
interface ContextConfig {
// Display counts
totalObservationCount: number;
fullObservationCount: number;
sessionCount: number;
// Token display toggles
showReadTokens: boolean;
showWorkTokens: boolean;
showSavingsAmount: boolean;
showSavingsPercent: boolean;
// Filters
observationTypes: Set<string>;
observationConcepts: Set<string>;
// Display options
fullObservationField: 'narrative' | 'facts';
showLastSummary: boolean;
showLastMessage: boolean;
}
/**
* Load all context configuration settings
* Priority: ~/.claude-mem/settings.json > env var > defaults
*/
function loadContextConfig(): ContextConfig {
const settingsPath = path.join(homedir(), '.claude-mem', 'settings.json');
const settings = SettingsDefaultsManager.loadFromFile(settingsPath);
// For non-code modes, use all types/concepts from active mode instead of settings
const modeId = settings.CLAUDE_MEM_MODE;
const isCodeMode = modeId === 'code' || modeId.startsWith('code--');
let observationTypes: Set<string>;
let observationConcepts: Set<string>;
if (isCodeMode) {
// Code mode: use settings-based filtering
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)
);
} else {
// Non-code modes: use all types/concepts from active mode
const mode = ModeManager.getInstance().getActiveMode();
observationTypes = new Set(mode.observation_types.map(t => t.id));
observationConcepts = new Set(mode.observation_concepts.map(c => c.id));
}
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,
observationConcepts,
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
const CHARS_PER_TOKEN_ESTIMATE = 4;
const SUMMARY_LOOKAHEAD = 1;
export interface ContextInput {
session_id?: string;
transcript_path?: string;
cwd?: string;
hook_event_name?: string;
source?: "startup" | "resume" | "clear" | "compact";
[key: string]: any;
}
// ANSI color codes for terminal output
const colors = {
reset: '\x1b[0m',
bright: '\x1b[1m',
dim: '\x1b[2m',
cyan: '\x1b[36m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
magenta: '\x1b[35m',
gray: '\x1b[90m',
red: '\x1b[31m',
};
interface Observation {
id: number;
memory_session_id: string;
type: string;
title: string | null;
subtitle: string | null;
narrative: string | null;
facts: string | null;
concepts: string | null;
files_read: string | null;
files_modified: string | null;
discovery_tokens: number | null;
created_at: string;
created_at_epoch: number;
}
interface SessionSummary {
id: number;
memory_session_id: string;
request: string | null;
investigated: string | null;
learned: string | null;
completed: string | null;
next_steps: string | null;
created_at: string;
created_at_epoch: number;
}
// Helper: Render a summary field
function renderSummaryField(label: string, value: string | null, color: string, useColors: boolean): string[] {
if (!value) return [];
if (useColors) {
return [`${color}${label}:${colors.reset} ${value}`, ''];
}
return [`**${label}**: ${value}`, ''];
}
// Helper: Convert cwd path to dashed format
function cwdToDashed(cwd: string): string {
return cwd.replace(/\//g, '-');
}
// Helper: Extract last assistant message from transcript file
function extractPriorMessages(transcriptPath: string): { userMessage: string; assistantMessage: string } {
try {
if (!existsSync(transcriptPath)) {
return { userMessage: '', assistantMessage: '' };
}
const content = readFileSync(transcriptPath, 'utf-8').trim();
if (!content) {
return { userMessage: '', assistantMessage: '' };
}
const lines = content.split('\n').filter(line => line.trim());
let lastAssistantMessage = '';
for (let i = lines.length - 1; i >= 0; i--) {
try {
const line = lines[i];
if (!line.includes('"type":"assistant"')) {
continue;
}
const entry = JSON.parse(line);
if (entry.type === 'assistant' && entry.message?.content && Array.isArray(entry.message.content)) {
let text = '';
for (const block of entry.message.content) {
if (block.type === 'text') {
text += block.text;
}
}
text = text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trim();
if (text) {
lastAssistantMessage = text;
break;
}
}
} catch (parseError) {
logger.debug('PARSER', 'Skipping malformed transcript line', { lineIndex: i }, parseError as Error);
continue;
}
}
return { userMessage: '', assistantMessage: lastAssistantMessage };
} catch (error) {
logger.failure('WORKER', `Failed to extract prior messages from transcript`, { transcriptPath }, error as Error);
return { userMessage: '', assistantMessage: '' };
}
}
/**
* Generate context for a project
*/
export async function generateContext(input?: ContextInput, useColors: boolean = false): Promise<string> {
const config = loadContextConfig();
const cwd = input?.cwd ?? process.cwd();
const project = getProjectName(cwd);
let db: SessionStore | null = null;
try {
db = new SessionStore();
} catch (error: any) {
if (error.code === 'ERR_DLOPEN_FAILED') {
try {
unlinkSync(VERSION_MARKER_PATH);
} catch (unlinkError) {
logger.debug('SYSTEM', 'Marker file cleanup failed (may not exist)', {}, unlinkError as Error);
}
logger.error('SYSTEM', 'Native module rebuild needed - restart Claude Code to auto-fix');
return '';
}
throw error;
}
// Build SQL WHERE clause for observation types
const typeArray = Array.from(config.observationTypes);
const typePlaceholders = typeArray.map(() => '?').join(',');
// Build SQL WHERE clause for concepts
const conceptArray = Array.from(config.observationConcepts);
const conceptPlaceholders = conceptArray.map(() => '?').join(',');
// Get recent observations
const observations = db.db.prepare(`
SELECT
id, memory_session_id, type, title, subtitle, narrative,
facts, concepts, files_read, files_modified, discovery_tokens,
created_at, created_at_epoch
FROM observations
WHERE project = ?
AND type IN (${typePlaceholders})
AND EXISTS (
SELECT 1 FROM json_each(concepts)
WHERE value IN (${conceptPlaceholders})
)
ORDER BY created_at_epoch DESC
LIMIT ?
`).all(project, ...typeArray, ...conceptArray, config.totalObservationCount) as Observation[];
// Get recent summaries
const recentSummaries = db.db.prepare(`
SELECT id, memory_session_id, request, investigated, learned, completed, next_steps, created_at, created_at_epoch
FROM session_summaries
WHERE project = ?
ORDER BY created_at_epoch DESC
LIMIT ?
`).all(project, config.sessionCount + SUMMARY_LOOKAHEAD) as SessionSummary[];
// Retrieve prior session messages if enabled
let priorUserMessage = '';
let priorAssistantMessage = '';
if (config.showLastMessage && observations.length > 0) {
const currentSessionId = input?.session_id;
const priorSessionObs = observations.find(obs => obs.memory_session_id !== currentSessionId);
if (priorSessionObs) {
const priorSessionId = priorSessionObs.memory_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;
}
}
// If we have neither observations nor summaries, show empty state
if (observations.length === 0 && recentSummaries.length === 0) {
db?.close();
if (useColors) {
return `\n${colors.bright}${colors.cyan}[${project}] recent context${colors.reset}\n${colors.gray}${'─'.repeat(60)}${colors.reset}\n\n${colors.dim}No previous sessions found for this project yet.${colors.reset}\n`;
}
return `# [${project}] recent context\n\nNo previous sessions found for this project yet.`;
}
const displaySummaries = recentSummaries.slice(0, config.sessionCount);
const timelineObs = observations;
// Build output
const output: string[] = [];
// Header
if (useColors) {
output.push('');
output.push(`${colors.bright}${colors.cyan}[${project}] recent context${colors.reset}`);
output.push(`${colors.gray}${'─'.repeat(60)}${colors.reset}`);
output.push('');
} else {
output.push(`# [${project}] recent context`);
output.push('');
}
// Chronological Timeline
if (timelineObs.length > 0) {
// 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 | ${typeLegendItems}${colors.reset}`);
} else {
output.push(`**Legend:** 🎯 session-request | ${typeLegendItems}`);
}
output.push('');
// Column Key
if (useColors) {
output.push(`${colors.bright}💡 Column Key${colors.reset}`);
output.push(`${colors.dim} Read: Tokens to read this observation (cost to learn it now)${colors.reset}`);
output.push(`${colors.dim} Work: Tokens spent on work that produced this record (🔍 research, 🛠️ building, ⚖️ deciding)${colors.reset}`);
} else {
output.push(`💡 **Column Key**:`);
output.push(`- **Read**: Tokens to read this observation (cost to learn it now)`);
output.push(`- **Work**: Tokens spent on work that produced this record (🔍 research, 🛠️ building, ⚖️ deciding)`);
}
output.push('');
// Context Index Instructions
if (useColors) {
output.push(`${colors.dim}💡 Context Index: This semantic index (titles, types, files, tokens) is usually sufficient to understand past work.${colors.reset}`);
output.push('');
output.push(`${colors.dim}When you need implementation details, rationale, or debugging context:${colors.reset}`);
output.push(`${colors.dim} - Use the mem-search skill to fetch full observations on-demand${colors.reset}`);
output.push(`${colors.dim} - Critical types (🔴 bugfix, ⚖️ decision) often need detailed fetching${colors.reset}`);
output.push(`${colors.dim} - Trust this index over re-reading code for past decisions and learnings${colors.reset}`);
} else {
output.push(`💡 **Context Index:** This semantic index (titles, types, files, tokens) is usually sufficient to understand past work.`);
output.push('');
output.push(`When you need implementation details, rationale, or debugging context:`);
output.push(`- Use the mem-search skill to fetch full observations on-demand`);
output.push(`- Critical types (🔴 bugfix, ⚖️ decision) often need detailed fetching`);
output.push(`- Trust this index over re-reading code for past decisions and learnings`);
}
output.push('');
// Context Economics
const totalObservations = observations.length;
const totalReadTokens = observations.reduce((sum, obs) => {
const obsSize = (obs.title?.length || 0) +
(obs.subtitle?.length || 0) +
(obs.narrative?.length || 0) +
JSON.stringify(obs.facts || []).length;
return sum + Math.ceil(obsSize / CHARS_PER_TOKEN_ESTIMATE);
}, 0);
const totalDiscoveryTokens = observations.reduce((sum, obs) => sum + (obs.discovery_tokens || 0), 0);
const savings = totalDiscoveryTokens - totalReadTokens;
const savingsPercent = totalDiscoveryTokens > 0
? Math.round((savings / totalDiscoveryTokens) * 100)
: 0;
const showContextEconomics = config.showReadTokens || config.showWorkTokens ||
config.showSavingsAmount || config.showSavingsPercent;
if (showContextEconomics) {
if (useColors) {
output.push(`${colors.bright}${colors.cyan}📊 Context Economics${colors.reset}`);
output.push(`${colors.dim} Loading: ${totalObservations} observations (${totalReadTokens.toLocaleString()} tokens to read)${colors.reset}`);
output.push(`${colors.dim} Work investment: ${totalDiscoveryTokens.toLocaleString()} tokens spent on research, building, and decisions${colors.reset}`);
if (totalDiscoveryTokens > 0 && (config.showSavingsAmount || config.showSavingsPercent)) {
let savingsLine = ' Your savings: ';
if (config.showSavingsAmount && config.showSavingsPercent) {
savingsLine += `${savings.toLocaleString()} tokens (${savingsPercent}% reduction from reuse)`;
} else if (config.showSavingsAmount) {
savingsLine += `${savings.toLocaleString()} tokens`;
} else {
savingsLine += `${savingsPercent}% reduction from reuse`;
}
output.push(`${colors.green}${savingsLine}${colors.reset}`);
}
output.push('');
} else {
output.push(`📊 **Context Economics**:`);
output.push(`- Loading: ${totalObservations} observations (${totalReadTokens.toLocaleString()} tokens to read)`);
output.push(`- Work investment: ${totalDiscoveryTokens.toLocaleString()} tokens spent on research, building, and decisions`);
if (totalDiscoveryTokens > 0 && (config.showSavingsAmount || config.showSavingsPercent)) {
let savingsLine = '- Your savings: ';
if (config.showSavingsAmount && config.showSavingsPercent) {
savingsLine += `${savings.toLocaleString()} tokens (${savingsPercent}% reduction from reuse)`;
} else if (config.showSavingsAmount) {
savingsLine += `${savings.toLocaleString()} tokens`;
} else {
savingsLine += `${savingsPercent}% reduction from reuse`;
}
output.push(savingsLine);
}
output.push('');
}
}
// Prepare summaries for timeline display
const mostRecentSummaryId = recentSummaries[0]?.id;
interface SummaryTimelineItem extends SessionSummary {
displayEpoch: number;
displayTime: string;
shouldShowLink: boolean;
}
const summariesForTimeline: SummaryTimelineItem[] = displaySummaries.map((summary, i) => {
const olderSummary = i === 0 ? null : recentSummaries[i + 1];
return {
...summary,
displayEpoch: olderSummary ? olderSummary.created_at_epoch : summary.created_at_epoch,
displayTime: olderSummary ? olderSummary.created_at : summary.created_at,
shouldShowLink: summary.id !== mostRecentSummaryId
};
});
// Identify which observations should show full details
const fullObservationIds = new Set(
observations
.slice(0, config.fullObservationCount)
.map(obs => obs.id)
);
type TimelineItem =
| { type: 'observation'; data: Observation }
| { type: 'summary'; data: SummaryTimelineItem };
const timeline: TimelineItem[] = [
...timelineObs.map(obs => ({ type: 'observation' as const, data: obs })),
...summariesForTimeline.map(summary => ({ type: 'summary' as const, data: summary }))
];
// Sort chronologically
timeline.sort((a, b) => {
const aEpoch = a.type === 'observation' ? a.data.created_at_epoch : a.data.displayEpoch;
const bEpoch = b.type === 'observation' ? b.data.created_at_epoch : b.data.displayEpoch;
return aEpoch - bEpoch;
});
// Group by day
const itemsByDay = new Map<string, TimelineItem[]>();
for (const item of timeline) {
const itemDate = item.type === 'observation' ? item.data.created_at : item.data.displayTime;
const day = formatDate(itemDate);
if (!itemsByDay.has(day)) {
itemsByDay.set(day, []);
}
itemsByDay.get(day)!.push(item);
}
// Sort days chronologically
const sortedDays = Array.from(itemsByDay.entries()).sort((a, b) => {
const aDate = new Date(a[0]).getTime();
const bDate = new Date(b[0]).getTime();
return aDate - bDate;
});
// Render each day's timeline
for (const [day, dayItems] of sortedDays) {
if (useColors) {
output.push(`${colors.bright}${colors.cyan}${day}${colors.reset}`);
output.push('');
} else {
output.push(`### ${day}`);
output.push('');
}
let currentFile: string | null = null;
let lastTime = '';
let tableOpen = false;
for (const item of dayItems) {
if (item.type === 'summary') {
if (tableOpen) {
output.push('');
tableOpen = false;
currentFile = null;
lastTime = '';
}
const summary = item.data;
const summaryTitle = `${summary.request || 'Session started'} (${formatDateTime(summary.displayTime)})`;
if (useColors) {
output.push(`🎯 ${colors.yellow}#S${summary.id}${colors.reset} ${summaryTitle}`);
} else {
output.push(`**🎯 #S${summary.id}** ${summaryTitle}`);
}
output.push('');
} else {
const obs = item.data;
const file = extractFirstFile(obs.files_modified, cwd);
if (file !== currentFile) {
if (tableOpen) {
output.push('');
}
if (useColors) {
output.push(`${colors.dim}${file}${colors.reset}`);
} else {
output.push(`**${file}**`);
}
if (!useColors) {
output.push(`| ID | Time | T | Title | Read | Work |`);
output.push(`|----|------|---|-------|------|------|`);
}
currentFile = file;
tableOpen = true;
lastTime = '';
}
const time = formatTime(obs.created_at);
const title = obs.title || 'Untitled';
const icon = ModeManager.getInstance().getTypeIcon(obs.type);
const obsSize = (obs.title?.length || 0) +
(obs.subtitle?.length || 0) +
(obs.narrative?.length || 0) +
JSON.stringify(obs.facts || []).length;
const readTokens = Math.ceil(obsSize / CHARS_PER_TOKEN_ESTIMATE);
const discoveryTokens = obs.discovery_tokens || 0;
const workEmoji = ModeManager.getInstance().getWorkEmoji(obs.type);
const discoveryDisplay = discoveryTokens > 0 ? `${workEmoji} ${discoveryTokens.toLocaleString()}` : '-';
const showTime = time !== lastTime;
const timeDisplay = showTime ? time : '';
lastTime = time;
const shouldShowFull = fullObservationIds.has(obs.id);
if (shouldShowFull) {
const detailField = config.fullObservationField === 'narrative'
? obs.narrative
: (obs.facts ? parseJsonArray(obs.facts).join('\n') : null);
if (useColors) {
const timePart = showTime ? `${colors.dim}${time}${colors.reset}` : ' '.repeat(time.length);
const readPart = (config.showReadTokens && readTokens > 0) ? `${colors.dim}(~${readTokens}t)${colors.reset}` : '';
const discoveryPart = (config.showWorkTokens && discoveryTokens > 0) ? `${colors.dim}(${workEmoji} ${discoveryTokens.toLocaleString()}t)${colors.reset}` : '';
output.push(` ${colors.dim}#${obs.id}${colors.reset} ${timePart} ${icon} ${colors.bright}${title}${colors.reset}`);
if (detailField) {
output.push(` ${colors.dim}${detailField}${colors.reset}`);
}
if (readPart || discoveryPart) {
output.push(` ${readPart} ${discoveryPart}`);
}
output.push('');
} else {
if (tableOpen) {
output.push('');
tableOpen = false;
}
output.push(`**#${obs.id}** ${timeDisplay || '″'} ${icon} **${title}**`);
if (detailField) {
output.push('');
output.push(detailField);
output.push('');
}
const tokenParts: string[] = [];
if (config.showReadTokens) {
tokenParts.push(`Read: ~${readTokens}`);
}
if (config.showWorkTokens) {
tokenParts.push(`Work: ${discoveryDisplay}`);
}
if (tokenParts.length > 0) {
output.push(tokenParts.join(', '));
}
output.push('');
currentFile = null;
}
} else {
if (useColors) {
const timePart = showTime ? `${colors.dim}${time}${colors.reset}` : ' '.repeat(time.length);
const readPart = (config.showReadTokens && readTokens > 0) ? `${colors.dim}(~${readTokens}t)${colors.reset}` : '';
const discoveryPart = (config.showWorkTokens && discoveryTokens > 0) ? `${colors.dim}(${workEmoji} ${discoveryTokens.toLocaleString()}t)${colors.reset}` : '';
output.push(` ${colors.dim}#${obs.id}${colors.reset} ${timePart} ${icon} ${title} ${readPart} ${discoveryPart}`);
} else {
const readCol = config.showReadTokens ? `~${readTokens}` : '';
const workCol = config.showWorkTokens ? discoveryDisplay : '';
output.push(`| #${obs.id} | ${timeDisplay || '″'} | ${icon} | ${title} | ${readCol} | ${workCol} |`);
}
}
}
}
if (tableOpen) {
output.push('');
}
}
// Add full summary details for most recent session
const mostRecentSummary = recentSummaries[0];
const mostRecentObservation = observations[0];
const shouldShowSummary = config.showLastSummary &&
mostRecentSummary &&
(mostRecentSummary.investigated || mostRecentSummary.learned || mostRecentSummary.completed || mostRecentSummary.next_steps) &&
(!mostRecentObservation || mostRecentSummary.created_at_epoch > mostRecentObservation.created_at_epoch);
if (shouldShowSummary) {
output.push(...renderSummaryField('Investigated', mostRecentSummary.investigated, colors.blue, useColors));
output.push(...renderSummaryField('Learned', mostRecentSummary.learned, colors.yellow, useColors));
output.push(...renderSummaryField('Completed', mostRecentSummary.completed, colors.green, useColors));
output.push(...renderSummaryField('Next Steps', mostRecentSummary.next_steps, colors.magenta, useColors));
}
// Previously section
if (priorAssistantMessage) {
output.push('');
output.push('---');
output.push('');
if (useColors) {
output.push(`${colors.bright}${colors.magenta}📋 Previously${colors.reset}`);
output.push('');
output.push(`${colors.dim}A: ${priorAssistantMessage}${colors.reset}`);
} else {
output.push(`**📋 Previously**`);
output.push('');
output.push(`A: ${priorAssistantMessage}`);
}
output.push('');
}
// Footer
if (showContextEconomics && totalDiscoveryTokens > 0 && savings > 0) {
const workTokensK = Math.round(totalDiscoveryTokens / 1000);
output.push('');
if (useColors) {
output.push(`${colors.dim}💰 Access ${workTokensK}k tokens of past research & decisions for just ${totalReadTokens.toLocaleString()}t. Use the mem-search skill to access memories by ID instead of re-reading files.${colors.reset}`);
} else {
output.push(`💰 Access ${workTokensK}k tokens of past research & decisions for just ${totalReadTokens.toLocaleString()}t. Use the mem-search skill to access memories by ID instead of re-reading files.`);
}
}
}
db?.close();
return output.join('\n').trimEnd();
}
// Re-export everything from the new context module
export { generateContext } from './context/index.js';
export type { ContextInput, ContextConfig } from './context/types.js';