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:
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"claude-mem-search": {
|
"claude-mem-search": {
|
||||||
"type": "stdio",
|
"type": "stdio",
|
||||||
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/search-server.cjs"
|
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/mcp-server.cjs"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Executable
+658
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+13
-13
@@ -26,9 +26,9 @@ const WORKER_SERVICE = {
|
|||||||
source: 'src/services/worker-service.ts'
|
source: 'src/services/worker-service.ts'
|
||||||
};
|
};
|
||||||
|
|
||||||
const SEARCH_SERVER = {
|
const MCP_SERVER = {
|
||||||
name: 'search-server',
|
name: 'mcp-server',
|
||||||
source: 'src/servers/search-server.ts'
|
source: 'src/servers/mcp-server.ts'
|
||||||
};
|
};
|
||||||
|
|
||||||
const CONTEXT_GENERATOR = {
|
const CONTEXT_GENERATOR = {
|
||||||
@@ -97,15 +97,15 @@ async function buildHooks() {
|
|||||||
const workerStats = fs.statSync(`${hooksDir}/${WORKER_SERVICE.name}.cjs`);
|
const workerStats = fs.statSync(`${hooksDir}/${WORKER_SERVICE.name}.cjs`);
|
||||||
console.log(`✓ worker-service built (${(workerStats.size / 1024).toFixed(2)} KB)`);
|
console.log(`✓ worker-service built (${(workerStats.size / 1024).toFixed(2)} KB)`);
|
||||||
|
|
||||||
// Build search server
|
// Build MCP server
|
||||||
console.log(`\n🔧 Building search server...`);
|
console.log(`\n🔧 Building MCP server...`);
|
||||||
await build({
|
await build({
|
||||||
entryPoints: [SEARCH_SERVER.source],
|
entryPoints: [MCP_SERVER.source],
|
||||||
bundle: true,
|
bundle: true,
|
||||||
platform: 'node',
|
platform: 'node',
|
||||||
target: 'node18',
|
target: 'node18',
|
||||||
format: 'cjs',
|
format: 'cjs',
|
||||||
outfile: `${hooksDir}/${SEARCH_SERVER.name}.cjs`,
|
outfile: `${hooksDir}/${MCP_SERVER.name}.cjs`,
|
||||||
minify: true,
|
minify: true,
|
||||||
logLevel: 'error',
|
logLevel: 'error',
|
||||||
external: ['better-sqlite3'],
|
external: ['better-sqlite3'],
|
||||||
@@ -117,10 +117,10 @@ async function buildHooks() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Make search server executable
|
// Make MCP server executable
|
||||||
fs.chmodSync(`${hooksDir}/${SEARCH_SERVER.name}.cjs`, 0o755);
|
fs.chmodSync(`${hooksDir}/${MCP_SERVER.name}.cjs`, 0o755);
|
||||||
const searchServerStats = fs.statSync(`${hooksDir}/${SEARCH_SERVER.name}.cjs`);
|
const mcpServerStats = fs.statSync(`${hooksDir}/${MCP_SERVER.name}.cjs`);
|
||||||
console.log(`✓ search-server built (${(searchServerStats.size / 1024).toFixed(2)} KB)`);
|
console.log(`✓ mcp-server built (${(mcpServerStats.size / 1024).toFixed(2)} KB)`);
|
||||||
|
|
||||||
// Build context generator
|
// Build context generator
|
||||||
console.log(`\n🔧 Building context generator...`);
|
console.log(`\n🔧 Building context generator...`);
|
||||||
@@ -174,11 +174,11 @@ async function buildHooks() {
|
|||||||
console.log(`✓ ${hook.name} built (${sizeInKB} KB)`);
|
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(` Output: ${hooksDir}/`);
|
||||||
console.log(` - Hooks: *-hook.js`);
|
console.log(` - Hooks: *-hook.js`);
|
||||||
console.log(` - Worker: worker-service.cjs`);
|
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(` - Skills: plugin/skills/`);
|
||||||
console.log('\n💡 Note: Dependencies will be auto-installed on first hook execution');
|
console.log('\n💡 Note: Dependencies will be auto-installed on first hook execution');
|
||||||
|
|
||||||
|
|||||||
@@ -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`);
|
||||||
@@ -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
|
* Close the Chroma client connection
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -169,16 +169,16 @@ export class WorkerService {
|
|||||||
// Initialize database (once, stays open)
|
// Initialize database (once, stays open)
|
||||||
await this.dbManager.initialize();
|
await this.dbManager.initialize();
|
||||||
|
|
||||||
// Connect to MCP search server
|
// Connect to MCP server
|
||||||
const searchServerPath = path.join(__dirname, '..', '..', 'plugin', 'scripts', 'search-server.cjs');
|
const mcpServerPath = path.join(__dirname, '..', '..', 'plugin', 'scripts', 'mcp-server.cjs');
|
||||||
const transport = new StdioClientTransport({
|
const transport = new StdioClientTransport({
|
||||||
command: 'node',
|
command: 'node',
|
||||||
args: [searchServerPath],
|
args: [mcpServerPath],
|
||||||
env: process.env
|
env: process.env
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.mcpClient.connect(transport);
|
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
|
// Shutdown all active sessions
|
||||||
await this.sessionManager.shutdownAll();
|
await this.sessionManager.shutdownAll();
|
||||||
|
|
||||||
// Close MCP client connection (terminates search server process)
|
// Close MCP client connection (terminates MCP server process)
|
||||||
if (this.mcpClient) {
|
if (this.mcpClient) {
|
||||||
try {
|
try {
|
||||||
await this.mcpClient.close();
|
await this.mcpClient.close();
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user