feat: add get_timeline_by_query tool for enhanced observation search with timeline context
- Implemented a new tool to search for observations using natural language and retrieve timeline context around the best match. - Introduced two modes: "auto" for automatic timeline anchor selection and "interactive" for user selection of top matches. - Added input schema validation using zod for query parameters including depth before/after, limit, and project filtering. - Integrated hybrid semantic search with fallback to FTS5 for observation retrieval. - Enhanced response formatting for both modes, including detailed timeline context and observation summaries.
This commit is contained in:
@@ -1377,6 +1377,322 @@ const tools = [
|
||||
};
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'get_timeline_by_query',
|
||||
description: 'Search for observations using natural language and get timeline context around the best match. Two modes: "auto" (default) automatically uses top result as timeline anchor; "interactive" returns top matches for you to choose from. This combines search + timeline into a single operation for faster context discovery.',
|
||||
inputSchema: z.object({
|
||||
query: z.string().describe('Natural language search query to find relevant observations'),
|
||||
mode: z.enum(['auto', 'interactive']).default('auto').describe('auto: Automatically use top search result as timeline anchor. interactive: Show top N search results for manual anchor selection.'),
|
||||
depth_before: z.number().min(0).max(50).default(10).describe('Number of timeline records before anchor (default: 10)'),
|
||||
depth_after: z.number().min(0).max(50).default(10).describe('Number of timeline records after anchor (default: 10)'),
|
||||
limit: z.number().min(1).max(20).default(5).describe('For interactive mode: number of top search results to display (default: 5)'),
|
||||
project: z.string().optional().describe('Filter by project name')
|
||||
}),
|
||||
handler: async (args: 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 (chromaClient) {
|
||||
try {
|
||||
console.error('[search-server] Using hybrid semantic search for timeline query');
|
||||
const chromaResults = await queryChroma(query, 100);
|
||||
console.error(`[search-server] Chroma returned ${chromaResults.ids.length} semantic matches`);
|
||||
|
||||
if (chromaResults.ids.length > 0) {
|
||||
// Filter by recency (90 days)
|
||||
const ninetyDaysAgo = Math.floor(Date.now() / 1000) - (90 * 24 * 60 * 60);
|
||||
const recentIds = chromaResults.ids.filter((id, idx) => {
|
||||
const meta = chromaResults.metadatas[idx];
|
||||
return meta && meta.created_at_epoch > ninetyDaysAgo;
|
||||
});
|
||||
|
||||
console.error(`[search-server] ${recentIds.length} results within 90-day window`);
|
||||
|
||||
if (recentIds.length > 0) {
|
||||
results = store.getObservationsByIds(recentIds, { orderBy: 'date_desc', limit: mode === 'auto' ? 1 : limit });
|
||||
console.error(`[search-server] Hydrated ${results.length} observations from SQLite`);
|
||||
}
|
||||
}
|
||||
} catch (chromaError: any) {
|
||||
console.error('[search-server] Chroma query failed, falling back to FTS5:', chromaError.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to FTS5
|
||||
if (results.length === 0) {
|
||||
console.error('[search-server] Using FTS5 keyword search');
|
||||
results = search.searchObservations(query, {
|
||||
orderBy: 'relevance',
|
||||
limit: mode === 'auto' ? 1 : limit,
|
||||
project
|
||||
});
|
||||
}
|
||||
|
||||
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];
|
||||
console.error(`[search-server] Auto mode: Using observation #${topResult.id} as timeline anchor`);
|
||||
|
||||
// Get timeline around this observation
|
||||
const timeline = store.getTimelineAroundObservation(
|
||||
topResult.id,
|
||||
topResult.created_at_epoch,
|
||||
depth_before,
|
||||
depth_after,
|
||||
project
|
||||
);
|
||||
|
||||
// Combine and sort all items chronologically (same logic as get_context_timeline)
|
||||
interface TimelineItem {
|
||||
type: 'observation' | 'session' | 'prompt';
|
||||
data: any;
|
||||
epoch: number;
|
||||
}
|
||||
|
||||
const items: TimelineItem[] = [
|
||||
...timeline.observations.map(obs => ({ type: 'observation' as const, data: obs, epoch: obs.created_at_epoch })),
|
||||
...timeline.sessions.map(sess => ({ type: 'session' as const, data: sess, epoch: sess.created_at_epoch })),
|
||||
...timeline.prompts.map(prompt => ({ type: 'prompt' as const, data: prompt, epoch: prompt.created_at_epoch }))
|
||||
];
|
||||
|
||||
items.sort((a, b) => a.epoch - b.epoch);
|
||||
|
||||
if (items.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)
|
||||
function formatDate(epochMs: number): string {
|
||||
const date = new Date(epochMs);
|
||||
return date.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
function formatTime(epochMs: number): string {
|
||||
const date = new Date(epochMs);
|
||||
return date.toLocaleString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
});
|
||||
}
|
||||
|
||||
function 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
|
||||
});
|
||||
}
|
||||
|
||||
function 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:** ${items.length} (${timeline.observations.length} obs, ${timeline.sessions.length} sessions, ${timeline.prompts.length} prompts)`);
|
||||
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 = 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;
|
||||
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;
|
||||
const truncated = prompt.prompt.length > 100 ? prompt.prompt.substring(0, 100) + '...' : prompt.prompt;
|
||||
|
||||
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;
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user