tokenmax-collector 0.2.1 → 0.3.1

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": "tokenmax-collector",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
4
4
  "description": "Report local ccusage token usage to a tokenmax instance",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.ts CHANGED
@@ -40,12 +40,22 @@ async function runCollect(io: CliIo): Promise<number> {
40
40
  for (const warning of result.warnings) {
41
41
  io.stderr(warning);
42
42
  }
43
- io.stdout(
44
- result.kind === "reported"
45
- ? `accepted ${result.accepted} days for ${result.machine}`
46
- : "nothing to report",
47
- );
48
- return 0;
43
+ if (result.kind === "empty") {
44
+ io.stdout("nothing to report");
45
+ return 0;
46
+ }
47
+ let exitCode = 0;
48
+ for (const target of result.targets) {
49
+ if ("accepted" in target) {
50
+ io.stdout(
51
+ `accepted ${target.accepted} days for ${result.machine} at ${target.url}`,
52
+ );
53
+ } else {
54
+ io.stderr(`${target.url}: ${target.message}`);
55
+ exitCode = 1;
56
+ }
57
+ }
58
+ return exitCode;
49
59
  }
50
60
  if (result.kind === "missing-config") {
51
61
  io.stderr(
package/src/collect.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import {
2
+ type AntigravityStep,
2
3
  antigravityConversationsDir,
3
4
  readAntigravitySteps,
4
5
  } from "./antigravity";
@@ -9,12 +10,19 @@ import {
9
10
  windowStart,
10
11
  } from "./ccusage";
11
12
  import { type CommandRunner, runCommand } from "./command";
12
- import { readConfig, runtimeTimezone } from "./config";
13
+ import { type CollectorTarget, readConfig, runtimeTimezone } from "./config";
14
+ import { type DevinStep, devinTranscriptsDir, readDevinSteps } from "./devin";
13
15
  import type { Fetcher } from "./http";
14
16
  import { type MachineIdentity, machineId } from "./machine";
15
- import { mapAntigravitySteps, mapCcusageDays } from "./mapping";
17
+ import {
18
+ antigravityProvider,
19
+ devinProvider,
20
+ mapAntigravitySteps,
21
+ mapCcusageDays,
22
+ mapDevinSteps,
23
+ } from "./mapping";
16
24
  import { type CollectorEnv, collectorPaths, processEnv } from "./paths";
17
- import { loadPrices } from "./pricing";
25
+ import { loadPrices, type PriceTable } from "./pricing";
18
26
  import type { UsageDay, UsageReport } from "./usage";
19
27
 
20
28
  export interface CollectOptions {
@@ -26,8 +34,17 @@ export interface CollectOptions {
26
34
  timezone?: string;
27
35
  }
28
36
 
37
+ export type TargetResult =
38
+ | { accepted: number; url: string }
39
+ | { message: string; url: string };
40
+
29
41
  export type CollectResult =
30
- | { kind: "reported"; accepted: number; machine: string; warnings: string[] }
42
+ | {
43
+ kind: "reported";
44
+ machine: string;
45
+ targets: TargetResult[];
46
+ warnings: string[];
47
+ }
31
48
  | { kind: "empty"; warnings: string[] }
32
49
  | { kind: "missing-config"; configFile: string }
33
50
  | { kind: "failed"; message: string };
@@ -56,63 +73,100 @@ function acceptedCount(body: string): number | null {
56
73
  return typeof accepted === "number" ? accepted : null;
57
74
  }
58
75
 
59
- interface AntigravityRows {
76
+ interface LocalRows {
60
77
  days: UsageDay[];
61
78
  warnings: string[];
62
79
  }
63
80
 
81
+ interface LocalSource<Step extends { at: Date }> {
82
+ map: (steps: Step[], timezone: string, prices: PriceTable) => UsageDay[];
83
+ provider: string;
84
+ read: (home: string) => Promise<{ failures: string[]; steps: Step[] }>;
85
+ }
86
+
87
+ interface LocalWindow {
88
+ fetcher: Fetcher;
89
+ home: string;
90
+ pricesFile: string;
91
+ since: string;
92
+ timezone: string;
93
+ today: Date;
94
+ }
95
+
96
+ const antigravitySource: LocalSource<AntigravityStep> = {
97
+ map: mapAntigravitySteps,
98
+ provider: antigravityProvider,
99
+ read: (home) => readAntigravitySteps(antigravityConversationsDir(home)),
100
+ };
101
+
102
+ const devinSource: LocalSource<DevinStep> = {
103
+ map: mapDevinSteps,
104
+ provider: devinProvider,
105
+ read: (home) => readDevinSteps(devinTranscriptsDir(home)),
106
+ };
107
+
64
108
  async function report(
65
109
  fetcher: Fetcher,
66
- url: string,
67
- key: string,
68
- usage: UsageReport,
69
- warnings: string[],
70
- ): Promise<CollectResult> {
71
- const response = await fetcher(reportUrl(url), {
72
- body: JSON.stringify(usage),
73
- headers: {
74
- Authorization: `Bearer ${key}`,
75
- "Content-Type": "application/json",
76
- },
77
- method: "POST",
78
- });
79
- const body = await response.text();
80
- if (response.status !== 200) {
81
- return {
82
- kind: "failed",
83
- message: `tokenmax responded ${response.status}: ${body}`,
84
- };
85
- }
86
- const accepted = acceptedCount(body);
87
- if (accepted === null) {
88
- return {
89
- kind: "failed",
90
- message: `tokenmax responded an unexpected body: ${body}`,
91
- };
110
+ target: CollectorTarget,
111
+ body: string,
112
+ ): Promise<TargetResult> {
113
+ const { url } = target;
114
+ try {
115
+ const response = await fetcher(reportUrl(url), {
116
+ body,
117
+ headers: {
118
+ Authorization: `Bearer ${target.key}`,
119
+ "Content-Type": "application/json",
120
+ },
121
+ method: "POST",
122
+ });
123
+ const answer = await response.text();
124
+ if (response.status !== 200) {
125
+ return {
126
+ message: `tokenmax responded ${response.status}: ${answer}`,
127
+ url,
128
+ };
129
+ }
130
+ const accepted = acceptedCount(answer);
131
+ if (accepted === null) {
132
+ return {
133
+ message: `tokenmax responded an unexpected body: ${answer}`,
134
+ url,
135
+ };
136
+ }
137
+ return { accepted, url };
138
+ } catch (error) {
139
+ return { message: messageOf(error), url };
92
140
  }
93
- return { accepted, kind: "reported", machine: usage.machine, warnings };
94
141
  }
95
142
 
96
- async function antigravityDays(
97
- home: string,
98
- today: Date,
99
- timezone: string,
100
- fetcher: Fetcher,
101
- pricesFile: string,
102
- ): Promise<AntigravityRows> {
103
- const since = windowStart(today, timezone);
104
- const usage = await readAntigravitySteps(antigravityConversationsDir(home));
105
- const warnings = usage.failures.map(
106
- (failure) => `antigravity: skipped ${failure}`,
107
- );
108
- const steps = usage.steps.filter(
109
- (step) => calendarDate(step.at, timezone) >= since,
110
- );
111
- if (steps.length === 0) {
112
- return { days: [], warnings };
143
+ async function localDays<Step extends { at: Date }>(
144
+ source: LocalSource<Step>,
145
+ window: LocalWindow,
146
+ ): Promise<LocalRows> {
147
+ try {
148
+ const usage = await source.read(window.home);
149
+ const warnings = usage.failures.map(
150
+ (failure) => `${source.provider}: skipped ${failure}`,
151
+ );
152
+ const steps = usage.steps.filter(
153
+ (step) => calendarDate(step.at, window.timezone) >= window.since,
154
+ );
155
+ if (steps.length === 0) {
156
+ return { days: [], warnings };
157
+ }
158
+ const prices = await loadPrices(
159
+ window.fetcher,
160
+ window.pricesFile,
161
+ window.today,
162
+ );
163
+ return { days: source.map(steps, window.timezone, prices), warnings };
164
+ } catch (error) {
165
+ return {
166
+ days: [],
167
+ warnings: [`${source.provider}: ${messageOf(error)}`],
168
+ };
113
169
  }
114
- const prices = await loadPrices(fetcher, pricesFile, today);
115
- return { days: mapAntigravitySteps(steps, timezone, prices), warnings };
116
170
  }
117
171
 
118
172
  export async function collect(options: CollectOptions): Promise<CollectResult> {
@@ -148,32 +202,27 @@ export async function collect(options: CollectOptions): Promise<CollectResult> {
148
202
  return { kind: "failed", message: messageOf(error) };
149
203
  }
150
204
 
151
- let antigravity: AntigravityRows;
152
- try {
153
- antigravity = await antigravityDays(
154
- env.home,
155
- today,
156
- timezone,
157
- fetcher,
158
- paths.pricesFile,
159
- );
160
- } catch (error) {
161
- antigravity = { days: [], warnings: [`antigravity: ${messageOf(error)}`] };
162
- }
163
- days.push(...antigravity.days);
205
+ const window: LocalWindow = {
206
+ fetcher,
207
+ home: env.home,
208
+ pricesFile: paths.pricesFile,
209
+ since: windowStart(today, timezone),
210
+ timezone,
211
+ today,
212
+ };
213
+ const antigravity = await localDays(antigravitySource, window);
214
+ const devin = await localDays(devinSource, window);
215
+ days.push(...antigravity.days, ...devin.days);
216
+ const warnings = [...antigravity.warnings, ...devin.warnings];
164
217
  if (days.length === 0) {
165
- return { kind: "empty", warnings: antigravity.warnings };
218
+ return { kind: "empty", warnings };
166
219
  }
167
220
 
168
- try {
169
- return await report(
170
- fetcher,
171
- config.config.url,
172
- config.config.key,
173
- { days, machine, timezone },
174
- antigravity.warnings,
175
- );
176
- } catch (error) {
177
- return { kind: "failed", message: messageOf(error) };
221
+ const usage: UsageReport = { days, machine, timezone };
222
+ const body = JSON.stringify(usage);
223
+ const targets: TargetResult[] = [];
224
+ for (const target of config.config.targets) {
225
+ targets.push(await report(fetcher, target, body));
178
226
  }
227
+ return { kind: "reported", machine, targets, warnings };
179
228
  }
package/src/config.ts CHANGED
@@ -2,12 +2,25 @@ import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import { dirname } from "node:path";
3
3
  import { z } from "zod";
4
4
 
5
- export const collectorConfig = z.object({
5
+ export const collectorTarget = z.object({
6
6
  key: z.string().min(1),
7
- timezone: z.string().min(1).optional(),
8
7
  url: z.url({ protocol: /^https?$/ }),
9
8
  });
10
9
 
10
+ const timezone = z.string().min(1).optional();
11
+
12
+ export const collectorConfig = z.object({
13
+ targets: z.array(collectorTarget).min(1),
14
+ timezone,
15
+ });
16
+
17
+ const singleTargetConfig = collectorTarget
18
+ .extend({ timezone })
19
+ .transform(({ key, url, ...rest }) => ({ targets: [{ key, url }], ...rest }));
20
+
21
+ const storedConfig = z.union([collectorConfig, singleTargetConfig]);
22
+
23
+ export type CollectorTarget = z.infer<typeof collectorTarget>;
11
24
  export type CollectorConfig = z.infer<typeof collectorConfig>;
12
25
 
13
26
  export function canonicalTimezone(value: string): string | null {
@@ -62,7 +75,7 @@ export async function readConfig(
62
75
  };
63
76
  }
64
77
 
65
- const parsed = collectorConfig.safeParse(payload);
78
+ const parsed = storedConfig.safeParse(payload);
66
79
  if (!parsed.success) {
67
80
  return {
68
81
  kind: "invalid",
package/src/devin.ts ADDED
@@ -0,0 +1,94 @@
1
+ import { readdir, readFile } from "node:fs/promises";
2
+ import { resolve } from "node:path";
3
+ import { z } from "zod";
4
+
5
+ export interface DevinStep {
6
+ at: Date;
7
+ cacheCreate: number;
8
+ cacheRead: number;
9
+ input: number;
10
+ model: string;
11
+ output: number;
12
+ }
13
+
14
+ export interface DevinUsage {
15
+ failures: string[];
16
+ steps: DevinStep[];
17
+ }
18
+
19
+ const unknownModel = "devin-unknown";
20
+
21
+ const metrics = z.object({
22
+ cached_tokens: z.int().optional(),
23
+ completion_tokens: z.int(),
24
+ extra: z
25
+ .object({ cache_creation_input_tokens: z.int().optional() })
26
+ .optional(),
27
+ prompt_tokens: z.int(),
28
+ });
29
+
30
+ const step = z.object({
31
+ metrics: metrics.optional(),
32
+ model_name: z.string().optional(),
33
+ source: z.string(),
34
+ timestamp: z.iso.datetime({ offset: true }),
35
+ });
36
+
37
+ const transcript = z.object({ steps: z.array(step) });
38
+
39
+ const issueText = (error: z.ZodError): string =>
40
+ error.issues
41
+ .map((issue) => `${issue.path.join(".")}: ${issue.message}`)
42
+ .join("; ");
43
+
44
+ export function devinTranscriptsDir(home: string): string {
45
+ return resolve(home, ".local", "share", "devin", "cli", "transcripts");
46
+ }
47
+
48
+ function parseTranscript(source: string): DevinStep[] {
49
+ const parsed = transcript.safeParse(JSON.parse(source));
50
+ if (!parsed.success) {
51
+ throw new Error(issueText(parsed.error));
52
+ }
53
+ const steps: DevinStep[] = [];
54
+ for (const entry of parsed.data.steps) {
55
+ if (entry.source !== "agent" || entry.metrics === undefined) {
56
+ continue;
57
+ }
58
+ const cacheRead = entry.metrics.cached_tokens ?? 0;
59
+ const cacheCreate = entry.metrics.extra?.cache_creation_input_tokens ?? 0;
60
+ steps.push({
61
+ at: new Date(entry.timestamp),
62
+ cacheCreate,
63
+ cacheRead,
64
+ input: entry.metrics.prompt_tokens - cacheRead - cacheCreate,
65
+ model: entry.model_name ?? unknownModel,
66
+ output: entry.metrics.completion_tokens,
67
+ });
68
+ }
69
+ return steps;
70
+ }
71
+
72
+ export async function readDevinSteps(dir: string): Promise<DevinUsage> {
73
+ let files: string[];
74
+ try {
75
+ files = await readdir(dir);
76
+ } catch (error) {
77
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
78
+ return { failures: [], steps: [] };
79
+ }
80
+ throw error;
81
+ }
82
+ const steps: DevinStep[] = [];
83
+ const failures: string[] = [];
84
+ for (const file of files.filter((name) => name.endsWith(".json")).sort()) {
85
+ const path = resolve(dir, file);
86
+ try {
87
+ steps.push(...parseTranscript(await readFile(path, "utf8")));
88
+ } catch (error) {
89
+ const message = error instanceof Error ? error.message : String(error);
90
+ failures.push(`${path}: ${message}`);
91
+ }
92
+ }
93
+ return { failures, steps };
94
+ }
package/src/install.ts CHANGED
@@ -3,8 +3,10 @@ import { dirname } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import {
5
5
  type CollectorConfig,
6
+ type CollectorTarget,
6
7
  canonicalTimezone,
7
8
  collectorConfig,
9
+ readConfig,
8
10
  runtimeTimezone,
9
11
  writeConfig,
10
12
  } from "./config";
@@ -67,6 +69,13 @@ function scheduleFiles(
67
69
  ];
68
70
  }
69
71
 
72
+ async function configuredTargets(
73
+ configFile: string,
74
+ ): Promise<CollectorTarget[]> {
75
+ const existing = await readConfig(configFile);
76
+ return existing.kind === "ok" ? existing.config.targets : [];
77
+ }
78
+
70
79
  export async function install(options: InstallOptions): Promise<InstallPlan> {
71
80
  const env = options.env ?? processEnv();
72
81
  const platform = resolvePlatform(options.platform ?? process.platform);
@@ -76,10 +85,12 @@ export async function install(options: InstallOptions): Promise<InstallPlan> {
76
85
  if (timezone === null) {
77
86
  throw new Error(`invalid timezone: ${requestedTimezone}`);
78
87
  }
88
+ const others = (await configuredTargets(paths.configFile)).filter(
89
+ (target) => target.url !== options.url,
90
+ );
79
91
  const config = collectorConfig.parse({
80
- key: options.key,
92
+ targets: [...others, { key: options.key, url: options.url }],
81
93
  timezone,
82
- url: options.url,
83
94
  });
84
95
  const cliPath = options.cliPath ?? resolveCliPath();
85
96
  if (
@@ -106,6 +117,9 @@ export async function install(options: InstallOptions): Promise<InstallPlan> {
106
117
  const log = options.log ?? console.log;
107
118
 
108
119
  log(`config: ${plan.configFile} (600, key redacted)`);
120
+ for (const target of plan.config.targets) {
121
+ log(`target: ${target.url}`);
122
+ }
109
123
  for (const file of plan.files) {
110
124
  log(`schedule: ${file.path}`);
111
125
  }
package/src/machine.ts CHANGED
@@ -11,12 +11,13 @@ const disallowed = /[^A-Za-z0-9._-]/g;
11
11
 
12
12
  const platformUuidPattern = /"IOPlatformUUID"\s*=\s*"([^"]+)"/;
13
13
 
14
+ /** The hostname changes with the network, and every spelling of it used to open
15
+ * a new machine on the server, so the platform identifier travels alone. */
14
16
  export function machineId(
15
17
  hostnameValue: string,
16
18
  platformUuid: string | null,
17
19
  ): string {
18
- const raw =
19
- platformUuid === null ? hostnameValue : `${hostnameValue}-${platformUuid}`;
20
+ const raw = platformUuid ?? hostnameValue;
20
21
  return raw.replace(disallowed, "-").slice(0, 64);
21
22
  }
22
23
 
package/src/mapping.ts CHANGED
@@ -1,11 +1,18 @@
1
1
  import type { AntigravityStep } from "./antigravity";
2
2
  import { type CcusageDaily, calendarDate } from "./ccusage";
3
- import { costOf, type PriceTable } from "./pricing";
3
+ import type { DevinStep } from "./devin";
4
+ import { costOf, type ModelPrice, type PriceTable } from "./pricing";
4
5
  import type { UsageDay } from "./usage";
5
6
 
6
7
  export const antigravityProvider = "antigravity";
8
+ export const devinProvider = "devin";
7
9
 
8
10
  const agentModelPrefix = /^\[[^\]]*\]\s*/;
11
+ const effortSuffix = /-(low|medium|high|xhigh)$/;
12
+ const gptMinorVersion = /^gpt-(\d+)-(\d+)-/;
13
+
14
+ const litellmModel = (devinModel: string): string =>
15
+ devinModel.replace(effortSuffix, "").replace(gptMinorVersion, "gpt-$1.$2-");
9
16
 
10
17
  const rowKey = (day: UsageDay): string =>
11
18
  `${day.date}\u0000${day.provider}\u0000${day.model}`;
@@ -53,23 +60,33 @@ export function mapCcusageDays(output: CcusageDaily): UsageDay[] {
53
60
  return sortedRows(rows);
54
61
  }
55
62
 
56
- export function mapAntigravitySteps(
57
- steps: AntigravityStep[],
63
+ interface LocalStep {
64
+ at: Date;
65
+ cacheCreate?: number;
66
+ cacheRead: number;
67
+ input: number;
68
+ model: string;
69
+ output: number;
70
+ }
71
+
72
+ function mapLocalSteps(
73
+ steps: LocalStep[],
74
+ provider: string,
58
75
  timezone: string,
59
- prices: PriceTable,
76
+ priceOf: (model: string) => ModelPrice | undefined,
60
77
  ): UsageDay[] {
61
78
  const rows = new Map<string, UsageDay>();
62
79
 
63
80
  for (const step of steps) {
64
81
  const row: UsageDay = {
65
- cache_create: 0,
82
+ cache_create: step.cacheCreate ?? 0,
66
83
  cache_read: step.cacheRead,
67
84
  cost_usd: 0,
68
85
  date: calendarDate(step.at, timezone),
69
86
  input: step.input,
70
87
  model: step.model,
71
88
  output: step.output,
72
- provider: antigravityProvider,
89
+ provider,
73
90
  };
74
91
  if (isEmptyRow(row)) {
75
92
  continue;
@@ -80,17 +97,19 @@ export function mapAntigravitySteps(
80
97
  rows.set(key, row);
81
98
  continue;
82
99
  }
100
+ existing.cache_create += row.cache_create;
83
101
  existing.cache_read += row.cache_read;
84
102
  existing.input += row.input;
85
103
  existing.output += row.output;
86
104
  }
87
105
 
88
106
  for (const row of rows.values()) {
89
- const price = prices.get(row.model) ?? prices.get(`gemini/${row.model}`);
107
+ const price = priceOf(row.model);
90
108
  row.cost_usd =
91
109
  price === undefined
92
110
  ? 0
93
111
  : costOf(price, {
112
+ cacheCreate: row.cache_create,
94
113
  cacheRead: row.cache_read,
95
114
  input: row.input,
96
115
  output: row.output,
@@ -100,6 +119,32 @@ export function mapAntigravitySteps(
100
119
  return sortedRows(rows);
101
120
  }
102
121
 
122
+ export function mapAntigravitySteps(
123
+ steps: AntigravityStep[],
124
+ timezone: string,
125
+ prices: PriceTable,
126
+ ): UsageDay[] {
127
+ return mapLocalSteps(
128
+ steps,
129
+ antigravityProvider,
130
+ timezone,
131
+ (model) => prices.get(model) ?? prices.get(`gemini/${model}`),
132
+ );
133
+ }
134
+
135
+ export function mapDevinSteps(
136
+ steps: DevinStep[],
137
+ timezone: string,
138
+ prices: PriceTable,
139
+ ): UsageDay[] {
140
+ return mapLocalSteps(
141
+ steps.map((step) => ({ ...step, model: litellmModel(step.model) })),
142
+ devinProvider,
143
+ timezone,
144
+ (model) => prices.get(model),
145
+ );
146
+ }
147
+
103
148
  function sortedRows(rows: Map<string, UsageDay>): UsageDay[] {
104
149
  return [...rows.values()].sort(
105
150
  (a, b) =>
package/src/pricing.ts CHANGED
@@ -4,6 +4,7 @@ import { z } from "zod";
4
4
  import type { Fetcher } from "./http";
5
5
 
6
6
  export interface ModelPrice {
7
+ cacheCreate: number;
7
8
  cacheRead: number;
8
9
  input: number;
9
10
  output: number;
@@ -12,6 +13,7 @@ export interface ModelPrice {
12
13
  export type PriceTable = Map<string, ModelPrice>;
13
14
 
14
15
  export interface PricedUsage {
16
+ cacheCreate?: number;
15
17
  cacheRead: number;
16
18
  input: number;
17
19
  output: number;
@@ -23,6 +25,7 @@ export const litellmPricesUrl =
23
25
  const maxAgeMs = 24 * 60 * 60 * 1000;
24
26
 
25
27
  const pricedEntry = z.object({
28
+ cache_creation_input_token_cost: z.number().optional(),
26
29
  cache_read_input_token_cost: z.number().optional(),
27
30
  input_cost_per_token: z.number(),
28
31
  output_cost_per_token: z.number(),
@@ -38,6 +41,7 @@ export function parseLitellmPrices(source: string): PriceTable {
38
41
  const priced = pricedEntry.safeParse(entry);
39
42
  if (priced.success) {
40
43
  table.set(model, {
44
+ cacheCreate: priced.data.cache_creation_input_token_cost ?? 0,
41
45
  cacheRead: priced.data.cache_read_input_token_cost ?? 0,
42
46
  input: priced.data.input_cost_per_token,
43
47
  output: priced.data.output_cost_per_token,
@@ -51,7 +55,8 @@ export function costOf(price: ModelPrice, usage: PricedUsage): number {
51
55
  return (
52
56
  usage.input * price.input +
53
57
  usage.output * price.output +
54
- usage.cacheRead * price.cacheRead
58
+ usage.cacheRead * price.cacheRead +
59
+ (usage.cacheCreate ?? 0) * price.cacheCreate
55
60
  );
56
61
  }
57
62