e0ipso / e0ipso/kenkeep

Add a usage-visualization section to `kenkeep status` (read the usage ledger we already write)

Open
#112 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
JavaScript
Stars
26
Forks
2
PR merge metrics
No merged PRs in 30d

Description

## Summary

`kenkeep status` should grow a **Usage** section that visualizes the knowledge-base read data capture already records in `.ai/kenkeep/.state/usage.jsonl`. Today that ledger is write-only — nothing reads it back — so a curator has no way to see which nodes are earning their context tokens. This is the R2 recommendation from the agentic-engineering review, scoped down per an interview to a `status` section (not a new subcommand).

**Requirements captured in interview:**
- Views: most-read leaderboard (knowledge nodes), a separate traversal report (branch-index navigation), per-branch rollup, headline totals, never-read count.
- Visual style: **ASCII bar charts** (plain text, no dependencies), for the ranked/rollup views.
- Command surface: a **section inside `kenkeep status`**, not a new `kenkeep usage` command.
- Read-only and advisory; all-time totals (no time series).

## Resolved design decisions

| Question | Decision |
|---|---|
| `--verbose` flag on `status`? | **No — keep `status` flagless.** Show capped summaries only; there is no expand affordance. |
| Index (`index.md`) reads in the leaderboard? | **No — leaves-only leaderboard.** Index reads go in a **separate "Traversal" section** (navigation hotspots), not mixed with knowledge reads. |
| Default leaderboard depth | **Top 15** knowledge nodes. |
| Retired leaf ids (id in ledger, gone from tree) | **Resolve via `nodes/.redirects.json` now** to the successor's branch; only ids with no redirect entry fall back to a `(retired)` bucket. |

Consequences of "flagless": the most-read leaderboard is capped at 15 with no way to see the rest; the never-read view is a **count line only** (no full list, since there's no `--verbose` to gate a potentially long list). Both are deliberate simplicity trade-offs from the interview.

## Guardrail — this must stay on the right side of the usage invariant

AGENTS.md: *"usage is write-only — capture appends to it, but no automated step (curation, rebalance, or node pruning) ever reads it back, so read counts never reshape the knowledge base."*

This feature adds the **first read path** to `usage.jsonl`. That is allowed because it feeds a **human** looking at a report, not an automated pipeline — exactly the precedent set by `freshness` (reads git history, prints advisory, exits 0, changes nothing). To stay clearly compliant:
- The usage read must be **display-only**. It must not be consumed by curate, rebalance, index generation, pruning, or any decision function.
- The output must **state that it is a partial, per-user view** — `usage.jsonl` is gitignored local state, so it reflects only reads on this machine, not the team's.
- Keep the aggregation in a display helper; do not wire it into `src/lib/nodes.ts` generation, `src/lib/rebalance.ts`, or curation.

## Current state (findings from the code)

**The ledger** (`src/lib/paths.ts` → `paths.usageFile` = `.ai/kenkeep/.state/usage.jsonl`). One JSON line per read occurrence, schema in `src/lib/schemas.ts`:

```ts
export const UsageRecordSchema = z.object({
document: z.string(), // leaf: node id (e.g. "practice-foo"); index: kk-root-relative path (e.g. "nodes/topic/index.md")
type: z.enum(['leaf', 'index']),
session_id: z.string(),
used_at: z.string(), // ISO timestamp
});
```

Key detail: a **leaf** document is stored by its **node id**, an **index** document by its **relative path**. This matters for the per-branch rollup and for splitting leaves vs. traversal (filter on `type`).

**Writers only.** `src/lib/usage.ts` exposes `classifyRead`, `reconcileUsage`, `recordUsage` — all write-side. `capture.ts` is the only caller. There is **no** `readUsage`/aggregation function yet; it needs to be added.

**The host command** (`src/commands/status.ts`, `runStatus`): already resolves `repoPaths(root)`, reads nodes via `readAllNodes(paths.nodesDir)` (in `countNodes`, guarded so a malformed tree degrades to zeros), computes freshness, and prints fixed sections with `log.plain`. Adding a section is a natural extension. Note it early-returns with code 1 when `paths.installedVersionFile` is missing.

**Rendering precedent** (`src/commands/freshness.ts` → `renderFreshness`): deterministic, no timestamps, stable ordering, a "By branch:" loop. The Usage section should mirror this style (minus the `--verbose` branch, since `status` stays flagless).

**Redirect resolution** (`src/lib/redirects.ts`, already used by `src/lib/prompt-retrieval.ts`): `readRedirectsLedger(nodesDir)` + `resolveRedirect(ledger, liveIdSet, id)` map a retired id transitively to its live successor(s). Reuse these for retired-id branch resolution — do not reimplement.

**`NodeFile` shape** (`src/lib/nodes.ts`): carries `path`, `filename`, `relPath` (POSIX path relative to `nodes/`), `relDir`, `frontmatter` (with `kk_id`, `type`), `body`. So a node's **branch = first segment of `relPath`** (or `relDir`); a root-level leaf has empty `relDir`.

## Proposed design

### New read/aggregation helper (in `src/lib/usage.ts`)

Add display-only functions, clearly separated from the write path with a comment marking them as advisory/read-only:

```ts
export interface UsageSummary {
totalReads: number; // total line count (all read occurrences, leaf + index)
distinctDocuments: number; // distinct documents seen
leaderboard: { id: string; count: number }[]; // LEAVES only, desc by count then id, capped at 15
traversal: { document: string; count: number }[]; // INDEX reads only, desc by count then path (navigation hotspots)
perBranch: { branch: string; count: number }[]; // leaf reads by branch, desc by count then branch
neverReadCount: number; // leaf ids present in the tree but absent from usage
totalLeafCount: number; // for the "N of M" never-read line
coveragePct: number | null; // distinct leaves read / total leaves (null if tree unavailable)
available: boolean; // false when the ledger is missing/empty
}

export function readUsageRecords(usageFile: string): UsageRecord[]; // tolerant: skip malformed lines, validate via UsageRecordSchema
export function summarizeUsage(records: UsageRecord[], nodes: NodeFile[] | null, nodesDir: string): UsageSummary;
```

- **Malformed-line tolerance** mirrors the writer: skip unparseable lines rather than throwing (a corrupt ledger must never break `status`).
- `summarizeUsage` takes the already-read `NodeFile[]` so `status` reads the tree once. Pass `null` when the tree is unavailable/malformed — then per-branch/never-read/coverage degrade to empty/`null`/`0`, but leaderboard, traversal, and totals still render from the ledger alone. `nodesDir` is passed so the helper can `readRedirectsLedger` for retired-id resolution.

### How each view is computed

1. **Headline totals** — `totalReads` = line count; `distinctDocuments` = unique `document`; `coveragePct` = distinct **leaf** documents read ÷ total leaf nodes in tree.
2. **Most-read leaderboard (leaves only)** — filter `type === 'leaf'`, group by id, sum, sort desc by count then id, cap at **15**. Render each as an ASCII bar scaled to the section max.
3. **Traversal (index reads only)** — filter `type === 'index'`, group by `document` (the path), sum, sort desc, render as its own bar section titled as navigation/traversal. Keeps navigation noise out of the knowledge leaderboard while still surfacing which branch indexes get walked most.
4. **Per-branch rollup (leaf reads)** — build an id→branch map from `readAllNodes` (`branch = relPath.split('/')[0]`, or `(root)` for a root-level leaf). For a leaf id absent from the map, `resolveRedirect` it to a live successor and use that node's branch; if the redirect ledger has no entry, bucket under `(retired)`. Sum per branch, sort desc, render as bars. (Index reads are represented in the Traversal section, so the rollup stays leaf-only to mean "knowledge traffic by area" — flag if you'd rather the rollup also fold in index reads.)
5. **Never-read** — `neverReadCount = |{ all leaf ids in tree } − { leaf documents in usage }|`; render one line ("N of M leaf nodes have never been read locally"). No full list (flagless).

### ASCII bar rendering

A small deterministic helper (no deps), bars from `█` scaled to a fixed max width (say 24 cols) relative to the section's max value, count printed after the bar. Example:

```
Usage (local reads only — .state/usage.jsonl is gitignored per-user state)
Total reads: 128 across 34 documents · coverage: 34/83 leaves (41%)

Most read (nodes):
practice-recursion-guard-kenkeep-builder-internal ████████████████████ 21
map-capture-hook ██████████ 11
practice-conventional-commits-and-release ██████ 6
… (top 15)

Traversal (branch-index reads):
nodes/hooks/index.md ████████████ 14
nodes/harnesses/index.md ████████ 9

By branch (node reads):
hooks ████████████████ 44
harnesses ██████████ 27
curation ████ 10

Never read: 49 of 83 leaf nodes have never been read locally.
```

(Exact glyphs/width open to taste; must stay deterministic and ASCII-safe.)

### Integration into `status`

- In `runStatus`, after the existing sections, read `paths.usageFile`, reuse the `NodeFile[]` already loaded for counts (refactor `countNodes` to read the tree once and share, or read once and pass to both), call `summarizeUsage`, and print the Usage section.
- When the ledger is missing/empty (`available: false`), print a single line: `Usage: no reads recorded yet.` — never error.
- **No new flag** — `status` stays flagless.

## Testing

- New `tests/commands/status.test.ts` (none exists today) or extend coverage: seed a temp `usage.jsonl` + a small node tree, assert the rendered section — totals, leaderboard order (leaves only; count-then-id tiebreak; 15-cap), traversal section (index reads only), per-branch bucketing, redirect resolution of a retired id (seed `nodes/.redirects.json`), `(retired)` fallback for an id with no redirect entry, and the never-read count line.
- Unit-test `readUsageRecords` malformed-line tolerance and `summarizeUsage` with `nodes: null` (leaderboard/traversal/totals still render; per-branch/never-read/coverage degrade).
- Determinism: identical ledger + tree ⇒ byte-identical output (freshness-style contract).

## Non-goals

- No time-series/trend view — all-time totals only.
- No new top-level `kenkeep usage` command — this lives in `status`.
- No `--verbose`/flags on `status`.
- No consumption of usage data by any automated step (curation, rebalance, index gen, pruning). Display-only.
- No change to the capture/write path or the record schema.

## One remaining design call (safe default chosen)

The per-branch rollup counts **leaf reads only**, on the reasoning that index reads are already surfaced in the Traversal section. If you'd prefer the rollup to represent *all* traffic (leaf + index) by branch, say so and it's a one-line change. Everything else is locked by the decisions above.

## Related
- R2 in the agentic-engineering review; usage invariant clarified in AGENTS.md on this branch (PR #111).
- Advisory-report precedent: `src/commands/freshness.ts`. Redirect resolution: `src/lib/redirects.ts`.

Contributor guide

Open the contributing guide

Research direction

Start with src/commands/status.ts and src/lib/usage.ts, then read UsageRecordSchema in src/lib/schemas.ts, freshness rendering in src/commands/freshness.ts, and redirect helpers in src/lib/redirects.ts. Trace how status loads nodes and run the existing test suite before adding focused status and usage tests. Done means deterministic, display-only totals and ASCII summaries with leaf, traversal, branch, redirect, malformed-ledger, and missing-ledger coverage, without changing capture or automation.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
cli, tooling
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
52/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.