@jaybeeuu/agent-cortex 2.8.0 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +173 -0
  2. package/README.md +26 -3
  3. package/agents/README.md +2 -2
  4. package/agents/guppi/agent.md +162 -0
  5. package/agents/guppi/claude/frontmatter.json +6 -0
  6. package/agents/guppi/pi/frontmatter.json +6 -0
  7. package/agents/plan/agent.md +6 -0
  8. package/agents/ralph-plan/agent.md +6 -0
  9. package/bin/installers/claude.mjs +1 -20
  10. package/bin/installers/pi.mjs +8 -2
  11. package/extensions/interrupt-bookmark/README.md +129 -0
  12. package/extensions/interrupt-bookmark/index.test.ts +717 -0
  13. package/extensions/interrupt-bookmark/index.ts +354 -0
  14. package/extensions/interrupt-bookmark/package.json +20 -0
  15. package/extensions/skill-stats/README.md +84 -3
  16. package/extensions/skill-stats/index.ts +62 -3
  17. package/extensions/skill-stats/package.json +1 -1
  18. package/extensions/skill-stats/rollup.test.ts +643 -0
  19. package/extensions/skill-stats/rollup.ts +415 -0
  20. package/extensions/skill-stats/timeline.ts +101 -0
  21. package/package.json +1 -1
  22. package/scripts/lib/manifest.mjs +29 -0
  23. package/skills/review/review-defects/EXAMPLES.md +80 -4
  24. package/skills/review/review-defects/SKILL.md +45 -15
  25. package/skills/review/review-report/EXAMPLES.md +114 -0
  26. package/skills/review/review-report/SKILL.md +138 -0
  27. package/skills/review/review-report/scripts/report.mjs +1032 -0
  28. package/skills/workflow/hitl-queue/SKILL.md +134 -0
  29. package/skills/workflow/hitl-queue/scripts/queue.mjs +626 -0
  30. package/skills/workflow/plan/SKILL.md +13 -0
  31. package/skills/workflow/prioritise-work/CHALLENGE.md +139 -0
  32. package/skills/workflow/prioritise-work/SKILL.md +150 -0
  33. package/skills/workflow/prioritise-work/scripts/challenge.mjs +505 -0
  34. package/skills/workflow/prioritise-work/scripts/strategy.mjs +566 -0
  35. package/skills/workflow/prioritise-work/templates/strategy.md +61 -0
  36. package/skills/workflow/run-pipeline-stage/playbooks/review.md +5 -2
  37. package/skills/workflow/score-bead/FORMAT.md +73 -0
  38. package/skills/workflow/score-bead/SKILL.md +112 -0
  39. package/skills/workflow/score-bead/scripts/rice.mjs +664 -0
  40. package/skills/workflow/using-agent-skills/SKILL.md +6 -1
  41. package/token-map.json +8 -2
@@ -0,0 +1,354 @@
1
+ /**
2
+ * Interrupt bookmark extension.
3
+ *
4
+ * An interruption mid-task — a meeting note, PR feedback to pass on, a Slack
5
+ * todo — drags its turns into the context that the original task then has to
6
+ * carry. PI can rewind a session tree and summarise the abandoned branch, but
7
+ * nothing exposes that as a deliberate operation. This extension adds two tools:
8
+ *
9
+ * - `interrupt_checkpoint` captures the current leaf id and returns it, so the
10
+ * caller holds a resume point;
11
+ * - `interrupt_resume` rewinds to that id, keeps the abandoned branch under a
12
+ * summary, and labels the summary entry `interrupt`.
13
+ *
14
+ * The summary comes from pi's own tree-navigation summariser
15
+ * (`navigateTree(..., { summarize: true })`). This extension makes no model call
16
+ * of its own. Nothing is deleted: the abandoned branch stays in the session
17
+ * file, reachable from the session tree.
18
+ *
19
+ * ## Why the rewind runs in two steps
20
+ *
21
+ * PI exposes `waitForIdle()` and `navigateTree()` on `ExtensionCommandContext`
22
+ * only, because they can deadlock when called from a handler that runs inside a
23
+ * turn (`docs/extensions.md`, "ExtensionCommandContext"). A tool receives
24
+ * `ExtensionToolContext`, and the session throws on navigation while it is
25
+ * streaming — which it always is while a tool call is running. A tool therefore
26
+ * cannot navigate the tree, and waiting for idle inside a tool call could never
27
+ * finish.
28
+ *
29
+ * So the resume tool validates the request, records it, and ends the turn. An
30
+ * `agent_settled` handler then dispatches the command that performs the rewind,
31
+ * which is the pattern pi documents for a tool that must reach a command-only
32
+ * API (`docs/extensions.md`, "ctx.reload()"). One consequence is worth stating:
33
+ * the summary entry is reported to the human and written to the session as a
34
+ * custom entry, not returned in the tool result — the tool result belongs to the
35
+ * branch that the rewind summarises.
36
+ */
37
+
38
+ import type {
39
+ ExtensionAPI,
40
+ ExtensionCommandContext,
41
+ ExtensionToolContext,
42
+ } from "@earendil-works/pi-coding-agent";
43
+ import { Type } from "typebox";
44
+
45
+ /** The tool that captures a resume point. */
46
+ export const CHECKPOINT_TOOL = "interrupt_checkpoint";
47
+
48
+ /** The tool that asks for a rewind. */
49
+ export const RESUME_TOOL = "interrupt_resume";
50
+
51
+ /** The command that performs the rewind, dispatched once the turn settles. */
52
+ export const REWIND_COMMAND = "interrupt-rewind";
53
+
54
+ /** The label pi attaches to the branch-summary entry a rewind leaves behind. */
55
+ export const SUMMARY_LABEL = "interrupt";
56
+
57
+ /** Custom-entry type recording the outcome. Never part of the model's context. */
58
+ export const REWIND_ENTRY = "interrupt-rewind";
59
+
60
+ /**
61
+ * How many times a rewind is attempted: the first try, then exactly one retry.
62
+ * A second failure is reported, never retried again.
63
+ */
64
+ export const REWIND_ATTEMPTS = 2;
65
+
66
+ /** A rewind waiting for the turn to settle. */
67
+ export interface RewindRequest {
68
+ /** The entry id the caller wants to return to. */
69
+ checkpointId: string;
70
+ /** What the interruption was about, as summariser instructions. */
71
+ summary: string;
72
+ }
73
+
74
+ /** What a rewind did, or why it did nothing. */
75
+ export interface RewindReport {
76
+ ok: boolean;
77
+ /** How many navigation attempts were made. */
78
+ attempts: number;
79
+ /** The `branch_summary` entry the rewind left behind, when there is one. */
80
+ summaryEntryId?: string;
81
+ /** True when pi cancelled the navigation (an extension or the user declined). */
82
+ cancelled?: boolean;
83
+ /** Why the last attempt failed. */
84
+ reason?: string;
85
+ }
86
+
87
+ /** The parts of the command context a rewind uses. */
88
+ type RewindContext = Pick<
89
+ ExtensionCommandContext,
90
+ "waitForIdle" | "navigateTree" | "sessionManager"
91
+ >;
92
+
93
+ /** The parts of the tool context the two tools read. */
94
+ type BookmarkContext = Pick<ExtensionToolContext, "sessionManager">;
95
+
96
+ /** Instructions for pi's summariser: what the interruption was, and why it is summarised. */
97
+ export function summariserInstructions(summary: string): string {
98
+ return [
99
+ "The task was interrupted. Record what the interruption was about, and what the abandoned turns",
100
+ "were doing, so the task can resume without them.",
101
+ `The interruption: ${summary}`,
102
+ ].join(" ");
103
+ }
104
+
105
+ function errorMessage(error: unknown): string {
106
+ return error instanceof Error ? error.message : String(error);
107
+ }
108
+
109
+ /**
110
+ * The branch-summary entry a navigation from `previousLeaf` appended, if any.
111
+ *
112
+ * `navigateTree()` returns this entry, but `ExtensionCommandContext` declares
113
+ * only `cancelled` in its result type, and after the call the leaf is the label
114
+ * entry pi appends on top of the summary. The entry is therefore found by the
115
+ * `fromId` pi records on it — no cast, and no assumption about the leaf.
116
+ */
117
+ function summaryAfter(
118
+ sessionManager: RewindContext["sessionManager"],
119
+ previousLeaf: string | null,
120
+ ): string | undefined {
121
+ const entries = sessionManager.getEntries();
122
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
123
+ const entry = entries[index];
124
+ if (entry.type === "branch_summary" && entry.fromId === previousLeaf) return entry.id;
125
+ }
126
+ return undefined;
127
+ }
128
+
129
+ /**
130
+ * Rewind to `checkpointId`, summarising the abandoned branch.
131
+ *
132
+ * Navigation rejects while a response, a compaction or another navigation is
133
+ * active, so each attempt waits for idle first. Waits and attempts are
134
+ * deliberately paired: an attempt that skips the wait fails for the reason the
135
+ * wait exists.
136
+ */
137
+ export async function rewind(ctx: RewindContext, request: RewindRequest): Promise<RewindReport> {
138
+ let reason = "";
139
+
140
+ for (let attempt = 1; attempt <= REWIND_ATTEMPTS; attempt += 1) {
141
+ await ctx.waitForIdle();
142
+
143
+ // Captured before the navigation, so the summary it writes can be found by
144
+ // the branch it abandoned.
145
+ const previousLeaf = ctx.sessionManager.getLeafId();
146
+
147
+ try {
148
+ const result = await ctx.navigateTree(request.checkpointId, {
149
+ summarize: true,
150
+ customInstructions: summariserInstructions(request.summary),
151
+ label: SUMMARY_LABEL,
152
+ });
153
+
154
+ return {
155
+ ok: true,
156
+ attempts: attempt,
157
+ cancelled: result.cancelled,
158
+ summaryEntryId: summaryAfter(ctx.sessionManager, previousLeaf),
159
+ };
160
+ } catch (error) {
161
+ reason = errorMessage(error);
162
+ }
163
+ }
164
+
165
+ return { ok: false, attempts: REWIND_ATTEMPTS, reason };
166
+ }
167
+
168
+ /**
169
+ * Why this request cannot be rewound, or undefined when it can.
170
+ *
171
+ * An unknown id is refused here, before anything is queued or written, so the
172
+ * session is untouched and the message names the id that failed.
173
+ */
174
+ export function refusal(ctx: BookmarkContext, request: RewindRequest): string | undefined {
175
+ if (ctx.sessionManager.getEntry(request.checkpointId) === undefined) {
176
+ return `Unknown checkpoint id: ${request.checkpointId}. Call ${CHECKPOINT_TOOL} to record a resume point.`;
177
+ }
178
+
179
+ if (ctx.sessionManager.getLeafId() === request.checkpointId) {
180
+ return `Checkpoint ${request.checkpointId} is the current position, so there are no interrupt turns to summarise. Nothing was rewound.`;
181
+ }
182
+
183
+ return undefined;
184
+ }
185
+
186
+ export default function interruptBookmarkExtension(pi: ExtensionAPI): void {
187
+ /**
188
+ * The rewind the next settle must dispatch. At most one: a second would
189
+ * navigate from a position the first has already replaced.
190
+ */
191
+ let pending: RewindRequest | undefined;
192
+
193
+ /**
194
+ * The summary waiting for its dispatch, keyed by checkpoint id. The command
195
+ * receives the id in its argument text and the summary from here, so a summary
196
+ * with spaces or newlines never has to survive a command line. One entry at a
197
+ * time: a new request replaces the previous one.
198
+ */
199
+ const handoff = new Map<string, string>();
200
+
201
+ pi.registerTool({
202
+ name: CHECKPOINT_TOOL,
203
+ label: "Interrupt Checkpoint",
204
+ description:
205
+ "Record the current session position and return its id, so an interruption can be rewound later.",
206
+ promptSnippet: `${CHECKPOINT_TOOL}: record a resume point before handling an interruption`,
207
+ promptGuidelines: [
208
+ `Use ${CHECKPOINT_TOOL} before an interruption is handled, and ${RESUME_TOOL} to return to it afterwards.`,
209
+ ],
210
+ parameters: Type.Object({}),
211
+ async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
212
+ const leafId = ctx.sessionManager.getLeafId();
213
+
214
+ if (leafId === null) {
215
+ return {
216
+ content: [
217
+ {
218
+ type: "text" as const,
219
+ text: "The session has no entries yet, so there is no position to record.",
220
+ },
221
+ ],
222
+ details: { checkpointId: null },
223
+ };
224
+ }
225
+
226
+ return {
227
+ content: [
228
+ {
229
+ type: "text" as const,
230
+ text: `Checkpoint ${leafId}. Pass this id to ${RESUME_TOOL} to rewind here.`,
231
+ },
232
+ ],
233
+ details: { checkpointId: leafId },
234
+ };
235
+ },
236
+ });
237
+
238
+ pi.registerTool({
239
+ name: RESUME_TOOL,
240
+ label: "Interrupt Resume",
241
+ description:
242
+ "Rewind to a checkpoint, keep the abandoned branch under a summary, then continue from the checkpoint. Runs after the current turn ends.",
243
+ promptSnippet: `${RESUME_TOOL}: rewind to a checkpoint and summarise the interruption away`,
244
+ parameters: Type.Object({
245
+ checkpointId: Type.String({
246
+ description: `The id ${CHECKPOINT_TOOL} returned.`,
247
+ }),
248
+ summary: Type.String({
249
+ description: "What the interruption was about, so the summariser records it.",
250
+ }),
251
+ }),
252
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
253
+ if (pending !== undefined) {
254
+ throw new Error(
255
+ `A rewind to ${pending.checkpointId} is already scheduled. Nothing was changed.`,
256
+ );
257
+ }
258
+
259
+ const problem = refusal(ctx, params);
260
+ if (problem !== undefined) {
261
+ // A thrown message reaches the model as the tool's error result, and
262
+ // nothing has been written or queued at this point.
263
+ throw new Error(problem);
264
+ }
265
+
266
+ pending = { checkpointId: params.checkpointId, summary: params.summary };
267
+
268
+ return {
269
+ content: [
270
+ {
271
+ type: "text" as const,
272
+ text:
273
+ `Rewind to ${params.checkpointId} scheduled. The abandoned branch is kept under a ` +
274
+ `summary labelled "${SUMMARY_LABEL}" once this turn ends; this tool call is part of it.`,
275
+ },
276
+ ],
277
+ details: { checkpointId: params.checkpointId, scheduled: true },
278
+ // The rewind needs the turn over, so end it rather than letting the model
279
+ // keep working in a context that is about to be rewound.
280
+ terminate: true,
281
+ };
282
+ },
283
+ });
284
+
285
+ // Command handlers are the only place pi allows tree navigation. The dispatch
286
+ // uses `expandPromptTemplates`, which runs the command instead of prompting the
287
+ // model, so nothing is sent to a provider here.
288
+ pi.registerCommand(REWIND_COMMAND, {
289
+ description: "Rewind to a scheduled interrupt checkpoint and summarise the abandoned branch",
290
+ handler: async (args, ctx) => {
291
+ const checkpointId = args.trim();
292
+
293
+ if (checkpointId === "") {
294
+ ctx.ui.notify("No checkpoint was named, so nothing was rewound.", "warning");
295
+ return;
296
+ }
297
+
298
+ const summary = handoff.get(checkpointId);
299
+ if (summary === undefined) {
300
+ ctx.ui.notify(
301
+ `No interrupt rewind is pending for ${checkpointId}, so nothing was rewound.`,
302
+ "warning",
303
+ );
304
+ return;
305
+ }
306
+ handoff.delete(checkpointId);
307
+
308
+ const report = await rewind(ctx, { checkpointId, summary });
309
+ pi.appendEntry(REWIND_ENTRY, {
310
+ checkpointId,
311
+ attempts: report.attempts,
312
+ ok: report.ok,
313
+ cancelled: report.cancelled ?? false,
314
+ summaryEntryId: report.summaryEntryId,
315
+ reason: report.reason,
316
+ });
317
+
318
+ if (!report.ok) {
319
+ ctx.ui.notify(
320
+ `Interrupt rewind to ${checkpointId} failed after ${report.attempts} attempts: ${report.reason}. The conversation is unchanged; continue in the current context and say the rewind failed.`,
321
+ "error",
322
+ );
323
+ return;
324
+ }
325
+
326
+ if (report.cancelled === true) {
327
+ ctx.ui.notify(
328
+ `Interrupt rewind to ${checkpointId} was cancelled. The conversation is unchanged.`,
329
+ "warning",
330
+ );
331
+ return;
332
+ }
333
+
334
+ const summaryEntry =
335
+ report.summaryEntryId === undefined ? "" : ` (summary entry ${report.summaryEntryId})`;
336
+ ctx.ui.notify(
337
+ `Rewound to ${checkpointId}${summaryEntry}. The abandoned branch is kept, labelled "${SUMMARY_LABEL}".`,
338
+ "info",
339
+ );
340
+ },
341
+ });
342
+
343
+ pi.on("agent_settled", () => {
344
+ const request = pending;
345
+ if (request === undefined) return;
346
+
347
+ // Taken here, so a second settle cannot dispatch the same rewind twice. A
348
+ // dispatch that never runs leaves no pending request behind.
349
+ pending = undefined;
350
+ handoff.clear();
351
+ handoff.set(request.checkpointId, request.summary);
352
+ pi.sendUserMessage(`/${REWIND_COMMAND} ${request.checkpointId}`, { expandPromptTemplates: true });
353
+ });
354
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "@agent-cortex/interrupt-bookmark",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "test": "node --test --import tsx/esm index.test.ts",
8
+ "typecheck": "tsc --noEmit"
9
+ },
10
+ "devDependencies": {
11
+ "@earendil-works/pi-coding-agent": "^1.5.2",
12
+ "@types/node": "^22.0.0",
13
+ "tsx": "^4.0.0",
14
+ "typebox": "^1.1.38",
15
+ "typescript": "^5.0.0"
16
+ },
17
+ "peerDependencies": {
18
+ "typebox": "*"
19
+ }
20
+ }
@@ -16,7 +16,63 @@ A skill that is *loaded* frequently but *never read* may be burning context budg
16
16
 
17
17
  ## Data
18
18
 
19
- Usage data is stored in `~/.pi/agent-cortex/skill-usage.json` — a single file across all projects, with per-project breakdowns so you can slice by project.
19
+ Usage data is stored in `~/.pi/agent-cortex/` — global, across all projects, with per-project
20
+ breakdowns so you can slice by project. There are two files, with different fidelity:
21
+
22
+ | File | Fidelity | Kept for |
23
+ |---|---|---|
24
+ | `skill-usage.json` | Per-skill counters, per-project breakdown, and a 200-event ring of recent events | Until a skill has been unused for ~3 months (existing age-out) |
25
+ | `skill-usage-daily.json` | One record per skill per day: loaded / invoked / read counts and the distinct projects seen that day | 365 days by default, pruned on every write |
26
+
27
+ The counters answer "what is my all-time shape, and what happened in the last few minutes". The
28
+ daily rollups answer "how has my usage changed over months" without scanning session transcripts.
29
+ The rollups are a second file beside the counters, not a replacement: the counters and their
30
+ 200-event ring are unchanged by this feature.
31
+
32
+ ### Retention and size policy
33
+
34
+ - **Retention window: 365 days by default.** Override it per machine with
35
+ `AGENT_CORTEX_SKILL_STATS_RETENTION_DAYS` (a positive whole number of days). Invalid values fall
36
+ back to the default.
37
+ - **Pruning happens on write, not on read.** Every save drops day records older than the window, so a
38
+ long-lived install can never accumulate more than the window's worth of days. The `timeline` view
39
+ only reads; it prunes nothing.
40
+ - **Each record is fixed-size.** Counts are numbers, the distinct-project list per skill-day is capped
41
+ at 50 entries, and a stored project path is truncated to 120 characters, so one skill-day's record
42
+ cannot grow with the number of events that day or with working-directory depth. Two long paths that
43
+ share their first 120 characters count as one project.
44
+ - **The file has a hard ceiling of 2,920,000 bytes, enforced on every write.** That is 365 days x 40
45
+ skills x 200 bytes per skill-day (the counts plus one realistic project path); the 40 sizes the
46
+ budget and is not a limit on how many skills an install may have. A realistic file is far smaller:
47
+ 994,668 bytes for 40 skills used every day of the window, two events each and a short project path
48
+ (`/proj/a`), and 1,272,068 bytes for the same usage with a 26-character project path. A retention
49
+ window longer than 365 days does not raise the ceiling, so the detail it implies is shed by the
50
+ rule below.
51
+ When a write would exceed the ceiling, `saveRollups` drops the per-skill-day project lists first —
52
+ the loaded / invoked / read counts and every day survive, and every number the timeline renders is
53
+ unchanged. Only if the counts alone exceed the ceiling does it drop the oldest days, continuing the
54
+ direction pruning already takes. `saveRollups` returns what this write gave up
55
+ (`shedProjectLists`, `droppedDays`) and the size it wrote, and it keeps running totals in the file
56
+ under `shed`, so `/skill-stats timeline` reports the loss after a reload rather than leaving the
57
+ user to find out only by looking. The rendered line names the day of the most recent shedding
58
+ write, how many project lists and how many days went in total, and the ceiling that forced it.
59
+ - **A ceiling shed moves the reported start; ordinary pruning does not.** When shedding drops whole
60
+ days, those days were inside the retention window, so `since` moves to the oldest day the file
61
+ still holds and the header can never claim a day that was deleted. Days that age out of the window
62
+ are different: they were real history and the rendered retention line explains them, so `since`
63
+ keeps the first-ever day.
64
+ - **The file is written minified** — it is read by the extension, not by a human. The suite fills
65
+ every cap at once (40 skills x 365 days x 50 project paths), asserts the 22,033,268 raw bytes are
66
+ written as at most 2,920,000, and asserts a second year of data leaves the file byte-for-byte
67
+ unchanged.
68
+
69
+ ### History before the rollups
70
+
71
+ Rollups start with the first skill event recorded after this version was installed. Anything earlier
72
+ is reported as **unavailable**, not as zero usage, and no data point is invented for it. A ceiling
73
+ shed that deletes days reports on the same screen, so a moved start is explained rather than silent.
74
+ Backfilling from old session transcripts is a possible follow-up, and deliberately not part of this
75
+ feature.
20
76
 
21
77
  ## Installation
22
78
 
@@ -43,7 +99,8 @@ ln -sf $(pwd)/extensions/skill-stats ~/.pi/agent/extensions/skill-stats
43
99
  |---|---|
44
100
  | `/skill-stats` | Show usage dashboard with per-project slicing |
45
101
  | `/skill-stats --project ./relative/path` | Filter to a specific project |
46
- | `/skill-usage-reset` | Wipe all recorded data |
102
+ | `/skill-stats timeline` | Per-day, per-week and per-month counts and distinct skills, from the daily rollups |
103
+ | `/skill-usage-reset` | Wipe all recorded data, counters and rollups |
47
104
 
48
105
  ## Raw data
49
106
 
@@ -59,7 +116,7 @@ The JSON file at `~/.pi/agent-cortex/skill-usage.json` contains the full record:
59
116
  "readCount": 18,
60
117
  "lastUsed": "2026-06-07T...",
61
118
  "byProject": {
62
- "/home/jaybeeuu/src/agent-cortex": {
119
+ "/home/user/projects/app": {
63
120
  "loadedCount": 40,
64
121
  "invokedCount": 3,
65
122
  "readCount": 16,
@@ -72,3 +129,27 @@ The JSON file at `~/.pi/agent-cortex/skill-usage.json` contains the full record:
72
129
  "lastUpdated": "2026-06-07T..."
73
130
  }
74
131
  ```
132
+
133
+ The rollups in `~/.pi/agent-cortex/skill-usage-daily.json` are minified and keyed by day:
134
+
135
+ ```json
136
+ {
137
+ "version": 1,
138
+ "since": "2026-06-01",
139
+ "days": {
140
+ "2026-06-07": {
141
+ "bd-tool": { "loaded": 2, "invoked": 1, "read": 3, "projects": ["/home/user/projects/app"] }
142
+ }
143
+ },
144
+ "shed": { "on": "2026-06-08", "projectLists": 14600, "days": 0 }
145
+ }
146
+ ```
147
+
148
+ `since` is the first day a rollup is held for. It survives pruning, so the timeline can say where
149
+ the recorded history starts; it moves forward only when a ceiling shed deletes days, which nothing
150
+ else explains.
151
+
152
+ `shed` appears only after a write had to give up detail for the size ceiling, and holds the totals
153
+ dropped by shedding (`projectLists`, `days`) plus the day of the most recent such write. A file
154
+ written before this field existed has no `shed` key, and such a file loads and renders unchanged:
155
+ an absent record means nothing was shed.
@@ -4,13 +4,56 @@ import path from "node:path";
4
4
  // Re-export public API for tests and consumers
5
5
  export { type ProjectCounts, type SkillStats, type UsageStore, type RawEvent, loadStore, saveStore, ageOutStore } from "./store.js";
6
6
  export { recordUsage } from "./record.js";
7
+ export {
8
+ type DaySkillCounts,
9
+ type Granularity,
10
+ type RollupStore,
11
+ type SaveRollupsResult,
12
+ type ShedRecord,
13
+ type TimelineBucket,
14
+ DEFAULT_RETENTION_DAYS,
15
+ MAX_PROJECT_PATH_CHARS,
16
+ MAX_PROJECTS_PER_DAY,
17
+ MAX_ROLLUP_FILE_BYTES,
18
+ RETENTION_ENV,
19
+ bucketKey,
20
+ dayKey,
21
+ loadRollups,
22
+ oldestDay,
23
+ pruneRollups,
24
+ recordDailyUsage,
25
+ retentionCutoff,
26
+ retentionDays,
27
+ rollupFilePath,
28
+ saveRollups,
29
+ shiftDay,
30
+ timeline,
31
+ } from "./rollup.js";
32
+ export { formatTimeline, isTimelineRequest } from "./timeline.js";
7
33
  export { type SkillRecord, globalSkillDirs, scanSkills, extractSkillName, discoverProjectSkills, resolveSkillFromPath } from "./discover.js";
8
34
  export { formatTable } from "./format.js";
9
35
 
10
- import { loadStore, saveStore } from "./store.js";
36
+ import { loadStore, saveStore, type RawEvent } from "./store.js";
11
37
  import { recordUsage } from "./record.js";
12
38
  import { resolveSkillFromPath, globalSkillDirs, scanSkills, discoverProjectSkills } from "./discover.js";
13
39
  import { formatTable } from "./format.js";
40
+ import { loadRollups, recordDailyUsage, saveRollups } from "./rollup.js";
41
+ import { formatTimeline, isTimelineRequest } from "./timeline.js";
42
+
43
+ /**
44
+ * Record one usage event into the long-range rollups. The counters and the
45
+ * recent-event ring in the store are written by `recordUsage`/`saveStore` as
46
+ * before; this adds the per-day record beside them.
47
+ */
48
+ function recordRollup(
49
+ skillName: string,
50
+ source: RawEvent["source"],
51
+ project: string,
52
+ ): void {
53
+ const rollups = loadRollups();
54
+ recordDailyUsage(rollups, skillName, source, project);
55
+ saveRollups(rollups);
56
+ }
14
57
 
15
58
  // ── Extension entry point ─────────────────────────────────────────────────────
16
59
 
@@ -32,12 +75,16 @@ export default function (pi: ExtensionAPI) {
32
75
  if (!skills?.length) return;
33
76
 
34
77
  const store = loadStore();
78
+ const rollups = loadRollups();
35
79
  const project = ctx.cwd;
36
80
  for (const skill of skills) {
37
81
  const name = typeof skill === "string" ? skill : skill.name;
38
- if (name) recordUsage(store, name, "loaded", project);
82
+ if (!name) continue;
83
+ recordUsage(store, name, "loaded", project);
84
+ recordDailyUsage(rollups, name, "loaded", project);
39
85
  }
40
86
  saveStore(store);
87
+ saveRollups(rollups);
41
88
  });
42
89
 
43
90
  // ── 2. Track /skill:name invocations ──────────────────────────────────
@@ -48,6 +95,7 @@ export default function (pi: ExtensionAPI) {
48
95
  const store = loadStore();
49
96
  recordUsage(store, m[1], "invoked", ctx.cwd);
50
97
  saveStore(store);
98
+ recordRollup(m[1], "invoked", ctx.cwd);
51
99
  });
52
100
 
53
101
  // ── 3. Track SKILL.md reads by the LLM ────────────────────────────────
@@ -64,6 +112,7 @@ export default function (pi: ExtensionAPI) {
64
112
  const store = loadStore();
65
113
  recordUsage(store, skillName, "read", ctx.cwd);
66
114
  saveStore(store);
115
+ recordRollup(skillName, "read", ctx.cwd);
67
116
  });
68
117
 
69
118
  // ── 4. Track turns ────────────────────────────────────────────────────
@@ -88,8 +137,17 @@ export default function (pi: ExtensionAPI) {
88
137
  // ── 5. /skill-stats command ───────────────────────────────────────────
89
138
  pi.registerCommand("skill-stats", {
90
139
  description:
91
- "Show skill usage stats. Filter by project: /skill-stats --project <path>",
140
+ "Show skill usage stats. Filter by project: /skill-stats --project <path>. Long-range history: /skill-stats timeline",
92
141
  handler: async (args, ctx) => {
142
+ // The timeline view reads the daily rollups and nothing else — not the
143
+ // counters, and never a session transcript.
144
+ if (isTimelineRequest(args)) {
145
+ const output = formatTimeline(loadRollups());
146
+ ctx.ui.notify("Skill usage timeline from daily rollups.", "info");
147
+ console.log(output);
148
+ return;
149
+ }
150
+
93
151
  const store = loadStore();
94
152
 
95
153
  let projectFilter: string | null = null;
@@ -216,6 +274,7 @@ export default function (pi: ExtensionAPI) {
216
274
  lastUpdated: new Date().toISOString(),
217
275
  recentEvents: [],
218
276
  });
277
+ saveRollups({ version: 1, since: null, days: {} });
219
278
  ctx.ui.notify("Skill usage statistics reset.", "info");
220
279
  },
221
280
  });
@@ -4,7 +4,7 @@
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "scripts": {
7
- "test": "node --test --import tsx/esm index.test.ts",
7
+ "test": "node --test --import tsx/esm index.test.ts rollup.test.ts",
8
8
  "typecheck": "tsc --noEmit"
9
9
  },
10
10
  "devDependencies": {