@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.
Files changed (48) hide show
  1. package/README.md +1 -0
  2. package/app/api/experiments/[id]/route.ts +39 -0
  3. package/app/api/experiments/route.test.ts +51 -0
  4. package/app/api/experiments/route.ts +7 -0
  5. package/app/api/learning-cards/[id]/route.test.ts +62 -0
  6. package/app/api/learning-cards/[id]/route.ts +36 -0
  7. package/app/api/recipes/draft/route.ts +6 -1
  8. package/app/api/recipes/route.ts +3 -0
  9. package/app/api/sessions/[id]/learning-cards/route.test.ts +35 -0
  10. package/app/api/sessions/[id]/learning-cards/route.ts +10 -0
  11. package/components/Chat/ChatPanel.tsx +2 -0
  12. package/components/LearningCards/LearningCard.test.ts +156 -0
  13. package/components/LearningCards/LearningCard.tsx +210 -0
  14. package/components/Settings/ExperimentsSection.test.ts +89 -0
  15. package/components/Settings/ExperimentsSection.tsx +225 -0
  16. package/components/Settings/SettingsDialog.test.ts +3 -0
  17. package/components/Settings/SettingsDialog.tsx +9 -0
  18. package/components/Settings/commands.ts +9 -0
  19. package/components/Settings/entry-points.test.ts +1 -0
  20. package/data/experiments/index.ts +67 -0
  21. package/data/learning-cards/index.ts +79 -0
  22. package/lib/chat/store.ts +16 -0
  23. package/lib/db/migrations.ts +59 -0
  24. package/lib/done/index.ts +2 -0
  25. package/lib/experiments/hooks.ts +39 -0
  26. package/lib/experiments/index.ts +19 -0
  27. package/lib/experiments/meter.ts +327 -0
  28. package/lib/experiments/registry.ts +37 -0
  29. package/lib/experiments/view.ts +19 -0
  30. package/lib/learning-cards/__fixtures__/db.ts +52 -0
  31. package/lib/learning-cards/__fixtures__/sessions.ts +63 -0
  32. package/lib/learning-cards/analyze.ts +228 -0
  33. package/lib/learning-cards/apply.ts +196 -0
  34. package/lib/learning-cards/index.ts +29 -0
  35. package/lib/learning-cards/input.ts +116 -0
  36. package/lib/learning-cards/pairs.ts +111 -0
  37. package/lib/learning-cards/signals.ts +98 -0
  38. package/lib/learning-cards/store.ts +103 -0
  39. package/lib/learning-cards/types.ts +79 -0
  40. package/lib/orchestrator/quick.ts +14 -1
  41. package/lib/recipes/draft.ts +62 -33
  42. package/lib/recipes/settle.ts +4 -1
  43. package/lib/settings-store.ts +22 -0
  44. package/lib/settings-url.ts +1 -0
  45. package/lib/tasks/finish.ts +2 -0
  46. package/lib/tasks/session.ts +2 -0
  47. package/lib/updates/settings.ts +2 -20
  48. 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(
@@ -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
+ }