Skip to content
Merged
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
12 changes: 6 additions & 6 deletions herdr-insights/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,10 @@ Reads local sessions from **Pi, Codex, Claude Code, and OpenCode**.

![Insights](preview.png)

*Preview uses synthetic data. The installed dashboard only shows recorded usage.*

## Install

Requires Herdr and **Node.js 22.15+**. No dependencies or account needed.
Installing requires Herdr and **Node.js 22.15+**. No dependencies or account needed.
The uninstaller also runs on older Node versions.

Ask your agent:

Expand All @@ -26,9 +25,10 @@ cd pi-extensions
npm run install:local --prefix herdr-insights -- --open
```

The installer runs `herdr-app check`, then opens an **Insights** tab. It installs
only `mods/insights/` under `~/.config/herdr-app`; your layouts, keys, and terminals
are unchanged. Set `HERDR_APP_HOME` to use a different app home.
The installer runs `herdr-app check`; with `--open` it then opens an **Insights**
tab. It installs owned files only under `mods/insights/` and writes its install
manifest to `data/insights/` in `~/.config/herdr-app`; your layouts, keys, and
terminals are unchanged. Set `HERDR_APP_HOME` to use a different app home.

## Use

Expand Down
18 changes: 8 additions & 10 deletions herdr-insights/RECIPE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,6 @@ and opens in its own tab without changing terminals, layouts, or keys.

![Insights](preview.png)

The screenshot uses synthetic data for illustration. Never include those rows or
future model names as defaults: the installed mod shows only recorded usage.

**What you see:** A vertically scrollable dashboard centered at a maximum width
of 1180px. Use Herdr theme colors, 1px borders, 8px card corners, 6px control
corners, no decorative shadows, and 4/8/12/16/24px spacing. Page padding is 12px
Expand Down Expand Up @@ -46,7 +43,7 @@ metrics or heatmap. Menus cap choices at 250 while retaining selected values.
Week, month, and current-year periods are calendar-to-date; past years are full.
Compare with the same calendar portion of the previous period, clamping month
ends. All has no comparison. The chart always spans 26 weeks ending at the selected
period's end, rather than switching to daily buckets as in the illustrative preview.
period's end.

Weekly heatmap mode repeats each week's total on every day in that week;
cumulative mode includes all preceding history. A streak ending yesterday still
Expand All @@ -65,8 +62,9 @@ are **Insights**; the native mod ID is `insights`.
semantic colors, `on_size_change`, and fresh callback contexts. Do not retain `cx`.
Cancel tasks/timers and release native editor/input state on close. Use native
animations, never redraw timers. No DOM, web view, shell interpolation, or network.
`ui.js` centralizes surfaces, typography, spacing, and controls; bundle monochrome
provider/model SVGs in `logos/`. Pure `analytics.js` runs in Node and prepares totals,
`ui.js` centralizes surfaces, typography, spacing, and controls; bundle provider/model
SVGs in `logos/`. Some logos carry brand colors, so do not assume they are
monochrome or theme-tintable. Pure `analytics.js` runs in Node and prepares totals,
facets, chart buckets, heatmap, and leaderboard comparisons.

The UI invokes a local Node `presenter.mjs` through Herdr `exec` with separate
Expand All @@ -93,8 +91,10 @@ cacheWrite, and reasoning field specs. `roles` filters the mapped role; `include
is a list of `{path, equals}` or `{path, exists}` conditions. Reject unknown keys
and executable mappings. `core/config.mjs` defines and validates the full schema.

Provide dependency-free Node install/uninstall scripts. Require Node >=22.15 before
writing anything. Install only the mod tree and generate `runtime.json` with the
Provide dependency-free Node install/uninstall scripts. The installer requires
Node >=22.15 and validates it before writing anything; the uninstaller intentionally
has no Node gate so it remains usable on older runtimes. Install only the mod tree
(plus its manifest under `data/insights/`) and generate `runtime.json` with the
absolute installer Node path; the UI uses that path, falling back to `node` on PATH
only for manual copies. Track ownership/hashes in a separate install manifest.
Refuse symlinks and unowned collisions; refuse edited owned files unless explicitly
Expand All @@ -114,8 +114,6 @@ a backup before rebuilding; read-only prepare must not repair or create stores.

Never write layouts, keys, source transcripts, or an existing user's usage data
from the installer. Do not copy an absolute runtime path from another machine.
The preview is a native UI screenshot with synthetic data; the production dashboard
must not imitate its daily-bucket mock behavior or fabricated model list.

**Done when:** Package tests and checks pass, including distribution naming,
PNG preview, recipe structure, no layouts, token accounting, deduplication,
Expand Down
33 changes: 4 additions & 29 deletions herdr-insights/scripts/check.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
collectSourceFiles,
lstatOrNull,
} from "./lib.mjs";
import { inspectDistribution } from "./distribution.mjs";

const errors = [];
const notes = [];
Expand Down Expand Up @@ -116,7 +117,6 @@ async function checkPackage() {
async function checkFiles() {
for (const relative of [
".gitignore",
"README.md",
"RECIPE.md",
"preview.png",
"mods/insights/main.js",
Expand All @@ -138,18 +138,6 @@ async function checkFiles() {
}

async function checkDocs() {
const readme = await readText(path.join(PACKAGE_ROOT, "README.md"));
if (readme !== null) {
for (const heading of ["# Insights", "## Install", "## Use", "## Privacy", "## Development"]) {
if (!readme.includes(heading)) fail(`README.md is missing ${heading}`);
}
for (const phrase of ["![Insights](preview.png)", "insights.json", "herdr-app check", "Node.js 22.15+", "synthetic data"]) {
if (!readme.toLowerCase().includes(phrase.toLowerCase())) {
fail(`README.md does not mention ${JSON.stringify(phrase)}`);
}
}
}

const recipe = await readText(path.join(PACKAGE_ROOT, "RECIPE.md"));
if (recipe !== null) {
const ordered = [
Expand Down Expand Up @@ -209,22 +197,9 @@ async function checkTestInventory() {
}

async function checkDistribution() {
if (path.basename(PACKAGE_ROOT) !== "herdr-insights") fail("Distribution folder must be herdr-insights");
if (await lstatOrNull(path.join(PACKAGE_ROOT, "layouts"))) fail("Herdr mods must not ship layouts/");
try {
const preview = await fs.readFile(path.join(PACKAGE_ROOT, "preview.png"));
if (preview.length < 24 || preview.subarray(0, 8).toString("hex") !== "89504e470d0a1a0a"
|| preview.toString("ascii", 12, 16) !== "IHDR"
|| preview.readUInt32BE(16) === 0 || preview.readUInt32BE(20) === 0) {
fail("preview.png must be a PNG image with nonzero dimensions");
}
const mod = JSON.parse(await fs.readFile(path.join(PACKAGE_ROOT, "mods/insights/mod.json"), "utf8"));
if (mod.title !== "Insights" || mod.place !== "none") {
fail('mod.json must use title "Insights" and place "none"');
}
} catch (error) {
fail(`Invalid Herdr distribution: ${error.message}`);
}
const result = await inspectDistribution(PACKAGE_ROOT);
for (const message of result.errors) fail(message);
notes.push(...result.notes);
}

async function checkSourceIfPresent() {
Expand Down
45 changes: 45 additions & 0 deletions herdr-insights/scripts/distribution.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { promises as fs } from "node:fs";
import path from "node:path";

import { lstatOrNull } from "./lib.mjs";
import { validatePng } from "./png.mjs";

export async function inspectDistribution(packageRoot) {
const errors = [];
const notes = [];

if (path.basename(packageRoot) !== "herdr-insights") {
errors.push("Distribution folder must be herdr-insights");
}
for (const [relative, message] of [
["layouts", "Herdr mods must not ship layouts/"],
["keys.json", "Herdr mods must not ship keys.json"],
]) {
try {
if (await lstatOrNull(path.join(packageRoot, relative))) errors.push(message);
} catch (error) {
errors.push(`Cannot inspect ${relative}: ${error.message}`);
}
}

try {
const preview = await fs.readFile(path.join(packageRoot, "preview.png"));
const image = validatePng(preview);
notes.push(`preview.png is a complete ${image.width}x${image.height} PNG`);
} catch (error) {
errors.push(`preview.png is not a complete, valid PNG image: ${error.message}`);
}

try {
const mod = JSON.parse(
await fs.readFile(path.join(packageRoot, "mods/insights/mod.json"), "utf8"),
);
if (mod.title !== "Insights" || mod.place !== "none") {
errors.push('mod.json must use title "Insights" and place "none"');
}
} catch (error) {
errors.push(`Invalid Herdr distribution: ${error.message}`);
}

return { errors, notes };
}
4 changes: 3 additions & 1 deletion herdr-insights/scripts/install.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,6 @@ async function addParentIssues(targetDirectory, target, issues) {

async function install(options) {
assertSupportedNodeVersion();
const appHome = await resolveAppHome(options.appHome);
const sourceRoot = options.source
? path.resolve(expandHome(options.source))
: PACKAGE_ROOT;
Expand All @@ -137,6 +136,9 @@ async function install(options) {
source.files.push(createRuntimeFile());
source.files.sort((left, right) => left.relative.localeCompare(right.relative));

// Validate the source before creating the app home. A bad --source must not
// leave a new target directory behind.
const appHome = await resolveAppHome(options.appHome);
const targetDirectory = modDir(appHome);
const targetDataDirectory = dataDir(appHome);
const targetManifest = manifestPath(appHome);
Expand Down
14 changes: 12 additions & 2 deletions herdr-insights/scripts/lib.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -80,12 +80,22 @@ export function expandHome(value) {
return value;
}

export async function resolveAppHome(explicit) {
export async function resolveAppHome(explicit, options = {}) {
const configured =
explicit && explicit.length > 0
? explicit
: process.env.HERDR_APP_HOME || path.join(os.homedir(), ".config", "herdr-app");
const absolute = path.resolve(expandHome(configured));
const create = options.create !== false;

if (!create) {
const existing = await lstatOrNull(absolute);
if (!existing) return null;
if (!existing.isDirectory() || existing.isSymbolicLink()) {
throw new Error(`App home is not a directory: ${absolute}`);
}
return fs.realpath(absolute);
}

try {
await fs.mkdir(absolute, { recursive: true });
Expand All @@ -96,7 +106,7 @@ export async function resolveAppHome(explicit) {
}

const stat = await fs.lstat(absolute);
if (!stat.isDirectory()) {
if (!stat.isDirectory() || stat.isSymbolicLink()) {
throw new Error(`App home is not a directory: ${absolute}`);
}
return fs.realpath(absolute);
Expand Down
78 changes: 78 additions & 0 deletions herdr-insights/scripts/png.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const CRC_TABLE = new Uint32Array(256);

for (let index = 0; index < 256; index += 1) {
let value = index;
for (let bit = 0; bit < 8; bit += 1) {
value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
}
CRC_TABLE[index] = value >>> 0;
}

function crc32(buffer, start, end) {
let crc = 0xffffffff;
for (let index = start; index < end; index += 1) {
crc = (crc >>> 8) ^ CRC_TABLE[(crc ^ buffer[index]) & 0xff];
}
return (crc ^ 0xffffffff) >>> 0;
}

// Parse the PNG container without decoding pixels. Chunk bounds, CRCs, the
// IHDR dimensions, at least one IDAT chunk, and the terminal IEND chunk make
// truncation and corruption fail without imposing optional screen-size rules.
export function validatePng(input) {
const buffer = Buffer.isBuffer(input) ? input : Buffer.from(input);
if (buffer.length < PNG_SIGNATURE.length || !buffer.subarray(0, 8).equals(PNG_SIGNATURE)) {
throw new Error("missing the PNG signature");
}

let offset = PNG_SIGNATURE.length;
let width = 0;
let height = 0;
let chunkCount = 0;
let sawIdat = false;
let sawIend = false;

while (offset < buffer.length) {
if (sawIend) throw new Error("has data after the IEND chunk");
if (buffer.length - offset < 12) throw new Error("has a truncated chunk header");

const length = buffer.readUInt32BE(offset);
const typeStart = offset + 4;
const dataStart = offset + 8;
const crcOffset = dataStart + length;
if (length > 0x7fffffff || crcOffset + 4 > buffer.length) {
throw new Error("has a truncated chunk");
}

const type = buffer.toString("ascii", typeStart, dataStart);
if (!/^[A-Za-z]{4}$/.test(type)) throw new Error("has an invalid chunk type");
const expectedCrc = buffer.readUInt32BE(crcOffset);
const actualCrc = crc32(buffer, typeStart, crcOffset);
if (expectedCrc !== actualCrc) throw new Error(`has an invalid ${type} chunk CRC`);

if (chunkCount === 0) {
if (type !== "IHDR" || length !== 13) {
throw new Error("does not start with a valid IHDR chunk");
}
width = buffer.readUInt32BE(dataStart);
height = buffer.readUInt32BE(dataStart + 4);
if (width === 0 || height === 0) throw new Error("has zero image dimensions");
} else if (type === "IHDR") {
throw new Error("has more than one IHDR chunk");
}
if (type === "IDAT") sawIdat = true;
if (type === "IEND") {
if (length !== 0) throw new Error("has a non-empty IEND chunk");
sawIend = true;
}

chunkCount += 1;
offset = crcOffset + 4;
}

if (chunkCount === 0) throw new Error("has no PNG chunks");
if (!sawIdat) throw new Error("has no IDAT image data");
if (!sawIend) throw new Error("is missing the terminal IEND chunk");
return { width, height, chunks: chunkCount };
}
Loading
Loading