@saadnvd1/agent-os 0.3.556 → 0.3.557
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/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/lib/db/migrations.ts +33 -0
- package/lib/done/index.ts +2 -0
- package/lib/experiments/hooks.ts +31 -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/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
package/README.md
CHANGED
|
@@ -27,6 +27,7 @@ and check on all of it from your phone.
|
|
|
27
27
|
- **Workspaces.** Group projects, finish work with Done, and get a log of every merged task. [More](docs/reference/workspaces.md)
|
|
28
28
|
- **LumifyHub (optional).** Boards, cards and docs for a workspace, if you want them. [More](docs/lumifyhub.md)
|
|
29
29
|
- **No approvals mode.** One switch in Settings that approves every ask, lets brakes and plan-only tasks through, and starts every agent with its permission prompts off; each approval it makes is logged. Off by default. [More](docs/reference/orchestrator.md#no-approvals-mode)
|
|
30
|
+
- **Experiments.** Settings → Experiments lists features still being tried, each off until you turn it on, with what it has cost and how often its suggestions were taken. [More](docs/decisions/2026-10-13-experiments.md)
|
|
30
31
|
- **Also:** a ⌘K palette, code search, a file picker, a git panel, dev servers, up to four panes, and dictation.
|
|
31
32
|
|
|
32
33
|
## Documentation
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { experimentsLocked, setExperiment } from "@/lib/experiments";
|
|
3
|
+
import { isExperimentId } from "@/lib/experiments/registry";
|
|
4
|
+
import { usageDetail } from "@/lib/experiments/meter";
|
|
5
|
+
import { experimentView } from "@/lib/experiments/view";
|
|
6
|
+
|
|
7
|
+
type Ctx = { params: Promise<{ id: string }> };
|
|
8
|
+
|
|
9
|
+
// GET /api/experiments/:id - the experiment with its per-day usage
|
|
10
|
+
export async function GET(_req: NextRequest, { params }: Ctx) {
|
|
11
|
+
const { id } = await params;
|
|
12
|
+
if (!isExperimentId(id))
|
|
13
|
+
return NextResponse.json({ error: "Unknown experiment" }, { status: 404 });
|
|
14
|
+
return NextResponse.json({
|
|
15
|
+
...experimentView(id),
|
|
16
|
+
locked: experimentsLocked(),
|
|
17
|
+
detail: usageDetail(id),
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// PUT /api/experiments/:id {on} - anyone using the app may switch one
|
|
22
|
+
export async function PUT(request: NextRequest, { params }: Ctx) {
|
|
23
|
+
const { id } = await params;
|
|
24
|
+
if (!isExperimentId(id))
|
|
25
|
+
return NextResponse.json({ error: "Unknown experiment" }, { status: 404 });
|
|
26
|
+
const body = (await request.json().catch(() => ({}))) as { on?: unknown };
|
|
27
|
+
if (typeof body.on !== "boolean")
|
|
28
|
+
return NextResponse.json(
|
|
29
|
+
{ error: "on must be true or false" },
|
|
30
|
+
{ status: 400 }
|
|
31
|
+
);
|
|
32
|
+
if (experimentsLocked())
|
|
33
|
+
return NextResponse.json(
|
|
34
|
+
{ error: "Experiments are switched off for this server" },
|
|
35
|
+
{ status: 409 }
|
|
36
|
+
);
|
|
37
|
+
setExperiment(id, body.on);
|
|
38
|
+
return NextResponse.json(experimentView(id));
|
|
39
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { NextRequest } from "next/server";
|
|
2
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
3
|
+
import { db } from "@/lib/db";
|
|
4
|
+
import { experimentOn } from "@/lib/experiments";
|
|
5
|
+
import { GET } from "./route";
|
|
6
|
+
import * as one from "./[id]/route";
|
|
7
|
+
|
|
8
|
+
const ctx = (id: string) => ({ params: Promise.resolve({ id }) });
|
|
9
|
+
const put = (id: string, body: unknown) =>
|
|
10
|
+
one.PUT(
|
|
11
|
+
new NextRequest(`http://localhost/api/experiments/${id}`, {
|
|
12
|
+
method: "PUT",
|
|
13
|
+
body: JSON.stringify(body),
|
|
14
|
+
}),
|
|
15
|
+
ctx(id)
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
db.prepare(`DELETE FROM settings WHERE key LIKE 'experiments.%'`).run();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("/api/experiments", () => {
|
|
23
|
+
it("lists every experiment, off, with zero usage", async () => {
|
|
24
|
+
const body = await (await GET()).json();
|
|
25
|
+
const e = body.experiments.find(
|
|
26
|
+
(x: { id: string }) => x.id === "learning-cards"
|
|
27
|
+
);
|
|
28
|
+
expect(e).toMatchObject({ on: false, status: "experimental" });
|
|
29
|
+
expect(e.usage.today.calls).toBe(0);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("switches one on and off", async () => {
|
|
33
|
+
expect((await put("learning-cards", { on: true })).status).toBe(200);
|
|
34
|
+
expect(experimentOn("learning-cards")).toBe(true);
|
|
35
|
+
await put("learning-cards", { on: false });
|
|
36
|
+
expect(experimentOn("learning-cards")).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("refuses an unknown id and a bad body", async () => {
|
|
40
|
+
expect((await put("nope", { on: true })).status).toBe(404);
|
|
41
|
+
expect((await put("learning-cards", { on: "yes" })).status).toBe(400);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("gives the detail view", async () => {
|
|
45
|
+
const res = await one.GET(
|
|
46
|
+
new NextRequest("http://localhost/api/experiments/learning-cards"),
|
|
47
|
+
ctx("learning-cards")
|
|
48
|
+
);
|
|
49
|
+
expect((await res.json()).detail.days).toHaveLength(30);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import "@/components/Chat/composer/test-dom";
|
|
2
|
+
import { act, createElement } from "react";
|
|
3
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
|
|
6
|
+
for (const key of ["Event", "CustomEvent", "FocusEvent", "HTMLButtonElement"])
|
|
7
|
+
Object.defineProperty(globalThis, key, {
|
|
8
|
+
value: (window as unknown as Record<string, unknown>)[key],
|
|
9
|
+
configurable: true,
|
|
10
|
+
writable: true,
|
|
11
|
+
});
|
|
12
|
+
(
|
|
13
|
+
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
|
14
|
+
).IS_REACT_ACT_ENVIRONMENT = true;
|
|
15
|
+
|
|
16
|
+
const w = (calls: number) => ({
|
|
17
|
+
calls,
|
|
18
|
+
tokens: calls * 1000,
|
|
19
|
+
costUsd: calls * 0.01,
|
|
20
|
+
scripts: 0,
|
|
21
|
+
shown: 0,
|
|
22
|
+
accepted: 1,
|
|
23
|
+
dismissed: 1,
|
|
24
|
+
acceptRate: 0.5,
|
|
25
|
+
});
|
|
26
|
+
const state = vi.hoisted(() => ({ mutate: vi.fn() }));
|
|
27
|
+
vi.mock("@/data/experiments", () => ({
|
|
28
|
+
useExperimentsQuery: () => ({
|
|
29
|
+
isPending: false,
|
|
30
|
+
isError: false,
|
|
31
|
+
data: {
|
|
32
|
+
locked: false,
|
|
33
|
+
experiments: [
|
|
34
|
+
{
|
|
35
|
+
id: "learning-cards",
|
|
36
|
+
name: "Learning cards",
|
|
37
|
+
description: "Suggest things when a session ends.",
|
|
38
|
+
area: "sessions",
|
|
39
|
+
status: "experimental",
|
|
40
|
+
suggests: true,
|
|
41
|
+
on: false,
|
|
42
|
+
usage: { today: w(1), d7: w(2), d30: w(3) },
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
},
|
|
46
|
+
}),
|
|
47
|
+
useSetExperiment: () => ({ mutate: state.mutate }),
|
|
48
|
+
useExperimentDetailQuery: () => ({ isPending: true }),
|
|
49
|
+
}));
|
|
50
|
+
|
|
51
|
+
const { ExperimentsSection } = await import("./ExperimentsSection");
|
|
52
|
+
|
|
53
|
+
let root: Root;
|
|
54
|
+
let host: HTMLDivElement;
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
state.mutate.mockReset();
|
|
57
|
+
host = document.createElement("div");
|
|
58
|
+
document.body.appendChild(host);
|
|
59
|
+
root = createRoot(host);
|
|
60
|
+
});
|
|
61
|
+
afterEach(() => {
|
|
62
|
+
act(() => root.unmount());
|
|
63
|
+
host.remove();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("ExperimentsSection", () => {
|
|
67
|
+
it("lists each experiment, off, with its usage", () => {
|
|
68
|
+
act(() => root.render(createElement(ExperimentsSection)));
|
|
69
|
+
expect(host.textContent).toContain("Learning cards");
|
|
70
|
+
expect(host.textContent).toContain("Suggest things when a session ends.");
|
|
71
|
+
expect(host.textContent).toContain("50% (1/2)");
|
|
72
|
+
expect(
|
|
73
|
+
host.querySelector('[role="switch"]')?.getAttribute("aria-checked")
|
|
74
|
+
).toBe("false");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("turns one on from its toggle", () => {
|
|
78
|
+
act(() => root.render(createElement(ExperimentsSection)));
|
|
79
|
+
act(() => {
|
|
80
|
+
host
|
|
81
|
+
.querySelector('[role="switch"]')
|
|
82
|
+
?.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
|
83
|
+
});
|
|
84
|
+
expect(state.mutate).toHaveBeenCalledWith(
|
|
85
|
+
{ id: "learning-cards", on: true },
|
|
86
|
+
expect.any(Object)
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { toast } from "sonner";
|
|
5
|
+
import { ArrowLeft } from "lucide-react";
|
|
6
|
+
import { Button } from "@/components/ui/button";
|
|
7
|
+
import { SettingToggle } from "@/components/Settings/SettingToggle";
|
|
8
|
+
import { formatTokens } from "@/lib/chat/context";
|
|
9
|
+
import {
|
|
10
|
+
useExperimentDetailQuery,
|
|
11
|
+
useExperimentsQuery,
|
|
12
|
+
useSetExperiment,
|
|
13
|
+
type ExperimentView,
|
|
14
|
+
} from "@/data/experiments";
|
|
15
|
+
|
|
16
|
+
const usd = (n: number) => `$${n.toFixed(2)}`;
|
|
17
|
+
const pct = (n: number | null) =>
|
|
18
|
+
n === null ? "-" : `${Math.round(n * 100)}%`;
|
|
19
|
+
|
|
20
|
+
const WINDOWS = [
|
|
21
|
+
["today", "Today"],
|
|
22
|
+
["d7", "7 days"],
|
|
23
|
+
["d30", "30 days"],
|
|
24
|
+
] as const;
|
|
25
|
+
|
|
26
|
+
function Usage({ e }: { e: ExperimentView }) {
|
|
27
|
+
return (
|
|
28
|
+
<table className="w-full text-xs tabular-nums">
|
|
29
|
+
<thead className="text-muted-foreground">
|
|
30
|
+
<tr>
|
|
31
|
+
<th className="py-1 text-left font-normal" />
|
|
32
|
+
<th className="py-1 text-right font-normal">Calls</th>
|
|
33
|
+
<th className="py-1 text-right font-normal">Tokens</th>
|
|
34
|
+
<th className="py-1 text-right font-normal">Cost</th>
|
|
35
|
+
{e.suggests && (
|
|
36
|
+
<th className="py-1 text-right font-normal">Accepted</th>
|
|
37
|
+
)}
|
|
38
|
+
</tr>
|
|
39
|
+
</thead>
|
|
40
|
+
<tbody>
|
|
41
|
+
{WINDOWS.map(([key, label]) => {
|
|
42
|
+
const w = e.usage[key];
|
|
43
|
+
return (
|
|
44
|
+
<tr key={key} className="border-border border-t">
|
|
45
|
+
<td className="py-1">{label}</td>
|
|
46
|
+
<td className="py-1 text-right">{w.calls}</td>
|
|
47
|
+
<td className="py-1 text-right">{formatTokens(w.tokens)}</td>
|
|
48
|
+
<td className="py-1 text-right">{usd(w.costUsd)}</td>
|
|
49
|
+
{e.suggests && (
|
|
50
|
+
<td className="py-1 text-right">
|
|
51
|
+
{pct(w.acceptRate)}
|
|
52
|
+
{w.accepted + w.dismissed > 0 &&
|
|
53
|
+
` (${w.accepted}/${w.accepted + w.dismissed})`}
|
|
54
|
+
</td>
|
|
55
|
+
)}
|
|
56
|
+
</tr>
|
|
57
|
+
);
|
|
58
|
+
})}
|
|
59
|
+
</tbody>
|
|
60
|
+
</table>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function Detail({ id, onBack }: { id: string; onBack: () => void }) {
|
|
65
|
+
const { data, isPending, isError, error } = useExperimentDetailQuery(id);
|
|
66
|
+
return (
|
|
67
|
+
<div className="space-y-4">
|
|
68
|
+
<Button
|
|
69
|
+
variant="ghost"
|
|
70
|
+
size="sm"
|
|
71
|
+
onClick={onBack}
|
|
72
|
+
className="min-h-11 md:min-h-0"
|
|
73
|
+
>
|
|
74
|
+
<ArrowLeft className="h-4 w-4" /> Experiments
|
|
75
|
+
</Button>
|
|
76
|
+
{isPending && (
|
|
77
|
+
<div className="bg-muted/40 h-24 animate-pulse rounded-lg" />
|
|
78
|
+
)}
|
|
79
|
+
{isError && <p className="text-destructive text-sm">{error.message}</p>}
|
|
80
|
+
{data && (
|
|
81
|
+
<>
|
|
82
|
+
<div>
|
|
83
|
+
<h3 className="text-sm font-medium">{data.name}</h3>
|
|
84
|
+
<p className="text-muted-foreground text-xs">{data.description}</p>
|
|
85
|
+
</div>
|
|
86
|
+
<Usage e={data} />
|
|
87
|
+
<Breakdowns title="By trigger" rows={data.detail.byTrigger} />
|
|
88
|
+
<Breakdowns title="By model" rows={data.detail.byModel} />
|
|
89
|
+
<section>
|
|
90
|
+
<h4 className="text-muted-foreground mb-1 text-xs">Last 30 days</h4>
|
|
91
|
+
<table className="w-full text-xs tabular-nums">
|
|
92
|
+
<tbody>
|
|
93
|
+
{data.detail.days
|
|
94
|
+
.filter((d) => d.calls || d.accepted || d.dismissed)
|
|
95
|
+
.reverse()
|
|
96
|
+
.map((d) => (
|
|
97
|
+
<tr key={d.day} className="border-border border-t">
|
|
98
|
+
<td className="py-1">{d.day}</td>
|
|
99
|
+
<td className="py-1 text-right">{d.calls} calls</td>
|
|
100
|
+
<td className="py-1 text-right">
|
|
101
|
+
{formatTokens(d.tokens)}
|
|
102
|
+
</td>
|
|
103
|
+
<td className="py-1 text-right">{usd(d.costUsd)}</td>
|
|
104
|
+
<td className="py-1 text-right">
|
|
105
|
+
{d.accepted}/{d.accepted + d.dismissed}
|
|
106
|
+
</td>
|
|
107
|
+
</tr>
|
|
108
|
+
))}
|
|
109
|
+
<tr className="border-border border-t font-medium">
|
|
110
|
+
<td className="py-1">Total</td>
|
|
111
|
+
<td className="py-1 text-right">
|
|
112
|
+
{data.detail.days.reduce((t, d) => t + d.calls, 0)} calls
|
|
113
|
+
</td>
|
|
114
|
+
<td className="py-1 text-right">
|
|
115
|
+
{formatTokens(
|
|
116
|
+
data.detail.days.reduce((t, d) => t + d.tokens, 0)
|
|
117
|
+
)}
|
|
118
|
+
</td>
|
|
119
|
+
<td className="py-1 text-right">
|
|
120
|
+
{usd(data.detail.days.reduce((t, d) => t + d.costUsd, 0))}
|
|
121
|
+
</td>
|
|
122
|
+
<td />
|
|
123
|
+
</tr>
|
|
124
|
+
</tbody>
|
|
125
|
+
</table>
|
|
126
|
+
</section>
|
|
127
|
+
{data.detail.recent.length > 0 && (
|
|
128
|
+
<section>
|
|
129
|
+
<h4 className="text-muted-foreground mb-1 text-xs">Recent</h4>
|
|
130
|
+
<ul className="space-y-1 text-xs">
|
|
131
|
+
{data.detail.recent.map((r, i) => (
|
|
132
|
+
<li key={i} className="flex justify-between gap-3">
|
|
133
|
+
<span className="min-w-0 truncate">
|
|
134
|
+
{new Date(r.at).toLocaleString()} · {r.event} ·{" "}
|
|
135
|
+
{r.trigger}
|
|
136
|
+
{r.detail ? ` · ${r.detail}` : ""}
|
|
137
|
+
</span>
|
|
138
|
+
<span className="text-muted-foreground tabular-nums">
|
|
139
|
+
{r.tokens ? formatTokens(r.tokens) : ""}
|
|
140
|
+
</span>
|
|
141
|
+
</li>
|
|
142
|
+
))}
|
|
143
|
+
</ul>
|
|
144
|
+
</section>
|
|
145
|
+
)}
|
|
146
|
+
</>
|
|
147
|
+
)}
|
|
148
|
+
</div>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function Breakdowns({
|
|
153
|
+
title,
|
|
154
|
+
rows,
|
|
155
|
+
}: {
|
|
156
|
+
title: string;
|
|
157
|
+
rows: { key: string; calls: number; tokens: number; costUsd: number }[];
|
|
158
|
+
}) {
|
|
159
|
+
if (!rows.length) return null;
|
|
160
|
+
return (
|
|
161
|
+
<section>
|
|
162
|
+
<h4 className="text-muted-foreground mb-1 text-xs">{title}</h4>
|
|
163
|
+
<table className="w-full text-xs tabular-nums">
|
|
164
|
+
<tbody>
|
|
165
|
+
{rows.map((r) => (
|
|
166
|
+
<tr key={r.key} className="border-border border-t">
|
|
167
|
+
<td className="py-1">{r.key}</td>
|
|
168
|
+
<td className="py-1 text-right">{r.calls} calls</td>
|
|
169
|
+
<td className="py-1 text-right">{formatTokens(r.tokens)}</td>
|
|
170
|
+
<td className="py-1 text-right">{usd(r.costUsd)}</td>
|
|
171
|
+
</tr>
|
|
172
|
+
))}
|
|
173
|
+
</tbody>
|
|
174
|
+
</table>
|
|
175
|
+
</section>
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function ExperimentsSection() {
|
|
180
|
+
const { data, isPending, isError, error } = useExperimentsQuery();
|
|
181
|
+
const set = useSetExperiment();
|
|
182
|
+
const [open, setOpen] = useState<string | null>(null);
|
|
183
|
+
|
|
184
|
+
if (open) return <Detail id={open} onBack={() => setOpen(null)} />;
|
|
185
|
+
if (isPending)
|
|
186
|
+
return <div className="bg-muted/40 h-16 animate-pulse rounded-lg" />;
|
|
187
|
+
if (isError)
|
|
188
|
+
return <p className="text-destructive text-sm">{error.message}</p>;
|
|
189
|
+
|
|
190
|
+
return (
|
|
191
|
+
<div className="space-y-5">
|
|
192
|
+
<p className="text-muted-foreground text-sm">
|
|
193
|
+
Features still being tried. Each is off until you turn it on, does no
|
|
194
|
+
work while off, and shows what it costs here.
|
|
195
|
+
</p>
|
|
196
|
+
{data.experiments.map((e) => (
|
|
197
|
+
<div key={e.id} className="space-y-2">
|
|
198
|
+
<SettingToggle
|
|
199
|
+
title={`${e.name} · ${e.status === "beta" ? "Beta" : "Experimental"}`}
|
|
200
|
+
detail={e.description}
|
|
201
|
+
checked={e.on}
|
|
202
|
+
locked={data.locked}
|
|
203
|
+
onChange={(on) =>
|
|
204
|
+
set.mutate(
|
|
205
|
+
{ id: e.id, on },
|
|
206
|
+
{ onError: (err) => toast.error(err.message) }
|
|
207
|
+
)
|
|
208
|
+
}
|
|
209
|
+
/>
|
|
210
|
+
<div className="px-3">
|
|
211
|
+
<Usage e={e} />
|
|
212
|
+
<Button
|
|
213
|
+
variant="link"
|
|
214
|
+
size="sm"
|
|
215
|
+
className="min-h-11 px-0 md:min-h-0"
|
|
216
|
+
onClick={() => setOpen(e.id)}
|
|
217
|
+
>
|
|
218
|
+
Details
|
|
219
|
+
</Button>
|
|
220
|
+
</div>
|
|
221
|
+
</div>
|
|
222
|
+
))}
|
|
223
|
+
</div>
|
|
224
|
+
);
|
|
225
|
+
}
|
|
@@ -47,6 +47,9 @@ vi.mock("@/components/Audits/AuditsPanel", () => ({
|
|
|
47
47
|
vi.mock("@/components/Updates/UpdatesSection", () => ({
|
|
48
48
|
UpdatesSection: stub("updates"),
|
|
49
49
|
}));
|
|
50
|
+
vi.mock("./ExperimentsSection", () => ({
|
|
51
|
+
ExperimentsSection: stub("experiments"),
|
|
52
|
+
}));
|
|
50
53
|
vi.mock("./NotificationsSection", () => ({
|
|
51
54
|
NotificationsSection: stub("notifications"),
|
|
52
55
|
}));
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
LayoutGrid,
|
|
15
15
|
ListChecks,
|
|
16
16
|
ShieldOff,
|
|
17
|
+
FlaskConical,
|
|
17
18
|
RefreshCw,
|
|
18
19
|
Smartphone,
|
|
19
20
|
X,
|
|
@@ -33,6 +34,7 @@ import { GlobalMergeSection } from "@/components/Merge/GlobalMergeSection";
|
|
|
33
34
|
import { ModelsSection } from "@/components/Models/ModelsSection";
|
|
34
35
|
import { SchedulesPanel } from "@/components/Schedules";
|
|
35
36
|
import { UpdatesSection } from "@/components/Updates/UpdatesSection";
|
|
37
|
+
import { ExperimentsSection } from "./ExperimentsSection";
|
|
36
38
|
import { WorkspaceSettings } from "@/components/Workspaces";
|
|
37
39
|
import { useSettingsUrl } from "@/hooks/useSettingsUrl";
|
|
38
40
|
import { SETTINGS_SECTIONS, type SettingsSection } from "@/lib/settings-url";
|
|
@@ -106,6 +108,11 @@ export const SECTION_INFO: Record<
|
|
|
106
108
|
hint: "Version, check for updates, the update banner",
|
|
107
109
|
icon: RefreshCw,
|
|
108
110
|
},
|
|
111
|
+
experiments: {
|
|
112
|
+
label: "Experiments",
|
|
113
|
+
hint: "Try features that aren't finished, and see what they cost",
|
|
114
|
+
icon: FlaskConical,
|
|
115
|
+
},
|
|
109
116
|
};
|
|
110
117
|
|
|
111
118
|
function SectionBody({
|
|
@@ -138,6 +145,8 @@ function SectionBody({
|
|
|
138
145
|
return <SetupSection />;
|
|
139
146
|
case "updates":
|
|
140
147
|
return <UpdatesSection />;
|
|
148
|
+
case "experiments":
|
|
149
|
+
return <ExperimentsSection />;
|
|
141
150
|
}
|
|
142
151
|
}
|
|
143
152
|
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
ClipboardList,
|
|
4
4
|
Clock,
|
|
5
5
|
Cpu,
|
|
6
|
+
FlaskConical,
|
|
6
7
|
GitMerge,
|
|
7
8
|
LayoutGrid,
|
|
8
9
|
RefreshCw,
|
|
@@ -111,5 +112,13 @@ export function settingsCommands(workspaceId: string | null): PaletteCommand[] {
|
|
|
111
112
|
icon: RefreshCw,
|
|
112
113
|
run: () => settingsUiActions.open("updates"),
|
|
113
114
|
},
|
|
115
|
+
{
|
|
116
|
+
id: "app.experiments",
|
|
117
|
+
title: "Experiments",
|
|
118
|
+
group,
|
|
119
|
+
keywords: ["experiment", "labs", "beta", "feature", "try"],
|
|
120
|
+
icon: FlaskConical,
|
|
121
|
+
run: () => settingsUiActions.open("experiments"),
|
|
122
|
+
},
|
|
114
123
|
];
|
|
115
124
|
}
|
|
@@ -28,6 +28,7 @@ describe("⌘K entry points", () => {
|
|
|
28
28
|
["app.schedules", "schedules"],
|
|
29
29
|
["app.audits", "audits"],
|
|
30
30
|
["app.updates", "updates"],
|
|
31
|
+
["app.experiments", "experiments"],
|
|
31
32
|
] as const)("%s opens Settings at %s", (id, section) => {
|
|
32
33
|
run(id);
|
|
33
34
|
expect(settingsUi.open).toBe(true);
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
2
|
+
import type { ExperimentView } from "@/lib/experiments/view";
|
|
3
|
+
import type { UsageDetail } from "@/lib/experiments/meter";
|
|
4
|
+
import type { ExperimentId } from "@/lib/experiments/registry";
|
|
5
|
+
|
|
6
|
+
export type { ExperimentView, UsageDetail };
|
|
7
|
+
|
|
8
|
+
export interface ExperimentsList {
|
|
9
|
+
locked: boolean;
|
|
10
|
+
experiments: ExperimentView[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ExperimentDetail extends ExperimentView {
|
|
14
|
+
locked: boolean;
|
|
15
|
+
detail: UsageDetail;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const experimentsKeys = {
|
|
19
|
+
all: ["experiments"] as const,
|
|
20
|
+
detail: (id: string) => ["experiments", id] as const,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
async function json<T>(res: Response): Promise<T> {
|
|
24
|
+
const data = await res.json();
|
|
25
|
+
if (!res.ok) throw new Error(data.error || "Request failed");
|
|
26
|
+
return data;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function useExperimentsQuery() {
|
|
30
|
+
return useQuery({
|
|
31
|
+
queryKey: experimentsKeys.all,
|
|
32
|
+
queryFn: async () => json<ExperimentsList>(await fetch("/api/experiments")),
|
|
33
|
+
staleTime: 10_000,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function useExperimentDetailQuery(id: string | null) {
|
|
38
|
+
return useQuery({
|
|
39
|
+
queryKey: experimentsKeys.detail(id ?? ""),
|
|
40
|
+
queryFn: async () =>
|
|
41
|
+
json<ExperimentDetail>(await fetch(`/api/experiments/${id}`)),
|
|
42
|
+
enabled: !!id,
|
|
43
|
+
staleTime: 10_000,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// The client's one check: false until the list has loaded.
|
|
48
|
+
export function useExperiment(id: ExperimentId): boolean {
|
|
49
|
+
const { data } = useExperimentsQuery();
|
|
50
|
+
return !!data?.experiments.find((e) => e.id === id)?.on;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function useSetExperiment() {
|
|
54
|
+
const queryClient = useQueryClient();
|
|
55
|
+
return useMutation({
|
|
56
|
+
mutationFn: async ({ id, on }: { id: string; on: boolean }) =>
|
|
57
|
+
json<ExperimentView>(
|
|
58
|
+
await fetch(`/api/experiments/${id}`, {
|
|
59
|
+
method: "PUT",
|
|
60
|
+
headers: { "Content-Type": "application/json" },
|
|
61
|
+
body: JSON.stringify({ on }),
|
|
62
|
+
})
|
|
63
|
+
),
|
|
64
|
+
onSuccess: () =>
|
|
65
|
+
queryClient.invalidateQueries({ queryKey: experimentsKeys.all }),
|
|
66
|
+
});
|
|
67
|
+
}
|
package/lib/db/migrations.ts
CHANGED
|
@@ -2028,6 +2028,39 @@ const migrations: Migration[] = [
|
|
|
2028
2028
|
`);
|
|
2029
2029
|
},
|
|
2030
2030
|
},
|
|
2031
|
+
{
|
|
2032
|
+
id: 85,
|
|
2033
|
+
name: "experiment_usage",
|
|
2034
|
+
up: (db) => {
|
|
2035
|
+
// What each experiment did: model calls with their cost, and the
|
|
2036
|
+
// non-model work (scripts run, suggestions shown and decided).
|
|
2037
|
+
db.exec(`
|
|
2038
|
+
CREATE TABLE IF NOT EXISTS experiment_usage (
|
|
2039
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
2040
|
+
experiment_id TEXT NOT NULL,
|
|
2041
|
+
event TEXT NOT NULL,
|
|
2042
|
+
trigger TEXT NOT NULL,
|
|
2043
|
+
session_id TEXT,
|
|
2044
|
+
workspace_id TEXT,
|
|
2045
|
+
model TEXT,
|
|
2046
|
+
provider TEXT,
|
|
2047
|
+
input_tokens INTEGER NOT NULL DEFAULT 0,
|
|
2048
|
+
cache_write_tokens INTEGER NOT NULL DEFAULT 0,
|
|
2049
|
+
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
|
|
2050
|
+
output_tokens INTEGER NOT NULL DEFAULT 0,
|
|
2051
|
+
cost_usd REAL NOT NULL DEFAULT 0,
|
|
2052
|
+
duration_ms INTEGER,
|
|
2053
|
+
ok INTEGER NOT NULL DEFAULT 1,
|
|
2054
|
+
detail TEXT,
|
|
2055
|
+
at INTEGER NOT NULL
|
|
2056
|
+
);
|
|
2057
|
+
CREATE INDEX IF NOT EXISTS idx_experiment_usage_exp_at
|
|
2058
|
+
ON experiment_usage(experiment_id, at);
|
|
2059
|
+
CREATE INDEX IF NOT EXISTS idx_experiment_usage_session
|
|
2060
|
+
ON experiment_usage(session_id, experiment_id, event);
|
|
2061
|
+
`);
|
|
2062
|
+
},
|
|
2063
|
+
},
|
|
2031
2064
|
];
|
|
2032
2065
|
|
|
2033
2066
|
// `upTo`: stop after this id (tests that start from an older database).
|
package/lib/done/index.ts
CHANGED
|
@@ -32,6 +32,7 @@ import { doneOnPeer, peerSessionLink } from "../hosts/peer-actions";
|
|
|
32
32
|
import { hostLink } from "../hosts/remote-api";
|
|
33
33
|
import { doneHeldTask } from "../tasks/held-remote";
|
|
34
34
|
import { captureMergedTask } from "../session-logs";
|
|
35
|
+
import { sessionEnded } from "../experiments/hooks";
|
|
35
36
|
import { pushMerged } from "../notify/push/events";
|
|
36
37
|
|
|
37
38
|
export interface DoneOptions {
|
|
@@ -112,6 +113,7 @@ function mark(s: Session, status: "merged" | "done", pr: TaskPR | null) {
|
|
|
112
113
|
: `UPDATE sessions SET task_status = 'done' WHERE id = ?`
|
|
113
114
|
).run(s.id);
|
|
114
115
|
syncTaskCardInBackground(s, status, pr);
|
|
116
|
+
void sessionEnded(s.id, status);
|
|
115
117
|
if (status === "merged") {
|
|
116
118
|
pushMerged(s.id);
|
|
117
119
|
void captureMergedTask(s.id, { head: pr?.head });
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { experimentOn } from ".";
|
|
2
|
+
import { EXPERIMENTS, type ExperimentId } from "./registry";
|
|
3
|
+
|
|
4
|
+
export type EndHow = "done" | "merged" | "recipe-settled";
|
|
5
|
+
export type EndHook = (sessionId: string, how: EndHow) => Promise<void> | void;
|
|
6
|
+
|
|
7
|
+
// What each experiment does when a session ends. An experiment adds itself
|
|
8
|
+
// here; one that is off is never called.
|
|
9
|
+
export const END_HOOKS: Partial<Record<ExperimentId, EndHook>> = {};
|
|
10
|
+
|
|
11
|
+
// A merge is seen again on every poll; each session is handed on once.
|
|
12
|
+
const seen = new Set<string>();
|
|
13
|
+
export const resetEndHooks = () => seen.clear();
|
|
14
|
+
|
|
15
|
+
// Called where sessions end. Never makes the caller wait or fail.
|
|
16
|
+
export function sessionEnded(sessionId: string, how: EndHow): Promise<void> {
|
|
17
|
+
if (seen.has(sessionId)) return Promise.resolve();
|
|
18
|
+
seen.add(sessionId);
|
|
19
|
+
return (async () => {
|
|
20
|
+
await new Promise<void>((r) => setImmediate(r));
|
|
21
|
+
for (const { id } of EXPERIMENTS) {
|
|
22
|
+
const hook = END_HOOKS[id];
|
|
23
|
+
if (!hook || !experimentOn(id)) continue;
|
|
24
|
+
try {
|
|
25
|
+
await hook(sessionId, how);
|
|
26
|
+
} catch (error) {
|
|
27
|
+
console.error(`[experiments] ${id} on ${sessionId}:`, error);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
})();
|
|
31
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { getSetting, putSetting } from "../settings-store";
|
|
2
|
+
import { type ExperimentId } from "./registry";
|
|
3
|
+
|
|
4
|
+
type Env = Record<string, string | undefined>;
|
|
5
|
+
|
|
6
|
+
const key = (id: ExperimentId) => `experiments.${id}`;
|
|
7
|
+
|
|
8
|
+
// AGENTOS_EXPERIMENTS=off pins every experiment off, like the update banner.
|
|
9
|
+
export const experimentsLocked = (env: Env = process.env) =>
|
|
10
|
+
env.AGENTOS_EXPERIMENTS === "off";
|
|
11
|
+
|
|
12
|
+
// The one check, on the server. Off by default: no row means off.
|
|
13
|
+
export function experimentOn(id: ExperimentId, env: Env = process.env) {
|
|
14
|
+
if (experimentsLocked(env)) return false;
|
|
15
|
+
return getSetting(key(id)) === "1";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const setExperiment = (id: ExperimentId, on: boolean) =>
|
|
19
|
+
putSetting(key(id), on ? "1" : null);
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What experiments cost and whether they earn it. Every model call an
|
|
3
|
+
* experiment makes goes through experimentRun, which records it here and in
|
|
4
|
+
* the usage ledger; non-model work (scripts, suggestions shown, accepted,
|
|
5
|
+
* dismissed) is counted with countEvent.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { db } from "../db";
|
|
9
|
+
import {
|
|
10
|
+
runClaude,
|
|
11
|
+
type ClaudeRun,
|
|
12
|
+
type ClaudeRunner,
|
|
13
|
+
} from "../orchestrator/claude-cli";
|
|
14
|
+
import type { RunUsage } from "../usage/check-runs";
|
|
15
|
+
import { recordLooseCall } from "../usage/calls";
|
|
16
|
+
import { dayKey } from "../usage/aggregate";
|
|
17
|
+
import { experimentOn } from ".";
|
|
18
|
+
import { getExperiment, type ExperimentId } from "./registry";
|
|
19
|
+
|
|
20
|
+
export type ExperimentEvent =
|
|
21
|
+
| "model"
|
|
22
|
+
| "script"
|
|
23
|
+
| "shown"
|
|
24
|
+
| "accepted"
|
|
25
|
+
| "dismissed"
|
|
26
|
+
| "applied"
|
|
27
|
+
| "error";
|
|
28
|
+
|
|
29
|
+
export interface MeterContext {
|
|
30
|
+
trigger: string;
|
|
31
|
+
sessionId?: string | null;
|
|
32
|
+
workspaceId?: string | null;
|
|
33
|
+
detail?: string | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const clip = (s: string | null | undefined, n = 120) =>
|
|
37
|
+
s ? s.slice(0, n) : null;
|
|
38
|
+
|
|
39
|
+
function insert(
|
|
40
|
+
id: ExperimentId,
|
|
41
|
+
event: ExperimentEvent,
|
|
42
|
+
ctx: MeterContext,
|
|
43
|
+
u?: RunUsage,
|
|
44
|
+
ok = true,
|
|
45
|
+
at = Date.now()
|
|
46
|
+
): number {
|
|
47
|
+
const info = db
|
|
48
|
+
.prepare(
|
|
49
|
+
`INSERT INTO experiment_usage (experiment_id, event, trigger, session_id, workspace_id, model, provider,
|
|
50
|
+
input_tokens, cache_write_tokens, cache_read_tokens, output_tokens, cost_usd, duration_ms, ok, detail, at)
|
|
51
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
52
|
+
)
|
|
53
|
+
.run(
|
|
54
|
+
id,
|
|
55
|
+
event,
|
|
56
|
+
ctx.trigger,
|
|
57
|
+
ctx.sessionId ?? null,
|
|
58
|
+
ctx.workspaceId ?? null,
|
|
59
|
+
u?.model ?? null,
|
|
60
|
+
u ? (u.provider ?? "anthropic") : null,
|
|
61
|
+
u?.inputTokens ?? 0,
|
|
62
|
+
u?.cacheWriteTokens ?? 0,
|
|
63
|
+
u?.cacheReadTokens ?? 0,
|
|
64
|
+
u?.outputTokens ?? 0,
|
|
65
|
+
u?.costUsd ?? 0,
|
|
66
|
+
u?.durationMs ?? null,
|
|
67
|
+
ok ? 1 : 0,
|
|
68
|
+
clip(ctx.detail),
|
|
69
|
+
at
|
|
70
|
+
);
|
|
71
|
+
return Number(info.lastInsertRowid);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function recordModel(id: ExperimentId, ctx: MeterContext, u: RunUsage) {
|
|
75
|
+
try {
|
|
76
|
+
const rowId = insert(id, "model", ctx, u);
|
|
77
|
+
recordLooseCall({
|
|
78
|
+
callKey: `experiment:${rowId}`,
|
|
79
|
+
at: Date.now(),
|
|
80
|
+
sessionId: ctx.sessionId ?? null,
|
|
81
|
+
taskId: null,
|
|
82
|
+
workspaceId: ctx.workspaceId ?? null,
|
|
83
|
+
projectId: null,
|
|
84
|
+
sessionName: getExperiment(id).name,
|
|
85
|
+
role: `experiment:${id}`,
|
|
86
|
+
kind: "helper",
|
|
87
|
+
agentType: null,
|
|
88
|
+
model: u.model,
|
|
89
|
+
provider: u.provider ?? "anthropic",
|
|
90
|
+
input: u.inputTokens,
|
|
91
|
+
cacheRead: u.cacheReadTokens,
|
|
92
|
+
cacheWrite5m: u.cacheWriteTokens,
|
|
93
|
+
cacheWrite1h: 0,
|
|
94
|
+
output: u.outputTokens,
|
|
95
|
+
source: "helper",
|
|
96
|
+
});
|
|
97
|
+
} catch (e) {
|
|
98
|
+
console.error(`[experiments] recording ${id}:`, e);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The only way an experiment calls a model. Returns null, having started
|
|
104
|
+
* nothing, when the experiment is off. A failed run is recorded too (its
|
|
105
|
+
* usage, or an error row when it reported none) and then thrown.
|
|
106
|
+
*/
|
|
107
|
+
export async function experimentRun(
|
|
108
|
+
id: ExperimentId,
|
|
109
|
+
ctx: MeterContext,
|
|
110
|
+
run: ClaudeRun,
|
|
111
|
+
runner: ClaudeRunner = runClaude
|
|
112
|
+
): Promise<unknown | null> {
|
|
113
|
+
if (!experimentOn(id)) return null;
|
|
114
|
+
let usage: RunUsage | null = null;
|
|
115
|
+
try {
|
|
116
|
+
const out = await runner({
|
|
117
|
+
...run,
|
|
118
|
+
onUsage: (u) => {
|
|
119
|
+
usage = u;
|
|
120
|
+
run.onUsage?.(u);
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
if (usage) recordModel(id, ctx, usage);
|
|
124
|
+
return out;
|
|
125
|
+
} catch (error) {
|
|
126
|
+
try {
|
|
127
|
+
if (usage) recordModel(id, ctx, usage);
|
|
128
|
+
insert(
|
|
129
|
+
id,
|
|
130
|
+
"error",
|
|
131
|
+
{
|
|
132
|
+
...ctx,
|
|
133
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
134
|
+
},
|
|
135
|
+
undefined,
|
|
136
|
+
false
|
|
137
|
+
);
|
|
138
|
+
} catch (e) {
|
|
139
|
+
console.error(`[experiments] recording ${id}:`, e);
|
|
140
|
+
}
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Non-model work. Does nothing when the experiment is off, unless `force`:
|
|
147
|
+
* a person deciding on a card that is already on screen is still counted.
|
|
148
|
+
*/
|
|
149
|
+
export function countEvent(
|
|
150
|
+
id: ExperimentId,
|
|
151
|
+
event: Exclude<ExperimentEvent, "model">,
|
|
152
|
+
ctx: MeterContext,
|
|
153
|
+
force = false
|
|
154
|
+
): void {
|
|
155
|
+
if (!force && !experimentOn(id)) return;
|
|
156
|
+
try {
|
|
157
|
+
insert(id, event, ctx);
|
|
158
|
+
} catch (e) {
|
|
159
|
+
console.error(`[experiments] counting ${id}:`, e);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Whether this session already has this event, so a hook runs once.
|
|
164
|
+
export function hasEvent(
|
|
165
|
+
id: ExperimentId,
|
|
166
|
+
sessionId: string,
|
|
167
|
+
event: ExperimentEvent,
|
|
168
|
+
trigger?: string
|
|
169
|
+
): boolean {
|
|
170
|
+
return !!db
|
|
171
|
+
.prepare(
|
|
172
|
+
`SELECT 1 FROM experiment_usage WHERE experiment_id = ? AND session_id = ? AND event = ?
|
|
173
|
+
AND (? IS NULL OR trigger = ?) LIMIT 1`
|
|
174
|
+
)
|
|
175
|
+
.get(id, sessionId, event, trigger ?? null, trigger ?? null);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export interface WindowTotals {
|
|
179
|
+
calls: number;
|
|
180
|
+
tokens: number;
|
|
181
|
+
costUsd: number;
|
|
182
|
+
scripts: number;
|
|
183
|
+
shown: number;
|
|
184
|
+
accepted: number;
|
|
185
|
+
dismissed: number;
|
|
186
|
+
// accepted / (accepted + dismissed); null with no decisions yet.
|
|
187
|
+
acceptRate: number | null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export interface UsageSummary {
|
|
191
|
+
today: WindowTotals;
|
|
192
|
+
d7: WindowTotals;
|
|
193
|
+
d30: WindowTotals;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const DAY = 86_400_000;
|
|
197
|
+
|
|
198
|
+
export function startOfDay(now: number): number {
|
|
199
|
+
const d = new Date(now);
|
|
200
|
+
d.setHours(0, 0, 0, 0);
|
|
201
|
+
return d.getTime();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function windowTotals(id: ExperimentId, since: number): WindowTotals {
|
|
205
|
+
const r = db
|
|
206
|
+
.prepare(
|
|
207
|
+
`SELECT
|
|
208
|
+
coalesce(sum(event = 'model'), 0) AS calls,
|
|
209
|
+
coalesce(sum(input_tokens + cache_write_tokens + cache_read_tokens + output_tokens), 0) AS tokens,
|
|
210
|
+
coalesce(sum(cost_usd), 0) AS costUsd,
|
|
211
|
+
coalesce(sum(event = 'script'), 0) AS scripts,
|
|
212
|
+
coalesce(sum(event = 'shown'), 0) AS shown,
|
|
213
|
+
coalesce(sum(event = 'accepted'), 0) AS accepted,
|
|
214
|
+
coalesce(sum(event = 'dismissed'), 0) AS dismissed
|
|
215
|
+
FROM experiment_usage WHERE experiment_id = ? AND at >= ?`
|
|
216
|
+
)
|
|
217
|
+
.get(id, since) as Omit<WindowTotals, "acceptRate">;
|
|
218
|
+
const decided = r.accepted + r.dismissed;
|
|
219
|
+
return { ...r, acceptRate: decided ? r.accepted / decided : null };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function usageSummary(id: ExperimentId, now = Date.now()): UsageSummary {
|
|
223
|
+
const today = startOfDay(now);
|
|
224
|
+
return {
|
|
225
|
+
today: windowTotals(id, today),
|
|
226
|
+
d7: windowTotals(id, today - 6 * DAY),
|
|
227
|
+
d30: windowTotals(id, today - 29 * DAY),
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export interface DayRow {
|
|
232
|
+
day: string;
|
|
233
|
+
calls: number;
|
|
234
|
+
tokens: number;
|
|
235
|
+
costUsd: number;
|
|
236
|
+
accepted: number;
|
|
237
|
+
dismissed: number;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export interface Breakdown {
|
|
241
|
+
key: string;
|
|
242
|
+
calls: number;
|
|
243
|
+
tokens: number;
|
|
244
|
+
costUsd: number;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export interface RecentEvent {
|
|
248
|
+
at: number;
|
|
249
|
+
event: string;
|
|
250
|
+
trigger: string;
|
|
251
|
+
model: string | null;
|
|
252
|
+
tokens: number;
|
|
253
|
+
costUsd: number;
|
|
254
|
+
ok: boolean;
|
|
255
|
+
detail: string | null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export interface UsageDetail {
|
|
259
|
+
days: DayRow[];
|
|
260
|
+
byTrigger: Breakdown[];
|
|
261
|
+
byModel: Breakdown[];
|
|
262
|
+
recent: RecentEvent[];
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function usageDetail(id: ExperimentId, now = Date.now()): UsageDetail {
|
|
266
|
+
const since = startOfDay(now) - 29 * DAY;
|
|
267
|
+
const rows = db
|
|
268
|
+
.prepare(
|
|
269
|
+
`SELECT at, event, trigger, model, cost_usd AS costUsd,
|
|
270
|
+
input_tokens + cache_write_tokens + cache_read_tokens + output_tokens AS tokens
|
|
271
|
+
FROM experiment_usage WHERE experiment_id = ? AND at >= ?`
|
|
272
|
+
)
|
|
273
|
+
.all(id, since) as {
|
|
274
|
+
at: number;
|
|
275
|
+
event: string;
|
|
276
|
+
trigger: string;
|
|
277
|
+
model: string | null;
|
|
278
|
+
costUsd: number;
|
|
279
|
+
tokens: number;
|
|
280
|
+
}[];
|
|
281
|
+
const days = new Map<string, DayRow>();
|
|
282
|
+
for (let i = 29; i >= 0; i--) {
|
|
283
|
+
const day = dayKey(startOfDay(now) - i * DAY);
|
|
284
|
+
days.set(day, {
|
|
285
|
+
day,
|
|
286
|
+
calls: 0,
|
|
287
|
+
tokens: 0,
|
|
288
|
+
costUsd: 0,
|
|
289
|
+
accepted: 0,
|
|
290
|
+
dismissed: 0,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
const trig = new Map<string, Breakdown>();
|
|
294
|
+
const mod = new Map<string, Breakdown>();
|
|
295
|
+
const add = (m: Map<string, Breakdown>, key: string, r: (typeof rows)[0]) => {
|
|
296
|
+
const b = m.get(key) ?? { key, calls: 0, tokens: 0, costUsd: 0 };
|
|
297
|
+
b.calls += r.event === "model" ? 1 : 0;
|
|
298
|
+
b.tokens += r.tokens;
|
|
299
|
+
b.costUsd += r.costUsd;
|
|
300
|
+
m.set(key, b);
|
|
301
|
+
};
|
|
302
|
+
for (const r of rows) {
|
|
303
|
+
const d = days.get(dayKey(r.at));
|
|
304
|
+
if (d) {
|
|
305
|
+
d.calls += r.event === "model" ? 1 : 0;
|
|
306
|
+
d.tokens += r.tokens;
|
|
307
|
+
d.costUsd += r.costUsd;
|
|
308
|
+
d.accepted += r.event === "accepted" ? 1 : 0;
|
|
309
|
+
d.dismissed += r.event === "dismissed" ? 1 : 0;
|
|
310
|
+
}
|
|
311
|
+
add(trig, r.trigger, r);
|
|
312
|
+
if (r.event === "model") add(mod, r.model ?? "unrecorded", r);
|
|
313
|
+
}
|
|
314
|
+
const recent = db
|
|
315
|
+
.prepare(
|
|
316
|
+
`SELECT at, event, trigger, model, input_tokens + cache_write_tokens + cache_read_tokens + output_tokens AS tokens,
|
|
317
|
+
cost_usd AS costUsd, ok, detail
|
|
318
|
+
FROM experiment_usage WHERE experiment_id = ? ORDER BY at DESC, id DESC LIMIT 50`
|
|
319
|
+
)
|
|
320
|
+
.all(id) as (Omit<RecentEvent, "ok"> & { ok: number })[];
|
|
321
|
+
return {
|
|
322
|
+
days: [...days.values()],
|
|
323
|
+
byTrigger: [...trig.values()],
|
|
324
|
+
byModel: [...mod.values()],
|
|
325
|
+
recent: recent.map((r) => ({ ...r, ok: !!r.ok })),
|
|
326
|
+
};
|
|
327
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Every experiment AgentOS knows about. All are off until someone turns them
|
|
2
|
+
// on. To add one: an entry here, `experimentOn(id)` where it does its work,
|
|
3
|
+
// and `experimentRun(id, ...)` for any model call it makes.
|
|
4
|
+
|
|
5
|
+
export type ExperimentStatus = "experimental" | "beta";
|
|
6
|
+
|
|
7
|
+
export interface Experiment {
|
|
8
|
+
// Kebab-case and stable: it names the setting and keys the usage rows.
|
|
9
|
+
id: string;
|
|
10
|
+
name: string;
|
|
11
|
+
description: string;
|
|
12
|
+
// Who owns it: "sessions", "tasks", "orchestrator"...
|
|
13
|
+
area: string;
|
|
14
|
+
status: ExperimentStatus;
|
|
15
|
+
// It shows suggestions, so an accept rate means something for it.
|
|
16
|
+
suggests?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const EXPERIMENTS = [
|
|
20
|
+
{
|
|
21
|
+
id: "learning-cards",
|
|
22
|
+
name: "Learning cards",
|
|
23
|
+
description:
|
|
24
|
+
"When a session ends, suggest saving it as a recipe or keeping a lesson, in the one place it belongs.",
|
|
25
|
+
area: "sessions",
|
|
26
|
+
status: "experimental",
|
|
27
|
+
suggests: true,
|
|
28
|
+
},
|
|
29
|
+
] as const satisfies readonly Experiment[];
|
|
30
|
+
|
|
31
|
+
export type ExperimentId = (typeof EXPERIMENTS)[number]["id"];
|
|
32
|
+
|
|
33
|
+
export const isExperimentId = (v: unknown): v is ExperimentId =>
|
|
34
|
+
EXPERIMENTS.some((e) => e.id === v);
|
|
35
|
+
|
|
36
|
+
export const getExperiment = (id: ExperimentId): Experiment =>
|
|
37
|
+
EXPERIMENTS.find((e) => e.id === id)!;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { experimentOn, experimentsLocked } from ".";
|
|
2
|
+
import { EXPERIMENTS, type Experiment, type ExperimentId } from "./registry";
|
|
3
|
+
import { usageSummary, type UsageSummary } from "./meter";
|
|
4
|
+
|
|
5
|
+
export interface ExperimentView extends Experiment {
|
|
6
|
+
on: boolean;
|
|
7
|
+
usage: UsageSummary;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const experimentView = (id: ExperimentId): ExperimentView => ({
|
|
11
|
+
...EXPERIMENTS.find((e) => e.id === id)!,
|
|
12
|
+
on: experimentOn(id),
|
|
13
|
+
usage: usageSummary(id),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
export const experimentsView = () => ({
|
|
17
|
+
locked: experimentsLocked(),
|
|
18
|
+
experiments: EXPERIMENTS.map((e) => experimentView(e.id)),
|
|
19
|
+
});
|
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";
|