@saadnvd1/agent-os 0.3.557 → 0.3.560

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 (38) hide show
  1. package/README.md +1 -1
  2. package/app/api/learning-cards/[id]/route.test.ts +62 -0
  3. package/app/api/learning-cards/[id]/route.ts +36 -0
  4. package/app/api/recipes/draft/route.ts +6 -1
  5. package/app/api/recipes/route.ts +3 -0
  6. package/app/api/sessions/[id]/learning-cards/route.test.ts +35 -0
  7. package/app/api/sessions/[id]/learning-cards/route.ts +10 -0
  8. package/app/api/sessions/route.ts +3 -3
  9. package/components/Chat/ChatPanel.tsx +2 -0
  10. package/components/LearningCards/LearningCard.test.ts +156 -0
  11. package/components/LearningCards/LearningCard.tsx +210 -0
  12. package/data/learning-cards/index.ts +79 -0
  13. package/lib/chat/store.ts +16 -0
  14. package/lib/db/migrations.ts +53 -0
  15. package/lib/db/queries.ts +4 -3
  16. package/lib/experiments/hooks.ts +12 -4
  17. package/lib/learning-cards/__fixtures__/db.ts +52 -0
  18. package/lib/learning-cards/__fixtures__/sessions.ts +63 -0
  19. package/lib/learning-cards/analyze.ts +228 -0
  20. package/lib/learning-cards/apply.ts +196 -0
  21. package/lib/learning-cards/index.ts +29 -0
  22. package/lib/learning-cards/input.ts +116 -0
  23. package/lib/learning-cards/pairs.ts +111 -0
  24. package/lib/learning-cards/signals.ts +98 -0
  25. package/lib/learning-cards/store.ts +103 -0
  26. package/lib/learning-cards/types.ts +79 -0
  27. package/lib/models/plan-step.ts +107 -15
  28. package/lib/orchestrator/conditions.ts +12 -4
  29. package/lib/orchestrator/events.ts +23 -0
  30. package/lib/orchestrator/quick.ts +14 -1
  31. package/lib/orchestrator/reconcile.ts +201 -0
  32. package/lib/plans/features.ts +69 -13
  33. package/lib/plans/pieces.ts +70 -15
  34. package/lib/plans/release.ts +46 -4
  35. package/lib/recipes/draft.ts +62 -33
  36. package/lib/recipes/settle.ts +3 -0
  37. package/lib/sidebar/shelves.ts +5 -6
  38. package/package.json +1 -1
package/lib/chat/store.ts CHANGED
@@ -180,6 +180,22 @@ export function itemsOfKind<K extends ChatItem["kind"]>(
180
180
  ).map((r) => JSON.parse(r.data));
181
181
  }
182
182
 
183
+ // Several kinds in the order they happened.
184
+ export function itemsOfKinds<K extends ChatItem["kind"]>(
185
+ sessionId: string,
186
+ kinds: K[]
187
+ ): Extract<ChatItem, { kind: K }>[] {
188
+ flushItems();
189
+ return (
190
+ db
191
+ .prepare(
192
+ `SELECT data FROM chat_items WHERE session_id = ?
193
+ AND kind IN (${kinds.map(() => "?").join(",")}) ORDER BY seq`
194
+ )
195
+ .all(sessionId, ...kinds) as { data: string }[]
196
+ ).map((r) => JSON.parse(r.data));
197
+ }
198
+
183
199
  // A chat's background tasks still running, or ended after `since` (ms): what
184
200
  // the composer lists, read without parsing the rest of a long conversation.
185
201
  export function recentTasks(
@@ -2061,6 +2061,59 @@ const migrations: Migration[] = [
2061
2061
  `);
2062
2062
  },
2063
2063
  },
2064
+ {
2065
+ id: 86,
2066
+ name: "learning_cards",
2067
+ up: (db) => {
2068
+ // End-of-session suggestions (save as recipe, keep a lesson). An
2069
+ // accepted lesson row is the record of where it was applied.
2070
+ db.exec(`
2071
+ CREATE TABLE IF NOT EXISTS learning_cards (
2072
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
2073
+ session_id TEXT NOT NULL,
2074
+ workspace_id TEXT,
2075
+ kind TEXT NOT NULL,
2076
+ status TEXT NOT NULL DEFAULT 'pending',
2077
+ payload TEXT NOT NULL,
2078
+ destination TEXT,
2079
+ applied_ref TEXT,
2080
+ shown_at INTEGER,
2081
+ decided_at INTEGER,
2082
+ created_at INTEGER NOT NULL,
2083
+ UNIQUE (session_id, kind)
2084
+ );
2085
+ CREATE INDEX IF NOT EXISTS idx_learning_cards_dest
2086
+ ON learning_cards(destination, decided_at);
2087
+ `);
2088
+ },
2089
+ },
2090
+ {
2091
+ id: 87,
2092
+ name: "plan_pieces_reconcile",
2093
+ up: (db) => {
2094
+ // How many pieces a plan listed and what the checker folded, so a plan
2095
+ // that builds as fewer than it listed is never silent; the PRs agents
2096
+ // opened (from their `gh pr create` output) so one outside its task's
2097
+ // repo is noticed.
2098
+ const columns = db.prepare(`PRAGMA table_info(features)`).all() as {
2099
+ name: string;
2100
+ }[];
2101
+ if (!columns.some((c) => c.name === "listed"))
2102
+ db.exec(`ALTER TABLE features ADD COLUMN listed INTEGER`);
2103
+ if (!columns.some((c) => c.name === "folded"))
2104
+ db.exec(`ALTER TABLE features ADD COLUMN folded TEXT`);
2105
+ db.exec(`
2106
+ CREATE TABLE IF NOT EXISTS agent_prs (
2107
+ url TEXT PRIMARY KEY,
2108
+ session_id TEXT NOT NULL,
2109
+ repo TEXT NOT NULL,
2110
+ number INTEGER NOT NULL,
2111
+ seen_at TEXT NOT NULL DEFAULT (datetime('now')),
2112
+ told_at TEXT
2113
+ );
2114
+ `);
2115
+ },
2116
+ },
2064
2117
  ];
2065
2118
 
2066
2119
  // `upTo`: stop after this id (tests that start from an older database).
package/lib/db/queries.ts CHANGED
@@ -56,15 +56,16 @@ export const queries = {
56
56
  `SELECT ${sessionListColumns(db)} FROM sessions ORDER BY updated_at DESC`
57
57
  ),
58
58
 
59
- // The sidebar's sessions: not archived, and not a task that is over or
60
- // lives on another machine.
59
+ // The sidebar's sessions: not archived, and not a dropped task (a
60
+ // rejection, not finished work) or one that lives on another machine.
61
+ // Merged tasks stay: they are the Done shelf.
61
62
  getSidebarSessions: (db: Database.Database) =>
62
63
  getStmt(
63
64
  db,
64
65
  `SELECT ${sessionListColumns(db)} FROM sessions
65
66
  WHERE archived_at IS NULL
66
67
  AND (task_status IS NULL
67
- OR task_status NOT IN ('merged', 'dropped', 'moved'))
68
+ OR task_status NOT IN ('dropped', 'moved'))
68
69
  ORDER BY updated_at DESC`
69
70
  ),
70
71
 
@@ -6,16 +6,24 @@ export type EndHook = (sessionId: string, how: EndHow) => Promise<void> | void;
6
6
 
7
7
  // What each experiment does when a session ends. An experiment adds itself
8
8
  // here; one that is off is never called.
9
- export const END_HOOKS: Partial<Record<ExperimentId, EndHook>> = {};
9
+ // Loaded on first use so the places that end sessions don't pull in the
10
+ // experiment's code. A hook does its own once-only check in the database: this
11
+ // process forgets what it has seen on a restart.
12
+ export const END_HOOKS: Partial<Record<ExperimentId, EndHook>> = {
13
+ "learning-cards": async (id, how) =>
14
+ (await import("../learning-cards/analyze")).analyzeSession(id, how),
15
+ };
10
16
 
11
- // A merge is seen again on every poll; each session is handed on once.
17
+ // A merge is seen again on every poll; each session is handed on once. A
18
+ // recipe run settling is its own event: its session has usually ended already.
12
19
  const seen = new Set<string>();
13
20
  export const resetEndHooks = () => seen.clear();
14
21
 
15
22
  // Called where sessions end. Never makes the caller wait or fail.
16
23
  export function sessionEnded(sessionId: string, how: EndHow): Promise<void> {
17
- if (seen.has(sessionId)) return Promise.resolve();
18
- seen.add(sessionId);
24
+ const key = how === "recipe-settled" ? `${sessionId}:settled` : sessionId;
25
+ if (seen.has(key)) return Promise.resolve();
26
+ seen.add(key);
19
27
  return (async () => {
20
28
  await new Promise<void>((r) => setImmediate(r));
21
29
  for (const { id } of EXPERIMENTS) {
@@ -0,0 +1,52 @@
1
+ import { randomUUID } from "crypto";
2
+ import { db } from "../../db";
3
+ import { saveItem } from "../../chat/store";
4
+ import { makeSession, workspaceWithProject } from "../../recipes/test-fixtures";
5
+ import type { Step } from "../types";
6
+
7
+ // A finished session in a fresh workspace, its steps written as chat items.
8
+ export function seedSession(
9
+ o: {
10
+ prompt?: string;
11
+ steps?: Step[];
12
+ final?: string;
13
+ orchestrator?: boolean;
14
+ fields?: Record<string, string | number | null>;
15
+ } = {}
16
+ ) {
17
+ const { ws, project } = workspaceWithProject();
18
+ const id = makeSession({
19
+ project_id: project.id,
20
+ task_prompt: o.prompt ?? "Send the latest build to the beta testers",
21
+ name: "Beta build",
22
+ ...o.fields,
23
+ });
24
+ let n = 0;
25
+ const at = () => ({ id: `i${n++}`, createdAt: Date.now() + n });
26
+ for (const s of o.steps ?? [])
27
+ saveItem(
28
+ id,
29
+ s.kind === "user"
30
+ ? { ...at(), kind: "user", text: s.text }
31
+ : {
32
+ ...at(),
33
+ kind: "tool",
34
+ name: s.name,
35
+ title: s.name,
36
+ input: s.command ? { command: s.command } : { file_path: s.path },
37
+ status: s.failed ? "error" : "done",
38
+ output: s.output,
39
+ }
40
+ );
41
+ if (o.final) saveItem(id, { ...at(), kind: "assistant", text: o.final });
42
+ if (o.orchestrator)
43
+ makeSession({ role: "orchestrator", workspace_id: ws.id });
44
+ return { id, ws, project };
45
+ }
46
+
47
+ export const clearCards = () =>
48
+ db.exec(
49
+ `DELETE FROM learning_cards; DELETE FROM experiment_usage; DELETE FROM orchestrator_notes`
50
+ );
51
+
52
+ export const uid = () => randomUUID();
@@ -0,0 +1,63 @@
1
+ import type { SessionInput, Step } from "../types";
2
+
3
+ export const bash = (
4
+ command: string,
5
+ o: { ok?: boolean; output?: string } = {}
6
+ ): Step => ({
7
+ kind: "tool",
8
+ name: "Bash",
9
+ ok: o.ok ?? true,
10
+ failed: !(o.ok ?? true),
11
+ command,
12
+ path: "",
13
+ input: command,
14
+ output: o.output ?? "",
15
+ });
16
+
17
+ export const edit = (path: string, change = "x"): Step => ({
18
+ kind: "tool",
19
+ name: "Edit",
20
+ ok: true,
21
+ failed: false,
22
+ command: "",
23
+ path,
24
+ input: `${path} ${change}`,
25
+ output: "",
26
+ });
27
+
28
+ export const said = (text: string, byPerson = true): Step => ({
29
+ kind: "user",
30
+ text,
31
+ byPerson,
32
+ });
33
+
34
+ export const input = (o: Partial<SessionInput> = {}): SessionInput => ({
35
+ id: "s1",
36
+ name: "A session",
37
+ workspaceId: "w1",
38
+ projectId: "p1",
39
+ skip: null,
40
+ brief: "Refactor the parser module",
41
+ final: "Done. The parser is split in two files.",
42
+ steps: [],
43
+ reviews: [],
44
+ inRepo: true,
45
+ run: null,
46
+ previousRun: null,
47
+ recentBriefs: [],
48
+ ...o,
49
+ });
50
+
51
+ // Sends a build to testers: external CLIs, a report of facts, no repo.
52
+ export const releaseToTesters = (): SessionInput =>
53
+ input({
54
+ brief: "Send the latest build to the beta testers",
55
+ final:
56
+ "Build 57 (1.4.2) sent to 3 testers. Invite id 6a1f2c9d, group Beta.",
57
+ inRepo: false,
58
+ steps: [
59
+ said("Send the latest build to the beta testers"),
60
+ bash("asc builds list --app 123"),
61
+ bash("cd app && eas submit --platform ios"),
62
+ ],
63
+ });
@@ -0,0 +1,228 @@
1
+ // The end-of-session hook: scripts first (free); a helper-model call only
2
+ // on a hit, through the experiments meter, never on the transcript.
3
+
4
+ import os from "os";
5
+ import { runClaude, type ClaudeRunner } from "../orchestrator/claude-cli";
6
+ import { getOrchestrator } from "../orchestrator/home";
7
+ import { addNote } from "../orchestrator/notes";
8
+ import { untrusted, UNTRUSTED_RULE } from "../orchestrator/untrusted";
9
+ import { isRemoteHost } from "../hosts";
10
+ import { getProject } from "../projects";
11
+ import { getRecipe } from "../recipes/store";
12
+ import { draftRequest, parseDraft, recipePrefill } from "../recipes/draft";
13
+ import { countEvent, experimentRun, hasEvent } from "../experiments/meter";
14
+ import { experimentOn } from "../experiments";
15
+ import type { EndHow } from "../experiments/hooks";
16
+ import { readSession } from "./input";
17
+ import { correctionPairs } from "./pairs";
18
+ import { recipeSignals } from "./signals";
19
+ import { insertCard } from "./store";
20
+ import type { Destination, Pair, SessionInput } from "./types";
21
+
22
+ const ID = "learning-cards" as const;
23
+ const LESSON_MAX = 200;
24
+ const TASK_MAX = 600;
25
+
26
+ const inFlight = new Set<string>();
27
+
28
+ const LESSON_SYSTEM = `You judge whether a few corrections from one finished agent session are worth keeping for next time. Reply reusable=false for anything specific to that one piece of work. Otherwise give one plain line (the lesson, under ${LESSON_MAX} characters) and where it belongs:
29
+ - "recipe": the session ran from a saved recipe and the lesson is about running that recipe.
30
+ - "claude_md": a fact about the repository (how to build, test, run or avoid breaking it) that every future agent there should know.
31
+ - "check": better caught by a script or automated check; put a short task brief for it in taskPrompt instead of a lesson.
32
+ - "rule": only a preference about how work is done in this workspace (process, style, when to ask), never a fact about code.
33
+ ${UNTRUSTED_RULE}`;
34
+ const LESSON_SCHEMA = {
35
+ type: "object",
36
+ properties: {
37
+ reusable: { type: "boolean" },
38
+ lesson: { type: "string" },
39
+ destination: { enum: ["recipe", "claude_md", "check", "rule"] },
40
+ taskPrompt: { type: "string" },
41
+ },
42
+ required: ["reusable", "lesson", "destination"],
43
+ };
44
+
45
+ interface Where {
46
+ recipe: { id: string; name: string } | null;
47
+ project: { id: string; name: string } | null;
48
+ repo: boolean;
49
+ workspaceId: string | null;
50
+ }
51
+
52
+ function whereOf(input: SessionInput): Where {
53
+ const project = input.projectId ? getProject(input.projectId) : null;
54
+ const usable = project && !project.is_uncategorized ? project : null;
55
+ const recipe = input.run ? getRecipe(input.run.recipeId) : null;
56
+ return {
57
+ recipe: recipe ? { id: recipe.id, name: recipe.name } : null,
58
+ project: usable ? { id: usable.id, name: usable.name } : null,
59
+ repo: !!usable && input.inRepo && !isRemoteHost(usable.host_id),
60
+ workspaceId: input.workspaceId,
61
+ };
62
+ }
63
+
64
+ export interface Verdict {
65
+ reusable?: unknown;
66
+ lesson?: unknown;
67
+ destination?: unknown;
68
+ taskPrompt?: unknown;
69
+ }
70
+
71
+ // What the model said, held to what the session can actually do. Null: no card.
72
+ export function checkLesson(
73
+ v: Verdict | null,
74
+ where: Where
75
+ ): { lesson: string; destination: Destination; taskPrompt?: string } | null {
76
+ if (!v || v.reusable !== true || typeof v.lesson !== "string") return null;
77
+ const lesson = v.lesson.replace(/\s+/g, " ").trim().slice(0, LESSON_MAX);
78
+ const task =
79
+ typeof v.taskPrompt === "string"
80
+ ? v.taskPrompt.trim().slice(0, TASK_MAX)
81
+ : "";
82
+ let destination = v.destination as Destination;
83
+ // Not run from a recipe: its lesson is a repo fact, if there is a repo.
84
+ if (destination === "recipe" && !where.recipe) destination = "claude_md";
85
+ switch (destination) {
86
+ case "recipe":
87
+ return lesson ? { lesson, destination } : null;
88
+ case "claude_md":
89
+ return lesson && where.repo && where.project
90
+ ? { lesson, destination }
91
+ : null;
92
+ case "check":
93
+ return task && where.project
94
+ ? {
95
+ lesson: lesson || task.slice(0, LESSON_MAX),
96
+ destination,
97
+ taskPrompt: task,
98
+ }
99
+ : null;
100
+ case "rule":
101
+ return lesson && where.workspaceId ? { lesson, destination } : null;
102
+ default:
103
+ return null;
104
+ }
105
+ }
106
+
107
+ const pairsText = (pairs: Pair[]) =>
108
+ pairs
109
+ .map((p, i) => `${i + 1}. [${p.kind}] ${p.before}\n then: ${p.after}`)
110
+ .join("\n");
111
+
112
+ export async function analyzeSession(
113
+ sessionId: string,
114
+ how: EndHow,
115
+ runner: ClaudeRunner = runClaude
116
+ ): Promise<void> {
117
+ if (!experimentOn(ID)) return;
118
+ if (
119
+ inFlight.has(sessionId) ||
120
+ hasEvent(ID, sessionId, "script", "session-end")
121
+ )
122
+ return;
123
+ inFlight.add(sessionId);
124
+ try {
125
+ const input = await readSession(sessionId);
126
+ if (!input || input.skip) return;
127
+ // A recipe run's session ends before the run settles; its settle comes.
128
+ if (input.run && !input.run.settled && how !== "recipe-settled") return;
129
+
130
+ const ctx = { sessionId, workspaceId: input.workspaceId };
131
+ const recipe = recipeSignals(input);
132
+ const pairs = correctionPairs(input);
133
+ countEvent(ID, "script", {
134
+ ...ctx,
135
+ trigger: "session-end",
136
+ detail: `signals: ${recipe.signals.join(",") || "none"}; pairs: ${pairs.length}`,
137
+ });
138
+
139
+ let made = 0;
140
+ if (recipe.hit && input.workspaceId) {
141
+ try {
142
+ recipePrefill(sessionId);
143
+ const out = await experimentRun(
144
+ ID,
145
+ { ...ctx, trigger: "recipe-draft" },
146
+ {
147
+ ...draftRequest(input.brief, input.final, true),
148
+ workspaceId: input.workspaceId,
149
+ },
150
+ runner
151
+ );
152
+ const draft = parseDraft(out);
153
+ if (
154
+ (out as { repeatable?: unknown } | null)?.repeatable === true &&
155
+ draft
156
+ )
157
+ made += insertCard({
158
+ sessionId,
159
+ workspaceId: input.workspaceId,
160
+ kind: "recipe",
161
+ payload: { signals: recipe.signals, ...draft },
162
+ })
163
+ ? 1
164
+ : 0;
165
+ } catch (error) {
166
+ console.warn("[learning-cards] recipe draft:", error);
167
+ }
168
+ }
169
+
170
+ if (pairs.length) {
171
+ try {
172
+ const where = whereOf(input);
173
+ const out = (await experimentRun(
174
+ ID,
175
+ { ...ctx, trigger: "lesson" },
176
+ {
177
+ cwd: os.tmpdir(),
178
+ system: LESSON_SYSTEM,
179
+ prompt: [
180
+ where.recipe
181
+ ? `The session ran from the recipe "${where.recipe.name}".`
182
+ : "The session did not run from a recipe.",
183
+ untrusted("session corrections", pairsText(pairs)),
184
+ ].join("\n\n"),
185
+ schema: LESSON_SCHEMA,
186
+ tools: [],
187
+ role: "helper",
188
+ timeoutMs: 20_000,
189
+ workspaceId: input.workspaceId ?? undefined,
190
+ },
191
+ runner
192
+ )) as Verdict | null;
193
+ const ok = checkLesson(out, where);
194
+ if (ok)
195
+ made += insertCard({
196
+ sessionId,
197
+ workspaceId: input.workspaceId,
198
+ kind: "lesson",
199
+ destination: ok.destination,
200
+ payload: {
201
+ ...ok,
202
+ recipeId: where.recipe?.id,
203
+ recipeName: where.recipe?.name,
204
+ projectId: where.project?.id,
205
+ projectName: where.project?.name,
206
+ pairs: pairs.length,
207
+ },
208
+ })
209
+ ? 1
210
+ : 0;
211
+ } catch (error) {
212
+ console.warn("[learning-cards] lesson:", error);
213
+ }
214
+ }
215
+
216
+ if (made && input.workspaceId && getOrchestrator(input.workspaceId))
217
+ addNote(
218
+ input.workspaceId,
219
+ `Learning card on ${input.name}: ${
220
+ made > 1
221
+ ? "save as a recipe and keep a lesson"
222
+ : "a suggestion to save or keep"
223
+ }? Open the session to decide.`
224
+ );
225
+ } finally {
226
+ inFlight.delete(sessionId);
227
+ }
228
+ }
@@ -0,0 +1,196 @@
1
+ // What a tap on a card does. The only place a lesson is written anywhere.
2
+
3
+ import fs from "fs";
4
+ import path from "path";
5
+ import { db } from "../db";
6
+ import { countEvent } from "../experiments/meter";
7
+ import { experimentOn } from "../experiments";
8
+ import { getOrchestrator } from "../orchestrator/home";
9
+ import { quickPR, startQuickChange } from "../orchestrator/quick";
10
+ import { addRule } from "../orchestrator/rules";
11
+ import { isRemoteHost } from "../hosts";
12
+ import { getProject } from "../projects";
13
+ import { createRecipe, getRecipe, updateRecipe } from "../recipes/store";
14
+ import { recipePrefill } from "../recipes/draft";
15
+ import { createTask } from "../tasks";
16
+ import { claimCard, getCard, releaseCard, settleCard } from "./store";
17
+ import type { Card, LessonPayload, RecipePayload } from "./types";
18
+
19
+ const ID = "learning-cards" as const;
20
+ const HEADING = "## Lessons";
21
+
22
+ export class CardRefused extends Error {}
23
+
24
+ export interface Edits {
25
+ lesson?: string;
26
+ brief?: string;
27
+ taskPrompt?: string;
28
+ }
29
+
30
+ // CLAUDE.md with the line under "## Lessons" (the heading made if missing).
31
+ export function withLesson(text: string, line: string): string {
32
+ const bullet = `- ${line.replace(/\s+/g, " ").trim()}`;
33
+ const lines = text.replace(/\s+$/, "").split("\n");
34
+ const at = lines.findIndex((l) => l.trim() === HEADING);
35
+ if (at === -1)
36
+ return `${text.trim() ? `${lines.join("\n")}\n\n` : ""}${HEADING}\n\n${bullet}\n`;
37
+ let end = at + 1;
38
+ while (end < lines.length && !/^#{1,6}\s/.test(lines[end])) end++;
39
+ while (end > at + 1 && !lines[end - 1].trim()) end--;
40
+ if (lines.slice(at + 1, end).some((l) => l.trim() === bullet))
41
+ return `${lines.join("\n")}\n`;
42
+ lines.splice(end, 0, ...(end === at + 1 ? ["", bullet] : [bullet]));
43
+ return `${lines.join("\n")}\n`;
44
+ }
45
+
46
+ const clean = (s: string | undefined, max: number) =>
47
+ (s ?? "").replace(/\s+/g, " ").trim().slice(0, max);
48
+
49
+ async function toClaudeMd(card: Card, p: LessonPayload, line: string) {
50
+ const project = p.projectId ? getProject(p.projectId) : null;
51
+ if (!project) throw new CardRefused("That project is gone");
52
+ const workspaceId = card.workspace_id;
53
+ const source = (
54
+ db
55
+ .prepare(`SELECT name FROM sessions WHERE id = ?`)
56
+ .get(card.session_id) as { name: string } | undefined
57
+ )?.name;
58
+ if (
59
+ workspaceId &&
60
+ getOrchestrator(workspaceId) &&
61
+ !isRemoteHost(project.host_id)
62
+ ) {
63
+ const quick = await startQuickChange(
64
+ workspaceId,
65
+ project.id,
66
+ "Add a lesson to CLAUDE.md"
67
+ );
68
+ const file = path.join(quick.worktreePath, "CLAUDE.md");
69
+ const before = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
70
+ fs.writeFileSync(file, withLesson(before, line));
71
+ await quickPR(
72
+ workspaceId,
73
+ quick.id,
74
+ "docs: add a lesson to CLAUDE.md",
75
+ `${line}${source ? `\n\nFrom the session "${source}".` : ""}`
76
+ );
77
+ return quick.id;
78
+ }
79
+ const task = await createTask({
80
+ projectId: project.id,
81
+ prompt: `Add exactly this line under a "${HEADING}" heading in CLAUDE.md at the repository root, creating the file and heading if missing, and change nothing else: ${line}`,
82
+ startedBy: null,
83
+ });
84
+ return task.id;
85
+ }
86
+
87
+ async function applyLesson(card: Card, p: LessonPayload, edits: Edits) {
88
+ const line = clean(edits.lesson, 200) || p.lesson;
89
+ switch (p.destination) {
90
+ case "recipe": {
91
+ const recipe = p.recipeId ? getRecipe(p.recipeId) : null;
92
+ if (!recipe || recipe.archived_at)
93
+ throw new CardRefused("That recipe is gone");
94
+ updateRecipe(
95
+ recipe.id,
96
+ { brief: `${recipe.brief}\n\nLesson: ${line}` },
97
+ null
98
+ );
99
+ return recipe.id;
100
+ }
101
+ case "claude_md":
102
+ return toClaudeMd(card, p, line);
103
+ case "check": {
104
+ const prompt = clean(edits.taskPrompt, 600) || p.taskPrompt;
105
+ if (!prompt || !p.projectId)
106
+ throw new CardRefused("There's no task to start");
107
+ return (
108
+ await createTask({ projectId: p.projectId, prompt, startedBy: null })
109
+ ).id;
110
+ }
111
+ case "rule":
112
+ if (!card.workspace_id)
113
+ throw new CardRefused("There's no workspace for a rule");
114
+ return String(addRule(card.workspace_id, line, "saad").id);
115
+ }
116
+ }
117
+
118
+ async function applyRecipe(card: Card, p: RecipePayload, edits: Edits) {
119
+ const base = recipePrefill(card.session_id);
120
+ const recipe = createRecipe(
121
+ {
122
+ workspaceId: base.workspaceId,
123
+ projectId: base.projectId,
124
+ name: base.name,
125
+ kind: base.kind,
126
+ brief: edits.brief?.trim() || p.brief,
127
+ model: base.model ?? undefined,
128
+ view: base.view,
129
+ sourceSessionId: card.session_id,
130
+ },
131
+ null
132
+ );
133
+ return recipe.id;
134
+ }
135
+
136
+ function pending(cardId: number): Card {
137
+ if (!experimentOn(ID)) throw new CardRefused("Learning cards are off");
138
+ const card = getCard(cardId);
139
+ if (!card) throw new CardRefused("No such card");
140
+ if (card.status !== "pending") throw new CardRefused("Already decided");
141
+ return card;
142
+ }
143
+
144
+ const meter = (card: Card, event: "accepted" | "dismissed") =>
145
+ countEvent(
146
+ ID,
147
+ event,
148
+ {
149
+ trigger: "card-apply",
150
+ sessionId: card.session_id,
151
+ workspaceId: card.workspace_id,
152
+ detail: card.kind === "lesson" ? `lesson: ${card.destination}` : "recipe",
153
+ },
154
+ true
155
+ );
156
+
157
+ export async function applyCard(
158
+ cardId: number,
159
+ edits: Edits = {}
160
+ ): Promise<Card> {
161
+ const card = pending(cardId);
162
+ if (!claimCard(cardId)) throw new CardRefused("Already decided");
163
+ try {
164
+ const ref =
165
+ card.kind === "recipe"
166
+ ? await applyRecipe(card, card.payload as RecipePayload, edits)
167
+ : await applyLesson(card, card.payload as LessonPayload, edits);
168
+ settleCard(cardId, "accepted", ref);
169
+ } catch (error) {
170
+ releaseCard(cardId);
171
+ throw error;
172
+ }
173
+ meter(card, "accepted");
174
+ return getCard(cardId)!;
175
+ }
176
+
177
+ export function dismissCard(cardId: number): Card {
178
+ const card = pending(cardId);
179
+ settleCard(cardId, "dismissed", null);
180
+ meter(card, "dismissed");
181
+ return getCard(cardId)!;
182
+ }
183
+
184
+ // The recipe form saved a recipe from this session: its card is taken.
185
+ export function acceptRecipeCardFor(sessionId: string, recipeId: string): void {
186
+ if (!experimentOn(ID)) return;
187
+ const row = db
188
+ .prepare(
189
+ `SELECT id FROM learning_cards WHERE session_id = ? AND kind = 'recipe' AND status = 'pending'`
190
+ )
191
+ .get(sessionId) as { id: number } | undefined;
192
+ if (!row || !claimCard(row.id)) return;
193
+ const card = getCard(row.id)!;
194
+ settleCard(row.id, "accepted", recipeId);
195
+ meter(card, "accepted");
196
+ }