om-memory-system 4.31.1 → 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.
@@ -84,5 +84,8 @@ export function importSkillTurnOutput(options, report) {
84
84
  return;
85
85
  // handleFinishedTurn logs a failure as skill-record-failed, so one bad turn never stops the run.
86
86
  await handleFinishedTurn(unit, { memories: false, skills: true }, { recordSkillTurn: async (turn) => count(await output(turn, current)) });
87
+ // `skills.db` writes finish without giving the event loop a turn. In the
88
+ // web app, a long run would otherwise hold back every request, progress included.
89
+ await new Promise((resolve) => setImmediate(resolve));
87
90
  };
88
91
  }
@@ -39,6 +39,21 @@ export declare function toolCallFailureReason(result: ToolCallResult, lengthStop
39
39
  * conversation content, so it is replaced; validator messages name fields only.
40
40
  */
41
41
  export declare function describeValidationError(error: unknown): string;
42
+ /**
43
+ * Where a JSON `SyntaxError` stopped, from Node's "at position N" text, or
44
+ * undefined when the runtime gives none. A position near the end of the
45
+ * arguments means a cut-off reply; one far before it means bad escaping.
46
+ */
47
+ export declare function jsonErrorPosition(error: unknown): number | undefined;
48
+ /** Log fields for a failed tool reply: no reply text, only codes and a position. */
49
+ export declare function validationLogFields(error: unknown, stopReason: string | undefined): {
50
+ error: string;
51
+ errorType: string;
52
+ stopReason: string | null;
53
+ jsonErrorAt: number | null;
54
+ };
55
+ /** The error a failed tool reply returns. It says when the reply was cut off. */
56
+ export declare function validationFailureMessage(error: unknown, stopReason: string | undefined): string;
42
57
  export declare function applySafeExtraParams(requestBody: Record<string, any>, extraParams: Record<string, unknown>): void;
43
58
  export declare abstract class BaseAIProvider {
44
59
  protected config: ProviderConfig;
@@ -29,6 +29,32 @@ export function describeValidationError(error) {
29
29
  return error.message;
30
30
  return "unknown validation error";
31
31
  }
32
+ /**
33
+ * Where a JSON `SyntaxError` stopped, from Node's "at position N" text, or
34
+ * undefined when the runtime gives none. A position near the end of the
35
+ * arguments means a cut-off reply; one far before it means bad escaping.
36
+ */
37
+ export function jsonErrorPosition(error) {
38
+ if (!(error instanceof SyntaxError))
39
+ return undefined;
40
+ const match = /at position (\d+)/.exec(error.message);
41
+ return match ? Number(match[1]) : undefined;
42
+ }
43
+ /** Log fields for a failed tool reply: no reply text, only codes and a position. */
44
+ export function validationLogFields(error, stopReason) {
45
+ return {
46
+ error: describeValidationError(error),
47
+ errorType: error instanceof Error ? error.constructor.name : typeof error,
48
+ // "length" means the reply hit the output limit and was cut off.
49
+ stopReason: stopReason ?? null,
50
+ jsonErrorAt: jsonErrorPosition(error) ?? null,
51
+ };
52
+ }
53
+ /** The error a failed tool reply returns. It says when the reply was cut off. */
54
+ export function validationFailureMessage(error, stopReason) {
55
+ const cutOff = stopReason === "length" ? " (the reply was cut off)" : "";
56
+ return `Validation failed: ${describeValidationError(error)}${cutOff}`;
57
+ }
32
58
  const PROTECTED_KEYS = new Set([
33
59
  "model",
34
60
  "messages",
@@ -1,4 +1,4 @@
1
- import { BaseAIProvider, applySafeExtraParams, describeValidationError, isTransportError, } from "./base-provider.js";
1
+ import { BaseAIProvider, applySafeExtraParams, validationFailureMessage, validationLogFields, isTransportError, } from "./base-provider.js";
2
2
  import { log } from "../../logger.js";
3
3
  import { UserProfileValidator } from "../validators/user-profile-validator.js";
4
4
  import { parseRetryAfter } from "../../../core/capture-retry-policy.js";
@@ -338,16 +338,13 @@ export class OpenAIChatCompletionProvider extends BaseAIProvider {
338
338
  }
339
339
  catch (validationError) {
340
340
  log("OpenAI tool response validation failed", {
341
- error: describeValidationError(validationError),
342
- errorType: validationError instanceof Error
343
- ? validationError.constructor.name
344
- : typeof validationError,
341
+ ...validationLogFields(validationError, stopReason),
345
342
  toolName: toolSchema.function.name,
346
343
  iteration: iterations,
347
344
  // Log only the size: the arguments are the model's reply.
348
345
  argumentsChars: toolCall.function.arguments.length,
349
346
  }, "error");
350
- const errorMessage = `Validation failed: ${describeValidationError(validationError)}`;
347
+ const errorMessage = validationFailureMessage(validationError, stopReason);
351
348
  lastErrorMessage = errorMessage;
352
349
  await this.addToolResponse(session.id, messages, toolCallId, JSON.stringify({ success: false, error: errorMessage }));
353
350
  return {
@@ -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",
@@ -90,7 +90,8 @@ function nowOf(deps) {
90
90
  }
91
91
  const FAILURE_TEXT = {
92
92
  "not-configured": "The external API is not set up. Set it up in the External API card.",
93
- "invalid-reply": "The model reply was not valid, so nothing was saved",
93
+ // One reply in two came back as broken JSON in a live check, so a retry usually works.
94
+ "invalid-reply": "The model reply was not valid, so nothing was saved. Try again.",
94
95
  timeout: "The model did not reply in time",
95
96
  };
96
97
  /** Log one metadata record, start the skill draft wait and return the failure. */
@@ -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