@pulsarinteractive/geniusfitness 0.1.0-beta.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.
@@ -0,0 +1,271 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { constants } from 'node:fs';
3
+ import { open } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import * as z from 'zod/v4';
6
+ import catalog from './command-catalog.mjs';
7
+ import { CredentialStore } from './store.mjs';
8
+ import { AgentError, fail, safeError } from './errors.mjs';
9
+
10
+ export const operationIdSchema = z
11
+ .string()
12
+ .regex(/^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$/);
13
+ const identifier = z
14
+ .string()
15
+ .min(1)
16
+ .max(128)
17
+ // eslint-disable-next-line no-control-regex -- Reject control characters in untrusted input.
18
+ .regex(/^[^\u0000-\u001f]+$/u);
19
+ export const commandSchema = z
20
+ .object({
21
+ operationId: operationIdSchema,
22
+ issuedAt: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER),
23
+ scope: z
24
+ .string()
25
+ .max(256)
26
+ // eslint-disable-next-line no-control-regex -- Reject control characters in untrusted input.
27
+ .regex(/^(project|personal):[^\u0000-\u001f]+$/u),
28
+ epoch: identifier,
29
+ id: identifier,
30
+ expectedRevision: z
31
+ .string()
32
+ .regex(/^[1-9][0-9]{0,15}$/)
33
+ .nullable(),
34
+ kind: z.enum(Object.keys(catalog.commands)),
35
+ data: z.record(z.string(), z.json()),
36
+ })
37
+ .strict();
38
+ export const draftSchema = commandSchema.omit({ operationId: true, issuedAt: true });
39
+ const canonical = (value) =>
40
+ JSON.stringify(value, (_, item) =>
41
+ item && typeof item === 'object' && !Array.isArray(item)
42
+ ? Object.fromEntries(
43
+ Object.keys(item)
44
+ .sort()
45
+ .map((key) => [key, item[key]]),
46
+ )
47
+ : item,
48
+ );
49
+ const checked = (schema, value) => {
50
+ const parsed = schema.safeParse(value);
51
+ if (!parsed.success) {
52
+ fail(
53
+ 'invalid_arguments',
54
+ 'The command does not match its schema. Use commands describe; do not supply credentials or server-owned fields.',
55
+ );
56
+ }
57
+ return parsed.data;
58
+ };
59
+ /** Validate the public envelope, generated field allowlist and resource scope. */
60
+ export function validateCommand(value) {
61
+ const command = checked(commandSchema, value),
62
+ definition = catalog.commands[command.kind];
63
+ if (
64
+ Buffer.byteLength(JSON.stringify(command)) > catalog.maxCommandBytes ||
65
+ Object.keys(command.data).some((key) => !definition.fields.includes(key)) ||
66
+ (definition.create ? command.expectedRevision !== null : command.expectedRevision === null)
67
+ ) {
68
+ fail(
69
+ 'invalid_arguments',
70
+ 'Use the declared command fields and size limit. Creation requires a null revision; other commands require the current record revision.',
71
+ );
72
+ }
73
+ const prefix = definition.module.startsWith('LocalUser/') ? 'personal:' : 'project:';
74
+ if (
75
+ !command.scope.startsWith(prefix) ||
76
+ (definition.module.endsWith('/$') && command.id !== command.scope.slice(prefix.length))
77
+ ) {
78
+ fail('invalid_arguments', 'The command must target the matching program or personal scope.');
79
+ }
80
+ if (
81
+ command.kind === 'program.create' &&
82
+ (!/^[a-f\d]{8}-[a-f\d]{4}-4[a-f\d]{3}-[89ab][a-f\d]{3}-[a-f\d]{12}$/i.test(command.id) ||
83
+ command.epoch !== command.id)
84
+ ) {
85
+ fail(
86
+ 'invalid_arguments',
87
+ 'Create a program with a UUID v4 as id, the same initial epoch, and scope project:<id>. Include title and icon.',
88
+ );
89
+ }
90
+ return command;
91
+ }
92
+ export function describeCommands() {
93
+ return {
94
+ ...catalog,
95
+ envelope: z.toJSONSchema(commandSchema),
96
+ draft: z.toJSONSchema(draftSchema),
97
+ recovery:
98
+ 'Retain operationId, issuedAt and the complete body. Retry an uncertain write unchanged; reread before creating a new edit after a conflict.',
99
+ };
100
+ }
101
+ /** Read one bounded regular JSON file, rejecting symlinks and non-file inputs. */
102
+ export async function readCommandFile(path) {
103
+ if (typeof path !== 'string') {
104
+ fail('invalid_arguments', 'Supply --file with a JSON command or draft.');
105
+ }
106
+ let handle;
107
+ try {
108
+ handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
109
+ const stat = await handle.stat();
110
+ if (!stat.isFile() || stat.size > catalog.maxCommandBytes) {
111
+ fail(
112
+ 'invalid_arguments',
113
+ `Use a regular JSON file no larger than ${catalog.maxCommandBytes} bytes.`,
114
+ );
115
+ }
116
+ const bytes = Buffer.alloc(catalog.maxCommandBytes + 1),
117
+ result = await handle.read(bytes, 0, bytes.length, 0);
118
+ if (result.bytesRead > catalog.maxCommandBytes) {
119
+ fail('invalid_arguments', `Command file exceeds ${catalog.maxCommandBytes} bytes.`);
120
+ }
121
+ return JSON.parse(bytes.subarray(0, result.bytesRead).toString('utf8'));
122
+ } catch (error) {
123
+ if (error instanceof AgentError) {
124
+ throw error;
125
+ }
126
+ fail('invalid_arguments', 'Could not read a regular, valid JSON command file.');
127
+ } finally {
128
+ await handle?.close();
129
+ }
130
+ }
131
+ const binding = (session) => ({
132
+ origin: session.origin,
133
+ connectionId: session.connection_id,
134
+ profileId: session.profile_id,
135
+ sessionId: session.session_id,
136
+ });
137
+ const receiptSchema = z
138
+ .object({
139
+ operationId: operationIdSchema,
140
+ scope: z.string(),
141
+ kind: z.string(),
142
+ epoch: z.string(),
143
+ cursor: z.string(),
144
+ items: z.array(
145
+ z
146
+ .object({
147
+ id: z.string(),
148
+ revision: z.string(),
149
+ collection: z.string(),
150
+ deleted: z.boolean(),
151
+ })
152
+ .strict(),
153
+ ),
154
+ affectedCollections: z.array(z.string()),
155
+ })
156
+ .strict();
157
+
158
+ /** A journal is bound to one authenticated terminal identity. Persist the full
159
+ * request before the network call, never regenerate a UUID after a timeout.
160
+ * It contains private project text but no access/refresh tokens. A process crash
161
+ * leaves its operation lock for explicit recovery after all clients stop. */
162
+ export class CommandJournal {
163
+ constructor(client) {
164
+ this.client = client;
165
+ }
166
+ store(id) {
167
+ checked(operationIdSchema, id);
168
+ return new CredentialStore({
169
+ directory: join(this.client.store.directory, 'operations', this.client.store.context),
170
+ context: id,
171
+ maxBytes: 524288,
172
+ });
173
+ }
174
+ async prepare(draft) {
175
+ const input = validateCommand({
176
+ ...checked(draftSchema, draft),
177
+ operationId: randomUUID(),
178
+ issuedAt: Date.now(),
179
+ });
180
+ const identity = binding(await this.client.session()),
181
+ store = this.store(input.operationId);
182
+ await store.locked(
183
+ () => store.write({ formatVersion: 1, binding: identity, input, state: 'prepared' }),
184
+ this.client.transport.signal,
185
+ );
186
+ return { operationId: input.operationId, state: 'prepared', request: input };
187
+ }
188
+ async inspect(id) {
189
+ const row = await this.store(id).read();
190
+ if (!row || row.formatVersion !== 1 || row.input?.operationId !== id) {
191
+ fail('operation_not_found', 'This context has no valid local operation with that ID.');
192
+ }
193
+ return {
194
+ operationId: id,
195
+ source: 'local_journal',
196
+ state: row.state,
197
+ request: row.input,
198
+ ...(row.receipt ? { receipt: row.receipt } : {}),
199
+ ...(row.error ? { error: row.error } : {}),
200
+ };
201
+ }
202
+ async execute(value) {
203
+ const input = validateCommand(value),
204
+ store = this.store(input.operationId);
205
+ return store.locked(async () => {
206
+ // Capture token and identity together under the refresh lock. A later
207
+ // logout/login cannot turn this pending operation into another profile.
208
+ const session = await this.client.authorizedSession(),
209
+ identity = binding(session);
210
+ let row = await store.read();
211
+ if (row && (row.formatVersion !== 1 || canonical(row.binding) !== canonical(identity))) {
212
+ fail(
213
+ 'operation_identity_mismatch',
214
+ 'This operation belongs to another connection, profile, session or environment.',
215
+ {
216
+ remediation:
217
+ 'Inspect it locally and reread current records. Do not replay it through a replacement login.',
218
+ },
219
+ );
220
+ }
221
+ if (row && canonical(row.input) !== canonical(input)) {
222
+ fail(
223
+ 'idempotency_mismatch',
224
+ 'The operation ID already belongs to a different immutable request.',
225
+ { status: 409, remediation: 'Use commands inspect, then retry the saved operation.' },
226
+ );
227
+ }
228
+ if (row?.state === 'confirmed') {
229
+ return { source: 'local_receipt', receipt: row.receipt };
230
+ }
231
+ row = { formatVersion: 1, binding: identity, input, state: 'uncertain' };
232
+ await store.write(row);
233
+ try {
234
+ const receipt = await this.client.transport.request('/api/agents/v1/commands', {
235
+ method: 'POST',
236
+ json: input,
237
+ token: session.access_token,
238
+ });
239
+ if (
240
+ !receiptSchema.safeParse(receipt).success ||
241
+ ['operationId', 'scope', 'kind', 'epoch'].some((key) => receipt[key] !== input[key])
242
+ ) {
243
+ fail(
244
+ 'invalid_response',
245
+ 'The command receipt was invalid. The server may have committed.',
246
+ { execution: 'unknown', remediation: 'Retry the identical saved operation.' },
247
+ );
248
+ }
249
+ await store.write({ ...row, state: 'confirmed', receipt });
250
+ return { source: 'server', receipt };
251
+ } catch (error) {
252
+ const safe = safeError(error);
253
+ const result = new AgentError(safe.code, safe.message, {
254
+ status: safe.status,
255
+ retryable: safe.retryable,
256
+ remediation: safe.remediation,
257
+ execution: 'unknown',
258
+ ...safe.details,
259
+ operationId: input.operationId,
260
+ });
261
+ // The uncertainty marker is already durable. Preserve the useful original
262
+ // error/operation ID even if disk pressure prevents saving diagnostics.
263
+ await store.write({ ...row, error: result.toJSON() }).catch(() => {});
264
+ throw result;
265
+ }
266
+ }, this.client.transport.signal);
267
+ }
268
+ async retry(id) {
269
+ return this.execute((await this.inspect(id)).request);
270
+ }
271
+ }
package/src/errors.mjs ADDED
@@ -0,0 +1,65 @@
1
+ /** Never forward raw network exceptions: URLs, HTML and provider diagnostics
2
+ * can contain credentials. API messages are data, never terminal control. */
3
+ export class AgentError extends Error {
4
+ constructor(
5
+ code,
6
+ message,
7
+ { status = 0, retryable = false, remediation = 'Check help and retry.', ...details } = {},
8
+ ) {
9
+ super(message);
10
+ this.code = code;
11
+ this.status = status;
12
+ this.retryable = retryable;
13
+ this.remediation = remediation;
14
+ this.details = details;
15
+ }
16
+ toJSON() {
17
+ return {
18
+ code: this.code,
19
+ message: this.message,
20
+ retryable: this.retryable,
21
+ remediation: this.remediation,
22
+ ...this.details,
23
+ };
24
+ }
25
+ }
26
+ export const fail = (code, message, options) => {
27
+ throw new AgentError(code, message, options);
28
+ };
29
+ export function safeError(error) {
30
+ return error instanceof AgentError
31
+ ? error
32
+ : new AgentError('internal_error', 'The command could not complete.', {
33
+ remediation: 'Retry once; run doctor if the problem persists.',
34
+ });
35
+ }
36
+ export function exitCode(error) {
37
+ if (['invalid_arguments', 'invalid_origin'].includes(error.code)) {
38
+ return 2;
39
+ }
40
+ if (
41
+ error.status === 401 ||
42
+ [
43
+ 'not_authenticated',
44
+ 'reauthentication_required',
45
+ 'access_denied',
46
+ 'expired_token',
47
+ 'invalid_grant',
48
+ ].includes(error.code)
49
+ ) {
50
+ return 3;
51
+ }
52
+ if (error.status === 403 || error.code === 'profile_selection_denied') {
53
+ return 4;
54
+ }
55
+ if (error.status === 409 || error.code === 'profile_selection_conflict') {
56
+ return 5;
57
+ }
58
+ if (error.status === 429) {
59
+ return 6;
60
+ }
61
+ if (error.retryable) {
62
+ return 7;
63
+ }
64
+ return 1;
65
+ }
@@ -0,0 +1,267 @@
1
+ import * as z from 'zod/v4';
2
+ import catalog from './command-catalog.mjs';
3
+ import { fail } from './errors.mjs';
4
+
5
+ const id = z
6
+ .string()
7
+ .min(1)
8
+ .max(256)
9
+ // eslint-disable-next-line no-control-regex -- Match the server identifier boundary.
10
+ .regex(/^[^\u0000-\u001f]+$/u);
11
+ const scope = z
12
+ .string()
13
+ .max(256)
14
+ // eslint-disable-next-line no-control-regex -- Scope prefixes bind records to the right owner.
15
+ .regex(/^(project|personal):[^\u0000-\u001f]+$/u);
16
+ const revision = z
17
+ .string()
18
+ .regex(/^(0|[1-9][0-9]{0,15})$/)
19
+ .refine((value) => BigInt(value) <= 9007199254740991n);
20
+ const date = z
21
+ .string()
22
+ .regex(/^\d{4}-\d{2}-\d{2}$/)
23
+ .refine(
24
+ (value) =>
25
+ Number.isFinite(Date.parse(value)) && new Date(value).toISOString().slice(0, 10) === value,
26
+ );
27
+
28
+ export const catalogSchema = z
29
+ .object({
30
+ query: z.string().max(160).optional(),
31
+ key: z
32
+ .string()
33
+ .regex(/^ex\.[0-9]+$/)
34
+ .optional(),
35
+ equipment: z.string().max(80).optional(),
36
+ muscle: z.string().max(80).optional(),
37
+ after: z
38
+ .string()
39
+ .regex(/^ex\.[0-9]+$/)
40
+ .optional(),
41
+ version: z
42
+ .string()
43
+ .regex(/^[a-f0-9]{64}$/)
44
+ .optional(),
45
+ })
46
+ .strict()
47
+ .refine((input) => !input.after || (!!input.version && !input.key));
48
+ export const metricsSchema = z
49
+ .object({
50
+ scope: z.string().regex(/^project:[A-Za-z0-9_-]{1,128}$/),
51
+ from: date,
52
+ until: date,
53
+ })
54
+ .strict()
55
+ .refine((input) => {
56
+ const days = (Date.parse(input.until) - Date.parse(input.from)) / 86400000;
57
+ return days >= 0 && days < 366;
58
+ });
59
+ export const resourcesSchema = z
60
+ .object({
61
+ scope,
62
+ collection: z.enum(Object.keys(catalog.collections)),
63
+ id: id.optional(),
64
+ after: id.optional(),
65
+ epoch: id.optional(),
66
+ checkpoint: revision.optional(),
67
+ archive: z.enum(['active', 'archived', 'all']).optional(),
68
+ })
69
+ .strict()
70
+ .refine(
71
+ (input) =>
72
+ (!input.after || (!!input.epoch && input.checkpoint !== undefined && !input.id)) &&
73
+ (input.archive === undefined || input.collection === 'planning' || input.archive === 'all') &&
74
+ input.scope.startsWith(
75
+ catalog.collections[input.collection].module.startsWith('LocalUser/')
76
+ ? 'personal:'
77
+ : 'project:',
78
+ ),
79
+ );
80
+
81
+ const checked = (schema, input) => {
82
+ const result = schema.safeParse(input);
83
+ if (!result.success) {
84
+ fail(
85
+ 'invalid_arguments',
86
+ 'Use the declared parameters, matching scope and bounded dates. Continue pages with the returned version or epoch/checkpoint.',
87
+ );
88
+ }
89
+ return result.data;
90
+ };
91
+ const exerciseSchema = z
92
+ .object({
93
+ key: z.string().regex(/^ex\.[0-9]+$/),
94
+ name: z.string().min(1),
95
+ bodyPart: z.string(),
96
+ target: z.string(),
97
+ secondary: z.array(z.string()).max(30),
98
+ equipment: z.string(),
99
+ equipmentGroup: z.string(),
100
+ muscleGroup: z.string(),
101
+ exerciseType: z.string(),
102
+ attribution: z.string(),
103
+ })
104
+ .strict();
105
+ const catalogPage = z
106
+ .object({
107
+ version: z.string().regex(/^[a-f0-9]{64}$/),
108
+ items: z.array(exerciseSchema).max(25),
109
+ nextAfter: z.string().nullable(),
110
+ })
111
+ .strict();
112
+
113
+ /** Catalogue IDs, not copied image files, connect imported exercises to the
114
+ * app's maintained library. Every request still uses the current delegation. */
115
+ export async function readCatalog(client, input) {
116
+ const query = checked(catalogSchema, input),
117
+ result = await client.get('catalog', query);
118
+ if (
119
+ !catalogPage.safeParse(result).success ||
120
+ (query.version && result.version !== query.version) ||
121
+ (query.key && (result.items.length !== 1 || result.items[0].key !== query.key)) ||
122
+ new Set(result.items.map((item) => item.key)).size !== result.items.length ||
123
+ (result.nextAfter !== null &&
124
+ (!result.items.length || result.nextAfter !== result.items.at(-1).key)) ||
125
+ result.items.some(
126
+ (item, index) =>
127
+ (query.after && item.key <= query.after) ||
128
+ (index > 0 && item.key <= result.items[index - 1].key),
129
+ )
130
+ ) {
131
+ fail(
132
+ 'invalid_response',
133
+ 'The catalogue page was invalid. Keep the previous cursor and do not import unknown IDs.',
134
+ );
135
+ }
136
+ return result;
137
+ }
138
+
139
+ const metricsPage = z
140
+ .object({
141
+ scope,
142
+ epoch: id,
143
+ checkpoint: revision,
144
+ from: date,
145
+ until: date,
146
+ days: z
147
+ .array(
148
+ z
149
+ .object({
150
+ day: date,
151
+ sessions: z.number().int().nonnegative().safe(),
152
+ runs: z.number().int().nonnegative().safe(),
153
+ durationSeconds: z.number().nonnegative(),
154
+ distanceMeters: z.number().nonnegative(),
155
+ })
156
+ .strict(),
157
+ )
158
+ .max(366),
159
+ })
160
+ .strict();
161
+
162
+ /** Daily aggregates keep long activity histories out of agent context. A failed
163
+ * or partial response is never summarized as zero activity. */
164
+ export async function readMetrics(client, input) {
165
+ const query = checked(metricsSchema, input),
166
+ result = await client.get('metrics', query);
167
+ if (
168
+ !metricsPage.safeParse(result).success ||
169
+ ['scope', 'from', 'until'].some((key) => result[key] !== query[key]) ||
170
+ result.days.some(
171
+ (day, index) =>
172
+ day.day < query.from ||
173
+ day.day > query.until ||
174
+ (index > 0 && day.day <= result.days[index - 1].day),
175
+ )
176
+ ) {
177
+ fail(
178
+ 'invalid_response',
179
+ 'The metrics response was invalid. Do not infer empty activity from it.',
180
+ );
181
+ }
182
+ return result;
183
+ }
184
+
185
+ const resourcePage = z
186
+ .object({
187
+ scope,
188
+ collection: z.string(),
189
+ epoch: id,
190
+ checkpoint: revision,
191
+ items: z
192
+ .array(z.object({ id, revision, data: z.record(z.string(), z.unknown()) }).strict())
193
+ .max(25),
194
+ omittedIds: z.array(id).max(25),
195
+ nextAfter: id.nullable(),
196
+ changeCursor: z.string().max(4096).nullable(),
197
+ etag: z.string().max(8192),
198
+ notModified: z.boolean().optional(),
199
+ })
200
+ .strict();
201
+ export async function readResources(client, input) {
202
+ const query = checked(resourcesSchema, input),
203
+ result = await client.get('resources', query);
204
+ if (
205
+ !resourcePage.safeParse(result).success ||
206
+ result.scope !== query.scope ||
207
+ result.collection !== query.collection ||
208
+ (query.epoch && result.epoch !== query.epoch) ||
209
+ (query.checkpoint && result.checkpoint !== query.checkpoint) ||
210
+ (query.id && result.items.some((item) => item.id !== query.id)) ||
211
+ new Set(result.items.map((item) => item.id)).size !== result.items.length ||
212
+ (query.after &&
213
+ ((result.nextAfter !== null && result.nextAfter <= query.after) ||
214
+ result.items.some((item) => item.id <= query.after)))
215
+ ) {
216
+ fail(
217
+ 'invalid_response',
218
+ 'The resource page was invalid. Preserve the previous inventory and cursor.',
219
+ );
220
+ }
221
+ return result;
222
+ }
223
+
224
+ /** Local, reviewed contract subset. Only this command's editable fields are
225
+ * returned; private server models never enter the distributed client. */
226
+ export function commandHelp(kind) {
227
+ if (typeof kind !== 'string' || !Object.hasOwn(catalog.commands, kind)) {
228
+ fail('invalid_arguments', 'Choose a command from capabilities or commands describe.');
229
+ }
230
+ const definition = catalog.commands[kind],
231
+ properties = {};
232
+ for (const field of definition.fields) {
233
+ properties[field] = catalog.fieldSchemas[definition.module][field];
234
+ }
235
+ const required = definition.create
236
+ ? definition.fields.filter((field) => {
237
+ const schema = properties[field];
238
+ return (
239
+ !Object.hasOwn(schema, 'default') &&
240
+ !(
241
+ schema.type === 'null' ||
242
+ (Array.isArray(schema.type) && schema.type.includes('null')) ||
243
+ schema.anyOf?.some((part) => part.type === 'null')
244
+ )
245
+ );
246
+ })
247
+ : [];
248
+ for (const field of definition.requiredFields ?? []) {
249
+ if (!required.includes(field)) {
250
+ required.push(field);
251
+ }
252
+ const schema = properties[field];
253
+ if (schema.anyOf?.some((part) => part.type === 'null')) {
254
+ properties[field] = { ...schema, anyOf: schema.anyOf.filter((part) => part.type !== 'null') };
255
+ }
256
+ }
257
+ return {
258
+ kind,
259
+ ...definition,
260
+ dataSchema: { type: 'object', properties, required, additionalProperties: false },
261
+ workflow: definition.create
262
+ ? 'Use a new stable ID and null expectedRevision; read the scope epoch first. program.create uses its UUID as initial epoch and needs all-resources consent.'
263
+ : 'Read the current record and use its revision. Preserve the prepared operation after an uncertain response.',
264
+ coach:
265
+ 'Other athletes’ programs need current Coach subscription, coaching:write and ordinary program access.',
266
+ };
267
+ }