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.
- package/dist/services/skill-learning/activity-range.d.ts +46 -0
- package/dist/services/skill-learning/activity-range.js +83 -0
- package/dist/services/skill-learning/activity.d.ts +61 -0
- package/dist/services/skill-learning/activity.js +88 -0
- package/dist/services/skill-learning/apply.d.ts +4 -1
- package/dist/services/skill-learning/apply.js +21 -8
- package/dist/services/skill-learning/history.d.ts +48 -0
- package/dist/services/skill-learning/history.js +95 -0
- package/dist/services/skill-learning/query-params.d.ts +5 -0
- package/dist/services/skill-learning/query-params.js +13 -0
- package/dist/services/skill-learning/ranking.d.ts +1 -0
- package/dist/services/skill-learning/ranking.js +8 -3
- package/dist/services/skill-learning/turn-summaries.d.ts +9 -3
- package/dist/services/skill-learning/turn-summaries.js +40 -28
- package/dist/services/skill-routes.js +46 -9
- package/dist/web/assets/index-CzTfLJbi.js +167 -0
- package/dist/web/assets/index-DOfXzWqw.css +2 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-B_xLhsQO.css +0 -2
- package/dist/web/assets/index-BetjjXGs.js +0 -129
|
@@ -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
|
-
|
|
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,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
|
-
|
|
15
|
-
|
|
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
|
-
*
|
|
17
|
-
*
|
|
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
|
|
21
|
-
|
|
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
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
*
|
|
34
|
-
*
|
|
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
|
|
41
|
-
const rows =
|
|
42
|
-
|
|
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();
|