c3761a2204
- Replaced instances of silentDebug with happy_path_error__with_fallback across multiple files to improve error logging and handling. - Updated the utility function to provide clearer semantics for error handling when expected values are missing. - Introduced a script to find potential silent failures in the codebase that may need to be addressed with the new error handling approach.
2097 lines
80 KiB
TypeScript
2097 lines
80 KiB
TypeScript
/**
|
|
* 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 { basename } from 'path';
|
|
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 { happy_path_error__with_fallback } 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 timelineService: 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;
|
|
}
|
|
|
|
/**
|
|
* Tool handler: search
|
|
*/
|
|
async search(args: any): Promise<any> {
|
|
try {
|
|
// Normalize URL-friendly params to internal format
|
|
const normalized = this.normalizeParams(args);
|
|
const { query, format = 'index', type, obs_type, concepts, files, ...options } = normalized;
|
|
let observations: ObservationSearchResult[] = [];
|
|
let sessions: SessionSummarySearchResult[] = [];
|
|
let prompts: UserPromptSearchResult[] = [];
|
|
|
|
// Determine which types to query based on type filter
|
|
const searchObservations = !type || type === 'observations';
|
|
const searchSessions = !type || type === 'sessions';
|
|
const searchPrompts = !type || type === 'prompts';
|
|
|
|
// PATH 1: FILTER-ONLY (no query text) - Skip Chroma/FTS5, use direct SQLite filtering
|
|
// This path enables date filtering which Chroma cannot do (requires direct SQLite access)
|
|
if (!query) {
|
|
happy_path_error__with_fallback(`[mcp-server] Filter-only query (no query text), using direct SQLite filtering (enables date filters)`);
|
|
const obsOptions = { ...options, type: obs_type, concepts, files };
|
|
if (searchObservations) {
|
|
observations = this.sessionSearch.searchObservations(undefined, obsOptions);
|
|
}
|
|
if (searchSessions) {
|
|
sessions = this.sessionSearch.searchSessions(undefined, options);
|
|
}
|
|
if (searchPrompts) {
|
|
prompts = this.sessionSearch.searchUserPrompts(undefined, options);
|
|
}
|
|
}
|
|
// PATH 2: CHROMA SEMANTIC SEARCH (query text + Chroma available)
|
|
else if (this.chromaSync) {
|
|
let chromaSucceeded = false;
|
|
try {
|
|
happy_path_error__with_fallback(`[mcp-server] Using ChromaDB semantic search (type filter: ${type || 'all'})`);
|
|
|
|
// Build Chroma where filter for doc_type
|
|
let whereFilter: Record<string, any> | undefined;
|
|
if (type === 'observations') {
|
|
whereFilter = { doc_type: 'observation' };
|
|
} else if (type === 'sessions') {
|
|
whereFilter = { doc_type: 'session_summary' };
|
|
} else if (type === 'prompts') {
|
|
whereFilter = { doc_type: 'user_prompt' };
|
|
}
|
|
|
|
// Step 1: Chroma semantic search with optional type filter
|
|
const chromaResults = await this.queryChroma(query, 100, whereFilter);
|
|
chromaSucceeded = true; // Chroma didn't throw error
|
|
happy_path_error__with_fallback(`[mcp-server] ChromaDB returned ${chromaResults.ids.length} semantic matches`);
|
|
|
|
if (chromaResults.ids.length > 0) {
|
|
// Step 2: Filter by recency (90 days)
|
|
const ninetyDaysAgo = Date.now() - (90 * 24 * 60 * 60 * 1000);
|
|
const recentMetadata = chromaResults.metadatas.map((meta, idx) => ({
|
|
id: chromaResults.ids[idx],
|
|
meta,
|
|
isRecent: meta && meta.created_at_epoch > ninetyDaysAgo
|
|
})).filter(item => item.isRecent);
|
|
|
|
happy_path_error__with_fallback(`[mcp-server] ${recentMetadata.length} results within 90-day window`);
|
|
|
|
// Step 3: Categorize IDs by document type
|
|
const obsIds: number[] = [];
|
|
const sessionIds: number[] = [];
|
|
const promptIds: number[] = [];
|
|
|
|
for (const item of recentMetadata) {
|
|
const docType = item.meta?.doc_type;
|
|
if (docType === 'observation' && searchObservations) {
|
|
obsIds.push(item.id);
|
|
} else if (docType === 'session_summary' && searchSessions) {
|
|
sessionIds.push(item.id);
|
|
} else if (docType === 'user_prompt' && searchPrompts) {
|
|
promptIds.push(item.id);
|
|
}
|
|
}
|
|
|
|
happy_path_error__with_fallback(`[mcp-server] Categorized: ${obsIds.length} obs, ${sessionIds.length} sessions, ${promptIds.length} prompts`);
|
|
|
|
// Step 4: Hydrate from SQLite with additional filters
|
|
if (obsIds.length > 0) {
|
|
// Apply obs_type, concepts, files filters if provided
|
|
const obsOptions = { ...options, type: obs_type, concepts, files };
|
|
observations = this.sessionStore.getObservationsByIds(obsIds, obsOptions);
|
|
}
|
|
if (sessionIds.length > 0) {
|
|
sessions = this.sessionStore.getSessionSummariesByIds(sessionIds, { orderBy: 'date_desc', limit: options.limit });
|
|
}
|
|
if (promptIds.length > 0) {
|
|
prompts = this.sessionStore.getUserPromptsByIds(promptIds, { orderBy: 'date_desc', limit: options.limit });
|
|
}
|
|
|
|
happy_path_error__with_fallback(`[mcp-server] Hydrated ${observations.length} obs, ${sessions.length} sessions, ${prompts.length} prompts from SQLite`);
|
|
} else {
|
|
// Chroma returned 0 results - this is the correct answer, don't fall back to FTS5
|
|
happy_path_error__with_fallback(`[mcp-server] ChromaDB found no matches (this is final - NOT falling back to FTS5)`);
|
|
}
|
|
} catch (chromaError: any) {
|
|
happy_path_error__with_fallback('[mcp-server] ChromaDB failed - returning empty results (FTS5 fallback removed):', chromaError.message);
|
|
happy_path_error__with_fallback('[mcp-server] Install UVX/Python to enable vector search: https://docs.astral.sh/uv/getting-started/installation/');
|
|
// Return empty results - no fallback
|
|
observations = [];
|
|
sessions = [];
|
|
prompts = [];
|
|
}
|
|
}
|
|
// ChromaDB not initialized - return empty results (no fallback)
|
|
else {
|
|
happy_path_error__with_fallback(`[mcp-server] ChromaDB not initialized - returning empty results (FTS5 fallback removed)`);
|
|
happy_path_error__with_fallback(`[mcp-server] Install UVX/Python to enable vector search: https://docs.astral.sh/uv/getting-started/installation/`);
|
|
observations = [];
|
|
sessions = [];
|
|
prompts = [];
|
|
}
|
|
|
|
const totalResults = observations.length + sessions.length + prompts.length;
|
|
|
|
if (totalResults === 0) {
|
|
return {
|
|
content: [{
|
|
type: 'text' as const,
|
|
text: `No results found matching "${query}"`
|
|
}]
|
|
};
|
|
}
|
|
|
|
// Combine all results with timestamps for unified sorting
|
|
interface CombinedResult {
|
|
type: 'observation' | 'session' | 'prompt';
|
|
data: any;
|
|
epoch: number;
|
|
}
|
|
|
|
const allResults: CombinedResult[] = [
|
|
...observations.map(obs => ({ type: 'observation' as const, data: obs, epoch: obs.created_at_epoch })),
|
|
...sessions.map(sess => ({ type: 'session' as const, data: sess, epoch: sess.created_at_epoch })),
|
|
...prompts.map(prompt => ({ type: 'prompt' as const, data: prompt, epoch: prompt.created_at_epoch }))
|
|
];
|
|
|
|
// Sort by date (most recent first)
|
|
if (options.orderBy === 'date_desc') {
|
|
allResults.sort((a, b) => b.epoch - a.epoch);
|
|
} else if (options.orderBy === 'date_asc') {
|
|
allResults.sort((a, b) => a.epoch - b.epoch);
|
|
}
|
|
|
|
// Apply limit across all types
|
|
const limitedResults = allResults.slice(0, options.limit || 20);
|
|
|
|
// Format based on requested format
|
|
let combinedText: string;
|
|
if (format === 'index') {
|
|
const header = `Found ${totalResults} result(s) matching "${query}" (${observations.length} obs, ${sessions.length} sessions, ${prompts.length} prompts):\n\n`;
|
|
const formattedResults = limitedResults.map((item, i) => {
|
|
if (item.type === 'observation') {
|
|
return this.formatter.formatObservationIndex(item.data, i);
|
|
} else if (item.type === 'session') {
|
|
return this.formatter.formatSessionIndex(item.data, i);
|
|
} else {
|
|
return this.formatter.formatUserPromptIndex(item.data, i);
|
|
}
|
|
});
|
|
combinedText = header + formattedResults.join('\n\n') + this.formatter.formatSearchTips();
|
|
} else {
|
|
const formattedResults = limitedResults.map(item => {
|
|
if (item.type === 'observation') {
|
|
return this.formatter.formatObservationResult(item.data);
|
|
} else if (item.type === 'session') {
|
|
return this.formatter.formatSessionResult(item.data);
|
|
} else {
|
|
return this.formatter.formatUserPromptResult(item.data);
|
|
}
|
|
});
|
|
combinedText = formattedResults.join('\n\n---\n\n');
|
|
}
|
|
|
|
return {
|
|
content: [{
|
|
type: 'text' as const,
|
|
text: combinedText
|
|
}]
|
|
};
|
|
} catch (error: any) {
|
|
return {
|
|
content: [{
|
|
type: 'text' as const,
|
|
text: `Search failed: ${error.message}`
|
|
}],
|
|
isError: true
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Tool handler: timeline
|
|
*/
|
|
async timeline(args: any): Promise<any> {
|
|
try {
|
|
const { anchor, query, depth_before = 10, depth_after = 10, project } = args;
|
|
|
|
// Validate: must provide either anchor or query, not both
|
|
if (!anchor && !query) {
|
|
return {
|
|
content: [{
|
|
type: 'text' as const,
|
|
text: 'Error: Must provide either "anchor" or "query" parameter'
|
|
}],
|
|
isError: true
|
|
};
|
|
}
|
|
|
|
if (anchor && query) {
|
|
return {
|
|
content: [{
|
|
type: 'text' as const,
|
|
text: 'Error: Cannot provide both "anchor" and "query" parameters. Use one or the other.'
|
|
}],
|
|
isError: true
|
|
};
|
|
}
|
|
|
|
let anchorId: string | number;
|
|
let anchorEpoch: number;
|
|
let timelineData: any;
|
|
|
|
// MODE 1: Query-based timeline
|
|
if (query) {
|
|
// Step 1: Search for observations
|
|
let results: ObservationSearchResult[] = [];
|
|
|
|
if (this.chromaSync) {
|
|
try {
|
|
happy_path_error__with_fallback('[mcp-server] Using hybrid semantic search for timeline query');
|
|
const chromaResults = await this.queryChroma(query, 100);
|
|
happy_path_error__with_fallback(`[mcp-server] Chroma returned ${chromaResults?.ids?.length ?? 0} semantic matches`);
|
|
|
|
if (chromaResults?.ids && chromaResults.ids.length > 0) {
|
|
const ninetyDaysAgo = Date.now() - (90 * 24 * 60 * 60 * 1000);
|
|
const recentIds = chromaResults.ids.filter((_id, idx) => {
|
|
const meta = chromaResults.metadatas[idx];
|
|
return meta && meta.created_at_epoch > ninetyDaysAgo;
|
|
});
|
|
|
|
if (recentIds.length > 0) {
|
|
results = this.sessionStore.getObservationsByIds(recentIds, { orderBy: 'date_desc', limit: 1 });
|
|
}
|
|
}
|
|
} catch (chromaError: any) {
|
|
happy_path_error__with_fallback('[mcp-server] Chroma query failed - no results (FTS5 fallback removed):', chromaError.message);
|
|
}
|
|
}
|
|
|
|
if (results.length === 0) {
|
|
return {
|
|
content: [{
|
|
type: 'text' as const,
|
|
text: `No observations found matching "${query}". Try a different search query.`
|
|
}]
|
|
};
|
|
}
|
|
|
|
// Use top result as anchor
|
|
const topResult = results[0];
|
|
anchorId = topResult.id;
|
|
anchorEpoch = topResult.created_at_epoch;
|
|
happy_path_error__with_fallback(`[mcp-server] Query mode: Using observation #${topResult.id} as timeline anchor`);
|
|
timelineData = this.sessionStore.getTimelineAroundObservation(topResult.id, topResult.created_at_epoch, depth_before, depth_after, project);
|
|
}
|
|
// MODE 2: Anchor-based timeline
|
|
else if (typeof anchor === 'number') {
|
|
// Observation ID
|
|
const obs = this.sessionStore.getObservationById(anchor);
|
|
if (!obs) {
|
|
return {
|
|
content: [{
|
|
type: 'text' as const,
|
|
text: `Observation #${anchor} not found`
|
|
}],
|
|
isError: true
|
|
};
|
|
}
|
|
anchorId = anchor;
|
|
anchorEpoch = obs.created_at_epoch;
|
|
timelineData = this.sessionStore.getTimelineAroundObservation(anchor, anchorEpoch, depth_before, depth_after, project);
|
|
} else if (typeof anchor === 'string') {
|
|
// Session ID or ISO timestamp
|
|
if (anchor.startsWith('S') || anchor.startsWith('#S')) {
|
|
const sessionId = anchor.replace(/^#?S/, '');
|
|
const sessionNum = parseInt(sessionId, 10);
|
|
const sessions = this.sessionStore.getSessionSummariesByIds([sessionNum]);
|
|
if (sessions.length === 0) {
|
|
return {
|
|
content: [{
|
|
type: 'text' as const,
|
|
text: `Session #${sessionNum} not found`
|
|
}],
|
|
isError: true
|
|
};
|
|
}
|
|
anchorEpoch = sessions[0].created_at_epoch;
|
|
anchorId = `S${sessionNum}`;
|
|
timelineData = this.sessionStore.getTimelineAroundTimestamp(anchorEpoch, depth_before, depth_after, project);
|
|
} else {
|
|
// ISO timestamp
|
|
const date = new Date(anchor);
|
|
if (isNaN(date.getTime())) {
|
|
return {
|
|
content: [{
|
|
type: 'text' as const,
|
|
text: `Invalid timestamp: ${anchor}`
|
|
}],
|
|
isError: true
|
|
};
|
|
}
|
|
anchorEpoch = date.getTime();
|
|
anchorId = anchor;
|
|
timelineData = this.sessionStore.getTimelineAroundTimestamp(anchorEpoch, depth_before, depth_after, project);
|
|
}
|
|
} else {
|
|
return {
|
|
content: [{
|
|
type: 'text' as const,
|
|
text: 'Invalid anchor: must be observation ID (number), session ID (e.g., "S123"), or ISO timestamp'
|
|
}],
|
|
isError: true
|
|
};
|
|
}
|
|
|
|
// Combine, sort, and filter timeline items
|
|
const items: TimelineItem[] = [
|
|
...(timelineData.observations || []).map((obs: any) => ({ type: 'observation' as const, data: obs, epoch: obs.created_at_epoch })),
|
|
...(timelineData.sessions || []).map((sess: any) => ({ type: 'session' as const, data: sess, epoch: sess.created_at_epoch })),
|
|
...(timelineData.prompts || []).map((prompt: any) => ({ type: 'prompt' as const, data: prompt, epoch: prompt.created_at_epoch }))
|
|
];
|
|
items.sort((a, b) => a.epoch - b.epoch);
|
|
const filteredItems = this.timelineService.filterByDepth(items, anchorId, anchorEpoch, depth_before, depth_after);
|
|
|
|
if (!filteredItems || filteredItems.length === 0) {
|
|
return {
|
|
content: [{
|
|
type: 'text' as const,
|
|
text: query
|
|
? `Found observation matching "${query}", but no timeline context available (${depth_before} records before, ${depth_after} records after).`
|
|
: `No context found around anchor (${depth_before} records before, ${depth_after} records after)`
|
|
}]
|
|
};
|
|
}
|
|
|
|
// Format timeline (helper functions)
|
|
const formatDate = (epochMs: number): string => {
|
|
const date = new Date(epochMs);
|
|
return date.toLocaleString('en-US', {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
year: 'numeric'
|
|
});
|
|
};
|
|
|
|
const formatTime = (epochMs: number): string => {
|
|
const date = new Date(epochMs);
|
|
return date.toLocaleString('en-US', {
|
|
hour: 'numeric',
|
|
minute: '2-digit',
|
|
hour12: true
|
|
});
|
|
};
|
|
|
|
const 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
|
|
});
|
|
};
|
|
|
|
const estimateTokens = (text: string | null): number => {
|
|
if (!text) return 0;
|
|
return Math.ceil(text.length / 4);
|
|
};
|
|
|
|
// Format results
|
|
const lines: string[] = [];
|
|
|
|
// Header
|
|
if (query) {
|
|
const anchorObs = filteredItems.find(item => item.type === 'observation' && item.data.id === anchorId);
|
|
const anchorTitle = anchorObs && anchorObs.type === 'observation' ? ((anchorObs.data as ObservationSearchResult).title || 'Untitled') : 'Unknown';
|
|
lines.push(`# Timeline for query: "${query}"`);
|
|
lines.push(`**Anchor:** Observation #${anchorId} - ${anchorTitle}`);
|
|
} else {
|
|
lines.push(`# Timeline around anchor: ${anchorId}`);
|
|
}
|
|
|
|
lines.push(`**Window:** ${depth_before} records before → ${depth_after} records after | **Items:** ${filteredItems?.length ?? 0}`);
|
|
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 filteredItems) {
|
|
const day = 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.id === anchorId) ||
|
|
(typeof anchorId === 'string' && anchorId.startsWith('S') && item.type === 'session' && `S${item.data.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} (${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}** (${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 = '';
|
|
}
|
|
|
|
let icon = '•';
|
|
switch (obs.type) {
|
|
case 'bugfix': icon = '🔴'; break;
|
|
case 'feature': icon = '🟣'; break;
|
|
case 'refactor': icon = '🔄'; break;
|
|
case 'change': icon = '✅'; break;
|
|
case 'discovery': icon = '🔵'; break;
|
|
case 'decision': icon = '🧠'; break;
|
|
}
|
|
|
|
const time = formatTime(item.epoch);
|
|
const title = obs.title || 'Untitled';
|
|
const tokens = 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 {
|
|
content: [{
|
|
type: 'text' as const,
|
|
text: lines.join('\n')
|
|
}]
|
|
};
|
|
} catch (error: any) {
|
|
return {
|
|
content: [{
|
|
type: 'text' as const,
|
|
text: `Timeline query failed: ${error.message}`
|
|
}],
|
|
isError: true
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Tool handler: decisions
|
|
*/
|
|
async decisions(args: any): Promise<any> {
|
|
try {
|
|
const normalized = this.normalizeParams(args);
|
|
const { query, format = 'index', ...filters } = normalized;
|
|
let results: ObservationSearchResult[] = [];
|
|
|
|
// Search for decision-type observations
|
|
if (this.chromaSync) {
|
|
try {
|
|
if (query) {
|
|
// Semantic search filtered to decision type
|
|
happy_path_error__with_fallback('[mcp-server] Using Chroma semantic search with type=decision filter');
|
|
const chromaResults = await this.queryChroma(query, Math.min((filters.limit || 20) * 2, 100), { type: 'decision' });
|
|
const obsIds = chromaResults.ids;
|
|
|
|
if (obsIds.length > 0) {
|
|
results = this.sessionStore.getObservationsByIds(obsIds, { ...filters, type: 'decision' });
|
|
// Preserve Chroma ranking order
|
|
results.sort((a, b) => obsIds.indexOf(a.id) - obsIds.indexOf(b.id));
|
|
}
|
|
} else {
|
|
// No query: get all decisions, rank by "decision" keyword
|
|
happy_path_error__with_fallback('[mcp-server] Using metadata-first + semantic ranking for decisions');
|
|
const metadataResults = this.sessionSearch.findByType('decision', filters);
|
|
|
|
if (metadataResults.length > 0) {
|
|
const ids = metadataResults.map(obs => obs.id);
|
|
const chromaResults = await this.queryChroma('decision', Math.min(ids.length, 100));
|
|
|
|
const rankedIds: number[] = [];
|
|
for (const chromaId of chromaResults.ids) {
|
|
if (ids.includes(chromaId) && !rankedIds.includes(chromaId)) {
|
|
rankedIds.push(chromaId);
|
|
}
|
|
}
|
|
|
|
if (rankedIds.length > 0) {
|
|
results = this.sessionStore.getObservationsByIds(rankedIds, { limit: filters.limit || 20 });
|
|
results.sort((a, b) => rankedIds.indexOf(a.id) - rankedIds.indexOf(b.id));
|
|
}
|
|
}
|
|
}
|
|
} catch (chromaError: any) {
|
|
happy_path_error__with_fallback('[mcp-server] Chroma search failed, using SQLite fallback:', chromaError.message);
|
|
}
|
|
}
|
|
|
|
if (results.length === 0) {
|
|
results = this.sessionSearch.findByType('decision', filters);
|
|
}
|
|
|
|
if (results.length === 0) {
|
|
return {
|
|
content: [{
|
|
type: 'text' as const,
|
|
text: 'No decision observations found'
|
|
}]
|
|
};
|
|
}
|
|
|
|
let combinedText: string;
|
|
if (format === 'index') {
|
|
const header = `Found ${results.length} decision(s):\n\n`;
|
|
const formattedResults = results.map((obs, i) => this.formatter.formatObservationIndex(obs, i));
|
|
combinedText = header + formattedResults.join('\n\n');
|
|
} else {
|
|
const formattedResults = results.map((obs) => this.formatter.formatObservationResult(obs));
|
|
combinedText = formattedResults.join('\n\n---\n\n');
|
|
}
|
|
|
|
return {
|
|
content: [{
|
|
type: 'text' as const,
|
|
text: combinedText
|
|
}]
|
|
};
|
|
} catch (error: any) {
|
|
return {
|
|
content: [{
|
|
type: 'text' as const,
|
|
text: `Search failed: ${error.message}`
|
|
}],
|
|
isError: true
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Tool handler: changes
|
|
*/
|
|
async changes(args: any): Promise<any> {
|
|
try {
|
|
const normalized = this.normalizeParams(args);
|
|
const { format = 'index', ...filters } = normalized;
|
|
let results: ObservationSearchResult[] = [];
|
|
|
|
// Search for change-type observations and change-related concepts
|
|
if (this.chromaSync) {
|
|
try {
|
|
happy_path_error__with_fallback('[mcp-server] Using hybrid search for change-related observations');
|
|
|
|
// Get all observations with type="change" or concepts containing change
|
|
const typeResults = this.sessionSearch.findByType('change', filters);
|
|
const conceptChangeResults = this.sessionSearch.findByConcept('change', filters);
|
|
const conceptWhatChangedResults = this.sessionSearch.findByConcept('what-changed', filters);
|
|
|
|
// Combine and deduplicate
|
|
const allIds = new Set<number>();
|
|
[...typeResults, ...conceptChangeResults, ...conceptWhatChangedResults].forEach(obs => allIds.add(obs.id));
|
|
|
|
if (allIds.size > 0) {
|
|
const idsArray = Array.from(allIds);
|
|
const chromaResults = await this.queryChroma('what changed', Math.min(idsArray.length, 100));
|
|
|
|
const rankedIds: number[] = [];
|
|
for (const chromaId of chromaResults.ids) {
|
|
if (idsArray.includes(chromaId) && !rankedIds.includes(chromaId)) {
|
|
rankedIds.push(chromaId);
|
|
}
|
|
}
|
|
|
|
if (rankedIds.length > 0) {
|
|
results = this.sessionStore.getObservationsByIds(rankedIds, { limit: filters.limit || 20 });
|
|
results.sort((a, b) => rankedIds.indexOf(a.id) - rankedIds.indexOf(b.id));
|
|
}
|
|
}
|
|
} catch (chromaError: any) {
|
|
happy_path_error__with_fallback('[mcp-server] Chroma ranking failed, using SQLite order:', chromaError.message);
|
|
}
|
|
}
|
|
|
|
if (results.length === 0) {
|
|
const typeResults = this.sessionSearch.findByType('change', filters);
|
|
const conceptResults = this.sessionSearch.findByConcept('change', filters);
|
|
const whatChangedResults = this.sessionSearch.findByConcept('what-changed', filters);
|
|
|
|
const allIds = new Set<number>();
|
|
[...typeResults, ...conceptResults, ...whatChangedResults].forEach(obs => allIds.add(obs.id));
|
|
|
|
results = Array.from(allIds).map(id =>
|
|
typeResults.find(obs => obs.id === id) ||
|
|
conceptResults.find(obs => obs.id === id) ||
|
|
whatChangedResults.find(obs => obs.id === id)
|
|
).filter(Boolean) as ObservationSearchResult[];
|
|
|
|
results.sort((a, b) => b.created_at_epoch - a.created_at_epoch);
|
|
results = results.slice(0, filters.limit || 20);
|
|
}
|
|
|
|
if (results.length === 0) {
|
|
return {
|
|
content: [{
|
|
type: 'text' as const,
|
|
text: 'No change-related observations found'
|
|
}]
|
|
};
|
|
}
|
|
|
|
let combinedText: string;
|
|
if (format === 'index') {
|
|
const header = `Found ${results.length} change-related observation(s):\n\n`;
|
|
const formattedResults = results.map((obs, i) => this.formatter.formatObservationIndex(obs, i));
|
|
combinedText = header + formattedResults.join('\n\n');
|
|
} else {
|
|
const formattedResults = results.map((obs) => this.formatter.formatObservationResult(obs));
|
|
combinedText = formattedResults.join('\n\n---\n\n');
|
|
}
|
|
|
|
return {
|
|
content: [{
|
|
type: 'text' as const,
|
|
text: combinedText
|
|
}]
|
|
};
|
|
} catch (error: any) {
|
|
return {
|
|
content: [{
|
|
type: 'text' as const,
|
|
text: `Search failed: ${error.message}`
|
|
}],
|
|
isError: true
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Tool handler: how_it_works
|
|
*/
|
|
async howItWorks(args: any): Promise<any> {
|
|
try {
|
|
const normalized = this.normalizeParams(args);
|
|
const { format = 'index', ...filters } = normalized;
|
|
let results: ObservationSearchResult[] = [];
|
|
|
|
// Search for how-it-works concept observations
|
|
if (this.chromaSync) {
|
|
try {
|
|
happy_path_error__with_fallback('[mcp-server] Using metadata-first + semantic ranking for how-it-works');
|
|
const metadataResults = this.sessionSearch.findByConcept('how-it-works', filters);
|
|
|
|
if (metadataResults.length > 0) {
|
|
const ids = metadataResults.map(obs => obs.id);
|
|
const chromaResults = await this.queryChroma('how it works architecture', Math.min(ids.length, 100));
|
|
|
|
const rankedIds: number[] = [];
|
|
for (const chromaId of chromaResults.ids) {
|
|
if (ids.includes(chromaId) && !rankedIds.includes(chromaId)) {
|
|
rankedIds.push(chromaId);
|
|
}
|
|
}
|
|
|
|
if (rankedIds.length > 0) {
|
|
results = this.sessionStore.getObservationsByIds(rankedIds, { limit: filters.limit || 20 });
|
|
results.sort((a, b) => rankedIds.indexOf(a.id) - rankedIds.indexOf(b.id));
|
|
}
|
|
}
|
|
} catch (chromaError: any) {
|
|
happy_path_error__with_fallback('[mcp-server] Chroma ranking failed, using SQLite order:', chromaError.message);
|
|
}
|
|
}
|
|
|
|
if (results.length === 0) {
|
|
results = this.sessionSearch.findByConcept('how-it-works', filters);
|
|
}
|
|
|
|
if (results.length === 0) {
|
|
return {
|
|
content: [{
|
|
type: 'text' as const,
|
|
text: 'No "how it works" observations found'
|
|
}]
|
|
};
|
|
}
|
|
|
|
let combinedText: string;
|
|
if (format === 'index') {
|
|
const header = `Found ${results.length} "how it works" observation(s):\n\n`;
|
|
const formattedResults = results.map((obs, i) => this.formatter.formatObservationIndex(obs, i));
|
|
combinedText = header + formattedResults.join('\n\n');
|
|
} else {
|
|
const formattedResults = results.map((obs) => this.formatter.formatObservationResult(obs));
|
|
combinedText = formattedResults.join('\n\n---\n\n');
|
|
}
|
|
|
|
return {
|
|
content: [{
|
|
type: 'text' as const,
|
|
text: combinedText
|
|
}]
|
|
};
|
|
} catch (error: any) {
|
|
return {
|
|
content: [{
|
|
type: 'text' as const,
|
|
text: `Search failed: ${error.message}`
|
|
}],
|
|
isError: true
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Tool handler: search_observations
|
|
*/
|
|
async searchObservations(args: any): Promise<any> {
|
|
try {
|
|
const normalized = this.normalizeParams(args);
|
|
const { query, format = 'index', ...options } = normalized;
|
|
let results: ObservationSearchResult[] = [];
|
|
|
|
// Vector-first search via ChromaDB
|
|
if (this.chromaSync) {
|
|
try {
|
|
happy_path_error__with_fallback('[mcp-server] Using hybrid semantic search (Chroma + SQLite)');
|
|
|
|
// Step 1: Chroma semantic search (top 100)
|
|
const chromaResults = await this.queryChroma(query, 100);
|
|
happy_path_error__with_fallback(`[mcp-server] Chroma returned ${chromaResults.ids.length} semantic matches`);
|
|
|
|
if (chromaResults.ids.length > 0) {
|
|
// Step 2: Filter by recency (90 days)
|
|
const ninetyDaysAgo = Date.now() - (90 * 24 * 60 * 60 * 1000);
|
|
const recentIds = chromaResults.ids.filter((_id, idx) => {
|
|
const meta = chromaResults.metadatas[idx];
|
|
return meta && meta.created_at_epoch > ninetyDaysAgo;
|
|
});
|
|
|
|
happy_path_error__with_fallback(`[mcp-server] ${recentIds.length} results within 90-day window`);
|
|
|
|
// Step 3: Hydrate from SQLite in temporal order
|
|
if (recentIds.length > 0) {
|
|
const limit = options.limit || 20;
|
|
results = this.sessionStore.getObservationsByIds(recentIds, { orderBy: 'date_desc', limit });
|
|
happy_path_error__with_fallback(`[mcp-server] Hydrated ${results.length} observations from SQLite`);
|
|
}
|
|
}
|
|
} catch (chromaError: any) {
|
|
happy_path_error__with_fallback('[mcp-server] Chroma query failed - no results (FTS5 fallback removed):', chromaError.message);
|
|
}
|
|
}
|
|
|
|
if (results.length === 0) {
|
|
return {
|
|
content: [{
|
|
type: 'text' as const,
|
|
text: `No observations found matching "${query}"`
|
|
}]
|
|
};
|
|
}
|
|
|
|
// Format based on requested format
|
|
let combinedText: string;
|
|
if (format === 'index') {
|
|
const header = `Found ${results.length} observation(s) matching "${query}":\n\n`;
|
|
const formattedResults = results.map((obs, i) => this.formatter.formatObservationIndex(obs, i));
|
|
combinedText = header + formattedResults.join('\n\n') + this.formatter.formatSearchTips();
|
|
} else {
|
|
const formattedResults = results.map((obs) => this.formatter.formatObservationResult(obs));
|
|
combinedText = formattedResults.join('\n\n---\n\n');
|
|
}
|
|
|
|
return {
|
|
content: [{
|
|
type: 'text' as const,
|
|
text: combinedText
|
|
}]
|
|
};
|
|
} catch (error: any) {
|
|
return {
|
|
content: [{
|
|
type: 'text' as const,
|
|
text: `Search failed: ${error.message}`
|
|
}],
|
|
isError: true
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Tool handler: search_sessions
|
|
*/
|
|
async searchSessions(args: any): Promise<any> {
|
|
try {
|
|
const normalized = this.normalizeParams(args);
|
|
const { query, format = 'index', ...options } = normalized;
|
|
let results: SessionSummarySearchResult[] = [];
|
|
|
|
// Vector-first search via ChromaDB
|
|
if (this.chromaSync) {
|
|
try {
|
|
happy_path_error__with_fallback('[mcp-server] Using hybrid semantic search for sessions');
|
|
|
|
// Step 1: Chroma semantic search (top 100)
|
|
const chromaResults = await this.queryChroma(query, 100, { doc_type: 'session_summary' });
|
|
happy_path_error__with_fallback(`[mcp-server] Chroma returned ${chromaResults.ids.length} semantic matches`);
|
|
|
|
if (chromaResults.ids.length > 0) {
|
|
// Step 2: Filter by recency (90 days)
|
|
const ninetyDaysAgo = Date.now() - (90 * 24 * 60 * 60 * 1000);
|
|
const recentIds = chromaResults.ids.filter((_id, idx) => {
|
|
const meta = chromaResults.metadatas[idx];
|
|
return meta && meta.created_at_epoch > ninetyDaysAgo;
|
|
});
|
|
|
|
happy_path_error__with_fallback(`[mcp-server] ${recentIds.length} results within 90-day window`);
|
|
|
|
// Step 3: Hydrate from SQLite in temporal order
|
|
if (recentIds.length > 0) {
|
|
const limit = options.limit || 20;
|
|
results = this.sessionStore.getSessionSummariesByIds(recentIds, { orderBy: 'date_desc', limit });
|
|
happy_path_error__with_fallback(`[mcp-server] Hydrated ${results.length} sessions from SQLite`);
|
|
}
|
|
}
|
|
} catch (chromaError: any) {
|
|
happy_path_error__with_fallback('[mcp-server] Chroma query failed - no results (FTS5 fallback removed):', chromaError.message);
|
|
}
|
|
}
|
|
|
|
if (results.length === 0) {
|
|
return {
|
|
content: [{
|
|
type: 'text' as const,
|
|
text: `No sessions found matching "${query}"`
|
|
}]
|
|
};
|
|
}
|
|
|
|
// Format based on requested format
|
|
let combinedText: string;
|
|
if (format === 'index') {
|
|
const header = `Found ${results.length} session(s) matching "${query}":\n\n`;
|
|
const formattedResults = results.map((session, i) => this.formatter.formatSessionIndex(session, i));
|
|
combinedText = header + formattedResults.join('\n\n') + this.formatter.formatSearchTips();
|
|
} else {
|
|
const formattedResults = results.map((session) => this.formatter.formatSessionResult(session));
|
|
combinedText = formattedResults.join('\n\n---\n\n');
|
|
}
|
|
|
|
return {
|
|
content: [{
|
|
type: 'text' as const,
|
|
text: combinedText
|
|
}]
|
|
};
|
|
} catch (error: any) {
|
|
return {
|
|
content: [{
|
|
type: 'text' as const,
|
|
text: `Search failed: ${error.message}`
|
|
}],
|
|
isError: true
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Tool handler: search_user_prompts
|
|
*/
|
|
async searchUserPrompts(args: any): Promise<any> {
|
|
try {
|
|
const normalized = this.normalizeParams(args);
|
|
const { query, format = 'index', ...options } = normalized;
|
|
let results: UserPromptSearchResult[] = [];
|
|
|
|
// Vector-first search via ChromaDB
|
|
if (this.chromaSync) {
|
|
try {
|
|
happy_path_error__with_fallback('[mcp-server] Using hybrid semantic search for user prompts');
|
|
|
|
// Step 1: Chroma semantic search (top 100)
|
|
const chromaResults = await this.queryChroma(query, 100, { doc_type: 'user_prompt' });
|
|
happy_path_error__with_fallback(`[mcp-server] Chroma returned ${chromaResults.ids.length} semantic matches`);
|
|
|
|
if (chromaResults.ids.length > 0) {
|
|
// Step 2: Filter by recency (90 days)
|
|
const ninetyDaysAgo = Date.now() - (90 * 24 * 60 * 60 * 1000);
|
|
const recentIds = chromaResults.ids.filter((_id, idx) => {
|
|
const meta = chromaResults.metadatas[idx];
|
|
return meta && meta.created_at_epoch > ninetyDaysAgo;
|
|
});
|
|
|
|
happy_path_error__with_fallback(`[mcp-server] ${recentIds.length} results within 90-day window`);
|
|
|
|
// Step 3: Hydrate from SQLite in temporal order
|
|
if (recentIds.length > 0) {
|
|
const limit = options.limit || 20;
|
|
results = this.sessionStore.getUserPromptsByIds(recentIds, { orderBy: 'date_desc', limit });
|
|
happy_path_error__with_fallback(`[mcp-server] Hydrated ${results.length} user prompts from SQLite`);
|
|
}
|
|
}
|
|
} catch (chromaError: any) {
|
|
happy_path_error__with_fallback('[mcp-server] Chroma query failed - no results (FTS5 fallback removed):', chromaError.message);
|
|
}
|
|
}
|
|
|
|
if (results.length === 0) {
|
|
return {
|
|
content: [{
|
|
type: 'text' as const,
|
|
text: query ? `No user prompts found matching "${query}"` : 'No user prompts found'
|
|
}]
|
|
};
|
|
}
|
|
|
|
// Format based on requested format
|
|
let combinedText: string;
|
|
if (format === 'index') {
|
|
const header = `Found ${results.length} user prompt(s) matching "${query}":\n\n`;
|
|
const formattedResults = results.map((prompt, i) => this.formatter.formatUserPromptIndex(prompt, i));
|
|
combinedText = header + formattedResults.join('\n\n') + this.formatter.formatSearchTips();
|
|
} else {
|
|
const formattedResults = results.map((prompt) => this.formatter.formatUserPromptResult(prompt));
|
|
combinedText = formattedResults.join('\n\n---\n\n');
|
|
}
|
|
|
|
return {
|
|
content: [{
|
|
type: 'text' as const,
|
|
text: combinedText
|
|
}]
|
|
};
|
|
} catch (error: any) {
|
|
return {
|
|
content: [{
|
|
type: 'text' as const,
|
|
text: `Search failed: ${error.message}`
|
|
}],
|
|
isError: true
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Tool handler: find_by_concept
|
|
*/
|
|
async findByConcept(args: any): Promise<any> {
|
|
try {
|
|
const normalized = this.normalizeParams(args);
|
|
const { concepts: concept, format = 'index', ...filters } = normalized;
|
|
let results: ObservationSearchResult[] = [];
|
|
|
|
// Metadata-first, semantic-enhanced search
|
|
if (this.chromaSync) {
|
|
try {
|
|
happy_path_error__with_fallback('[mcp-server] Using metadata-first + semantic ranking for concept search');
|
|
|
|
// Step 1: SQLite metadata filter (get all IDs with this concept)
|
|
const metadataResults = this.sessionSearch.findByConcept(concept, filters);
|
|
happy_path_error__with_fallback(`[mcp-server] Found ${metadataResults.length} observations with concept "${concept}"`);
|
|
|
|
if (metadataResults.length > 0) {
|
|
// Step 2: Chroma semantic ranking (rank by relevance to concept)
|
|
const ids = metadataResults.map(obs => obs.id);
|
|
const chromaResults = await this.queryChroma(concept, Math.min(ids.length, 100));
|
|
|
|
// Intersect: Keep only IDs that passed metadata filter, in semantic rank order
|
|
const rankedIds: number[] = [];
|
|
for (const chromaId of chromaResults.ids) {
|
|
if (ids.includes(chromaId) && !rankedIds.includes(chromaId)) {
|
|
rankedIds.push(chromaId);
|
|
}
|
|
}
|
|
|
|
happy_path_error__with_fallback(`[mcp-server] Chroma ranked ${rankedIds.length} results by semantic relevance`);
|
|
|
|
// Step 3: Hydrate in semantic rank order
|
|
if (rankedIds.length > 0) {
|
|
results = this.sessionStore.getObservationsByIds(rankedIds, { limit: filters.limit || 20 });
|
|
// Restore semantic ranking order
|
|
results.sort((a, b) => rankedIds.indexOf(a.id) - rankedIds.indexOf(b.id));
|
|
}
|
|
}
|
|
} catch (chromaError: any) {
|
|
happy_path_error__with_fallback('[mcp-server] Chroma ranking failed, using SQLite order:', chromaError.message);
|
|
// Fall through to SQLite fallback
|
|
}
|
|
}
|
|
|
|
// Fall back to SQLite-only if Chroma unavailable or failed
|
|
if (results.length === 0) {
|
|
happy_path_error__with_fallback('[mcp-server] Using SQLite-only concept search');
|
|
results = this.sessionSearch.findByConcept(concept, filters);
|
|
}
|
|
|
|
if (results.length === 0) {
|
|
return {
|
|
content: [{
|
|
type: 'text' as const,
|
|
text: `No observations found with concept "${concept}"`
|
|
}]
|
|
};
|
|
}
|
|
|
|
// Format based on requested format
|
|
let combinedText: string;
|
|
if (format === 'index') {
|
|
const header = `Found ${results.length} observation(s) with concept "${concept}":\n\n`;
|
|
const formattedResults = results.map((obs, i) => this.formatter.formatObservationIndex(obs, i));
|
|
combinedText = header + formattedResults.join('\n\n') + this.formatter.formatSearchTips();
|
|
} else {
|
|
const formattedResults = results.map((obs) => this.formatter.formatObservationResult(obs));
|
|
combinedText = formattedResults.join('\n\n---\n\n');
|
|
}
|
|
|
|
return {
|
|
content: [{
|
|
type: 'text' as const,
|
|
text: combinedText
|
|
}]
|
|
};
|
|
} catch (error: any) {
|
|
return {
|
|
content: [{
|
|
type: 'text' as const,
|
|
text: `Search failed: ${error.message}`
|
|
}],
|
|
isError: true
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Tool handler: find_by_file
|
|
*/
|
|
async findByFile(args: any): Promise<any> {
|
|
try {
|
|
const normalized = this.normalizeParams(args);
|
|
const { files: filePath, format = 'index', ...filters } = normalized;
|
|
let observations: ObservationSearchResult[] = [];
|
|
let sessions: SessionSummarySearchResult[] = [];
|
|
|
|
// Metadata-first, semantic-enhanced search for observations
|
|
if (this.chromaSync) {
|
|
try {
|
|
happy_path_error__with_fallback('[mcp-server] Using metadata-first + semantic ranking for file search');
|
|
|
|
// Step 1: SQLite metadata filter (get all results with this file)
|
|
const metadataResults = this.sessionSearch.findByFile(filePath, filters);
|
|
happy_path_error__with_fallback(`[mcp-server] Found ${metadataResults.observations.length} observations, ${metadataResults.sessions.length} sessions for file "${filePath}"`);
|
|
|
|
// Sessions: Keep as-is (already summarized, no semantic ranking needed)
|
|
sessions = metadataResults.sessions;
|
|
|
|
// Observations: Apply semantic ranking
|
|
if (metadataResults.observations.length > 0) {
|
|
// Step 2: Chroma semantic ranking (rank by relevance to file path)
|
|
const ids = metadataResults.observations.map(obs => obs.id);
|
|
const chromaResults = await this.queryChroma(filePath, Math.min(ids.length, 100));
|
|
|
|
// Intersect: Keep only IDs that passed metadata filter, in semantic rank order
|
|
const rankedIds: number[] = [];
|
|
for (const chromaId of chromaResults.ids) {
|
|
if (ids.includes(chromaId) && !rankedIds.includes(chromaId)) {
|
|
rankedIds.push(chromaId);
|
|
}
|
|
}
|
|
|
|
happy_path_error__with_fallback(`[mcp-server] Chroma ranked ${rankedIds.length} observations by semantic relevance`);
|
|
|
|
// Step 3: Hydrate in semantic rank order
|
|
if (rankedIds.length > 0) {
|
|
observations = this.sessionStore.getObservationsByIds(rankedIds, { limit: filters.limit || 20 });
|
|
// Restore semantic ranking order
|
|
observations.sort((a, b) => rankedIds.indexOf(a.id) - rankedIds.indexOf(b.id));
|
|
}
|
|
}
|
|
} catch (chromaError: any) {
|
|
happy_path_error__with_fallback('[mcp-server] Chroma ranking failed, using SQLite order:', chromaError.message);
|
|
// Fall through to SQLite fallback
|
|
}
|
|
}
|
|
|
|
// Fall back to SQLite-only if Chroma unavailable or failed
|
|
if (observations.length === 0 && sessions.length === 0) {
|
|
happy_path_error__with_fallback('[mcp-server] Using SQLite-only file search');
|
|
const results = this.sessionSearch.findByFile(filePath, filters);
|
|
observations = results.observations;
|
|
sessions = results.sessions;
|
|
}
|
|
|
|
const totalResults = observations.length + sessions.length;
|
|
|
|
if (totalResults === 0) {
|
|
return {
|
|
content: [{
|
|
type: 'text' as const,
|
|
text: `No results found for file "${filePath}"`
|
|
}]
|
|
};
|
|
}
|
|
|
|
let combinedText: string;
|
|
if (format === 'index') {
|
|
const header = `Found ${totalResults} result(s) for file "${filePath}":\n\n`;
|
|
const formattedResults: string[] = [];
|
|
|
|
// Add observations
|
|
observations.forEach((obs, i) => {
|
|
formattedResults.push(this.formatter.formatObservationIndex(obs, i));
|
|
});
|
|
|
|
// Add sessions
|
|
sessions.forEach((session, i) => {
|
|
formattedResults.push(this.formatter.formatSessionIndex(session, i + observations.length));
|
|
});
|
|
|
|
combinedText = header + formattedResults.join('\n\n') + this.formatter.formatSearchTips();
|
|
} else {
|
|
const formattedResults: string[] = [];
|
|
|
|
// Add observations
|
|
observations.forEach((obs) => {
|
|
formattedResults.push(this.formatter.formatObservationResult(obs));
|
|
});
|
|
|
|
// Add sessions
|
|
sessions.forEach((session) => {
|
|
formattedResults.push(this.formatter.formatSessionResult(session));
|
|
});
|
|
|
|
combinedText = formattedResults.join('\n\n---\n\n');
|
|
}
|
|
|
|
return {
|
|
content: [{
|
|
type: 'text' as const,
|
|
text: combinedText
|
|
}]
|
|
};
|
|
} catch (error: any) {
|
|
return {
|
|
content: [{
|
|
type: 'text' as const,
|
|
text: `Search failed: ${error.message}`
|
|
}],
|
|
isError: true
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Tool handler: find_by_type
|
|
*/
|
|
async findByType(args: any): Promise<any> {
|
|
try {
|
|
const normalized = this.normalizeParams(args);
|
|
const { type, format = 'index', ...filters } = normalized;
|
|
const typeStr = Array.isArray(type) ? type.join(', ') : type;
|
|
let results: ObservationSearchResult[] = [];
|
|
|
|
// Metadata-first, semantic-enhanced search
|
|
if (this.chromaSync) {
|
|
try {
|
|
happy_path_error__with_fallback('[mcp-server] Using metadata-first + semantic ranking for type search');
|
|
|
|
// Step 1: SQLite metadata filter (get all IDs with this type)
|
|
const metadataResults = this.sessionSearch.findByType(type, filters);
|
|
happy_path_error__with_fallback(`[mcp-server] Found ${metadataResults.length} observations with type "${typeStr}"`);
|
|
|
|
if (metadataResults.length > 0) {
|
|
// Step 2: Chroma semantic ranking (rank by relevance to type)
|
|
const ids = metadataResults.map(obs => obs.id);
|
|
const chromaResults = await this.queryChroma(typeStr, Math.min(ids.length, 100));
|
|
|
|
// Intersect: Keep only IDs that passed metadata filter, in semantic rank order
|
|
const rankedIds: number[] = [];
|
|
for (const chromaId of chromaResults.ids) {
|
|
if (ids.includes(chromaId) && !rankedIds.includes(chromaId)) {
|
|
rankedIds.push(chromaId);
|
|
}
|
|
}
|
|
|
|
happy_path_error__with_fallback(`[mcp-server] Chroma ranked ${rankedIds.length} results by semantic relevance`);
|
|
|
|
// Step 3: Hydrate in semantic rank order
|
|
if (rankedIds.length > 0) {
|
|
results = this.sessionStore.getObservationsByIds(rankedIds, { limit: filters.limit || 20 });
|
|
// Restore semantic ranking order
|
|
results.sort((a, b) => rankedIds.indexOf(a.id) - rankedIds.indexOf(b.id));
|
|
}
|
|
}
|
|
} catch (chromaError: any) {
|
|
happy_path_error__with_fallback('[mcp-server] Chroma ranking failed, using SQLite order:', chromaError.message);
|
|
// Fall through to SQLite fallback
|
|
}
|
|
}
|
|
|
|
// Fall back to SQLite-only if Chroma unavailable or failed
|
|
if (results.length === 0) {
|
|
happy_path_error__with_fallback('[mcp-server] Using SQLite-only type search');
|
|
results = this.sessionSearch.findByType(type, filters);
|
|
}
|
|
|
|
if (results.length === 0) {
|
|
return {
|
|
content: [{
|
|
type: 'text' as const,
|
|
text: `No observations found with type "${typeStr}"`
|
|
}]
|
|
};
|
|
}
|
|
|
|
// Format based on requested format
|
|
let combinedText: string;
|
|
if (format === 'index') {
|
|
const header = `Found ${results.length} observation(s) with type "${typeStr}":\n\n`;
|
|
const formattedResults = results.map((obs, i) => this.formatter.formatObservationIndex(obs, i));
|
|
combinedText = header + formattedResults.join('\n\n') + this.formatter.formatSearchTips();
|
|
} else {
|
|
const formattedResults = results.map((obs) => this.formatter.formatObservationResult(obs));
|
|
combinedText = formattedResults.join('\n\n---\n\n');
|
|
}
|
|
|
|
return {
|
|
content: [{
|
|
type: 'text' as const,
|
|
text: combinedText
|
|
}]
|
|
};
|
|
} catch (error: any) {
|
|
return {
|
|
content: [{
|
|
type: 'text' as const,
|
|
text: `Search failed: ${error.message}`
|
|
}],
|
|
isError: true
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Tool handler: get_recent_context
|
|
*/
|
|
async getRecentContext(args: any): Promise<any> {
|
|
try {
|
|
const project = args.project || basename(process.cwd());
|
|
const limit = args.limit || 3;
|
|
|
|
const sessions = this.sessionStore.getRecentSessionsWithStatus(project, limit);
|
|
|
|
if (sessions.length === 0) {
|
|
return {
|
|
content: [{
|
|
type: 'text' as const,
|
|
text: `# Recent Session Context\n\nNo previous sessions found for project "${project}".`
|
|
}]
|
|
};
|
|
}
|
|
|
|
const lines: string[] = [];
|
|
lines.push('# Recent Session Context');
|
|
lines.push('');
|
|
lines.push(`Showing last ${sessions.length} session(s) for **${project}**:`);
|
|
lines.push('');
|
|
|
|
for (const session of sessions) {
|
|
if (!session.sdk_session_id) continue;
|
|
|
|
lines.push('---');
|
|
lines.push('');
|
|
|
|
if (session.has_summary) {
|
|
const summary = this.sessionStore.getSummaryForSession(session.sdk_session_id);
|
|
if (summary) {
|
|
const promptLabel = summary.prompt_number ? ` (Prompt #${summary.prompt_number})` : '';
|
|
lines.push(`**Summary${promptLabel}**`);
|
|
lines.push('');
|
|
|
|
if (summary.request) lines.push(`**Request:** ${summary.request}`);
|
|
if (summary.completed) lines.push(`**Completed:** ${summary.completed}`);
|
|
if (summary.learned) lines.push(`**Learned:** ${summary.learned}`);
|
|
if (summary.next_steps) lines.push(`**Next Steps:** ${summary.next_steps}`);
|
|
|
|
// Handle files_read
|
|
if (summary.files_read) {
|
|
try {
|
|
const filesRead = JSON.parse(summary.files_read);
|
|
if (Array.isArray(filesRead) && filesRead.length > 0) {
|
|
lines.push(`**Files Read:** ${filesRead.join(', ')}`);
|
|
}
|
|
} catch {
|
|
if (summary.files_read.trim()) {
|
|
lines.push(`**Files Read:** ${summary.files_read}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle files_edited
|
|
if (summary.files_edited) {
|
|
try {
|
|
const filesEdited = JSON.parse(summary.files_edited);
|
|
if (Array.isArray(filesEdited) && filesEdited.length > 0) {
|
|
lines.push(`**Files Edited:** ${filesEdited.join(', ')}`);
|
|
}
|
|
} catch {
|
|
if (summary.files_edited.trim()) {
|
|
lines.push(`**Files Edited:** ${summary.files_edited}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
const date = new Date(summary.created_at).toLocaleString();
|
|
lines.push(`**Date:** ${date}`);
|
|
}
|
|
} else if (session.status === 'active') {
|
|
lines.push('**In Progress**');
|
|
lines.push('');
|
|
|
|
if (session.user_prompt) {
|
|
lines.push(`**Request:** ${session.user_prompt}`);
|
|
}
|
|
|
|
const observations = this.sessionStore.getObservationsForSession(session.sdk_session_id);
|
|
if (observations.length > 0) {
|
|
lines.push('');
|
|
lines.push(`**Observations (${observations.length}):**`);
|
|
for (const obs of observations) {
|
|
lines.push(`- ${obs.title}`);
|
|
}
|
|
} else {
|
|
lines.push('');
|
|
lines.push('*No observations yet*');
|
|
}
|
|
|
|
lines.push('');
|
|
lines.push('**Status:** Active - summary pending');
|
|
|
|
const date = new Date(session.started_at).toLocaleString();
|
|
lines.push(`**Date:** ${date}`);
|
|
} else {
|
|
lines.push(`**${session.status.charAt(0).toUpperCase() + session.status.slice(1)}**`);
|
|
lines.push('');
|
|
|
|
if (session.user_prompt) {
|
|
lines.push(`**Request:** ${session.user_prompt}`);
|
|
}
|
|
|
|
lines.push('');
|
|
lines.push(`**Status:** ${session.status} - no summary available`);
|
|
|
|
const date = new Date(session.started_at).toLocaleString();
|
|
lines.push(`**Date:** ${date}`);
|
|
}
|
|
|
|
lines.push('');
|
|
}
|
|
|
|
return {
|
|
content: [{
|
|
type: 'text' as const,
|
|
text: lines.join('\n')
|
|
}]
|
|
};
|
|
} catch (error: any) {
|
|
return {
|
|
content: [{
|
|
type: 'text' as const,
|
|
text: `Failed to get recent context: ${error.message}`
|
|
}],
|
|
isError: true
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Tool handler: get_context_timeline
|
|
*/
|
|
async getContextTimeline(args: any): Promise<any> {
|
|
try {
|
|
const { anchor, depth_before = 10, depth_after = 10, project } = args;
|
|
let anchorEpoch: number;
|
|
let anchorId: string | number = anchor;
|
|
|
|
// Resolve anchor and get timeline data
|
|
let timelineData;
|
|
if (typeof anchor === 'number') {
|
|
// Observation ID - use ID-based boundary detection
|
|
const obs = this.sessionStore.getObservationById(anchor);
|
|
if (!obs) {
|
|
return {
|
|
content: [{
|
|
type: 'text' as const,
|
|
text: `Observation #${anchor} not found`
|
|
}],
|
|
isError: true
|
|
};
|
|
}
|
|
anchorEpoch = obs.created_at_epoch;
|
|
timelineData = this.sessionStore.getTimelineAroundObservation(anchor, anchorEpoch, depth_before, depth_after, project);
|
|
} else if (typeof anchor === 'string') {
|
|
// Session ID or ISO timestamp
|
|
if (anchor.startsWith('S') || anchor.startsWith('#S')) {
|
|
const sessionId = anchor.replace(/^#?S/, '');
|
|
const sessionNum = parseInt(sessionId, 10);
|
|
const sessions = this.sessionStore.getSessionSummariesByIds([sessionNum]);
|
|
if (sessions.length === 0) {
|
|
return {
|
|
content: [{
|
|
type: 'text' as const,
|
|
text: `Session #${sessionNum} not found`
|
|
}],
|
|
isError: true
|
|
};
|
|
}
|
|
anchorEpoch = sessions[0].created_at_epoch;
|
|
anchorId = `S${sessionNum}`;
|
|
timelineData = this.sessionStore.getTimelineAroundTimestamp(anchorEpoch, depth_before, depth_after, project);
|
|
} else {
|
|
// ISO timestamp
|
|
const date = new Date(anchor);
|
|
if (isNaN(date.getTime())) {
|
|
return {
|
|
content: [{
|
|
type: 'text' as const,
|
|
text: `Invalid timestamp: ${anchor}`
|
|
}],
|
|
isError: true
|
|
};
|
|
}
|
|
anchorEpoch = date.getTime(); // Keep as milliseconds
|
|
timelineData = this.sessionStore.getTimelineAroundTimestamp(anchorEpoch, depth_before, depth_after, project);
|
|
}
|
|
} else {
|
|
return {
|
|
content: [{
|
|
type: 'text' as const,
|
|
text: 'Invalid anchor: must be observation ID (number), session ID (e.g., "S123"), or ISO timestamp'
|
|
}],
|
|
isError: true
|
|
};
|
|
}
|
|
|
|
// Combine, sort, and filter timeline items
|
|
const items: TimelineItem[] = [
|
|
...timelineData.observations.map(obs => ({ type: 'observation' as const, data: obs, epoch: obs.created_at_epoch })),
|
|
...timelineData.sessions.map(sess => ({ type: 'session' as const, data: sess, epoch: sess.created_at_epoch })),
|
|
...timelineData.prompts.map(prompt => ({ type: 'prompt' as const, data: prompt, epoch: prompt.created_at_epoch }))
|
|
];
|
|
items.sort((a, b) => a.epoch - b.epoch);
|
|
const filteredItems = this.timelineService.filterByDepth(items, anchorId, anchorEpoch, depth_before, depth_after);
|
|
|
|
if (!filteredItems || filteredItems.length === 0) {
|
|
const anchorDate = new Date(anchorEpoch).toLocaleString();
|
|
return {
|
|
content: [{
|
|
type: 'text' as const,
|
|
text: `No context found around ${anchorDate} (${depth_before} records before, ${depth_after} records after)`
|
|
}]
|
|
};
|
|
}
|
|
|
|
// Helper functions matching context-hook.ts
|
|
const formatDate = (epochMs: number): string => {
|
|
const date = new Date(epochMs);
|
|
return date.toLocaleString('en-US', {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
year: 'numeric'
|
|
});
|
|
};
|
|
|
|
const formatTime = (epochMs: number): string => {
|
|
const date = new Date(epochMs);
|
|
return date.toLocaleString('en-US', {
|
|
hour: 'numeric',
|
|
minute: '2-digit',
|
|
hour12: true
|
|
});
|
|
};
|
|
|
|
const 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
|
|
});
|
|
};
|
|
|
|
const estimateTokens = (text: string | null): number => {
|
|
if (!text) return 0;
|
|
return Math.ceil(text.length / 4);
|
|
};
|
|
|
|
// Format results matching context-hook.ts exactly
|
|
const lines: string[] = [];
|
|
|
|
// Header
|
|
lines.push(`# Timeline around anchor: ${anchorId}`);
|
|
lines.push(`**Window:** ${depth_before} records before → ${depth_after} records after | **Items:** ${filteredItems?.length ?? 0}`);
|
|
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 filteredItems) {
|
|
const day = 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.id === anchorId) ||
|
|
(typeof anchorId === 'string' && anchorId.startsWith('S') && item.type === 'session' && `S${item.data.id}` === anchorId)
|
|
);
|
|
|
|
if (item.type === 'session') {
|
|
// Close any open table
|
|
if (tableOpen) {
|
|
lines.push('');
|
|
tableOpen = false;
|
|
currentFile = null;
|
|
lastTime = '';
|
|
}
|
|
|
|
// Render session
|
|
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} (${formatDateTime(item.epoch)}) [→](${link})${marker}`);
|
|
lines.push('');
|
|
} else if (item.type === 'prompt') {
|
|
// Close any open table
|
|
if (tableOpen) {
|
|
lines.push('');
|
|
tableOpen = false;
|
|
currentFile = null;
|
|
lastTime = '';
|
|
}
|
|
|
|
// Render prompt
|
|
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}** (${formatDateTime(item.epoch)})`);
|
|
lines.push(`> ${truncated}`);
|
|
lines.push('');
|
|
} else if (item.type === 'observation') {
|
|
// Render observation in table
|
|
const obs = item.data as ObservationSearchResult;
|
|
const file = 'General'; // Simplified for timeline view
|
|
|
|
// Check if we need a new file section
|
|
if (file !== currentFile) {
|
|
// Close previous table
|
|
if (tableOpen) {
|
|
lines.push('');
|
|
}
|
|
|
|
// File header
|
|
lines.push(`**${file}**`);
|
|
lines.push(`| ID | Time | T | Title | Tokens |`);
|
|
lines.push(`|----|------|---|-------|--------|`);
|
|
|
|
currentFile = file;
|
|
tableOpen = true;
|
|
lastTime = '';
|
|
}
|
|
|
|
// Map observation type to emoji
|
|
let icon = '•';
|
|
switch (obs.type) {
|
|
case 'bugfix': icon = '🔴'; break;
|
|
case 'feature': icon = '🟣'; break;
|
|
case 'refactor': icon = '🔄'; break;
|
|
case 'change': icon = '✅'; break;
|
|
case 'discovery': icon = '🔵'; break;
|
|
case 'decision': icon = '🧠'; break;
|
|
}
|
|
|
|
const time = formatTime(item.epoch);
|
|
const title = obs.title || 'Untitled';
|
|
const tokens = 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} |`);
|
|
}
|
|
}
|
|
|
|
// Close final table if open
|
|
if (tableOpen) {
|
|
lines.push('');
|
|
}
|
|
}
|
|
|
|
return {
|
|
content: [{
|
|
type: 'text' as const,
|
|
text: lines.join('\n')
|
|
}]
|
|
};
|
|
} catch (error: any) {
|
|
return {
|
|
content: [{
|
|
type: 'text' as const,
|
|
text: `Timeline query failed: ${error.message}`
|
|
}],
|
|
isError: true
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Tool handler: get_timeline_by_query
|
|
*/
|
|
async getTimelineByQuery(args: any): Promise<any> {
|
|
try {
|
|
const { query, mode = 'auto', depth_before = 10, depth_after = 10, limit = 5, project } = args;
|
|
|
|
// Step 1: Search for observations
|
|
let results: ObservationSearchResult[] = [];
|
|
|
|
// Use hybrid search if available
|
|
if (this.chromaSync) {
|
|
try {
|
|
happy_path_error__with_fallback('[mcp-server] Using hybrid semantic search for timeline query');
|
|
const chromaResults = await this.queryChroma(query, 100);
|
|
happy_path_error__with_fallback(`[mcp-server] Chroma returned ${chromaResults.ids.length} semantic matches`);
|
|
|
|
if (chromaResults.ids.length > 0) {
|
|
// Filter by recency (90 days)
|
|
const ninetyDaysAgo = Date.now() - (90 * 24 * 60 * 60 * 1000);
|
|
const recentIds = chromaResults.ids.filter((_id, idx) => {
|
|
const meta = chromaResults.metadatas[idx];
|
|
return meta && meta.created_at_epoch > ninetyDaysAgo;
|
|
});
|
|
|
|
happy_path_error__with_fallback(`[mcp-server] ${recentIds.length} results within 90-day window`);
|
|
|
|
if (recentIds.length > 0) {
|
|
results = this.sessionStore.getObservationsByIds(recentIds, { orderBy: 'date_desc', limit: mode === 'auto' ? 1 : limit });
|
|
happy_path_error__with_fallback(`[mcp-server] Hydrated ${results.length} observations from SQLite`);
|
|
}
|
|
}
|
|
} catch (chromaError: any) {
|
|
happy_path_error__with_fallback('[mcp-server] Chroma query failed - no results (FTS5 fallback removed):', chromaError.message);
|
|
}
|
|
}
|
|
|
|
if (results.length === 0) {
|
|
return {
|
|
content: [{
|
|
type: 'text' as const,
|
|
text: `No observations found matching "${query}". Try a different search query.`
|
|
}]
|
|
};
|
|
}
|
|
|
|
// Step 2: Handle based on mode
|
|
if (mode === 'interactive') {
|
|
// Return formatted index of top results for LLM to choose from
|
|
const lines: string[] = [];
|
|
lines.push(`# Timeline Anchor Search Results`);
|
|
lines.push('');
|
|
lines.push(`Found ${results.length} observation(s) matching "${query}"`);
|
|
lines.push('');
|
|
lines.push(`To get timeline context around any of these observations, use the \`get_context_timeline\` tool with the observation ID as the anchor.`);
|
|
lines.push('');
|
|
lines.push(`**Top ${results.length} matches:**`);
|
|
lines.push('');
|
|
|
|
for (let i = 0; i < results.length; i++) {
|
|
const obs = results[i];
|
|
const title = obs.title || `Observation #${obs.id}`;
|
|
const date = new Date(obs.created_at_epoch).toLocaleString();
|
|
const type = obs.type ? `[${obs.type}]` : '';
|
|
|
|
lines.push(`${i + 1}. **${type} ${title}**`);
|
|
lines.push(` - ID: ${obs.id}`);
|
|
lines.push(` - Date: ${date}`);
|
|
if (obs.subtitle) {
|
|
lines.push(` - ${obs.subtitle}`);
|
|
}
|
|
lines.push(` - Source: claude-mem://observation/${obs.id}`);
|
|
lines.push('');
|
|
}
|
|
|
|
return {
|
|
content: [{
|
|
type: 'text' as const,
|
|
text: lines.join('\n')
|
|
}]
|
|
};
|
|
} else {
|
|
// Auto mode: Use top result as timeline anchor
|
|
const topResult = results[0];
|
|
happy_path_error__with_fallback(`[mcp-server] Auto mode: Using observation #${topResult.id} as timeline anchor`);
|
|
|
|
// Get timeline around this observation
|
|
const timelineData = this.sessionStore.getTimelineAroundObservation(
|
|
topResult.id,
|
|
topResult.created_at_epoch,
|
|
depth_before,
|
|
depth_after,
|
|
project
|
|
);
|
|
|
|
// Combine, sort, and filter timeline items
|
|
const items: TimelineItem[] = [
|
|
...(timelineData.observations || []).map(obs => ({ type: 'observation' as const, data: obs, epoch: obs.created_at_epoch })),
|
|
...(timelineData.sessions || []).map(sess => ({ type: 'session' as const, data: sess, epoch: sess.created_at_epoch })),
|
|
...(timelineData.prompts || []).map(prompt => ({ type: 'prompt' as const, data: prompt, epoch: prompt.created_at_epoch }))
|
|
];
|
|
items.sort((a, b) => a.epoch - b.epoch);
|
|
const filteredItems = this.timelineService.filterByDepth(items, topResult.id, 0, depth_before, depth_after);
|
|
|
|
if (!filteredItems || filteredItems.length === 0) {
|
|
return {
|
|
content: [{
|
|
type: 'text' as const,
|
|
text: `Found observation #${topResult.id} matching "${query}", but no timeline context available (${depth_before} records before, ${depth_after} records after).`
|
|
}]
|
|
};
|
|
}
|
|
|
|
// Helper functions (reused from get_context_timeline)
|
|
const formatDate = (epochMs: number): string => {
|
|
const date = new Date(epochMs);
|
|
return date.toLocaleString('en-US', {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
year: 'numeric'
|
|
});
|
|
};
|
|
|
|
const formatTime = (epochMs: number): string => {
|
|
const date = new Date(epochMs);
|
|
return date.toLocaleString('en-US', {
|
|
hour: 'numeric',
|
|
minute: '2-digit',
|
|
hour12: true
|
|
});
|
|
};
|
|
|
|
const 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
|
|
});
|
|
};
|
|
|
|
const estimateTokens = (text: string | null): number => {
|
|
if (!text) return 0;
|
|
return Math.ceil(text.length / 4);
|
|
};
|
|
|
|
// Format timeline (reused from get_context_timeline)
|
|
const lines: string[] = [];
|
|
|
|
// Header
|
|
lines.push(`# Timeline for query: "${query}"`);
|
|
lines.push(`**Anchor:** Observation #${topResult.id} - ${topResult.title || 'Untitled'}`);
|
|
lines.push(`**Window:** ${depth_before} records before → ${depth_after} records after | **Items:** ${filteredItems?.length ?? 0}`);
|
|
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 filteredItems) {
|
|
const day = 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 = (item.type === 'observation' && item.data.id === topResult.id);
|
|
|
|
if (item.type === 'session') {
|
|
// Close any open table
|
|
if (tableOpen) {
|
|
lines.push('');
|
|
tableOpen = false;
|
|
currentFile = null;
|
|
lastTime = '';
|
|
}
|
|
|
|
// Render session
|
|
const sess = item.data as SessionSummarySearchResult;
|
|
const title = sess.request || 'Session summary';
|
|
const link = `claude-mem://session-summary/${sess.id}`;
|
|
|
|
lines.push(`**🎯 #S${sess.id}** ${title} (${formatDateTime(item.epoch)}) [→](${link})`);
|
|
lines.push('');
|
|
} else if (item.type === 'prompt') {
|
|
// Close any open table
|
|
if (tableOpen) {
|
|
lines.push('');
|
|
tableOpen = false;
|
|
currentFile = null;
|
|
lastTime = '';
|
|
}
|
|
|
|
// Render prompt
|
|
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}** (${formatDateTime(item.epoch)})`);
|
|
lines.push(`> ${truncated}`);
|
|
lines.push('');
|
|
} else if (item.type === 'observation') {
|
|
// Render observation in table
|
|
const obs = item.data as ObservationSearchResult;
|
|
const file = 'General'; // Simplified for timeline view
|
|
|
|
// Check if we need a new file section
|
|
if (file !== currentFile) {
|
|
// Close previous table
|
|
if (tableOpen) {
|
|
lines.push('');
|
|
}
|
|
|
|
// File header
|
|
lines.push(`**${file}**`);
|
|
lines.push(`| ID | Time | T | Title | Tokens |`);
|
|
lines.push(`|----|------|---|-------|--------|`);
|
|
|
|
currentFile = file;
|
|
tableOpen = true;
|
|
lastTime = '';
|
|
}
|
|
|
|
// Map observation type to emoji
|
|
let icon = '•';
|
|
switch (obs.type) {
|
|
case 'bugfix': icon = '🔴'; break;
|
|
case 'feature': icon = '🟣'; break;
|
|
case 'refactor': icon = '🔄'; break;
|
|
case 'change': icon = '✅'; break;
|
|
case 'discovery': icon = '🔵'; break;
|
|
case 'decision': icon = '🧠'; break;
|
|
}
|
|
|
|
const time = formatTime(item.epoch);
|
|
const title = obs.title || 'Untitled';
|
|
const tokens = 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} |`);
|
|
}
|
|
}
|
|
|
|
// Close final table if open
|
|
if (tableOpen) {
|
|
lines.push('');
|
|
}
|
|
}
|
|
|
|
return {
|
|
content: [{
|
|
type: 'text' as const,
|
|
text: lines.join('\n')
|
|
}]
|
|
};
|
|
}
|
|
} catch (error: any) {
|
|
return {
|
|
content: [{
|
|
type: 'text' as const,
|
|
text: `Timeline query failed: ${error.message}`
|
|
}],
|
|
isError: true
|
|
};
|
|
}
|
|
}
|
|
}
|