@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,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
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
2
|
+
import { toast } from "sonner";
|
|
3
|
+
import type { Card } from "@/lib/learning-cards/types";
|
|
4
|
+
import { useExperiment } from "../experiments";
|
|
5
|
+
|
|
6
|
+
export type { Card };
|
|
7
|
+
|
|
8
|
+
export interface CardEdits {
|
|
9
|
+
lesson?: string;
|
|
10
|
+
brief?: string;
|
|
11
|
+
taskPrompt?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const learningCardsKeys = {
|
|
15
|
+
session: (sessionId: string) => ["learning-cards", sessionId] as const,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
async function json<T>(res: Response): Promise<T> {
|
|
19
|
+
const data = await res.json();
|
|
20
|
+
if (!res.ok) throw new Error(data.error || "Request failed");
|
|
21
|
+
return data;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Pending cards of a session; nothing is asked while the experiment is off.
|
|
25
|
+
export function useLearningCards(sessionId: string) {
|
|
26
|
+
const enabled = useExperiment("learning-cards");
|
|
27
|
+
return useQuery({
|
|
28
|
+
queryKey: learningCardsKeys.session(sessionId),
|
|
29
|
+
queryFn: async () =>
|
|
30
|
+
(
|
|
31
|
+
await json<{ cards: Card[] }>(
|
|
32
|
+
await fetch(
|
|
33
|
+
`/api/sessions/${encodeURIComponent(sessionId)}/learning-cards`
|
|
34
|
+
)
|
|
35
|
+
)
|
|
36
|
+
).cards,
|
|
37
|
+
enabled,
|
|
38
|
+
staleTime: 10_000,
|
|
39
|
+
refetchInterval: 30_000,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function useDecide(sessionId: string) {
|
|
44
|
+
const queryClient = useQueryClient();
|
|
45
|
+
return useMutation({
|
|
46
|
+
mutationFn: async (
|
|
47
|
+
v: { id: number; action: "apply" | "dismiss" } & CardEdits
|
|
48
|
+
) =>
|
|
49
|
+
json<{ card: Card }>(
|
|
50
|
+
await fetch(`/api/learning-cards/${encodeURIComponent(String(v.id))}`, {
|
|
51
|
+
method: "POST",
|
|
52
|
+
headers: { "Content-Type": "application/json" },
|
|
53
|
+
body: JSON.stringify(v),
|
|
54
|
+
})
|
|
55
|
+
),
|
|
56
|
+
onSuccess: (_d, v) => {
|
|
57
|
+
queryClient.invalidateQueries({
|
|
58
|
+
queryKey: learningCardsKeys.session(sessionId),
|
|
59
|
+
});
|
|
60
|
+
// A saved recipe or a new rule shows up in their own lists.
|
|
61
|
+
if (v.action === "apply") {
|
|
62
|
+
queryClient.invalidateQueries({ queryKey: ["recipes"] });
|
|
63
|
+
queryClient.invalidateQueries({ queryKey: ["orchestrator"] });
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
onError: (error) =>
|
|
67
|
+
toast.error(error instanceof Error ? error.message : "Couldn't do that"),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const useApplyCard = (sessionId: string) => {
|
|
72
|
+
const m = useDecide(sessionId);
|
|
73
|
+
return {
|
|
74
|
+
...m,
|
|
75
|
+
apply: (id: number, edits: CardEdits = {}) =>
|
|
76
|
+
m.mutate({ id, action: "apply", ...edits }),
|
|
77
|
+
dismiss: (id: number) => m.mutate({ id, action: "dismiss" }),
|
|
78
|
+
};
|
|
79
|
+
};
|
package/lib/chat/store.ts
CHANGED
|
@@ -180,6 +180,22 @@ export function itemsOfKind<K extends ChatItem["kind"]>(
|
|
|
180
180
|
).map((r) => JSON.parse(r.data));
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
+
// Several kinds in the order they happened.
|
|
184
|
+
export function itemsOfKinds<K extends ChatItem["kind"]>(
|
|
185
|
+
sessionId: string,
|
|
186
|
+
kinds: K[]
|
|
187
|
+
): Extract<ChatItem, { kind: K }>[] {
|
|
188
|
+
flushItems();
|
|
189
|
+
return (
|
|
190
|
+
db
|
|
191
|
+
.prepare(
|
|
192
|
+
`SELECT data FROM chat_items WHERE session_id = ?
|
|
193
|
+
AND kind IN (${kinds.map(() => "?").join(",")}) ORDER BY seq`
|
|
194
|
+
)
|
|
195
|
+
.all(sessionId, ...kinds) as { data: string }[]
|
|
196
|
+
).map((r) => JSON.parse(r.data));
|
|
197
|
+
}
|
|
198
|
+
|
|
183
199
|
// A chat's background tasks still running, or ended after `since` (ms): what
|
|
184
200
|
// the composer lists, read without parsing the rest of a long conversation.
|
|
185
201
|
export function recentTasks(
|
package/lib/db/migrations.ts
CHANGED
|
@@ -2028,6 +2028,65 @@ 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
|
+
},
|
|
2064
|
+
{
|
|
2065
|
+
id: 86,
|
|
2066
|
+
name: "learning_cards",
|
|
2067
|
+
up: (db) => {
|
|
2068
|
+
// End-of-session suggestions (save as recipe, keep a lesson). An
|
|
2069
|
+
// accepted lesson row is the record of where it was applied.
|
|
2070
|
+
db.exec(`
|
|
2071
|
+
CREATE TABLE IF NOT EXISTS learning_cards (
|
|
2072
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
2073
|
+
session_id TEXT NOT NULL,
|
|
2074
|
+
workspace_id TEXT,
|
|
2075
|
+
kind TEXT NOT NULL,
|
|
2076
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
2077
|
+
payload TEXT NOT NULL,
|
|
2078
|
+
destination TEXT,
|
|
2079
|
+
applied_ref TEXT,
|
|
2080
|
+
shown_at INTEGER,
|
|
2081
|
+
decided_at INTEGER,
|
|
2082
|
+
created_at INTEGER NOT NULL,
|
|
2083
|
+
UNIQUE (session_id, kind)
|
|
2084
|
+
);
|
|
2085
|
+
CREATE INDEX IF NOT EXISTS idx_learning_cards_dest
|
|
2086
|
+
ON learning_cards(destination, decided_at);
|
|
2087
|
+
`);
|
|
2088
|
+
},
|
|
2089
|
+
},
|
|
2031
2090
|
];
|
|
2032
2091
|
|
|
2033
2092
|
// `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,39 @@
|
|
|
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
|
+
// Loaded on first use so the places that end sessions don't pull in the
|
|
10
|
+
// experiment's code. A hook does its own once-only check in the database: this
|
|
11
|
+
// process forgets what it has seen on a restart.
|
|
12
|
+
export const END_HOOKS: Partial<Record<ExperimentId, EndHook>> = {
|
|
13
|
+
"learning-cards": async (id, how) =>
|
|
14
|
+
(await import("../learning-cards/analyze")).analyzeSession(id, how),
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// A merge is seen again on every poll; each session is handed on once. A
|
|
18
|
+
// recipe run settling is its own event: its session has usually ended already.
|
|
19
|
+
const seen = new Set<string>();
|
|
20
|
+
export const resetEndHooks = () => seen.clear();
|
|
21
|
+
|
|
22
|
+
// Called where sessions end. Never makes the caller wait or fail.
|
|
23
|
+
export function sessionEnded(sessionId: string, how: EndHow): Promise<void> {
|
|
24
|
+
const key = how === "recipe-settled" ? `${sessionId}:settled` : sessionId;
|
|
25
|
+
if (seen.has(key)) return Promise.resolve();
|
|
26
|
+
seen.add(key);
|
|
27
|
+
return (async () => {
|
|
28
|
+
await new Promise<void>((r) => setImmediate(r));
|
|
29
|
+
for (const { id } of EXPERIMENTS) {
|
|
30
|
+
const hook = END_HOOKS[id];
|
|
31
|
+
if (!hook || !experimentOn(id)) continue;
|
|
32
|
+
try {
|
|
33
|
+
await hook(sessionId, how);
|
|
34
|
+
} catch (error) {
|
|
35
|
+
console.error(`[experiments] ${id} on ${sessionId}:`, error);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
})();
|
|
39
|
+
}
|