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.
- package/dist/importer/import-turn-outputs.js +3 -0
- package/dist/services/ai/providers/base-provider.d.ts +15 -0
- package/dist/services/ai/providers/base-provider.js +26 -0
- package/dist/services/ai/providers/openai-chat-completion.js +3 -6
- 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/drafting.js +2 -1
- 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-9hu4Z7SV.css +0 -2
- package/dist/web/assets/index-BMt9gPNK.js +0 -129
|
@@ -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,
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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,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
|