@a-t-h-i/bot-lobby 0.6.31 → 0.6.33

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a-t-h-i/bot-lobby",
3
- "version": "0.6.31",
3
+ "version": "0.6.33",
4
4
  "description": "Structured multi-agent software engineering orchestrator for Pi",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -271,8 +271,8 @@ function buildRoutes(): Record<string, Route> {
271
271
  run: (body, ctx) => quickfixMovedToTask(body as { id: string }, ctx),
272
272
  };
273
273
  routes["metrics.get"] = {
274
- schema: Type.Object({ groupBy: Type.Union([Type.Literal("model"), Type.Literal("model-kind")]), query: Type.Optional(Type.String({ maxLength: 500 })) }, { additionalProperties: false }),
275
- run: (body, ctx) => metricsGet(body as { groupBy: "model" | "model-kind"; query?: string }, ctx),
274
+ schema: Type.Object({ groupBy: Type.Union([Type.Literal("model"), Type.Literal("model-kind")]), query: Type.Optional(Type.String({ maxLength: 500 })), from: Type.Optional(Type.String({ pattern: "^\\d{4}-\\d{2}-\\d{2}$" })), to: Type.Optional(Type.String({ pattern: "^\\d{4}-\\d{2}-\\d{2}$" })), timeZone: Type.Optional(Type.String({ maxLength: 100 })) }, { additionalProperties: false }),
275
+ run: (body, ctx) => metricsGet(body as Parameters<typeof metricsGet>[0], ctx),
276
276
  };
277
277
  routes["knowledge.files"] = { schema: Empty, run: (_body, ctx) => knowledgeFiles(ctx) };
278
278
  routes["knowledge.ask"] = {
@@ -3,10 +3,10 @@
3
3
  * time goes, and what the classifier did — the same figures the terminal
4
4
  * tab draws, as data. Search narrows runs exactly like the tab's `/`.
5
5
  */
6
- import { aggregateMetrics, collectMetrics, sortGroups, summarizeClassifier, taskStats, taskTimesByModel, type GroupBy, type MetricRecord } from "../../state/metrics.ts";
6
+ import { aggregateMetrics, collectMetrics, sortGroups, summarizeClassifier, taskStats, taskTimesByModel, type MetricRecord } from "../../state/metrics.ts";
7
7
  import { filterRecords } from "../../lobby/models/metrics.ts";
8
- import type { MetricsData } from "../protocol.ts";
9
- import type { ApiContext } from "./index.ts";
8
+ import type { Api, MetricsData } from "../protocol.ts";
9
+ import { fail, type ApiContext } from "./index.ts";
10
10
 
11
11
  function timed(records: readonly MetricRecord[]): MetricRecord[] {
12
12
  return records.filter((record) => record.status !== "cancelled" && record.durationMs > 0);
@@ -49,15 +49,48 @@ function shareOf(records: readonly MetricRecord[]): MetricsData["timeShare"]["by
49
49
  }
50
50
 
51
51
  /** The dashboard figures for one grouping and search. */
52
- export function metricsGet(body: { groupBy: GroupBy; query?: string }, ctx: ApiContext): MetricsData {
53
- const tasks = ctx.service.tasks();
54
- const records = filterRecords(collectMetrics(ctx.service.metrics(), tasks), body.query);
52
+ export function metricsGet(body: Api["metrics.get"]["request"], ctx: ApiContext): MetricsData {
53
+ const { from, to } = body;
54
+ const validDate = (value: string) => /^\d{4}-\d{2}-\d{2}$/.test(value) && Number.isFinite(Date.parse(value)) && new Date(value).toISOString().slice(0, 10) === value;
55
+ if ((from === undefined) !== (to === undefined) || (from !== undefined && (!validDate(from) || !validDate(to!) || from > to!))) {
56
+ fail(400, "bad_request", "Choose a valid start and end date, with the start on or before the end.");
57
+ }
58
+ let formatter: Intl.DateTimeFormat;
59
+ try {
60
+ formatter = new Intl.DateTimeFormat("en", { timeZone: body.timeZone ?? "UTC", year: "numeric", month: "2-digit", day: "2-digit" });
61
+ } catch {
62
+ fail(400, "bad_request", "Choose a valid time zone.");
63
+ }
64
+ const dayOf = (timestamp: string) => {
65
+ const date = new Date(timestamp);
66
+ if (!Number.isFinite(date.getTime())) return "";
67
+ const parts = formatter.formatToParts(date);
68
+ return ["year", "month", "day"].map((part) => parts.find((item) => item.type === part)!.value).join("-");
69
+ };
70
+ const inRange = (timestamp: string) => !from || (dayOf(timestamp) >= from && dayOf(timestamp) <= to!);
71
+ const allTasks = ctx.service.tasks();
72
+ const tasks = allTasks.filter((task) => inRange(task.state === "completed" ? task.updatedAt : task.createdAt));
73
+ const allRecords = filterRecords(collectMetrics(ctx.service.metrics(), allTasks), body.query);
74
+ const records = allRecords.filter((record) => inRange(record.startedAt));
55
75
  const stats = taskStats(tasks);
56
- const classifier = summarizeClassifier(ctx.service.classifierMetrics?.() ?? [], records);
76
+ const classifier = summarizeClassifier((ctx.service.classifierMetrics?.() ?? []).filter((record) => inRange(record.startedAt)), records);
77
+ const daily = new Map<string, MetricsData["daily"][number]>();
78
+ for (const record of records) {
79
+ const date = dayOf(record.startedAt);
80
+ if (!date) continue;
81
+ const entry = daily.get(date) ?? { date, runs: 0, successes: 0, cost: 0 };
82
+ entry.runs += 1;
83
+ entry.successes += Number(record.status === "success");
84
+ entry.cost += record.cost ?? 0;
85
+ daily.set(date, entry);
86
+ }
87
+ // Include range edges without allocating an entry for every empty day.
88
+ for (const date of [from, to]) if (date && !daily.has(date)) daily.set(date, { date, runs: 0, successes: 0, cost: 0 });
57
89
  return {
58
90
  tiles: tilesOf(records, stats),
91
+ daily: [...daily.values()].sort((a, b) => a.date.localeCompare(b.date)),
59
92
  groups: sortGroups(aggregateMetrics(records, body.groupBy), "runs"),
60
- timeShare: { byAgent: shareOf(records), taskTimes: taskTimesByModel(tasks, records) },
93
+ timeShare: { byAgent: shareOf(records), taskTimes: taskTimesByModel(tasks, allRecords) },
61
94
  ...(classifier ? { classifier } : {}),
62
95
  };
63
96
  }
@@ -335,6 +335,7 @@ export interface MetricsClassifier {
335
335
  export interface MetricsData {
336
336
  tiles: MetricsTiles;
337
337
  groups: MetricGroupInfo[];
338
+ daily: Array<{ date: string; runs: number; successes: number; cost: number }>;
338
339
  timeShare: { byAgent: MetricsAgentShare[]; taskTimes: MetricsTaskTime[] };
339
340
  classifier?: MetricsClassifier;
340
341
  }
@@ -634,7 +635,7 @@ export interface Api {
634
635
  "quickfix.cancel": { request: { id: string }; result: { notice: string } };
635
636
  "quickfix.runAnyway": { request: { id: string }; result: { notice: string } };
636
637
  "quickfix.movedToTask": { request: { id: string }; result: { notice: string; key?: string } };
637
- "metrics.get": { request: { groupBy: "model" | "model-kind"; query?: string }; result: MetricsData };
638
+ "metrics.get": { request: { groupBy: "model" | "model-kind"; query?: string; from?: string; to?: string; timeZone?: string }; result: MetricsData };
638
639
  "projects.browse": { request: { path?: string }; result: FolderListing };
639
640
  "projects.open": { request: { path: string }; result: { project: ProjectInfo } };
640
641
  "knowledge.files": { request: Record<string, never>; result: { files: KnowledgeFileInfo[]; models?: Partial<Record<KnowledgeAgentName, string>> } };