@jaybeeuu/agent-cortex 2.8.0 → 2.10.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.
- package/CHANGELOG.md +304 -0
- package/README.md +30 -3
- package/agents/README.md +7 -2
- package/agents/guppi/agent.md +231 -0
- package/agents/guppi/claude/frontmatter.json +6 -0
- package/agents/guppi/pi/frontmatter.json +6 -0
- package/agents/plan/agent.md +6 -0
- package/agents/ralph-plan/agent.md +6 -0
- package/bin/installers/claude.mjs +8 -23
- package/bin/installers/pi.mjs +8 -2
- package/extensions/interrupt-bookmark/README.md +129 -0
- package/extensions/interrupt-bookmark/index.test.ts +717 -0
- package/extensions/interrupt-bookmark/index.ts +354 -0
- package/extensions/interrupt-bookmark/package.json +20 -0
- package/extensions/skill-stats/README.md +84 -3
- package/extensions/skill-stats/index.ts +62 -3
- package/extensions/skill-stats/package.json +1 -1
- package/extensions/skill-stats/rollup.test.ts +643 -0
- package/extensions/skill-stats/rollup.ts +415 -0
- package/extensions/skill-stats/timeline.ts +101 -0
- package/package.json +1 -1
- package/scripts/lib/compose-agent.mjs +45 -7
- package/scripts/lib/manifest.mjs +29 -0
- package/skills/review/review-defects/EXAMPLES.md +80 -4
- package/skills/review/review-defects/SKILL.md +47 -16
- package/skills/review/review-report/EXAMPLES.md +118 -0
- package/skills/review/review-report/SKILL.md +138 -0
- package/skills/review/review-report/scripts/report.mjs +1032 -0
- package/skills/workflow/hitl-queue/BOUNDARY.md +122 -0
- package/skills/workflow/hitl-queue/DEADLINES.md +59 -0
- package/skills/workflow/hitl-queue/RECOMMEND.md +89 -0
- package/skills/workflow/hitl-queue/SCOPES.md +118 -0
- package/skills/workflow/hitl-queue/SKILL.md +150 -0
- package/skills/workflow/hitl-queue/scripts/boundary.mjs +493 -0
- package/skills/workflow/hitl-queue/scripts/queue.mjs +1012 -0
- package/skills/workflow/hitl-queue/scripts/recommend.mjs +492 -0
- package/skills/workflow/hitl-queue/scripts/scopes.mjs +479 -0
- package/skills/workflow/plan/SKILL.md +13 -0
- package/skills/workflow/prioritise-work/CHALLENGE.md +139 -0
- package/skills/workflow/prioritise-work/SKILL.md +150 -0
- package/skills/workflow/prioritise-work/scripts/challenge.mjs +505 -0
- package/skills/workflow/prioritise-work/scripts/strategy.mjs +756 -0
- package/skills/workflow/prioritise-work/templates/strategy.md +87 -0
- package/skills/workflow/ralph/REFERENCE.md +6 -1
- package/skills/workflow/ralph/scripts/prune-worktrees.mjs +185 -10
- package/skills/workflow/run-pipeline-stage/playbooks/review.md +5 -2
- package/skills/workflow/score-bead/FORMAT.md +73 -0
- package/skills/workflow/score-bead/SKILL.md +112 -0
- package/skills/workflow/score-bead/scripts/rice.mjs +664 -0
- package/skills/workflow/using-agent-skills/SKILL.md +6 -1
- package/token-map.README.md +14 -2
- package/token-map.json +17 -3
|
@@ -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
|
|
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-
|
|
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/
|
|
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)
|
|
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
|
});
|