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:
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user