om-memory-system 4.31.2 → 4.33.1

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
+ }
@@ -9,7 +9,7 @@ export interface ApplyDeps {
9
9
  folders: readonly string[];
10
10
  now?: () => number;
11
11
  }
12
- export type ApplyFailureCode = "not-found" | "not-pending" | "not-skill-file" | "outside-folder" | "invalid-name" | "stale" | "exists" | "read-only" | "write-failed";
12
+ export type ApplyFailureCode = "not-found" | "not-pending" | "not-skill-file" | "outside-folder" | "invalid-name" | "stale" | "exists" | "read-only" | "write-failed" | "state-write-failed";
13
13
  export type ApplyResult = {
14
14
  ok: true;
15
15
  code: "applied";
@@ -21,7 +21,7 @@ export type ApplyResult = {
21
21
  code: ApplyFailureCode;
22
22
  message: string;
23
23
  };
24
- export type UndoFailureCode = "not-found" | "not-applied" | "no-version" | "outside-folder" | "changed" | "read-only" | "write-failed";
24
+ export type UndoFailureCode = "not-found" | "not-applied" | "no-version" | "outside-folder" | "changed" | "read-only" | "write-failed" | "state-write-failed";
25
25
  export type UndoResult = {
26
26
  ok: true;
27
27
  code: "undone";
@@ -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>;
@@ -103,6 +103,8 @@ function replaceFile(path, content, overwrite = true) {
103
103
  function errorText(error) {
104
104
  return error instanceof Error ? error.message : String(error);
105
105
  }
106
+ /** The fixed answer when the state write fails; it holds no path and no error text. */
107
+ const STATE_WRITE_FAILED = "The proposal state could not be saved, so the SKILL.md change was rolled back";
106
108
  /**
107
109
  * Writes a pending proposal to its `SKILL.md` (design §7). Checks the real
108
110
  * path, the file name, a new skill's name and the base hash first. Saves the
@@ -166,7 +168,27 @@ export async function applyProposal(id, by, deps) {
166
168
  }
167
169
  clearSkillIndexCache();
168
170
  const appliedAt = now();
169
- await updateProposalState(store, id, "applied", appliedAt, by);
171
+ try {
172
+ await updateProposalState(store, id, "applied", appliedAt, by);
173
+ }
174
+ catch {
175
+ // The file and the state must agree: put the file back and drop the version row.
176
+ try {
177
+ if (proposal.kind === "new") {
178
+ unlinkSync(real);
179
+ removeEmptyFolder(dirname(real), target.folders);
180
+ }
181
+ else if (previous !== null) {
182
+ replaceFile(real, previous);
183
+ }
184
+ }
185
+ catch {
186
+ // Best effort; the code below still tells the reader the apply did not finish.
187
+ }
188
+ await deleteSkillVersion(store, versionId).catch(() => { });
189
+ clearSkillIndexCache();
190
+ return { ok: false, code: "state-write-failed", message: STATE_WRITE_FAILED };
191
+ }
170
192
  return {
171
193
  ok: true,
172
194
  code: "applied",
@@ -187,13 +209,8 @@ function removeEmptyFolder(folder, roots) {
187
209
  // The folder is gone or not empty; leave it.
188
210
  }
189
211
  }
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;
212
+ /** Every check undo makes before it writes. Reads only; changes nothing. */
213
+ async function planUndo(id, deps) {
197
214
  const { store } = deps;
198
215
  if (store.isReadOnly()) {
199
216
  return {
@@ -223,6 +240,26 @@ export async function undoProposal(id, deps) {
223
240
  savedContent: version.previousContent,
224
241
  };
225
242
  }
243
+ return { ok: true, proposal, version, real, folders };
244
+ }
245
+ /** True when `undoProposal` would accept this proposal now. Writes nothing. */
246
+ export async function canUndoProposal(id, deps) {
247
+ return (await planUndo(id, deps)).ok;
248
+ }
249
+ /**
250
+ * Restores the content saved before the last apply, or deletes the file the
251
+ * apply created. Only when the file still matches what OMMS wrote; otherwise
252
+ * refuses and returns the saved content. So an older change is refused once a
253
+ * later change has rewritten the file, until that later change is undone.
254
+ */
255
+ export async function undoProposal(id, deps) {
256
+ const now = deps.now ?? Date.now;
257
+ const plan = await planUndo(id, deps);
258
+ if (!plan.ok)
259
+ return plan;
260
+ const { proposal, version, real, folders } = plan;
261
+ // The plan checked that the file still holds what OMMS wrote; keep it to put back.
262
+ const written = readFileSync(real, "utf8");
226
263
  try {
227
264
  if (version.previousContent === null) {
228
265
  unlinkSync(real);
@@ -237,7 +274,21 @@ export async function undoProposal(id, deps) {
237
274
  }
238
275
  clearSkillIndexCache();
239
276
  const undoneAt = now();
240
- await updateProposalState(store, id, "undone", undoneAt);
277
+ try {
278
+ await updateProposalState(deps.store, id, "undone", undoneAt);
279
+ }
280
+ catch {
281
+ // The proposal stays applied, so the file goes back to what it was written as.
282
+ try {
283
+ mkdirSync(dirname(real), { recursive: true });
284
+ replaceFile(real, written);
285
+ }
286
+ catch {
287
+ // Best effort; the code below still tells the reader the undo did not finish.
288
+ }
289
+ clearSkillIndexCache();
290
+ return { ok: false, code: "state-write-failed", message: STATE_WRITE_FAILED };
291
+ }
241
292
  return {
242
293
  ok: true,
243
294
  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
+ }
@@ -24,7 +24,12 @@ export interface SkillRanking {
24
24
  mode: SkillMode;
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
+ /** True while at least one proposal of this skill is applied (an undone one does not count). */
28
+ modified: boolean;
29
+ /** `updated_at` of the newest applied proposal, or null when the skill is not modified. */
30
+ lastAppliedAt: number | null;
27
31
  }
32
+ 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
33
  /** (failed + 1) / (uses + 2): a skill with little evidence stays near the middle. */
29
34
  export declare function attentionScore(failedTurns: number, uses: number): number;
30
35
  /**