@saadnvd1/agent-os 0.3.557 → 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 -1
- 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/data/learning-cards/index.ts +79 -0
- package/lib/chat/store.ts +16 -0
- package/lib/db/migrations.ts +26 -0
- package/lib/experiments/hooks.ts +12 -4
- 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/package.json +1 -1
package/lib/db/migrations.ts
CHANGED
|
@@ -2061,6 +2061,32 @@ 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
|
+
},
|
|
2064
2090
|
];
|
|
2065
2091
|
|
|
2066
2092
|
// `upTo`: stop after this id (tests that start from an older database).
|
package/lib/experiments/hooks.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
18
|
-
seen.
|
|
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
|
+
}
|
|
@@ -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
|
+
}
|