@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.
- package/README.md +1 -0
- package/app/api/experiments/[id]/route.ts +39 -0
- package/app/api/experiments/route.test.ts +51 -0
- package/app/api/experiments/route.ts +7 -0
- package/app/api/learning-cards/[id]/route.test.ts +62 -0
- package/app/api/learning-cards/[id]/route.ts +36 -0
- package/app/api/recipes/draft/route.ts +6 -1
- package/app/api/recipes/route.ts +3 -0
- package/app/api/sessions/[id]/learning-cards/route.test.ts +35 -0
- package/app/api/sessions/[id]/learning-cards/route.ts +10 -0
- package/components/Chat/ChatPanel.tsx +2 -0
- package/components/LearningCards/LearningCard.test.ts +156 -0
- package/components/LearningCards/LearningCard.tsx +210 -0
- package/components/Settings/ExperimentsSection.test.ts +89 -0
- package/components/Settings/ExperimentsSection.tsx +225 -0
- package/components/Settings/SettingsDialog.test.ts +3 -0
- package/components/Settings/SettingsDialog.tsx +9 -0
- package/components/Settings/commands.ts +9 -0
- package/components/Settings/entry-points.test.ts +1 -0
- package/data/experiments/index.ts +67 -0
- package/data/learning-cards/index.ts +79 -0
- package/lib/chat/store.ts +16 -0
- package/lib/db/migrations.ts +59 -0
- package/lib/done/index.ts +2 -0
- package/lib/experiments/hooks.ts +39 -0
- package/lib/experiments/index.ts +19 -0
- package/lib/experiments/meter.ts +327 -0
- package/lib/experiments/registry.ts +37 -0
- package/lib/experiments/view.ts +19 -0
- package/lib/learning-cards/__fixtures__/db.ts +52 -0
- package/lib/learning-cards/__fixtures__/sessions.ts +63 -0
- package/lib/learning-cards/analyze.ts +228 -0
- package/lib/learning-cards/apply.ts +196 -0
- package/lib/learning-cards/index.ts +29 -0
- package/lib/learning-cards/input.ts +116 -0
- package/lib/learning-cards/pairs.ts +111 -0
- package/lib/learning-cards/signals.ts +98 -0
- package/lib/learning-cards/store.ts +103 -0
- package/lib/learning-cards/types.ts +79 -0
- package/lib/orchestrator/quick.ts +14 -1
- package/lib/recipes/draft.ts +62 -33
- package/lib/recipes/settle.ts +4 -1
- package/lib/settings-store.ts +22 -0
- package/lib/settings-url.ts +1 -0
- package/lib/tasks/finish.ts +2 -0
- package/lib/tasks/session.ts +2 -0
- package/lib/updates/settings.ts +2 -20
- 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
|
+
}
|