@saadnvd1/agent-os 0.3.556 → 0.3.558

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 (48) hide show
  1. package/README.md +1 -0
  2. package/app/api/experiments/[id]/route.ts +39 -0
  3. package/app/api/experiments/route.test.ts +51 -0
  4. package/app/api/experiments/route.ts +7 -0
  5. package/app/api/learning-cards/[id]/route.test.ts +62 -0
  6. package/app/api/learning-cards/[id]/route.ts +36 -0
  7. package/app/api/recipes/draft/route.ts +6 -1
  8. package/app/api/recipes/route.ts +3 -0
  9. package/app/api/sessions/[id]/learning-cards/route.test.ts +35 -0
  10. package/app/api/sessions/[id]/learning-cards/route.ts +10 -0
  11. package/components/Chat/ChatPanel.tsx +2 -0
  12. package/components/LearningCards/LearningCard.test.ts +156 -0
  13. package/components/LearningCards/LearningCard.tsx +210 -0
  14. package/components/Settings/ExperimentsSection.test.ts +89 -0
  15. package/components/Settings/ExperimentsSection.tsx +225 -0
  16. package/components/Settings/SettingsDialog.test.ts +3 -0
  17. package/components/Settings/SettingsDialog.tsx +9 -0
  18. package/components/Settings/commands.ts +9 -0
  19. package/components/Settings/entry-points.test.ts +1 -0
  20. package/data/experiments/index.ts +67 -0
  21. package/data/learning-cards/index.ts +79 -0
  22. package/lib/chat/store.ts +16 -0
  23. package/lib/db/migrations.ts +59 -0
  24. package/lib/done/index.ts +2 -0
  25. package/lib/experiments/hooks.ts +39 -0
  26. package/lib/experiments/index.ts +19 -0
  27. package/lib/experiments/meter.ts +327 -0
  28. package/lib/experiments/registry.ts +37 -0
  29. package/lib/experiments/view.ts +19 -0
  30. package/lib/learning-cards/__fixtures__/db.ts +52 -0
  31. package/lib/learning-cards/__fixtures__/sessions.ts +63 -0
  32. package/lib/learning-cards/analyze.ts +228 -0
  33. package/lib/learning-cards/apply.ts +196 -0
  34. package/lib/learning-cards/index.ts +29 -0
  35. package/lib/learning-cards/input.ts +116 -0
  36. package/lib/learning-cards/pairs.ts +111 -0
  37. package/lib/learning-cards/signals.ts +98 -0
  38. package/lib/learning-cards/store.ts +103 -0
  39. package/lib/learning-cards/types.ts +79 -0
  40. package/lib/orchestrator/quick.ts +14 -1
  41. package/lib/recipes/draft.ts +62 -33
  42. package/lib/recipes/settle.ts +4 -1
  43. package/lib/settings-store.ts +22 -0
  44. package/lib/settings-url.ts +1 -0
  45. package/lib/tasks/finish.ts +2 -0
  46. package/lib/tasks/session.ts +2 -0
  47. package/lib/updates/settings.ts +2 -20
  48. package/package.json +1 -1
@@ -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
+ }
@@ -0,0 +1,29 @@
1
+ import { experimentOn } from "../experiments";
2
+ import { countEvent } from "../experiments/meter";
3
+ import { listCards, markShown } from "./store";
4
+ import type { Card, RecipePayload } from "./types";
5
+
6
+ const ID = "learning-cards" as const;
7
+
8
+ // A session's pending cards. Nothing, and no work, while the experiment is off.
9
+ export function cardsFor(sessionId: string): Card[] {
10
+ if (!experimentOn(ID)) return [];
11
+ const cards = listCards(sessionId, "pending");
12
+ for (const c of cards)
13
+ if (markShown(c.id))
14
+ countEvent(ID, "shown", {
15
+ trigger: "card-view",
16
+ sessionId,
17
+ workspaceId: c.workspace_id,
18
+ detail: c.kind,
19
+ });
20
+ return cards;
21
+ }
22
+
23
+ // The brief a pending recipe card drafted, so Edit needn't draft it again.
24
+ export function pendingRecipeDraft(sessionId: string) {
25
+ if (!experimentOn(ID)) return undefined;
26
+ const card = listCards(sessionId, "pending").find((c) => c.kind === "recipe");
27
+ const p = card?.payload as RecipePayload | undefined;
28
+ return p ? { brief: p.brief, summary: p.summary } : undefined;
29
+ }
@@ -0,0 +1,116 @@
1
+ // Reads one finished session into the plain object the scripts take: the
2
+ // brief, the final report and the tool and user steps, each cut down.
3
+ // Never the transcript.
4
+
5
+ import { db, queries, type Session } from "../db";
6
+ import { itemsOfKind, itemsOfKinds } from "../chat/store";
7
+ import { isGitRepo } from "../git";
8
+ import { getProject } from "../projects";
9
+ import { finalMessage } from "../session-logs/gather";
10
+ import { getRecipe, listRuns, runForSession } from "../recipes/store";
11
+ import { isLive, type RecipeRun } from "../recipes/types";
12
+ import type { RunInfo, SessionInput, Step } from "./types";
13
+
14
+ const CUT = 300;
15
+ const BRIEF = 1500;
16
+ const FINAL = 2000;
17
+ const RECENT_DAYS = 60;
18
+ const RECENT_BRIEFS = 200;
19
+
20
+ const cut = (s: string, n = CUT) => s.slice(0, n);
21
+
22
+ function stepOf(item: ReturnType<typeof itemsOfKinds>[number]): Step | null {
23
+ if (item.kind === "user") {
24
+ const byPerson = !item.peer && !item.from && !item.origin;
25
+ return { kind: "user", text: cut(item.text), byPerson };
26
+ }
27
+ if (item.kind !== "tool") return null;
28
+ const input = (item.input ?? {}) as Record<string, unknown>;
29
+ const command = typeof input.command === "string" ? input.command : "";
30
+ const path = typeof input.file_path === "string" ? input.file_path : "";
31
+ return {
32
+ kind: "tool",
33
+ name: item.name,
34
+ ok: item.status === "done",
35
+ failed: item.status === "error",
36
+ command: cut(command),
37
+ path,
38
+ input: cut(command || path || JSON.stringify(item.input ?? "")),
39
+ output: (item.output ?? "").slice(-CUT),
40
+ };
41
+ }
42
+
43
+ const runInfo = (r: RecipeRun): RunInfo => ({
44
+ status: r.status,
45
+ summary: r.summary ?? "",
46
+ error: r.error ?? "",
47
+ settled: !isLive(r.status),
48
+ });
49
+
50
+ export async function readSession(id: string): Promise<SessionInput | null> {
51
+ const s = queries.getSession(db).get(id) as Session | undefined;
52
+ if (!s) return null;
53
+ const project = s.project_id ? getProject(s.project_id) : null;
54
+ const workspaceId = project?.workspace_id ?? s.workspace_id ?? null;
55
+ const steps = itemsOfKinds(id, ["user", "tool"])
56
+ .map(stepOf)
57
+ .filter((x): x is Step => !!x);
58
+ const final = finalMessage(id) ?? "";
59
+ const brief = (s.task_prompt || itemsOfKind(id, "user")[0]?.text || "").slice(
60
+ 0,
61
+ BRIEF
62
+ );
63
+ let skip: string | null = null;
64
+ if (s.role === "orchestrator") skip = "orchestrator";
65
+ else if (s.quick_of) skip = "quick change";
66
+ else if (!steps.length && !final) skip = "no conversation";
67
+
68
+ const run = runForSession(id);
69
+ const previous = run
70
+ ? listRuns(run.recipe_id, { before: run.id, limit: 5 }).find(
71
+ (r) =>
72
+ !isLive(r.status) &&
73
+ r.status !== "cancelled" &&
74
+ r.status !== "skipped"
75
+ )
76
+ : undefined;
77
+ const recipe = run ? getRecipe(run.recipe_id) : null;
78
+
79
+ const recentBriefs = workspaceId
80
+ ? (
81
+ db
82
+ .prepare(
83
+ `SELECT s.task_prompt AS p FROM sessions s JOIN projects p ON p.id = s.project_id
84
+ WHERE p.workspace_id = ? AND s.id != ? AND s.quick_of IS NULL
85
+ AND s.task_prompt IS NOT NULL
86
+ AND s.created_at >= datetime('now', ?)
87
+ ORDER BY s.created_at DESC LIMIT ?`
88
+ )
89
+ .all(workspaceId, id, `-${RECENT_DAYS} days`, RECENT_BRIEFS) as {
90
+ p: string;
91
+ }[]
92
+ ).map((r) => r.p)
93
+ : [];
94
+
95
+ return {
96
+ id,
97
+ name: s.name,
98
+ workspaceId,
99
+ projectId: s.project_id ?? null,
100
+ skip,
101
+ brief,
102
+ final: final.slice(-FINAL),
103
+ steps,
104
+ reviews: db
105
+ .prepare(
106
+ `SELECT status, COALESCE(detail, '') AS detail FROM orchestrator_checks
107
+ WHERE session_id = ? AND kind = 'review' ORDER BY created_at, id`
108
+ )
109
+ .all(id) as { status: string; detail: string }[],
110
+ // The project's own folder: a task's worktree is gone once it's merged.
111
+ inRepo: await isGitRepo(project?.working_directory ?? s.working_directory),
112
+ run: run && recipe ? { ...runInfo(run), recipeId: recipe.id } : null,
113
+ previousRun: previous ? runInfo(previous) : null,
114
+ recentBriefs,
115
+ };
116
+ }
@@ -0,0 +1,111 @@
1
+ // Zero-token extraction of correction pairs: a thing that went wrong and
2
+ // what fixed it, a few hundred characters each.
3
+
4
+ import type { Pair, SessionInput, Step } from "./types";
5
+
6
+ const SIDE = 300;
7
+ const MAX_PAIRS = 4;
8
+ const MAX_TOTAL = 6000;
9
+ const LOOKAHEAD = 6;
10
+
11
+ const clip = (s: string, n = SIDE) => {
12
+ const one = s.replace(/\s+/g, " ").trim();
13
+ return one.length > n ? `${one.slice(0, n - 1)}…` : one;
14
+ };
15
+
16
+ type Tool = Extract<Step, { kind: "tool" }>;
17
+
18
+ const firstWord = (c: string) => c.trim().split(/\s+/)[0] ?? "";
19
+
20
+ // Same tool, same job (the command's first word or the file), different input.
21
+ function sameJobDifferently(bad: Tool, good: Tool): boolean {
22
+ if (bad.name !== good.name || bad.input === good.input) return false;
23
+ if (bad.name === "Bash")
24
+ return !!bad.command && firstWord(bad.command) === firstWord(good.command);
25
+ return !!bad.path && bad.path === good.path;
26
+ }
27
+
28
+ const CORRECTION =
29
+ /^\s*(?:no\b|nope\b|wrong\b|not that\b|actually\b|stop\b|don'?t\b|do not\b)|\binstead\b|\buse\b.{1,60}\bnot\b|\bthat'?s (?:wrong|not)\b/i;
30
+ const CHANGES = new Set(["Edit", "Write", "MultiEdit", "Bash"]);
31
+
32
+ function toolRetries(steps: Step[]): Pair[] {
33
+ const out: Pair[] = [];
34
+ const tools = steps.filter((s): s is Tool => s.kind === "tool");
35
+ tools.forEach((bad, i) => {
36
+ if (!bad.failed) return;
37
+ const good = tools
38
+ .slice(i + 1, i + 1 + LOOKAHEAD)
39
+ .find((t) => t.ok && sameJobDifferently(bad, t));
40
+ if (!good) return;
41
+ out.push({
42
+ kind: "tool-retry",
43
+ before: clip(`${bad.name}: ${bad.input} -> ${bad.output}`),
44
+ after: clip(`${good.name}: ${good.input}`),
45
+ });
46
+ });
47
+ return out;
48
+ }
49
+
50
+ function reviewPair(input: SessionInput): Pair[] {
51
+ const at = input.reviews.findIndex((r) => r.status === "block");
52
+ if (at === -1) return [];
53
+ if (!input.reviews.slice(at + 1).some((r) => r.status === "pass")) return [];
54
+ return [
55
+ {
56
+ kind: "review",
57
+ before: clip(input.reviews[at].detail || "review blocked"),
58
+ after: "the next review passed",
59
+ },
60
+ ];
61
+ }
62
+
63
+ function userCorrections(steps: Step[]): Pair[] {
64
+ const out: Pair[] = [];
65
+ const firstUser = steps.findIndex((s) => s.kind === "user");
66
+ steps.forEach((s, i) => {
67
+ if (s.kind !== "user" || i === firstUser || !s.byPerson) return;
68
+ if (!CORRECTION.test(s.text)) return;
69
+ const fix = steps
70
+ .slice(i + 1)
71
+ .find((n): n is Tool => n.kind === "tool" && n.ok && CHANGES.has(n.name));
72
+ if (!fix) return;
73
+ out.push({
74
+ kind: "user-correction",
75
+ before: clip(s.text),
76
+ after: clip(`${fix.name}: ${fix.input}`),
77
+ });
78
+ });
79
+ return out;
80
+ }
81
+
82
+ function recipePair(input: SessionInput): Pair[] {
83
+ const { run, previousRun: prev } = input;
84
+ if (!run || !prev || run.status !== "succeeded") return [];
85
+ if (prev.status !== "failed" && prev.status !== "timed_out") return [];
86
+ return [
87
+ {
88
+ kind: "recipe-retry",
89
+ before: clip(prev.error || prev.summary || prev.status),
90
+ after: clip(run.summary || "succeeded"),
91
+ },
92
+ ];
93
+ }
94
+
95
+ export function correctionPairs(input: SessionInput): Pair[] {
96
+ const all = [
97
+ ...recipePair(input),
98
+ ...reviewPair(input),
99
+ ...userCorrections(input.steps),
100
+ ...toolRetries(input.steps),
101
+ ];
102
+ const out: Pair[] = [];
103
+ let total = 0;
104
+ for (const p of all) {
105
+ const size = p.before.length + p.after.length;
106
+ if (out.length >= MAX_PAIRS || total + size > MAX_TOTAL) break;
107
+ total += size;
108
+ out.push(p);
109
+ }
110
+ return out;
111
+ }