@danypops/jittor 0.6.0 → 0.7.0

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.
@@ -1,8 +1,9 @@
1
1
  import type { Database } from "bun:sqlite";
2
- import { DEFAULT_QUERY_LIMIT, MAX_QUERY_LIMIT } from "../constants.ts";
2
+ import { DEFAULT_QUERY_LIMIT, MAX_QUERY_LIMIT, USAGE_AGGREGATE_MAX_ROWS } from "../constants.ts";
3
3
  import type { MetricObservation, MetricQuery, StoredMetricObservation } from "../domain/metric.ts";
4
4
  import { validateMetricObservation } from "../domain/metric.ts";
5
- import type { MetricStore } from "../ports/metric-store.ts";
5
+ import type { UsageAggregateRow } from "../domain/usage.ts";
6
+ import type { DistinctScopesFilter, MetricStore, UsageAggregateFilter } from "../ports/metric-store.ts";
6
7
 
7
8
  interface MetricRow {
8
9
  id: number;
@@ -75,6 +76,46 @@ export class SQLiteMetricStore implements MetricStore {
75
76
  return rows.map(fromRow);
76
77
  }
77
78
 
79
+ distinctScopes(filter: DistinctScopesFilter): string[] {
80
+ const limit = Math.max(1, Math.min(MAX_QUERY_LIMIT, Math.floor(filter.limit)));
81
+ const rows = this.db.query(`
82
+ SELECT DISTINCT scope FROM metric_observations
83
+ WHERE source = ? AND observed_at >= ? AND observed_at <= ?
84
+ ORDER BY scope ASC
85
+ LIMIT ?
86
+ `).all(filter.source, filter.since, filter.until, limit) as Array<{ scope: string }>;
87
+ return rows.map((row) => row.scope);
88
+ }
89
+
90
+ aggregateUsage(filter: UsageAggregateFilter): UsageAggregateRow[] {
91
+ if (filter.scopes.length === 0) return [];
92
+ if (!Number.isFinite(filter.bucketSizeMs) || filter.bucketSizeMs <= 0) throw new Error("bucketSizeMs must be a positive number");
93
+ if (!Number.isInteger(filter.bucketCount) || filter.bucketCount <= 0) throw new Error("bucketCount must be a positive integer");
94
+ const scopePlaceholders = filter.scopes.map(() => "?").join(", ");
95
+ // MIN(a, b) is SQLite's scalar two-argument form (smallest of the arguments), mirroring
96
+ // usageBucketIndex's own `Math.min(bucketCount - 1, Math.floor(...))` clamp exactly, so a bucket
97
+ // this returns always matches the same-indexed bucket the client-side window renders.
98
+ const rows = this.db.query(`
99
+ SELECT scope, metric,
100
+ MIN(CAST((observed_at - ?) AS REAL) / ?, ? - 1) AS bucket_index_raw,
101
+ SUM(value) AS sum
102
+ FROM metric_observations
103
+ WHERE source = ? AND observed_at >= ? AND observed_at <= ? AND value >= 0 AND scope IN (${scopePlaceholders})
104
+ GROUP BY scope, metric, CAST(bucket_index_raw AS INTEGER)
105
+ LIMIT ?
106
+ `).all(
107
+ filter.since, filter.bucketSizeMs, filter.bucketCount,
108
+ filter.source, filter.since, filter.until, ...filter.scopes,
109
+ USAGE_AGGREGATE_MAX_ROWS,
110
+ ) as Array<{ scope: string; metric: string; bucket_index_raw: number; sum: number }>;
111
+ return rows.map((row) => ({
112
+ scope: row.scope,
113
+ metric: row.metric,
114
+ bucketIndex: Math.max(0, Math.floor(row.bucket_index_raw)),
115
+ sum: row.sum,
116
+ }));
117
+ }
118
+
78
119
  pruneBefore(cutoff: number): number {
79
120
  if (!Number.isSafeInteger(cutoff) || cutoff < 0) throw new Error("cutoff must be a non-negative integer timestamp");
80
121
  return this.db.query("DELETE FROM metric_observations WHERE observed_at < ?").run(cutoff).changes;
package/src/cli.ts CHANGED
@@ -17,16 +17,19 @@ import {
17
17
  MODEL_RANKING_DEFAULT_RELIABILITY_WEIGHT,
18
18
  MODEL_RANKING_MAX_SOURCES,
19
19
  SYSTEMD_UNIT_NAME,
20
+ MAX_USAGE_BUCKETS,
21
+ USAGE_MAX_DISTINCT_SCOPES,
20
22
  } from "./constants.ts";
21
23
  import { connectJittorClient, type JittorClient } from "./client.ts";
22
24
  import { serveMain } from "./daemon.ts";
23
25
  import type { BenchmarkQuery, BenchmarkQueryResult, BenchmarkRefreshResult } from "./domain/benchmark.ts";
24
26
  import type { ModelRecommendationInput } from "./domain/model-ranking-service.ts";
25
27
  import type { ModelCandidate, ModelRankingResult, ScopeAuthority, UtilityWeights } from "./domain/model-ranking.ts";
26
- import { TASK_CLASSES, type ModelTaskClass } from "./domain/model-observation.ts";
28
+ import { TASK_DOMAINS, TASK_TYPES, type ModelTaskDomain, type ModelTaskType } from "./domain/model-observation.ts";
27
29
  import type { ContextAssessment } from "./domain/context-telemetry.ts";
28
30
  import { METRIC_UNITS, type MetricObservation, type MetricQuery, type MetricUnit, type StoredMetricObservation } from "./domain/metric.ts";
29
31
  import type { CompactionDurationEstimate } from "./domain/context-telemetry.ts";
32
+ import type { TaskCostSummary } from "./domain/task-cost.ts";
30
33
  import type { PolicyDecision, Route } from "./policy.ts";
31
34
  import type { RouteOverride, RouterStatus, TelemetryPollResult } from "./ports/router-controller.ts";
32
35
  import { EXPECTED_OPERATION_NAMES, type OperationInputs, type OperationName, type OperationOutputs } from "./service.ts";
@@ -104,7 +107,10 @@ function usage(stderr: (line: string) => void): number {
104
107
  " benchmarks <status|refresh|list|rank> [options] [--json]",
105
108
  " metrics record --source <s> --scope <s> --metric <s> --value <number|null> --unit <unit> [--observed-at <ms>] [--attributes <json>] [--json]",
106
109
  " metrics query [--source <s>] [--scope <s>] [--metric <s>] [--since <ms>] [--until <ms>] [--limit <n>] [--order asc|desc] [--json]",
107
- " metrics prune --before <ms> [--json]",
110
+ " metrics prune --before <ms> [--force] [--json] (force required if before is newer than 24h ago)",
111
+ ` metrics distinct-scopes --source <s> --since <ms> --until <ms> [--limit 1..${USAGE_MAX_DISTINCT_SCOPES}] [--json]`,
112
+ ` metrics usage-series --source <s> --since <ms> --until <ms> --bucket-size-ms <ms> --bucket-count 1..${MAX_USAGE_BUCKETS} [--scope-limit 1..${USAGE_MAX_DISTINCT_SCOPES}] [--json]`,
113
+ " metrics cost-by-task --since <ms> --until <ms> [--json]",
108
114
  " telemetry poll [--json]",
109
115
  " compaction estimate [--json]",
110
116
  " router <status|decide|pause|resume|clear-override> [--json]",
@@ -225,14 +231,93 @@ function parseMetricsQueryArgs(args: string[]): MetricsQueryArgs | null {
225
231
  return { input, json };
226
232
  }
227
233
 
228
- interface MetricsPruneArgs { input: { before: number }; json: boolean }
234
+ interface MetricsDistinctScopesArgs { input: { source: string; since: number; until: number; limit?: number }; json: boolean }
235
+
236
+ function parseMetricsDistinctScopesArgs(args: string[]): MetricsDistinctScopesArgs | null {
237
+ let json = false;
238
+ let source: string | undefined;
239
+ let since: number | undefined;
240
+ let until: number | undefined;
241
+ let limit: number | undefined;
242
+ for (let index = 0; index < args.length; index += 1) {
243
+ const argument = args[index];
244
+ if (argument === "--json") { json = true; continue; }
245
+ if (!["--source", "--since", "--until", "--limit"].includes(argument ?? "")) return null;
246
+ const raw = args[++index];
247
+ if (raw === undefined || raw.length === 0) return null;
248
+ if (argument === "--source") { source = raw; continue; }
249
+ const parsed = Number(raw);
250
+ if (!Number.isSafeInteger(parsed) || parsed < 0) return null;
251
+ if (argument === "--since") since = parsed;
252
+ else if (argument === "--until") until = parsed;
253
+ else {
254
+ if (parsed < 1 || parsed > USAGE_MAX_DISTINCT_SCOPES) return null;
255
+ limit = parsed;
256
+ }
257
+ }
258
+ if (source === undefined || since === undefined || until === undefined || until < since) return null;
259
+ return { input: { source, since, until, ...(limit === undefined ? {} : { limit }) }, json };
260
+ }
261
+
262
+ interface MetricsUsageSeriesArgs { input: { source: string; since: number; until: number; bucketSizeMs: number; bucketCount: number; scopeLimit?: number }; json: boolean }
263
+
264
+ function parseMetricsUsageSeriesArgs(args: string[]): MetricsUsageSeriesArgs | null {
265
+ let json = false;
266
+ let source: string | undefined;
267
+ let since: number | undefined;
268
+ let until: number | undefined;
269
+ let bucketSizeMs: number | undefined;
270
+ let bucketCount: number | undefined;
271
+ let scopeLimit: number | undefined;
272
+ for (let index = 0; index < args.length; index += 1) {
273
+ const argument = args[index];
274
+ if (argument === "--json") { json = true; continue; }
275
+ if (!(["--source", "--since", "--until", "--bucket-size-ms", "--bucket-count", "--scope-limit"].includes(argument ?? ""))) return null;
276
+ const raw = args[++index];
277
+ if (raw === undefined || raw.length === 0) return null;
278
+ if (argument === "--source") { source = raw; continue; }
279
+ const parsed = Number(raw);
280
+ if (!Number.isSafeInteger(parsed) || parsed < 0) return null;
281
+ if (argument === "--since") since = parsed;
282
+ else if (argument === "--until") until = parsed;
283
+ else if (argument === "--bucket-size-ms") { if (parsed < 1) return null; bucketSizeMs = parsed; }
284
+ else if (argument === "--bucket-count") { if (parsed < 1 || parsed > MAX_USAGE_BUCKETS) return null; bucketCount = parsed; }
285
+ else { if (parsed < 1 || parsed > USAGE_MAX_DISTINCT_SCOPES) return null; scopeLimit = parsed; }
286
+ }
287
+ if (source === undefined || since === undefined || until === undefined || until < since || bucketSizeMs === undefined || bucketCount === undefined) return null;
288
+ return { input: { source, since, until, bucketSizeMs, bucketCount, ...(scopeLimit === undefined ? {} : { scopeLimit }) }, json };
289
+ }
290
+
291
+ interface CostByTaskArgs { input: { since: number; until: number }; json: boolean }
292
+
293
+ function parseCostByTaskArgs(args: string[]): CostByTaskArgs | null {
294
+ let json = false;
295
+ let since: number | undefined;
296
+ let until: number | undefined;
297
+ for (let index = 0; index < args.length; index += 1) {
298
+ const argument = args[index];
299
+ if (argument === "--json") { json = true; continue; }
300
+ if (!["--since", "--until"].includes(argument ?? "")) return null;
301
+ const raw = args[++index];
302
+ const parsed = Number(raw);
303
+ if (!Number.isSafeInteger(parsed) || parsed < 0) return null;
304
+ if (argument === "--since") since = parsed;
305
+ else until = parsed;
306
+ }
307
+ if (since === undefined || until === undefined || until < since) return null;
308
+ return { input: { since, until }, json };
309
+ }
310
+
311
+ interface MetricsPruneArgs { input: { before: number; force?: boolean }; json: boolean }
229
312
 
230
313
  function parseMetricsPruneArgs(args: string[]): MetricsPruneArgs | null {
231
314
  let json = false;
315
+ let force = false;
232
316
  let before: number | undefined;
233
317
  for (let index = 0; index < args.length; index += 1) {
234
318
  const argument = args[index];
235
319
  if (argument === "--json") { json = true; continue; }
320
+ if (argument === "--force") { force = true; continue; }
236
321
  if (argument !== "--before") return null;
237
322
  const raw = args[++index];
238
323
  const parsed = Number(raw);
@@ -240,7 +325,7 @@ function parseMetricsPruneArgs(args: string[]): MetricsPruneArgs | null {
240
325
  before = parsed;
241
326
  }
242
327
  if (before === undefined) return null;
243
- return { input: { before }, json };
328
+ return { input: { before, ...(force ? { force } : {}) }, json };
244
329
  }
245
330
 
246
331
  interface RouterOverrideArgs { input: RouteOverride; json: boolean }
@@ -356,7 +441,8 @@ function parseBenchmarkArgs(action: string | undefined, args: string[]): Benchma
356
441
  const candidates: ModelCandidate[] = [];
357
442
  const sourceIds: string[] = [];
358
443
  let scopeAuthority: ScopeAuthority = "available-models";
359
- let taskClass: ModelTaskClass = "general";
444
+ let domain: ModelTaskDomain = "general";
445
+ let type: ModelTaskType = "general";
360
446
  let budgetPressure = 0;
361
447
  const weights: UtilityWeights = {
362
448
  quality: MODEL_RANKING_DEFAULT_QUALITY_WEIGHT, cost: MODEL_RANKING_DEFAULT_COST_WEIGHT,
@@ -368,7 +454,7 @@ function parseBenchmarkArgs(action: string | undefined, args: string[]): Benchma
368
454
  if (argument === "--json") { json = true; continue; }
369
455
  if (argument === "--force" && action === "refresh") { force = true; continue; }
370
456
  const allowed = action === "list" ? ["--source", "--model", "--dimension", "--limit"]
371
- : action === "rank" ? ["--candidate", "--source", "--task", "--scope", "--budget", "--weight-quality", "--weight-cost", "--weight-latency", "--weight-context", "--weight-reliability"] : [];
457
+ : action === "rank" ? ["--candidate", "--source", "--domain", "--type", "--scope", "--budget", "--weight-quality", "--weight-cost", "--weight-latency", "--weight-context", "--weight-reliability"] : [];
372
458
  if (!allowed.includes(argument ?? "")) return null;
373
459
  const raw = args[++index];
374
460
  if (raw === undefined || raw.length === 0) return null;
@@ -387,9 +473,12 @@ function parseBenchmarkArgs(action: string | undefined, args: string[]): Benchma
387
473
  if (!candidate) return null;
388
474
  candidates.push(candidate);
389
475
  } else if (argument === "--source") sourceIds.push(raw);
390
- else if (argument === "--task") {
391
- if (!TASK_CLASSES.includes(raw as ModelTaskClass)) return null;
392
- taskClass = raw as ModelTaskClass;
476
+ else if (argument === "--domain") {
477
+ if (!TASK_DOMAINS.includes(raw as ModelTaskDomain)) return null;
478
+ domain = raw as ModelTaskDomain;
479
+ } else if (argument === "--type") {
480
+ if (!TASK_TYPES.includes(raw as ModelTaskType)) return null;
481
+ type = raw as ModelTaskType;
393
482
  } else if (argument === "--scope") {
394
483
  if (raw !== "exact-session" && raw !== "available-models") return null;
395
484
  scopeAuthority = raw;
@@ -405,7 +494,7 @@ function parseBenchmarkArgs(action: string | undefined, args: string[]): Benchma
405
494
  return {
406
495
  action, json, force,
407
496
  ...(action === "list" ? { query: query as BenchmarkQuery } : {}),
408
- ...(action === "rank" ? { recommendation: { candidates, sourceIds: [...new Set(sourceIds)], scopeAuthority, taskClass, budgetPressure, weights } } : {}),
497
+ ...(action === "rank" ? { recommendation: { candidates, sourceIds: [...new Set(sourceIds)], scopeAuthority, domain, type, budgetPressure, weights } } : {}),
409
498
  };
410
499
  }
411
500
 
@@ -465,6 +554,31 @@ export function formatMetricsQuery(rows: StoredMetricObservation[]): string {
465
554
  return lines.join("\n");
466
555
  }
467
556
 
557
+ export function formatMetricsDistinctScopes(scopes: string[]): string {
558
+ if (scopes.length === 0) return "Scopes: none matched";
559
+ return [`Scopes: ${scopes.length.toLocaleString()}`, ...scopes.map((scope) => `- ${humanField(scope)}`)].join("\n");
560
+ }
561
+
562
+ export function formatMetricsUsageSeries(result: { rows: Array<{ scope: string; metric: string; bucketIndex: number; sum: number }>; truncated: boolean }): string {
563
+ if (result.rows.length === 0) return `Usage series: no data${result.truncated ? " (scope limit reached)" : ""}`;
564
+ const lines = [`Usage series: ${result.rows.length.toLocaleString()} bucket(s)${result.truncated ? " (scope limit reached)" : ""}`];
565
+ for (const row of result.rows) lines.push(`- ${humanField(row.scope)}/${humanField(row.metric)} bucket ${row.bucketIndex}: ${row.sum.toLocaleString()}`);
566
+ return lines.join("\n");
567
+ }
568
+
569
+ function formatUsdAmount(amount: number): string {
570
+ return `$${amount.toFixed(Math.abs(amount) < 0.01 && amount !== 0 ? 4 : 2)}`;
571
+ }
572
+
573
+ export function formatCostByTask(summary: TaskCostSummary): string {
574
+ const lines = [
575
+ `Cost by task: ${summary.entries.length.toLocaleString()} task(s)${summary.truncated ? " (query limit reached; totals are a lower bound)" : ""}`,
576
+ ...summary.entries.map((entry) => `- ${humanField(entry.taskId)}: ${formatUsdAmount(entry.costUsd)} · ↑${entry.inputTokens.toLocaleString()} ↓${entry.outputTokens.toLocaleString()} R${entry.cacheReadTokens.toLocaleString()} W${entry.cacheWriteTokens.toLocaleString()}`),
577
+ `Unattributed spend (no task was focused): ${formatUsdAmount(summary.unattributedCostUsd)}`,
578
+ ];
579
+ return lines.join("\n");
580
+ }
581
+
468
582
  export function formatTelemetryPoll(result: TelemetryPollResult): string {
469
583
  if (result.sources.length === 0) return "Telemetry: no sources configured";
470
584
  return ["Telemetry:", ...result.sources.map((source) => {
@@ -535,6 +649,21 @@ export async function runCli(args: string[], deps: CliDependencies = DEFAULT_DEP
535
649
  if (!parsed) return usage(deps.stderr);
536
650
  return callAndPrint(deps, "metrics.prune", parsed.input, parsed.json, (result) => `Pruned ${result.deleted.toLocaleString()} observation(s)`);
537
651
  }
652
+ if (action === "distinct-scopes") {
653
+ const parsed = parseMetricsDistinctScopesArgs(rest);
654
+ if (!parsed) return usage(deps.stderr);
655
+ return callAndPrint(deps, "metrics.distinct_scopes", parsed.input, parsed.json, formatMetricsDistinctScopes);
656
+ }
657
+ if (action === "usage-series") {
658
+ const parsed = parseMetricsUsageSeriesArgs(rest);
659
+ if (!parsed) return usage(deps.stderr);
660
+ return callAndPrint(deps, "metrics.usage_series", parsed.input, parsed.json, formatMetricsUsageSeries);
661
+ }
662
+ if (action === "cost-by-task") {
663
+ const parsed = parseCostByTaskArgs(rest);
664
+ if (!parsed) return usage(deps.stderr);
665
+ return callAndPrint(deps, "metrics.cost_by_task", parsed.input, parsed.json, formatCostByTask);
666
+ }
538
667
  return usage(deps.stderr);
539
668
  }
540
669
  if (command === "telemetry") {
package/src/client.ts CHANGED
@@ -1,51 +1,19 @@
1
+ import { AuthenticatedRpcClient, type FetchTransport } from "@danypops/daemon-kit/rpc-client";
1
2
  import type { OperationInputs, OperationName, OperationOutputs } from "./service.ts";
2
3
  import { ensureAuthToken, readDaemonHandle, resolveJittorPaths, type JittorPaths } from "./state.ts";
3
4
 
4
- export type FetchTransport = (request: Request) => Promise<Response>;
5
+ export type { FetchTransport };
5
6
 
6
- export class JittorClient {
7
- constructor(
8
- private readonly baseUrl: string,
9
- private readonly token: string,
10
- private readonly transport: FetchTransport = fetch,
11
- ) {}
12
-
13
- async call<Name extends OperationName>(operation: Name, input: OperationInputs[Name]): Promise<OperationOutputs[Name]> {
14
- const response = await this.transport(new Request(`${this.baseUrl}/api/v1/ops`, {
15
- method: "POST",
16
- headers: { authorization: `Bearer ${this.token}`, "content-type": "application/json" },
17
- body: JSON.stringify({ op: operation, input }),
18
- }));
19
- const body = await response.json() as { result?: OperationOutputs[Name]; error?: string };
20
- if (!response.ok) throw new Error(body.error ?? `Jittor operation failed with HTTP ${response.status}`);
21
- return body.result as OperationOutputs[Name];
22
- }
23
-
24
- async operations(): Promise<OperationName[]> {
25
- const response = await this.transport(new Request(`${this.baseUrl}/api/v1/ops`, {
26
- headers: { authorization: `Bearer ${this.token}` },
27
- }));
28
- const body = await response.json() as { operations?: OperationName[]; error?: string };
29
- if (!response.ok) throw new Error(body.error ?? `Jittor discovery failed with HTTP ${response.status}`);
30
- return body.operations ?? [];
31
- }
32
-
33
- async ready(): Promise<boolean> {
34
- const response = await this.transport(new Request(`${this.baseUrl}/ready`, {
35
- headers: { authorization: `Bearer ${this.token}` },
36
- }));
37
- if (response.status === 503) return false;
38
- if (!response.ok) throw new Error(`Jittor readiness check failed with HTTP ${response.status}`);
39
- return true;
40
- }
41
-
42
- async health(): Promise<{ ok: true; version: string }> {
43
- const response = await this.transport(new Request(`${this.baseUrl}/health`, {
44
- headers: { authorization: `Bearer ${this.token}` },
45
- }));
46
- const body = await response.json() as { ok?: boolean; version?: string; error?: string };
47
- if (!response.ok || body.ok !== true || typeof body.version !== "string") throw new Error(body.error ?? "Jittor health check failed");
48
- return { ok: true, version: body.version };
7
+ /**
8
+ * Jittor's typed authenticated RPC client, now a thin named subclass of
9
+ * `@danypops/daemon-kit/rpc-client`'s `AuthenticatedRpcClient` -- the shared substrate factored
10
+ * out after jittor's own client.ts and web-spider-daemon's were found byte-identical (see
11
+ * daemon-kit's README). Keeps the old 3-positional-argument constructor so every existing call
12
+ * site is untouched by this migration.
13
+ */
14
+ export class JittorClient extends AuthenticatedRpcClient<OperationName, OperationInputs, OperationOutputs> {
15
+ constructor(baseUrl: string, token: string, transport: FetchTransport = fetch) {
16
+ super(baseUrl, token, { label: "Jittor", transport });
49
17
  }
50
18
  }
51
19
 
package/src/constants.ts CHANGED
@@ -1,4 +1,3 @@
1
- export const SQLITE_SCHEMA_VERSION = 1;
2
1
  export const SQLITE_BUSY_TIMEOUT_MS = 5_000;
3
2
  export const DEFAULT_QUERY_LIMIT = 1_000;
4
3
  export const MAX_QUERY_LIMIT = 10_000;
@@ -39,7 +38,6 @@ export const FOOTER_BAR_MIN_WIDTH = 4;
39
38
  export const FOOTER_BAR_MAX_WIDTH = 8;
40
39
  export const FOOTER_WIDE_TERMINAL_WIDTH = 100;
41
40
  export const FOOTER_COMPACTION_RENDER_INTERVAL_MS = 500;
42
- export const FOOTER_COMPACTION_DRAIN_STEP_MS = 3_000;
43
41
  /** Half-period of the compacting liveness indicator; equal to the render tick so it visibly alternates every repaint. */
44
42
  export const FOOTER_COMPACTION_BLINK_HALF_PERIOD_MS = 500;
45
43
  export const MILLISECONDS_PER_SECOND = 1_000;
@@ -47,13 +45,20 @@ export const MILLISECONDS_PER_MINUTE = 60 * MILLISECONDS_PER_SECOND;
47
45
  export const MILLISECONDS_PER_HOUR = 60 * MILLISECONDS_PER_MINUTE;
48
46
  export const PAPYRUS_CONTEXT_INJECTION_CHANNEL = "papyrus.context-injection.v1";
49
47
  export const PAPYRUS_CONTEXT_INJECTION_SCHEMA = "papyrus.context-injection/v1";
48
+ /** Matches Papyrus's own constants by convention; Jittor does not depend on the Papyrus package. */
49
+ export const PAPYRUS_TASK_FOCUS_CHANNEL = "papyrus.task-focus.v1";
50
+ export const PAPYRUS_TASK_FOCUS_SCHEMA = "papyrus.task-focus/v1";
51
+ export const TASK_FOCUS_EVENT_MAX_AGE_MS = 5 * MILLISECONDS_PER_MINUTE;
52
+ export const TASK_FOCUS_ID_MAX_LENGTH = 200;
53
+ export const TASK_COST_QUERY_LIMIT = 10_000;
54
+ /** A `metrics prune --before` cutoff newer than this must pass `force: true`. Guards against accidentally wiping recent/live data with a too-recent cutoff (e.g. "now"), while still allowing routine cleanup of genuinely old rows without ceremony. */
55
+ export const PRUNE_MIN_AGE_MS = 24 * MILLISECONDS_PER_HOUR;
50
56
  export const CONTEXT_OBSERVATION_MAX_CHARACTERS = 10_000_000;
51
57
  export const CONTEXT_OBSERVATION_MAX_AGE_MS = 5 * MILLISECONDS_PER_MINUTE;
52
58
  export const CONTEXT_ASSESSMENT_QUERY_LIMIT = 10_000;
53
59
  export const CONTEXT_EVENT_DEDUP_LIMIT = 1_000;
54
60
  export const CONTEXT_ASSESSMENT_DEFAULT_WINDOW_MS = 24 * MILLISECONDS_PER_HOUR;
55
61
  export const MILLISECONDS_PER_DAY = 24 * MILLISECONDS_PER_HOUR;
56
- export const LOOPBACK_HOST = "127.0.0.1";
57
62
  export const JITTOR_STATE_DIRECTORY = "jittor";
58
63
  export const JITTOR_EXTENSION_SETTINGS_FILENAME = "extension.json";
59
64
  export const DATABASE_FILENAME = "jittor.db";
@@ -62,7 +67,17 @@ export const HANDLE_FILENAME = "daemon.json";
62
67
  export const SYSTEMD_UNIT_NAME = "jittor.service";
63
68
  export const USAGE_CHART_HEIGHT = 8;
64
69
  export const USAGE_Y_AXIS_WIDTH = 7;
65
- export const USAGE_TOKEN_QUERY_LIMIT = 10_000;
70
+ /**
71
+ * A single flat "most recent N rows" query lets one heavy provider/model monopolize the entire
72
+ * budget within the query window, silently starving every other series out of the chart no matter
73
+ * which time frame is selected (a real bug: a single long, heavy session can fill 10k rows within
74
+ * a few hours, hiding a whole other provider's usage from a day earlier even in the Monthly view).
75
+ * The usage/cost dashboard instead fetches per distinct scope (see distinctScopes), bounded by
76
+ * these two limits; the worst-case total row volume (40 * 250 = 10,000) matches the old flat cap,
77
+ * but is now fairly distributed across every active series instead of claimable by just one.
78
+ */
79
+ export const USAGE_MAX_DISTINCT_SCOPES = 40;
80
+ export const USAGE_PER_SCOPE_QUERY_LIMIT = 250;
66
81
  export const USAGE_RENDER_MAX_SERIES = 20;
67
82
  export const HUMAN_STATUS_MAX_SOURCES = 20;
68
83
  export const HUMAN_TEXT_FIELD_MAX_CHARACTERS = 160;
@@ -73,6 +88,8 @@ export const COMPACTION_DURATION_ESTIMATE_MAX_SAMPLES = 20;
73
88
  /** Below this many samples the estimate stays explicit cold-start uncertainty rather than a guess. */
74
89
  export const COMPACTION_DURATION_ESTIMATE_MIN_SAMPLES = 3;
75
90
  export const MAX_USAGE_BUCKETS = 120;
91
+ /** Defense-in-depth cap on the SQL-side usage aggregation result: (scopes x metrics x buckets) is already small by construction, but this bounds it explicitly rather than trusting that alone. */
92
+ export const USAGE_AGGREGATE_MAX_ROWS = 25_000;
76
93
  export const MAX_DYNAMIC_ROUTES = 100;
77
94
  export const CODEX_ERROR_MESSAGE_LIMIT = 160;
78
95
  export const CODEX_RETRY_AFTER_MAX_MS = 5 * MILLISECONDS_PER_MINUTE;
@@ -81,3 +98,13 @@ export const CODEX_RECOVERY_MAX_DELAY_MS = CODEX_RETRY_AFTER_MAX_MS;
81
98
  export const CODEX_RECOVERY_MAX_ATTEMPTS = 3;
82
99
  export const CODEX_RECOVERY_ATTEMPT_WINDOW_MS = 10 * MILLISECONDS_PER_MINUTE;
83
100
  export const CODEX_RECOVERY_JITTER_RATIO = 0.2;
101
+ export const GOOGLE_VERTEX_BUDGET_DISPLAY_NAME_MAX_CHARACTERS = 160;
102
+ /** Bounded per-poll pull size; a budget subscription realistically holds at most a few pending notifications. */
103
+ export const GOOGLE_VERTEX_BUDGET_MAX_MESSAGES_PER_PULL = 20;
104
+ /**
105
+ * Lower than Codex's header-derived 0.8: this is Google's own documented "estimated ... subject to
106
+ * change until your invoice is finalized" data, delivered at-least-once and possibly out of order,
107
+ * multiple times per day rather than on every response.
108
+ */
109
+ export const GOOGLE_VERTEX_BUDGET_CONFIDENCE = 0.6;
110
+ export const GOOGLE_ADC_TOKEN_REFRESH_SKEW_MS = 60_000;
package/src/daemon.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { LOOPBACK_HOST, MAINTENANCE_INTERVAL_MS, TELEMETRY_POLL_INTERVAL_MS } from "./constants.ts";
1
+ import { startDaemon as startDaemonKit, type RunningDaemon } from "@danypops/daemon-kit/daemon";
2
+ import { MAINTENANCE_INTERVAL_MS, TELEMETRY_POLL_INTERVAL_MS } from "./constants.ts";
2
3
  import { DEFAULT_POLICY, UNCONFIGURED_ROUTE } from "./config.ts";
3
4
  import { SQLiteMetricStore } from "./adapters/sqlite-metric-store.ts";
4
5
  import { MetricBenchmarkStore } from "./adapters/metric-benchmark-store.ts";
@@ -11,19 +12,17 @@ import { createApp, JittorService } from "./service.ts";
11
12
  import { JittorRouter } from "./router.ts";
12
13
  import type { BenchmarkSource } from "./ports/benchmark-source.ts";
13
14
  import type { TelemetrySource } from "./ports/telemetry-source.ts";
14
- import { CodexTelemetrySource, OpenRouterTelemetrySource } from "./providers/telemetry-sources.ts";
15
- import {
16
- ensureAuthToken,
17
- removeDaemonHandle,
18
- resolveJittorPaths,
19
- writeDaemonHandle,
20
- type JittorPaths,
21
- } from "./state.ts";
15
+ import { CodexTelemetrySource, GoogleVertexBudgetTelemetrySource, OpenRouterTelemetrySource } from "./providers/telemetry-sources.ts";
16
+ import { createGoogleAdcTokenProvider } from "./providers/google-adc-auth.ts";
17
+ import { GOOGLE_PUBSUB_READONLY_SCOPE } from "./providers/google-vertex-budget.ts";
18
+ import type { GoogleVertexMetricSource } from "./providers/google-vertex-contracts.ts";
19
+ import { ensureAuthToken, resolveJittorPaths, type JittorPaths } from "./state.ts";
20
+ import { logEvent, logger } from "./log.ts";
22
21
 
23
- export interface RunningDaemon {
24
- host: typeof LOOPBACK_HOST;
25
- port: number;
26
- stop(): Promise<void>;
22
+ export type { RunningDaemon } from "@danypops/daemon-kit/daemon";
23
+
24
+ export function reportMaintenanceFailure(event: string, error: unknown): void {
25
+ logEvent("error", event, { message: error instanceof Error ? error.message : String(error) });
27
26
  }
28
27
 
29
28
  export function benchmarkSourcesFromEnvironment(env: Record<string, string | undefined> = process.env): BenchmarkSource[] {
@@ -39,9 +38,27 @@ export function telemetrySourcesFromEnvironment(env: Record<string, string | und
39
38
  if (codexAuthFile) sources.push(new CodexTelemetrySource(codexAuthFile));
40
39
  const openRouterKey = env["OPENROUTER_API_KEY"];
41
40
  if (openRouterKey) sources.push(new OpenRouterTelemetrySource(openRouterKey));
41
+ // Opt-in only: the Pub/Sub subscription is one-time GCP console/CLI setup outside Jittor (see
42
+ // docs/PROVIDER_RESEARCH.md), so its absence must never attempt ADC discovery or a network call.
43
+ const vertexBudgetSubscription = env["JITTOR_GOOGLE_VERTEX_BUDGET_SUBSCRIPTION"];
44
+ if (vertexBudgetSubscription) {
45
+ const source = (env["JITTOR_GOOGLE_VERTEX_BUDGET_SOURCE"] ?? "google-vertex") as GoogleVertexMetricSource;
46
+ const tokenProvider = createGoogleAdcTokenProvider([GOOGLE_PUBSUB_READONLY_SCOPE]);
47
+ sources.push(new GoogleVertexBudgetTelemetrySource(vertexBudgetSubscription, tokenProvider, Date.now, fetch, source));
48
+ }
42
49
  return sources;
43
50
  }
44
51
 
52
+ /**
53
+ * Composition root, now built on `@danypops/daemon-kit/daemon`'s `startDaemon` for binding,
54
+ * atomic handle write, maintenance-timer driving, and clean shutdown -- the skeleton that used to
55
+ * be hand-rolled here (and, byte-identically, in web-spider-daemon's and papyrus's daemon.ts; see
56
+ * daemon-kit's README). Each maintenance task still catches and classifies its own failure via
57
+ * `reportMaintenanceFailure` (preserving Jittor's specific `checkpoint_failed`/
58
+ * `benchmark_refresh_failed`/`telemetry_poll_failed` event taxonomy) rather than relying on
59
+ * daemon-kit's own generic "maintenance task failed: <name>" catch, which exists as a safety net
60
+ * for tasks that don't self-classify, not to replace a consumer's own richer classification.
61
+ */
45
62
  export function startDaemon(
46
63
  paths: JittorPaths = resolveJittorPaths(),
47
64
  env: Record<string, string | undefined> = process.env,
@@ -61,40 +78,24 @@ export function startDaemon(
61
78
  currentRoute: UNCONFIGURED_ROUTE,
62
79
  });
63
80
  const service = new JittorService(metrics, router, benchmarks, modelRanker);
64
- const app = createApp({ service, token });
65
- const server = Bun.serve({
66
- hostname: LOOPBACK_HOST,
67
- port: 0,
68
- fetch: (request) => app.fetch(request),
81
+
82
+ const daemon = startDaemonKit({
83
+ daemonLabel: "Jittor",
84
+ handlePath: paths.handle,
85
+ logger,
86
+ buildApp: () => createApp({ service, token }),
87
+ maintenanceTasks: [
88
+ { name: "checkpoint", intervalMs: MAINTENANCE_INTERVAL_MS, run: async () => { await service.execute("service.checkpoint", {}).catch((error) => reportMaintenanceFailure("checkpoint_failed", error)); } },
89
+ { name: "benchmark-refresh", intervalMs: MAINTENANCE_INTERVAL_MS, run: async () => { await benchmarks.refresh().catch((error) => reportMaintenanceFailure("benchmark_refresh_failed", error)); } },
90
+ { name: "telemetry-poll", intervalMs: TELEMETRY_POLL_INTERVAL_MS, run: async () => { await router.poll().catch((error) => reportMaintenanceFailure("telemetry_poll_failed", error)); } },
91
+ ],
92
+ onShutdown: () => { service.close(); },
69
93
  });
70
- const port = server.port;
71
- if (port === undefined) {
72
- server.stop(true);
73
- service.close();
74
- throw new Error("Jittor daemon failed to bind a loopback port");
75
- }
76
- writeDaemonHandle(paths, { host: LOOPBACK_HOST, port, pid: process.pid });
77
- const maintenance = setInterval(() => {
78
- void service.execute("service.checkpoint", {});
79
- void benchmarks.refresh();
80
- }, MAINTENANCE_INTERVAL_MS);
81
- const poll = setInterval(() => { void router.poll(); }, TELEMETRY_POLL_INTERVAL_MS);
82
- if (sources.length > 0) void router.poll();
83
- if (benchmarkSources.length > 0) void benchmarks.refresh();
84
- let stopped = false;
85
- return {
86
- host: LOOPBACK_HOST,
87
- port,
88
- async stop(): Promise<void> {
89
- if (stopped) return;
90
- stopped = true;
91
- clearInterval(maintenance);
92
- clearInterval(poll);
93
- await server.stop(true);
94
- service.close();
95
- removeDaemonHandle(paths);
96
- },
97
- };
94
+
95
+ if (sources.length > 0) router.poll().catch((error) => reportMaintenanceFailure("telemetry_poll_failed", error));
96
+ if (benchmarkSources.length > 0) benchmarks.refresh().catch((error) => reportMaintenanceFailure("benchmark_refresh_failed", error));
97
+
98
+ return daemon;
98
99
  }
99
100
 
100
101
  export function serveMain(): void {
package/src/db.ts CHANGED
@@ -1,7 +1,6 @@
1
- import { Database } from "bun:sqlite";
2
- import { dirname } from "node:path";
3
- import { mkdirSync } from "node:fs";
4
- import { SQLITE_BUSY_TIMEOUT_MS, SQLITE_SCHEMA_VERSION } from "./constants.ts";
1
+ import type { Database } from "bun:sqlite";
2
+ import { openSqliteWithPragmas } from "@danypops/daemon-kit/storage";
3
+ import { SQLITE_BUSY_TIMEOUT_MS } from "./constants.ts";
5
4
 
6
5
  const INITIAL_SCHEMA = `
7
6
  CREATE TABLE metric_observations (
@@ -20,31 +19,15 @@ CREATE INDEX metric_observations_time_idx
20
19
  ON metric_observations(observed_at);
21
20
  `;
22
21
 
23
- function migrate(db: Database): void {
24
- const row = db.query("PRAGMA user_version").get() as { user_version: number };
25
- if (row.user_version > SQLITE_SCHEMA_VERSION) {
26
- throw new Error(`database schema ${row.user_version} is newer than supported ${SQLITE_SCHEMA_VERSION}`);
27
- }
28
- if (row.user_version < 1) {
29
- const migration = db.transaction(() => {
30
- db.exec(INITIAL_SCHEMA);
31
- db.exec("PRAGMA user_version = 1");
32
- });
33
- migration.immediate();
34
- }
35
- const migrated = db.query("PRAGMA user_version").get() as { user_version: number };
36
- if (migrated.user_version !== SQLITE_SCHEMA_VERSION) {
37
- throw new Error(`missing migration from schema ${migrated.user_version} to ${SQLITE_SCHEMA_VERSION}`);
38
- }
39
- }
40
-
22
+ /**
23
+ * Delegates bootstrap (pragmas, migration engine) to `@danypops/daemon-kit/storage`, which
24
+ * generalizes the byte-identical pragma/PRAGMA-user_version skeleton jittor's own db.ts used to
25
+ * hand-roll (see daemon-kit's README). Jittor's only remaining responsibility is its own schema.
26
+ */
41
27
  export function openJittorDb(path: string): Database {
42
- if (path !== ":memory:") mkdirSync(dirname(path), { recursive: true });
43
- const db = new Database(path, { create: true, strict: true });
44
- db.exec("PRAGMA foreign_keys = ON");
45
- db.exec(`PRAGMA busy_timeout = ${SQLITE_BUSY_TIMEOUT_MS}`);
46
- if (path !== ":memory:") db.exec("PRAGMA journal_mode = WAL");
47
- migrate(db);
48
- db.exec("PRAGMA optimize=0x10002");
49
- return db;
28
+ return openSqliteWithPragmas(path, {
29
+ databaseOptions: { create: true, strict: true },
30
+ busyTimeoutMs: SQLITE_BUSY_TIMEOUT_MS,
31
+ migrations: [{ version: 1, up: (db) => db.exec(INITIAL_SCHEMA) }],
32
+ });
50
33
  }