Mem-search enhancements: table output, simplified API, Sonnet default, and removed fake URIs (#317)
* feat: Add batch fetching for observations and update documentation - Implemented a new endpoint for fetching multiple observations by IDs in a single request. - Updated the DataRoutes to include a POST /api/observations/batch endpoint. - Enhanced SKILL.md documentation to reflect changes in the search process and batch fetching capabilities. - Increased the default limit for search results from 5 to 40 for better usability. * feat!: Fix timeline parameter passing with SearchManager alignment BREAKING CHANGE: Timeline MCP tools now use standardized parameter names - anchor_id → anchor - before → depth_before - after → depth_after - obs_type → type (timeline tool only) Fixes timeline endpoint failures caused by parameter name mismatch between MCP layer and SearchManager. Adds new SessionStore methods for fetching prompts and session summaries by ID. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * docs: reframe timeline parameter fix as bug fix, not breaking change The timeline tools were completely broken due to parameter name mismatch. There's nothing to migrate from since the old parameters never worked. Co-authored-by: Alex Newman <thedotmack@users.noreply.github.com> * Refactor mem-search documentation and optimize API tool definitions - Updated SKILL.md to emphasize batch fetching for observations, clarifying usage and efficiency. - Removed deprecated tools from mcp-server.ts and streamlined tool definitions for clarity. - Enhanced formatting in FormattingService.ts for better output readability. - Adjusted SearchManager.ts to improve result headers and removed unnecessary search tips from combined text. * Refactor FormattingService and SearchManager for table-based output - Updated FormattingService to format search results as tables, including methods for formatting observations, sessions, and user prompts. - Removed JSON format handling from SearchManager and streamlined result formatting to consistently use table format. - Enhanced readability and consistency in search tips and formatting logic. - Introduced token estimation for observations and improved time formatting. * refactor: update documentation and API references for version bump and search functionalities * Refactor code structure for improved readability and maintainability * chore: change default model from haiku to sonnet 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: unify timeline formatting across search and context services Extract shared timeline formatting utilities into reusable module to align MCP search output format with context-generator's date/file-grouped format. Changes: - Create src/shared/timeline-formatting.ts with reusable utilities (parseJsonArray, formatDateTime, formatTime, formatDate, toRelativePath, extractFirstFile, groupByDate) - Refactor context-generator.ts to use shared utilities - Update SearchManager.search() to use date/file grouping - Add search-specific row formatters to FormattingService - Fix timeline methods to extract actual file paths from metadata instead of hardcoding 'General' - Remove Work column from search output (kept in context output) Result: Consistent date/file-grouped markdown formatting across both systems while maintaining their different column requirements. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * refactor: remove redundant legend from search output Remove legend from search/timeline results since it's already shown in SessionStart context. Saves ~30 tokens per search result. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * Refactor session summary rendering to remove links - Removed link generation for session summaries in context generation and search manager. - Updated output formatting to exclude links while maintaining the session summary structure. - Adjusted related components in TimelineService to ensure consistency across the application. * fix: move skillPath declaration outside try block to fix scoping bug The skillPath variable was declared inside the try block but referenced in the catch block for error logging. Since const is block-scoped, this would cause a ReferenceError when the error handler executes. Moved skillPath declaration before the try block so it's accessible in both try and catch scopes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * fix: address PR #317 code review feedback **Critical Fixes:** - Replace happy_path_error__with_fallback debug calls with proper logger methods in mcp-server.ts - All HTTP API calls now use logger.debug/error for consistent logging **Code Quality Improvements:** - Extract 90-day recency window magic numbers to named constants - Added RECENCY_WINDOW_DAYS and RECENCY_WINDOW_MS constants in SearchManager **Documentation:** - Document model cost implications of Haiku → Sonnet upgrade in CHANGELOG - Provide clear migration path for users who want to revert to Haiku 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * refactor: simplify CHANGELOG - remove cost documentation Removed model cost comparison documentation per user feedback. Kept only the technical code quality improvements. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Alex Newman <thedotmack@users.noreply.github.com>
This commit is contained in:
+174
-226
@@ -30,18 +30,9 @@ const WORKER_BASE_URL = `http://${WORKER_HOST}:${WORKER_PORT}`;
|
||||
const TOOL_ENDPOINT_MAP: Record<string, string> = {
|
||||
'search': '/api/search',
|
||||
'timeline': '/api/timeline',
|
||||
'decisions': '/api/decisions',
|
||||
'changes': '/api/changes',
|
||||
'how_it_works': '/api/how-it-works',
|
||||
'search_observations': '/api/search/observations',
|
||||
'search_sessions': '/api/search/sessions',
|
||||
'search_user_prompts': '/api/search/prompts',
|
||||
'find_by_concept': '/api/search/by-concept',
|
||||
'find_by_file': '/api/search/by-file',
|
||||
'find_by_type': '/api/search/by-type',
|
||||
'get_recent_context': '/api/context/recent',
|
||||
'get_context_timeline': '/api/context/timeline',
|
||||
'get_timeline_by_query': '/api/timeline/by-query'
|
||||
'progressive_description': '/api/instructions'
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -89,6 +80,94 @@ async function callWorkerAPI(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call Worker HTTP API with path parameter (GET)
|
||||
*/
|
||||
async function callWorkerAPIWithPath(
|
||||
endpoint: string,
|
||||
id: number
|
||||
): Promise<{ content: Array<{ type: 'text'; text: string }>; isError?: boolean }> {
|
||||
logger.debug('HTTP', 'Worker API request (path)', undefined, { endpoint, id });
|
||||
|
||||
try {
|
||||
const url = `${WORKER_BASE_URL}${endpoint}/${id}`;
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Worker API error (${response.status}): ${errorText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
logger.debug('HTTP', 'Worker API success (path)', undefined, { endpoint, id });
|
||||
|
||||
// Wrap raw data in MCP format
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify(data, null, 2)
|
||||
}]
|
||||
};
|
||||
} catch (error: any) {
|
||||
logger.error('HTTP', 'Worker API error (path)', undefined, { endpoint, id, error: error.message });
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: `Error calling Worker API: ${error.message}`
|
||||
}],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call Worker HTTP API with POST body
|
||||
*/
|
||||
async function callWorkerAPIPost(
|
||||
endpoint: string,
|
||||
body: Record<string, any>
|
||||
): Promise<{ content: Array<{ type: 'text'; text: string }>; isError?: boolean }> {
|
||||
logger.debug('HTTP', 'Worker API request (POST)', undefined, { endpoint });
|
||||
|
||||
try {
|
||||
const url = `${WORKER_BASE_URL}${endpoint}`;
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Worker API error (${response.status}): ${errorText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
logger.debug('HTTP', 'Worker API success (POST)', undefined, { endpoint });
|
||||
|
||||
// Wrap raw data in MCP format
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: JSON.stringify(data, null, 2)
|
||||
}]
|
||||
};
|
||||
} catch (error: any) {
|
||||
logger.error('HTTP', 'Worker API error (POST)', undefined, { endpoint, error: error.message });
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: `Error calling Worker API: ${error.message}`
|
||||
}],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify Worker is accessible
|
||||
*/
|
||||
@@ -103,24 +182,24 @@ async function verifyWorkerConnection(): Promise<boolean> {
|
||||
|
||||
/**
|
||||
* Tool definitions with HTTP-based handlers
|
||||
* Descriptions removed - use progressive_description tool for parameter documentation
|
||||
*/
|
||||
const tools = [
|
||||
{
|
||||
name: 'search',
|
||||
description: 'Unified search across all memory types (observations, sessions, and user prompts) using vector-first semantic search (ChromaDB). Returns combined results from all document types. IMPORTANT: Always use index format first (default) to get an overview with minimal token usage, then use format: "full" only for specific items of interest.',
|
||||
description: 'Search memory',
|
||||
inputSchema: z.object({
|
||||
query: z.string().optional().describe('Natural language search query for semantic ranking via ChromaDB vector search. Optional - omit for date-filtered queries only (Chroma cannot filter by date, requires direct SQLite).'),
|
||||
format: z.enum(['index', 'full']).default('index').describe('Output format: "index" for titles/dates only (default, RECOMMENDED for initial search), "full" for complete details (use only after reviewing index results)'),
|
||||
type: z.enum(['observations', 'sessions', 'prompts']).optional().describe('Filter by document type (observations, sessions, or prompts). Omit to search all types.'),
|
||||
obs_type: z.string().optional().describe('Filter observations by type (single value or comma-separated list: decision,bugfix,feature,refactor,discovery,change). Only applies when type="observations"'),
|
||||
concepts: z.string().optional().describe('Filter by concept tags (single value or comma-separated list). Only applies when type="observations"'),
|
||||
files: z.string().optional().describe('Filter by file paths (single value or comma-separated list for partial match). Only applies when type="observations"'),
|
||||
project: z.string().optional().describe('Filter by project name'),
|
||||
dateStart: z.union([z.string(), z.number()]).optional().describe('Start date for filtering (ISO string or epoch timestamp)'),
|
||||
dateEnd: z.union([z.string(), z.number()]).optional().describe('End date for filtering (ISO string or epoch timestamp)'),
|
||||
limit: z.number().min(1).max(100).default(20).describe('Maximum number of results'),
|
||||
offset: z.number().min(0).default(0).describe('Number of results to skip'),
|
||||
orderBy: z.enum(['relevance', 'date_desc', 'date_asc']).default('date_desc').describe('Sort order')
|
||||
query: z.string().optional(),
|
||||
type: z.enum(['observations', 'sessions', 'prompts']).optional(),
|
||||
obs_type: z.string().optional(),
|
||||
concepts: z.string().optional(),
|
||||
files: z.string().optional(),
|
||||
project: z.string().optional(),
|
||||
dateStart: z.union([z.string(), z.number()]).optional(),
|
||||
dateEnd: z.union([z.string(), z.number()]).optional(),
|
||||
limit: z.number().min(1).max(100).default(20),
|
||||
offset: z.number().min(0).default(0),
|
||||
orderBy: z.enum(['relevance', 'date_desc', 'date_asc']).default('date_desc')
|
||||
}),
|
||||
handler: async (args: any) => {
|
||||
const endpoint = TOOL_ENDPOINT_MAP['search'];
|
||||
@@ -129,197 +208,33 @@ const tools = [
|
||||
},
|
||||
{
|
||||
name: 'timeline',
|
||||
description: 'Fetch timeline of observations around a specific point in time. Supports two modes: anchor-based (fetch observations before/after a specific observation ID) and query-based (semantic search for anchor point). IMPORTANT: Use anchor_id when you know the specific observation, or query to find an anchor point first.',
|
||||
description: 'Timeline context',
|
||||
inputSchema: z.object({
|
||||
query: z.string().optional().describe('Natural language query to find anchor observation (query-based mode). Mutually exclusive with anchor_id.'),
|
||||
anchor_id: z.number().optional().describe('Observation ID to use as anchor (anchor-based mode). Mutually exclusive with query.'),
|
||||
before: z.number().min(0).max(100).default(10).describe('Number of observations to fetch before anchor'),
|
||||
after: z.number().min(0).max(100).default(10).describe('Number of observations to fetch after anchor'),
|
||||
format: z.enum(['index', 'full']).default('index').describe('Output format: "index" for titles/dates only (default, RECOMMENDED), "full" for complete details'),
|
||||
obs_type: z.string().optional().describe('Filter observations by type (single value or comma-separated list: decision,bugfix,feature,refactor,discovery,change)'),
|
||||
concepts: z.string().optional().describe('Filter by concept tags (single value or comma-separated list)'),
|
||||
files: z.string().optional().describe('Filter by file paths (single value or comma-separated list for partial match)'),
|
||||
project: z.string().optional().describe('Filter by project name')
|
||||
query: z.string().optional(),
|
||||
anchor: z.number().optional(),
|
||||
depth_before: z.number().min(0).max(100).default(10),
|
||||
depth_after: z.number().min(0).max(100).default(10),
|
||||
type: z.string().optional(),
|
||||
concepts: z.string().optional(),
|
||||
files: z.string().optional(),
|
||||
project: z.string().optional()
|
||||
}),
|
||||
handler: async (args: any) => {
|
||||
const endpoint = TOOL_ENDPOINT_MAP['timeline'];
|
||||
return await callWorkerAPI(endpoint, args);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'decisions',
|
||||
description: 'Semantic shortcut for finding architectural, design, and implementation decisions. Optimized for decision-type observations with relevant keyword boosting.',
|
||||
inputSchema: z.object({
|
||||
query: z.string().describe('Natural language query for finding decisions'),
|
||||
format: z.enum(['index', 'full']).default('index').describe('Output format: "index" for titles/dates only (default, RECOMMENDED), "full" for complete details'),
|
||||
limit: z.number().min(1).max(100).default(20).describe('Maximum number of results'),
|
||||
dateStart: z.union([z.string(), z.number()]).optional().describe('Start date for filtering (ISO string or epoch timestamp)'),
|
||||
dateEnd: z.union([z.string(), z.number()]).optional().describe('End date for filtering (ISO string or epoch timestamp)')
|
||||
}),
|
||||
handler: async (args: any) => {
|
||||
const endpoint = TOOL_ENDPOINT_MAP['decisions'];
|
||||
return await callWorkerAPI(endpoint, args);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'changes',
|
||||
description: 'Semantic shortcut for finding code changes, refactorings, and modifications. Optimized for change-type observations with relevant keyword boosting.',
|
||||
inputSchema: z.object({
|
||||
query: z.string().describe('Natural language query for finding changes'),
|
||||
format: z.enum(['index', 'full']).default('index').describe('Output format: "index" for titles/dates only (default, RECOMMENDED), "full" for complete details'),
|
||||
limit: z.number().min(1).max(100).default(20).describe('Maximum number of results'),
|
||||
dateStart: z.union([z.string(), z.number()]).optional().describe('Start date for filtering (ISO string or epoch timestamp)'),
|
||||
dateEnd: z.union([z.string(), z.number()]).optional().describe('End date for filtering (ISO string or epoch timestamp)')
|
||||
}),
|
||||
handler: async (args: any) => {
|
||||
const endpoint = TOOL_ENDPOINT_MAP['changes'];
|
||||
return await callWorkerAPI(endpoint, args);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'how_it_works',
|
||||
description: 'Semantic shortcut for understanding system architecture, design patterns, and implementation details. Optimized for discovery-type observations with architecture/design keyword boosting.',
|
||||
inputSchema: z.object({
|
||||
query: z.string().describe('Natural language query for understanding how something works'),
|
||||
format: z.enum(['index', 'full']).default('index').describe('Output format: "index" for titles/dates only (default, RECOMMENDED), "full" for complete details'),
|
||||
limit: z.number().min(1).max(100).default(20).describe('Maximum number of results'),
|
||||
dateStart: z.union([z.string(), z.number()]).optional().describe('Start date for filtering (ISO string or epoch timestamp)'),
|
||||
dateEnd: z.union([z.string(), z.number()]).optional().describe('End date for filtering (ISO string or epoch timestamp)')
|
||||
}),
|
||||
handler: async (args: any) => {
|
||||
const endpoint = TOOL_ENDPOINT_MAP['how_it_works'];
|
||||
return await callWorkerAPI(endpoint, args);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'search_observations',
|
||||
description: '[DEPRECATED - Use "search" with type="observations" instead] Search observations (facts/narratives) using FTS5 full-text search. Supports filtering by type, concepts, files, and date range.',
|
||||
inputSchema: z.object({
|
||||
query: z.string().optional().describe('Full-text search query (FTS5)'),
|
||||
format: z.enum(['index', 'full']).default('index').describe('Output format: "index" for titles/dates only (default, RECOMMENDED), "full" for complete details'),
|
||||
type: z.string().optional().describe('Filter by observation type (single value or comma-separated list: decision,bugfix,feature,refactor,discovery,change)'),
|
||||
concepts: z.string().optional().describe('Filter by concept tags (single value or comma-separated list)'),
|
||||
files: z.string().optional().describe('Filter by file paths (single value or comma-separated list for partial match)'),
|
||||
project: z.string().optional().describe('Filter by project name'),
|
||||
dateStart: z.union([z.string(), z.number()]).optional().describe('Start date for filtering (ISO string or epoch timestamp)'),
|
||||
dateEnd: z.union([z.string(), z.number()]).optional().describe('End date for filtering (ISO string or epoch timestamp)'),
|
||||
limit: z.number().min(1).max(100).default(20).describe('Maximum number of results'),
|
||||
offset: z.number().min(0).default(0).describe('Number of results to skip'),
|
||||
orderBy: z.enum(['relevance', 'date_desc', 'date_asc']).default('relevance').describe('Sort order (relevance only when query provided)')
|
||||
}),
|
||||
handler: async (args: any) => {
|
||||
const endpoint = TOOL_ENDPOINT_MAP['search_observations'];
|
||||
return await callWorkerAPI(endpoint, args);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'search_sessions',
|
||||
description: '[DEPRECATED - Use "search" with type="sessions" instead] Search session summaries using FTS5 full-text search. Returns both request_summary and learned_summary fields.',
|
||||
inputSchema: z.object({
|
||||
query: z.string().optional().describe('Full-text search query (FTS5)'),
|
||||
format: z.enum(['index', 'full']).default('index').describe('Output format: "index" for titles/dates only (default, RECOMMENDED), "full" for complete details'),
|
||||
project: z.string().optional().describe('Filter by project name'),
|
||||
dateStart: z.union([z.string(), z.number()]).optional().describe('Start date for filtering (ISO string or epoch timestamp)'),
|
||||
dateEnd: z.union([z.string(), z.number()]).optional().describe('End date for filtering (ISO string or epoch timestamp)'),
|
||||
limit: z.number().min(1).max(100).default(20).describe('Maximum number of results'),
|
||||
offset: z.number().min(0).default(0).describe('Number of results to skip'),
|
||||
orderBy: z.enum(['relevance', 'date_desc', 'date_asc']).default('relevance').describe('Sort order (relevance only when query provided)')
|
||||
}),
|
||||
handler: async (args: any) => {
|
||||
const endpoint = TOOL_ENDPOINT_MAP['search_sessions'];
|
||||
return await callWorkerAPI(endpoint, args);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'search_user_prompts',
|
||||
description: '[DEPRECATED - Use "search" with type="prompts" instead] Search user prompts using FTS5 full-text search. Searches prompt text only.',
|
||||
inputSchema: z.object({
|
||||
query: z.string().optional().describe('Full-text search query (FTS5)'),
|
||||
format: z.enum(['index', 'full']).default('index').describe('Output format: "index" for titles/dates only (default, RECOMMENDED), "full" for complete details'),
|
||||
project: z.string().optional().describe('Filter by project name'),
|
||||
dateStart: z.union([z.string(), z.number()]).optional().describe('Start date for filtering (ISO string or epoch timestamp)'),
|
||||
dateEnd: z.union([z.string(), z.number()]).optional().describe('End date for filtering (ISO string or epoch timestamp)'),
|
||||
limit: z.number().min(1).max(100).default(20).describe('Maximum number of results'),
|
||||
offset: z.number().min(0).default(0).describe('Number of results to skip'),
|
||||
orderBy: z.enum(['relevance', 'date_desc', 'date_asc']).default('relevance').describe('Sort order (relevance only when query provided)')
|
||||
}),
|
||||
handler: async (args: any) => {
|
||||
const endpoint = TOOL_ENDPOINT_MAP['search_user_prompts'];
|
||||
return await callWorkerAPI(endpoint, args);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'find_by_concept',
|
||||
description: 'Find observations tagged with specific concepts. Returns observations that match any of the provided concept tags.',
|
||||
inputSchema: z.object({
|
||||
concepts: z.string().describe('Concept tag(s) to filter by (single value or comma-separated list)'),
|
||||
format: z.enum(['index', 'full']).default('index').describe('Output format: "index" for titles/dates only (default, RECOMMENDED), "full" for complete details'),
|
||||
type: z.string().optional().describe('Filter by observation type (single value or comma-separated list: decision,bugfix,feature,refactor,discovery,change)'),
|
||||
files: z.string().optional().describe('Filter by file paths (single value or comma-separated list for partial match)'),
|
||||
project: z.string().optional().describe('Filter by project name'),
|
||||
dateStart: z.union([z.string(), z.number()]).optional().describe('Start date for filtering (ISO string or epoch timestamp)'),
|
||||
dateEnd: z.union([z.string(), z.number()]).optional().describe('End date for filtering (ISO string or epoch timestamp)'),
|
||||
limit: z.number().min(1).max(100).default(20).describe('Maximum number of results'),
|
||||
offset: z.number().min(0).default(0).describe('Number of results to skip'),
|
||||
orderBy: z.enum(['date_desc', 'date_asc']).default('date_desc').describe('Sort order')
|
||||
}),
|
||||
handler: async (args: any) => {
|
||||
const endpoint = TOOL_ENDPOINT_MAP['find_by_concept'];
|
||||
return await callWorkerAPI(endpoint, args);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'find_by_file',
|
||||
description: 'Find observations related to specific file paths. Uses partial matching - searches for file paths containing the provided string.',
|
||||
inputSchema: z.object({
|
||||
files: z.string().describe('File path(s) to filter by (single value or comma-separated list for partial match)'),
|
||||
format: z.enum(['index', 'full']).default('index').describe('Output format: "index" for titles/dates only (default, RECOMMENDED), "full" for complete details'),
|
||||
type: z.string().optional().describe('Filter by observation type (single value or comma-separated list: decision,bugfix,feature,refactor,discovery,change)'),
|
||||
concepts: z.string().optional().describe('Filter by concept tags (single value or comma-separated list)'),
|
||||
project: z.string().optional().describe('Filter by project name'),
|
||||
dateStart: z.union([z.string(), z.number()]).optional().describe('Start date for filtering (ISO string or epoch timestamp)'),
|
||||
dateEnd: z.union([z.string(), z.number()]).optional().describe('End date for filtering (ISO string or epoch timestamp)'),
|
||||
limit: z.number().min(1).max(100).default(20).describe('Maximum number of results'),
|
||||
offset: z.number().min(0).default(0).describe('Number of results to skip'),
|
||||
orderBy: z.enum(['date_desc', 'date_asc']).default('date_desc').describe('Sort order')
|
||||
}),
|
||||
handler: async (args: any) => {
|
||||
const endpoint = TOOL_ENDPOINT_MAP['find_by_file'];
|
||||
return await callWorkerAPI(endpoint, args);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'find_by_type',
|
||||
description: 'Find observations of specific types. Returns observations matching any of the provided observation types.',
|
||||
inputSchema: z.object({
|
||||
type: z.string().describe('Observation type(s) to filter by (single value or comma-separated list: decision,bugfix,feature,refactor,discovery,change)'),
|
||||
format: z.enum(['index', 'full']).default('index').describe('Output format: "index" for titles/dates only (default, RECOMMENDED), "full" for complete details'),
|
||||
concepts: z.string().optional().describe('Filter by concept tags (single value or comma-separated list)'),
|
||||
files: z.string().optional().describe('Filter by file paths (single value or comma-separated list for partial match)'),
|
||||
project: z.string().optional().describe('Filter by project name'),
|
||||
dateStart: z.union([z.string(), z.number()]).optional().describe('Start date for filtering (ISO string or epoch timestamp)'),
|
||||
dateEnd: z.union([z.string(), z.number()]).optional().describe('End date for filtering (ISO string or epoch timestamp)'),
|
||||
limit: z.number().min(1).max(100).default(20).describe('Maximum number of results'),
|
||||
offset: z.number().min(0).default(0).describe('Number of results to skip'),
|
||||
orderBy: z.enum(['date_desc', 'date_asc']).default('date_desc').describe('Sort order')
|
||||
}),
|
||||
handler: async (args: any) => {
|
||||
const endpoint = TOOL_ENDPOINT_MAP['find_by_type'];
|
||||
return await callWorkerAPI(endpoint, args);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'get_recent_context',
|
||||
description: 'Get recent session context for timeline display. Returns recent observations, sessions, and user prompts with metadata for building timeline UI.',
|
||||
description: 'Recent context',
|
||||
inputSchema: z.object({
|
||||
limit: z.number().min(1).max(100).default(30).describe('Maximum number of timeline items to return'),
|
||||
format: z.enum(['index', 'full']).default('index').describe('Output format: "index" for titles/dates only (default, RECOMMENDED), "full" for complete details'),
|
||||
type: z.string().optional().describe('Filter by observation type (single value or comma-separated list: decision,bugfix,feature,refactor,discovery,change)'),
|
||||
concepts: z.string().optional().describe('Filter by concept tags (single value or comma-separated list)'),
|
||||
files: z.string().optional().describe('Filter by file paths (single value or comma-separated list for partial match)'),
|
||||
project: z.string().optional().describe('Filter by project name'),
|
||||
dateStart: z.union([z.string(), z.number()]).optional().describe('Start date for filtering (ISO string or epoch timestamp)'),
|
||||
dateEnd: z.union([z.string(), z.number()]).optional().describe('End date for filtering (ISO string or epoch timestamp)')
|
||||
limit: z.number().min(1).max(100).default(30),
|
||||
type: z.string().optional(),
|
||||
concepts: z.string().optional(),
|
||||
files: z.string().optional(),
|
||||
project: z.string().optional(),
|
||||
dateStart: z.union([z.string(), z.number()]).optional(),
|
||||
dateEnd: z.union([z.string(), z.number()]).optional()
|
||||
}),
|
||||
handler: async (args: any) => {
|
||||
const endpoint = TOOL_ENDPOINT_MAP['get_recent_context'];
|
||||
@@ -328,16 +243,15 @@ const tools = [
|
||||
},
|
||||
{
|
||||
name: 'get_context_timeline',
|
||||
description: 'Get timeline of observations around a specific observation ID. Returns observations before and after the anchor point with metadata for timeline display.',
|
||||
description: 'Timeline around ID',
|
||||
inputSchema: z.object({
|
||||
anchor_id: z.number().describe('Observation ID to use as anchor point'),
|
||||
before: z.number().min(0).max(100).default(10).describe('Number of observations to fetch before anchor'),
|
||||
after: z.number().min(0).max(100).default(10).describe('Number of observations to fetch after anchor'),
|
||||
format: z.enum(['index', 'full']).default('index').describe('Output format: "index" for titles/dates only (default, RECOMMENDED), "full" for complete details'),
|
||||
type: z.string().optional().describe('Filter by observation type (single value or comma-separated list: decision,bugfix,feature,refactor,discovery,change)'),
|
||||
concepts: z.string().optional().describe('Filter by concept tags (single value or comma-separated list)'),
|
||||
files: z.string().optional().describe('Filter by file paths (single value or comma-separated list for partial match)'),
|
||||
project: z.string().optional().describe('Filter by project name')
|
||||
anchor: z.number(),
|
||||
depth_before: z.number().min(0).max(100).default(10),
|
||||
depth_after: z.number().min(0).max(100).default(10),
|
||||
type: z.string().optional(),
|
||||
concepts: z.string().optional(),
|
||||
files: z.string().optional(),
|
||||
project: z.string().optional()
|
||||
}),
|
||||
handler: async (args: any) => {
|
||||
const endpoint = TOOL_ENDPOINT_MAP['get_context_timeline'];
|
||||
@@ -345,24 +259,58 @@ const tools = [
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'get_timeline_by_query',
|
||||
description: 'Combined search + timeline tool. First searches for observations matching the query, then returns timeline around the best match. Useful for finding specific observations and viewing their context.',
|
||||
name: 'progressive_description',
|
||||
description: 'Usage help',
|
||||
inputSchema: z.object({
|
||||
query: z.string().describe('Natural language query to find anchor observation'),
|
||||
before: z.number().min(0).max(100).default(10).describe('Number of observations to fetch before anchor'),
|
||||
after: z.number().min(0).max(100).default(10).describe('Number of observations to fetch after anchor'),
|
||||
format: z.enum(['index', 'full']).default('index').describe('Output format: "index" for titles/dates only (default, RECOMMENDED), "full" for complete details'),
|
||||
type: z.string().optional().describe('Filter by observation type (single value or comma-separated list: decision,bugfix,feature,refactor,discovery,change)'),
|
||||
concepts: z.string().optional().describe('Filter by concept tags (single value or comma-separated list)'),
|
||||
files: z.string().optional().describe('Filter by file paths (single value or comma-separated list for partial match)'),
|
||||
project: z.string().optional().describe('Filter by project name'),
|
||||
dateStart: z.union([z.string(), z.number()]).optional().describe('Start date for filtering (ISO string or epoch timestamp)'),
|
||||
dateEnd: z.union([z.string(), z.number()]).optional().describe('End date for filtering (ISO string or epoch timestamp)')
|
||||
topic: z.enum(['workflow', 'search_params', 'examples', 'all']).default('all')
|
||||
}),
|
||||
handler: async (args: any) => {
|
||||
const endpoint = TOOL_ENDPOINT_MAP['get_timeline_by_query'];
|
||||
const endpoint = TOOL_ENDPOINT_MAP['progressive_description'];
|
||||
return await callWorkerAPI(endpoint, args);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'get_observation',
|
||||
description: 'Fetch by ID',
|
||||
inputSchema: z.object({
|
||||
id: z.number()
|
||||
}),
|
||||
handler: async (args: any) => {
|
||||
return await callWorkerAPIWithPath('/api/observation', args.id);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'get_batch_observations',
|
||||
description: 'Batch fetch',
|
||||
inputSchema: z.object({
|
||||
ids: z.array(z.number()),
|
||||
orderBy: z.enum(['date_desc', 'date_asc']).optional(),
|
||||
limit: z.number().optional(),
|
||||
project: z.string().optional()
|
||||
}),
|
||||
handler: async (args: any) => {
|
||||
return await callWorkerAPIPost('/api/observations/batch', args);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'get_session',
|
||||
description: 'Session by ID',
|
||||
inputSchema: z.object({
|
||||
id: z.number()
|
||||
}),
|
||||
handler: async (args: any) => {
|
||||
return await callWorkerAPIWithPath('/api/session', args.id);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'get_prompt',
|
||||
description: 'Prompt by ID',
|
||||
inputSchema: z.object({
|
||||
id: z.number()
|
||||
}),
|
||||
handler: async (args: any) => {
|
||||
return await callWorkerAPIWithPath('/api/prompt', args.id);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -17,6 +17,14 @@ import {
|
||||
} from '../constants/observation-metadata.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { SettingsDefaultsManager } from '../shared/SettingsDefaultsManager.js';
|
||||
import {
|
||||
parseJsonArray,
|
||||
formatDateTime,
|
||||
formatTime,
|
||||
formatDate,
|
||||
toRelativePath,
|
||||
extractFirstFile
|
||||
} from '../shared/timeline-formatting.js';
|
||||
|
||||
// Version marker path - use homedir-based path that works in both CJS and ESM contexts
|
||||
const VERSION_MARKER_PATH = path.join(homedir(), '.claude', 'plugins', 'marketplaces', 'thedotmack', 'plugin', '.install-version');
|
||||
@@ -145,57 +153,6 @@ interface SessionSummary {
|
||||
created_at_epoch: number;
|
||||
}
|
||||
|
||||
// Helper: Parse JSON array safely
|
||||
function parseJsonArray(json: string | null): string[] {
|
||||
if (!json) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(json);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch (err) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: Format date with time
|
||||
function formatDateTime(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
});
|
||||
}
|
||||
|
||||
// Helper: Format just time (no date)
|
||||
function formatTime(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
});
|
||||
}
|
||||
|
||||
// Helper: Format just date
|
||||
function formatDate(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
// Helper: Convert absolute paths to relative paths
|
||||
function toRelativePath(filePath: string, cwd: string): string {
|
||||
if (path.isAbsolute(filePath)) {
|
||||
return path.relative(cwd, filePath);
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
// Helper: Render a summary field
|
||||
function renderSummaryField(label: string, value: string | null, color: string, useColors: boolean): string[] {
|
||||
if (!value) return [];
|
||||
@@ -544,20 +501,16 @@ export async function generateContext(input?: ContextInput, useColors: boolean =
|
||||
|
||||
const summary = item.data;
|
||||
const summaryTitle = `${summary.request || 'Session started'} (${formatDateTime(summary.displayTime)})`;
|
||||
const link = summary.shouldShowLink ? `claude-mem://session-summary/${summary.id}` : '';
|
||||
|
||||
if (useColors) {
|
||||
const linkPart = link ? `${colors.dim}[${link}]${colors.reset}` : '';
|
||||
output.push(`🎯 ${colors.yellow}#S${summary.id}${colors.reset} ${summaryTitle} ${linkPart}`);
|
||||
output.push(`🎯 ${colors.yellow}#S${summary.id}${colors.reset} ${summaryTitle}`);
|
||||
} else {
|
||||
const linkPart = link ? ` [→](${link})` : '';
|
||||
output.push(`**🎯 #S${summary.id}** ${summaryTitle}${linkPart}`);
|
||||
output.push(`**🎯 #S${summary.id}** ${summaryTitle}`);
|
||||
}
|
||||
output.push('');
|
||||
} else {
|
||||
const obs = item.data;
|
||||
const files = parseJsonArray(obs.files_modified);
|
||||
const file = (files.length > 0 && files[0]) ? toRelativePath(files[0], cwd) : 'General';
|
||||
const file = extractFirstFile(obs.files_modified, cwd);
|
||||
|
||||
if (file !== currentFile) {
|
||||
if (tableOpen) {
|
||||
|
||||
@@ -1614,8 +1614,9 @@ export class SessionStore {
|
||||
prompts: prompts.map(p => ({
|
||||
id: p.id,
|
||||
claude_session_id: p.claude_session_id,
|
||||
prompt_number: p.prompt_number,
|
||||
prompt_text: p.prompt_text,
|
||||
project: p.project,
|
||||
prompt: p.prompt_text,
|
||||
created_at: p.created_at,
|
||||
created_at_epoch: p.created_at_epoch
|
||||
}))
|
||||
@@ -1626,6 +1627,112 @@ export class SessionStore {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single user prompt by ID
|
||||
*/
|
||||
getPromptById(id: number): {
|
||||
id: number;
|
||||
claude_session_id: string;
|
||||
prompt_number: number;
|
||||
prompt_text: string;
|
||||
project: string;
|
||||
created_at: string;
|
||||
created_at_epoch: number;
|
||||
} | null {
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT
|
||||
p.id,
|
||||
p.claude_session_id,
|
||||
p.prompt_number,
|
||||
p.prompt_text,
|
||||
s.project,
|
||||
p.created_at,
|
||||
p.created_at_epoch
|
||||
FROM user_prompts p
|
||||
LEFT JOIN sdk_sessions s ON p.claude_session_id = s.claude_session_id
|
||||
WHERE p.id = ?
|
||||
LIMIT 1
|
||||
`);
|
||||
|
||||
return stmt.get(id) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get multiple user prompts by IDs
|
||||
*/
|
||||
getPromptsByIds(ids: number[]): Array<{
|
||||
id: number;
|
||||
claude_session_id: string;
|
||||
prompt_number: number;
|
||||
prompt_text: string;
|
||||
project: string;
|
||||
created_at: string;
|
||||
created_at_epoch: number;
|
||||
}> {
|
||||
if (ids.length === 0) return [];
|
||||
|
||||
const placeholders = ids.map(() => '?').join(',');
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT
|
||||
p.id,
|
||||
p.claude_session_id,
|
||||
p.prompt_number,
|
||||
p.prompt_text,
|
||||
s.project,
|
||||
p.created_at,
|
||||
p.created_at_epoch
|
||||
FROM user_prompts p
|
||||
LEFT JOIN sdk_sessions s ON p.claude_session_id = s.claude_session_id
|
||||
WHERE p.id IN (${placeholders})
|
||||
ORDER BY p.created_at_epoch DESC
|
||||
`);
|
||||
|
||||
return stmt.all(...ids) as Array<{
|
||||
id: number;
|
||||
claude_session_id: string;
|
||||
prompt_number: number;
|
||||
prompt_text: string;
|
||||
project: string;
|
||||
created_at: string;
|
||||
created_at_epoch: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full session summary by ID (includes request_summary and learned_summary)
|
||||
*/
|
||||
getSessionSummaryById(id: number): {
|
||||
id: number;
|
||||
sdk_session_id: string | null;
|
||||
claude_session_id: string;
|
||||
project: string;
|
||||
user_prompt: string;
|
||||
request_summary: string | null;
|
||||
learned_summary: string | null;
|
||||
status: string;
|
||||
created_at: string;
|
||||
created_at_epoch: number;
|
||||
} | null {
|
||||
const stmt = this.db.prepare(`
|
||||
SELECT
|
||||
id,
|
||||
sdk_session_id,
|
||||
claude_session_id,
|
||||
project,
|
||||
user_prompt,
|
||||
request_summary,
|
||||
learned_summary,
|
||||
status,
|
||||
created_at,
|
||||
created_at_epoch
|
||||
FROM sdk_sessions
|
||||
WHERE id = ?
|
||||
LIMIT 1
|
||||
`);
|
||||
|
||||
return stmt.get(id) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the database connection
|
||||
*/
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import express from 'express';
|
||||
import http from 'http';
|
||||
import path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||
import { getWorkerPort, getWorkerHost } from '../shared/worker-utils.js';
|
||||
@@ -142,6 +143,39 @@ export class WorkerService {
|
||||
}
|
||||
});
|
||||
|
||||
// Instructions endpoint - loads SKILL.md sections on-demand for progressive instruction loading
|
||||
this.app.get('/api/instructions', async (req, res) => {
|
||||
const topic = (req.query.topic as string) || 'all';
|
||||
// Read SKILL.md from plugin directory
|
||||
// Path resolution: __dirname is build output directory (plugin/scripts/)
|
||||
// SKILL.md is at plugin/skills/mem-search/SKILL.md
|
||||
const skillPath = path.join(__dirname, '../skills/mem-search/SKILL.md');
|
||||
|
||||
try {
|
||||
const fullContent = await fs.promises.readFile(skillPath, 'utf-8');
|
||||
|
||||
// Extract section based on topic
|
||||
const section = this.extractInstructionSection(fullContent, topic);
|
||||
|
||||
// Return in MCP format
|
||||
res.json({
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: section
|
||||
}]
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('WORKER', 'Failed to load instructions', { topic, skillPath }, error as Error);
|
||||
res.status(500).json({
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `Error loading instructions: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
}],
|
||||
isError: true
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Admin endpoints for process management
|
||||
this.app.post('/api/admin/restart', async (_req, res) => {
|
||||
res.json({ status: 'restarting' });
|
||||
@@ -334,6 +368,35 @@ export class WorkerService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a specific section from instruction content
|
||||
* Used by /api/instructions endpoint for progressive instruction loading
|
||||
*/
|
||||
private extractInstructionSection(content: string, topic: string): string {
|
||||
const sections: Record<string, string> = {
|
||||
'workflow': this.extractBetween(content, '## The Workflow', '## Search Parameters'),
|
||||
'search_params': this.extractBetween(content, '## Search Parameters', '## Examples'),
|
||||
'examples': this.extractBetween(content, '## Examples', '## Why This Workflow'),
|
||||
'all': content
|
||||
};
|
||||
|
||||
return sections[topic] || sections['all'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract text between two markers
|
||||
* Helper for extractInstructionSection
|
||||
*/
|
||||
private extractBetween(content: string, startMarker: string, endMarker: string): string {
|
||||
const startIdx = content.indexOf(startMarker);
|
||||
const endIdx = content.indexOf(endMarker);
|
||||
|
||||
if (startIdx === -1) return content;
|
||||
if (endIdx === -1) return content.substring(startIdx);
|
||||
|
||||
return content.substring(startIdx, endIdx).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown the worker service
|
||||
*/
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
/**
|
||||
* FormattingService - Handles all formatting logic for search results
|
||||
* Extracted from mcp-server.ts to follow worker service organization pattern
|
||||
* Uses table format matching context-generator style for visual consistency
|
||||
*/
|
||||
|
||||
import { ObservationSearchResult, SessionSummarySearchResult, UserPromptSearchResult } from '../sqlite/types.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { TYPE_ICON_MAP, TYPE_WORK_EMOJI_MAP } from '../../constants/observation-metadata.js';
|
||||
|
||||
export type FormatType = 'index' | 'full';
|
||||
// Token estimation constant (matches context-generator)
|
||||
const CHARS_PER_TOKEN_ESTIMATE = 4;
|
||||
|
||||
export class FormattingService {
|
||||
/**
|
||||
@@ -15,232 +16,155 @@ export class FormattingService {
|
||||
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.
|
||||
1. Search with index to see titles, dates, IDs
|
||||
2. Use timeline to get context around interesting results
|
||||
3. Batch fetch full details: get_batch_observations(ids=[...])
|
||||
|
||||
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"`;
|
||||
Tips:
|
||||
• Filter by type: obs_type="bugfix,feature"
|
||||
• Filter by date: dateStart="2025-01-01"
|
||||
• Sort: orderBy="date_desc" or "date_asc"`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format observation as index entry (title, date, ID only)
|
||||
* Format time from epoch (matches context-generator formatTime)
|
||||
*/
|
||||
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}`;
|
||||
private formatTime(epoch: number): string {
|
||||
return new Date(epoch).toLocaleString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Format session summary as index entry (title, date, ID only)
|
||||
* Estimate read tokens for an observation
|
||||
*/
|
||||
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}`;
|
||||
private estimateReadTokens(obs: ObservationSearchResult): number {
|
||||
const size = (obs.title?.length || 0) +
|
||||
(obs.subtitle?.length || 0) +
|
||||
(obs.narrative?.length || 0) +
|
||||
(obs.facts?.length || 0);
|
||||
return Math.ceil(size / CHARS_PER_TOKEN_ESTIMATE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format user prompt as index entry (full text - don't truncate context!)
|
||||
* Format observation as table row
|
||||
* | ID | Time | T | Title | Read | Work |
|
||||
*/
|
||||
formatUserPromptIndex(prompt: UserPromptSearchResult, index: number): string {
|
||||
const date = new Date(prompt.created_at_epoch).toLocaleString();
|
||||
formatObservationIndex(obs: ObservationSearchResult, _index: number): string {
|
||||
const id = `#${obs.id}`;
|
||||
const time = this.formatTime(obs.created_at_epoch);
|
||||
const icon = TYPE_ICON_MAP[obs.type as keyof typeof TYPE_ICON_MAP] || '•';
|
||||
const title = obs.title || 'Untitled';
|
||||
const readTokens = this.estimateReadTokens(obs);
|
||||
const workEmoji = TYPE_WORK_EMOJI_MAP[obs.type as keyof typeof TYPE_WORK_EMOJI_MAP] || '🔍';
|
||||
const workTokens = obs.discovery_tokens || 0;
|
||||
const workDisplay = workTokens > 0 ? `${workEmoji} ${workTokens}` : '-';
|
||||
|
||||
return `${index + 1}. "${prompt.prompt_text}"
|
||||
Date: ${date} | Prompt #${prompt.prompt_number}
|
||||
Source: claude-mem://user-prompt/${prompt.id}`;
|
||||
return `| ${id} | ${time} | ${icon} | ${title} | ~${readTokens} | ${workDisplay} |`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format observation as text content with metadata
|
||||
* Format session summary as table row
|
||||
* | ID | Time | T | Title | - | - |
|
||||
*/
|
||||
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 (e) {
|
||||
logger.warn('FORMAT', 'Invalid JSON in facts field', { obsId: obs.id });
|
||||
}
|
||||
}
|
||||
|
||||
if (obs.concepts) {
|
||||
try {
|
||||
const concepts = JSON.parse(obs.concepts);
|
||||
if (concepts.length > 0) {
|
||||
metadata.push(`Concepts: ${concepts.join(', ')}`);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('FORMAT', 'Invalid JSON in concepts field', { obsId: obs.id });
|
||||
}
|
||||
}
|
||||
|
||||
if (obs.files_read || obs.files_modified) {
|
||||
const files: string[] = [];
|
||||
if (obs.files_read) {
|
||||
try {
|
||||
files.push(...JSON.parse(obs.files_read));
|
||||
} catch (e) {
|
||||
logger.warn('FORMAT', 'Invalid JSON in files_read field', { obsId: obs.id });
|
||||
}
|
||||
}
|
||||
if (obs.files_modified) {
|
||||
try {
|
||||
files.push(...JSON.parse(obs.files_modified));
|
||||
} catch (e) {
|
||||
logger.warn('FORMAT', 'Invalid JSON in files_modified field', { obsId: obs.id });
|
||||
}
|
||||
}
|
||||
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 {
|
||||
formatSessionIndex(session: SessionSummarySearchResult, _index: number): string {
|
||||
const id = `#S${session.id}`;
|
||||
const time = this.formatTime(session.created_at_epoch);
|
||||
const icon = '🎯';
|
||||
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 (e) {
|
||||
logger.warn('FORMAT', 'Invalid JSON in session files_read field', { sessionId: session.sdk_session_id });
|
||||
}
|
||||
}
|
||||
if (session.files_edited) {
|
||||
try {
|
||||
files.push(...JSON.parse(session.files_edited));
|
||||
} catch (e) {
|
||||
logger.warn('FORMAT', 'Invalid JSON in session files_edited field', { sessionId: session.sdk_session_id });
|
||||
}
|
||||
}
|
||||
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');
|
||||
return `| ${id} | ${time} | ${icon} | ${title} | - | - |`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format user prompt as text content with metadata
|
||||
* Format user prompt as table row
|
||||
* | ID | Time | T | Title | - | - |
|
||||
*/
|
||||
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('---');
|
||||
formatUserPromptIndex(prompt: UserPromptSearchResult, _index: number): string {
|
||||
const id = `#P${prompt.id}`;
|
||||
const time = this.formatTime(prompt.created_at_epoch);
|
||||
const icon = '💬';
|
||||
// Truncate long prompts for table display
|
||||
const title = prompt.prompt_text.length > 60
|
||||
? prompt.prompt_text.substring(0, 57) + '...'
|
||||
: prompt.prompt_text;
|
||||
|
||||
const date = new Date(prompt.created_at_epoch).toLocaleString();
|
||||
contentParts.push(`Date: ${date}`);
|
||||
return `| ${id} | ${time} | ${icon} | ${title} | - | - |`;
|
||||
}
|
||||
|
||||
return contentParts.join('\n');
|
||||
/**
|
||||
* Generate table header for observations
|
||||
*/
|
||||
formatTableHeader(): string {
|
||||
return `| ID | Time | T | Title | Read | Work |
|
||||
|-----|------|---|-------|------|------|`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate table header for search results (no Work column)
|
||||
*/
|
||||
formatSearchTableHeader(): string {
|
||||
return `| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format observation as table row for search results (no Work column)
|
||||
*/
|
||||
formatObservationSearchRow(obs: ObservationSearchResult, lastTime: string): { row: string; time: string } {
|
||||
const id = `#${obs.id}`;
|
||||
const time = this.formatTime(obs.created_at_epoch);
|
||||
const icon = TYPE_ICON_MAP[obs.type as keyof typeof TYPE_ICON_MAP] || '•';
|
||||
const title = obs.title || 'Untitled';
|
||||
const readTokens = this.estimateReadTokens(obs);
|
||||
|
||||
// Use ditto mark if same time as previous row
|
||||
const timeDisplay = time === lastTime ? '″' : time;
|
||||
|
||||
return {
|
||||
row: `| ${id} | ${timeDisplay} | ${icon} | ${title} | ~${readTokens} |`,
|
||||
time
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format session summary as table row for search results (no Work column)
|
||||
*/
|
||||
formatSessionSearchRow(session: SessionSummarySearchResult, lastTime: string): { row: string; time: string } {
|
||||
const id = `#S${session.id}`;
|
||||
const time = this.formatTime(session.created_at_epoch);
|
||||
const icon = '🎯';
|
||||
const title = session.request || `Session ${session.sdk_session_id?.substring(0, 8) || 'unknown'}`;
|
||||
|
||||
// Use ditto mark if same time as previous row
|
||||
const timeDisplay = time === lastTime ? '″' : time;
|
||||
|
||||
return {
|
||||
row: `| ${id} | ${timeDisplay} | ${icon} | ${title} | - |`,
|
||||
time
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format user prompt as table row for search results (no Work column)
|
||||
*/
|
||||
formatUserPromptSearchRow(prompt: UserPromptSearchResult, lastTime: string): { row: string; time: string } {
|
||||
const id = `#P${prompt.id}`;
|
||||
const time = this.formatTime(prompt.created_at_epoch);
|
||||
const icon = '💬';
|
||||
// Truncate long prompts for table display
|
||||
const title = prompt.prompt_text.length > 60
|
||||
? prompt.prompt_text.substring(0, 57) + '...'
|
||||
: prompt.prompt_text;
|
||||
|
||||
// Use ditto mark if same time as previous row
|
||||
const timeDisplay = time === lastTime ? '″' : time;
|
||||
|
||||
return {
|
||||
row: `| ${id} | ${timeDisplay} | ${icon} | ${title} | - |`,
|
||||
time
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,8 +14,11 @@ import { FormattingService } from './FormattingService.js';
|
||||
import { TimelineService, TimelineItem } from './TimelineService.js';
|
||||
import { ObservationSearchResult, SessionSummarySearchResult, UserPromptSearchResult } from '../sqlite/types.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { formatDate, formatTime, extractFirstFile, groupByDate } from '../../shared/timeline-formatting.js';
|
||||
|
||||
const COLLECTION_NAME = 'cm__claude-mem';
|
||||
const RECENCY_WINDOW_DAYS = 90;
|
||||
const RECENCY_WINDOW_MS = RECENCY_WINDOW_DAYS * 24 * 60 * 60 * 1000;
|
||||
|
||||
export class SearchManager {
|
||||
constructor(
|
||||
@@ -84,7 +87,7 @@ export class SearchManager {
|
||||
try {
|
||||
// Normalize URL-friendly params to internal format
|
||||
const normalized = this.normalizeParams(args);
|
||||
const { query, format = 'index', type, obs_type, concepts, files, ...options } = normalized;
|
||||
const { query, type, obs_type, concepts, files, ...options } = normalized;
|
||||
let observations: ObservationSearchResult[] = [];
|
||||
let sessions: SessionSummarySearchResult[] = [];
|
||||
let prompts: UserPromptSearchResult[] = [];
|
||||
@@ -132,7 +135,7 @@ export class SearchManager {
|
||||
|
||||
if (chromaResults.ids.length > 0) {
|
||||
// Step 2: Filter by recency (90 days)
|
||||
const ninetyDaysAgo = Date.now() - (90 * 24 * 60 * 60 * 1000);
|
||||
const ninetyDaysAgo = Date.now() - RECENCY_WINDOW_MS;
|
||||
const recentMetadata = chromaResults.metadatas.map((meta, idx) => ({
|
||||
id: chromaResults.ids[idx],
|
||||
meta,
|
||||
@@ -198,13 +201,6 @@ export class SearchManager {
|
||||
const totalResults = observations.length + sessions.length + prompts.length;
|
||||
|
||||
if (totalResults === 0) {
|
||||
if (format === 'json') {
|
||||
return {
|
||||
observations: [],
|
||||
sessions: [],
|
||||
prompts: []
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
@@ -218,15 +214,31 @@ export class SearchManager {
|
||||
type: 'observation' | 'session' | 'prompt';
|
||||
data: any;
|
||||
epoch: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
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 }))
|
||||
...observations.map(obs => ({
|
||||
type: 'observation' as const,
|
||||
data: obs,
|
||||
epoch: obs.created_at_epoch,
|
||||
created_at: obs.created_at
|
||||
})),
|
||||
...sessions.map(sess => ({
|
||||
type: 'session' as const,
|
||||
data: sess,
|
||||
epoch: sess.created_at_epoch,
|
||||
created_at: sess.created_at
|
||||
})),
|
||||
...prompts.map(prompt => ({
|
||||
type: 'prompt' as const,
|
||||
data: prompt,
|
||||
epoch: prompt.created_at_epoch,
|
||||
created_at: prompt.created_at
|
||||
}))
|
||||
];
|
||||
|
||||
// Sort by date (most recent first)
|
||||
// Sort by date
|
||||
if (options.orderBy === 'date_desc') {
|
||||
allResults.sort((a, b) => b.epoch - a.epoch);
|
||||
} else if (options.orderBy === 'date_asc') {
|
||||
@@ -236,46 +248,62 @@ export class SearchManager {
|
||||
// Apply limit across all types
|
||||
const limitedResults = allResults.slice(0, options.limit || 20);
|
||||
|
||||
// Format based on requested format
|
||||
if (format === 'json') {
|
||||
// Raw JSON format for exports
|
||||
return {
|
||||
observations,
|
||||
sessions,
|
||||
prompts
|
||||
};
|
||||
}
|
||||
// Group by date, then by file within each day
|
||||
const cwd = process.cwd();
|
||||
const resultsByDate = groupByDate(limitedResults, item => item.created_at);
|
||||
|
||||
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);
|
||||
// Build output with date/file grouping
|
||||
const lines: string[] = [];
|
||||
lines.push(`Found ${totalResults} result(s) matching "${query}" (${observations.length} obs, ${sessions.length} sessions, ${prompts.length} prompts)`);
|
||||
lines.push('');
|
||||
|
||||
for (const [day, dayResults] of resultsByDate) {
|
||||
lines.push(`### ${day}`);
|
||||
lines.push('');
|
||||
|
||||
// Group by file within this day
|
||||
const resultsByFile = new Map<string, CombinedResult[]>();
|
||||
for (const result of dayResults) {
|
||||
let file = 'General';
|
||||
if (result.type === 'observation') {
|
||||
file = extractFirstFile(result.data.files_modified, cwd);
|
||||
}
|
||||
});
|
||||
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);
|
||||
if (!resultsByFile.has(file)) {
|
||||
resultsByFile.set(file, []);
|
||||
}
|
||||
});
|
||||
combinedText = formattedResults.join('\n\n---\n\n');
|
||||
resultsByFile.get(file)!.push(result);
|
||||
}
|
||||
|
||||
// Render each file section
|
||||
for (const [file, fileResults] of resultsByFile) {
|
||||
lines.push(`**${file}**`);
|
||||
lines.push(this.formatter.formatSearchTableHeader());
|
||||
|
||||
let lastTime = '';
|
||||
for (const result of fileResults) {
|
||||
if (result.type === 'observation') {
|
||||
const formatted = this.formatter.formatObservationSearchRow(result.data as ObservationSearchResult, lastTime);
|
||||
lines.push(formatted.row);
|
||||
lastTime = formatted.time;
|
||||
} else if (result.type === 'session') {
|
||||
const formatted = this.formatter.formatSessionSearchRow(result.data as SessionSummarySearchResult, lastTime);
|
||||
lines.push(formatted.row);
|
||||
lastTime = formatted.time;
|
||||
} else {
|
||||
const formatted = this.formatter.formatUserPromptSearchRow(result.data as UserPromptSearchResult, lastTime);
|
||||
lines.push(formatted.row);
|
||||
lastTime = formatted.time;
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: combinedText
|
||||
text: lines.join('\n')
|
||||
}]
|
||||
};
|
||||
} catch (error: any) {
|
||||
@@ -295,6 +323,7 @@ export class SearchManager {
|
||||
async timeline(args: any): Promise<any> {
|
||||
try {
|
||||
const { anchor, query, depth_before = 10, depth_after = 10, project } = args;
|
||||
const cwd = process.cwd();
|
||||
|
||||
// Validate: must provide either anchor or query, not both
|
||||
if (!anchor && !query) {
|
||||
@@ -333,7 +362,7 @@ export class SearchManager {
|
||||
logger.debug('SEARCH', 'Chroma returned semantic matches for timeline', { matchCount: chromaResults?.ids?.length ?? 0 });
|
||||
|
||||
if (chromaResults?.ids && chromaResults.ids.length > 0) {
|
||||
const ninetyDaysAgo = Date.now() - (90 * 24 * 60 * 60 * 1000);
|
||||
const ninetyDaysAgo = Date.now() - RECENCY_WINDOW_MS;
|
||||
const recentIds = chromaResults.ids.filter((_id, idx) => {
|
||||
const meta = chromaResults.metadatas[idx];
|
||||
return meta && meta.created_at_epoch > ninetyDaysAgo;
|
||||
@@ -495,9 +524,6 @@ export class SearchManager {
|
||||
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[]>();
|
||||
@@ -541,10 +567,9 @@ export class SearchManager {
|
||||
|
||||
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(`**🎯 #S${sess.id}** ${title} (${formatDateTime(item.epoch)})${marker}`);
|
||||
lines.push('');
|
||||
} else if (item.type === 'prompt') {
|
||||
if (tableOpen) {
|
||||
@@ -562,7 +587,7 @@ export class SearchManager {
|
||||
lines.push('');
|
||||
} else if (item.type === 'observation') {
|
||||
const obs = item.data as ObservationSearchResult;
|
||||
const file = 'General';
|
||||
const file = extractFirstFile(obs.files_modified, cwd);
|
||||
|
||||
if (file !== currentFile) {
|
||||
if (tableOpen) {
|
||||
@@ -629,7 +654,7 @@ export class SearchManager {
|
||||
async decisions(args: any): Promise<any> {
|
||||
try {
|
||||
const normalized = this.normalizeParams(args);
|
||||
const { query, format = 'index', ...filters } = normalized;
|
||||
const { query, ...filters } = normalized;
|
||||
let results: ObservationSearchResult[] = [];
|
||||
|
||||
// Search for decision-type observations
|
||||
@@ -686,20 +711,14 @@ export class SearchManager {
|
||||
};
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
// Format as table
|
||||
const header = `Found ${results.length} decision(s)\n\n${this.formatter.formatTableHeader()}`;
|
||||
const formattedResults = results.map((obs, i) => this.formatter.formatObservationIndex(obs, i));
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: combinedText
|
||||
text: header + '\n' + formattedResults.join('\n')
|
||||
}]
|
||||
};
|
||||
} catch (error: any) {
|
||||
@@ -719,7 +738,7 @@ export class SearchManager {
|
||||
async changes(args: any): Promise<any> {
|
||||
try {
|
||||
const normalized = this.normalizeParams(args);
|
||||
const { format = 'index', ...filters } = normalized;
|
||||
const { ...filters } = normalized;
|
||||
let results: ObservationSearchResult[] = [];
|
||||
|
||||
// Search for change-type observations and change-related concepts
|
||||
@@ -784,20 +803,14 @@ export class SearchManager {
|
||||
};
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
// Format as table
|
||||
const header = `Found ${results.length} change-related observation(s)\n\n${this.formatter.formatTableHeader()}`;
|
||||
const formattedResults = results.map((obs, i) => this.formatter.formatObservationIndex(obs, i));
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: combinedText
|
||||
text: header + '\n' + formattedResults.join('\n')
|
||||
}]
|
||||
};
|
||||
} catch (error: any) {
|
||||
@@ -817,7 +830,7 @@ export class SearchManager {
|
||||
async howItWorks(args: any): Promise<any> {
|
||||
try {
|
||||
const normalized = this.normalizeParams(args);
|
||||
const { format = 'index', ...filters } = normalized;
|
||||
const { ...filters } = normalized;
|
||||
let results: ObservationSearchResult[] = [];
|
||||
|
||||
// Search for how-it-works concept observations
|
||||
@@ -860,20 +873,14 @@ export class SearchManager {
|
||||
};
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
// Format as table
|
||||
const header = `Found ${results.length} "how it works" observation(s)\n\n${this.formatter.formatTableHeader()}`;
|
||||
const formattedResults = results.map((obs, i) => this.formatter.formatObservationIndex(obs, i));
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: combinedText
|
||||
text: header + '\n' + formattedResults.join('\n')
|
||||
}]
|
||||
};
|
||||
} catch (error: any) {
|
||||
@@ -893,7 +900,7 @@ export class SearchManager {
|
||||
async searchObservations(args: any): Promise<any> {
|
||||
try {
|
||||
const normalized = this.normalizeParams(args);
|
||||
const { query, format = 'index', ...options } = normalized;
|
||||
const { query, ...options } = normalized;
|
||||
let results: ObservationSearchResult[] = [];
|
||||
|
||||
// Vector-first search via ChromaDB
|
||||
@@ -907,7 +914,7 @@ export class SearchManager {
|
||||
|
||||
if (chromaResults.ids.length > 0) {
|
||||
// Step 2: Filter by recency (90 days)
|
||||
const ninetyDaysAgo = Date.now() - (90 * 24 * 60 * 60 * 1000);
|
||||
const ninetyDaysAgo = Date.now() - RECENCY_WINDOW_MS;
|
||||
const recentIds = chromaResults.ids.filter((_id, idx) => {
|
||||
const meta = chromaResults.metadatas[idx];
|
||||
return meta && meta.created_at_epoch > ninetyDaysAgo;
|
||||
@@ -936,21 +943,14 @@ export class SearchManager {
|
||||
};
|
||||
}
|
||||
|
||||
// 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');
|
||||
}
|
||||
// Format as table
|
||||
const header = `Found ${results.length} observation(s) matching "${query}"\n\n${this.formatter.formatTableHeader()}`;
|
||||
const formattedResults = results.map((obs, i) => this.formatter.formatObservationIndex(obs, i));
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: combinedText
|
||||
text: header + '\n' + formattedResults.join('\n')
|
||||
}]
|
||||
};
|
||||
} catch (error: any) {
|
||||
@@ -970,7 +970,7 @@ export class SearchManager {
|
||||
async searchSessions(args: any): Promise<any> {
|
||||
try {
|
||||
const normalized = this.normalizeParams(args);
|
||||
const { query, format = 'index', ...options } = normalized;
|
||||
const { query, ...options } = normalized;
|
||||
let results: SessionSummarySearchResult[] = [];
|
||||
|
||||
// Vector-first search via ChromaDB
|
||||
@@ -984,7 +984,7 @@ export class SearchManager {
|
||||
|
||||
if (chromaResults.ids.length > 0) {
|
||||
// Step 2: Filter by recency (90 days)
|
||||
const ninetyDaysAgo = Date.now() - (90 * 24 * 60 * 60 * 1000);
|
||||
const ninetyDaysAgo = Date.now() - RECENCY_WINDOW_MS;
|
||||
const recentIds = chromaResults.ids.filter((_id, idx) => {
|
||||
const meta = chromaResults.metadatas[idx];
|
||||
return meta && meta.created_at_epoch > ninetyDaysAgo;
|
||||
@@ -1013,21 +1013,14 @@ export class SearchManager {
|
||||
};
|
||||
}
|
||||
|
||||
// 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');
|
||||
}
|
||||
// Format as table
|
||||
const header = `Found ${results.length} session(s) matching "${query}"\n\n${this.formatter.formatTableHeader()}`;
|
||||
const formattedResults = results.map((session, i) => this.formatter.formatSessionIndex(session, i));
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: combinedText
|
||||
text: header + '\n' + formattedResults.join('\n')
|
||||
}]
|
||||
};
|
||||
} catch (error: any) {
|
||||
@@ -1047,7 +1040,7 @@ export class SearchManager {
|
||||
async searchUserPrompts(args: any): Promise<any> {
|
||||
try {
|
||||
const normalized = this.normalizeParams(args);
|
||||
const { query, format = 'index', ...options } = normalized;
|
||||
const { query, ...options } = normalized;
|
||||
let results: UserPromptSearchResult[] = [];
|
||||
|
||||
// Vector-first search via ChromaDB
|
||||
@@ -1061,7 +1054,7 @@ export class SearchManager {
|
||||
|
||||
if (chromaResults.ids.length > 0) {
|
||||
// Step 2: Filter by recency (90 days)
|
||||
const ninetyDaysAgo = Date.now() - (90 * 24 * 60 * 60 * 1000);
|
||||
const ninetyDaysAgo = Date.now() - RECENCY_WINDOW_MS;
|
||||
const recentIds = chromaResults.ids.filter((_id, idx) => {
|
||||
const meta = chromaResults.metadatas[idx];
|
||||
return meta && meta.created_at_epoch > ninetyDaysAgo;
|
||||
@@ -1090,21 +1083,14 @@ export class SearchManager {
|
||||
};
|
||||
}
|
||||
|
||||
// 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');
|
||||
}
|
||||
// Format as table
|
||||
const header = `Found ${results.length} user prompt(s) matching "${query}"\n\n${this.formatter.formatTableHeader()}`;
|
||||
const formattedResults = results.map((prompt, i) => this.formatter.formatUserPromptIndex(prompt, i));
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: combinedText
|
||||
text: header + '\n' + formattedResults.join('\n')
|
||||
}]
|
||||
};
|
||||
} catch (error: any) {
|
||||
@@ -1124,7 +1110,7 @@ export class SearchManager {
|
||||
async findByConcept(args: any): Promise<any> {
|
||||
try {
|
||||
const normalized = this.normalizeParams(args);
|
||||
const { concepts: concept, format = 'index', ...filters } = normalized;
|
||||
const { concepts: concept, ...filters } = normalized;
|
||||
let results: ObservationSearchResult[] = [];
|
||||
|
||||
// Metadata-first, semantic-enhanced search
|
||||
@@ -1179,21 +1165,14 @@ export class SearchManager {
|
||||
};
|
||||
}
|
||||
|
||||
// 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');
|
||||
}
|
||||
// Format as table
|
||||
const header = `Found ${results.length} observation(s) with concept "${concept}"\n\n${this.formatter.formatTableHeader()}`;
|
||||
const formattedResults = results.map((obs, i) => this.formatter.formatObservationIndex(obs, i));
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: combinedText
|
||||
text: header + '\n' + formattedResults.join('\n')
|
||||
}]
|
||||
};
|
||||
} catch (error: any) {
|
||||
@@ -1213,7 +1192,7 @@ export class SearchManager {
|
||||
async findByFile(args: any): Promise<any> {
|
||||
try {
|
||||
const normalized = this.normalizeParams(args);
|
||||
const { files: filePath, format = 'index', ...filters } = normalized;
|
||||
const { files: filePath, ...filters } = normalized;
|
||||
let observations: ObservationSearchResult[] = [];
|
||||
let sessions: SessionSummarySearchResult[] = [];
|
||||
|
||||
@@ -1277,42 +1256,24 @@ export class SearchManager {
|
||||
};
|
||||
}
|
||||
|
||||
let combinedText: string;
|
||||
if (format === 'index') {
|
||||
const header = `Found ${totalResults} result(s) for file "${filePath}":\n\n`;
|
||||
const formattedResults: string[] = [];
|
||||
// Format as table
|
||||
const header = `Found ${totalResults} result(s) for file "${filePath}"\n\n${this.formatter.formatTableHeader()}`;
|
||||
const formattedResults: string[] = [];
|
||||
|
||||
// Add observations
|
||||
observations.forEach((obs, i) => {
|
||||
formattedResults.push(this.formatter.formatObservationIndex(obs, i));
|
||||
});
|
||||
// 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');
|
||||
}
|
||||
// Add sessions
|
||||
sessions.forEach((session, i) => {
|
||||
formattedResults.push(this.formatter.formatSessionIndex(session, i + observations.length));
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: combinedText
|
||||
text: header + '\n' + formattedResults.join('\n')
|
||||
}]
|
||||
};
|
||||
} catch (error: any) {
|
||||
@@ -1332,7 +1293,7 @@ export class SearchManager {
|
||||
async findByType(args: any): Promise<any> {
|
||||
try {
|
||||
const normalized = this.normalizeParams(args);
|
||||
const { type, format = 'index', ...filters } = normalized;
|
||||
const { type, ...filters } = normalized;
|
||||
const typeStr = Array.isArray(type) ? type.join(', ') : type;
|
||||
let results: ObservationSearchResult[] = [];
|
||||
|
||||
@@ -1388,21 +1349,14 @@ export class SearchManager {
|
||||
};
|
||||
}
|
||||
|
||||
// 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');
|
||||
}
|
||||
// Format as table
|
||||
const header = `Found ${results.length} observation(s) with type "${typeStr}"\n\n${this.formatter.formatTableHeader()}`;
|
||||
const formattedResults = results.map((obs, i) => this.formatter.formatObservationIndex(obs, i));
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: combinedText
|
||||
text: header + '\n' + formattedResults.join('\n')
|
||||
}]
|
||||
};
|
||||
} catch (error: any) {
|
||||
@@ -1556,6 +1510,7 @@ export class SearchManager {
|
||||
async getContextTimeline(args: any): Promise<any> {
|
||||
try {
|
||||
const { anchor, depth_before = 10, depth_after = 10, project } = args;
|
||||
const cwd = process.cwd();
|
||||
let anchorEpoch: number;
|
||||
let anchorId: string | number = anchor;
|
||||
|
||||
@@ -1680,9 +1635,6 @@ export class SearchManager {
|
||||
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[]>();
|
||||
@@ -1728,10 +1680,9 @@ export class SearchManager {
|
||||
// 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(`**🎯 #S${sess.id}** ${title} (${formatDateTime(item.epoch)})${marker}`);
|
||||
lines.push('');
|
||||
} else if (item.type === 'prompt') {
|
||||
// Close any open table
|
||||
@@ -1752,7 +1703,7 @@ export class SearchManager {
|
||||
} else if (item.type === 'observation') {
|
||||
// Render observation in table
|
||||
const obs = item.data as ObservationSearchResult;
|
||||
const file = 'General'; // Simplified for timeline view
|
||||
const file = extractFirstFile(obs.files_modified, cwd);
|
||||
|
||||
// Check if we need a new file section
|
||||
if (file !== currentFile) {
|
||||
@@ -1824,6 +1775,7 @@ export class SearchManager {
|
||||
async getTimelineByQuery(args: any): Promise<any> {
|
||||
try {
|
||||
const { query, mode = 'auto', depth_before = 10, depth_after = 10, limit = 5, project } = args;
|
||||
const cwd = process.cwd();
|
||||
|
||||
// Step 1: Search for observations
|
||||
let results: ObservationSearchResult[] = [];
|
||||
@@ -1837,7 +1789,7 @@ export class SearchManager {
|
||||
|
||||
if (chromaResults.ids.length > 0) {
|
||||
// Filter by recency (90 days)
|
||||
const ninetyDaysAgo = Date.now() - (90 * 24 * 60 * 60 * 1000);
|
||||
const ninetyDaysAgo = Date.now() - RECENCY_WINDOW_MS;
|
||||
const recentIds = chromaResults.ids.filter((_id, idx) => {
|
||||
const meta = chromaResults.metadatas[idx];
|
||||
return meta && meta.created_at_epoch > ninetyDaysAgo;
|
||||
@@ -1889,7 +1841,6 @@ export class SearchManager {
|
||||
if (obs.subtitle) {
|
||||
lines.push(` - ${obs.subtitle}`);
|
||||
}
|
||||
lines.push(` - Source: claude-mem://observation/${obs.id}`);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
@@ -1975,9 +1926,6 @@ export class SearchManager {
|
||||
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[]>();
|
||||
@@ -2020,9 +1968,8 @@ export class SearchManager {
|
||||
// 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(`**🎯 #S${sess.id}** ${title} (${formatDateTime(item.epoch)})`);
|
||||
lines.push('');
|
||||
} else if (item.type === 'prompt') {
|
||||
// Close any open table
|
||||
@@ -2043,7 +1990,7 @@ export class SearchManager {
|
||||
} else if (item.type === 'observation') {
|
||||
// Render observation in table
|
||||
const obs = item.data as ObservationSearchResult;
|
||||
const file = 'General'; // Simplified for timeline view
|
||||
const file = extractFirstFile(obs.files_modified, cwd);
|
||||
|
||||
// Check if we need a new file section
|
||||
if (file !== currentFile) {
|
||||
|
||||
@@ -148,10 +148,9 @@ export class TimelineService {
|
||||
|
||||
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(`**🎯 #S${sess.id}** ${title} (${this.formatDateTime(item.epoch)})${marker}`);
|
||||
lines.push('');
|
||||
} else if (item.type === 'prompt') {
|
||||
if (tableOpen) {
|
||||
|
||||
@@ -38,6 +38,7 @@ export class DataRoutes extends BaseRouteHandler {
|
||||
|
||||
// Fetch by ID endpoints
|
||||
app.get('/api/observation/:id', this.handleGetObservationById.bind(this));
|
||||
app.post('/api/observations/batch', this.handleGetObservationsByIds.bind(this));
|
||||
app.get('/api/session/:id', this.handleGetSessionById.bind(this));
|
||||
app.get('/api/prompt/:id', this.handleGetPromptById.bind(this));
|
||||
|
||||
@@ -96,6 +97,36 @@ export class DataRoutes extends BaseRouteHandler {
|
||||
res.json(observation);
|
||||
});
|
||||
|
||||
/**
|
||||
* Get observations by array of IDs
|
||||
* POST /api/observations/batch
|
||||
* Body: { ids: number[], orderBy?: 'date_desc' | 'date_asc', limit?: number, project?: string }
|
||||
*/
|
||||
private handleGetObservationsByIds = this.wrapHandler((req: Request, res: Response): void => {
|
||||
const { ids, orderBy, limit, project } = req.body;
|
||||
|
||||
if (!ids || !Array.isArray(ids)) {
|
||||
this.badRequest(res, 'ids must be an array of numbers');
|
||||
return;
|
||||
}
|
||||
|
||||
if (ids.length === 0) {
|
||||
res.json([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate all IDs are numbers
|
||||
if (!ids.every(id => typeof id === 'number' && Number.isInteger(id))) {
|
||||
this.badRequest(res, 'All ids must be integers');
|
||||
return;
|
||||
}
|
||||
|
||||
const store = this.dbManager.getSessionStore();
|
||||
const observations = store.getObservationsByIds(ids, { orderBy, limit, project });
|
||||
|
||||
res.json(observations);
|
||||
});
|
||||
|
||||
/**
|
||||
* Get session by ID
|
||||
* GET /api/session/:id
|
||||
|
||||
@@ -45,7 +45,7 @@ export class SearchRoutes extends BaseRouteHandler {
|
||||
|
||||
/**
|
||||
* Unified search (observations + sessions + prompts)
|
||||
* GET /api/search?query=...&type=observations&format=index&limit=20
|
||||
* GET /api/search?query=...&type=observations&limit=20
|
||||
*/
|
||||
private handleUnifiedSearch = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
|
||||
const result = await this.searchManager.search(req.query);
|
||||
@@ -63,7 +63,7 @@ export class SearchRoutes extends BaseRouteHandler {
|
||||
|
||||
/**
|
||||
* Semantic shortcut for finding decision observations
|
||||
* GET /api/decisions?format=index&limit=20
|
||||
* GET /api/decisions?limit=20
|
||||
*/
|
||||
private handleDecisions = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
|
||||
const result = await this.searchManager.decisions(req.query);
|
||||
@@ -72,7 +72,7 @@ export class SearchRoutes extends BaseRouteHandler {
|
||||
|
||||
/**
|
||||
* Semantic shortcut for finding change-related observations
|
||||
* GET /api/changes?format=index&limit=20
|
||||
* GET /api/changes?limit=20
|
||||
*/
|
||||
private handleChanges = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
|
||||
const result = await this.searchManager.changes(req.query);
|
||||
@@ -81,7 +81,7 @@ export class SearchRoutes extends BaseRouteHandler {
|
||||
|
||||
/**
|
||||
* Semantic shortcut for finding "how it works" explanations
|
||||
* GET /api/how-it-works?format=index&limit=20
|
||||
* GET /api/how-it-works?limit=20
|
||||
*/
|
||||
private handleHowItWorks = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
|
||||
const result = await this.searchManager.howItWorks(req.query);
|
||||
@@ -90,7 +90,7 @@ export class SearchRoutes extends BaseRouteHandler {
|
||||
|
||||
/**
|
||||
* Search observations (use /api/search?type=observations instead)
|
||||
* GET /api/search/observations?query=...&format=index&limit=20&project=...
|
||||
* GET /api/search/observations?query=...&limit=20&project=...
|
||||
*/
|
||||
private handleSearchObservations = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
|
||||
const result = await this.searchManager.searchObservations(req.query);
|
||||
@@ -99,7 +99,7 @@ export class SearchRoutes extends BaseRouteHandler {
|
||||
|
||||
/**
|
||||
* Search session summaries
|
||||
* GET /api/search/sessions?query=...&format=index&limit=20
|
||||
* GET /api/search/sessions?query=...&limit=20
|
||||
*/
|
||||
private handleSearchSessions = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
|
||||
const result = await this.searchManager.searchSessions(req.query);
|
||||
@@ -108,7 +108,7 @@ export class SearchRoutes extends BaseRouteHandler {
|
||||
|
||||
/**
|
||||
* Search user prompts
|
||||
* GET /api/search/prompts?query=...&format=index&limit=20
|
||||
* GET /api/search/prompts?query=...&limit=20
|
||||
*/
|
||||
private handleSearchPrompts = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
|
||||
const result = await this.searchManager.searchUserPrompts(req.query);
|
||||
@@ -117,7 +117,7 @@ export class SearchRoutes extends BaseRouteHandler {
|
||||
|
||||
/**
|
||||
* Search observations by concept
|
||||
* GET /api/search/by-concept?concept=discovery&format=index&limit=5
|
||||
* GET /api/search/by-concept?concept=discovery&limit=5
|
||||
*/
|
||||
private handleSearchByConcept = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
|
||||
const result = await this.searchManager.findByConcept(req.query);
|
||||
@@ -126,7 +126,7 @@ export class SearchRoutes extends BaseRouteHandler {
|
||||
|
||||
/**
|
||||
* Search by file path
|
||||
* GET /api/search/by-file?filePath=...&format=index&limit=10
|
||||
* GET /api/search/by-file?filePath=...&limit=10
|
||||
*/
|
||||
private handleSearchByFile = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
|
||||
const result = await this.searchManager.findByFile(req.query);
|
||||
@@ -135,7 +135,7 @@ export class SearchRoutes extends BaseRouteHandler {
|
||||
|
||||
/**
|
||||
* Search observations by type
|
||||
* GET /api/search/by-type?type=bugfix&format=index&limit=10
|
||||
* GET /api/search/by-type?type=bugfix&limit=10
|
||||
*/
|
||||
private handleSearchByType = this.wrapHandler(async (req: Request, res: Response): Promise<void> => {
|
||||
const result = await this.searchManager.findByType(req.query);
|
||||
@@ -252,7 +252,6 @@ export class SearchRoutes extends BaseRouteHandler {
|
||||
description: 'Search observations using full-text search',
|
||||
parameters: {
|
||||
query: 'Search query (required)',
|
||||
format: 'Response format: "index" or "full" (default: "full")',
|
||||
limit: 'Number of results (default: 20)',
|
||||
project: 'Filter by project name (optional)'
|
||||
}
|
||||
@@ -263,7 +262,6 @@ export class SearchRoutes extends BaseRouteHandler {
|
||||
description: 'Search session summaries using full-text search',
|
||||
parameters: {
|
||||
query: 'Search query (required)',
|
||||
format: 'Response format: "index" or "full" (default: "full")',
|
||||
limit: 'Number of results (default: 20)'
|
||||
}
|
||||
},
|
||||
@@ -273,7 +271,6 @@ export class SearchRoutes extends BaseRouteHandler {
|
||||
description: 'Search user prompts using full-text search',
|
||||
parameters: {
|
||||
query: 'Search query (required)',
|
||||
format: 'Response format: "index" or "full" (default: "full")',
|
||||
limit: 'Number of results (default: 20)',
|
||||
project: 'Filter by project name (optional)'
|
||||
}
|
||||
@@ -284,7 +281,6 @@ export class SearchRoutes extends BaseRouteHandler {
|
||||
description: 'Find observations by concept tag',
|
||||
parameters: {
|
||||
concept: 'Concept tag (required): discovery, decision, bugfix, feature, refactor',
|
||||
format: 'Response format: "index" or "full" (default: "full")',
|
||||
limit: 'Number of results (default: 10)',
|
||||
project: 'Filter by project name (optional)'
|
||||
}
|
||||
@@ -295,7 +291,6 @@ export class SearchRoutes extends BaseRouteHandler {
|
||||
description: 'Find observations and sessions by file path',
|
||||
parameters: {
|
||||
filePath: 'File path or partial path (required)',
|
||||
format: 'Response format: "index" or "full" (default: "full")',
|
||||
limit: 'Number of results per type (default: 10)',
|
||||
project: 'Filter by project name (optional)'
|
||||
}
|
||||
@@ -306,7 +301,6 @@ export class SearchRoutes extends BaseRouteHandler {
|
||||
description: 'Find observations by type',
|
||||
parameters: {
|
||||
type: 'Observation type (required): discovery, decision, bugfix, feature, refactor',
|
||||
format: 'Response format: "index" or "full" (default: "full")',
|
||||
limit: 'Number of results (default: 10)',
|
||||
project: 'Filter by project name (optional)'
|
||||
}
|
||||
@@ -350,7 +344,7 @@ export class SearchRoutes extends BaseRouteHandler {
|
||||
}
|
||||
],
|
||||
examples: [
|
||||
'curl "http://localhost:37777/api/search/observations?query=authentication&format=index&limit=5"',
|
||||
'curl "http://localhost:37777/api/search/observations?query=authentication&limit=5"',
|
||||
'curl "http://localhost:37777/api/search/by-type?type=bugfix&limit=10"',
|
||||
'curl "http://localhost:37777/api/context/recent?project=claude-mem&limit=3"',
|
||||
'curl "http://localhost:37777/api/context/timeline?anchor=123&depth_before=5&depth_after=5"'
|
||||
|
||||
@@ -44,7 +44,7 @@ export class SettingsDefaultsManager {
|
||||
* Default values for all settings
|
||||
*/
|
||||
private static readonly DEFAULTS: SettingsDefaults = {
|
||||
CLAUDE_MEM_MODEL: 'claude-haiku-4-5',
|
||||
CLAUDE_MEM_MODEL: 'claude-sonnet-4-5',
|
||||
CLAUDE_MEM_CONTEXT_OBSERVATIONS: '50',
|
||||
CLAUDE_MEM_WORKER_PORT: '37777',
|
||||
CLAUDE_MEM_WORKER_HOST: '127.0.0.1',
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Shared timeline formatting utilities
|
||||
*
|
||||
* Pure formatting and grouping functions extracted from context-generator.ts
|
||||
* to be reused by SearchManager and other services.
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* Parse JSON array string, returning empty array on failure
|
||||
*/
|
||||
export function parseJsonArray(json: string | null): string[] {
|
||||
if (!json) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(json);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch (err) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date with time (e.g., "Dec 14, 7:30 PM")
|
||||
*/
|
||||
export function formatDateTime(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Format just time, no date (e.g., "7:30 PM")
|
||||
*/
|
||||
export function formatTime(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Format just date (e.g., "Dec 14, 2025")
|
||||
*/
|
||||
export function formatDate(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert absolute paths to relative paths
|
||||
*/
|
||||
export function toRelativePath(filePath: string, cwd: string): string {
|
||||
if (path.isAbsolute(filePath)) {
|
||||
return path.relative(cwd, filePath);
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract first file from files_modified JSON array, or return 'General'
|
||||
*/
|
||||
export function extractFirstFile(filesModified: string | null, cwd: string): string {
|
||||
const files = parseJsonArray(filesModified);
|
||||
return files.length > 0 ? toRelativePath(files[0], cwd) : 'General';
|
||||
}
|
||||
|
||||
/**
|
||||
* Group items by date
|
||||
*
|
||||
* Generic function that works with any item type that has a date field.
|
||||
* Returns a Map of date string -> items array, sorted chronologically.
|
||||
*
|
||||
* @param items - Array of items to group
|
||||
* @param getDate - Function to extract date string from each item
|
||||
* @returns Map of formatted date strings to item arrays, sorted chronologically
|
||||
*/
|
||||
export function groupByDate<T>(
|
||||
items: T[],
|
||||
getDate: (item: T) => string
|
||||
): Map<string, T[]> {
|
||||
// Group by day
|
||||
const itemsByDay = new Map<string, T[]>();
|
||||
for (const item of items) {
|
||||
const itemDate = getDate(item);
|
||||
const day = formatDate(itemDate);
|
||||
if (!itemsByDay.has(day)) {
|
||||
itemsByDay.set(day, []);
|
||||
}
|
||||
itemsByDay.get(day)!.push(item);
|
||||
}
|
||||
|
||||
// Sort days chronologically
|
||||
const sortedEntries = Array.from(itemsByDay.entries()).sort((a, b) => {
|
||||
const aDate = new Date(a[0]).getTime();
|
||||
const bDate = new Date(b[0]).getTime();
|
||||
return aDate - bDate;
|
||||
});
|
||||
|
||||
return new Map(sortedEntries);
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
* Shared across UI components and hooks
|
||||
*/
|
||||
export const DEFAULT_SETTINGS = {
|
||||
CLAUDE_MEM_MODEL: 'claude-haiku-4-5',
|
||||
CLAUDE_MEM_MODEL: 'claude-sonnet-4-5',
|
||||
CLAUDE_MEM_CONTEXT_OBSERVATIONS: '50',
|
||||
CLAUDE_MEM_WORKER_PORT: '37777',
|
||||
CLAUDE_MEM_WORKER_HOST: '127.0.0.1',
|
||||
|
||||
Reference in New Issue
Block a user