om-memory-system 4.35.0-next.193 → 5.1.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.
@@ -1,34 +1,9 @@
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
- }
1
+ import { checkUndo } from "./apply.js";
2
+ import { countDiffLines } from "./line-diff.js";
3
+ import { listProposalRows, proposalLineCounts, } from "./proposal-summaries.js";
4
+ export { countDiffLines };
29
5
  function entriesOf(proposal, versions) {
30
- const counts = countDiffLines(proposal.diff);
31
- const base = { proposalId: proposal.id, summary: proposal.summary ?? "", ...counts };
6
+ const base = { proposalId: proposal.id, summary: proposal.summary ?? "" };
32
7
  const entries = [
33
8
  {
34
9
  ...base,
@@ -48,7 +23,7 @@ function entriesOf(proposal, versions) {
48
23
  kind: proposal.state,
49
24
  at: proposal.updatedAt,
50
25
  appliedBy: applied ? proposal.appliedBy : null,
51
- versionId: applied && version !== undefined ? String(version) : null,
26
+ versionId: applied && version !== undefined ? String(version.id) : null,
52
27
  });
53
28
  }
54
29
  return entries;
@@ -64,32 +39,78 @@ export async function skillHistory(deps, skill, limit, offset) {
64
39
  // A name without a path is a skill outside the watched folders. Its proposals
65
40
  // have no path, so a watched skill that shares the name stays out.
66
41
  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));
42
+ ? await listProposalRows(deps.store, { skillPath: skill.path })
43
+ : (await listProposalRows(deps.store, { skillName: skill.name })).filter((p) => !p.skillPath);
44
+ const versions = await latestVersionFacts(deps, proposals.filter((p) => p.state === "applied").map((p) => p.id));
70
45
  const all = proposals.flatMap((p) => entriesOf(p, versions));
71
46
  // At the same time, the later step (applied, rejected) sorts above `created`.
72
47
  all.sort((a, b) => b.at - a.at || +(b.kind !== "created") - +(a.kind !== "created"));
73
- const appliedVersionIds = [...versions.values()];
48
+ const appliedVersionIds = [...versions.values()].map((v) => v.id);
74
49
  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;
50
+ // Diffs are read for the proposals on this page only.
51
+ const counts = await proposalLineCounts(deps.store, [...new Set(page.map((e) => e.proposalId))]);
52
+ const readOnly = deps.store.isReadOnly();
53
+ const entries = page.map((entry) => {
54
+ const version = entry.kind === "applied" ? (versions.get(entry.proposalId) ?? null) : null;
55
+ // A change with no diff, such as a new skill, keeps 0 and 0 here.
56
+ const lines = counts.get(entry.proposalId);
77
57
  return {
78
58
  ...entry,
79
- canUndo: version !== null && (await canUndoProposal(entry.proposalId, deps)),
80
- laterAppliedCount: version === null ? 0 : appliedVersionIds.filter((id) => id > version).length,
59
+ added: lines?.hasDiff ? lines.added : 0,
60
+ removed: lines?.hasDiff ? lines.removed : 0,
61
+ canUndo: entry.kind === "applied" &&
62
+ checkUndo({
63
+ id: entry.proposalId,
64
+ readOnly,
65
+ state: "applied",
66
+ version,
67
+ folders: deps.folders,
68
+ }).ok,
69
+ laterAppliedCount: version === null ? 0 : appliedVersionIds.filter((id) => id > version.id).length,
81
70
  };
82
- }));
71
+ });
83
72
  return { total: all.length, entries };
84
73
  }
74
+ /**
75
+ * The id, path and hash of the newest saved version for each proposal id, in
76
+ * one query. It names its columns, so the saved previous content is not read.
77
+ */
78
+ async function latestVersionFacts(deps, ids) {
79
+ const db = await deps.store.existingDb();
80
+ const found = new Map();
81
+ if (!db || ids.length === 0)
82
+ return found;
83
+ const rows = await db.all(`SELECT v.id, v.proposal_id, v.path, v.written_hash FROM skill_versions v
84
+ JOIN (SELECT MAX(id) AS id FROM skill_versions
85
+ WHERE proposal_id IN (SELECT value FROM json_each(?)) GROUP BY proposal_id) m
86
+ ON v.id = m.id`, [JSON.stringify(ids)]);
87
+ for (const row of rows) {
88
+ found.set(String(row.proposal_id), {
89
+ id: Number(row.id),
90
+ path: String(row.path),
91
+ writtenHash: String(row.written_hash),
92
+ });
93
+ }
94
+ return found;
95
+ }
85
96
  /**
86
97
  * 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.
98
+ * would accept now, so false once a later change rewrote the file. Reads the
99
+ * newest saved versions of all applied proposals in one query, then one file
100
+ * hash per applied proposal, and nothing for the rest.
89
101
  */
90
102
  export async function withCanUndo(deps, proposals) {
91
- return Promise.all(proposals.map(async (p) => ({
103
+ const versions = await latestVersionFacts(deps, proposals.filter((p) => p.state === "applied").map((p) => p.id));
104
+ const readOnly = deps.store.isReadOnly();
105
+ return proposals.map((p) => ({
92
106
  ...p,
93
- canUndo: p.state === "applied" && (await canUndoProposal(p.id, deps)),
94
- })));
107
+ canUndo: p.state === "applied" &&
108
+ checkUndo({
109
+ id: p.id,
110
+ readOnly,
111
+ state: p.state,
112
+ version: versions.get(p.id) ?? null,
113
+ folders: deps.folders,
114
+ }).ok,
115
+ }));
95
116
  }
@@ -4,3 +4,8 @@
4
4
  * texts have the same lines. The web page renders it as text.
5
5
  */
6
6
  export declare function lineDiff(before: string, after: string): string;
7
+ /** Added and removed line counts of a unified-style line diff. Hunk headers are skipped. */
8
+ export declare function countDiffLines(diff: string | null): {
9
+ added: number;
10
+ removed: number;
11
+ };
@@ -92,3 +92,17 @@ export function lineDiff(before, after) {
92
92
  }
93
93
  return out.join("\n");
94
94
  }
95
+ /** Added and removed line counts of a unified-style line diff. Hunk headers are skipped. */
96
+ export function countDiffLines(diff) {
97
+ let added = 0;
98
+ let removed = 0;
99
+ for (const line of (diff ?? "").split("\n")) {
100
+ if (line.startsWith("@@"))
101
+ continue;
102
+ if (line.startsWith("+"))
103
+ added++;
104
+ else if (line.startsWith("-"))
105
+ removed++;
106
+ }
107
+ return { added, removed };
108
+ }
@@ -55,6 +55,8 @@ export interface SkillSettings {
55
55
  lastReviewAt: number | null;
56
56
  usesAtLastReview: number;
57
57
  }
58
+ /** The turn ids in an `evidence_turn_ids` column. A damaged list reads as no evidence. */
59
+ export declare function parseEvidenceIds(raw: unknown): number[];
58
60
  /** Saves a pending proposal with a new id. */
59
61
  export declare function createProposal(store: SkillStore, input: NewSkillProposal, now: number): Promise<SkillProposal>;
60
62
  export declare function getProposal(store: SkillStore, id: string): Promise<SkillProposal | null>;
@@ -63,8 +65,13 @@ export interface ProposalFilter {
63
65
  skillName?: string;
64
66
  skillPath?: string;
65
67
  }
66
- /** Proposals, newest first. */
67
- export declare function listProposals(store: SkillStore, filter?: ProposalFilter): Promise<SkillProposal[]>;
68
+ /** The shared order of every proposal list: newest first, ties by insertion. */
69
+ export declare const PROPOSAL_ORDER = "ORDER BY created_at DESC, rowid DESC";
70
+ /** The `WHERE` clause and its arguments for a filter. Empty when the filter is empty. */
71
+ export declare function proposalFilterClause(filter: ProposalFilter): {
72
+ clause: string;
73
+ args: string[];
74
+ };
68
75
  /**
69
76
  * Sets the state and the update time. `appliedBy` is written when given and
70
77
  * kept otherwise. Returns false when no proposal has this id.
@@ -9,16 +9,20 @@ async function writableDb(store) {
9
9
  function text(value) {
10
10
  return value === null || value === undefined ? null : String(value);
11
11
  }
12
- function toProposal(row) {
13
- let evidence = [];
12
+ /** The turn ids in an `evidence_turn_ids` column. A damaged list reads as no evidence. */
13
+ export function parseEvidenceIds(raw) {
14
14
  try {
15
- const parsed = JSON.parse(String(row.evidence_turn_ids));
15
+ const parsed = JSON.parse(String(raw));
16
16
  if (Array.isArray(parsed))
17
- evidence = parsed.map(Number);
17
+ return parsed.map(Number);
18
18
  }
19
19
  catch {
20
20
  // A damaged list reads as no evidence.
21
21
  }
22
+ return [];
23
+ }
24
+ function toProposal(row) {
25
+ const evidence = parseEvidenceIds(row.evidence_turn_ids);
22
26
  return {
23
27
  id: String(row.id),
24
28
  kind: String(row.kind),
@@ -76,11 +80,10 @@ export async function getProposal(store, id) {
76
80
  const row = await db.get("SELECT * FROM skill_proposals WHERE id = ?", [id]);
77
81
  return row ? toProposal(row) : null;
78
82
  }
79
- /** Proposals, newest first. */
80
- export async function listProposals(store, filter = {}) {
81
- const db = await store.existingDb();
82
- if (!db)
83
- return [];
83
+ /** The shared order of every proposal list: newest first, ties by insertion. */
84
+ export const PROPOSAL_ORDER = "ORDER BY created_at DESC, rowid DESC";
85
+ /** The `WHERE` clause and its arguments for a filter. Empty when the filter is empty. */
86
+ export function proposalFilterClause(filter) {
84
87
  const where = [];
85
88
  const args = [];
86
89
  if (filter.state) {
@@ -95,9 +98,7 @@ export async function listProposals(store, filter = {}) {
95
98
  where.push("skill_path = ?");
96
99
  args.push(filter.skillPath);
97
100
  }
98
- const clause = where.length > 0 ? `WHERE ${where.join(" AND ")}` : "";
99
- const rows = await db.all(`SELECT * FROM skill_proposals ${clause} ORDER BY created_at DESC, rowid DESC`, args);
100
- return rows.map(toProposal);
101
+ return { clause: where.length > 0 ? `WHERE ${where.join(" AND ")}` : "", args };
101
102
  }
102
103
  /**
103
104
  * Sets the state and the update time. `appliedBy` is written when given and
@@ -0,0 +1,52 @@
1
+ import { type ProposalFilter, type SkillAppliedBy, type SkillDraftedBy, type SkillProposalKind, type SkillProposalState } from "./proposal-store.js";
2
+ import type { SkillStore } from "./store.js";
3
+ /**
4
+ * Proposal lists without the proposed content or the diff (TDR-059). Those two
5
+ * columns are the bulk of a proposal, so a list names its columns and reads
6
+ * them only to count lines, and only for the proposals it returns. Nothing
7
+ * here reads `CONFIG`.
8
+ */
9
+ /** A proposal as a list shows it, before line counts. */
10
+ export interface SkillProposalRow {
11
+ id: string;
12
+ kind: SkillProposalKind;
13
+ skillName: string;
14
+ skillPath: string | null;
15
+ state: SkillProposalState;
16
+ appliedBy: SkillAppliedBy | null;
17
+ draftedBy: SkillDraftedBy;
18
+ summary: string | null;
19
+ evidenceTurnIds: number[];
20
+ createdAt: number;
21
+ updatedAt: number;
22
+ }
23
+ export interface LineCounts {
24
+ added: number;
25
+ removed: number;
26
+ /** False for a proposal with no diff, such as a new skill. */
27
+ hasDiff: boolean;
28
+ }
29
+ /** A listed proposal: the row and its line counts. `withCanUndo` adds `canUndo`. */
30
+ export interface SkillProposalSummary extends SkillProposalRow, LineCounts {
31
+ }
32
+ export interface ProposalPage {
33
+ limit: number;
34
+ offset: number;
35
+ }
36
+ /** Proposals without content or diff, newest first. */
37
+ export declare function listProposalRows(store: SkillStore, filter?: ProposalFilter): Promise<SkillProposalRow[]>;
38
+ /**
39
+ * Added and removed lines for the given proposal ids. Reads the diff, and the
40
+ * content only where there is no diff. A proposal with no diff counts every
41
+ * content line as added. Unknown ids are left out.
42
+ */
43
+ export declare function proposalLineCounts(store: SkillStore, ids: readonly string[]): Promise<Map<string, LineCounts>>;
44
+ /**
45
+ * Summaries of the proposals that match the filter, newest first, with line
46
+ * counts for the returned page only. `total` counts every match. Without a
47
+ * page the whole list comes back.
48
+ */
49
+ export declare function listProposalSummaries(store: SkillStore, filter?: ProposalFilter, page?: ProposalPage): Promise<{
50
+ proposals: SkillProposalSummary[];
51
+ total: number;
52
+ }>;
@@ -0,0 +1,73 @@
1
+ import { countDiffLines } from "./line-diff.js";
2
+ import { PROPOSAL_ORDER, parseEvidenceIds, proposalFilterClause, } from "./proposal-store.js";
3
+ const LIST_COLUMNS = "id, kind, skill_name, skill_path, state, applied_by, drafted_by, summary, evidence_turn_ids, created_at, updated_at";
4
+ function text(value) {
5
+ return value === null || value === undefined ? null : String(value);
6
+ }
7
+ function toRow(row) {
8
+ return {
9
+ id: String(row.id),
10
+ kind: String(row.kind),
11
+ skillName: String(row.skill_name),
12
+ skillPath: text(row.skill_path),
13
+ state: String(row.state),
14
+ appliedBy: text(row.applied_by),
15
+ draftedBy: row.drafted_by === "agent" ? "agent" : "model",
16
+ summary: text(row.summary),
17
+ evidenceTurnIds: parseEvidenceIds(row.evidence_turn_ids),
18
+ createdAt: Number(row.created_at),
19
+ updatedAt: Number(row.updated_at),
20
+ };
21
+ }
22
+ /** Proposals without content or diff, newest first. */
23
+ export async function listProposalRows(store, filter = {}) {
24
+ const db = await store.existingDb();
25
+ if (!db)
26
+ return [];
27
+ const { clause, args } = proposalFilterClause(filter);
28
+ const rows = await db.all(`SELECT ${LIST_COLUMNS} FROM skill_proposals ${clause} ${PROPOSAL_ORDER}`, args);
29
+ return rows.map(toRow);
30
+ }
31
+ /** Lines of a new skill's content. A blank body has none, as on the Skills page. */
32
+ function contentLines(content) {
33
+ return content.trim() ? content.split("\n").length : 0;
34
+ }
35
+ /**
36
+ * Added and removed lines for the given proposal ids. Reads the diff, and the
37
+ * content only where there is no diff. A proposal with no diff counts every
38
+ * content line as added. Unknown ids are left out.
39
+ */
40
+ export async function proposalLineCounts(store, ids) {
41
+ const counts = new Map();
42
+ const db = await store.existingDb();
43
+ if (!db || ids.length === 0)
44
+ return counts;
45
+ // The diff text is read here and counted in JavaScript. Counting inside SQLite was
46
+ // measured 20 ms slower at 5,000 proposals, because `replace()` copies each diff.
47
+ const rows = await db.all(`SELECT id, diff, CASE WHEN diff IS NULL THEN proposed_content END AS content
48
+ FROM skill_proposals WHERE id IN (SELECT value FROM json_each(?))`, [JSON.stringify(ids)]);
49
+ for (const row of rows) {
50
+ const diff = text(row.diff);
51
+ counts.set(String(row.id), diff === null
52
+ ? { added: contentLines(String(row.content ?? "")), removed: 0, hasDiff: false }
53
+ : { ...countDiffLines(diff), hasDiff: true });
54
+ }
55
+ return counts;
56
+ }
57
+ /**
58
+ * Summaries of the proposals that match the filter, newest first, with line
59
+ * counts for the returned page only. `total` counts every match. Without a
60
+ * page the whole list comes back.
61
+ */
62
+ export async function listProposalSummaries(store, filter = {}, page) {
63
+ const all = await listProposalRows(store, filter);
64
+ const shown = page ? all.slice(page.offset, page.offset + page.limit) : all;
65
+ const counts = await proposalLineCounts(store, shown.map((p) => p.id));
66
+ return {
67
+ proposals: shown.map((p) => ({
68
+ ...p,
69
+ ...(counts.get(p.id) ?? { added: 0, removed: 0, hasDiff: false }),
70
+ })),
71
+ total: all.length,
72
+ };
73
+ }
@@ -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 ids, and its parameters. */
29
- function turnIdsFor(skill, index) {
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,15 +1,20 @@
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
- import { getProposal, listProposals, setSkillMode, } from "./skill-learning/proposal-store.js";
12
+ import { getProposal, setSkillMode, } from "./skill-learning/proposal-store.js";
13
+ import { listProposalSummaries } from "./skill-learning/proposal-summaries.js";
10
14
  import { intParam } from "./skill-learning/query-params.js";
11
15
  import { SKILL_ORDERS, rankSkills } from "./skill-learning/ranking.js";
12
16
  import { skillTurnSettingsFor } from "./skill-learning/settings-for.js";
17
+ import { skillModel } from "./skill-learning/skill-model.js";
13
18
  import { getSkillIndex } from "./skill-learning/skill-index.js";
14
19
  import { SkillStore, SkillStoreError, skillsDbPath } from "./skill-learning/store.js";
15
20
  import { RECENT_TURN_LIMIT, countSkillTurns, recentSkillTurns, skillTurnsByIds, } from "./skill-learning/turn-summaries.js";
@@ -132,6 +137,25 @@ async function dispatch(path, method, url, readBody, ctx) {
132
137
  if (path === "/api/skills/skill") {
133
138
  return showSkill(url, { store, folders: settings.folders }, index(), now);
134
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
+ }
135
159
  if (path === "/api/skills/activity") {
136
160
  const query = parseActivityQuery(url, now);
137
161
  if ("error" in query)
@@ -178,6 +202,11 @@ async function dispatch(path, method, url, readBody, ctx) {
178
202
  return setMode(await readBody(), store, index());
179
203
  case "/api/skills/delete-turns":
180
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);
181
210
  case "/api/skills/record-past":
182
211
  return recordPast(settings.enabled, ctx);
183
212
  }
@@ -200,10 +229,34 @@ async function showSkill(url, deps, index, now) {
200
229
  turnTotal: await countSkillTurns(store, skill, index),
201
230
  daily: await skillDaily(store, index, skill, now),
202
231
  proposals: skill.path
203
- ? await withCanUndo(deps, await listProposals(store, { skillPath: skill.path }))
232
+ ? await withCanUndo(deps, (await listProposalSummaries(store, { skillPath: skill.path })).proposals)
204
233
  : [],
205
234
  });
206
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
+ }
207
260
  async function proposals(url, store, folders) {
208
261
  const state = text(url.searchParams.get("state"));
209
262
  const skill = text(url.searchParams.get("skill"));
@@ -218,13 +271,13 @@ async function proposals(url, store, folders) {
218
271
  filter.skillPath = skill;
219
272
  else if (skill)
220
273
  filter.skillName = skill;
221
- const all = await listProposals(store, filter);
222
- // Without limit or offset the whole list comes back, as before.
274
+ // Without limit or offset the whole list comes back, as before. Summaries only: the
275
+ // content and diff come from the detail route.
223
276
  const offset = intParam(url, "offset", 0, Number.MAX_SAFE_INTEGER, 0);
224
- const limit = intParam(url, "limit", 1, MAX_PAGE, all.length || 1);
277
+ const limit = intParam(url, "limit", 1, MAX_PAGE, Number.MAX_SAFE_INTEGER);
225
278
  const paged = url.searchParams.has("limit") || url.searchParams.has("offset");
226
- const shown = paged ? all.slice(offset, offset + limit) : all;
227
- return ok("ok", { proposals: await withCanUndo({ store, folders }, shown), total: all.length });
279
+ const { proposals: shown, total } = await listProposalSummaries(store, filter, paged ? { limit, offset } : undefined);
280
+ return ok("ok", { proposals: await withCanUndo({ store, folders }, shown), total });
228
281
  }
229
282
  async function history(url, store, folders) {
230
283
  const path = text(url.searchParams.get("path"));