@saadnvd1/agent-os 0.3.555 → 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.
@@ -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
+ });
@@ -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
+ }
@@ -16,6 +16,7 @@ export const SETTINGS_SECTIONS = [
16
16
  "audits",
17
17
  "setup",
18
18
  "updates",
19
+ "experiments",
19
20
  ] as const;
20
21
 
21
22
  export type SettingsSection = (typeof SETTINGS_SECTIONS)[number];
@@ -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,
@@ -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
@@ -1,4 +1,4 @@
1
- import { getDb } from "@/lib/db";
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 function getSetting(key: string): string | null {
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";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.555",
3
+ "version": "0.3.557",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"