om-memory-system 4.31.2 → 4.32.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.
@@ -0,0 +1,46 @@
1
+ export declare const ACTIVITY_DEFAULT_DAYS = 30;
2
+ export declare const ACTIVITY_MAX_DAYS = 90;
3
+ export declare const ACTIVITY_BUCKETS: readonly ["day", "month", "year"];
4
+ export type ActivityBucket = (typeof ACTIVITY_BUCKETS)[number];
5
+ /** Which periods to count: the last N days, one calendar month, one year, or every year. */
6
+ export type ActivityQuery = {
7
+ bucket: "day";
8
+ days: number;
9
+ } | {
10
+ bucket: "day";
11
+ month: string;
12
+ } | {
13
+ bucket: "month";
14
+ year: number;
15
+ } | {
16
+ bucket: "year";
17
+ };
18
+ export interface ActivityWindow {
19
+ /** First instant counted, UTC milliseconds. */
20
+ start: number;
21
+ /** First instant after the window. */
22
+ end: number;
23
+ /** One key per bar, oldest first: `YYYY-MM-DD`, `YYYY-MM` or `YYYY`. */
24
+ keys: string[];
25
+ }
26
+ /** The SQL that turns a use time into its bucket key. */
27
+ export declare const BUCKET_SQL: Record<ActivityBucket, string>;
28
+ export declare const utcYear: (now: number) => number;
29
+ /**
30
+ * The window for a query. `firstYear` is the year of the earliest recorded
31
+ * use; the year bucket starts there and always reaches the year of `now`.
32
+ */
33
+ export declare function activityWindow(query: ActivityQuery, now: number, firstYear: number | null): ActivityWindow;
34
+ export declare const ACTIVITY_DEFAULT_TOP = 8;
35
+ export declare const ACTIVITY_MAX_TOP = 100;
36
+ /** How many of the most used skills to return: `top`, 1 to 100, default 8. */
37
+ export declare const topParam: (url: URL) => number;
38
+ /**
39
+ * The query from `bucket`, `days`, `month` and `year`. Numbers are clamped
40
+ * like every other Skills route number. An unknown bucket or a month that is
41
+ * not `YYYY-MM` from 1970 on gives an error, like an unknown `order`. With
42
+ * the day bucket, `month` wins over `days`.
43
+ */
44
+ export declare function parseActivityQuery(url: URL, now: number): ActivityQuery | {
45
+ error: string;
46
+ };
@@ -0,0 +1,83 @@
1
+ import { intParam } from "./query-params.js";
2
+ /*
3
+ * The time window of the Skills page activity charts: which UTC periods the
4
+ * chart shows, and how the route reads them from the query. Pure, so the
5
+ * window maths is tested without a database.
6
+ */
7
+ const DAY_MS = 86_400_000;
8
+ export const ACTIVITY_DEFAULT_DAYS = 30;
9
+ export const ACTIVITY_MAX_DAYS = 90;
10
+ export const ACTIVITY_BUCKETS = ["day", "month", "year"];
11
+ /** Date.UTC reads a year below 100 as 1900 and up, so the earliest year is 1970. */
12
+ const MIN_YEAR = 1970;
13
+ const MAX_YEAR = 9999;
14
+ /** The SQL that turns a use time into its bucket key. */
15
+ export const BUCKET_SQL = {
16
+ day: `date(u.created_at / 1000, 'unixepoch')`,
17
+ month: `strftime('%Y-%m', u.created_at / 1000, 'unixepoch')`,
18
+ year: `strftime('%Y', u.created_at / 1000, 'unixepoch')`,
19
+ };
20
+ const pad = (value) => String(value).padStart(2, "0");
21
+ const dayKey = (ms) => new Date(ms).toISOString().slice(0, 10);
22
+ export const utcYear = (now) => new Date(now).getUTCFullYear();
23
+ /**
24
+ * The window for a query. `firstYear` is the year of the earliest recorded
25
+ * use; the year bucket starts there and always reaches the year of `now`.
26
+ */
27
+ export function activityWindow(query, now, firstYear) {
28
+ if (query.bucket === "year") {
29
+ const last = utcYear(now);
30
+ const first = Math.min(firstYear ?? last, last);
31
+ const keys = Array.from({ length: last - first + 1 }, (_, i) => String(first + i));
32
+ return { start: Date.UTC(first, 0, 1), end: Date.UTC(last + 1, 0, 1), keys };
33
+ }
34
+ if (query.bucket === "month") {
35
+ const keys = Array.from({ length: 12 }, (_, i) => `${query.year}-${pad(i + 1)}`);
36
+ return { start: Date.UTC(query.year, 0, 1), end: Date.UTC(query.year + 1, 0, 1), keys };
37
+ }
38
+ if ("month" in query) {
39
+ const [year, month] = query.month.split("-").map(Number);
40
+ const start = Date.UTC(year, month - 1, 1);
41
+ const end = Date.UTC(year, month, 1);
42
+ const keys = Array.from({ length: (end - start) / DAY_MS }, (_, i) => dayKey(start + i * DAY_MS));
43
+ return { start, end, keys };
44
+ }
45
+ const today = Math.floor(now / DAY_MS) * DAY_MS;
46
+ const start = today - (query.days - 1) * DAY_MS;
47
+ const keys = Array.from({ length: query.days }, (_, i) => dayKey(start + i * DAY_MS));
48
+ return { start, end: today + DAY_MS, keys };
49
+ }
50
+ export const ACTIVITY_DEFAULT_TOP = 8;
51
+ export const ACTIVITY_MAX_TOP = 100;
52
+ /** How many of the most used skills to return: `top`, 1 to 100, default 8. */
53
+ export const topParam = (url) => intParam(url, "top", 1, ACTIVITY_MAX_TOP, ACTIVITY_DEFAULT_TOP);
54
+ const MONTH = /^(\d{4})-(0[1-9]|1[0-2])$/;
55
+ /**
56
+ * The query from `bucket`, `days`, `month` and `year`. Numbers are clamped
57
+ * like every other Skills route number. An unknown bucket or a month that is
58
+ * not `YYYY-MM` from 1970 on gives an error, like an unknown `order`. With
59
+ * the day bucket, `month` wins over `days`.
60
+ */
61
+ export function parseActivityQuery(url, now) {
62
+ const bucket = url.searchParams.get("bucket")?.trim() || "day";
63
+ if (!ACTIVITY_BUCKETS.includes(bucket)) {
64
+ return { error: `bucket must be one of: ${ACTIVITY_BUCKETS.join(", ")}` };
65
+ }
66
+ if (bucket === "year")
67
+ return { bucket };
68
+ if (bucket === "month") {
69
+ return { bucket, year: intParam(url, "year", MIN_YEAR, MAX_YEAR, utcYear(now)) };
70
+ }
71
+ const month = url.searchParams.get("month")?.trim();
72
+ if (month) {
73
+ const match = MONTH.exec(month);
74
+ if (!match || Number(match[1]) < MIN_YEAR) {
75
+ return { error: `month must be YYYY-MM, from ${MIN_YEAR}-01` };
76
+ }
77
+ return { bucket: "day", month };
78
+ }
79
+ return {
80
+ bucket: "day",
81
+ days: intParam(url, "days", 1, ACTIVITY_MAX_DAYS, ACTIVITY_DEFAULT_DAYS),
82
+ };
83
+ }
@@ -0,0 +1,61 @@
1
+ import { type ActivityBucket, type ActivityQuery } from "./activity-range.js";
2
+ import type { SkillIndex } from "./skill-index.js";
3
+ import type { SkillStore } from "./store.js";
4
+ /** The per-skill chart always shows this many days. */
5
+ export declare const SKILL_DAILY_DAYS = 30;
6
+ export { ACTIVITY_DEFAULT_DAYS, ACTIVITY_MAX_DAYS } from "./activity-range.js";
7
+ export interface DailyUses {
8
+ /** UTC day, `YYYY-MM-DD`. */
9
+ day: string;
10
+ /** Turns that used the skill that day. */
11
+ uses: number;
12
+ /** Of those turns, the ones with a failed tool call after the skill was used. */
13
+ failed: number;
14
+ }
15
+ /** Uses in one UTC period: a day `YYYY-MM-DD`, a month `YYYY-MM` or a year `YYYY`. */
16
+ export interface PeriodUses {
17
+ key: string;
18
+ uses: number;
19
+ failed: number;
20
+ }
21
+ export interface SkillActivity {
22
+ bucket: ActivityBucket;
23
+ /** One entry per period of the window, oldest first, zero-filled. */
24
+ series: PeriodUses[];
25
+ /** The day series as `{ day }` rows, as before. Empty for months and years. */
26
+ days: DailyUses[];
27
+ topSkills: {
28
+ name: string;
29
+ path: string | null;
30
+ uses: number;
31
+ failed: number;
32
+ }[];
33
+ /** Distinct skills used in the window, so a list of the top skills knows when to stop. */
34
+ topTotal: number;
35
+ totals: {
36
+ uses: number;
37
+ failed: number;
38
+ activeSkills: number;
39
+ };
40
+ /** Months with a use, and years with a use plus this year, newest first. */
41
+ available: {
42
+ months: string[];
43
+ years: number[];
44
+ };
45
+ }
46
+ /**
47
+ * One skill's uses and failed turns for each of the last 30 UTC days, oldest
48
+ * first, with zero days included. A missing `skills.db` gives all zeros and
49
+ * stays missing.
50
+ */
51
+ export declare function skillDaily(store: SkillStore, index: SkillIndex, skill: {
52
+ name: string;
53
+ path: string | null;
54
+ }, now: number): Promise<DailyUses[]>;
55
+ /**
56
+ * Uses and failed turns per UTC period across all skills, the `top` most used
57
+ * skills in the same window (8 by default), and the window totals. Counts a use like the
58
+ * ranking does, so a turn that used two skills counts once for each. A number
59
+ * asks for that many days ending today, as before.
60
+ */
61
+ export declare function skillActivity(store: SkillStore, index: SkillIndex, now: number, request: ActivityQuery | number, top?: number): Promise<SkillActivity>;
@@ -0,0 +1,88 @@
1
+ import { ACTIVITY_DEFAULT_TOP, BUCKET_SQL, activityWindow, utcYear, } from "./activity-range.js";
2
+ import { USES_CTES } from "./ranking.js";
3
+ /** The per-skill chart always shows this many days. */
4
+ export const SKILL_DAILY_DAYS = 30;
5
+ export { ACTIVITY_DEFAULT_DAYS, ACTIVITY_MAX_DAYS } from "./activity-range.js";
6
+ function fillKeys(keys, rows) {
7
+ const byKey = new Map(rows.map((r) => [String(r.key), r]));
8
+ return keys.map((key) => {
9
+ const row = byKey.get(key);
10
+ return { key, uses: Number(row?.uses ?? 0), failed: Number(row?.failed ?? 0) };
11
+ });
12
+ }
13
+ const asDays = (series) => series.map(({ key, uses, failed }) => ({ day: key, uses, failed }));
14
+ const entriesJson = (index) => JSON.stringify(index.entries.map((e) => ({ name: e.name, path: e.path })));
15
+ /**
16
+ * One skill's uses and failed turns for each of the last 30 UTC days, oldest
17
+ * first, with zero days included. A missing `skills.db` gives all zeros and
18
+ * stays missing.
19
+ */
20
+ export async function skillDaily(store, index, skill, now) {
21
+ const window = activityWindow({ bucket: "day", days: SKILL_DAILY_DAYS }, now, null);
22
+ const db = await store.existingDb();
23
+ if (!db)
24
+ return asDays(fillKeys(window.keys, []));
25
+ const rows = await db.all(`WITH ${USES_CTES}
26
+ SELECT ${BUCKET_SQL.day} AS key, COUNT(*) AS uses, SUM(u.failed) AS failed
27
+ FROM uses u
28
+ WHERE u.name = ? AND ${skill.path ? "u.path = ?" : "u.path IS NULL"}
29
+ GROUP BY key`, [entriesJson(index), window.start, skill.name, ...(skill.path ? [skill.path] : [])]);
30
+ return asDays(fillKeys(window.keys, rows));
31
+ }
32
+ /** Every month with a use, newest first, and those years plus the year of `now`. */
33
+ async function availablePeriods(db, json, now) {
34
+ const rows = db
35
+ ? await db.all(`WITH ${USES_CTES}
36
+ SELECT DISTINCT ${BUCKET_SQL.month} AS month FROM uses u ORDER BY month DESC`, [json, 0])
37
+ : [];
38
+ const months = rows.map((r) => String(r.month));
39
+ const years = [...new Set([utcYear(now), ...months.map((m) => Number(m.slice(0, 4)))])];
40
+ return { months, years: years.sort((a, b) => b - a) };
41
+ }
42
+ /**
43
+ * Uses and failed turns per UTC period across all skills, the `top` most used
44
+ * skills in the same window (8 by default), and the window totals. Counts a use like the
45
+ * ranking does, so a turn that used two skills counts once for each. A number
46
+ * asks for that many days ending today, as before.
47
+ */
48
+ export async function skillActivity(store, index, now, request, top = ACTIVITY_DEFAULT_TOP) {
49
+ const query = typeof request === "number" ? { bucket: "day", days: request } : request;
50
+ const db = await store.existingDb();
51
+ const json = entriesJson(index);
52
+ const available = await availablePeriods(db, json, now);
53
+ const oldest = available.months.at(-1);
54
+ const window = activityWindow(query, now, oldest ? Number(oldest.slice(0, 4)) : null);
55
+ // The CTEs take the index and the window start; the end bounds the window.
56
+ const args = [json, window.start, window.end];
57
+ const keyRows = db
58
+ ? await db.all(`WITH ${USES_CTES}
59
+ SELECT ${BUCKET_SQL[query.bucket]} AS key, COUNT(*) AS uses, SUM(u.failed) AS failed
60
+ FROM uses u WHERE u.created_at < ? GROUP BY key`, args)
61
+ : [];
62
+ const skillRows = db
63
+ ? await db.all(`WITH ${USES_CTES}
64
+ SELECT u.name, u.path, COUNT(*) AS uses, SUM(u.failed) AS failed
65
+ FROM uses u WHERE u.created_at < ? GROUP BY u.name, u.path
66
+ ORDER BY uses DESC, u.name ASC, u.path ASC`, args)
67
+ : [];
68
+ const series = fillKeys(window.keys, keyRows);
69
+ const skills = skillRows.map((r) => ({
70
+ name: String(r.name),
71
+ path: r.path == null ? null : String(r.path),
72
+ uses: Number(r.uses),
73
+ failed: Number(r.failed),
74
+ }));
75
+ return {
76
+ bucket: query.bucket,
77
+ series,
78
+ days: query.bucket === "day" ? asDays(series) : [],
79
+ topSkills: skills.slice(0, top),
80
+ topTotal: skills.length,
81
+ totals: {
82
+ uses: series.reduce((n, d) => n + d.uses, 0),
83
+ failed: series.reduce((n, d) => n + d.failed, 0),
84
+ activeSkills: skills.length,
85
+ },
86
+ available,
87
+ };
88
+ }
@@ -42,9 +42,12 @@ export declare function hashSkillContent(content: string | Buffer): string;
42
42
  * previous content before the write. Expected outcomes return a result code.
43
43
  */
44
44
  export declare function applyProposal(id: string, by: SkillAppliedBy, deps: ApplyDeps): Promise<ApplyResult>;
45
+ /** True when `undoProposal` would accept this proposal now. Writes nothing. */
46
+ export declare function canUndoProposal(id: string, deps: ApplyDeps): Promise<boolean>;
45
47
  /**
46
48
  * Restores the content saved before the last apply, or deletes the file the
47
49
  * apply created. Only when the file still matches what OMMS wrote; otherwise
48
- * refuses and returns the saved content.
50
+ * refuses and returns the saved content. So an older change is refused once a
51
+ * later change has rewritten the file, until that later change is undone.
49
52
  */
50
53
  export declare function undoProposal(id: string, deps: ApplyDeps): Promise<UndoResult>;
@@ -187,13 +187,8 @@ function removeEmptyFolder(folder, roots) {
187
187
  // The folder is gone or not empty; leave it.
188
188
  }
189
189
  }
190
- /**
191
- * Restores the content saved before the last apply, or deletes the file the
192
- * apply created. Only when the file still matches what OMMS wrote; otherwise
193
- * refuses and returns the saved content.
194
- */
195
- export async function undoProposal(id, deps) {
196
- const now = deps.now ?? Date.now;
190
+ /** Every check undo makes before it writes. Reads only; changes nothing. */
191
+ async function planUndo(id, deps) {
197
192
  const { store } = deps;
198
193
  if (store.isReadOnly()) {
199
194
  return {
@@ -223,6 +218,24 @@ export async function undoProposal(id, deps) {
223
218
  savedContent: version.previousContent,
224
219
  };
225
220
  }
221
+ return { ok: true, proposal, version, real, folders };
222
+ }
223
+ /** True when `undoProposal` would accept this proposal now. Writes nothing. */
224
+ export async function canUndoProposal(id, deps) {
225
+ return (await planUndo(id, deps)).ok;
226
+ }
227
+ /**
228
+ * Restores the content saved before the last apply, or deletes the file the
229
+ * apply created. Only when the file still matches what OMMS wrote; otherwise
230
+ * refuses and returns the saved content. So an older change is refused once a
231
+ * later change has rewritten the file, until that later change is undone.
232
+ */
233
+ export async function undoProposal(id, deps) {
234
+ const now = deps.now ?? Date.now;
235
+ const plan = await planUndo(id, deps);
236
+ if (!plan.ok)
237
+ return plan;
238
+ const { proposal, version, real, folders } = plan;
226
239
  try {
227
240
  if (version.previousContent === null) {
228
241
  unlinkSync(real);
@@ -237,7 +250,7 @@ export async function undoProposal(id, deps) {
237
250
  }
238
251
  clearSkillIndexCache();
239
252
  const undoneAt = now();
240
- await updateProposalState(store, id, "undone", undoneAt);
253
+ await updateProposalState(deps.store, id, "undone", undoneAt);
241
254
  return {
242
255
  ok: true,
243
256
  code: "undone",
@@ -0,0 +1,48 @@
1
+ import { type ApplyDeps } from "./apply.js";
2
+ import { type SkillProposal } from "./proposal-store.js";
3
+ export type SkillHistoryKind = "created" | "applied" | "rejected" | "undone" | "stale";
4
+ export interface SkillHistoryEntry {
5
+ /** `<proposal id>:created` or `<proposal id>:<state>`. */
6
+ id: string;
7
+ proposalId: string;
8
+ kind: SkillHistoryKind;
9
+ /** Epoch ms: the proposal's creation time, or the time it reached this state. */
10
+ at: number;
11
+ summary: string;
12
+ appliedBy: string | null;
13
+ /** The saved version of an applied change; null for any other kind. */
14
+ versionId: string | null;
15
+ /** True only when `undoProposal` would accept this change now. */
16
+ canUndo: boolean;
17
+ /** Applied changes to the same skill saved after this one. */
18
+ laterAppliedCount: number;
19
+ added: number;
20
+ removed: number;
21
+ }
22
+ /** Added and removed line counts of a unified-style line diff. Hunk headers are skipped. */
23
+ export declare function countDiffLines(diff: string | null): {
24
+ added: number;
25
+ removed: number;
26
+ };
27
+ /**
28
+ * The change history of one skill, newest first: a `created` entry for each
29
+ * proposal and a second entry once it was applied, rejected, undone or found
30
+ * stale. `canUndo` runs the same checks as undo, so it is false for an older
31
+ * change once a later change rewrote the file, and for a file edited by hand.
32
+ * No proposals, or no `skills.db`, give an empty history.
33
+ */
34
+ export declare function skillHistory(deps: ApplyDeps, skill: {
35
+ path?: string;
36
+ name?: string;
37
+ }, limit: number, offset: number): Promise<{
38
+ total: number;
39
+ entries: SkillHistoryEntry[];
40
+ }>;
41
+ /**
42
+ * Each proposal with `canUndo`: true only for an applied proposal that undo
43
+ * would accept now, so false once a later change rewrote the file. Reads one
44
+ * saved version and one file hash per applied proposal, and nothing for the rest.
45
+ */
46
+ export declare function withCanUndo(deps: ApplyDeps, proposals: readonly SkillProposal[]): Promise<(SkillProposal & {
47
+ canUndo: boolean;
48
+ })[]>;
@@ -0,0 +1,95 @@
1
+ import { canUndoProposal } from "./apply.js";
2
+ import { listProposals } from "./proposal-store.js";
3
+ /** Added and removed line counts of a unified-style line diff. Hunk headers are skipped. */
4
+ export function countDiffLines(diff) {
5
+ let added = 0;
6
+ let removed = 0;
7
+ for (const line of (diff ?? "").split("\n")) {
8
+ if (line.startsWith("@@"))
9
+ continue;
10
+ if (line.startsWith("+"))
11
+ added++;
12
+ else if (line.startsWith("-"))
13
+ removed++;
14
+ }
15
+ return { added, removed };
16
+ }
17
+ /** The newest saved version id for each proposal id. */
18
+ async function latestVersionIds(deps, ids) {
19
+ const db = await deps.store.existingDb();
20
+ const found = new Map();
21
+ if (!db || ids.length === 0)
22
+ return found;
23
+ const rows = await db.all(`SELECT proposal_id, MAX(id) AS id FROM skill_versions
24
+ WHERE proposal_id IN (SELECT value FROM json_each(?)) GROUP BY proposal_id`, [JSON.stringify(ids)]);
25
+ for (const row of rows)
26
+ found.set(String(row.proposal_id), Number(row.id));
27
+ return found;
28
+ }
29
+ function entriesOf(proposal, versions) {
30
+ const counts = countDiffLines(proposal.diff);
31
+ const base = { proposalId: proposal.id, summary: proposal.summary ?? "", ...counts };
32
+ const entries = [
33
+ {
34
+ ...base,
35
+ id: `${proposal.id}:created`,
36
+ kind: "created",
37
+ at: proposal.createdAt,
38
+ appliedBy: null,
39
+ versionId: null,
40
+ },
41
+ ];
42
+ if (proposal.state !== "pending") {
43
+ const version = versions.get(proposal.id);
44
+ const applied = proposal.state === "applied";
45
+ entries.push({
46
+ ...base,
47
+ id: `${proposal.id}:${proposal.state}`,
48
+ kind: proposal.state,
49
+ at: proposal.updatedAt,
50
+ appliedBy: applied ? proposal.appliedBy : null,
51
+ versionId: applied && version !== undefined ? String(version) : null,
52
+ });
53
+ }
54
+ return entries;
55
+ }
56
+ /**
57
+ * The change history of one skill, newest first: a `created` entry for each
58
+ * proposal and a second entry once it was applied, rejected, undone or found
59
+ * stale. `canUndo` runs the same checks as undo, so it is false for an older
60
+ * change once a later change rewrote the file, and for a file edited by hand.
61
+ * No proposals, or no `skills.db`, give an empty history.
62
+ */
63
+ export async function skillHistory(deps, skill, limit, offset) {
64
+ // A name without a path is a skill outside the watched folders. Its proposals
65
+ // have no path, so a watched skill that shares the name stays out.
66
+ const proposals = skill.path
67
+ ? await listProposals(deps.store, { skillPath: skill.path })
68
+ : (await listProposals(deps.store, { skillName: skill.name })).filter((p) => !p.skillPath);
69
+ const versions = await latestVersionIds(deps, proposals.filter((p) => p.state === "applied").map((p) => p.id));
70
+ const all = proposals.flatMap((p) => entriesOf(p, versions));
71
+ // At the same time, the later step (applied, rejected) sorts above `created`.
72
+ all.sort((a, b) => b.at - a.at || +(b.kind !== "created") - +(a.kind !== "created"));
73
+ const appliedVersionIds = [...versions.values()];
74
+ const page = all.slice(offset, offset + limit);
75
+ const entries = await Promise.all(page.map(async (entry) => {
76
+ const version = entry.kind === "applied" ? Number(entry.versionId) : null;
77
+ return {
78
+ ...entry,
79
+ canUndo: version !== null && (await canUndoProposal(entry.proposalId, deps)),
80
+ laterAppliedCount: version === null ? 0 : appliedVersionIds.filter((id) => id > version).length,
81
+ };
82
+ }));
83
+ return { total: all.length, entries };
84
+ }
85
+ /**
86
+ * Each proposal with `canUndo`: true only for an applied proposal that undo
87
+ * would accept now, so false once a later change rewrote the file. Reads one
88
+ * saved version and one file hash per applied proposal, and nothing for the rest.
89
+ */
90
+ export async function withCanUndo(deps, proposals) {
91
+ return Promise.all(proposals.map(async (p) => ({
92
+ ...p,
93
+ canUndo: p.state === "applied" && (await canUndoProposal(p.id, deps)),
94
+ })));
95
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * An integer query parameter, clamped into `min` to `max`. A missing, empty or
3
+ * non-numeric value gives `fallback`. A fraction is rounded down.
4
+ */
5
+ export declare function intParam(url: URL, name: string, min: number, max: number, fallback: number): number;
@@ -0,0 +1,13 @@
1
+ /**
2
+ * An integer query parameter, clamped into `min` to `max`. A missing, empty or
3
+ * non-numeric value gives `fallback`. A fraction is rounded down.
4
+ */
5
+ export function intParam(url, name, min, max, fallback) {
6
+ const raw = url.searchParams.get(name);
7
+ if (raw === null || raw.trim() === "")
8
+ return fallback;
9
+ const value = Number(raw);
10
+ if (!Number.isFinite(value))
11
+ return fallback;
12
+ return Math.min(max, Math.max(min, Math.floor(value)));
13
+ }
@@ -25,6 +25,7 @@ export interface SkillRanking {
25
25
  /** 5 uses, attention of 0.3, and a failed turn since the skill's last proposal or review. */
26
26
  suggested: boolean;
27
27
  }
28
+ export declare const USES_CTES = "\nidx AS (\n SELECT json_extract(value, '$.name') AS name, json_extract(value, '$.path') AS path\n FROM json_each(?)\n),\nresolved AS (\n SELECT\n su.turn_id,\n su.skill_name AS name,\n CASE\n WHEN su.skill_path IS NOT NULL AND EXISTS (SELECT 1 FROM idx WHERE idx.path = su.skill_path)\n THEN su.skill_path\n WHEN su.skill_path IS NULL AND (SELECT COUNT(*) FROM idx WHERE idx.name = su.skill_name) = 1\n THEN (SELECT idx.path FROM idx WHERE idx.name = su.skill_name)\n ELSE NULL\n END AS path,\n t.created_at,\n COALESCE(su.position, -1) AS position\n FROM skill_uses su\n JOIN turns t ON t.id = su.turn_id\n WHERE t.created_at >= ?\n),\nfirst_use AS (\n SELECT turn_id, name, path, created_at, MIN(position) AS position\n FROM resolved\n GROUP BY turn_id, name, path\n),\nuses AS (\n SELECT\n f.turn_id,\n f.name,\n f.path,\n f.created_at,\n EXISTS (\n SELECT 1 FROM tool_calls tc\n WHERE tc.turn_id = f.turn_id AND tc.outcome = 'error' AND tc.position > f.position\n ) AS failed\n FROM first_use f\n)";
28
29
  /** (failed + 1) / (uses + 2): a skill with little evidence stays near the middle. */
29
30
  export declare function attentionScore(failedTurns: number, uses: number): number;
30
31
  /**
@@ -11,8 +11,11 @@ export const SKILL_ORDERS = ["uses", "attention"];
11
11
  // order is above the earliest use position in that turn. A NULL position (a
12
12
  // row from before the column) and -1 (a slash command) count every failure.
13
13
  // A review (a draft with no change, or an automatic review) counts as a proposal.
14
- const RANKING_SQL = `
15
- WITH idx AS (
14
+ // The activity charts reuse these CTEs (idx, resolved, first_use, uses) so they
15
+ // count a use and a failed turn the same way. Parameters: the index JSON, then
16
+ // the earliest turn time.
17
+ export const USES_CTES = `
18
+ idx AS (
16
19
  SELECT json_extract(value, '$.name') AS name, json_extract(value, '$.path') AS path
17
20
  FROM json_each(?)
18
21
  ),
@@ -49,7 +52,9 @@ uses AS (
49
52
  WHERE tc.turn_id = f.turn_id AND tc.outcome = 'error' AND tc.position > f.position
50
53
  ) AS failed
51
54
  FROM first_use f
52
- ),
55
+ )`;
56
+ const RANKING_SQL = `
57
+ WITH ${USES_CTES},
53
58
  last_proposal AS (
54
59
  SELECT path, MAX(at) AS at FROM (
55
60
  SELECT skill_path AS path, created_at AS at FROM skill_proposals WHERE skill_path IS NOT NULL
@@ -13,12 +13,18 @@ export interface SkillTurnSummary {
13
13
  failedToolCalls: number;
14
14
  }
15
15
  /**
16
- * The newest turns that used one skill. `path` is null for a skill that no
17
- * watched folder holds. A missing `skills.db` gives no turns and stays missing.
16
+ * Turns that used one skill, newest first, `limit` of them after skipping
17
+ * `offset`. `path` is null for a skill that no watched folder holds. A missing
18
+ * `skills.db` gives no turns and stays missing.
18
19
  */
19
20
  export declare function recentSkillTurns(store: SkillStore, skill: {
20
21
  name: string;
21
22
  path: string | null;
22
- }, index: SkillIndex, limit?: number): Promise<SkillTurnSummary[]>;
23
+ }, index: SkillIndex, limit?: number, offset?: number): Promise<SkillTurnSummary[]>;
24
+ /** How many recorded turns used this skill. */
25
+ export declare function countSkillTurns(store: SkillStore, skill: {
26
+ name: string;
27
+ path: string | null;
28
+ }, index: SkillIndex): Promise<number>;
23
29
  /** The turns with these ids, newest first; ids of deleted turns are left out. */
24
30
  export declare function skillTurnsByIds(store: SkillStore, ids: readonly number[]): Promise<SkillTurnSummary[]>;
@@ -17,42 +17,54 @@ function toSummary(row) {
17
17
  // The same use-to-skill rule as the ranking: a recorded path counts while the
18
18
  // index lists it; a use with no path belongs to the one watched skill with its name.
19
19
  const BY_PATH = `
20
- SELECT ${COLUMNS} FROM turns t WHERE t.id IN (
21
- SELECT su.turn_id FROM skill_uses su
22
- WHERE su.skill_path = ? OR (su.skill_path IS NULL AND su.skill_name = ? AND ? = 1)
23
- ) ORDER BY t.created_at DESC, t.id DESC LIMIT ?`;
20
+ SELECT su.turn_id FROM skill_uses su
21
+ WHERE su.skill_path = ? OR (su.skill_path IS NULL AND su.skill_name = ? AND ? = 1)`;
24
22
  const BY_NAME = `
25
- SELECT ${COLUMNS} FROM turns t WHERE t.id IN (
26
- SELECT su.turn_id FROM skill_uses su
27
- WHERE su.skill_name = ? AND (
28
- (su.skill_path IS NOT NULL AND su.skill_path NOT IN (SELECT value FROM json_each(?)))
29
- OR (su.skill_path IS NULL AND ? = 0)
30
- )
31
- ) ORDER BY t.created_at DESC, t.id DESC LIMIT ?`;
23
+ SELECT su.turn_id FROM skill_uses su
24
+ WHERE su.skill_name = ? AND (
25
+ (su.skill_path IS NOT NULL AND su.skill_path NOT IN (SELECT value FROM json_each(?)))
26
+ OR (su.skill_path IS NULL AND ? = 0)
27
+ )`;
28
+ /** The SQL that selects a skill's turn ids, and its parameters. */
29
+ function turnIdsFor(skill, index) {
30
+ const named = index.entries.filter((e) => e.name === skill.name);
31
+ return skill.path
32
+ ? {
33
+ sql: BY_PATH,
34
+ args: [skill.path, skill.name, named.length === 1 && named[0].path === skill.path ? 1 : 0],
35
+ }
36
+ : {
37
+ sql: BY_NAME,
38
+ args: [
39
+ skill.name,
40
+ JSON.stringify(index.entries.map((e) => e.path)),
41
+ named.length === 1 ? 1 : 0,
42
+ ],
43
+ };
44
+ }
32
45
  /**
33
- * The newest turns that used one skill. `path` is null for a skill that no
34
- * watched folder holds. A missing `skills.db` gives no turns and stays missing.
46
+ * Turns that used one skill, newest first, `limit` of them after skipping
47
+ * `offset`. `path` is null for a skill that no watched folder holds. A missing
48
+ * `skills.db` gives no turns and stays missing.
35
49
  */
36
- export async function recentSkillTurns(store, skill, index, limit = RECENT_TURN_LIMIT) {
50
+ export async function recentSkillTurns(store, skill, index, limit = RECENT_TURN_LIMIT, offset = 0) {
37
51
  const db = await store.existingDb();
38
52
  if (!db)
39
53
  return [];
40
- const named = index.entries.filter((e) => e.name === skill.name);
41
- const rows = skill.path
42
- ? await db.all(BY_PATH, [
43
- skill.path,
44
- skill.name,
45
- named.length === 1 && named[0].path === skill.path ? 1 : 0,
46
- limit,
47
- ])
48
- : await db.all(BY_NAME, [
49
- skill.name,
50
- JSON.stringify(index.entries.map((e) => e.path)),
51
- named.length === 1 ? 1 : 0,
52
- limit,
53
- ]);
54
+ const ids = turnIdsFor(skill, index);
55
+ const rows = await db.all(`SELECT ${COLUMNS} FROM turns t WHERE t.id IN (${ids.sql})
56
+ ORDER BY t.created_at DESC, t.id DESC LIMIT ? OFFSET ?`, [...ids.args, limit, offset]);
54
57
  return rows.map(toSummary);
55
58
  }
59
+ /** How many recorded turns used this skill. */
60
+ export async function countSkillTurns(store, skill, index) {
61
+ const db = await store.existingDb();
62
+ if (!db)
63
+ return 0;
64
+ const ids = turnIdsFor(skill, index);
65
+ const row = await db.get(`SELECT COUNT(*) AS n FROM turns t WHERE t.id IN (${ids.sql})`, ids.args);
66
+ return Number(row?.n ?? 0);
67
+ }
56
68
  /** The turns with these ids, newest first; ids of deleted turns are left out. */
57
69
  export async function skillTurnsByIds(store, ids) {
58
70
  const db = await store.existingDb();