catence 0.0.0 → 0.2.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.
Files changed (110) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +201 -29
  3. package/config.example.json +63 -0
  4. package/dist/contracts/index.d.ts +4 -0
  5. package/dist/contracts/index.js +5 -0
  6. package/dist/contracts/release.d.ts +20 -0
  7. package/dist/contracts/release.js +17 -0
  8. package/dist/contracts/runtime.d.ts +11 -0
  9. package/dist/contracts/runtime.js +2 -0
  10. package/dist/contracts/staging.d.ts +183 -0
  11. package/dist/contracts/staging.js +36 -0
  12. package/dist/contracts/storage.d.ts +15 -0
  13. package/dist/contracts/storage.js +2 -0
  14. package/dist/core/dashboard/snapshot.d.ts +16 -0
  15. package/dist/core/dashboard/snapshot.js +65 -0
  16. package/dist/core/query/activity-discovery.d.ts +21 -0
  17. package/dist/core/query/activity-discovery.js +133 -0
  18. package/dist/core/query/analytics.d.ts +59 -0
  19. package/dist/core/query/analytics.js +395 -0
  20. package/dist/core/query/catalog.d.ts +26 -0
  21. package/dist/core/query/catalog.js +141 -0
  22. package/dist/core/query/fitness.d.ts +40 -0
  23. package/dist/core/query/fitness.js +297 -0
  24. package/dist/core/query/repository.d.ts +19 -0
  25. package/dist/core/query/repository.js +182 -0
  26. package/dist/core/query/sql-guard.d.ts +7 -0
  27. package/dist/core/query/sql-guard.js +121 -0
  28. package/dist/core/query/sql.d.ts +2 -0
  29. package/dist/core/query/sql.js +5 -0
  30. package/dist/core/query/swimming.d.ts +19 -0
  31. package/dist/core/query/swimming.js +184 -0
  32. package/dist/core/query/wellness.d.ts +45 -0
  33. package/dist/core/query/wellness.js +292 -0
  34. package/dist/core/retrieval/index.d.ts +13 -0
  35. package/dist/core/retrieval/index.js +248 -0
  36. package/dist/core/runtime/catalog.d.ts +41 -0
  37. package/dist/core/runtime/catalog.js +111 -0
  38. package/dist/core/runtime/configuration.d.ts +71 -0
  39. package/dist/core/runtime/configuration.js +106 -0
  40. package/dist/core/runtime/limiter.d.ts +13 -0
  41. package/dist/core/runtime/limiter.js +17 -0
  42. package/dist/core/runtime/secrets.d.ts +24 -0
  43. package/dist/core/runtime/secrets.js +68 -0
  44. package/dist/elt/application/demo.d.ts +23 -0
  45. package/dist/elt/application/demo.js +165 -0
  46. package/dist/elt/application/management.d.ts +25 -0
  47. package/dist/elt/application/management.js +285 -0
  48. package/dist/elt/ingestion/importer.d.ts +8 -0
  49. package/dist/elt/ingestion/importer.js +87 -0
  50. package/dist/elt/ingestion/providers/intervals.d.ts +21 -0
  51. package/dist/elt/ingestion/providers/intervals.js +224 -0
  52. package/dist/elt/ingestion/providers/registry.d.ts +19 -0
  53. package/dist/elt/ingestion/providers/registry.js +62 -0
  54. package/dist/elt/ingestion/providers/strava/service.d.ts +42 -0
  55. package/dist/elt/ingestion/providers/strava/service.js +366 -0
  56. package/dist/elt/normalization/activities/linking.d.ts +5 -0
  57. package/dist/elt/normalization/activities/linking.js +165 -0
  58. package/dist/elt/normalization/normalizers.d.ts +5 -0
  59. package/dist/elt/normalization/normalizers.js +816 -0
  60. package/dist/elt/normalization/segments/strava-normalizer.d.ts +3 -0
  61. package/dist/elt/normalization/segments/strava-normalizer.js +93 -0
  62. package/dist/elt/storage/artifacts.d.ts +13 -0
  63. package/dist/elt/storage/artifacts.js +48 -0
  64. package/dist/elt/storage/database.d.ts +74 -0
  65. package/dist/elt/storage/database.js +296 -0
  66. package/dist/elt/storage/migrations.d.ts +3 -0
  67. package/dist/elt/storage/migrations.js +832 -0
  68. package/dist/elt/storage/migrations.legacy.d.ts +12 -0
  69. package/dist/elt/storage/migrations.legacy.js +332 -0
  70. package/dist/elt/storage/sql.d.ts +2 -0
  71. package/dist/elt/storage/sql.js +7 -0
  72. package/dist/elt/storage/write-lock.d.ts +6 -0
  73. package/dist/elt/storage/write-lock.js +28 -0
  74. package/dist/elt/streams.d.ts +24 -0
  75. package/dist/elt/streams.js +62 -0
  76. package/dist/interfaces/cli/commands.d.ts +2 -0
  77. package/dist/interfaces/cli/commands.js +166 -0
  78. package/dist/interfaces/cli/main.d.ts +2 -0
  79. package/dist/interfaces/cli/main.js +3 -0
  80. package/dist/interfaces/http/cli-options.d.ts +9 -0
  81. package/dist/interfaces/http/cli-options.js +81 -0
  82. package/dist/interfaces/http/server.d.ts +17 -0
  83. package/dist/interfaces/http/server.js +196 -0
  84. package/dist/interfaces/mcp/cli-options.d.ts +6 -0
  85. package/dist/interfaces/mcp/cli-options.js +36 -0
  86. package/dist/interfaces/mcp/main.d.ts +2 -0
  87. package/dist/interfaces/mcp/main.js +66 -0
  88. package/dist/interfaces/mcp/server.d.ts +8 -0
  89. package/dist/interfaces/mcp/server.js +572 -0
  90. package/dist/runtime/index.d.ts +33 -0
  91. package/dist/runtime/index.js +21 -0
  92. package/docs/architecture.md +100 -0
  93. package/docs/assets/image.png +0 -0
  94. package/docs/mcp-activity-retrieval.md +377 -0
  95. package/package.json +81 -6
  96. package/pyproject.toml +28 -0
  97. package/python/__init__.py +1 -0
  98. package/python/catence/__init__.py +0 -0
  99. package/python/catence/providers/__init__.py +0 -0
  100. package/python/catence/providers/garmin/__init__.py +3 -0
  101. package/python/catence/providers/garmin/cli.py +62 -0
  102. package/python/catence/providers/garmin/contracts.py +23 -0
  103. package/python/catence/providers/garmin/registry.py +48 -0
  104. package/python/catence/providers/garmin/staging.py +87 -0
  105. package/python/catence/providers/garmin/streams.py +195 -0
  106. package/python/catence/providers/garmin/worker.py +450 -0
  107. package/python/catence/providers/strava/__init__.py +3 -0
  108. package/python/catence/providers/strava/cli.py +404 -0
  109. package/python/catence/providers/strava/staging.py +73 -0
  110. package/uv.lock +708 -0
@@ -0,0 +1,111 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { mkdir, readFile, readdir, rename, writeFile } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import path from 'node:path';
5
+ import { z } from 'zod';
6
+ import { resolvePaths } from './configuration.js';
7
+ const CATALOG_FORMAT_VERSION = 1;
8
+ const athleteIdSchema = z.string().regex(/^[a-z][a-z0-9-]{0,62}$/, 'must use lowercase letters, digits, and hyphens, starting with a letter');
9
+ const catalogSchema = z.object({
10
+ formatVersion: z.literal(CATALOG_FORMAT_VERSION),
11
+ defaultAthleteId: athleteIdSchema,
12
+ athletes: z.array(z.object({
13
+ id: athleteIdSchema,
14
+ label: z.string().trim().min(1).max(120),
15
+ createdAt: z.string().datetime(),
16
+ }).strict()).min(1),
17
+ }).strict().superRefine((catalog, context) => {
18
+ const ids = catalog.athletes.map((athlete) => athlete.id);
19
+ if (new Set(ids).size !== ids.length)
20
+ context.addIssue({ code: 'custom', path: ['athletes'], message: 'athlete IDs must be unique' });
21
+ if (!ids.includes(catalog.defaultAthleteId))
22
+ context.addIssue({ code: 'custom', path: ['defaultAthleteId'], message: 'must name a configured athlete' });
23
+ });
24
+ export function defaultCatalogHome() {
25
+ return path.resolve(process.env.CATENCE_HOME ?? path.join(homedir(), '.catence'));
26
+ }
27
+ export function resolveCatalogPaths(root = defaultCatalogHome()) {
28
+ const absoluteRoot = path.resolve(root);
29
+ return {
30
+ root: absoluteRoot,
31
+ catalog: path.join(absoluteRoot, 'catalog.json'),
32
+ athletes: path.join(absoluteRoot, 'athletes'),
33
+ console: path.join(absoluteRoot, 'console'),
34
+ };
35
+ }
36
+ export function athleteStorePaths(catalogPaths, athleteId) {
37
+ const id = athleteIdSchema.parse(athleteId);
38
+ return resolvePaths(path.join(catalogPaths.athletes, id));
39
+ }
40
+ function legacyStoreMessage(paths) {
41
+ return `Catence 0.2 uses a catalog at ${paths.root}. The existing directory looks like a 0.1 data store and cannot be migrated automatically. Create a fresh catalog and re-sync each athlete.`;
42
+ }
43
+ async function hasEntries(directory) {
44
+ return (await readdir(directory).catch((error) => error.code === 'ENOENT' ? [] : Promise.reject(error))).length > 0;
45
+ }
46
+ export async function loadCatalog(paths = resolveCatalogPaths()) {
47
+ const contents = await readFile(paths.catalog, 'utf8').catch((error) => {
48
+ if (error.code !== 'ENOENT')
49
+ throw error;
50
+ if (existsSync(path.join(paths.root, 'catence.duckdb')))
51
+ throw new Error(legacyStoreMessage(paths));
52
+ throw new Error(`No Catence catalog exists at ${paths.root}. Run catence-data setup first.`);
53
+ });
54
+ try {
55
+ return catalogSchema.parse(JSON.parse(contents));
56
+ }
57
+ catch (error) {
58
+ const message = error instanceof Error ? error.message : String(error);
59
+ throw new Error(`Invalid Catence catalog at ${paths.catalog}: ${message}`);
60
+ }
61
+ }
62
+ async function writeCatalog(paths, catalog) {
63
+ await mkdir(paths.root, { recursive: true, mode: 0o700 });
64
+ const temporary = `${paths.catalog}.tmp`;
65
+ await writeFile(temporary, `${JSON.stringify(catalog, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
66
+ await rename(temporary, paths.catalog);
67
+ }
68
+ export async function initializeCatalog(paths = resolveCatalogPaths(), athlete) {
69
+ if (existsSync(paths.catalog))
70
+ return loadCatalog(paths);
71
+ if (await hasEntries(paths.root)) {
72
+ if (existsSync(path.join(paths.root, 'catence.duckdb')))
73
+ throw new Error(legacyStoreMessage(paths));
74
+ throw new Error(`Refusing to initialize ${paths.root}: choose an empty --home directory or remove its unrelated contents.`);
75
+ }
76
+ const id = athleteIdSchema.parse(athlete.id);
77
+ const record = { id, label: athlete.label.trim(), createdAt: new Date().toISOString() };
78
+ if (!record.label)
79
+ throw new Error('Athlete label is required.');
80
+ const catalog = { formatVersion: CATALOG_FORMAT_VERSION, defaultAthleteId: id, athletes: [record] };
81
+ await writeCatalog(paths, catalog);
82
+ return catalog;
83
+ }
84
+ export async function addAthlete(paths, athlete) {
85
+ const catalog = await loadCatalog(paths);
86
+ const id = athleteIdSchema.parse(athlete.id);
87
+ if (catalog.athletes.some((entry) => entry.id === id))
88
+ throw new Error(`Athlete ${id} already exists.`);
89
+ const label = athlete.label.trim();
90
+ if (!label)
91
+ throw new Error('Athlete label is required.');
92
+ const next = {
93
+ ...catalog,
94
+ defaultAthleteId: athlete.setDefault ? id : catalog.defaultAthleteId,
95
+ athletes: [...catalog.athletes, { id, label, createdAt: new Date().toISOString() }],
96
+ };
97
+ await writeCatalog(paths, next);
98
+ return next;
99
+ }
100
+ export async function resolveAthlete(paths, athleteId) {
101
+ const catalog = await loadCatalog(paths);
102
+ const athlete = catalog.athletes.find((entry) => entry.id === athleteId);
103
+ if (!athlete)
104
+ throw new Error(`Unknown athlete ${athleteId}. Call list_athletes to inspect the configured catalog.`);
105
+ return { athlete, paths: athleteStorePaths(paths, athlete.id) };
106
+ }
107
+ export async function defaultAthlete(paths = resolveCatalogPaths()) {
108
+ const catalog = await loadCatalog(paths);
109
+ return resolveAthlete(paths, catalog.defaultAthleteId);
110
+ }
111
+ //# sourceMappingURL=catalog.js.map
@@ -0,0 +1,71 @@
1
+ import { z } from 'zod';
2
+ import type { CatencePaths } from '../../contracts/runtime.js';
3
+ export type { CatencePaths } from '../../contracts/runtime.js';
4
+ export declare function resolvePaths(root?: string): CatencePaths;
5
+ export declare function ensurePaths(paths: CatencePaths): Promise<void>;
6
+ export declare function defaultFromDate(now?: Date): string;
7
+ export declare function requireIntervalsConfig(environment?: NodeJS.ProcessEnv): {
8
+ apiKey: string;
9
+ athleteId: string;
10
+ };
11
+ declare const rateLimitSchema: z.ZodObject<{
12
+ requests: z.ZodNumber;
13
+ windowSeconds: z.ZodNumber;
14
+ }, z.core.$strict>;
15
+ declare const catenceConfigSchema: z.ZodObject<{
16
+ mcp: z.ZodOptional<z.ZodObject<{
17
+ rateLimits: z.ZodOptional<z.ZodObject<{
18
+ server: z.ZodOptional<z.ZodNullable<z.ZodObject<{
19
+ requests: z.ZodNumber;
20
+ windowSeconds: z.ZodNumber;
21
+ }, z.core.$strict>>>;
22
+ tools: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodNullable<z.ZodObject<{
23
+ requests: z.ZodNumber;
24
+ windowSeconds: z.ZodNumber;
25
+ }, z.core.$strict>>>>;
26
+ resources: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodNullable<z.ZodObject<{
27
+ requests: z.ZodNumber;
28
+ windowSeconds: z.ZodNumber;
29
+ }, z.core.$strict>>>>;
30
+ }, z.core.$strict>>;
31
+ }, z.core.$strict>>;
32
+ providers: z.ZodOptional<z.ZodObject<{
33
+ strava: z.ZodOptional<z.ZodObject<{
34
+ budget: z.ZodOptional<z.ZodObject<{
35
+ maxConcurrentRequests: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
36
+ readRequestsPer15Minutes: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
37
+ readRequestsPerDay: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
38
+ }, z.core.$strict>>;
39
+ }, z.core.$strict>>;
40
+ }, z.core.$strict>>;
41
+ console: z.ZodOptional<z.ZodObject<{
42
+ defaultProfile: z.ZodOptional<z.ZodString>;
43
+ limits: z.ZodOptional<z.ZodObject<{
44
+ toolRounds: z.ZodOptional<z.ZodNumber>;
45
+ toolResultCharacters: z.ZodOptional<z.ZodNumber>;
46
+ }, z.core.$strict>>;
47
+ profiles: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
48
+ label: z.ZodOptional<z.ZodString>;
49
+ model: z.ZodOptional<z.ZodString>;
50
+ models: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
51
+ label: z.ZodOptional<z.ZodString>;
52
+ model: z.ZodString;
53
+ }, z.core.$strict>>>;
54
+ defaultModel: z.ZodOptional<z.ZodString>;
55
+ defaultReasoningEffort: z.ZodOptional<z.ZodEnum<{
56
+ minimal: "minimal";
57
+ low: "low";
58
+ medium: "medium";
59
+ high: "high";
60
+ xhigh: "xhigh";
61
+ }>>;
62
+ apiKeyEnv: z.ZodOptional<z.ZodString>;
63
+ apiBaseEnv: z.ZodOptional<z.ZodString>;
64
+ apiVersionEnv: z.ZodOptional<z.ZodString>;
65
+ }, z.core.$strict>>>;
66
+ }, z.core.$strict>>;
67
+ }, z.core.$strict>;
68
+ export type CatenceConfig = z.infer<typeof catenceConfigSchema>;
69
+ export type ConfigRateLimit = z.infer<typeof rateLimitSchema>;
70
+ export declare function loadCatenceConfig(paths: CatencePaths): Promise<CatenceConfig>;
71
+ export declare function configuredMcpRateLimit(config: CatenceConfig, type: 'tools' | 'resources', name: string): ConfigRateLimit | null;
@@ -0,0 +1,106 @@
1
+ import { config as loadDotenv } from 'dotenv';
2
+ import { mkdir, readFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { z } from 'zod';
5
+ loadDotenv({ quiet: true });
6
+ export function resolvePaths(root = process.env.CATENCE_DATA_DIR ?? '.catence') {
7
+ const absoluteRoot = path.resolve(root);
8
+ return {
9
+ root: absoluteRoot,
10
+ database: path.join(absoluteRoot, 'catence.duckdb'),
11
+ raw: path.join(absoluteRoot, 'raw'),
12
+ lake: path.join(absoluteRoot, 'lake', 'activity_samples'),
13
+ staging: path.join(absoluteRoot, 'staging'),
14
+ config: path.join(absoluteRoot, 'config.json'),
15
+ secrets: path.join(absoluteRoot, 'secrets'),
16
+ lock: path.join(absoluteRoot, '.catence-write.lock'),
17
+ };
18
+ }
19
+ export async function ensurePaths(paths) {
20
+ await Promise.all([mkdir(paths.raw, { recursive: true }), mkdir(paths.lake, { recursive: true }), mkdir(paths.staging, { recursive: true }), mkdir(paths.secrets, { recursive: true, mode: 0o700 })]);
21
+ }
22
+ export function defaultFromDate(now = new Date()) {
23
+ const date = new Date(Date.UTC(now.getUTCFullYear() - 1, now.getUTCMonth(), now.getUTCDate()));
24
+ return date.toISOString().slice(0, 10);
25
+ }
26
+ export function requireIntervalsConfig(environment = process.env) {
27
+ const apiKey = environment.INTERVALS_API_KEY;
28
+ if (!apiKey)
29
+ throw new Error('INTERVALS_API_KEY is required for an Intervals sync.');
30
+ return { apiKey, athleteId: environment.INTERVALS_ATHLETE_ID ?? '0' };
31
+ }
32
+ const rateLimitSchema = z.object({ requests: z.number().int().positive(), windowSeconds: z.number().int().positive() }).strict();
33
+ const nullableRateLimitSchema = rateLimitSchema.nullable();
34
+ const environmentVariableSchema = z.string().regex(/^[A-Z][A-Z0-9_]*$/, 'must be an uppercase environment-variable name');
35
+ const consoleModelSchema = z.object({
36
+ label: z.string().min(1).optional(),
37
+ model: z.string().min(1),
38
+ }).strict();
39
+ const consoleProfileSchema = z.object({
40
+ label: z.string().min(1).optional(),
41
+ model: z.string().min(1).optional(),
42
+ models: z.record(z.string().min(1), consoleModelSchema).optional(),
43
+ defaultModel: z.string().min(1).optional(),
44
+ defaultReasoningEffort: z.enum(['minimal', 'low', 'medium', 'high', 'xhigh']).optional(),
45
+ apiKeyEnv: environmentVariableSchema.optional(),
46
+ apiBaseEnv: environmentVariableSchema.optional(),
47
+ apiVersionEnv: environmentVariableSchema.optional(),
48
+ }).strict().superRefine((profile, context) => {
49
+ if (Boolean(profile.model) === Boolean(profile.models)) {
50
+ context.addIssue({ code: 'custom', message: 'must define exactly one of model or models' });
51
+ }
52
+ if (profile.defaultModel && !profile.models) {
53
+ context.addIssue({ code: 'custom', path: ['defaultModel'], message: 'requires models' });
54
+ }
55
+ if (profile.models && profile.defaultModel && !Object.hasOwn(profile.models, profile.defaultModel)) {
56
+ context.addIssue({ code: 'custom', path: ['defaultModel'], message: 'must name one of models' });
57
+ }
58
+ });
59
+ const consoleLimitsSchema = z.object({
60
+ toolRounds: z.number().int().min(1).max(32).optional(),
61
+ toolResultCharacters: z.number().int().min(1_000).max(250_000).optional(),
62
+ }).strict();
63
+ const catenceConfigSchema = z.object({
64
+ mcp: z.object({
65
+ rateLimits: z.object({
66
+ server: nullableRateLimitSchema.optional(),
67
+ tools: z.record(z.string(), nullableRateLimitSchema).optional(),
68
+ resources: z.record(z.string(), nullableRateLimitSchema).optional(),
69
+ }).strict().optional(),
70
+ }).strict().optional(),
71
+ providers: z.object({
72
+ strava: z.object({
73
+ budget: z.object({
74
+ maxConcurrentRequests: z.number().int().positive().nullable().optional(),
75
+ readRequestsPer15Minutes: z.number().int().positive().nullable().optional(),
76
+ readRequestsPerDay: z.number().int().positive().nullable().optional(),
77
+ }).strict().optional(),
78
+ }).strict().optional(),
79
+ }).strict().optional(),
80
+ console: z.object({
81
+ defaultProfile: z.string().min(1).optional(),
82
+ limits: consoleLimitsSchema.optional(),
83
+ profiles: z.record(z.string().min(1), consoleProfileSchema).optional(),
84
+ }).strict().optional(),
85
+ }).strict();
86
+ export async function loadCatenceConfig(paths) {
87
+ const contents = await readFile(paths.config, 'utf8').catch((error) => {
88
+ if (error.code === 'ENOENT')
89
+ return '';
90
+ throw error;
91
+ });
92
+ if (!contents.trim())
93
+ return {};
94
+ try {
95
+ return catenceConfigSchema.parse(JSON.parse(contents));
96
+ }
97
+ catch (error) {
98
+ const message = error instanceof Error ? error.message : String(error);
99
+ throw new Error(`Invalid Catence config at ${paths.config}: ${message}`);
100
+ }
101
+ }
102
+ export function configuredMcpRateLimit(config, type, name) {
103
+ const scoped = config.mcp?.rateLimits?.[type];
104
+ return scoped?.[name] ?? scoped?.['*'] ?? config.mcp?.rateLimits?.server ?? null;
105
+ }
106
+ //# sourceMappingURL=configuration.js.map
@@ -0,0 +1,13 @@
1
+ export type RateLimit = {
2
+ requests: number;
3
+ windowSeconds: number;
4
+ };
5
+ export declare class SlidingWindowLimiter {
6
+ private readonly entries;
7
+ check(key: string, limit: RateLimit | null, now?: number): {
8
+ allowed: true;
9
+ } | {
10
+ allowed: false;
11
+ retryAfterSeconds: number;
12
+ };
13
+ }
@@ -0,0 +1,17 @@
1
+ export class SlidingWindowLimiter {
2
+ entries = new Map();
3
+ check(key, limit, now = Date.now()) {
4
+ if (!limit)
5
+ return { allowed: true };
6
+ const windowMs = limit.windowSeconds * 1_000;
7
+ const recent = (this.entries.get(key) ?? []).filter((entry) => entry > now - windowMs);
8
+ if (recent.length >= limit.requests) {
9
+ this.entries.set(key, recent);
10
+ return { allowed: false, retryAfterSeconds: Math.max(1, Math.ceil((recent[0] + windowMs - now) / 1_000)) };
11
+ }
12
+ recent.push(now);
13
+ this.entries.set(key, recent);
14
+ return { allowed: true };
15
+ }
16
+ }
17
+ //# sourceMappingURL=limiter.js.map
@@ -0,0 +1,24 @@
1
+ import { z } from 'zod';
2
+ import type { CatencePaths } from '../../contracts/runtime.js';
3
+ declare const secretSchema: z.ZodObject<{
4
+ garmin: z.ZodOptional<z.ZodObject<{
5
+ email: z.ZodOptional<z.ZodString>;
6
+ password: z.ZodOptional<z.ZodString>;
7
+ }, z.core.$strict>>;
8
+ intervals: z.ZodOptional<z.ZodObject<{
9
+ apiKey: z.ZodOptional<z.ZodString>;
10
+ athleteId: z.ZodOptional<z.ZodString>;
11
+ }, z.core.$strict>>;
12
+ strava: z.ZodOptional<z.ZodObject<{
13
+ clientId: z.ZodOptional<z.ZodString>;
14
+ clientSecret: z.ZodOptional<z.ZodString>;
15
+ }, z.core.$strict>>;
16
+ }, z.core.$strict>;
17
+ export type AthleteSecrets = z.infer<typeof secretSchema>;
18
+ export type SecretProvider = keyof AthleteSecrets;
19
+ export declare function providerSecretPath(paths: CatencePaths): string;
20
+ export declare function readAthleteSecrets(paths: CatencePaths): Promise<AthleteSecrets>;
21
+ export declare function setAthleteSecret(paths: CatencePaths, provider: SecretProvider, field: string, value: string): Promise<void>;
22
+ /** Build only the provider environment needed by a selected athlete worker. */
23
+ export declare function athleteProviderEnvironment(paths: CatencePaths, base?: NodeJS.ProcessEnv): Promise<NodeJS.ProcessEnv>;
24
+ export {};
@@ -0,0 +1,68 @@
1
+ import { chmod } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { z } from 'zod';
4
+ import { ensurePaths } from './configuration.js';
5
+ const secretSchema = z.object({
6
+ garmin: z.object({ email: z.string().min(1).optional(), password: z.string().min(1).optional() }).strict().optional(),
7
+ intervals: z.object({ apiKey: z.string().min(1).optional(), athleteId: z.string().min(1).optional() }).strict().optional(),
8
+ strava: z.object({ clientId: z.string().min(1).optional(), clientSecret: z.string().min(1).optional() }).strict().optional(),
9
+ }).strict();
10
+ const secretFields = {
11
+ garmin: ['email', 'password'],
12
+ intervals: ['apiKey', 'athleteId'],
13
+ strava: ['clientId', 'clientSecret'],
14
+ };
15
+ export function providerSecretPath(paths) {
16
+ return path.join(paths.secrets, 'providers.json');
17
+ }
18
+ export async function readAthleteSecrets(paths) {
19
+ const { readFile } = await import('node:fs/promises');
20
+ const contents = await readFile(providerSecretPath(paths), 'utf8').catch((error) => error.code === 'ENOENT' ? '{}' : Promise.reject(error));
21
+ try {
22
+ return secretSchema.parse(JSON.parse(contents));
23
+ }
24
+ catch (error) {
25
+ const message = error instanceof Error ? error.message : String(error);
26
+ throw new Error(`Invalid provider secrets at ${providerSecretPath(paths)}: ${message}`);
27
+ }
28
+ }
29
+ export async function setAthleteSecret(paths, provider, field, value) {
30
+ if (!secretFields[provider].includes(field))
31
+ throw new Error(`${field} is not a supported ${provider} secret field.`);
32
+ if (!value.trim())
33
+ throw new Error('Secret values cannot be empty.');
34
+ await ensurePaths(paths);
35
+ const secrets = await readAthleteSecrets(paths);
36
+ const next = { ...secrets, [provider]: { ...(secrets[provider] ?? {}), [field]: value } };
37
+ const { writeFile, rename } = await import('node:fs/promises');
38
+ const destination = providerSecretPath(paths);
39
+ const temporary = `${destination}.tmp`;
40
+ await writeFile(temporary, `${JSON.stringify(next, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
41
+ await chmod(temporary, 0o600);
42
+ await rename(temporary, destination);
43
+ await chmod(destination, 0o600);
44
+ }
45
+ /** Build only the provider environment needed by a selected athlete worker. */
46
+ export async function athleteProviderEnvironment(paths, base = process.env) {
47
+ const secrets = await readAthleteSecrets(paths);
48
+ const environment = { ...base };
49
+ // Never let credentials inherited by the shared process leak into an
50
+ // athlete store that has not explicitly configured that provider.
51
+ for (const name of ['GARMIN_EMAIL', 'GARMIN_PASSWORD', 'INTERVALS_API_KEY', 'INTERVALS_ATHLETE_ID', 'STRAVA_CLIENT_ID', 'STRAVA_CLIENT_SECRET']) {
52
+ delete environment[name];
53
+ }
54
+ if (secrets.garmin?.email)
55
+ environment.GARMIN_EMAIL = secrets.garmin.email;
56
+ if (secrets.garmin?.password)
57
+ environment.GARMIN_PASSWORD = secrets.garmin.password;
58
+ if (secrets.intervals?.apiKey)
59
+ environment.INTERVALS_API_KEY = secrets.intervals.apiKey;
60
+ if (secrets.intervals?.athleteId)
61
+ environment.INTERVALS_ATHLETE_ID = secrets.intervals.athleteId;
62
+ if (secrets.strava?.clientId)
63
+ environment.STRAVA_CLIENT_ID = secrets.strava.clientId;
64
+ if (secrets.strava?.clientSecret)
65
+ environment.STRAVA_CLIENT_SECRET = secrets.strava.clientSecret;
66
+ return environment;
67
+ }
68
+ //# sourceMappingURL=secrets.js.map
@@ -0,0 +1,23 @@
1
+ import type { CatencePaths } from '../../contracts/runtime.js';
2
+ export type DemoStoreMetadata = {
3
+ formatVersion: number;
4
+ generated: true;
5
+ seed: number;
6
+ days: number;
7
+ startDate: string;
8
+ endDate: string;
9
+ createdAt: string;
10
+ };
11
+ export type CreateDemoStoreOptions = {
12
+ /** Keep the demo small enough for fast evaluation while still supporting trends. */
13
+ days?: number;
14
+ seed?: number;
15
+ /** Primarily useful for deterministic tests and documentation screenshots. */
16
+ endDate?: string;
17
+ };
18
+ export declare function demoStoreMetadata(paths: CatencePaths): Promise<DemoStoreMetadata | null>;
19
+ /**
20
+ * Create generated data only in a fresh directory or one already marked as a
21
+ * Catence demo. A real data directory is never opened for mutation here.
22
+ */
23
+ export declare function createDemoStore(paths: CatencePaths, options?: CreateDemoStoreOptions): Promise<Record<string, unknown>>;
@@ -0,0 +1,165 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync } from 'node:fs';
3
+ import { readdir, readFile, writeFile } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { ensurePaths } from '../../core/runtime/configuration.js';
6
+ import { importRecord } from '../ingestion/importer.js';
7
+ import { CatenceDatabase } from '../storage/database.js';
8
+ const DEMO_MARKER = 'demo-store.json';
9
+ const DEMO_FORMAT_VERSION = 1;
10
+ function markerPath(paths) {
11
+ return path.join(paths.root, DEMO_MARKER);
12
+ }
13
+ function hash(value) {
14
+ return createHash('sha256').update(value).digest('hex');
15
+ }
16
+ function isoDate(date) {
17
+ return date.toISOString().slice(0, 10);
18
+ }
19
+ function addDays(date, days) {
20
+ return isoDate(new Date(Date.parse(`${date}T00:00:00.000Z`) + days * 86_400_000));
21
+ }
22
+ function seededRandom(seed) {
23
+ let state = seed >>> 0;
24
+ return () => {
25
+ state += 0x6D2B79F5;
26
+ let value = state;
27
+ value = Math.imul(value ^ (value >>> 15), value | 1);
28
+ value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
29
+ return ((value ^ (value >>> 14)) >>> 0) / 4_294_967_296;
30
+ };
31
+ }
32
+ function rounded(value, decimals = 0) {
33
+ const multiplier = 10 ** decimals;
34
+ return Math.round(value * multiplier) / multiplier;
35
+ }
36
+ function validDate(date) {
37
+ return /^\d{4}-\d{2}-\d{2}$/.test(date) && !Number.isNaN(Date.parse(`${date}T00:00:00.000Z`));
38
+ }
39
+ async function hasDirectoryContents(directory) {
40
+ const entries = await readdir(directory).catch((error) => error.code === 'ENOENT' ? [] : Promise.reject(error));
41
+ return entries.length > 0;
42
+ }
43
+ export async function demoStoreMetadata(paths) {
44
+ const contents = await readFile(markerPath(paths), 'utf8').catch((error) => error.code === 'ENOENT' ? null : Promise.reject(error));
45
+ if (!contents)
46
+ return null;
47
+ try {
48
+ const value = JSON.parse(contents);
49
+ if (value.formatVersion !== DEMO_FORMAT_VERSION || value.generated !== true || !Number.isInteger(value.seed) || !Number.isInteger(value.days)
50
+ || typeof value.startDate !== 'string' || typeof value.endDate !== 'string' || typeof value.createdAt !== 'string')
51
+ return null;
52
+ return value;
53
+ }
54
+ catch {
55
+ return null;
56
+ }
57
+ }
58
+ /**
59
+ * Create generated data only in a fresh directory or one already marked as a
60
+ * Catence demo. A real data directory is never opened for mutation here.
61
+ */
62
+ export async function createDemoStore(paths, options = {}) {
63
+ const days = options.days ?? 90;
64
+ const seed = options.seed ?? 17;
65
+ const endDate = options.endDate ?? isoDate(new Date());
66
+ if (!Number.isInteger(days) || days < 14 || days > 365)
67
+ throw new Error('Demo days must be an integer between 14 and 365.');
68
+ if (!Number.isInteger(seed) || seed < 0 || seed > 2_147_483_647)
69
+ throw new Error('Demo seed must be an integer between 0 and 2147483647.');
70
+ if (!validDate(endDate))
71
+ throw new Error('Demo endDate must use YYYY-MM-DD.');
72
+ const existing = await demoStoreMetadata(paths);
73
+ if (!existing && await hasDirectoryContents(paths.root)) {
74
+ throw new Error(`Refusing to overwrite ${paths.root}: it is not a Catence demo store. Choose an empty --data-dir.`);
75
+ }
76
+ if (existsSync(paths.database) && existing) {
77
+ return {
78
+ created: false,
79
+ dataDir: paths.root,
80
+ demoStore: existing,
81
+ message: 'Existing generated Catence demo store is ready. Its values are synthetic, not personal measurements.',
82
+ };
83
+ }
84
+ const startDate = addDays(endDate, -(days - 1));
85
+ const random = seededRandom(seed);
86
+ await ensurePaths(paths);
87
+ const database = await CatenceDatabase.open(paths);
88
+ try {
89
+ const runId = await database.beginRun('garmin', startDate);
90
+ let previousTrainingLoad = 0;
91
+ for (let offset = 0; offset < days; offset++) {
92
+ const date = addDays(startDate, offset);
93
+ const weeklyLoad = [0, 26, 58, 76, 18, 54, 118][offset % 7];
94
+ const illness = offset >= Math.floor(days * 0.48) && offset < Math.floor(days * 0.48) + 6;
95
+ const recovery = Math.sin(offset / 5) * 0.7 + (random() - 0.5) * 0.45 - (illness ? 1.8 : 0);
96
+ const trainingLoad = Math.max(0, weeklyLoad + (random() - 0.5) * 12 - (illness ? 28 : 0));
97
+ const restingHeartRate = rounded(52 - recovery * 3.8 + previousTrainingLoad * 0.045 + (random() - 0.5) * 1.3);
98
+ const hrv = rounded(53 + recovery * 8.5 - previousTrainingLoad * 0.025 + (random() - 0.5) * 2.5);
99
+ const sleepScore = rounded(76 + recovery * 9 - previousTrainingLoad * 0.025 + (random() - 0.5) * 4);
100
+ const stress = rounded(34 - recovery * 8 + previousTrainingLoad * 0.04 + (random() - 0.5) * 4);
101
+ const bodyBattery = rounded(59 + recovery * 17 - previousTrainingLoad * 0.04 + (random() - 0.5) * 5);
102
+ const sleepIsMissing = offset % 23 === 8 || offset % 31 === 12;
103
+ const wellnessPayload = {
104
+ calendarDate: date,
105
+ restingHeartRate,
106
+ hrvSDNN: hrv,
107
+ sleepTimeSeconds: rounded(7.25 * 3_600 + recovery * 450 + (random() - 0.5) * 420),
108
+ averageStressLevel: stress,
109
+ bodyBattery,
110
+ trainingReadinessScore: rounded(62 + recovery * 16 - previousTrainingLoad * 0.04),
111
+ averageSpO2: rounded(96.4 + recovery * 0.25 + (random() - 0.5) * 0.3, 1),
112
+ totalSteps: rounded(8_300 + (random() - 0.5) * 3_000),
113
+ };
114
+ if (!sleepIsMissing)
115
+ wellnessPayload.sleepScore = sleepScore;
116
+ await importRecord(database, runId, {
117
+ kind: 'source_entity', schemaVersion: 1, provider: 'garmin', entityType: 'daily_health', remoteId: `demo-wellness:${date}`,
118
+ parentRemoteId: null, occurredOn: date, sourceUpdatedAt: null, rawObjectHash: hash(`demo-wellness:${seed}:${date}`), payload: wellnessPayload, extension: { generated: true },
119
+ });
120
+ if (trainingLoad >= 12) {
121
+ await importRecord(database, runId, {
122
+ kind: 'source_entity', schemaVersion: 1, provider: 'garmin', entityType: 'activity', remoteId: `demo-activity:${date}`,
123
+ parentRemoteId: null, occurredOn: date, sourceUpdatedAt: null, rawObjectHash: hash(`demo-activity:${seed}:${date}`), extension: { generated: true },
124
+ payload: {
125
+ activityId: `demo-activity:${date}`,
126
+ startTimeGMT: `${date}T10:00:00Z`,
127
+ activityType: offset % 3 === 0 ? 'running' : 'road_biking',
128
+ activityName: offset % 3 === 0 ? 'Generated endurance run' : 'Generated endurance ride',
129
+ distance: rounded(5_000 + trainingLoad * 360),
130
+ duration: rounded(1_800 + trainingLoad * 18),
131
+ activityTrainingLoad: rounded(trainingLoad, 1),
132
+ averageHR: rounded(132 + trainingLoad * 0.12),
133
+ },
134
+ });
135
+ }
136
+ previousTrainingLoad = trainingLoad;
137
+ }
138
+ await database.finishRun(runId);
139
+ }
140
+ finally {
141
+ await database.close();
142
+ }
143
+ const metadata = {
144
+ formatVersion: DEMO_FORMAT_VERSION,
145
+ generated: true,
146
+ seed,
147
+ days,
148
+ startDate,
149
+ endDate,
150
+ createdAt: new Date().toISOString(),
151
+ };
152
+ await writeFile(markerPath(paths), `${JSON.stringify(metadata, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
153
+ return {
154
+ created: true,
155
+ dataDir: paths.root,
156
+ demoStore: metadata,
157
+ message: 'Generated Catence demo store is ready. Its values are synthetic, not personal measurements.',
158
+ exampleQuestions: [
159
+ 'Use wellness_correlate with metricA training_load, metricB resting_hr_bpm, and scanLags true.',
160
+ 'Use wellness_anomalies to find the deliberately generated multi-metric recovery disruption.',
161
+ 'Use wellness_coverage to inspect intentionally missing sleep-score dates.',
162
+ ],
163
+ };
164
+ }
165
+ //# sourceMappingURL=demo.js.map
@@ -0,0 +1,25 @@
1
+ import type { CatencePaths } from '../../contracts/runtime.js';
2
+ type SyncProviderChoice = 'intervals' | 'garmin';
3
+ export type ProviderChoice = SyncProviderChoice | 'strava' | 'all';
4
+ export declare function initializeDataStore(paths: CatencePaths): Promise<Record<string, unknown>>;
5
+ export declare function syncData(paths: CatencePaths, provider: ProviderChoice, explicitFrom?: string, advanceCursor?: boolean, refreshActivities?: boolean, backfill?: boolean): Promise<Record<string, unknown>>;
6
+ export declare function retryDataSync(paths: CatencePaths, previousRunId: string): Promise<Record<string, unknown>>;
7
+ export declare function connectStrava(paths: CatencePaths, code: string | undefined, redirectUri: string): Promise<unknown>;
8
+ /** Validate a local OAuth return address before opening a callback listener. */
9
+ export declare function loopbackStravaRedirect(redirectUri: string): URL;
10
+ /**
11
+ * Complete Strava OAuth through a short-lived loopback callback listener.
12
+ *
13
+ * The authorization URL is emitted through the caller so the CLI can keep
14
+ * stdout machine-readable for the final connection result.
15
+ */
16
+ export declare function connectStravaWithCallback(paths: CatencePaths, redirectUri: string, onAuthorizationUrl: (url: string) => void, timeoutMs?: number): Promise<unknown>;
17
+ export declare function disconnectStravaAccount(paths: CatencePaths): Promise<{
18
+ provider: 'strava';
19
+ disconnected: true;
20
+ }>;
21
+ export declare function linkActivity(paths: CatencePaths, source: string, activity: string): Promise<Record<string, string>>;
22
+ export declare function unlinkActivity(paths: CatencePaths, source: string): Promise<Record<string, string>>;
23
+ export declare function dataStatus(paths: CatencePaths): Promise<Record<string, unknown>>;
24
+ export declare function rebuildRetrievalIndex(paths: CatencePaths): Promise<Record<string, unknown>>;
25
+ export {};