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>
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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 [];
|
||||
@@ -556,8 +513,7 @@ export async function generateContext(input?: ContextInput, useColors: boolean =
|
||||
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) {
|
||||
|
||||
@@ -101,4 +101,70 @@ Tips:
|
||||
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,6 +14,7 @@ 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';
|
||||
|
||||
@@ -211,15 +212,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') {
|
||||
@@ -229,22 +246,62 @@ export class SearchManager {
|
||||
// Apply limit across all types
|
||||
const limitedResults = allResults.slice(0, options.limit || 20);
|
||||
|
||||
// Format as table
|
||||
const header = `Found ${totalResults} result(s) matching "${query}" (${observations.length} obs, ${sessions.length} sessions, ${prompts.length} prompts)\n\n${this.formatter.formatTableHeader()}`;
|
||||
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);
|
||||
// Group by date, then by file within each day
|
||||
const cwd = process.cwd();
|
||||
const resultsByDate = groupByDate(limitedResults, item => item.created_at);
|
||||
|
||||
// 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);
|
||||
}
|
||||
if (!resultsByFile.has(file)) {
|
||||
resultsByFile.set(file, []);
|
||||
}
|
||||
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: header + '\n' + formattedResults.join('\n')
|
||||
text: lines.join('\n')
|
||||
}]
|
||||
};
|
||||
} catch (error: any) {
|
||||
@@ -264,6 +321,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) {
|
||||
@@ -531,7 +589,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) {
|
||||
@@ -1454,6 +1512,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;
|
||||
|
||||
@@ -1650,7 +1709,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) {
|
||||
@@ -1722,6 +1781,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[] = [];
|
||||
@@ -1941,7 +2001,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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user