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:
Alex Newman
2025-12-14 20:54:32 -05:00
parent 7ef93343a4
commit 9bac3faae9
6 changed files with 372 additions and 180 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+9 -53
View File
@@ -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) {
+66
View File
@@ -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
};
}
}
+78 -18
View File
@@ -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) {
+112
View File
@@ -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);
}