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:
Alex Newman
2026-03-18 13:57:20 -07:00
committed by GitHub
parent 648c84804c
commit 7e07210635
8 changed files with 468 additions and 269 deletions
+9 -1
View File
@@ -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.`;
}
+101 -86
View File
@@ -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);
}
/**
+2
View File
@@ -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
);