catence 0.0.0 → 0.1.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.
Files changed (106) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +373 -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/configuration.d.ts +71 -0
  37. package/dist/core/runtime/configuration.js +106 -0
  38. package/dist/core/runtime/limiter.d.ts +13 -0
  39. package/dist/core/runtime/limiter.js +17 -0
  40. package/dist/elt/application/demo.d.ts +23 -0
  41. package/dist/elt/application/demo.js +165 -0
  42. package/dist/elt/application/management.d.ts +25 -0
  43. package/dist/elt/application/management.js +283 -0
  44. package/dist/elt/ingestion/importer.d.ts +8 -0
  45. package/dist/elt/ingestion/importer.js +87 -0
  46. package/dist/elt/ingestion/providers/intervals.d.ts +21 -0
  47. package/dist/elt/ingestion/providers/intervals.js +224 -0
  48. package/dist/elt/ingestion/providers/registry.d.ts +19 -0
  49. package/dist/elt/ingestion/providers/registry.js +62 -0
  50. package/dist/elt/ingestion/providers/strava/service.d.ts +42 -0
  51. package/dist/elt/ingestion/providers/strava/service.js +365 -0
  52. package/dist/elt/normalization/activities/linking.d.ts +5 -0
  53. package/dist/elt/normalization/activities/linking.js +165 -0
  54. package/dist/elt/normalization/normalizers.d.ts +5 -0
  55. package/dist/elt/normalization/normalizers.js +816 -0
  56. package/dist/elt/normalization/segments/strava-normalizer.d.ts +3 -0
  57. package/dist/elt/normalization/segments/strava-normalizer.js +93 -0
  58. package/dist/elt/storage/artifacts.d.ts +13 -0
  59. package/dist/elt/storage/artifacts.js +48 -0
  60. package/dist/elt/storage/database.d.ts +74 -0
  61. package/dist/elt/storage/database.js +296 -0
  62. package/dist/elt/storage/migrations.d.ts +3 -0
  63. package/dist/elt/storage/migrations.js +832 -0
  64. package/dist/elt/storage/migrations.legacy.d.ts +12 -0
  65. package/dist/elt/storage/migrations.legacy.js +332 -0
  66. package/dist/elt/storage/sql.d.ts +2 -0
  67. package/dist/elt/storage/sql.js +7 -0
  68. package/dist/elt/storage/write-lock.d.ts +6 -0
  69. package/dist/elt/storage/write-lock.js +28 -0
  70. package/dist/elt/streams.d.ts +24 -0
  71. package/dist/elt/streams.js +62 -0
  72. package/dist/interfaces/cli/commands.d.ts +2 -0
  73. package/dist/interfaces/cli/commands.js +101 -0
  74. package/dist/interfaces/cli/main.d.ts +2 -0
  75. package/dist/interfaces/cli/main.js +3 -0
  76. package/dist/interfaces/http/cli-options.d.ts +9 -0
  77. package/dist/interfaces/http/cli-options.js +81 -0
  78. package/dist/interfaces/http/server.d.ts +14 -0
  79. package/dist/interfaces/http/server.js +169 -0
  80. package/dist/interfaces/mcp/cli-options.d.ts +6 -0
  81. package/dist/interfaces/mcp/cli-options.js +36 -0
  82. package/dist/interfaces/mcp/main.d.ts +2 -0
  83. package/dist/interfaces/mcp/main.js +59 -0
  84. package/dist/interfaces/mcp/server.d.ts +8 -0
  85. package/dist/interfaces/mcp/server.js +521 -0
  86. package/dist/runtime/index.d.ts +29 -0
  87. package/dist/runtime/index.js +19 -0
  88. package/docs/architecture.md +100 -0
  89. package/docs/assets/image.png +0 -0
  90. package/docs/mcp-activity-retrieval.md +377 -0
  91. package/package.json +81 -6
  92. package/pyproject.toml +28 -0
  93. package/python/__init__.py +1 -0
  94. package/python/catence/__init__.py +0 -0
  95. package/python/catence/providers/__init__.py +0 -0
  96. package/python/catence/providers/garmin/__init__.py +3 -0
  97. package/python/catence/providers/garmin/cli.py +62 -0
  98. package/python/catence/providers/garmin/contracts.py +23 -0
  99. package/python/catence/providers/garmin/registry.py +48 -0
  100. package/python/catence/providers/garmin/staging.py +87 -0
  101. package/python/catence/providers/garmin/streams.py +195 -0
  102. package/python/catence/providers/garmin/worker.py +450 -0
  103. package/python/catence/providers/strava/__init__.py +3 -0
  104. package/python/catence/providers/strava/cli.py +404 -0
  105. package/python/catence/providers/strava/staging.py +73 -0
  106. package/uv.lock +708 -0
@@ -0,0 +1,81 @@
1
+ export const SERVE_USAGE = `Usage: catence serve [options]
2
+
3
+ Run Catence as a local Streamable HTTP MCP server.
4
+
5
+ Options:
6
+ --data-dir <directory> Catence data directory. Overrides CATENCE_DATA_DIR.
7
+ --host <host> Listener host. Defaults to 127.0.0.1.
8
+ --port <port> Listener port. Defaults to 8787.
9
+ --allow-origin <origin> Browser origin allowed to call Catence APIs. May be repeated.
10
+ -h, --help Show this help text.`;
11
+ function requiredValue(arguments_, index, option) {
12
+ const value = arguments_[index + 1];
13
+ if (!value || value.startsWith('-'))
14
+ throw new Error(`${option} requires a value.`);
15
+ return value;
16
+ }
17
+ function parsePort(value) {
18
+ if (!/^\d+$/.test(value))
19
+ throw new Error('--port must be an integer between 1 and 65535.');
20
+ const port = Number(value);
21
+ if (port < 1 || port > 65_535)
22
+ throw new Error('--port must be an integer between 1 and 65535.');
23
+ return port;
24
+ }
25
+ export function parseServeCliOptions(arguments_) {
26
+ let dataDir;
27
+ let host = process.env.CATENCE_HTTP_HOST ?? '127.0.0.1';
28
+ let port = process.env.CATENCE_HTTP_PORT ? parsePort(process.env.CATENCE_HTTP_PORT) : 8787;
29
+ const allowedOrigins = [];
30
+ for (let index = 0; index < arguments_.length; index++) {
31
+ const argument = arguments_[index];
32
+ if (argument === '-h' || argument === '--help')
33
+ return { dataDir, host, port, allowedOrigins, help: true };
34
+ if (argument === '--data-dir') {
35
+ dataDir = requiredValue(arguments_, index, '--data-dir');
36
+ index++;
37
+ continue;
38
+ }
39
+ if (argument.startsWith('--data-dir=')) {
40
+ dataDir = argument.slice('--data-dir='.length);
41
+ if (!dataDir)
42
+ throw new Error('--data-dir requires a value.');
43
+ continue;
44
+ }
45
+ if (argument === '--host') {
46
+ host = requiredValue(arguments_, index, '--host');
47
+ index++;
48
+ continue;
49
+ }
50
+ if (argument.startsWith('--host=')) {
51
+ host = argument.slice('--host='.length);
52
+ if (!host)
53
+ throw new Error('--host requires a value.');
54
+ continue;
55
+ }
56
+ if (argument === '--port') {
57
+ port = parsePort(requiredValue(arguments_, index, '--port'));
58
+ index++;
59
+ continue;
60
+ }
61
+ if (argument.startsWith('--port=')) {
62
+ port = parsePort(argument.slice('--port='.length));
63
+ continue;
64
+ }
65
+ if (argument === '--allow-origin') {
66
+ allowedOrigins.push(requiredValue(arguments_, index, '--allow-origin'));
67
+ index++;
68
+ continue;
69
+ }
70
+ if (argument.startsWith('--allow-origin=')) {
71
+ const origin = argument.slice('--allow-origin='.length);
72
+ if (!origin)
73
+ throw new Error('--allow-origin requires a value.');
74
+ allowedOrigins.push(origin);
75
+ continue;
76
+ }
77
+ throw new Error(`Unknown option: ${argument}`);
78
+ }
79
+ return { dataDir, host, port, allowedOrigins, help: false };
80
+ }
81
+ //# sourceMappingURL=cli-options.js.map
@@ -0,0 +1,14 @@
1
+ import { type Server } from 'node:http';
2
+ import { type CatencePaths } from '../../runtime/index.js';
3
+ export type CatenceHttpServerOptions = {
4
+ paths?: CatencePaths;
5
+ allowedOrigins?: readonly string[];
6
+ };
7
+ /**
8
+ * Create Catence's local Streamable HTTP server.
9
+ *
10
+ * Each MCP request gets an independent stateless transport and server instance.
11
+ * This keeps the HTTP surface compatible with local clients without introducing
12
+ * process-wide MCP session state or a second query implementation.
13
+ */
14
+ export declare function createCatenceHttpServer(options?: CatenceHttpServerOptions): Server;
@@ -0,0 +1,169 @@
1
+ import { createServer } from 'node:http';
2
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
3
+ import { catenceRuntimeHealth, DashboardSnapshotService, jsonSafe, openReadOnlyRepository, resolvePaths } from '../../runtime/index.js';
4
+ import { createCatenceMcpServer } from '../mcp/server.js';
5
+ const MAX_REQUEST_BYTES = 1_000_000;
6
+ function json(response, statusCode, value) {
7
+ const body = JSON.stringify(jsonSafe(value));
8
+ response.writeHead(statusCode, { 'content-type': 'application/json; charset=utf-8' });
9
+ response.end(body);
10
+ }
11
+ function addCorsHeaders(request, response, allowedOrigins) {
12
+ const origin = request.headers.origin;
13
+ if (!origin)
14
+ return true;
15
+ if (!allowedOrigins.includes(origin))
16
+ return false;
17
+ response.setHeader('access-control-allow-origin', origin);
18
+ response.setHeader('vary', 'Origin');
19
+ response.setHeader('access-control-allow-methods', 'GET, POST, DELETE, OPTIONS');
20
+ response.setHeader('access-control-allow-headers', 'content-type, mcp-session-id');
21
+ return true;
22
+ }
23
+ async function readJsonBody(request) {
24
+ let size = 0;
25
+ const chunks = [];
26
+ for await (const chunk of request) {
27
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
28
+ size += buffer.length;
29
+ if (size > MAX_REQUEST_BYTES)
30
+ throw new Error(`Request body exceeds the ${MAX_REQUEST_BYTES} byte limit.`);
31
+ chunks.push(buffer);
32
+ }
33
+ const body = Buffer.concat(chunks).toString('utf8').trim();
34
+ if (!body)
35
+ return undefined;
36
+ try {
37
+ return JSON.parse(body);
38
+ }
39
+ catch {
40
+ throw new Error('Request body must be valid JSON.');
41
+ }
42
+ }
43
+ function methodNotAllowed(response) {
44
+ json(response, 405, {
45
+ jsonrpc: '2.0',
46
+ error: { code: -32000, message: 'Method not allowed.' },
47
+ id: null,
48
+ });
49
+ }
50
+ function parseDate(value, name) {
51
+ if (value === null)
52
+ return undefined;
53
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value) || Number.isNaN(Date.parse(`${value}T00:00:00.000Z`))) {
54
+ throw new Error(`${name} must use YYYY-MM-DD.`);
55
+ }
56
+ return value;
57
+ }
58
+ function dashboardOptions(url) {
59
+ const endDate = parseDate(url.searchParams.get('endDate'), 'endDate') ?? new Date().toISOString().slice(0, 10);
60
+ const daysValue = url.searchParams.get('days') ?? '28';
61
+ if (!/^\d+$/.test(daysValue))
62
+ throw new Error('days must be an integer between 1 and 90.');
63
+ const days = Number(daysValue);
64
+ if (days < 1 || days > 90)
65
+ throw new Error('days must be an integer between 1 and 90.');
66
+ return { endDate, days };
67
+ }
68
+ async function dashboardSnapshot(paths, url) {
69
+ const repository = await openReadOnlyRepository(paths);
70
+ try {
71
+ return await new DashboardSnapshotService(repository).snapshot(dashboardOptions(url));
72
+ }
73
+ finally {
74
+ await repository.close();
75
+ }
76
+ }
77
+ async function handleMcpRequest(request, response, paths) {
78
+ if (request.method !== 'POST') {
79
+ methodNotAllowed(response);
80
+ return;
81
+ }
82
+ let body;
83
+ try {
84
+ body = await readJsonBody(request);
85
+ }
86
+ catch (error) {
87
+ json(response, 400, {
88
+ jsonrpc: '2.0',
89
+ error: { code: -32700, message: error instanceof Error ? error.message : String(error) },
90
+ id: null,
91
+ });
92
+ return;
93
+ }
94
+ const server = createCatenceMcpServer(paths);
95
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
96
+ let cleanedUp = false;
97
+ const cleanup = async () => {
98
+ if (cleanedUp)
99
+ return;
100
+ cleanedUp = true;
101
+ await Promise.allSettled([transport.close(), server.close()]);
102
+ };
103
+ response.once('close', () => { void cleanup(); });
104
+ try {
105
+ await server.connect(transport);
106
+ await transport.handleRequest(request, response, body);
107
+ }
108
+ catch (error) {
109
+ if (!response.headersSent) {
110
+ json(response, 500, {
111
+ jsonrpc: '2.0',
112
+ error: { code: -32603, message: error instanceof Error ? error.message : 'Internal server error.' },
113
+ id: null,
114
+ });
115
+ }
116
+ }
117
+ finally {
118
+ // The response may already have ended before handleRequest resolves, in
119
+ // which case adding a close listener here would miss cleanup entirely.
120
+ if (response.writableEnded || response.destroyed)
121
+ void cleanup();
122
+ }
123
+ }
124
+ /**
125
+ * Create Catence's local Streamable HTTP server.
126
+ *
127
+ * Each MCP request gets an independent stateless transport and server instance.
128
+ * This keeps the HTTP surface compatible with local clients without introducing
129
+ * process-wide MCP session state or a second query implementation.
130
+ */
131
+ export function createCatenceHttpServer(options = {}) {
132
+ const paths = options.paths ?? resolvePaths();
133
+ const allowedOrigins = options.allowedOrigins ?? [];
134
+ return createServer((request, response) => {
135
+ void (async () => {
136
+ if (!addCorsHeaders(request, response, allowedOrigins)) {
137
+ json(response, 403, { error: { code: 'origin_not_allowed', message: 'This browser origin is not allowed to access Catence.' } });
138
+ return;
139
+ }
140
+ const pathname = new URL(request.url ?? '/', 'http://localhost').pathname;
141
+ if (request.method === 'OPTIONS') {
142
+ response.writeHead(204).end();
143
+ return;
144
+ }
145
+ if (pathname === '/health' && request.method === 'GET') {
146
+ json(response, 200, catenceRuntimeHealth());
147
+ return;
148
+ }
149
+ if (pathname === '/api/v1/dashboard' && request.method === 'GET') {
150
+ try {
151
+ json(response, 200, await dashboardSnapshot(paths, new URL(request.url ?? '/', 'http://localhost')));
152
+ }
153
+ catch (error) {
154
+ json(response, 400, { error: { code: 'dashboard_unavailable', message: error instanceof Error ? error.message : String(error) } });
155
+ }
156
+ return;
157
+ }
158
+ if (pathname === '/mcp') {
159
+ await handleMcpRequest(request, response, paths);
160
+ return;
161
+ }
162
+ json(response, 404, { error: { code: 'not_found', message: 'No Catence endpoint matches this request.' } });
163
+ })().catch((error) => {
164
+ if (!response.headersSent)
165
+ json(response, 500, { error: { code: 'internal_error', message: error instanceof Error ? error.message : String(error) } });
166
+ });
167
+ });
168
+ }
169
+ //# sourceMappingURL=server.js.map
@@ -0,0 +1,6 @@
1
+ export type McpCliOptions = {
2
+ dataDir?: string;
3
+ help: boolean;
4
+ };
5
+ export declare const MCP_USAGE = "Usage: catence [--data-dir <directory>]\n catence demo [--data-dir <directory>]\n\nRun Catence as a local, stdio-only MCP server. Ordinary data access is read-only; only declared Strava hydration tools write through the locked local store.\n\nThe demo command creates safe generated data in ./catence-demo by default, then starts the same MCP server. It refuses to overwrite a non-demo data directory.\n\nOptions:\n --data-dir <directory> Catence data directory. Overrides CATENCE_DATA_DIR.\n -h, --help Show this help text.";
6
+ export declare function parseMcpCliOptions(arguments_: readonly string[]): McpCliOptions;
@@ -0,0 +1,36 @@
1
+ export const MCP_USAGE = `Usage: catence [--data-dir <directory>]
2
+ catence demo [--data-dir <directory>]
3
+
4
+ Run Catence as a local, stdio-only MCP server. Ordinary data access is read-only; only declared Strava hydration tools write through the locked local store.
5
+
6
+ The demo command creates safe generated data in ./catence-demo by default, then starts the same MCP server. It refuses to overwrite a non-demo data directory.
7
+
8
+ Options:
9
+ --data-dir <directory> Catence data directory. Overrides CATENCE_DATA_DIR.
10
+ -h, --help Show this help text.`;
11
+ export function parseMcpCliOptions(arguments_) {
12
+ let dataDir;
13
+ for (let index = 0; index < arguments_.length; index++) {
14
+ const argument = arguments_[index];
15
+ if (argument === '-h' || argument === '--help')
16
+ return { dataDir, help: true };
17
+ if (argument === '--data-dir') {
18
+ const value = arguments_[index + 1];
19
+ if (!value || value.startsWith('-'))
20
+ throw new Error('--data-dir requires a directory.');
21
+ dataDir = value;
22
+ index++;
23
+ continue;
24
+ }
25
+ if (argument.startsWith('--data-dir=')) {
26
+ const value = argument.slice('--data-dir='.length);
27
+ if (!value)
28
+ throw new Error('--data-dir requires a directory.');
29
+ dataDir = value;
30
+ continue;
31
+ }
32
+ throw new Error(`Unknown option: ${argument}`);
33
+ }
34
+ return { dataDir, help: false };
35
+ }
36
+ //# sourceMappingURL=cli-options.js.map
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env node
2
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
+ import { createDemoStore, resolvePaths } from '../../runtime/index.js';
4
+ import { parseServeCliOptions, SERVE_USAGE } from '../http/cli-options.js';
5
+ import { createCatenceHttpServer } from '../http/server.js';
6
+ import { MCP_USAGE, parseMcpCliOptions } from './cli-options.js';
7
+ import { createCatenceMcpServer } from './server.js';
8
+ async function run() {
9
+ const arguments_ = process.argv.slice(2);
10
+ if (arguments_[0] === 'serve') {
11
+ const options = parseServeCliOptions(arguments_.slice(1));
12
+ if (options.help) {
13
+ process.stderr.write(`${SERVE_USAGE}\n`);
14
+ return;
15
+ }
16
+ const httpServer = createCatenceHttpServer({
17
+ paths: resolvePaths(options.dataDir),
18
+ allowedOrigins: options.allowedOrigins,
19
+ });
20
+ await new Promise((resolve, reject) => {
21
+ httpServer.once('error', reject);
22
+ httpServer.listen(options.port, options.host, () => {
23
+ httpServer.off('error', reject);
24
+ resolve();
25
+ });
26
+ });
27
+ console.error(`Catence Streamable HTTP server is listening at http://${options.host}:${options.port}/mcp`);
28
+ const close = () => {
29
+ httpServer.close(() => process.exit(0));
30
+ };
31
+ process.once('SIGINT', close);
32
+ process.once('SIGTERM', close);
33
+ return;
34
+ }
35
+ const demo = arguments_[0] === 'demo';
36
+ const options = parseMcpCliOptions(demo ? arguments_.slice(1) : arguments_);
37
+ if (options.help) {
38
+ process.stderr.write(`${MCP_USAGE}\n`);
39
+ return;
40
+ }
41
+ const dataDir = options.dataDir ?? (demo ? process.env.CATENCE_DATA_DIR ?? './catence-demo' : undefined);
42
+ const paths = resolvePaths(dataDir);
43
+ if (demo) {
44
+ const result = await createDemoStore(paths);
45
+ process.stderr.write(`${String(result.message)}\n`);
46
+ }
47
+ const server = createCatenceMcpServer(paths);
48
+ await server.connect(new StdioServerTransport());
49
+ console.error('Catence MCP server is running on stdio; ordinary tools are read-only and Strava hydration is lock-guarded.');
50
+ }
51
+ try {
52
+ await run();
53
+ }
54
+ catch (error) {
55
+ const message = error instanceof Error ? error.message : String(error);
56
+ process.stderr.write(`catence: ${message}\n\n${process.argv[2] === 'serve' ? SERVE_USAGE : MCP_USAGE}\n`);
57
+ process.exitCode = 2;
58
+ }
59
+ //# sourceMappingURL=main.js.map
@@ -0,0 +1,8 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { hydrateStravaActivity, type CatencePaths } from '../../runtime/index.js';
3
+ type StravaActivityHydrator = typeof hydrateStravaActivity;
4
+ type McpDependencies = {
5
+ hydrateStravaActivity?: StravaActivityHydrator;
6
+ };
7
+ export declare function createCatenceMcpServer(paths?: CatencePaths, dependencies?: McpDependencies): McpServer;
8
+ export {};