@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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Zero-token check for "this session is probably a recipe".
|
|
2
|
+
|
|
3
|
+
import { parseResult } from "../recipes/result";
|
|
4
|
+
import type { SessionInput } from "./types";
|
|
5
|
+
|
|
6
|
+
// First words of a command that reach outside the repo.
|
|
7
|
+
export const EXTERNAL_CLIS = [
|
|
8
|
+
"asc",
|
|
9
|
+
"eas",
|
|
10
|
+
"tv",
|
|
11
|
+
"gh api",
|
|
12
|
+
"gh release",
|
|
13
|
+
"xcrun",
|
|
14
|
+
"fastlane",
|
|
15
|
+
"curl",
|
|
16
|
+
"aws",
|
|
17
|
+
"gcloud",
|
|
18
|
+
"kubectl",
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
export interface RecipeSignals {
|
|
22
|
+
hit: boolean;
|
|
23
|
+
signals: string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// "cd x && FOO=1 npx eas submit" -> "eas submit".
|
|
27
|
+
export function commandStart(command: string): string {
|
|
28
|
+
const parts = command.split(/\s*(?:&&|;)\s*/).map((p) => p.trim());
|
|
29
|
+
const real = parts.find((p) => p && !/^cd\s/.test(p)) ?? "";
|
|
30
|
+
return real
|
|
31
|
+
.replace(/^(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)+/, "")
|
|
32
|
+
.replace(/^(?:npx|bunx|sudo|time)\s+/, "");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const isExternal = (command: string): boolean => {
|
|
36
|
+
const start = commandStart(command);
|
|
37
|
+
return EXTERNAL_CLIS.some((c) => start === c || start.startsWith(`${c} `));
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const FACT_PATTERNS = [
|
|
41
|
+
/\b(?:build|run|release)\s*#?\d+/i,
|
|
42
|
+
/#\d{2,}/,
|
|
43
|
+
/\b\d+\.\d+\.\d+\b/,
|
|
44
|
+
/\b[0-9a-f]{7,}\b/i,
|
|
45
|
+
/^\s*[-*]?\s*[A-Za-z][\w ]{1,24}:\s+\S+/m,
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
export function hasFacts(final: string): boolean {
|
|
49
|
+
if (parseResult(final)) return true;
|
|
50
|
+
return FACT_PATTERNS.filter((p) => p.test(final)).length >= 2;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const STOP = new Set(
|
|
54
|
+
"a an and are as at be but by do for from has have in into is it its of on or that the this to was we with you your please then than so if not can will just all any also".split(
|
|
55
|
+
" "
|
|
56
|
+
)
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
export function words(text: string): Set<string> {
|
|
60
|
+
return new Set(
|
|
61
|
+
text
|
|
62
|
+
.toLowerCase()
|
|
63
|
+
.split(/[^a-z0-9]+/)
|
|
64
|
+
.filter((w) => w.length > 1 && !STOP.has(w) && !/^\d+$/.test(w))
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function jaccard(a: Set<string>, b: Set<string>): number {
|
|
69
|
+
if (!a.size || !b.size) return 0;
|
|
70
|
+
let both = 0;
|
|
71
|
+
for (const w of a) if (b.has(w)) both++;
|
|
72
|
+
return both / (a.size + b.size - both);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export const REPEAT_AT = 0.6;
|
|
76
|
+
const MIN_WORDS = 4;
|
|
77
|
+
|
|
78
|
+
export function repeats(brief: string, earlier: string[]): boolean {
|
|
79
|
+
const mine = words(brief);
|
|
80
|
+
if (mine.size < MIN_WORDS) return false;
|
|
81
|
+
return earlier.some((b) => jaccard(mine, words(b)) >= REPEAT_AT);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function recipeSignals(input: SessionInput): RecipeSignals {
|
|
85
|
+
if (input.skip || input.run) return { hit: false, signals: [] };
|
|
86
|
+
const signals: string[] = [];
|
|
87
|
+
const external = input.steps.some(
|
|
88
|
+
(s) => s.kind === "tool" && s.name === "Bash" && isExternal(s.command)
|
|
89
|
+
);
|
|
90
|
+
if (external) signals.push("external");
|
|
91
|
+
if (!input.inRepo) signals.push("outside-repo");
|
|
92
|
+
if (hasFacts(input.final)) signals.push("facts");
|
|
93
|
+
if (repeats(input.brief, input.recentBriefs)) signals.push("repeat");
|
|
94
|
+
const hit =
|
|
95
|
+
signals.includes("repeat") ||
|
|
96
|
+
((external || !input.inRepo) && signals.includes("facts"));
|
|
97
|
+
return { hit, signals };
|
|
98
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { db } from "../db";
|
|
2
|
+
import type {
|
|
3
|
+
Card,
|
|
4
|
+
CardKind,
|
|
5
|
+
CardStatus,
|
|
6
|
+
Destination,
|
|
7
|
+
LessonPayload,
|
|
8
|
+
RecipePayload,
|
|
9
|
+
} from "./types";
|
|
10
|
+
|
|
11
|
+
interface Row extends Omit<Card, "payload"> {
|
|
12
|
+
payload: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const toCard = (r: Row): Card => ({ ...r, payload: JSON.parse(r.payload) });
|
|
16
|
+
|
|
17
|
+
export function getCard(id: number): Card | null {
|
|
18
|
+
const row = db
|
|
19
|
+
.prepare(`SELECT * FROM learning_cards WHERE id = ?`)
|
|
20
|
+
.get(id) as Row | undefined;
|
|
21
|
+
return row ? toCard(row) : null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function listCards(sessionId: string, status?: CardStatus): Card[] {
|
|
25
|
+
const rows = db
|
|
26
|
+
.prepare(
|
|
27
|
+
`SELECT * FROM learning_cards WHERE session_id = ?
|
|
28
|
+
AND (? IS NULL OR status = ?) ORDER BY id`
|
|
29
|
+
)
|
|
30
|
+
.all(sessionId, status ?? null, status ?? null) as Row[];
|
|
31
|
+
return rows.map(toCard);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// One card per kind per session; a second insert is ignored.
|
|
35
|
+
export function insertCard(c: {
|
|
36
|
+
sessionId: string;
|
|
37
|
+
workspaceId: string | null;
|
|
38
|
+
kind: CardKind;
|
|
39
|
+
payload: RecipePayload | LessonPayload;
|
|
40
|
+
destination?: Destination;
|
|
41
|
+
}): Card | null {
|
|
42
|
+
const { lastInsertRowid, changes } = db
|
|
43
|
+
.prepare(
|
|
44
|
+
`INSERT OR IGNORE INTO learning_cards
|
|
45
|
+
(session_id, workspace_id, kind, payload, destination, created_at)
|
|
46
|
+
VALUES (?, ?, ?, ?, ?, ?)`
|
|
47
|
+
)
|
|
48
|
+
.run(
|
|
49
|
+
c.sessionId,
|
|
50
|
+
c.workspaceId,
|
|
51
|
+
c.kind,
|
|
52
|
+
JSON.stringify(c.payload),
|
|
53
|
+
c.destination ?? null,
|
|
54
|
+
Date.now()
|
|
55
|
+
);
|
|
56
|
+
return changes ? getCard(Number(lastInsertRowid)) : null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Takes the card for deciding; false when someone else already did.
|
|
60
|
+
export function claimCard(id: number): boolean {
|
|
61
|
+
return (
|
|
62
|
+
db
|
|
63
|
+
.prepare(
|
|
64
|
+
`UPDATE learning_cards SET status = 'accepted' WHERE id = ? AND status = 'pending'`
|
|
65
|
+
)
|
|
66
|
+
.run(id).changes === 1
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function releaseCard(id: number): void {
|
|
71
|
+
db.prepare(
|
|
72
|
+
`UPDATE learning_cards SET status = 'pending' WHERE id = ? AND status = 'accepted' AND decided_at IS NULL`
|
|
73
|
+
).run(id);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function settleCard(
|
|
77
|
+
id: number,
|
|
78
|
+
status: "accepted" | "dismissed",
|
|
79
|
+
appliedRef: string | null,
|
|
80
|
+
payload?: RecipePayload | LessonPayload
|
|
81
|
+
): void {
|
|
82
|
+
db.prepare(
|
|
83
|
+
`UPDATE learning_cards SET status = ?, applied_ref = ?, decided_at = ?,
|
|
84
|
+
payload = COALESCE(?, payload) WHERE id = ?`
|
|
85
|
+
).run(
|
|
86
|
+
status,
|
|
87
|
+
appliedRef,
|
|
88
|
+
Date.now(),
|
|
89
|
+
payload ? JSON.stringify(payload) : null,
|
|
90
|
+
id
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// First time a card is read: stamp it, and say whether this call did.
|
|
95
|
+
export function markShown(id: number): boolean {
|
|
96
|
+
return (
|
|
97
|
+
db
|
|
98
|
+
.prepare(
|
|
99
|
+
`UPDATE learning_cards SET shown_at = ? WHERE id = ? AND shown_at IS NULL`
|
|
100
|
+
)
|
|
101
|
+
.run(Date.now(), id).changes === 1
|
|
102
|
+
);
|
|
103
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
export type CardKind = "recipe" | "lesson";
|
|
2
|
+
export type CardStatus = "pending" | "accepted" | "dismissed";
|
|
3
|
+
export type Destination = "recipe" | "claude_md" | "check" | "rule";
|
|
4
|
+
|
|
5
|
+
export interface RecipePayload {
|
|
6
|
+
signals: string[];
|
|
7
|
+
brief: string;
|
|
8
|
+
summary: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface LessonPayload {
|
|
12
|
+
lesson: string;
|
|
13
|
+
destination: Destination;
|
|
14
|
+
recipeId?: string;
|
|
15
|
+
recipeName?: string;
|
|
16
|
+
projectId?: string;
|
|
17
|
+
projectName?: string;
|
|
18
|
+
taskPrompt?: string;
|
|
19
|
+
pairs: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface Card {
|
|
23
|
+
id: number;
|
|
24
|
+
session_id: string;
|
|
25
|
+
workspace_id: string | null;
|
|
26
|
+
kind: CardKind;
|
|
27
|
+
status: CardStatus;
|
|
28
|
+
payload: RecipePayload | LessonPayload;
|
|
29
|
+
destination: Destination | null;
|
|
30
|
+
applied_ref: string | null;
|
|
31
|
+
shown_at: number | null;
|
|
32
|
+
decided_at: number | null;
|
|
33
|
+
created_at: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// One step of a session, cut down to what the scripts read.
|
|
37
|
+
export type Step =
|
|
38
|
+
| { kind: "user"; text: string; byPerson: boolean }
|
|
39
|
+
| {
|
|
40
|
+
kind: "tool";
|
|
41
|
+
name: string;
|
|
42
|
+
ok: boolean;
|
|
43
|
+
failed: boolean;
|
|
44
|
+
command: string;
|
|
45
|
+
path: string;
|
|
46
|
+
input: string;
|
|
47
|
+
output: string;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export interface RunInfo {
|
|
51
|
+
status: string;
|
|
52
|
+
summary: string;
|
|
53
|
+
error: string;
|
|
54
|
+
// Settled: not queued, starting or running.
|
|
55
|
+
settled: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// What the scripts know about one session. Never the transcript.
|
|
59
|
+
export interface SessionInput {
|
|
60
|
+
id: string;
|
|
61
|
+
name: string;
|
|
62
|
+
workspaceId: string | null;
|
|
63
|
+
projectId: string | null;
|
|
64
|
+
skip: string | null;
|
|
65
|
+
brief: string;
|
|
66
|
+
final: string;
|
|
67
|
+
steps: Step[];
|
|
68
|
+
reviews: { status: string; detail: string }[];
|
|
69
|
+
inRepo: boolean;
|
|
70
|
+
run: (RunInfo & { recipeId: string }) | null;
|
|
71
|
+
previousRun: RunInfo | null;
|
|
72
|
+
recentBriefs: string[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface Pair {
|
|
76
|
+
kind: "tool-retry" | "review" | "user-correction" | "recipe-retry";
|
|
77
|
+
before: string;
|
|
78
|
+
after: string;
|
|
79
|
+
}
|
|
@@ -53,6 +53,15 @@ export async function quickChange(
|
|
|
53
53
|
projectRef: string,
|
|
54
54
|
name: string
|
|
55
55
|
): Promise<string> {
|
|
56
|
+
return (await startQuickChange(workspaceId, projectRef, name)).text;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// The same, for a caller that needs the new session's id and worktree.
|
|
60
|
+
export async function startQuickChange(
|
|
61
|
+
workspaceId: string,
|
|
62
|
+
projectRef: string,
|
|
63
|
+
name: string
|
|
64
|
+
): Promise<{ id: string; worktreePath: string; text: string }> {
|
|
56
65
|
const owner = ownerOf(workspaceId);
|
|
57
66
|
const project = workspaceProject(workspaceId, projectRef);
|
|
58
67
|
if (isRemoteHost(project.host_id))
|
|
@@ -107,7 +116,11 @@ export async function quickChange(
|
|
|
107
116
|
workspaceId,
|
|
108
117
|
`Quick change "${label}" in ${project.name} on ${wt.branchName}.`
|
|
109
118
|
);
|
|
110
|
-
return
|
|
119
|
+
return {
|
|
120
|
+
id,
|
|
121
|
+
worktreePath: wt.worktreePath,
|
|
122
|
+
text: `Quick change "${label}" (id ${id.slice(0, 8)}) in ${project.name}: worktree ${wt.worktreePath} on ${wt.branchName}, cut from ${baseBranch}. Edit files there with Write and Edit; git there only as \`git -C ${wt.worktreePath} <status|diff|add|...>\`. Then quick_pr commits, pushes and opens its PR.`,
|
|
123
|
+
};
|
|
111
124
|
}
|
|
112
125
|
|
|
113
126
|
// A quick change of this workspace's orchestrator, by name, id or PR.
|
package/lib/recipes/draft.ts
CHANGED
|
@@ -40,10 +40,10 @@ const SCHEMA = {
|
|
|
40
40
|
required: ["brief", "summary"],
|
|
41
41
|
};
|
|
42
42
|
|
|
43
|
-
export
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
):
|
|
43
|
+
export type RecipePrefill = Omit<RecipeDraft, "draft">;
|
|
44
|
+
|
|
45
|
+
// The form's deterministic part; refuses a session that can't become a recipe.
|
|
46
|
+
export function recipePrefill(sessionId: string): RecipePrefill {
|
|
47
47
|
const s = queries.getSession(db).get(sessionId) as Session | undefined;
|
|
48
48
|
if (!s) throw new Error("Session not found");
|
|
49
49
|
const project = s.project_id ? getProject(s.project_id) : null;
|
|
@@ -52,34 +52,6 @@ export async function recipeDraft(
|
|
|
52
52
|
const first = itemsOfKind(s.id, "user")[0]?.text ?? "";
|
|
53
53
|
const prompt = (s.task_prompt || first).trim().slice(0, MAX_BRIEF);
|
|
54
54
|
if (!prompt) throw new Error("That session has no prompt to save");
|
|
55
|
-
const final = finalMessage(s.id);
|
|
56
|
-
let draft: RecipeDraft["draft"] = null;
|
|
57
|
-
try {
|
|
58
|
-
const out = (await run({
|
|
59
|
-
cwd: os.tmpdir(),
|
|
60
|
-
system: SYSTEM,
|
|
61
|
-
prompt: [
|
|
62
|
-
`What it was asked:\n${prompt.slice(0, 1500)}`,
|
|
63
|
-
final ? `What it ended with:\n${final.slice(-2000)}` : "",
|
|
64
|
-
]
|
|
65
|
-
.filter(Boolean)
|
|
66
|
-
.join("\n\n"),
|
|
67
|
-
schema: SCHEMA,
|
|
68
|
-
tools: [],
|
|
69
|
-
role: "helper",
|
|
70
|
-
timeoutMs: 20_000,
|
|
71
|
-
})) as { brief?: unknown; summary?: unknown };
|
|
72
|
-
if (typeof out?.brief === "string" && out.brief.trim())
|
|
73
|
-
draft = {
|
|
74
|
-
brief: out.brief.trim().slice(0, MAX_BRIEF),
|
|
75
|
-
summary: typeof out.summary === "string" ? out.summary.trim() : "",
|
|
76
|
-
};
|
|
77
|
-
} catch (error) {
|
|
78
|
-
console.warn(
|
|
79
|
-
"[recipes] no drafted brief:",
|
|
80
|
-
error instanceof Error ? error.message : error
|
|
81
|
-
);
|
|
82
|
-
}
|
|
83
55
|
return {
|
|
84
56
|
name: recipeNameFrom(s.name),
|
|
85
57
|
workspaceId: project.workspace_id,
|
|
@@ -88,6 +60,63 @@ export async function recipeDraft(
|
|
|
88
60
|
model: s.model_overrides ?? null,
|
|
89
61
|
view: s.view,
|
|
90
62
|
prompt,
|
|
91
|
-
draft,
|
|
92
63
|
};
|
|
93
64
|
}
|
|
65
|
+
|
|
66
|
+
export const REPEATABLE_SYSTEM = `${SYSTEM} Also say whether this is a job someone would plausibly run again (repeatable); a one-off fix or a feature built once is not.`;
|
|
67
|
+
const REPEATABLE_SCHEMA = {
|
|
68
|
+
...SCHEMA,
|
|
69
|
+
properties: { ...SCHEMA.properties, repeatable: { type: "boolean" } },
|
|
70
|
+
required: ["brief", "summary", "repeatable"],
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// The model's rewrite of a brief and final report (never the transcript).
|
|
74
|
+
export const draftRequest = (
|
|
75
|
+
prompt: string,
|
|
76
|
+
final: string | null,
|
|
77
|
+
withRepeatable = false
|
|
78
|
+
) => ({
|
|
79
|
+
cwd: os.tmpdir(),
|
|
80
|
+
system: withRepeatable ? REPEATABLE_SYSTEM : SYSTEM,
|
|
81
|
+
prompt: [
|
|
82
|
+
`What it was asked:\n${prompt.slice(0, 1500)}`,
|
|
83
|
+
final ? `What it ended with:\n${final.slice(-2000)}` : "",
|
|
84
|
+
]
|
|
85
|
+
.filter(Boolean)
|
|
86
|
+
.join("\n\n"),
|
|
87
|
+
schema: withRepeatable ? REPEATABLE_SCHEMA : SCHEMA,
|
|
88
|
+
tools: [] as string[],
|
|
89
|
+
role: "helper" as const,
|
|
90
|
+
timeoutMs: 20_000,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
export function parseDraft(out: unknown): RecipeDraft["draft"] {
|
|
94
|
+
const o = out as { brief?: unknown; summary?: unknown } | null;
|
|
95
|
+
if (typeof o?.brief !== "string" || !o.brief.trim()) return null;
|
|
96
|
+
return {
|
|
97
|
+
brief: o.brief.trim().slice(0, MAX_BRIEF),
|
|
98
|
+
summary: typeof o.summary === "string" ? o.summary.trim() : "",
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function recipeDraft(
|
|
103
|
+
sessionId: string,
|
|
104
|
+
run: ClaudeRunner = runClaude,
|
|
105
|
+
// A brief already drafted for this session (a learning card's).
|
|
106
|
+
cached?: RecipeDraft["draft"]
|
|
107
|
+
): Promise<RecipeDraft> {
|
|
108
|
+
const base = recipePrefill(sessionId);
|
|
109
|
+
if (cached) return { ...base, draft: cached };
|
|
110
|
+
let draft: RecipeDraft["draft"] = null;
|
|
111
|
+
try {
|
|
112
|
+
draft = parseDraft(
|
|
113
|
+
await run(draftRequest(base.prompt, finalMessage(sessionId)))
|
|
114
|
+
);
|
|
115
|
+
} catch (error) {
|
|
116
|
+
console.warn(
|
|
117
|
+
"[recipes] no drafted brief:",
|
|
118
|
+
error instanceof Error ? error.message : error
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
return { ...base, draft };
|
|
122
|
+
}
|