Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 28 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,29 @@
<!-- This section is maintained by the coding agent via lore (https://github.com/BYK/loreai) -->
## Long-term Knowledge
# Jared (Outpost agent)

For long-term knowledge entries managed by [lore](https://github.com/BYK/loreai) (gotchas, patterns, decisions, architecture), see [`.lore.md`](.lore.md) in the project root.
<!-- End lore-managed section -->
Autonomous GitHub coding agent. Work in `/workspace/repo`.

## Model tiers

The primary model is chosen per event (see `src/agents/models.ts`): heavy for
code-producing situations, cheaper for lightweight ones.

| Role | Subagent | Model |
| --- | --- | --- |
| Triage / plan / review (heavy) | (primary Jared) | Claude Opus 4.8 |
| Triage / plan / review (light) | (primary Jared) | xAI Grok 4.3 |
| Explore | `explore` | OpenAI gpt-5-mini |
| Implement | `implement` | Moonshot kimi-k2.7-code |
| Ship (commit/push/PR) | `ship` | xAI Grok (`grok-build-0.1`) |

Pipeline: triage → explore → plan → implement → review → ship.
(`worker` is a deprecated alias of `implement`.)

Operators also talk to Jared directly from the Outpost dashboard. Those turns
(`New operator chat` / `Operator guidance:`) skip triage — treat the request as
the task and answer in the conversation.

Long-term project knowledge for *this* Outpost repo lives in `.lore.md` when present.
For target repositories, read their `AGENTS.md` / `CONTRIBUTING.md` first.

Skills are under `.agents/skills/`, generated from the canonical `skills/` tree
by `scripts/sync-skills.mjs`. Always load `repo-setup` before situation skills.
Comment thread
jared-outpost[bot] marked this conversation as resolved.
135 changes: 135 additions & 0 deletions packages/cli/src/lib/formatters/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1531,6 +1531,138 @@ function renderPlaceholderContent(message: string): string[] {
return [isPlainOutput() ? `(${message})` : muted(`(${message})`)];
}

// ---------------------------------------------------------------------------
// Heatmap renderer
// ---------------------------------------------------------------------------

/**
* Intensity ramp for heatmap cells.
*
* Index 0 is empty (zero value); 1-4 map increasing intensity to shade
* blocks in plain mode and to a blue→red heat gradient in color mode.
*/
const HEATMAP_SHADES = [" ", "░", "▒", "▓", "█"] as const;
const HEATMAP_COLORS = [
"#79B8FF", // low — cyan/blue
"#FDB81B", // yellow
"#FF9838", // orange
"#fe4144", // high — red
] as const;

/** Map a normalized intensity (0-1) to a ramp bucket index (0-4). */
function heatmapBucket(normalized: number): number {
if (normalized <= 0) {
return 0;
}
return Math.min(4, Math.max(1, Math.ceil(normalized * 4)));
}

/** Render a single heatmap cell for a normalized intensity. */
function heatmapCell(normalized: number, plain: boolean): string {
const bucket = heatmapBucket(normalized);
if (plain) {
return HEATMAP_SHADES[bucket] ?? " ";
}
if (bucket === 0) {
return " ";
}
const color = HEATMAP_COLORS[bucket - 1] ?? COLORS.magenta;
return chalk.hex(color)("█");
}

/**
* Render heatmap content: one row per series (category), columns over time.
*
* Cell intensity encodes the value relative to the global maximum across all
* cells, so hot spots stand out. Falls back to a "no data" line when there
* are no series or values.
*/
function renderHeatmapContent(
data: TimeseriesResult,
opts: { innerWidth: number; contentHeight: number }
): string[] {
const { innerWidth, contentHeight } = opts;
if (data.series.length === 0) {
return [noDataLine()];
}

const plain = isPlainOutput();

// Reserve rows for the bottom time axis (2 lines) and a legend (1 line).
const axisLines = 3;
const maxRows = Math.max(1, contentHeight - axisLines);
const series = data.series.slice(0, maxRows);

const maxLabelLen = Math.min(
20,
Math.max(4, ...series.map((s) => s.label.length))
);
const gutterW = maxLabelLen + 1; // label + space
const chartWidth = Math.max(1, innerWidth - gutterW);

// Determine the actual number of time buckets from the longest series.
const maxLen = Math.max(0, ...series.map((s) => s.values.length));
const bucketCount = Math.min(chartWidth, maxLen || chartWidth);

// Downsample every series to that bucket count; pad shorter series so every
// row has exactly `bucketCount` cells (downsample returns early for short input).
const rows = series.map((s) => {
const ds = downsample(
s.values.map((v) => v.value),
bucketCount
);
return ds.length < bucketCount
? [...ds, ...new Array(bucketCount - ds.length).fill(0)]
: ds;
});
const globalMax = Math.max(1, ...rows.flat());

const lines: string[] = [];
for (let i = 0; i < series.length; i += 1) {
const s = series[i];
const values = rows[i] ?? [];
if (!s) {
continue;
}
const label =
s.label.length > maxLabelLen
? `${s.label.slice(0, maxLabelLen - 1)}…`
: s.label.padEnd(maxLabelLen);
const cells = values.map((v) => heatmapCell(v / globalMax, plain)).join("");
const labelStr = plain ? label : chalk.hex(COLORS.cyan)(label);
lines.push(`${labelStr} ${cells}`);
}

// Bottom time axis, aligned to the chart area.
// Find the longest series so its timestamps match the bucketCount.
const longest = series.reduce(
(a, b) => (b.values.length > a.values.length ? b : a),
series[0] ?? { values: [] }
);
const axisTs = longest.values.map((v) => v.timestamp);
if (axisTs.length > 0) {
const dsTs = downsampleTimestamps(axisTs, bucketCount);
lines.push(
...buildTimeAxis({
timestamps: dsTs,
chartWidth: bucketCount,
gutterWidth: gutterW,
})
);
Comment thread
cursor[bot] marked this conversation as resolved.
}

// Intensity legend: low → high.
const gutter = " ".repeat(gutterW);
if (plain) {
lines.push(`${gutter}low ${HEATMAP_SHADES.slice(1).join("")} high`);
} else {
const ramp = HEATMAP_COLORS.map((c) => chalk.hex(c)("█")).join("");
lines.push(`${gutter}${muted("low")} ${ramp} ${muted("high")}`);
}

return lines;
}

/**
* Dispatch to the appropriate content renderer based on data type.
*
Expand All @@ -1547,6 +1679,9 @@ function renderContentLines(opts: {

switch (data.type) {
case "timeseries":
if (widget.displayType === "heatmap") {
return renderHeatmapContent(data, { innerWidth, contentHeight });
}
if (widget.displayType === "categorical_bar") {
return renderVerticalBarsContent(data, { innerWidth, contentHeight });
}
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/types/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export const DISPLAY_TYPES = [
"top_n",
"details",
"categorical_bar",
"heatmap",
"wheel",
"rage_and_dead_clicks",
"server_tree",
Expand Down Expand Up @@ -1041,6 +1042,7 @@ export const TIMESERIES_DISPLAY_TYPES = new Set([
"stacked_area",
"bar",
"categorical_bar",
"heatmap",
]);

/** Display types that use tabular data (events endpoint) */
Expand Down
53 changes: 53 additions & 0 deletions packages/cli/test/lib/formatters/dashboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -605,6 +605,59 @@ describe("formatDashboardWithData", () => {
expect(output).toContain("(no data)");
});

test("renders heatmap as one row per series with a legend", () => {
const data = makeDashboardData({
widgets: [
makeWidget({
title: "Errors by Browser",
displayType: "heatmap",
layout: { x: 0, y: 0, w: 3, h: 3 },
data: makeTimeseriesData({
series: [
{
label: "Chrome",
values: [
{ timestamp: 1_700_000_000, value: 0 },
{ timestamp: 1_700_000_060, value: 50 },
{ timestamp: 1_700_000_120, value: 100 },
],
},
{
label: "Firefox",
values: [
{ timestamp: 1_700_000_000, value: 5 },
{ timestamp: 1_700_000_060, value: 10 },
{ timestamp: 1_700_000_120, value: 20 },
],
},
],
}),
}),
],
});
const output = formatDashboardWithData(data);
expect(output).toContain("Errors by Browser");
// Category labels appear as row headers
expect(output).toContain("Chrome");
expect(output).toContain("Firefox");
// Intensity legend is present
expect(output).toContain("low");
expect(output).toContain("high");
});

test("shows no data for empty series in heatmap", () => {
const data = makeDashboardData({
widgets: [
makeWidget({
displayType: "heatmap",
data: makeTimeseriesData({ series: [] }),
}),
],
});
const output = formatDashboardWithData(data);
expect(output).toContain("(no data)");
});

test("bar chart fills full widget width (no trailing gap)", () => {
// Create enough data points that bars should fill the chart area.
// With a tall widget (h=3), the renderer uses bar mode (not sparkline).
Expand Down
1 change: 1 addition & 0 deletions packages/cli/test/types/dashboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ describe("DISPLAY_TYPES", () => {
"top_n",
"details",
"categorical_bar",
"heatmap",
"wheel",
"rage_and_dead_clicks",
"server_tree",
Expand Down
Loading