om-memory-system 5.0.0 → 5.2.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/drafting.d.ts +13 -0
- package/dist/services/skill-learning/drafting.js +1 -0
- package/dist/services/skill-learning/failure-explain.d.ts +52 -0
- package/dist/services/skill-learning/failure-explain.js +131 -0
- package/dist/services/skill-learning/failures.d.ts +57 -0
- package/dist/services/skill-learning/failures.js +103 -0
- package/dist/services/skill-learning/turn-summaries.d.ts +8 -0
- package/dist/services/skill-learning/turn-summaries.js +9 -4
- package/dist/services/skill-routes.js +52 -0
- package/dist/services/web-ensure.d.ts +11 -0
- package/dist/services/web-ensure.js +33 -4
- package/dist/web/assets/index-CJ_K-hMG.js +170 -0
- package/dist/web/assets/index-ccH97VE2.css +2 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-BaNPl-8h.js +0 -169
- package/dist/web/assets/index-DzCk_dKo.css +0 -2
|
@@ -50,6 +50,19 @@ export type IdeasResult = {
|
|
|
50
50
|
ok: true;
|
|
51
51
|
ideas: SkillIdea[];
|
|
52
52
|
} | DraftFailure;
|
|
53
|
+
declare function tool(name: string, description: string, properties: Record<string, unknown>): {
|
|
54
|
+
type: string;
|
|
55
|
+
function: {
|
|
56
|
+
name: string;
|
|
57
|
+
description: string;
|
|
58
|
+
parameters: {
|
|
59
|
+
type: string;
|
|
60
|
+
properties: Record<string, unknown>;
|
|
61
|
+
required: string[];
|
|
62
|
+
};
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
export { tool };
|
|
53
66
|
export declare function withFinalNewline(text: string): string;
|
|
54
67
|
export declare function sameText(a: string, b: string): boolean;
|
|
55
68
|
export declare const READ_ONLY: DraftFailure;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { type ProfileFailureCode } from "../../core/profile-failure.js";
|
|
2
|
+
import { type SkillModel } from "./drafting.js";
|
|
3
|
+
import { type FailureGroup } from "./failures.js";
|
|
4
|
+
import type { SkillIndex } from "./skill-index.js";
|
|
5
|
+
import type { SkillStore } from "./store.js";
|
|
6
|
+
/** At most this many groups go to the model, most calls first. */
|
|
7
|
+
export declare const MAX_EXPLAIN_GROUPS = 10;
|
|
8
|
+
/** One input excerpt per group, cut to this many characters. */
|
|
9
|
+
export declare const EXPLAIN_EXCERPT_CHARS = 500;
|
|
10
|
+
/** What a cause is blamed on; the panel shows the label as a lead. */
|
|
11
|
+
export type ExplainCauseKind = "skill" | "harness" | "environment" | "unknown";
|
|
12
|
+
export interface ExplainCause {
|
|
13
|
+
kind: ExplainCauseKind;
|
|
14
|
+
explanation: string;
|
|
15
|
+
actions: string[];
|
|
16
|
+
}
|
|
17
|
+
/** The model's reply: a summary and labelled causes. Shown, never saved. */
|
|
18
|
+
export interface FailureExplanation {
|
|
19
|
+
summary: string;
|
|
20
|
+
causes: ExplainCause[];
|
|
21
|
+
}
|
|
22
|
+
export type ExplainFailureCode = ProfileFailureCode | "not-found" | "invalid-args";
|
|
23
|
+
export type ExplainResult = {
|
|
24
|
+
ok: true;
|
|
25
|
+
explanation: FailureExplanation;
|
|
26
|
+
} | {
|
|
27
|
+
ok: false;
|
|
28
|
+
code: ExplainFailureCode;
|
|
29
|
+
message: string;
|
|
30
|
+
};
|
|
31
|
+
export interface ExplainDeps {
|
|
32
|
+
store: SkillStore;
|
|
33
|
+
index: SkillIndex;
|
|
34
|
+
model: SkillModel;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Builds the request the test asserts on: the skill name, its `SKILL.md` when
|
|
38
|
+
* a watched folder holds it, and the top groups. No prompts, no replies.
|
|
39
|
+
*/
|
|
40
|
+
export declare function explainUserPrompt(skill: {
|
|
41
|
+
name: string;
|
|
42
|
+
path: string | null;
|
|
43
|
+
}, skillFile: string | null, groups: readonly FailureGroup[]): string;
|
|
44
|
+
/**
|
|
45
|
+
* Asks the external API model to explain one skill's failure groups (design
|
|
46
|
+
* §9). The reply is returned, never saved. Reuses the drafting model path and
|
|
47
|
+
* its failure codes, including `not-configured`.
|
|
48
|
+
*/
|
|
49
|
+
export declare function explainFailures(skill: {
|
|
50
|
+
name: string;
|
|
51
|
+
path: string | null;
|
|
52
|
+
}, deps: ExplainDeps): Promise<ExplainResult>;
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { profileFailureCode, profileResultCode, } from "../../core/profile-failure.js";
|
|
4
|
+
import { log } from "../logger.js";
|
|
5
|
+
import { tool } from "./drafting.js";
|
|
6
|
+
import { skillFailureGroups } from "./failures.js";
|
|
7
|
+
/** At most this many groups go to the model, most calls first. */
|
|
8
|
+
export const MAX_EXPLAIN_GROUPS = 10;
|
|
9
|
+
/** One input excerpt per group, cut to this many characters. */
|
|
10
|
+
export const EXPLAIN_EXCERPT_CHARS = 500;
|
|
11
|
+
const ExplainReply = z.object({
|
|
12
|
+
summary: z.string(),
|
|
13
|
+
causes: z
|
|
14
|
+
.array(z.object({
|
|
15
|
+
kind: z.enum(["skill", "harness", "environment", "unknown"]),
|
|
16
|
+
explanation: z.string(),
|
|
17
|
+
actions: z.array(z.string()).default([]),
|
|
18
|
+
}))
|
|
19
|
+
.default([]),
|
|
20
|
+
});
|
|
21
|
+
const EXPLAIN_TOOL = tool("explain_skill_failures", "Explain a skill's failed tool calls", {
|
|
22
|
+
summary: { type: "string", description: "One or two plain sentences on what fails and why" },
|
|
23
|
+
causes: {
|
|
24
|
+
type: "array",
|
|
25
|
+
items: {
|
|
26
|
+
type: "object",
|
|
27
|
+
properties: {
|
|
28
|
+
kind: {
|
|
29
|
+
type: "string",
|
|
30
|
+
enum: ["skill", "harness", "environment", "unknown"],
|
|
31
|
+
description: "skill: the SKILL.md should change; harness: a host or harness bug; environment: machine, setup or credentials; unknown",
|
|
32
|
+
},
|
|
33
|
+
explanation: { type: "string", description: "What the error text shows" },
|
|
34
|
+
actions: {
|
|
35
|
+
type: "array",
|
|
36
|
+
items: { type: "string" },
|
|
37
|
+
description: "Suggested actions, as leads from the error text only",
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
required: ["kind", "explanation", "actions"],
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
const EXPLAIN_SYSTEM = [
|
|
45
|
+
"You explain why a coding agent's tool calls failed while it used one skill (SKILL.md).",
|
|
46
|
+
"You see the skill file and its failure groups: tool name, first error line, counts, the last time seen and one short input excerpt. You cannot see the host's code.",
|
|
47
|
+
"Label each cause skill, harness, environment or unknown. Treat your labels and suggested actions as leads from error text only, not conclusions.",
|
|
48
|
+
"Write plain British English. Call the explain_skill_failures tool.",
|
|
49
|
+
].join(" ");
|
|
50
|
+
const FAILURE_TEXT = {
|
|
51
|
+
"not-configured": "The external API is not set up. Set it up in the External API card.",
|
|
52
|
+
"invalid-args": "This skill has no failed tool calls to explain.",
|
|
53
|
+
"invalid-reply": "The model reply was not valid, so nothing is shown. Try again.",
|
|
54
|
+
timeout: "The model did not reply in time",
|
|
55
|
+
};
|
|
56
|
+
/** Log one metadata record with the reason code only, and return the failure. */
|
|
57
|
+
function fail(step, code) {
|
|
58
|
+
log(`Skill ${step} failed`, { host: null, reason: code }, "error");
|
|
59
|
+
return { ok: false, code, message: FAILURE_TEXT[code] ?? `Explaining failed: ${code}` };
|
|
60
|
+
}
|
|
61
|
+
function groupLine(group) {
|
|
62
|
+
const parts = [
|
|
63
|
+
`- Tool: ${group.name}`,
|
|
64
|
+
`Error: ${group.errorLine ?? "(no error text)"}`,
|
|
65
|
+
`Calls: ${group.calls} over ${group.turns} turns`,
|
|
66
|
+
`Last seen: ${new Date(group.lastSeenAt).toISOString()}`,
|
|
67
|
+
];
|
|
68
|
+
const call = group.latest[0];
|
|
69
|
+
if (call) {
|
|
70
|
+
parts.push(`Input excerpt: "${call.input.slice(0, EXPLAIN_EXCERPT_CHARS)}"`);
|
|
71
|
+
}
|
|
72
|
+
return parts.join("; ");
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Builds the request the test asserts on: the skill name, its `SKILL.md` when
|
|
76
|
+
* a watched folder holds it, and the top groups. No prompts, no replies.
|
|
77
|
+
*/
|
|
78
|
+
export function explainUserPrompt(skill, skillFile, groups) {
|
|
79
|
+
const file = skillFile === null ? "(not in a watched folder; no SKILL.md)" : `<<<\n${skillFile}\n>>>`;
|
|
80
|
+
return [
|
|
81
|
+
`Skill: ${skill.name}`,
|
|
82
|
+
`SKILL.md:\n${file}`,
|
|
83
|
+
`Failure groups, most calls first:`,
|
|
84
|
+
groups.map(groupLine).join("\n") || "(none)",
|
|
85
|
+
].join("\n\n");
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Asks the external API model to explain one skill's failure groups (design
|
|
89
|
+
* §9). The reply is returned, never saved. Reuses the drafting model path and
|
|
90
|
+
* its failure codes, including `not-configured`.
|
|
91
|
+
*/
|
|
92
|
+
export async function explainFailures(skill, deps) {
|
|
93
|
+
if (!deps.model.ready)
|
|
94
|
+
return fail("explain failures", "not-configured");
|
|
95
|
+
const groups = (await skillFailureGroups(deps.store, skill, deps.index)).slice(0, MAX_EXPLAIN_GROUPS);
|
|
96
|
+
if (groups.length === 0)
|
|
97
|
+
return fail("explain failures", "invalid-args");
|
|
98
|
+
let skillFile = null;
|
|
99
|
+
if (skill.path) {
|
|
100
|
+
try {
|
|
101
|
+
skillFile = readFileSync(skill.path, "utf8");
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
skillFile = null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const user = explainUserPrompt(skill, skillFile, groups);
|
|
108
|
+
let result;
|
|
109
|
+
try {
|
|
110
|
+
result = await deps.model.callTool(EXPLAIN_SYSTEM, user, EXPLAIN_TOOL);
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
return fail("explain failures", profileFailureCode(error));
|
|
114
|
+
}
|
|
115
|
+
if (!result.success)
|
|
116
|
+
return fail("explain failures", profileResultCode(result));
|
|
117
|
+
const parsed = ExplainReply.safeParse(result.data);
|
|
118
|
+
if (!parsed.success)
|
|
119
|
+
return fail("explain failures", "invalid-reply");
|
|
120
|
+
return {
|
|
121
|
+
ok: true,
|
|
122
|
+
explanation: {
|
|
123
|
+
summary: parsed.data.summary.trim(),
|
|
124
|
+
causes: parsed.data.causes.map((cause) => ({
|
|
125
|
+
kind: cause.kind,
|
|
126
|
+
explanation: cause.explanation.trim(),
|
|
127
|
+
actions: cause.actions.map((action) => action.trim()).filter(Boolean),
|
|
128
|
+
})),
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { SkillIndex } from "./skill-index.js";
|
|
2
|
+
import type { SkillStore } from "./store.js";
|
|
3
|
+
/** The server sends at most this many characters of one call's input (design §3). */
|
|
4
|
+
export declare const FAILURE_INPUT_CAP = 20000;
|
|
5
|
+
/** A group key is the first line of the error text, cut to this length (design §4). */
|
|
6
|
+
export declare const FAILURE_LINE_CHARS = 200;
|
|
7
|
+
/** How many of a group's latest calls the reply carries. */
|
|
8
|
+
export declare const FAILURE_SAMPLE = 5;
|
|
9
|
+
/** How many groups the reply carries, most calls first. */
|
|
10
|
+
export declare const FAILURE_GROUP_LIMIT = 50;
|
|
11
|
+
/** One failed tool call of a turn, as the dialog shows it. */
|
|
12
|
+
export interface TurnFailure {
|
|
13
|
+
position: number;
|
|
14
|
+
name: string;
|
|
15
|
+
input: string;
|
|
16
|
+
/** True when the stored input was longer than `FAILURE_INPUT_CAP`. */
|
|
17
|
+
inputCut: boolean;
|
|
18
|
+
/** Null when the host reported no error text. */
|
|
19
|
+
error: string | null;
|
|
20
|
+
}
|
|
21
|
+
/** One failed call of a group: the call fields plus its turn's time and project. */
|
|
22
|
+
export interface FailureGroupCall extends TurnFailure {
|
|
23
|
+
turnId: number;
|
|
24
|
+
createdAt: number;
|
|
25
|
+
projectName: string | null;
|
|
26
|
+
}
|
|
27
|
+
/** The failed calls of one skill that count against it, grouped by tool and first error line. */
|
|
28
|
+
export interface FailureGroup {
|
|
29
|
+
/** The tool name. */
|
|
30
|
+
name: string;
|
|
31
|
+
/** The first line of the error text, cut to `FAILURE_LINE_CHARS`; null when no error text. */
|
|
32
|
+
errorLine: string | null;
|
|
33
|
+
/** Calls in the group. */
|
|
34
|
+
calls: number;
|
|
35
|
+
/** Turns the calls span. */
|
|
36
|
+
turns: number;
|
|
37
|
+
/** When the newest call of the group happened. */
|
|
38
|
+
lastSeenAt: number;
|
|
39
|
+
/** The latest `FAILURE_SAMPLE` calls, newest first. */
|
|
40
|
+
latest: FailureGroupCall[];
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Every failed call of one turn, ordered by position. Null when the turn does
|
|
44
|
+
* not exist; an empty list when it has no failed call. A missing `skills.db`
|
|
45
|
+
* gives an empty list and stays missing.
|
|
46
|
+
*/
|
|
47
|
+
export declare function turnFailures(store: SkillStore, turnId: number): Promise<TurnFailure[] | null>;
|
|
48
|
+
/**
|
|
49
|
+
* The failed calls of one skill that count against it by the ranking rule,
|
|
50
|
+
* grouped by tool name and the first line of the error text, most calls first.
|
|
51
|
+
* At most `FAILURE_GROUP_LIMIT` groups come back, each with its latest calls.
|
|
52
|
+
* A missing `skills.db` gives an empty list and stays missing.
|
|
53
|
+
*/
|
|
54
|
+
export declare function skillFailureGroups(store: SkillStore, skill: {
|
|
55
|
+
name: string;
|
|
56
|
+
path: string | null;
|
|
57
|
+
}, index: SkillIndex): Promise<FailureGroup[]>;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { skillUsesFor } from "./turn-summaries.js";
|
|
2
|
+
/** The server sends at most this many characters of one call's input (design §3). */
|
|
3
|
+
export const FAILURE_INPUT_CAP = 20_000;
|
|
4
|
+
/** A group key is the first line of the error text, cut to this length (design §4). */
|
|
5
|
+
export const FAILURE_LINE_CHARS = 200;
|
|
6
|
+
/** How many of a group's latest calls the reply carries. */
|
|
7
|
+
export const FAILURE_SAMPLE = 5;
|
|
8
|
+
/** How many groups the reply carries, most calls first. */
|
|
9
|
+
export const FAILURE_GROUP_LIMIT = 50;
|
|
10
|
+
// An empty error text is no error text, as a null one is: both show as
|
|
11
|
+
// "No error text recorded". The first line of the error is the group key; a
|
|
12
|
+
// trailing carriage return from CRLF text is not part of the key.
|
|
13
|
+
const NO_ERROR = "tc.error IS NULL OR tc.error = ''";
|
|
14
|
+
const LINE = `CASE WHEN ${NO_ERROR} THEN NULL
|
|
15
|
+
ELSE rtrim(substr(substr(tc.error, 1, instr(tc.error || char(10), char(10)) - 1), 1, ${FAILURE_LINE_CHARS}), char(13)) END`;
|
|
16
|
+
const CALL_FIELDS = `
|
|
17
|
+
tc.position, tc.name,
|
|
18
|
+
substr(tc.input, 1, ${FAILURE_INPUT_CAP}) AS input,
|
|
19
|
+
length(tc.input) > ${FAILURE_INPUT_CAP} AS input_cut,
|
|
20
|
+
CASE WHEN ${NO_ERROR} THEN NULL ELSE tc.error END AS error`;
|
|
21
|
+
function callRow(row) {
|
|
22
|
+
return {
|
|
23
|
+
position: Number(row.position),
|
|
24
|
+
name: String(row.name),
|
|
25
|
+
input: String(row.input),
|
|
26
|
+
inputCut: Number(row.input_cut) === 1,
|
|
27
|
+
error: row.error == null ? null : String(row.error),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Every failed call of one turn, ordered by position. Null when the turn does
|
|
32
|
+
* not exist; an empty list when it has no failed call. A missing `skills.db`
|
|
33
|
+
* gives an empty list and stays missing.
|
|
34
|
+
*/
|
|
35
|
+
export async function turnFailures(store, turnId) {
|
|
36
|
+
const db = await store.existingDb();
|
|
37
|
+
if (!db)
|
|
38
|
+
return [];
|
|
39
|
+
const turn = await db.get("SELECT id FROM turns WHERE id = ?", [turnId]);
|
|
40
|
+
if (!turn)
|
|
41
|
+
return null;
|
|
42
|
+
const rows = await db.all(`SELECT ${CALL_FIELDS} FROM tool_calls tc
|
|
43
|
+
WHERE tc.turn_id = ? AND tc.outcome = 'error' ORDER BY tc.position, tc.id`, [turnId]);
|
|
44
|
+
return rows.map(callRow);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The failed calls of one skill that count against it by the ranking rule,
|
|
48
|
+
* grouped by tool name and the first line of the error text, most calls first.
|
|
49
|
+
* At most `FAILURE_GROUP_LIMIT` groups come back, each with its latest calls.
|
|
50
|
+
* A missing `skills.db` gives an empty list and stays missing.
|
|
51
|
+
*/
|
|
52
|
+
export async function skillFailureGroups(store, skill, index) {
|
|
53
|
+
const db = await store.existingDb();
|
|
54
|
+
if (!db)
|
|
55
|
+
return [];
|
|
56
|
+
const uses = skillUsesFor(skill, index);
|
|
57
|
+
// The earliest use of this skill only, as the ranking measures it; another
|
|
58
|
+
// skill used earlier in the same turn does not move the line.
|
|
59
|
+
const firstUse = `SELECT u.turn_id, MIN(COALESCE(u.position, -1)) AS position
|
|
60
|
+
FROM (${uses.sql}) u GROUP BY u.turn_id`;
|
|
61
|
+
const failed = `SELECT tc.id, tc.turn_id, tc.position, tc.name, t.created_at, t.project_name, ${LINE} AS line,
|
|
62
|
+
substr(tc.input, 1, ${FAILURE_INPUT_CAP}) AS input,
|
|
63
|
+
length(tc.input) > ${FAILURE_INPUT_CAP} AS input_cut,
|
|
64
|
+
CASE WHEN ${NO_ERROR} THEN NULL ELSE tc.error END AS error
|
|
65
|
+
FROM tool_calls tc
|
|
66
|
+
JOIN turns t ON t.id = tc.turn_id
|
|
67
|
+
JOIN (${firstUse}) f ON f.turn_id = tc.turn_id
|
|
68
|
+
WHERE tc.outcome = 'error' AND tc.position > f.position`;
|
|
69
|
+
const top = `SELECT name, line, COUNT(*) AS calls, COUNT(DISTINCT turn_id) AS turn_count,
|
|
70
|
+
MAX(created_at) AS last_seen
|
|
71
|
+
FROM (${failed}) GROUP BY name, line
|
|
72
|
+
ORDER BY calls DESC, last_seen DESC, name, line LIMIT ${FAILURE_GROUP_LIMIT}`;
|
|
73
|
+
const groups = await db.all(top, uses.args);
|
|
74
|
+
if (groups.length === 0)
|
|
75
|
+
return [];
|
|
76
|
+
// The latest calls of the returned groups in one query: window functions work in the
|
|
77
|
+
// store's libSQL (checked for this change), so no JavaScript fallback is needed.
|
|
78
|
+
const latest = await db.all(`SELECT turn_id, created_at, project_name, position, name, line, input, input_cut, error
|
|
79
|
+
FROM (SELECT g.*, ROW_NUMBER() OVER (PARTITION BY g.name, g.line ORDER BY g.created_at DESC, g.id DESC) AS rn
|
|
80
|
+
FROM (${failed}) g
|
|
81
|
+
JOIN (${top}) k ON k.name = g.name AND k.line IS g.line)
|
|
82
|
+
WHERE rn <= ${FAILURE_SAMPLE} ORDER BY name, line, rn`, [...uses.args, ...uses.args]);
|
|
83
|
+
const byGroup = new Map();
|
|
84
|
+
for (const row of latest) {
|
|
85
|
+
const key = JSON.stringify([String(row.name), row.line == null ? null : String(row.line)]);
|
|
86
|
+
const list = byGroup.get(key) ?? [];
|
|
87
|
+
list.push({
|
|
88
|
+
...callRow(row),
|
|
89
|
+
turnId: Number(row.turn_id),
|
|
90
|
+
createdAt: Number(row.created_at),
|
|
91
|
+
projectName: row.project_name == null ? null : String(row.project_name),
|
|
92
|
+
});
|
|
93
|
+
byGroup.set(key, list);
|
|
94
|
+
}
|
|
95
|
+
return groups.map((group) => ({
|
|
96
|
+
name: String(group.name),
|
|
97
|
+
errorLine: group.line == null ? null : String(group.line),
|
|
98
|
+
calls: Number(group.calls),
|
|
99
|
+
turns: Number(group.turn_count),
|
|
100
|
+
lastSeenAt: Number(group.last_seen),
|
|
101
|
+
latest: byGroup.get(JSON.stringify([String(group.name), group.line == null ? null : String(group.line)])) ?? [],
|
|
102
|
+
}));
|
|
103
|
+
}
|
|
@@ -12,6 +12,14 @@ export interface SkillTurnSummary {
|
|
|
12
12
|
promptPreview: string;
|
|
13
13
|
failedToolCalls: number;
|
|
14
14
|
}
|
|
15
|
+
/** The SQL that selects a skill's uses (turn id and position), and its parameters. */
|
|
16
|
+
export declare function skillUsesFor(skill: {
|
|
17
|
+
name: string;
|
|
18
|
+
path: string | null;
|
|
19
|
+
}, index: SkillIndex): {
|
|
20
|
+
sql: string;
|
|
21
|
+
args: (string | number)[];
|
|
22
|
+
};
|
|
15
23
|
/**
|
|
16
24
|
* Turns that used one skill, newest first, `limit` of them after skipping
|
|
17
25
|
* `offset`. `path` is null for a skill that no watched folder holds. A missing
|
|
@@ -17,16 +17,16 @@ 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 su.turn_id FROM skill_uses su
|
|
20
|
+
SELECT su.turn_id, su.position FROM skill_uses su
|
|
21
21
|
WHERE su.skill_path = ? OR (su.skill_path IS NULL AND su.skill_name = ? AND ? = 1)`;
|
|
22
22
|
const BY_NAME = `
|
|
23
|
-
SELECT su.turn_id FROM skill_uses su
|
|
23
|
+
SELECT su.turn_id, su.position FROM skill_uses su
|
|
24
24
|
WHERE su.skill_name = ? AND (
|
|
25
25
|
(su.skill_path IS NOT NULL AND su.skill_path NOT IN (SELECT value FROM json_each(?)))
|
|
26
26
|
OR (su.skill_path IS NULL AND ? = 0)
|
|
27
27
|
)`;
|
|
28
|
-
/** The SQL that selects a skill's turn
|
|
29
|
-
function
|
|
28
|
+
/** The SQL that selects a skill's uses (turn id and position), and its parameters. */
|
|
29
|
+
export function skillUsesFor(skill, index) {
|
|
30
30
|
const named = index.entries.filter((e) => e.name === skill.name);
|
|
31
31
|
return skill.path
|
|
32
32
|
? {
|
|
@@ -42,6 +42,11 @@ function turnIdsFor(skill, index) {
|
|
|
42
42
|
],
|
|
43
43
|
};
|
|
44
44
|
}
|
|
45
|
+
/** The SQL that selects a skill's turn ids, and its parameters. */
|
|
46
|
+
function turnIdsFor(skill, index) {
|
|
47
|
+
const uses = skillUsesFor(skill, index);
|
|
48
|
+
return { sql: `SELECT turn_id FROM (${uses.sql})`, args: uses.args };
|
|
49
|
+
}
|
|
45
50
|
/**
|
|
46
51
|
* Turns that used one skill, newest first, `limit` of them after skipping
|
|
47
52
|
* `offset`. `path` is null for a skill that no watched folder holds. A missing
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
|
+
import { CONFIG } from "../config.js";
|
|
2
3
|
import { RECORDING_OFF_TEXT, runSkillOperation, } from "../core/skill-operations.js";
|
|
3
4
|
import { log } from "./logger.js";
|
|
4
5
|
import { skillActivity, skillDaily } from "./skill-learning/activity.js";
|
|
5
6
|
import { parseActivityQuery, topParam } from "./skill-learning/activity-range.js";
|
|
6
7
|
import { undoProposal } from "./skill-learning/apply.js";
|
|
8
|
+
import { turnFailures, skillFailureGroups } from "./skill-learning/failures.js";
|
|
9
|
+
import { explainFailures } from "./skill-learning/failure-explain.js";
|
|
7
10
|
import { skillHistory, withCanUndo } from "./skill-learning/history.js";
|
|
8
11
|
import { skillOperationsDeps } from "./skill-learning/operations-deps.js";
|
|
9
12
|
import { getProposal, setSkillMode, } from "./skill-learning/proposal-store.js";
|
|
@@ -11,6 +14,7 @@ import { listProposalSummaries } from "./skill-learning/proposal-summaries.js";
|
|
|
11
14
|
import { intParam } from "./skill-learning/query-params.js";
|
|
12
15
|
import { SKILL_ORDERS, rankSkills } from "./skill-learning/ranking.js";
|
|
13
16
|
import { skillTurnSettingsFor } from "./skill-learning/settings-for.js";
|
|
17
|
+
import { skillModel } from "./skill-learning/skill-model.js";
|
|
14
18
|
import { getSkillIndex } from "./skill-learning/skill-index.js";
|
|
15
19
|
import { SkillStore, SkillStoreError, skillsDbPath } from "./skill-learning/store.js";
|
|
16
20
|
import { RECENT_TURN_LIMIT, countSkillTurns, recentSkillTurns, skillTurnsByIds, } from "./skill-learning/turn-summaries.js";
|
|
@@ -133,6 +137,25 @@ async function dispatch(path, method, url, readBody, ctx) {
|
|
|
133
137
|
if (path === "/api/skills/skill") {
|
|
134
138
|
return showSkill(url, { store, folders: settings.folders }, index(), now);
|
|
135
139
|
}
|
|
140
|
+
if (path === "/api/skills/failures") {
|
|
141
|
+
return showFailureGroups(url, store, index(), now);
|
|
142
|
+
}
|
|
143
|
+
const turnFailuresMatch = /^\/api\/skills\/turns\/([^/]+)\/failures$/.exec(path);
|
|
144
|
+
if (turnFailuresMatch) {
|
|
145
|
+
let id = "";
|
|
146
|
+
try {
|
|
147
|
+
id = decodeURIComponent(turnFailuresMatch[1]);
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
// A malformed percent sequence is not a number; the check below rejects "".
|
|
151
|
+
}
|
|
152
|
+
if (!/^\d+$/.test(id))
|
|
153
|
+
return fail("invalid-args", "The turn id must be a number");
|
|
154
|
+
const failures = await turnFailures(store, Number(id));
|
|
155
|
+
if (failures === null)
|
|
156
|
+
return fail("not-found", "No such turn");
|
|
157
|
+
return ok("ok", { failures });
|
|
158
|
+
}
|
|
136
159
|
if (path === "/api/skills/activity") {
|
|
137
160
|
const query = parseActivityQuery(url, now);
|
|
138
161
|
if ("error" in query)
|
|
@@ -179,6 +202,11 @@ async function dispatch(path, method, url, readBody, ctx) {
|
|
|
179
202
|
return setMode(await readBody(), store, index());
|
|
180
203
|
case "/api/skills/delete-turns":
|
|
181
204
|
return deleteTurns(await readBody(), store);
|
|
205
|
+
case "/api/skills/explain-failures":
|
|
206
|
+
// Stored text goes to the external model, so recording must be on.
|
|
207
|
+
if (!settings.enabled)
|
|
208
|
+
return fail("recording-off", RECORDING_OFF_TEXT);
|
|
209
|
+
return explainFailureGroups(await readBody(), store, index(), now);
|
|
182
210
|
case "/api/skills/record-past":
|
|
183
211
|
return recordPast(settings.enabled, ctx);
|
|
184
212
|
}
|
|
@@ -205,6 +233,30 @@ async function showSkill(url, deps, index, now) {
|
|
|
205
233
|
: [],
|
|
206
234
|
});
|
|
207
235
|
}
|
|
236
|
+
async function showFailureGroups(url, store, index, now) {
|
|
237
|
+
const path = text(url.searchParams.get("path"));
|
|
238
|
+
const name = text(url.searchParams.get("name"));
|
|
239
|
+
if (!path && !name)
|
|
240
|
+
return fail("invalid-args", "Give path or name");
|
|
241
|
+
const skill = (await rankSkills(store, index, now)).find((s) => path ? s.path === path : s.path === null && s.name === name);
|
|
242
|
+
if (!skill)
|
|
243
|
+
return fail("not-found", "No such skill");
|
|
244
|
+
return ok("ok", { groups: await skillFailureGroups(store, skill, index) });
|
|
245
|
+
}
|
|
246
|
+
/** Explains one skill's failure groups through the drafting model path. The reply is shown, never saved. */
|
|
247
|
+
async function explainFailureGroups(body, store, index, now) {
|
|
248
|
+
const path = text(body.path);
|
|
249
|
+
const name = text(body.name);
|
|
250
|
+
if (!path && !name)
|
|
251
|
+
return fail("invalid-args", "Give path or name");
|
|
252
|
+
const skill = (await rankSkills(store, index, now)).find((s) => path ? s.path === path : s.path === null && s.name === name);
|
|
253
|
+
if (!skill)
|
|
254
|
+
return fail("not-found", "No such skill");
|
|
255
|
+
const result = await explainFailures(skill, { store, index, model: skillModel(CONFIG) });
|
|
256
|
+
if (!result.ok)
|
|
257
|
+
return fail(result.code, result.message);
|
|
258
|
+
return ok("explained", { explanation: result.explanation });
|
|
259
|
+
}
|
|
208
260
|
async function proposals(url, store, folders) {
|
|
209
261
|
const state = text(url.searchParams.get("state"));
|
|
210
262
|
const skill = text(url.searchParams.get("skill"));
|
|
@@ -70,6 +70,17 @@ export declare const START_LOCK_STALE_MS = 20000;
|
|
|
70
70
|
export declare function startLockPath(home?: string): string;
|
|
71
71
|
/** Lock files are private to the user (0600 in a 0700 folder), like the token file. */
|
|
72
72
|
export declare const nodeLockFs: LockFs;
|
|
73
|
+
/**
|
|
74
|
+
* `nodeLockFs.replace`: write the temp file, rename it over the target, and
|
|
75
|
+
* when the rename reports ENOENT for a temp file that was just written,
|
|
76
|
+
* rewrite it and try again, up to `rewrites` times.
|
|
77
|
+
*/
|
|
78
|
+
export declare function replaceRetrying(path: string, text: string, deps?: {
|
|
79
|
+
mkdir?: (dir: string) => void;
|
|
80
|
+
write?: (path: string, text: string) => void;
|
|
81
|
+
rename?: (from: string, to: string) => void;
|
|
82
|
+
rewrites?: number;
|
|
83
|
+
}, rewrites?: number): void;
|
|
73
84
|
/** `renameSync` that retries while Windows reports the target busy. */
|
|
74
85
|
export declare function renameRetrying(from: string, to: string, rename?: (from: string, to: string) => void, platform?: NodeJS.Platform, wait?: (ms: number) => void): void;
|
|
75
86
|
/** Remove the start lock when it names `pid`. A restart copy calls this once it owns the port. */
|
|
@@ -70,16 +70,45 @@ export const nodeLockFs = {
|
|
|
70
70
|
}
|
|
71
71
|
},
|
|
72
72
|
replace(path, text) {
|
|
73
|
-
|
|
74
|
-
const temp = `${path}.${process.pid}.tmp`;
|
|
75
|
-
writeFileSync(temp, text, { mode: 0o600 });
|
|
76
|
-
renameRetrying(temp, path);
|
|
73
|
+
replaceRetrying(path, text);
|
|
77
74
|
},
|
|
78
75
|
};
|
|
79
76
|
// Windows can refuse a link or rename while another process has the file open.
|
|
80
77
|
// Bound retries so a permanent permission error still reaches the caller.
|
|
81
78
|
const WINDOWS_FILE_RETRY_WAITS_MS = [1, 2, 5, 10, 20, 50, 100, 200];
|
|
82
79
|
const WINDOWS_FILE_BUSY_CODES = new Set(["EPERM", "EACCES", "EBUSY"]);
|
|
80
|
+
/**
|
|
81
|
+
* How many times `replaceRetrying` rewrites its temp file when `rename`
|
|
82
|
+
* reports ENOENT for a file that was just written. Bun's rename can
|
|
83
|
+
* intermittently do that (see docs/tdr/030-intermittent-bun-rename-failure.md),
|
|
84
|
+
* which made tests/web-ensure.test.ts fail at random under load. Rewriting
|
|
85
|
+
* and retrying heals that without weakening a genuine failure, which keeps
|
|
86
|
+
* failing past this budget and still reaches the caller.
|
|
87
|
+
*/
|
|
88
|
+
const REPLACE_ENOENT_REWRITES = 3;
|
|
89
|
+
/**
|
|
90
|
+
* `nodeLockFs.replace`: write the temp file, rename it over the target, and
|
|
91
|
+
* when the rename reports ENOENT for a temp file that was just written,
|
|
92
|
+
* rewrite it and try again, up to `rewrites` times.
|
|
93
|
+
*/
|
|
94
|
+
export function replaceRetrying(path, text, deps = {}, rewrites = deps.rewrites ?? REPLACE_ENOENT_REWRITES) {
|
|
95
|
+
const mkdir = deps.mkdir ?? ((dir) => mkdirSync(dir, { recursive: true, mode: 0o700 }));
|
|
96
|
+
const write = deps.write ?? ((file, body) => writeFileSync(file, body, { mode: 0o600 }));
|
|
97
|
+
const rename = deps.rename ?? ((from, to) => renameRetrying(from, to));
|
|
98
|
+
mkdir(dirname(path));
|
|
99
|
+
const temp = `${path}.${process.pid}.tmp`;
|
|
100
|
+
for (let attempt = 0;; attempt++) {
|
|
101
|
+
write(temp, text);
|
|
102
|
+
try {
|
|
103
|
+
rename(temp, path);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
if (error.code !== "ENOENT" || attempt >= rewrites)
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
83
112
|
/** `renameSync` that retries while Windows reports the target busy. */
|
|
84
113
|
export function renameRetrying(from, to, rename = renameSync, platform = process.platform, wait = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)) {
|
|
85
114
|
for (let attempt = 0;; attempt++) {
|