om-memory-system 4.34.0 → 5.0.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,6 +1,8 @@
1
1
  import { applyProposal } from "../services/skill-learning/apply.js";
2
2
  import { draftImprovement, draftNewSkill, findSkillIdeas, } from "../services/skill-learning/drafting.js";
3
- import { getProposal, listProposals, updateProposalState, } from "../services/skill-learning/proposal-store.js";
3
+ import { withCanUndo } from "../services/skill-learning/history.js";
4
+ import { getProposal, updateProposalState, } from "../services/skill-learning/proposal-store.js";
5
+ import { listProposalSummaries, } from "../services/skill-learning/proposal-summaries.js";
4
6
  import { SKILL_ORDERS, rankSkills, } from "../services/skill-learning/ranking.js";
5
7
  import { getSkillIndex } from "../services/skill-learning/skill-index.js";
6
8
  import { evidence, propose, undo } from "./skill-agent-modes.js";
@@ -193,11 +195,13 @@ async function proposals(args, deps) {
193
195
  if (state && !PROPOSAL_STATES.includes(state)) {
194
196
  return reply("proposals", false, "invalid-args", `state must be one of: ${PROPOSAL_STATES.join(", ")}.`);
195
197
  }
196
- const found = await listProposals(deps.store, state ? { state } : {});
198
+ // Summaries, not full proposals: the JSON goes into the agent's context. `show` gives the text.
199
+ const { proposals: found } = await listProposalSummaries(deps.store, state ? { state } : {});
197
200
  const text = found.length === 0
198
201
  ? `No ${state ? `${state} ` : ""}proposals.`
199
202
  : ["Proposals, newest first:", ...found.map(proposalLine)].join("\n");
200
- return reply("proposals", true, "ok", text, { proposals: found });
203
+ const listed = await withCanUndo({ store: deps.store, folders: deps.folders }, found);
204
+ return reply("proposals", true, "ok", text, { proposals: listed });
201
205
  }
202
206
  /** The proposal for `args.id`, or the reply that explains why there is none. */
203
207
  async function findProposal(mode, args, deps) {
@@ -23,7 +23,7 @@ export const SKILLS_TOOL_DESCRIPTION = [
23
23
  '"draft" (skill, or an idea from "ideas" {name, description, reason, evidenceTurnIds} with folder): the external API drafts a pending proposal; it changes no file.',
24
24
  'To fix a skill yourself: call "evidence" (skill, limit) for its SKILL.md and recorded turns, write the fixed SKILL.md, then call "propose" (skill, or idea with folder; content, summary, evidence turn ids). Show the user the diff.',
25
25
  "This path makes no external API call, but the recorded turn text enters this session.",
26
- '"proposals" (state), "show" (id): review proposals and their diffs.',
26
+ '"proposals" (state): list proposals as summaries, with line counts and no content or diff. "show" (id): one proposal with its diff and content.',
27
27
  '"apply" (id): write a pending proposal to its SKILL.md, only when the user asks. "reject" (id), "undo" (id), "help".',
28
28
  ].join(" ");
29
29
  /** JSON Schema for the tool input; the V2 adapter passes it as is. */
@@ -42,8 +42,35 @@ 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
+ /** What `checkUndo` needs. A list can read these for many proposals in one query. */
46
+ export interface UndoFacts {
47
+ id: string;
48
+ readOnly: boolean;
49
+ /** The proposal's state. */
50
+ state: string;
51
+ /** The newest saved version of the proposal, or null when it has none. */
52
+ version: {
53
+ path: string;
54
+ writtenHash: string;
55
+ } | null;
56
+ folders: readonly string[];
57
+ }
58
+ type UndoCheck = {
59
+ ok: true;
60
+ real: string;
61
+ folders: string[];
62
+ } | {
63
+ ok: false;
64
+ code: "read-only" | "not-applied" | "no-version" | "outside-folder" | "changed";
65
+ message: string;
66
+ };
67
+ /**
68
+ * Every undo check that needs no database read: the store is writable, the
69
+ * proposal is applied, a version exists, its file is a `SKILL.md` inside a
70
+ * watched folder, and the file still holds what OMMS wrote. Reads one file.
71
+ * `planUndo` and the `canUndo` of a list both run it, so they cannot disagree.
72
+ */
73
+ export declare function checkUndo(facts: UndoFacts): UndoCheck;
47
74
  /**
48
75
  * Restores the content saved before the last apply, or deletes the file the
49
76
  * apply created. Only when the file still matches what OMMS wrote; otherwise
@@ -51,3 +78,4 @@ export declare function canUndoProposal(id: string, deps: ApplyDeps): Promise<bo
51
78
  * later change has rewritten the file, until that later change is undone.
52
79
  */
53
80
  export declare function undoProposal(id: string, deps: ApplyDeps): Promise<UndoResult>;
81
+ export {};
@@ -209,42 +209,57 @@ function removeEmptyFolder(folder, roots) {
209
209
  // The folder is gone or not empty; leave it.
210
210
  }
211
211
  }
212
- /** Every check undo makes before it writes. Reads only; changes nothing. */
213
- async function planUndo(id, deps) {
214
- const { store } = deps;
215
- if (store.isReadOnly()) {
216
- return {
217
- ok: false,
218
- code: "read-only",
219
- message: "skills.db is from a newer OMMS and is read-only",
220
- };
221
- }
222
- const proposal = await getProposal(store, id);
223
- if (!proposal)
224
- return { ok: false, code: "not-found", message: `No proposal ${id}` };
225
- if (proposal.state !== "applied") {
226
- return { ok: false, code: "not-applied", message: `Proposal ${id} is ${proposal.state}` };
212
+ const READ_ONLY_MESSAGE = "skills.db is from a newer OMMS and is read-only";
213
+ /**
214
+ * Every undo check that needs no database read: the store is writable, the
215
+ * proposal is applied, a version exists, its file is a `SKILL.md` inside a
216
+ * watched folder, and the file still holds what OMMS wrote. Reads one file.
217
+ * `planUndo` and the `canUndo` of a list both run it, so they cannot disagree.
218
+ */
219
+ export function checkUndo(facts) {
220
+ const { id, state, version } = facts;
221
+ if (facts.readOnly)
222
+ return { ok: false, code: "read-only", message: READ_ONLY_MESSAGE };
223
+ if (state !== "applied") {
224
+ return { ok: false, code: "not-applied", message: `Proposal ${id} is ${state}` };
227
225
  }
228
- const version = await getLatestVersion(store, id);
229
226
  if (!version)
230
227
  return { ok: false, code: "no-version", message: `No saved version for ${id}` };
231
- const target = checkTarget(version.path, deps.folders);
228
+ const target = checkTarget(version.path, facts.folders);
232
229
  if (!target.ok)
233
230
  return { ok: false, code: "outside-folder", message: target.message };
234
- const { real, folders } = target;
235
- if (readHash(real) !== version.writtenHash) {
231
+ if (readHash(target.real) !== version.writtenHash) {
236
232
  return {
237
233
  ok: false,
238
234
  code: "changed",
239
235
  message: "The file changed since OMMS wrote it, so undo would lose that change",
240
- savedContent: version.previousContent,
241
236
  };
242
237
  }
243
- return { ok: true, proposal, version, real, folders };
238
+ return { ok: true, real: target.real, folders: target.folders };
244
239
  }
245
- /** True when `undoProposal` would accept this proposal now. Writes nothing. */
246
- export async function canUndoProposal(id, deps) {
247
- return (await planUndo(id, deps)).ok;
240
+ /** Every check undo makes before it writes. Reads only; changes nothing. */
241
+ async function planUndo(id, deps) {
242
+ const { store } = deps;
243
+ // A read-only store refuses before it looks the proposal up.
244
+ if (store.isReadOnly())
245
+ return { ok: false, code: "read-only", message: READ_ONLY_MESSAGE };
246
+ const proposal = await getProposal(store, id);
247
+ if (!proposal)
248
+ return { ok: false, code: "not-found", message: `No proposal ${id}` };
249
+ const version = await getLatestVersion(store, id);
250
+ const check = checkUndo({
251
+ id,
252
+ readOnly: false,
253
+ state: proposal.state,
254
+ version,
255
+ folders: deps.folders,
256
+ });
257
+ if (!check.ok) {
258
+ return check.code === "changed"
259
+ ? { ...check, savedContent: version?.previousContent ?? null }
260
+ : check;
261
+ }
262
+ return { ok: true, proposal, version: version, real: check.real, folders: check.folders };
248
263
  }
249
264
  /**
250
265
  * Restores the content saved before the last apply, or deletes the file the
@@ -1,5 +1,5 @@
1
1
  import { type ApplyDeps } from "./apply.js";
2
- import { type SkillProposal } from "./proposal-store.js";
2
+ import { countDiffLines } from "./line-diff.js";
3
3
  export type SkillHistoryKind = "created" | "applied" | "rejected" | "undone" | "stale";
4
4
  export interface SkillHistoryEntry {
5
5
  /** `<proposal id>:created` or `<proposal id>:<state>`. */
@@ -19,11 +19,7 @@ export interface SkillHistoryEntry {
19
19
  added: number;
20
20
  removed: number;
21
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
- };
22
+ export { countDiffLines };
27
23
  /**
28
24
  * The change history of one skill, newest first: a `created` entry for each
29
25
  * proposal and a second entry once it was applied, rejected, undone or found
@@ -40,9 +36,13 @@ export declare function skillHistory(deps: ApplyDeps, skill: {
40
36
  }>;
41
37
  /**
42
38
  * 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.
39
+ * would accept now, so false once a later change rewrote the file. Reads the
40
+ * newest saved versions of all applied proposals in one query, then one file
41
+ * hash per applied proposal, and nothing for the rest.
45
42
  */
46
- export declare function withCanUndo(deps: ApplyDeps, proposals: readonly SkillProposal[]): Promise<(SkillProposal & {
43
+ export declare function withCanUndo<T extends {
44
+ id: string;
45
+ state: string;
46
+ }>(deps: ApplyDeps, proposals: readonly T[]): Promise<(T & {
47
47
  canUndo: boolean;
48
48
  })[]>;
@@ -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
+ }
@@ -6,7 +6,8 @@ import { parseActivityQuery, topParam } from "./skill-learning/activity-range.js
6
6
  import { undoProposal } from "./skill-learning/apply.js";
7
7
  import { skillHistory, withCanUndo } from "./skill-learning/history.js";
8
8
  import { skillOperationsDeps } from "./skill-learning/operations-deps.js";
9
- import { getProposal, listProposals, setSkillMode, } from "./skill-learning/proposal-store.js";
9
+ import { getProposal, setSkillMode, } from "./skill-learning/proposal-store.js";
10
+ import { listProposalSummaries } from "./skill-learning/proposal-summaries.js";
10
11
  import { intParam } from "./skill-learning/query-params.js";
11
12
  import { SKILL_ORDERS, rankSkills } from "./skill-learning/ranking.js";
12
13
  import { skillTurnSettingsFor } from "./skill-learning/settings-for.js";
@@ -200,7 +201,7 @@ async function showSkill(url, deps, index, now) {
200
201
  turnTotal: await countSkillTurns(store, skill, index),
201
202
  daily: await skillDaily(store, index, skill, now),
202
203
  proposals: skill.path
203
- ? await withCanUndo(deps, await listProposals(store, { skillPath: skill.path }))
204
+ ? await withCanUndo(deps, (await listProposalSummaries(store, { skillPath: skill.path })).proposals)
204
205
  : [],
205
206
  });
206
207
  }
@@ -218,13 +219,13 @@ async function proposals(url, store, folders) {
218
219
  filter.skillPath = skill;
219
220
  else if (skill)
220
221
  filter.skillName = skill;
221
- const all = await listProposals(store, filter);
222
- // Without limit or offset the whole list comes back, as before.
222
+ // Without limit or offset the whole list comes back, as before. Summaries only: the
223
+ // content and diff come from the detail route.
223
224
  const offset = intParam(url, "offset", 0, Number.MAX_SAFE_INTEGER, 0);
224
- const limit = intParam(url, "limit", 1, MAX_PAGE, all.length || 1);
225
+ const limit = intParam(url, "limit", 1, MAX_PAGE, Number.MAX_SAFE_INTEGER);
225
226
  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 });
227
+ const { proposals: shown, total } = await listProposalSummaries(store, filter, paged ? { limit, offset } : undefined);
228
+ return ok("ok", { proposals: await withCanUndo({ store, folders }, shown), total });
228
229
  }
229
230
  async function history(url, store, folders) {
230
231
  const path = text(url.searchParams.get("path"));