feat: add timeline-report skill with token economics, compress context output 53%
## Summary - New timeline-report skill for generating narrative project history reports - Compressed markdown context output ~53% (tables → flat compact lines, verbose labels → terse format) - Added `full=true` param to /api/context/inject for fetching all observations - Split TimelineRenderer into separate markdown/color rendering paths - Removed arbitrary file write vulnerability (dump_to_file param) - Fixed timestamp ditto marker leaking across session summary boundaries ## Review - Rebased on main (v10.6.0) to preserve OpenClaw system prompt injection - Reviewed by /review (gstack) + /octo:review (Codex, Gemini, Claude fleet) - Security fix (dump_to_file removal) confirmed by all 3 reviewers - Timestamp bug caught by Codex, fixed 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
@@ -134,6 +134,12 @@ export async function generateContext(
|
||||
// Use provided projects array (for worktree support) or fall back to single project
|
||||
const projects = input?.projects || [project];
|
||||
|
||||
// Full mode: fetch all observations but keep normal rendering (level 1 summaries)
|
||||
if (input?.full) {
|
||||
config.totalObservationCount = 999999;
|
||||
config.sessionCount = 999999;
|
||||
}
|
||||
|
||||
// Initialize database
|
||||
const db = initializeDatabase();
|
||||
if (!db) {
|
||||
@@ -155,7 +161,7 @@ export async function generateContext(
|
||||
}
|
||||
|
||||
// Build and return context
|
||||
return buildContextOutput(
|
||||
const output = buildContextOutput(
|
||||
project,
|
||||
observations,
|
||||
summaries,
|
||||
@@ -164,6 +170,8 @@ export async function generateContext(
|
||||
input?.session_id,
|
||||
useColors
|
||||
);
|
||||
|
||||
return output;
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* MarkdownFormatter - Formats context output as markdown (non-colored mode)
|
||||
* MarkdownFormatter - Formats context output as compact markdown for LLM injection
|
||||
*
|
||||
* Handles all markdown formatting for context injection.
|
||||
* Optimized for token efficiency: flat lines instead of tables, no repeated headers.
|
||||
* The colored terminal formatter (ColorFormatter.ts) handles human-readable display separately.
|
||||
*/
|
||||
|
||||
import type {
|
||||
@@ -34,7 +35,7 @@ function formatHeaderDateTime(): string {
|
||||
*/
|
||||
export function renderMarkdownHeader(project: string): string[] {
|
||||
return [
|
||||
`# [${project}] recent context, ${formatHeaderDateTime()}`,
|
||||
`# $CMEM ${project} ${formatHeaderDateTime()}`,
|
||||
''
|
||||
];
|
||||
}
|
||||
@@ -44,39 +45,28 @@ export function renderMarkdownHeader(project: string): string[] {
|
||||
*/
|
||||
export function renderMarkdownLegend(): string[] {
|
||||
const mode = ModeManager.getInstance().getActiveMode();
|
||||
const typeLegendItems = mode.observation_types.map(t => `${t.emoji} ${t.id}`).join(' | ');
|
||||
const typeLegendItems = mode.observation_types.map(t => `${t.emoji}${t.id}`).join(' ');
|
||||
|
||||
return [
|
||||
`**Legend:** session-request | ${typeLegendItems}`,
|
||||
`Legend: 🎯session ${typeLegendItems}`,
|
||||
`Format: ID TIME TYPE TITLE`,
|
||||
`Fetch details: get_observations([IDs]) | Search: mem-search skill`,
|
||||
''
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Render markdown column key
|
||||
* Render markdown column key - no longer needed in compact format
|
||||
*/
|
||||
export function renderMarkdownColumnKey(): string[] {
|
||||
return [
|
||||
`**Column Key**:`,
|
||||
`- **Read**: Tokens to read this observation (cost to learn it now)`,
|
||||
`- **Work**: Tokens spent on work that produced this record ( research, building, deciding)`,
|
||||
''
|
||||
];
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Render markdown context index instructions
|
||||
* Render markdown context index instructions - folded into legend
|
||||
*/
|
||||
export function renderMarkdownContextIndex(): string[] {
|
||||
return [
|
||||
`**Context Index:** This semantic index (titles, types, files, tokens) is usually sufficient to understand past work.`,
|
||||
'',
|
||||
`When you need implementation details, rationale, or debugging context:`,
|
||||
`- Fetch by ID: get_observations([IDs]) for observations visible in this index`,
|
||||
`- Search history: Use the mem-search skill for past decisions, bugs, and deeper research`,
|
||||
`- Trust this index over re-reading code for past decisions and learnings`,
|
||||
''
|
||||
];
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -88,21 +78,20 @@ export function renderMarkdownContextEconomics(
|
||||
): string[] {
|
||||
const output: string[] = [];
|
||||
|
||||
output.push(`**Context Economics**:`);
|
||||
output.push(`- Loading: ${economics.totalObservations} observations (${economics.totalReadTokens.toLocaleString()} tokens to read)`);
|
||||
output.push(`- Work investment: ${economics.totalDiscoveryTokens.toLocaleString()} tokens spent on research, building, and decisions`);
|
||||
const parts: string[] = [
|
||||
`${economics.totalObservations} obs (${economics.totalReadTokens.toLocaleString()}t read)`,
|
||||
`${economics.totalDiscoveryTokens.toLocaleString()}t work`
|
||||
];
|
||||
|
||||
if (economics.totalDiscoveryTokens > 0 && (config.showSavingsAmount || config.showSavingsPercent)) {
|
||||
let savingsLine = '- Your savings: ';
|
||||
if (config.showSavingsAmount && config.showSavingsPercent) {
|
||||
savingsLine += `${economics.savings.toLocaleString()} tokens (${economics.savingsPercent}% reduction from reuse)`;
|
||||
if (config.showSavingsPercent) {
|
||||
parts.push(`${economics.savingsPercent}% savings`);
|
||||
} else if (config.showSavingsAmount) {
|
||||
savingsLine += `${economics.savings.toLocaleString()} tokens`;
|
||||
} else {
|
||||
savingsLine += `${economics.savingsPercent}% reduction from reuse`;
|
||||
parts.push(`${economics.savings.toLocaleString()}t saved`);
|
||||
}
|
||||
output.push(savingsLine);
|
||||
}
|
||||
|
||||
output.push(`Stats: ${parts.join(' | ')}`);
|
||||
output.push('');
|
||||
|
||||
return output;
|
||||
@@ -114,37 +103,37 @@ export function renderMarkdownContextEconomics(
|
||||
export function renderMarkdownDayHeader(day: string): string[] {
|
||||
return [
|
||||
`### ${day}`,
|
||||
''
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Render markdown file header with table header
|
||||
* Render markdown file header - no longer renders table headers in compact format
|
||||
*/
|
||||
export function renderMarkdownFileHeader(file: string): string[] {
|
||||
return [
|
||||
`**${file}**`,
|
||||
`| ID | Time | T | Title | Read | Work |`,
|
||||
`|----|------|---|-------|------|------|`
|
||||
];
|
||||
export function renderMarkdownFileHeader(_file: string): string[] {
|
||||
// File grouping eliminated in compact format - file context is in observation titles
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Render markdown table row for observation
|
||||
* Format compact time: "9:23 AM" → "9:23a", "12:05 PM" → "12:05p"
|
||||
*/
|
||||
function compactTime(time: string): string {
|
||||
return time.toLowerCase().replace(' am', 'a').replace(' pm', 'p');
|
||||
}
|
||||
|
||||
/**
|
||||
* Render compact flat line for observation (replaces table row)
|
||||
*/
|
||||
export function renderMarkdownTableRow(
|
||||
obs: Observation,
|
||||
timeDisplay: string,
|
||||
config: ContextConfig
|
||||
_config: ContextConfig
|
||||
): string {
|
||||
const title = obs.title || 'Untitled';
|
||||
const icon = ModeManager.getInstance().getTypeIcon(obs.type);
|
||||
const { readTokens, discoveryDisplay } = formatObservationTokenDisplay(obs, config);
|
||||
const time = timeDisplay ? compactTime(timeDisplay) : '"';
|
||||
|
||||
const readCol = config.showReadTokens ? `~${readTokens}` : '';
|
||||
const workCol = config.showWorkTokens ? discoveryDisplay : '';
|
||||
|
||||
return `| #${obs.id} | ${timeDisplay || '"'} | ${icon} | ${title} | ${readCol} | ${workCol} |`;
|
||||
return `${obs.id} ${time} ${icon} ${title}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -159,24 +148,23 @@ export function renderMarkdownFullObservation(
|
||||
const output: string[] = [];
|
||||
const title = obs.title || 'Untitled';
|
||||
const icon = ModeManager.getInstance().getTypeIcon(obs.type);
|
||||
const time = timeDisplay ? compactTime(timeDisplay) : '"';
|
||||
const { readTokens, discoveryDisplay } = formatObservationTokenDisplay(obs, config);
|
||||
|
||||
output.push(`**#${obs.id}** ${timeDisplay || '"'} ${icon} **${title}**`);
|
||||
output.push(`**${obs.id}** ${time} ${icon} **${title}**`);
|
||||
if (detailField) {
|
||||
output.push('');
|
||||
output.push(detailField);
|
||||
output.push('');
|
||||
}
|
||||
|
||||
const tokenParts: string[] = [];
|
||||
if (config.showReadTokens) {
|
||||
tokenParts.push(`Read: ~${readTokens}`);
|
||||
tokenParts.push(`~${readTokens}t`);
|
||||
}
|
||||
if (config.showWorkTokens) {
|
||||
tokenParts.push(`Work: ${discoveryDisplay}`);
|
||||
tokenParts.push(discoveryDisplay);
|
||||
}
|
||||
if (tokenParts.length > 0) {
|
||||
output.push(tokenParts.join(', '));
|
||||
output.push(tokenParts.join(' '));
|
||||
}
|
||||
output.push('');
|
||||
|
||||
@@ -190,10 +178,8 @@ export function renderMarkdownSummaryItem(
|
||||
summary: { id: number; request: string | null },
|
||||
formattedTime: string
|
||||
): string[] {
|
||||
const summaryTitle = `${summary.request || 'Session started'} (${formattedTime})`;
|
||||
return [
|
||||
`**#S${summary.id}** ${summaryTitle}`,
|
||||
''
|
||||
`S${summary.id} ${summary.request || 'Session started'} (${formattedTime})`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -229,7 +215,7 @@ export function renderMarkdownFooter(totalDiscoveryTokens: number, totalReadToke
|
||||
const workTokensK = Math.round(totalDiscoveryTokens / 1000);
|
||||
return [
|
||||
'',
|
||||
`Access ${workTokensK}k tokens of past research & decisions for just ${totalReadTokens.toLocaleString()}t. Use the claude-mem skill to access memories by ID.`
|
||||
`Access ${workTokensK}k tokens of past work via get_observations([IDs]) or mem-search skill.`
|
||||
];
|
||||
}
|
||||
|
||||
@@ -237,5 +223,5 @@ export function renderMarkdownFooter(totalDiscoveryTokens: number, totalReadToke
|
||||
* Render markdown empty state
|
||||
*/
|
||||
export function renderMarkdownEmptyState(project: string): string {
|
||||
return `# [${project}] recent context, ${formatHeaderDateTime()}\n\nNo previous sessions found for this project yet.`;
|
||||
return `# $CMEM ${project} ${formatHeaderDateTime()}\n\nNo previous sessions found.`;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* TimelineRenderer - Renders the chronological timeline of observations and summaries
|
||||
*
|
||||
* Handles day grouping, file grouping within days, and table rendering.
|
||||
* Handles day grouping and rendering. In markdown (LLM) mode, uses flat compact lines.
|
||||
* In color (terminal) mode, uses file grouping with visual formatting.
|
||||
*/
|
||||
|
||||
import type {
|
||||
@@ -49,6 +50,103 @@ function getDetailField(obs: Observation, config: ContextConfig): string | null
|
||||
return obs.facts ? parseJsonArray(obs.facts).join('\n') : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single day's timeline items (markdown/LLM mode - flat compact lines)
|
||||
*/
|
||||
function renderDayTimelineMarkdown(
|
||||
day: string,
|
||||
dayItems: TimelineItem[],
|
||||
fullObservationIds: Set<number>,
|
||||
config: ContextConfig,
|
||||
): string[] {
|
||||
const output: string[] = [];
|
||||
|
||||
output.push(...Markdown.renderMarkdownDayHeader(day));
|
||||
|
||||
let lastTime = '';
|
||||
|
||||
for (const item of dayItems) {
|
||||
if (item.type === 'summary') {
|
||||
lastTime = '';
|
||||
|
||||
const summary = item.data as SummaryTimelineItem;
|
||||
const formattedTime = formatDateTime(summary.displayTime);
|
||||
output.push(...Markdown.renderMarkdownSummaryItem(summary, formattedTime));
|
||||
} else {
|
||||
const obs = item.data as Observation;
|
||||
const time = formatTime(obs.created_at);
|
||||
const showTime = time !== lastTime;
|
||||
const timeDisplay = showTime ? time : '';
|
||||
lastTime = time;
|
||||
|
||||
const shouldShowFull = fullObservationIds.has(obs.id);
|
||||
|
||||
if (shouldShowFull) {
|
||||
const detailField = getDetailField(obs, config);
|
||||
output.push(...Markdown.renderMarkdownFullObservation(obs, timeDisplay, detailField, config));
|
||||
} else {
|
||||
output.push(Markdown.renderMarkdownTableRow(obs, timeDisplay, config));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single day's timeline items (color/terminal mode - file grouped with tables)
|
||||
*/
|
||||
function renderDayTimelineColor(
|
||||
day: string,
|
||||
dayItems: TimelineItem[],
|
||||
fullObservationIds: Set<number>,
|
||||
config: ContextConfig,
|
||||
cwd: string,
|
||||
): string[] {
|
||||
const output: string[] = [];
|
||||
|
||||
output.push(...Color.renderColorDayHeader(day));
|
||||
|
||||
let currentFile: string | null = null;
|
||||
let lastTime = '';
|
||||
|
||||
for (const item of dayItems) {
|
||||
if (item.type === 'summary') {
|
||||
currentFile = null;
|
||||
lastTime = '';
|
||||
|
||||
const summary = item.data as SummaryTimelineItem;
|
||||
const formattedTime = formatDateTime(summary.displayTime);
|
||||
output.push(...Color.renderColorSummaryItem(summary, formattedTime));
|
||||
} else {
|
||||
const obs = item.data as Observation;
|
||||
const file = extractFirstFile(obs.files_modified, cwd, obs.files_read);
|
||||
const time = formatTime(obs.created_at);
|
||||
const showTime = time !== lastTime;
|
||||
lastTime = time;
|
||||
|
||||
const shouldShowFull = fullObservationIds.has(obs.id);
|
||||
|
||||
// Check if we need a new file section
|
||||
if (file !== currentFile) {
|
||||
output.push(...Color.renderColorFileHeader(file));
|
||||
currentFile = file;
|
||||
}
|
||||
|
||||
if (shouldShowFull) {
|
||||
const detailField = getDetailField(obs, config);
|
||||
output.push(...Color.renderColorFullObservation(obs, time, showTime, detailField, config));
|
||||
} else {
|
||||
output.push(Color.renderColorTableRow(obs, time, showTime, config));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
output.push('');
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single day's timeline items
|
||||
*/
|
||||
@@ -60,93 +158,10 @@ export function renderDayTimeline(
|
||||
cwd: string,
|
||||
useColors: boolean
|
||||
): string[] {
|
||||
const output: string[] = [];
|
||||
|
||||
// Day header
|
||||
if (useColors) {
|
||||
output.push(...Color.renderColorDayHeader(day));
|
||||
} else {
|
||||
output.push(...Markdown.renderMarkdownDayHeader(day));
|
||||
return renderDayTimelineColor(day, dayItems, fullObservationIds, config, cwd);
|
||||
}
|
||||
|
||||
let currentFile: string | null = null;
|
||||
let lastTime = '';
|
||||
let tableOpen = false;
|
||||
|
||||
for (const item of dayItems) {
|
||||
if (item.type === 'summary') {
|
||||
// Close any open table before summary
|
||||
if (tableOpen) {
|
||||
output.push('');
|
||||
tableOpen = false;
|
||||
currentFile = null;
|
||||
lastTime = '';
|
||||
}
|
||||
|
||||
const summary = item.data as SummaryTimelineItem;
|
||||
const formattedTime = formatDateTime(summary.displayTime);
|
||||
|
||||
if (useColors) {
|
||||
output.push(...Color.renderColorSummaryItem(summary, formattedTime));
|
||||
} else {
|
||||
output.push(...Markdown.renderMarkdownSummaryItem(summary, formattedTime));
|
||||
}
|
||||
} else {
|
||||
const obs = item.data as Observation;
|
||||
const file = extractFirstFile(obs.files_modified, cwd, obs.files_read);
|
||||
const time = formatTime(obs.created_at);
|
||||
const showTime = time !== lastTime;
|
||||
const timeDisplay = showTime ? time : '';
|
||||
lastTime = time;
|
||||
|
||||
const shouldShowFull = fullObservationIds.has(obs.id);
|
||||
|
||||
// Check if we need a new file section
|
||||
if (file !== currentFile) {
|
||||
if (tableOpen) {
|
||||
output.push('');
|
||||
}
|
||||
|
||||
if (useColors) {
|
||||
output.push(...Color.renderColorFileHeader(file));
|
||||
} else {
|
||||
output.push(...Markdown.renderMarkdownFileHeader(file));
|
||||
}
|
||||
|
||||
currentFile = file;
|
||||
tableOpen = true;
|
||||
}
|
||||
|
||||
if (shouldShowFull) {
|
||||
const detailField = getDetailField(obs, config);
|
||||
|
||||
if (useColors) {
|
||||
output.push(...Color.renderColorFullObservation(obs, time, showTime, detailField, config));
|
||||
} else {
|
||||
// Close table for full observation in markdown mode
|
||||
if (tableOpen && !useColors) {
|
||||
output.push('');
|
||||
tableOpen = false;
|
||||
}
|
||||
output.push(...Markdown.renderMarkdownFullObservation(obs, timeDisplay, detailField, config));
|
||||
currentFile = null; // Reset to trigger new table header if needed
|
||||
}
|
||||
} else {
|
||||
if (useColors) {
|
||||
output.push(Color.renderColorTableRow(obs, time, showTime, config));
|
||||
} else {
|
||||
output.push(Markdown.renderMarkdownTableRow(obs, timeDisplay, config));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close any remaining open table
|
||||
if (tableOpen) {
|
||||
output.push('');
|
||||
}
|
||||
|
||||
return output;
|
||||
return renderDayTimelineMarkdown(day, dayItems, fullObservationIds, config);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,6 +13,8 @@ export interface ContextInput {
|
||||
source?: "startup" | "resume" | "clear" | "compact";
|
||||
/** Array of projects to query (for worktree support: [parent, worktree]) */
|
||||
projects?: string[];
|
||||
/** When true, return ALL observations with no limit */
|
||||
full?: boolean;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
|
||||
@@ -208,6 +208,7 @@ export class SearchRoutes extends BaseRouteHandler {
|
||||
// Support both legacy `project` and new `projects` parameter
|
||||
const projectsParam = (req.query.projects as string) || (req.query.project as string);
|
||||
const useColors = req.query.colors === 'true';
|
||||
const full = req.query.full === 'true';
|
||||
|
||||
if (!projectsParam) {
|
||||
this.badRequest(res, 'Project(s) parameter is required');
|
||||
@@ -234,7 +235,8 @@ export class SearchRoutes extends BaseRouteHandler {
|
||||
{
|
||||
session_id: 'context-inject-' + Date.now(),
|
||||
cwd: cwd,
|
||||
projects: projects
|
||||
projects: projects,
|
||||
full
|
||||
},
|
||||
useColors
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user