@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,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
|
+
}
|
package/lib/recipes/settle.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { blockedReason } from "../tasks/state";
|
|
|
11
11
|
import { runClaude, type ClaudeRunner } from "../orchestrator/claude-cli";
|
|
12
12
|
import { releasePieces } from "../plans/release";
|
|
13
13
|
import { queueEvent } from "../orchestrator/events";
|
|
14
|
+
import { sessionEnded } from "../experiments/hooks";
|
|
14
15
|
import { untrusted } from "../orchestrator/untrusted";
|
|
15
16
|
import {
|
|
16
17
|
cleanOutputs,
|
|
@@ -172,8 +173,10 @@ function finish(
|
|
|
172
173
|
run.id
|
|
173
174
|
).changes;
|
|
174
175
|
idleSince.delete(run.id);
|
|
175
|
-
if (changed)
|
|
176
|
+
if (changed) {
|
|
176
177
|
tell(run, status, summary, result, session?.pr_number ?? run.pr_number);
|
|
178
|
+
if (run.session_id) void sessionEnded(run.session_id, "recipe-settled");
|
|
179
|
+
}
|
|
177
180
|
return changed === 1;
|
|
178
181
|
}
|
|
179
182
|
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { getDb } from "@/lib/db";
|
|
2
|
+
|
|
3
|
+
// The settings table: a key and a string, shared by every feature.
|
|
4
|
+
export function getSetting(key: string): string | null {
|
|
5
|
+
const row = getDb()
|
|
6
|
+
.prepare(`SELECT value FROM settings WHERE key = ?`)
|
|
7
|
+
.get(key) as { value: string } | undefined;
|
|
8
|
+
return row?.value ?? null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function putSetting(key: string, value: string | null): void {
|
|
12
|
+
if (value === null) {
|
|
13
|
+
getDb().prepare(`DELETE FROM settings WHERE key = ?`).run(key);
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
getDb()
|
|
17
|
+
.prepare(
|
|
18
|
+
`INSERT INTO settings (key, value) VALUES (?, ?)
|
|
19
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value`
|
|
20
|
+
)
|
|
21
|
+
.run(key, value);
|
|
22
|
+
}
|
package/lib/settings-url.ts
CHANGED
package/lib/tasks/finish.ts
CHANGED
|
@@ -26,6 +26,7 @@ import { canSignOff } from "./state";
|
|
|
26
26
|
import { forgetPR, getTaskSession, prFor, projectPathFor } from "./session";
|
|
27
27
|
import { dropSessionDatabase } from "../project-config/database";
|
|
28
28
|
import { captureMergedTask } from "../session-logs";
|
|
29
|
+
import { sessionEnded } from "../experiments/hooks";
|
|
29
30
|
|
|
30
31
|
// Never deleted, whatever a setting says.
|
|
31
32
|
export const PROTECTED_BRANCHES = new Set(["main", "master", "HEAD"]);
|
|
@@ -163,6 +164,7 @@ export async function signOffTask(
|
|
|
163
164
|
pushMerged(id);
|
|
164
165
|
// Reads the plan file now: cleanup below removes the worktree.
|
|
165
166
|
void captureMergedTask(id, { head: opts.head ?? pr!.head ?? null });
|
|
167
|
+
void sessionEnded(id, "merged");
|
|
166
168
|
const head = opts.head ?? pr!.head;
|
|
167
169
|
resolveMergedTaskAsks(
|
|
168
170
|
id,
|
package/lib/tasks/session.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { findPRStrict } from "./gh";
|
|
|
16
16
|
import { currentBranch, git } from "../git-exec";
|
|
17
17
|
import { lookupPR } from "./pr-poll";
|
|
18
18
|
import { captureMergedTask } from "../session-logs";
|
|
19
|
+
import { sessionEnded } from "../experiments/hooks";
|
|
19
20
|
import { getOrchestrator } from "../orchestrator/home";
|
|
20
21
|
import {
|
|
21
22
|
pushCiFailed,
|
|
@@ -211,6 +212,7 @@ export async function prFor(
|
|
|
211
212
|
if (pr.state === "MERGED") {
|
|
212
213
|
if (session.pr_status !== "merged") pushMerged(session.id);
|
|
213
214
|
void captureMergedTask(session.id, { head: pr.head });
|
|
215
|
+
void sessionEnded(session.id, "merged");
|
|
214
216
|
}
|
|
215
217
|
}
|
|
216
218
|
// Only an answer just read from GitHub: a cached one may predate a
|
package/lib/updates/settings.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { getSetting, putSetting } from "../settings-store";
|
|
2
2
|
|
|
3
3
|
// Whether this server shows the update banner, and the checks behind it.
|
|
4
4
|
// AGENTOS_UPDATE_BANNER=off pins it off, like the network settings.
|
|
@@ -17,22 +17,4 @@ export function bannerEnabled(env: Env = process.env): boolean {
|
|
|
17
17
|
export const setBannerEnabled = (on: boolean) =>
|
|
18
18
|
putSetting(KEY, on ? "1" : "0");
|
|
19
19
|
|
|
20
|
-
export
|
|
21
|
-
const row = getDb()
|
|
22
|
-
.prepare(`SELECT value FROM settings WHERE key = ?`)
|
|
23
|
-
.get(key) as { value: string } | undefined;
|
|
24
|
-
return row?.value ?? null;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export function putSetting(key: string, value: string | null): void {
|
|
28
|
-
if (value === null) {
|
|
29
|
-
getDb().prepare(`DELETE FROM settings WHERE key = ?`).run(key);
|
|
30
|
-
return;
|
|
31
|
-
}
|
|
32
|
-
getDb()
|
|
33
|
-
.prepare(
|
|
34
|
-
`INSERT INTO settings (key, value) VALUES (?, ?)
|
|
35
|
-
ON CONFLICT(key) DO UPDATE SET value = excluded.value`
|
|
36
|
-
)
|
|
37
|
-
.run(key, value);
|
|
38
|
-
}
|
|
20
|
+
export { getSetting, putSetting } from "../settings-store";
|