om-memory-system 4.30.1 → 4.31.2

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.
Files changed (144) hide show
  1. package/README.md +1 -0
  2. package/dist/adapters/opencode/conversation.js +22 -17
  3. package/dist/adapters/opencode/skills-tool.d.ts +3 -0
  4. package/dist/adapters/opencode/skills-tool.js +31 -0
  5. package/dist/adapters/pi/capture.js +39 -23
  6. package/dist/adapters/pi/extension.d.ts +1 -0
  7. package/dist/adapters/pi/extension.js +3 -0
  8. package/dist/adapters/pi/skills-tool.d.ts +3 -0
  9. package/dist/adapters/pi/skills-tool.js +34 -0
  10. package/dist/cli/index.js +5 -0
  11. package/dist/cli/skills-command.d.ts +12 -0
  12. package/dist/cli/skills-command.js +242 -0
  13. package/dist/cli/skills-record-past.d.ts +5 -0
  14. package/dist/cli/skills-record-past.js +85 -0
  15. package/dist/config-template.d.ts +1 -1
  16. package/dist/config-template.js +12 -0
  17. package/dist/config.d.ts +64 -48
  18. package/dist/config.js +28 -5
  19. package/dist/core/host.d.ts +7 -0
  20. package/dist/core/profile-backoff.d.ts +16 -8
  21. package/dist/core/profile-backoff.js +25 -16
  22. package/dist/core/skill-agent-modes.d.ts +4 -0
  23. package/dist/core/skill-agent-modes.js +145 -0
  24. package/dist/core/skill-operation-reply.d.ts +16 -0
  25. package/dist/core/skill-operation-reply.js +35 -0
  26. package/dist/core/skill-operations.d.ts +58 -0
  27. package/dist/core/skill-operations.js +296 -0
  28. package/dist/core/skills-tool.d.ts +90 -0
  29. package/dist/core/skills-tool.js +64 -0
  30. package/dist/core/tool-input.d.ts +7 -0
  31. package/dist/core/tool-input.js +21 -0
  32. package/dist/core/tool-outcome.d.ts +13 -0
  33. package/dist/core/tool-outcome.js +22 -0
  34. package/dist/core/turn-outputs.d.ts +30 -0
  35. package/dist/core/turn-outputs.js +22 -0
  36. package/dist/importer/auto-backfill.js +13 -1
  37. package/dist/importer/claude-conversation.d.ts +1 -0
  38. package/dist/importer/claude-conversation.js +44 -10
  39. package/dist/importer/claude-hook-api.d.ts +0 -8
  40. package/dist/importer/claude-hook-api.js +36 -65
  41. package/dist/importer/claude-import.js +3 -1
  42. package/dist/importer/claude-reader.d.ts +2 -0
  43. package/dist/importer/claude-reader.js +2 -2
  44. package/dist/importer/claude-turn-units.d.ts +26 -0
  45. package/dist/importer/claude-turn-units.js +85 -0
  46. package/dist/importer/import-args.d.ts +3 -0
  47. package/dist/importer/import-args.js +8 -2
  48. package/dist/importer/import-project.d.ts +6 -0
  49. package/dist/importer/import-project.js +9 -0
  50. package/dist/importer/import-runs.d.ts +8 -2
  51. package/dist/importer/import-runs.js +3 -3
  52. package/dist/importer/import-sessions.d.ts +8 -1
  53. package/dist/importer/import-sessions.js +17 -4
  54. package/dist/importer/import-turn-outputs.d.ts +49 -0
  55. package/dist/importer/import-turn-outputs.js +91 -0
  56. package/dist/importer/importer.d.ts +5 -2
  57. package/dist/importer/importer.js +18 -32
  58. package/dist/importer/opencode-import.d.ts +2 -0
  59. package/dist/importer/opencode-import.js +6 -3
  60. package/dist/importer/opencode-reader.js +11 -5
  61. package/dist/importer/pi-conversation.d.ts +5 -0
  62. package/dist/importer/pi-conversation.js +33 -9
  63. package/dist/importer/profile-catch-up.d.ts +1 -1
  64. package/dist/importer/record-past-turns.d.ts +28 -0
  65. package/dist/importer/record-past-turns.js +63 -0
  66. package/dist/importer/run-import.d.ts +4 -0
  67. package/dist/importer/run-import.js +24 -3
  68. package/dist/importer/web-import-api.d.ts +14 -0
  69. package/dist/importer/web-import-api.js +6 -0
  70. package/dist/importer/web-import-group.d.ts +4 -1
  71. package/dist/importer/web-import-group.js +26 -6
  72. package/dist/importer/web-import-jobs.js +6 -2
  73. package/dist/index.js +6 -1
  74. package/dist/services/ai/providers/base-provider.d.ts +15 -0
  75. package/dist/services/ai/providers/base-provider.js +26 -0
  76. package/dist/services/ai/providers/openai-chat-completion.js +3 -6
  77. package/dist/services/ai/session/ai-session-manager.js +13 -0
  78. package/dist/services/auto-capture.d.ts +1 -1
  79. package/dist/services/auto-capture.js +71 -19
  80. package/dist/services/chat/chat-runtime.d.ts +2 -0
  81. package/dist/services/chat/chat-runtime.js +3 -2
  82. package/dist/services/global-config-writer.js +32 -2
  83. package/dist/services/profile-cleanup-routes.d.ts +2 -2
  84. package/dist/services/profile-cleanup-routes.js +19 -2
  85. package/dist/services/settings-snapshot.js +7 -1
  86. package/dist/services/skill-learning/agent-proposals.d.ts +76 -0
  87. package/dist/services/skill-learning/agent-proposals.js +180 -0
  88. package/dist/services/skill-learning/apply.d.ts +50 -0
  89. package/dist/services/skill-learning/apply.js +247 -0
  90. package/dist/services/skill-learning/auto-review.d.ts +40 -0
  91. package/dist/services/skill-learning/auto-review.js +132 -0
  92. package/dist/services/skill-learning/draft-turns.d.ts +60 -0
  93. package/dist/services/skill-learning/draft-turns.js +131 -0
  94. package/dist/services/skill-learning/drafting.d.ts +73 -0
  95. package/dist/services/skill-learning/drafting.js +264 -0
  96. package/dist/services/skill-learning/line-diff.d.ts +6 -0
  97. package/dist/services/skill-learning/line-diff.js +94 -0
  98. package/dist/services/skill-learning/operations-deps.d.ts +7 -0
  99. package/dist/services/skill-learning/operations-deps.js +19 -0
  100. package/dist/services/skill-learning/proposal-store.d.ts +82 -0
  101. package/dist/services/skill-learning/proposal-store.js +164 -0
  102. package/dist/services/skill-learning/ranking.d.ts +36 -0
  103. package/dist/services/skill-learning/ranking.js +140 -0
  104. package/dist/services/skill-learning/settings-for.d.ts +11 -0
  105. package/dist/services/skill-learning/settings-for.js +21 -0
  106. package/dist/services/skill-learning/skill-config.d.ts +45 -0
  107. package/dist/services/skill-learning/skill-config.js +70 -0
  108. package/dist/services/skill-learning/skill-detect.d.ts +29 -0
  109. package/dist/services/skill-learning/skill-detect.js +130 -0
  110. package/dist/services/skill-learning/skill-folders.d.ts +24 -0
  111. package/dist/services/skill-learning/skill-folders.js +42 -0
  112. package/dist/services/skill-learning/skill-index.d.ts +28 -0
  113. package/dist/services/skill-learning/skill-index.js +118 -0
  114. package/dist/services/skill-learning/skill-model.d.ts +10 -0
  115. package/dist/services/skill-learning/skill-model.js +15 -0
  116. package/dist/services/skill-learning/store.d.ts +79 -0
  117. package/dist/services/skill-learning/store.js +245 -0
  118. package/dist/services/skill-learning/turn-output.d.ts +34 -0
  119. package/dist/services/skill-learning/turn-output.js +98 -0
  120. package/dist/services/skill-learning/turn-summaries.d.ts +24 -0
  121. package/dist/services/skill-learning/turn-summaries.js +64 -0
  122. package/dist/services/skill-learning/web-review.d.ts +18 -0
  123. package/dist/services/skill-learning/web-review.js +44 -0
  124. package/dist/services/skill-routes.d.ts +20 -0
  125. package/dist/services/skill-routes.js +249 -0
  126. package/dist/services/turso/connection-manager.js +11 -7
  127. package/dist/services/turso/table-lease.d.ts +40 -0
  128. package/dist/services/turso/table-lease.js +77 -0
  129. package/dist/services/turso/turso-db.d.ts +4 -0
  130. package/dist/services/turso/turso-db.js +13 -0
  131. package/dist/services/user-prompt/profile-catch-up-lease.d.ts +3 -22
  132. package/dist/services/user-prompt/profile-catch-up-lease.js +9 -65
  133. package/dist/services/web-jobs.d.ts +7 -0
  134. package/dist/services/web-jobs.js +14 -0
  135. package/dist/services/web-route-modules.d.ts +2 -2
  136. package/dist/services/web-route-modules.js +6 -1
  137. package/dist/services/web-server.js +9 -21
  138. package/dist/v2/adapter.js +13 -8
  139. package/dist/web/assets/index-B_xLhsQO.css +2 -0
  140. package/dist/web/assets/index-BetjjXGs.js +129 -0
  141. package/dist/web/index.html +2 -2
  142. package/package.json +1 -1
  143. package/dist/web/assets/index-BGDKFzoC.css +0 -2
  144. package/dist/web/assets/index-CE8tXPA_.js +0 -128
@@ -0,0 +1,132 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { skillDraftBackoff } from "../../core/profile-backoff.js";
3
+ import { log } from "../logger.js";
4
+ import { TableLease } from "../turso/table-lease.js";
5
+ import { applyProposal } from "./apply.js";
6
+ import { countSkillUses, skillRef } from "./draft-turns.js";
7
+ import { draftImprovement } from "./drafting.js";
8
+ import { setSkillReview } from "./proposal-store.js";
9
+ /** A skill in automatic mode is reviewed after this many new uses... */
10
+ export const AUTO_REVIEW_MIN_USES = 10;
11
+ /** ...and at least this long after its last review. */
12
+ export const AUTO_REVIEW_MIN_GAP_MS = 24 * 3_600_000;
13
+ /** The web app checks for due skills this often. */
14
+ export const AUTO_REVIEW_INTERVAL_MS = 10 * 60_000;
15
+ export const SKILL_REVIEW_LEASE_TABLE = "skill_review_lease";
16
+ /** The review run record in `skills.db`, shared by every process. */
17
+ export function skillReviewLease(store, now) {
18
+ return new TableLease({ table: SKILL_REVIEW_LEASE_TABLE, connection: () => store.db(), now });
19
+ }
20
+ /** Automatic skills with 10 new uses and 24 hours since their last review. Reads only. */
21
+ async function dueSkills(deps, now) {
22
+ const db = await deps.store.existingDb();
23
+ if (!db)
24
+ return [];
25
+ const rows = await db.all(`SELECT skill_path, last_review_at, uses_at_last_review FROM skill_settings
26
+ WHERE mode = 'automatic' ORDER BY skill_path`);
27
+ const due = [];
28
+ for (const row of rows) {
29
+ // A skill no watched folder holds now gets no draft.
30
+ const ref = skillRef(deps.index, String(row.skill_path));
31
+ if (!ref)
32
+ continue;
33
+ const last = row.last_review_at == null ? null : Number(row.last_review_at);
34
+ if (last !== null && now - last < AUTO_REVIEW_MIN_GAP_MS)
35
+ continue;
36
+ const uses = await countSkillUses(db, ref);
37
+ if (uses - Number(row.uses_at_last_review ?? 0) < AUTO_REVIEW_MIN_USES)
38
+ continue;
39
+ due.push({ path: ref.path, uses });
40
+ }
41
+ return due;
42
+ }
43
+ async function reviewSkill(deps, skill, now) {
44
+ const draft = await draftImprovement(skill.path, { ...deps, now });
45
+ // A failed draft is not a review, so the skill is tried again after the wait.
46
+ if (!draft.ok)
47
+ return draft.code;
48
+ let code = draft.code;
49
+ if (draft.code === "proposed") {
50
+ // A file changed since the draft makes the proposal stale and stays as it is.
51
+ const applied = await applyProposal(draft.proposal.id, "automatic", {
52
+ store: deps.store,
53
+ folders: deps.folders,
54
+ now,
55
+ });
56
+ code = applied.code;
57
+ }
58
+ await setSkillReview(deps.store, skill.path, now(), skill.uses);
59
+ return code;
60
+ }
61
+ /**
62
+ * One automatic review pass (design §6). Drafts and applies an improvement
63
+ * for each due skill in automatic mode. Starts no draft during the skill
64
+ * draft wait, and runs only while it holds the `skill_review_lease`, so one
65
+ * review runs at a time across processes.
66
+ */
67
+ export async function runAutoReview(deps, now) {
68
+ const reviews = [];
69
+ const backoff = deps.backoff ?? skillDraftBackoff;
70
+ if (!deps.model.ready)
71
+ return { skipped: "not-configured", reviews };
72
+ if (!backoff.canRun(now))
73
+ return { skipped: "backoff", reviews };
74
+ const due = await dueSkills(deps, now);
75
+ if (due.length === 0)
76
+ return { reviews };
77
+ if (await deps.lease.isActive())
78
+ return { skipped: "busy", reviews };
79
+ const owner = randomUUID();
80
+ await deps.lease.take(owner);
81
+ try {
82
+ for (const skill of due) {
83
+ if (!backoff.canRun(now))
84
+ return { skipped: "backoff", reviews };
85
+ if ((await deps.lease.beginBatch(owner)) !== "ok")
86
+ return { skipped: "superseded", reviews };
87
+ try {
88
+ reviews.push({ skillPath: skill.path, code: await reviewSkill(deps, skill, () => now) });
89
+ }
90
+ finally {
91
+ await deps.lease.endBatch(owner);
92
+ }
93
+ }
94
+ return { reviews };
95
+ }
96
+ finally {
97
+ await deps.lease.release(owner);
98
+ }
99
+ }
100
+ /**
101
+ * Runs `runAutoReview` every 10 minutes. `makeDeps` returns null while skill
102
+ * learning is off, and the tick then does nothing. A tick never overlaps the
103
+ * one before it. The timer does not keep the process alive. Returns a stop
104
+ * function.
105
+ */
106
+ export function startAutoReviewLoop(makeDeps, intervalMs = AUTO_REVIEW_INTERVAL_MS) {
107
+ let running = false;
108
+ const tick = async () => {
109
+ if (running)
110
+ return;
111
+ running = true;
112
+ try {
113
+ const deps = await makeDeps();
114
+ if (!deps)
115
+ return;
116
+ const report = await runAutoReview(deps, Date.now());
117
+ if (report.reviews.length > 0) {
118
+ log("Skill automatic review finished", { codes: report.reviews.map((r) => r.code) });
119
+ }
120
+ }
121
+ catch (error) {
122
+ // The name only: a message can hold a path or provider text.
123
+ log("Skill automatic review failed", { error: error?.name ?? "error" }, "error");
124
+ }
125
+ finally {
126
+ running = false;
127
+ }
128
+ };
129
+ const timer = setInterval(() => void tick(), intervalMs);
130
+ timer.unref?.();
131
+ return () => clearInterval(timer);
132
+ }
@@ -0,0 +1,60 @@
1
+ import type { TursoDb } from "../turso/turso-db.js";
2
+ import type { SkillIndex } from "./skill-index.js";
3
+ /** Design §6: the budgets for turn text sent to the external API model. */
4
+ export declare const MAX_DRAFT_TURNS = 20;
5
+ export declare const MAX_IDEA_TURNS = 200;
6
+ export declare const MAX_TURN_CHARS = 4000;
7
+ export declare const MAX_TOTAL_TURN_CHARS = 60000;
8
+ /** An idea turn shows only the start of its prompt. */
9
+ export declare const IDEA_PROMPT_CHARS = 300;
10
+ /** A watched skill and the rule that matches its recorded uses. */
11
+ export interface SkillRef {
12
+ name: string;
13
+ path: string;
14
+ /**
15
+ * True when exactly one watched skill has this name, so a use recorded
16
+ * without a path counts for it. Ranking uses the same rule.
17
+ */
18
+ byName: boolean;
19
+ }
20
+ export interface DraftTurn {
21
+ id: number;
22
+ createdAt: number;
23
+ prompt: string;
24
+ replies: string[];
25
+ tools: Array<{
26
+ name: string;
27
+ outcome: string;
28
+ error: string | null;
29
+ }>;
30
+ }
31
+ /** The watched skill at `path`, or null when no watched folder holds it. */
32
+ export declare function skillRef(index: SkillIndex, path: string): SkillRef | null;
33
+ /** The `skill_uses` rows (alias `su`) that count for a skill; bind `useArgs(ref)`. */
34
+ export declare const USES_WHERE = "(su.skill_path = ? OR (su.skill_path IS NULL AND su.skill_name = ? AND ? = 1))";
35
+ export declare function useArgs(ref: SkillRef): Array<string | number>;
36
+ /** Recorded turns that used the skill, all time. A turn counts once. */
37
+ export declare function countSkillUses(db: TursoDb, ref: SkillRef): Promise<number>;
38
+ /** The newest turns that used the skill, newest first. */
39
+ export declare function recentSkillTurns(db: TursoDb, ref: SkillRef, limit?: number): Promise<DraftTurn[]>;
40
+ /** The newest recorded turns of any kind, newest first. */
41
+ export declare function recentTurns(db: TursoDb, limit?: number): Promise<DraftTurn[]>;
42
+ /** The given turns that still exist, newest first. */
43
+ export declare function turnsById(db: TursoDb, ids: readonly number[]): Promise<DraftTurn[]>;
44
+ /** The label the model sees and returns as evidence. */
45
+ export declare function turnLabel(id: number): string;
46
+ /** A turn for an improvement draft. Tool outcomes come before replies, so a cut keeps them. */
47
+ export declare function fullTurnText(turn: DraftTurn): string;
48
+ /** A one-line turn for new skill ideas: the start of the prompt and the tool outcomes. */
49
+ export declare function compactTurnText(turn: DraftTurn): string;
50
+ /**
51
+ * Turn blocks in the given order, each cut to `MAX_TURN_CHARS`, until the
52
+ * total reaches `MAX_TOTAL_TURN_CHARS`. Returns the ids actually sent, so
53
+ * evidence can name only those.
54
+ */
55
+ export declare function packTurns(turns: readonly DraftTurn[], format: (turn: DraftTurn) => string): {
56
+ text: string;
57
+ sentIds: number[];
58
+ };
59
+ /** Evidence labels from a reply, kept only when they name a turn that was sent. */
60
+ export declare function pickEvidence(labels: readonly string[], sentIds: readonly number[]): number[];
@@ -0,0 +1,131 @@
1
+ /** Design §6: the budgets for turn text sent to the external API model. */
2
+ export const MAX_DRAFT_TURNS = 20;
3
+ export const MAX_IDEA_TURNS = 200;
4
+ export const MAX_TURN_CHARS = 4_000;
5
+ export const MAX_TOTAL_TURN_CHARS = 60_000;
6
+ /** An idea turn shows only the start of its prompt. */
7
+ export const IDEA_PROMPT_CHARS = 300;
8
+ /** The watched skill at `path`, or null when no watched folder holds it. */
9
+ export function skillRef(index, path) {
10
+ const entry = index.entries.find((e) => e.path === path);
11
+ if (!entry)
12
+ return null;
13
+ const sameName = index.entries.filter((e) => e.name === entry.name).length;
14
+ return { name: entry.name, path, byName: sameName === 1 };
15
+ }
16
+ /** The `skill_uses` rows (alias `su`) that count for a skill; bind `useArgs(ref)`. */
17
+ export const USES_WHERE = "(su.skill_path = ? OR (su.skill_path IS NULL AND su.skill_name = ? AND ? = 1))";
18
+ export function useArgs(ref) {
19
+ return [ref.path, ref.name, ref.byName ? 1 : 0];
20
+ }
21
+ /** Recorded turns that used the skill, all time. A turn counts once. */
22
+ export async function countSkillUses(db, ref) {
23
+ const row = await db.get(`SELECT COUNT(DISTINCT su.turn_id) AS n FROM skill_uses su WHERE ${USES_WHERE}`, useArgs(ref));
24
+ return Number(row?.n ?? 0);
25
+ }
26
+ function parseReplies(value) {
27
+ try {
28
+ const parsed = JSON.parse(String(value));
29
+ return Array.isArray(parsed) ? parsed.map(String) : [];
30
+ }
31
+ catch {
32
+ return [];
33
+ }
34
+ }
35
+ async function loadTurns(db, where, args) {
36
+ const rows = await db.all(`SELECT t.id, t.created_at, t.prompt, t.replies_json FROM turns t
37
+ WHERE ${where} ORDER BY t.created_at DESC, t.id DESC LIMIT ?`, args);
38
+ if (rows.length === 0)
39
+ return [];
40
+ const turns = rows.map((row) => ({
41
+ id: Number(row.id),
42
+ createdAt: Number(row.created_at),
43
+ prompt: String(row.prompt),
44
+ replies: parseReplies(row.replies_json),
45
+ tools: [],
46
+ }));
47
+ const byId = new Map(turns.map((turn) => [turn.id, turn]));
48
+ const calls = await db.all(`SELECT turn_id, name, outcome, error FROM tool_calls
49
+ WHERE turn_id IN (SELECT value FROM json_each(?)) ORDER BY turn_id, position`, [JSON.stringify([...byId.keys()])]);
50
+ for (const call of calls) {
51
+ byId.get(Number(call.turn_id))?.tools.push({
52
+ name: String(call.name),
53
+ outcome: String(call.outcome),
54
+ error: call.error == null ? null : String(call.error),
55
+ });
56
+ }
57
+ return turns;
58
+ }
59
+ /** The newest turns that used the skill, newest first. */
60
+ export function recentSkillTurns(db, ref, limit = MAX_DRAFT_TURNS) {
61
+ return loadTurns(db, `t.id IN (SELECT su.turn_id FROM skill_uses su WHERE ${USES_WHERE})`, [
62
+ ...useArgs(ref),
63
+ limit,
64
+ ]);
65
+ }
66
+ /** The newest recorded turns of any kind, newest first. */
67
+ export function recentTurns(db, limit = MAX_IDEA_TURNS) {
68
+ return loadTurns(db, "1 = 1", [limit]);
69
+ }
70
+ /** The given turns that still exist, newest first. */
71
+ export function turnsById(db, ids) {
72
+ return loadTurns(db, "t.id IN (SELECT value FROM json_each(?))", [
73
+ JSON.stringify(ids),
74
+ MAX_DRAFT_TURNS,
75
+ ]);
76
+ }
77
+ /** The label the model sees and returns as evidence. */
78
+ export function turnLabel(id) {
79
+ return `T${id}`;
80
+ }
81
+ function toolLine(tool) {
82
+ return `- ${tool.name}: ${tool.outcome}${tool.error ? ` (${tool.error})` : ""}`;
83
+ }
84
+ /** A turn for an improvement draft. Tool outcomes come before replies, so a cut keeps them. */
85
+ export function fullTurnText(turn) {
86
+ const parts = [
87
+ `[${turnLabel(turn.id)}] ${new Date(turn.createdAt).toISOString()}`,
88
+ `Prompt: ${turn.prompt}`,
89
+ ];
90
+ if (turn.tools.length > 0)
91
+ parts.push("Tool calls:", ...turn.tools.map(toolLine));
92
+ parts.push(...turn.replies.map((reply, i) => `Reply ${i + 1}: ${reply}`));
93
+ return parts.join("\n");
94
+ }
95
+ /** A one-line turn for new skill ideas: the start of the prompt and the tool outcomes. */
96
+ export function compactTurnText(turn) {
97
+ const prompt = turn.prompt.replace(/\s+/g, " ").trim().slice(0, IDEA_PROMPT_CHARS);
98
+ const tools = turn.tools.map((tool) => `${tool.name} ${tool.outcome}`).join(", ");
99
+ return `[${turnLabel(turn.id)}] ${prompt}${tools ? ` | tools: ${tools}` : ""}`;
100
+ }
101
+ /**
102
+ * Turn blocks in the given order, each cut to `MAX_TURN_CHARS`, until the
103
+ * total reaches `MAX_TOTAL_TURN_CHARS`. Returns the ids actually sent, so
104
+ * evidence can name only those.
105
+ */
106
+ export function packTurns(turns, format) {
107
+ const blocks = [];
108
+ const sentIds = [];
109
+ let left = MAX_TOTAL_TURN_CHARS;
110
+ for (const turn of turns) {
111
+ if (left <= 0)
112
+ break;
113
+ const block = format(turn).slice(0, Math.min(MAX_TURN_CHARS, left));
114
+ blocks.push(block);
115
+ sentIds.push(turn.id);
116
+ left -= block.length;
117
+ }
118
+ return { text: blocks.join("\n\n"), sentIds };
119
+ }
120
+ /** Evidence labels from a reply, kept only when they name a turn that was sent. */
121
+ export function pickEvidence(labels, sentIds) {
122
+ const sent = new Set(sentIds);
123
+ const picked = [];
124
+ for (const label of labels) {
125
+ const match = /^\s*T?(\d+)\s*$/i.exec(label);
126
+ const id = match ? Number(match[1]) : NaN;
127
+ if (sent.has(id) && !picked.includes(id))
128
+ picked.push(id);
129
+ }
130
+ return picked;
131
+ }
@@ -0,0 +1,73 @@
1
+ import { type Backoff } from "../../core/profile-backoff.js";
2
+ import { type ProfileFailureCode } from "../../core/profile-failure.js";
3
+ import type { ToolCallResult } from "../ai/providers/base-provider.js";
4
+ import { type SkillProposal } from "./proposal-store.js";
5
+ import { type SkillIndex } from "./skill-index.js";
6
+ import type { SkillStore } from "./store.js";
7
+ export { MAX_DRAFT_TURNS, MAX_IDEA_TURNS, MAX_TOTAL_TURN_CHARS, MAX_TURN_CHARS, } from "./draft-turns.js";
8
+ export declare const NO_CHANGE_PROPOSED = "No change proposed";
9
+ export declare const MAX_SKILL_IDEAS = 3;
10
+ export declare const SKILL_NAME: RegExp;
11
+ /** The external API model, as the chat uses it (ADR-033). */
12
+ export interface SkillModel {
13
+ /** False when the external API is not fully set up. No call is made then. */
14
+ ready: boolean;
15
+ /** One structured call. Throws or returns `success: false` on failure. */
16
+ callTool(system: string, user: string, tool: object): Promise<ToolCallResult>;
17
+ }
18
+ export interface DraftDeps {
19
+ /** An initialised store. */
20
+ store: SkillStore;
21
+ /** The current watched skills. */
22
+ index: SkillIndex;
23
+ model: SkillModel;
24
+ now?: () => number;
25
+ /** The wait a failure starts. Defaults to `skillDraftBackoff`. */
26
+ backoff?: Backoff;
27
+ }
28
+ export type DraftFailureCode = ProfileFailureCode | "not-found" | "invalid-name" | "read-only";
29
+ export type DraftFailure = {
30
+ ok: false;
31
+ code: DraftFailureCode;
32
+ message: string;
33
+ };
34
+ export type DraftResult = {
35
+ ok: true;
36
+ code: "proposed";
37
+ proposal: SkillProposal;
38
+ } | {
39
+ ok: true;
40
+ code: "no-change";
41
+ message: typeof NO_CHANGE_PROPOSED;
42
+ } | DraftFailure;
43
+ export interface SkillIdea {
44
+ name: string;
45
+ description: string;
46
+ reason: string;
47
+ evidenceTurnIds: number[];
48
+ }
49
+ export type IdeasResult = {
50
+ ok: true;
51
+ ideas: SkillIdea[];
52
+ } | DraftFailure;
53
+ export declare function withFinalNewline(text: string): string;
54
+ export declare function sameText(a: string, b: string): boolean;
55
+ export declare const READ_ONLY: DraftFailure;
56
+ /**
57
+ * Asks the model to improve one watched skill from its newest recorded uses
58
+ * (design §6). Saves a pending `improve` proposal, or nothing when the reply
59
+ * is invalid or leaves the file as it is.
60
+ */
61
+ export declare function draftImprovement(skillPath: string, deps: DraftDeps): Promise<DraftResult>;
62
+ /**
63
+ * Asks the model for new skill ideas from up to 200 compact recent turns.
64
+ * Keeps at most three valid kebab-case names that no watched skill uses.
65
+ * Ideas are returned, not saved.
66
+ */
67
+ export declare function findSkillIdeas(deps: DraftDeps): Promise<IdeasResult>;
68
+ /**
69
+ * Asks the model for a full `SKILL.md` for one idea and saves it as a pending
70
+ * `new` proposal at `<folder>/<name>/SKILL.md`. The front matter must name
71
+ * the idea and hold a description.
72
+ */
73
+ export declare function draftNewSkill(idea: SkillIdea, folder: string, deps: DraftDeps): Promise<DraftResult>;
@@ -0,0 +1,264 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { z } from "zod";
4
+ import { recordProfileFailure, skillDraftBackoff, } from "../../core/profile-backoff.js";
5
+ import { ProfileModelError, profileFailureCode, profileResultCode, } from "../../core/profile-failure.js";
6
+ import { hashSkillContent, SKILL_FILE_NAME } from "./apply.js";
7
+ import { compactTurnText, countSkillUses, fullTurnText, packTurns, pickEvidence, recentSkillTurns, recentTurns, skillRef, turnsById, } from "./draft-turns.js";
8
+ import { lineDiff } from "./line-diff.js";
9
+ import { createProposal, setSkillReview } from "./proposal-store.js";
10
+ import { readFrontMatter } from "./skill-index.js";
11
+ export { MAX_DRAFT_TURNS, MAX_IDEA_TURNS, MAX_TOTAL_TURN_CHARS, MAX_TURN_CHARS, } from "./draft-turns.js";
12
+ export const NO_CHANGE_PROPOSED = "No change proposed";
13
+ export const MAX_SKILL_IDEAS = 3;
14
+ export const SKILL_NAME = /^[a-z0-9]+(-[a-z0-9]+)*$/;
15
+ const labels = z.array(z.union([z.string(), z.number()]).transform(String));
16
+ const DraftReply = z.object({
17
+ content: z.string().min(1),
18
+ summary: z.string(),
19
+ evidence: labels.default([]),
20
+ });
21
+ const IdeasReply = z.object({
22
+ ideas: z.array(z.object({
23
+ name: z.string(),
24
+ description: z.string(),
25
+ reason: z.string(),
26
+ evidence: labels.default([]),
27
+ })),
28
+ });
29
+ const NewSkillReply = z.object({ content: z.string().min(1), summary: z.string() });
30
+ function tool(name, description, properties) {
31
+ return {
32
+ type: "function",
33
+ function: {
34
+ name,
35
+ description,
36
+ parameters: { type: "object", properties, required: Object.keys(properties) },
37
+ },
38
+ };
39
+ }
40
+ const evidenceField = {
41
+ type: "array",
42
+ items: { type: "string" },
43
+ description: "Labels such as T12 of the given turns that support the answer",
44
+ };
45
+ const DRAFT_TOOL = tool("propose_skill_update", "Return the revised SKILL.md", {
46
+ content: { type: "string", description: "The full revised SKILL.md, front matter included" },
47
+ summary: { type: "string", description: "One or two sentences on what changed and why" },
48
+ evidence: evidenceField,
49
+ });
50
+ const IDEAS_TOOL = tool("suggest_skills", "Return new skill ideas", {
51
+ ideas: {
52
+ type: "array",
53
+ items: {
54
+ type: "object",
55
+ properties: {
56
+ name: { type: "string", description: "Kebab-case name, such as release-notes" },
57
+ description: { type: "string", description: "One line: when an agent should use it" },
58
+ reason: { type: "string", description: "What in the turns shows the need" },
59
+ evidence: evidenceField,
60
+ },
61
+ required: ["name", "description", "reason", "evidence"],
62
+ },
63
+ },
64
+ });
65
+ const NEW_SKILL_TOOL = tool("write_skill", "Return the new SKILL.md", {
66
+ content: { type: "string", description: "The full SKILL.md, front matter included" },
67
+ summary: { type: "string", description: "One sentence on what the skill does" },
68
+ });
69
+ const DRAFT_SYSTEM = [
70
+ "You improve one agent skill file (SKILL.md) from recorded turns in which a coding agent used it.",
71
+ "Look for where the agent struggled: failed tool calls, repeated attempts, user corrections and missing steps.",
72
+ "Change the file only where the turns show a gap or an error. Keep the front matter, the skill name and every part that still works.",
73
+ "When the turns show no gap, return the current file unchanged.",
74
+ "List as evidence only labels of the turns given. Write plain British English. Call the propose_skill_update tool.",
75
+ ].join(" ");
76
+ const IDEAS_SYSTEM = [
77
+ "You find repeated workflows in recorded coding agent turns that no listed skill covers.",
78
+ `Suggest at most ${MAX_SKILL_IDEAS} new skills. Give each a kebab-case name that differs from every listed skill.`,
79
+ "Suggest a skill only when several turns show the same kind of task. Return an empty list when none does.",
80
+ "List as evidence only labels of the turns given. Write plain British English. Call the suggest_skills tool.",
81
+ ].join(" ");
82
+ const NEW_SKILL_SYSTEM = [
83
+ "You write a new agent skill file (SKILL.md) for a coding agent.",
84
+ "Start with YAML front matter between --- lines that holds `name` set to the given name and a one-line `description` of when to use the skill.",
85
+ "Then give short, ordered instructions. Use only what the idea and the turns support.",
86
+ "Write plain British English. Call the write_skill tool.",
87
+ ].join(" ");
88
+ function nowOf(deps) {
89
+ return (deps.now ?? Date.now)();
90
+ }
91
+ const FAILURE_TEXT = {
92
+ "not-configured": "The external API is not set up. Set it up in the External API card.",
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.",
95
+ timeout: "The model did not reply in time",
96
+ };
97
+ /** Log one metadata record, start the skill draft wait and return the failure. */
98
+ function fail(deps, step, code) {
99
+ recordProfileFailure(null, `Skill ${step} failed`, new ProfileModelError(code), nowOf(deps), deps.backoff ?? skillDraftBackoff);
100
+ return { ok: false, code, message: FAILURE_TEXT[code] ?? `Drafting failed: ${code}` };
101
+ }
102
+ /** One model call with its reply checked by `schema`. Failures are logged by code only. */
103
+ async function ask(deps, step, prompt, schema) {
104
+ if (!deps.model.ready)
105
+ return fail(deps, step, "not-configured");
106
+ let result;
107
+ try {
108
+ result = await deps.model.callTool(prompt.system, prompt.user, prompt.tool);
109
+ }
110
+ catch (error) {
111
+ return fail(deps, step, profileFailureCode(error));
112
+ }
113
+ if (!result.success)
114
+ return fail(deps, step, profileResultCode(result));
115
+ const parsed = schema.safeParse(result.data);
116
+ if (!parsed.success)
117
+ return fail(deps, step, "invalid-reply");
118
+ (deps.backoff ?? skillDraftBackoff).recordSuccess();
119
+ return { ok: true, data: parsed.data };
120
+ }
121
+ export function withFinalNewline(text) {
122
+ return text.endsWith("\n") ? text : `${text}\n`;
123
+ }
124
+ export function sameText(a, b) {
125
+ return a.replace(/\n+$/, "") === b.replace(/\n+$/, "");
126
+ }
127
+ export const READ_ONLY = {
128
+ ok: false,
129
+ code: "read-only",
130
+ message: "skills.db is from a newer OMMS and is read-only",
131
+ };
132
+ /**
133
+ * Asks the model to improve one watched skill from its newest recorded uses
134
+ * (design §6). Saves a pending `improve` proposal, or nothing when the reply
135
+ * is invalid or leaves the file as it is.
136
+ */
137
+ export async function draftImprovement(skillPath, deps) {
138
+ if (deps.store.isReadOnly())
139
+ return READ_ONLY;
140
+ const ref = skillRef(deps.index, skillPath);
141
+ if (!ref)
142
+ return { ok: false, code: "not-found", message: `${skillPath} is not a watched skill` };
143
+ let raw;
144
+ try {
145
+ raw = readFileSync(skillPath);
146
+ }
147
+ catch {
148
+ return { ok: false, code: "not-found", message: `${skillPath} cannot be read` };
149
+ }
150
+ const current = raw.toString("utf8");
151
+ const db = await deps.store.existingDb();
152
+ const packed = packTurns(db ? await recentSkillTurns(db, ref) : [], fullTurnText);
153
+ const user = [
154
+ `Skill: ${ref.name}`,
155
+ `Current SKILL.md:\n<<<\n${current}\n>>>`,
156
+ `Recorded turns, newest first:`,
157
+ packed.text || "(none)",
158
+ ].join("\n\n");
159
+ const reply = await ask(deps, "draft", { system: DRAFT_SYSTEM, user, tool: DRAFT_TOOL }, DraftReply);
160
+ if (!reply.ok)
161
+ return reply;
162
+ const content = withFinalNewline(reply.data.content);
163
+ if (sameText(content, current)) {
164
+ // A draft with no change counts as a review, so ranking stops marking the skill Suggested.
165
+ const uses = db ? await countSkillUses(db, ref) : 0;
166
+ await setSkillReview(deps.store, skillPath, nowOf(deps), uses);
167
+ return { ok: true, code: "no-change", message: NO_CHANGE_PROPOSED };
168
+ }
169
+ const proposal = await createProposal(deps.store, {
170
+ kind: "improve",
171
+ skillName: ref.name,
172
+ skillPath,
173
+ baseHash: hashSkillContent(raw),
174
+ content,
175
+ diff: lineDiff(current, content),
176
+ summary: reply.data.summary.trim(),
177
+ evidenceTurnIds: pickEvidence(reply.data.evidence, packed.sentIds),
178
+ }, nowOf(deps));
179
+ return { ok: true, code: "proposed", proposal };
180
+ }
181
+ /**
182
+ * Asks the model for new skill ideas from up to 200 compact recent turns.
183
+ * Keeps at most three valid kebab-case names that no watched skill uses.
184
+ * Ideas are returned, not saved.
185
+ */
186
+ export async function findSkillIdeas(deps) {
187
+ const db = await deps.store.existingDb();
188
+ const turns = db ? await recentTurns(db) : [];
189
+ if (deps.model.ready && turns.length === 0)
190
+ return { ok: true, ideas: [] };
191
+ const packed = packTurns(turns, compactTurnText);
192
+ const watched = deps.index.entries.map((e) => `- ${e.name}: ${e.description}`);
193
+ const user = [
194
+ `Watched skills:\n${watched.join("\n") || "(none)"}`,
195
+ `Recorded turns, newest first:`,
196
+ packed.text,
197
+ ].join("\n\n");
198
+ const reply = await ask(deps, "ideas", { system: IDEAS_SYSTEM, user, tool: IDEAS_TOOL }, IdeasReply);
199
+ if (!reply.ok)
200
+ return reply;
201
+ const taken = new Set(deps.index.entries.map((e) => e.name.toLowerCase()));
202
+ const ideas = [];
203
+ for (const idea of reply.data.ideas) {
204
+ const name = idea.name.trim();
205
+ if (!SKILL_NAME.test(name) || taken.has(name) || !idea.description.trim())
206
+ continue;
207
+ taken.add(name);
208
+ ideas.push({
209
+ name,
210
+ description: idea.description.trim(),
211
+ reason: idea.reason.trim(),
212
+ evidenceTurnIds: pickEvidence(idea.evidence, packed.sentIds),
213
+ });
214
+ if (ideas.length === MAX_SKILL_IDEAS)
215
+ break;
216
+ }
217
+ return { ok: true, ideas };
218
+ }
219
+ /**
220
+ * Asks the model for a full `SKILL.md` for one idea and saves it as a pending
221
+ * `new` proposal at `<folder>/<name>/SKILL.md`. The front matter must name
222
+ * the idea and hold a description.
223
+ */
224
+ export async function draftNewSkill(idea, folder, deps) {
225
+ if (deps.store.isReadOnly())
226
+ return READ_ONLY;
227
+ if (!SKILL_NAME.test(idea.name)) {
228
+ return {
229
+ ok: false,
230
+ code: "invalid-name",
231
+ message: "A new skill name may hold only lower-case letters, digits and single hyphens",
232
+ };
233
+ }
234
+ const db = await deps.store.existingDb();
235
+ const evidence = db && idea.evidenceTurnIds.length > 0 ? await turnsById(db, idea.evidenceTurnIds) : [];
236
+ const packed = packTurns(evidence, fullTurnText);
237
+ const user = [
238
+ `Name: ${idea.name}`,
239
+ `Description: ${idea.description}`,
240
+ `Reason: ${idea.reason}`,
241
+ `Recorded turns, newest first:`,
242
+ packed.text || "(none)",
243
+ ].join("\n\n");
244
+ const prompt = { system: NEW_SKILL_SYSTEM, user, tool: NEW_SKILL_TOOL };
245
+ const reply = await ask(deps, "new skill draft", prompt, NewSkillReply);
246
+ if (!reply.ok)
247
+ return reply;
248
+ const content = withFinalNewline(reply.data.content);
249
+ const front = readFrontMatter(content);
250
+ if (front.name !== idea.name || !front.description?.trim()) {
251
+ return fail(deps, "new skill draft", "invalid-reply");
252
+ }
253
+ const proposal = await createProposal(deps.store, {
254
+ kind: "new",
255
+ skillName: idea.name,
256
+ skillPath: join(folder, idea.name, SKILL_FILE_NAME),
257
+ baseHash: null,
258
+ content,
259
+ diff: lineDiff("", content),
260
+ summary: reply.data.summary.trim(),
261
+ evidenceTurnIds: pickEvidence(idea.evidenceTurnIds.map(String), packed.sentIds),
262
+ }, nowOf(deps));
263
+ return { ok: true, code: "proposed", proposal };
264
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * A unified-style line diff: `@@ -start,count +start,count @@` hunks with
3
+ * three lines of context, then lines marked ` `, `-` or `+`. Empty when the
4
+ * texts have the same lines. The web page renders it as text.
5
+ */
6
+ export declare function lineDiff(before: string, after: string): string;