feat(timeline): implement TimelineService for building and formatting timeline items

- Extracted timeline-related functionality from mcp-server.ts to a dedicated TimelineService class.
- Added methods to build, filter, and format timeline items based on observations, sessions, and prompts.
- Introduced interfaces for TimelineItem and TimelineData to standardize data structures.
- Implemented sorting and grouping of timeline items by date, with markdown formatting for output.
- Included utility methods for date and time formatting, as well as token estimation.
This commit is contained in:
Alex Newman
2025-12-07 19:14:18 -05:00
parent 83b4806718
commit c415ff5120
11 changed files with 3578 additions and 20 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
"mcpServers": {
"claude-mem-search": {
"type": "stdio",
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/search-server.cjs"
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/mcp-server.cjs"
}
}
}
+658
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+13 -13
View File
@@ -26,9 +26,9 @@ const WORKER_SERVICE = {
source: 'src/services/worker-service.ts'
};
const SEARCH_SERVER = {
name: 'search-server',
source: 'src/servers/search-server.ts'
const MCP_SERVER = {
name: 'mcp-server',
source: 'src/servers/mcp-server.ts'
};
const CONTEXT_GENERATOR = {
@@ -97,15 +97,15 @@ async function buildHooks() {
const workerStats = fs.statSync(`${hooksDir}/${WORKER_SERVICE.name}.cjs`);
console.log(`✓ worker-service built (${(workerStats.size / 1024).toFixed(2)} KB)`);
// Build search server
console.log(`\n🔧 Building search server...`);
// Build MCP server
console.log(`\n🔧 Building MCP server...`);
await build({
entryPoints: [SEARCH_SERVER.source],
entryPoints: [MCP_SERVER.source],
bundle: true,
platform: 'node',
target: 'node18',
format: 'cjs',
outfile: `${hooksDir}/${SEARCH_SERVER.name}.cjs`,
outfile: `${hooksDir}/${MCP_SERVER.name}.cjs`,
minify: true,
logLevel: 'error',
external: ['better-sqlite3'],
@@ -117,10 +117,10 @@ async function buildHooks() {
}
});
// Make search server executable
fs.chmodSync(`${hooksDir}/${SEARCH_SERVER.name}.cjs`, 0o755);
const searchServerStats = fs.statSync(`${hooksDir}/${SEARCH_SERVER.name}.cjs`);
console.log(`search-server built (${(searchServerStats.size / 1024).toFixed(2)} KB)`);
// Make MCP server executable
fs.chmodSync(`${hooksDir}/${MCP_SERVER.name}.cjs`, 0o755);
const mcpServerStats = fs.statSync(`${hooksDir}/${MCP_SERVER.name}.cjs`);
console.log(`mcp-server built (${(mcpServerStats.size / 1024).toFixed(2)} KB)`);
// Build context generator
console.log(`\n🔧 Building context generator...`);
@@ -174,11 +174,11 @@ async function buildHooks() {
console.log(`${hook.name} built (${sizeInKB} KB)`);
}
console.log('\n✅ All hooks, worker service, and search server built successfully!');
console.log('\n✅ All hooks, worker service, and MCP server built successfully!');
console.log(` Output: ${hooksDir}/`);
console.log(` - Hooks: *-hook.js`);
console.log(` - Worker: worker-service.cjs`);
console.log(` - Search Server: search-server.cjs`);
console.log(` - MCP Server: mcp-server.cjs`);
console.log(` - Skills: plugin/skills/`);
console.log('\n💡 Note: Dependencies will be auto-installed on first hook execution');
+229
View File
@@ -0,0 +1,229 @@
#!/usr/bin/env node
/**
* One-time script to extract tool handlers from mcp-server.ts into SearchManager.ts
*/
import { readFileSync, writeFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const projectRoot = join(__dirname, '..');
const mcpServerPath = join(projectRoot, 'src/servers/mcp-server.ts');
const outputPath = join(projectRoot, 'src/services/worker/SearchManager.ts');
console.log('Reading mcp-server.ts...');
const content = readFileSync(mcpServerPath, 'utf-8');
// Extract just the sections we need by finding line numbers
// This is more reliable than parsing
// Extract tool handler bodies by finding each "handler: async (args: any) => {"
// and extracting until the matching closing brace
const extractHandlerBody = (content, startPattern) => {
const lines = content.split('\n');
const startIdx = lines.findIndex(line => line.includes(startPattern));
if (startIdx === -1) return null;
// Find the "handler: async (args: any) => {" line
let handlerIdx = -1;
for (let i = startIdx; i < Math.min(startIdx + 30, lines.length); i++) {
if (lines[i].includes('handler: async (args: any) => {')) {
handlerIdx = i;
break;
}
}
if (handlerIdx === -1) return null;
// Extract the body by counting braces
let braceCount = 0;
let bodyLines = [];
let started = false;
for (let i = handlerIdx; i < lines.length; i++) {
const line = lines[i];
for (const char of line) {
if (char === '{') {
braceCount++;
started = true;
} else if (char === '}') {
braceCount--;
}
}
if (started) {
bodyLines.push(line);
}
if (started && braceCount === 0) {
break;
}
}
// Remove the first line (handler wrapper) and last line (closing brace)
if (bodyLines.length > 2) {
bodyLines = bodyLines.slice(1, -1);
}
return bodyLines.join('\n');
};
// Tool name to search pattern mapping
const tools = {
'search': "name: 'search'",
'timeline': "name: 'timeline'",
'decisions': "name: 'decisions'",
'changes': "name: 'changes'",
'how_it_works': "name: 'how_it_works'",
'search_observations': "name: 'search_observations'",
'search_sessions': "name: 'search_sessions'",
'search_user_prompts': "name: 'search_user_prompts'",
'find_by_concept': "name: 'find_by_concept'",
'find_by_file': "name: 'find_by_file'",
'find_by_type': "name: 'find_by_type'",
'get_recent_context': "name: 'get_recent_context'",
'get_context_timeline': "name: 'get_context_timeline'",
'get_timeline_by_query': "name: 'get_timeline_by_query'"
};
console.log('Extracting tool handlers...');
const handlers = {};
for (const [toolName, pattern] of Object.entries(tools)) {
console.log(` Extracting ${toolName}...`);
const body = extractHandlerBody(content, pattern);
if (body) {
handlers[toolName] = body;
console.log(`${body.split('\n').length} lines`);
} else {
console.log(` ✗ Not found`);
}
}
console.log(`\nExtracted ${Object.keys(handlers).length}/${Object.keys(tools).length} handlers`);
// Now generate SearchManager.ts
console.log('\nGenerating SearchManager.ts...');
const methodBodies = Object.entries(handlers).map(([toolName, body]) => {
// Convert tool name to camelCase method name
const methodName = toolName.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
// Replace standalone function calls with class methods
let processedBody = body
.replace(/formatSearchTips\(\)/g, 'this.formatter.formatSearchTips()')
.replace(/formatObservationIndex\(/g, 'this.formatter.formatObservationIndex(')
.replace(/formatSessionIndex\(/g, 'this.formatter.formatSessionIndex(')
.replace(/formatUserPromptIndex\(/g, 'this.formatter.formatUserPromptIndex(')
.replace(/formatObservationResult\(/g, 'this.formatter.formatObservationResult(')
.replace(/formatSessionResult\(/g, 'this.formatter.formatSessionResult(')
.replace(/formatUserPromptResult\(/g, 'this.formatter.formatUserPromptResult(')
.replace(/filterTimelineByDepth\(/g, 'this.timeline.filterByDepth(')
.replace(/\bsearch\./g, 'this.sessionSearch.')
.replace(/\bstore\./g, 'this.sessionStore.')
.replace(/queryChroma\(/g, 'this.queryChroma(')
.replace(/normalizeParams\(/g, 'this.normalizeParams(')
.replace(/chromaClient/g, 'this.chromaSync');
return ` /**
* Tool handler: ${toolName}
*/
async ${methodName}(args: any): Promise<any> {
${processedBody}
}`;
}).join('\n\n');
const searchManagerContent = `/**
* SearchManager - Core search orchestration for claude-mem
* Extracted from mcp-server.ts to centralize business logic in Worker services
*
* This class contains all tool handler logic that was previously in the MCP server.
* The MCP server now acts as a thin HTTP wrapper that calls these methods via HTTP.
*/
import { SessionSearch } from '../sqlite/SessionSearch.js';
import { SessionStore } from '../sqlite/SessionStore.js';
import { ChromaSync } from '../sync/ChromaSync.js';
import { FormattingService } from './FormattingService.js';
import { TimelineService, TimelineItem } from './TimelineService.js';
import { ObservationSearchResult, SessionSummarySearchResult, UserPromptSearchResult } from '../sqlite/types.js';
import { silentDebug } from '../../utils/silent-debug.js';
const COLLECTION_NAME = 'cm__claude-mem';
export class SearchManager {
constructor(
private sessionSearch: SessionSearch,
private sessionStore: SessionStore,
private chromaSync: ChromaSync,
private formatter: FormattingService,
private timeline: TimelineService
) {}
/**
* Query Chroma vector database via ChromaSync
*/
private async queryChroma(
query: string,
limit: number,
whereFilter?: Record<string, any>
): Promise<{ ids: number[]; distances: number[]; metadatas: any[] }> {
return await this.chromaSync.queryChroma(query, limit, whereFilter);
}
/**
* Helper to normalize query parameters from URL-friendly format
* Converts comma-separated strings to arrays and flattens date params
*/
private normalizeParams(args: any): any {
const normalized: any = { ...args };
// Parse comma-separated concepts into array
if (normalized.concepts && typeof normalized.concepts === 'string') {
normalized.concepts = normalized.concepts.split(',').map((s: string) => s.trim()).filter(Boolean);
}
// Parse comma-separated files into array
if (normalized.files && typeof normalized.files === 'string') {
normalized.files = normalized.files.split(',').map((s: string) => s.trim()).filter(Boolean);
}
// Parse comma-separated obs_type into array
if (normalized.obs_type && typeof normalized.obs_type === 'string') {
normalized.obs_type = normalized.obs_type.split(',').map((s: string) => s.trim()).filter(Boolean);
}
// Parse comma-separated type (for filterSchema) into array
if (normalized.type && typeof normalized.type === 'string' && normalized.type.includes(',')) {
normalized.type = normalized.type.split(',').map((s: string) => s.trim()).filter(Boolean);
}
// Flatten dateStart/dateEnd into dateRange object
if (normalized.dateStart || normalized.dateEnd) {
normalized.dateRange = {
start: normalized.dateStart,
end: normalized.dateEnd
};
delete normalized.dateStart;
delete normalized.dateEnd;
}
return normalized;
}
${methodBodies}
}
`;
writeFileSync(outputPath, searchManagerContent, 'utf-8');
console.log(`\n✅ SearchManager.ts generated at ${outputPath}`);
console.log(` Total methods: ${Object.keys(handlers).length + 2} (${Object.keys(handlers).length} tools + queryChroma + normalizeParams)`);
console.log(` File size: ${(searchManagerContent.length / 1024).toFixed(1)} KB`);
+73
View File
@@ -729,6 +729,79 @@ export class ChromaSync {
}
}
/**
* Query Chroma collection for semantic search
* Used by SearchManager for vector-based search
*/
async queryChroma(
query: string,
limit: number,
whereFilter?: Record<string, any>
): Promise<{ ids: number[]; distances: number[]; metadatas: any[] }> {
await this.ensureConnection();
if (!this.client) {
throw new Error('Chroma client not initialized');
}
const whereStringified = whereFilter ? JSON.stringify(whereFilter) : undefined;
const arguments_obj = {
collection_name: this.collectionName,
query_texts: [query],
n_results: limit,
include: ['documents', 'metadatas', 'distances'],
where: whereStringified
};
const result = await this.client.callTool({
name: 'chroma_query_documents',
arguments: arguments_obj
});
const resultText = result.content[0]?.text || '';
// Parse JSON response
let parsed: any;
try {
parsed = JSON.parse(resultText);
} catch (error) {
logger.error('CHROMA_SYNC', 'Failed to parse Chroma response', { project: this.project }, error as Error);
return { ids: [], distances: [], metadatas: [] };
}
// Extract unique IDs from document IDs
const ids: number[] = [];
const docIds = parsed.ids?.[0] || [];
for (const docId of docIds) {
// Extract sqlite_id from document ID (supports three formats):
// - obs_{id}_narrative, obs_{id}_fact_0, etc (observations)
// - summary_{id}_request, summary_{id}_learned, etc (session summaries)
// - prompt_{id} (user prompts)
const obsMatch = docId.match(/obs_(\d+)_/);
const summaryMatch = docId.match(/summary_(\d+)_/);
const promptMatch = docId.match(/prompt_(\d+)/);
let sqliteId: number | null = null;
if (obsMatch) {
sqliteId = parseInt(obsMatch[1], 10);
} else if (summaryMatch) {
sqliteId = parseInt(summaryMatch[1], 10);
} else if (promptMatch) {
sqliteId = parseInt(promptMatch[1], 10);
}
if (sqliteId !== null && !ids.includes(sqliteId)) {
ids.push(sqliteId);
}
}
const distances = parsed.distances?.[0] || [];
const metadatas = parsed.metadatas?.[0] || [];
return { ids, distances, metadatas };
}
/**
* Close the Chroma client connection
*/
+5 -5
View File
@@ -169,16 +169,16 @@ export class WorkerService {
// Initialize database (once, stays open)
await this.dbManager.initialize();
// Connect to MCP search server
const searchServerPath = path.join(__dirname, '..', '..', 'plugin', 'scripts', 'search-server.cjs');
// Connect to MCP server
const mcpServerPath = path.join(__dirname, '..', '..', 'plugin', 'scripts', 'mcp-server.cjs');
const transport = new StdioClientTransport({
command: 'node',
args: [searchServerPath],
args: [mcpServerPath],
env: process.env
});
await this.mcpClient.connect(transport);
logger.success('WORKER', 'Connected to MCP search server');
logger.success('WORKER', 'Connected to MCP server');
}
/**
@@ -188,7 +188,7 @@ export class WorkerService {
// Shutdown all active sessions
await this.sessionManager.shutdownAll();
// Close MCP client connection (terminates search server process)
// Close MCP client connection (terminates MCP server process)
if (this.mcpClient) {
try {
await this.mcpClient.close();
+233
View File
@@ -0,0 +1,233 @@
/**
* FormattingService - Handles all formatting logic for search results
* Extracted from mcp-server.ts to follow worker service organization pattern
*/
import { ObservationSearchResult, SessionSummarySearchResult, UserPromptSearchResult } from '../sqlite/types.js';
export type FormatType = 'index' | 'full';
export class FormattingService {
/**
* Format search tips footer
*/
formatSearchTips(): string {
return `\n---
💡 Search Strategy:
ALWAYS search with index format FIRST to get an overview and identify relevant results.
This is critical for token efficiency - index format uses ~10x fewer tokens than full format.
Search workflow:
1. Initial search: Use default (index) format to see titles, dates, and sources
2. Review results: Identify which items are most relevant to your needs
3. Deep dive: Only then use format: "full" on specific items of interest
4. Narrow down: Use filters (type, dateStart/dateEnd, concepts, files) to refine results
Other tips:
• To search by concept: Use find_by_concept tool
• To browse by type: Use find_by_type with ["decision", "feature", etc.]
• To sort by date: Use orderBy: "date_desc" or "date_asc"`;
}
/**
* Format observation as index entry (title, date, ID only)
*/
formatObservationIndex(obs: ObservationSearchResult, index: number): string {
const title = obs.title || `Observation #${obs.id}`;
const date = new Date(obs.created_at_epoch).toLocaleString();
const type = obs.type ? `[${obs.type}]` : '';
return `${index + 1}. ${type} ${title}
Date: ${date}
Source: claude-mem://observation/${obs.id}`;
}
/**
* Format session summary as index entry (title, date, ID only)
*/
formatSessionIndex(session: SessionSummarySearchResult, index: number): string {
const title = session.request || `Session ${session.sdk_session_id?.substring(0, 8) || 'unknown'}`;
const date = new Date(session.created_at_epoch).toLocaleString();
return `${index + 1}. ${title}
Date: ${date}
Source: claude-mem://session/${session.sdk_session_id}`;
}
/**
* Format user prompt as index entry (full text - don't truncate context!)
*/
formatUserPromptIndex(prompt: UserPromptSearchResult, index: number): string {
const date = new Date(prompt.created_at_epoch).toLocaleString();
return `${index + 1}. "${prompt.prompt_text}"
Date: ${date} | Prompt #${prompt.prompt_number}
Source: claude-mem://user-prompt/${prompt.id}`;
}
/**
* Format observation as text content with metadata
*/
formatObservationResult(obs: ObservationSearchResult): string {
const title = obs.title || `Observation #${obs.id}`;
// Build content from available fields
const contentParts: string[] = [];
contentParts.push(`## ${title}`);
contentParts.push(`*Source: claude-mem://observation/${obs.id}*`);
contentParts.push('');
if (obs.subtitle) {
contentParts.push(`**${obs.subtitle}**`);
contentParts.push('');
}
if (obs.narrative) {
contentParts.push(obs.narrative);
contentParts.push('');
}
if (obs.text) {
contentParts.push(obs.text);
contentParts.push('');
}
// Add metadata
const metadata: string[] = [];
metadata.push(`Type: ${obs.type}`);
if (obs.facts) {
try {
const facts = JSON.parse(obs.facts);
if (facts.length > 0) {
metadata.push(`Facts: ${facts.join('; ')}`);
}
} catch {}
}
if (obs.concepts) {
try {
const concepts = JSON.parse(obs.concepts);
if (concepts.length > 0) {
metadata.push(`Concepts: ${concepts.join(', ')}`);
}
} catch {}
}
if (obs.files_read || obs.files_modified) {
const files: string[] = [];
if (obs.files_read) {
try {
files.push(...JSON.parse(obs.files_read));
} catch {}
}
if (obs.files_modified) {
try {
files.push(...JSON.parse(obs.files_modified));
} catch {}
}
if (files.length > 0) {
metadata.push(`Files: ${[...new Set(files)].join(', ')}`);
}
}
if (metadata.length > 0) {
contentParts.push('---');
contentParts.push(metadata.join(' | '));
}
// Add date
const date = new Date(obs.created_at_epoch).toLocaleString();
contentParts.push('');
contentParts.push(`---`);
contentParts.push(`Date: ${date}`);
return contentParts.join('\n');
}
/**
* Format session summary as text content with metadata
*/
formatSessionResult(session: SessionSummarySearchResult): string {
const title = session.request || `Session ${session.sdk_session_id?.substring(0, 8) || 'unknown'}`;
// Build content from available fields
const contentParts: string[] = [];
contentParts.push(`## ${title}`);
contentParts.push(`*Source: claude-mem://session/${session.sdk_session_id}*`);
contentParts.push('');
if (session.completed) {
contentParts.push(`**Completed:** ${session.completed}`);
contentParts.push('');
}
if (session.learned) {
contentParts.push(`**Learned:** ${session.learned}`);
contentParts.push('');
}
if (session.investigated) {
contentParts.push(`**Investigated:** ${session.investigated}`);
contentParts.push('');
}
if (session.next_steps) {
contentParts.push(`**Next Steps:** ${session.next_steps}`);
contentParts.push('');
}
if (session.notes) {
contentParts.push(`**Notes:** ${session.notes}`);
contentParts.push('');
}
// Add metadata
const metadata: string[] = [];
if (session.files_read || session.files_edited) {
const files: string[] = [];
if (session.files_read) {
try {
files.push(...JSON.parse(session.files_read));
} catch {}
}
if (session.files_edited) {
try {
files.push(...JSON.parse(session.files_edited));
} catch {}
}
if (files.length > 0) {
metadata.push(`Files: ${[...new Set(files)].join(', ')}`);
}
}
const date = new Date(session.created_at_epoch).toLocaleDateString();
metadata.push(`Date: ${date}`);
if (metadata.length > 0) {
contentParts.push('---');
contentParts.push(metadata.join(' | '));
}
return contentParts.join('\n');
}
/**
* Format user prompt as text content with metadata
*/
formatUserPromptResult(prompt: UserPromptSearchResult): string {
const contentParts: string[] = [];
contentParts.push(`## User Prompt #${prompt.prompt_number}`);
contentParts.push(`*Source: claude-mem://user-prompt/${prompt.id}*`);
contentParts.push('');
contentParts.push(prompt.prompt_text);
contentParts.push('');
contentParts.push('---');
const date = new Date(prompt.created_at_epoch).toLocaleString();
contentParts.push(`Date: ${date}`);
return contentParts.join('\n');
}
}
File diff suppressed because it is too large Load Diff
+270
View File
@@ -0,0 +1,270 @@
/**
* TimelineService - Handles timeline building, filtering, and formatting
* Extracted from mcp-server.ts to follow worker service organization pattern
*/
import { ObservationSearchResult, SessionSummarySearchResult, UserPromptSearchResult } from '../sqlite/types.js';
/**
* Timeline item for unified chronological display
*/
export interface TimelineItem {
type: 'observation' | 'session' | 'prompt';
data: ObservationSearchResult | SessionSummarySearchResult | UserPromptSearchResult;
epoch: number;
}
export interface TimelineData {
observations: ObservationSearchResult[];
sessions: SessionSummarySearchResult[];
prompts: UserPromptSearchResult[];
}
export class TimelineService {
/**
* Build timeline items from observations, sessions, and prompts
*/
buildTimeline(data: TimelineData): TimelineItem[] {
const items: TimelineItem[] = [
...data.observations.map(obs => ({ type: 'observation' as const, data: obs, epoch: obs.created_at_epoch })),
...data.sessions.map(sess => ({ type: 'session' as const, data: sess, epoch: sess.created_at_epoch })),
...data.prompts.map(prompt => ({ type: 'prompt' as const, data: prompt, epoch: prompt.created_at_epoch }))
];
items.sort((a, b) => a.epoch - b.epoch);
return items;
}
/**
* Filter timeline items to respect depth_before/depth_after window around anchor
*/
filterByDepth(
items: TimelineItem[],
anchorId: number | string,
anchorEpoch: number,
depth_before: number,
depth_after: number
): TimelineItem[] {
if (items.length === 0) return items;
let anchorIndex = -1;
if (typeof anchorId === 'number') {
anchorIndex = items.findIndex(item => item.type === 'observation' && (item.data as ObservationSearchResult).id === anchorId);
} else if (typeof anchorId === 'string' && anchorId.startsWith('S')) {
const sessionNum = parseInt(anchorId.slice(1), 10);
anchorIndex = items.findIndex(item => item.type === 'session' && (item.data as SessionSummarySearchResult).id === sessionNum);
} else {
// Timestamp anchor - find closest item
anchorIndex = items.findIndex(item => item.epoch >= anchorEpoch);
if (anchorIndex === -1) anchorIndex = items.length - 1;
}
if (anchorIndex === -1) return items;
const startIndex = Math.max(0, anchorIndex - depth_before);
const endIndex = Math.min(items.length, anchorIndex + depth_after + 1);
return items.slice(startIndex, endIndex);
}
/**
* Format timeline items as markdown with grouped days and tables
*/
formatTimeline(
items: TimelineItem[],
anchorId: number | string | null,
query?: string,
depth_before?: number,
depth_after?: number
): string {
if (items.length === 0) {
return query
? `Found observation matching "${query}", but no timeline context available.`
: 'No timeline items found';
}
const lines: string[] = [];
// Header
if (query && anchorId) {
const anchorObs = items.find(item => item.type === 'observation' && (item.data as ObservationSearchResult).id === anchorId);
const anchorTitle = anchorObs ? ((anchorObs.data as ObservationSearchResult).title || 'Untitled') : 'Unknown';
lines.push(`# Timeline for query: "${query}"`);
lines.push(`**Anchor:** Observation #${anchorId} - ${anchorTitle}`);
} else if (anchorId) {
lines.push(`# Timeline around anchor: ${anchorId}`);
} else {
lines.push(`# Timeline`);
}
if (depth_before !== undefined && depth_after !== undefined) {
lines.push(`**Window:** ${depth_before} records before → ${depth_after} records after | **Items:** ${items.length}`);
} else {
lines.push(`**Items:** ${items.length}`);
}
lines.push('');
// Legend
lines.push(`**Legend:** 🎯 session-request | 🔴 bugfix | 🟣 feature | 🔄 refactor | ✅ change | 🔵 discovery | 🧠 decision`);
lines.push('');
// Group by day
const dayMap = new Map<string, TimelineItem[]>();
for (const item of items) {
const day = this.formatDate(item.epoch);
if (!dayMap.has(day)) {
dayMap.set(day, []);
}
dayMap.get(day)!.push(item);
}
// Sort days chronologically
const sortedDays = Array.from(dayMap.entries()).sort((a, b) => {
const aDate = new Date(a[0]).getTime();
const bDate = new Date(b[0]).getTime();
return aDate - bDate;
});
// Render each day
for (const [day, dayItems] of sortedDays) {
lines.push(`### ${day}`);
lines.push('');
let currentFile: string | null = null;
let lastTime = '';
let tableOpen = false;
for (const item of dayItems) {
const isAnchor = (
(typeof anchorId === 'number' && item.type === 'observation' && (item.data as ObservationSearchResult).id === anchorId) ||
(typeof anchorId === 'string' && anchorId.startsWith('S') && item.type === 'session' && `S${(item.data as SessionSummarySearchResult).id}` === anchorId)
);
if (item.type === 'session') {
if (tableOpen) {
lines.push('');
tableOpen = false;
currentFile = null;
lastTime = '';
}
const sess = item.data as SessionSummarySearchResult;
const title = sess.request || 'Session summary';
const link = `claude-mem://session-summary/${sess.id}`;
const marker = isAnchor ? ' ← **ANCHOR**' : '';
lines.push(`**🎯 #S${sess.id}** ${title} (${this.formatDateTime(item.epoch)}) [→](${link})${marker}`);
lines.push('');
} else if (item.type === 'prompt') {
if (tableOpen) {
lines.push('');
tableOpen = false;
currentFile = null;
lastTime = '';
}
const prompt = item.data as UserPromptSearchResult;
const truncated = prompt.prompt_text.length > 100 ? prompt.prompt_text.substring(0, 100) + '...' : prompt.prompt_text;
lines.push(`**💬 User Prompt #${prompt.prompt_number}** (${this.formatDateTime(item.epoch)})`);
lines.push(`> ${truncated}`);
lines.push('');
} else if (item.type === 'observation') {
const obs = item.data as ObservationSearchResult;
const file = 'General';
if (file !== currentFile) {
if (tableOpen) {
lines.push('');
}
lines.push(`**${file}**`);
lines.push(`| ID | Time | T | Title | Tokens |`);
lines.push(`|----|------|---|-------|--------|`);
currentFile = file;
tableOpen = true;
lastTime = '';
}
const icon = this.getTypeIcon(obs.type);
const time = this.formatTime(item.epoch);
const title = obs.title || 'Untitled';
const tokens = this.estimateTokens(obs.narrative);
const showTime = time !== lastTime;
const timeDisplay = showTime ? time : '″';
lastTime = time;
const anchorMarker = isAnchor ? ' ← **ANCHOR**' : '';
lines.push(`| #${obs.id} | ${timeDisplay} | ${icon} | ${title}${anchorMarker} | ~${tokens} |`);
}
}
if (tableOpen) {
lines.push('');
}
}
return lines.join('\n');
}
/**
* 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 '•';
}
}
/**
* Format date for grouping (e.g., "Dec 7, 2025")
*/
private formatDate(epochMs: number): string {
const date = new Date(epochMs);
return date.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
});
}
/**
* Format time (e.g., "6:30 PM")
*/
private formatTime(epochMs: number): string {
const date = new Date(epochMs);
return date.toLocaleString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true
});
}
/**
* Format date and time (e.g., "Dec 7, 6:30 PM")
*/
private formatDateTime(epochMs: number): string {
const date = new Date(epochMs);
return date.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: true
});
}
/**
* Estimate tokens from text length (~4 chars per token)
*/
private estimateTokens(text: string | null): number {
if (!text) return 0;
return Math.ceil(text.length / 4);
}
}