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,165 @@
1
+ import { json } from '../../storage/sql.js';
2
+ function sportFamily(value) {
3
+ if (!value)
4
+ return null;
5
+ const normalized = value.toLowerCase().replaceAll(/[^a-z]/g, '');
6
+ if (normalized.includes('ride') || normalized.includes('cycling') || normalized.includes('bik'))
7
+ return 'ride';
8
+ if (normalized.includes('run'))
9
+ return 'run';
10
+ if (normalized.includes('swim'))
11
+ return 'swim';
12
+ return normalized || null;
13
+ }
14
+ function isVirtualOrIndoor(value) {
15
+ return Boolean(value && /virtual|indoor/i.test(value));
16
+ }
17
+ function timestamp(value) {
18
+ if (!value)
19
+ return null;
20
+ const parsed = Date.parse(value instanceof Date ? value.toISOString() : value);
21
+ return Number.isFinite(parsed) ? parsed : null;
22
+ }
23
+ function qualifies(source, candidate) {
24
+ if (source.provider === candidate.provider || !source.started_at_utc || !candidate.started_at_utc)
25
+ return null;
26
+ if (!source.distance_m || !candidate.distance_m || !source.moving_s || !candidate.moving_s)
27
+ return null;
28
+ if (sportFamily(source.sport) !== sportFamily(candidate.sport))
29
+ return null;
30
+ if (isVirtualOrIndoor(source.sport) !== isVirtualOrIndoor(candidate.sport))
31
+ return null;
32
+ const startDelta = Math.abs(timestamp(source.started_at_utc) - timestamp(candidate.started_at_utc)) / 1_000;
33
+ const durationDelta = Math.abs(source.moving_s - candidate.moving_s);
34
+ const distanceDelta = Math.abs(source.distance_m - candidate.distance_m);
35
+ const maxDuration = Math.max(120, source.moving_s * 0.05);
36
+ const maxDistance = Math.max(200, source.distance_m * 0.025);
37
+ if (startDelta > 90 || durationDelta > maxDuration || distanceDelta > maxDistance)
38
+ return null;
39
+ const score = 1 - ((startDelta / 90) + (durationDelta / maxDuration) + (distanceDelta / maxDistance)) / 3;
40
+ return { score, evidence: { sportFamily: sportFamily(source.sport), startDeltaSeconds: startDelta, durationDeltaSeconds: durationDelta, distanceDeltaMeters: distanceDelta, thresholds: { startSeconds: 90, durationSeconds: maxDuration, distanceMeters: maxDistance } } };
41
+ }
42
+ /** Attach a source to a logical activity only when its link is provable or uniquely high-confidence. */
43
+ export async function reconcileActivityLink(database, activitySourceId) {
44
+ const sources = await database.rows(`
45
+ SELECT source.activity_source_id, source.activity_id, source.provider, source.remote_activity_id, source.external_id,
46
+ activity.started_at_utc, activity.sport, summary.distance_m, summary.moving_s
47
+ FROM activity_sources source
48
+ JOIN activities activity USING (activity_id)
49
+ LEFT JOIN activity_summaries summary USING (activity_source_id)
50
+ WHERE source.activity_source_id = $activitySourceId
51
+ `, { activitySourceId });
52
+ const source = sources[0];
53
+ if (!source)
54
+ return;
55
+ const current = await database.rows('SELECT method, activity_id FROM activity_links WHERE activity_source_id = $activitySourceId', { activitySourceId });
56
+ if (current[0]?.method === 'manual' || current[0]?.method === 'fuzzy_high_confidence') {
57
+ if (source.activity_id !== current[0].activity_id) {
58
+ await database.run('UPDATE activity_sources SET activity_id = $activityId WHERE activity_source_id = $activitySourceId', { activityId: current[0].activity_id, activitySourceId });
59
+ }
60
+ return;
61
+ }
62
+ const externalCandidates = source.provider === 'intervals' && source.external_id
63
+ ? await database.rows(`
64
+ SELECT source.activity_source_id, source.activity_id, source.provider, source.remote_activity_id, source.external_id,
65
+ activity.started_at_utc, activity.sport, summary.distance_m, summary.moving_s
66
+ FROM activity_sources source
67
+ JOIN activities activity USING (activity_id)
68
+ LEFT JOIN activity_summaries summary USING (activity_source_id)
69
+ WHERE source.provider = 'garmin' AND source.remote_activity_id = $externalId
70
+ `, { externalId: source.external_id })
71
+ : source.provider === 'garmin'
72
+ ? await database.rows(`
73
+ SELECT source.activity_source_id, source.activity_id, source.provider, source.remote_activity_id, source.external_id,
74
+ activity.started_at_utc, activity.sport, summary.distance_m, summary.moving_s
75
+ FROM activity_sources source
76
+ JOIN activities activity USING (activity_id)
77
+ LEFT JOIN activity_summaries summary USING (activity_source_id)
78
+ WHERE source.provider = 'intervals' AND source.external_id = $remoteActivityId
79
+ `, { remoteActivityId: source.remote_activity_id })
80
+ : [];
81
+ const externalCandidate = externalCandidates.length === 1 ? externalCandidates[0] : null;
82
+ if (externalCandidate) {
83
+ const linkMethods = await database.rows(`SELECT activity_source_id, method FROM activity_links WHERE activity_source_id = $sourceId OR activity_source_id = $candidateId`, { sourceId: source.activity_source_id, candidateId: externalCandidate.activity_source_id });
84
+ if (!linkMethods.some((link) => link.method === 'manual')) {
85
+ const garmin = source.provider === 'garmin' ? source : externalCandidate;
86
+ const intervals = source.provider === 'intervals' ? source : externalCandidate;
87
+ const targetActivityId = garmin.activity_id;
88
+ const previousActivityIds = new Set([source.activity_id, externalCandidate.activity_id]);
89
+ for (const linkedSource of [source, externalCandidate]) {
90
+ await database.run('UPDATE activity_sources SET activity_id = $activityId WHERE activity_source_id = $activitySourceId', {
91
+ activityId: targetActivityId, activitySourceId: linkedSource.activity_source_id,
92
+ });
93
+ await database.run(`INSERT INTO activity_links VALUES ($activitySourceId, $activityId, 'strong_external_id', 1.0, $evidence, now())
94
+ ON CONFLICT (activity_source_id) DO UPDATE SET activity_id = excluded.activity_id, method = excluded.method,
95
+ confidence = excluded.confidence, evidence_json = excluded.evidence_json, linked_at = now()`, {
96
+ activitySourceId: linkedSource.activity_source_id, activityId: targetActivityId,
97
+ evidence: json({ matchedActivitySourceId: linkedSource.provider === 'garmin' ? intervals.activity_source_id : garmin.activity_source_id, externalId: intervals.external_id }),
98
+ });
99
+ }
100
+ await database.run(`UPDATE activities SET link_state = 'strong_external_id' WHERE activity_id = $activityId`, { activityId: targetActivityId });
101
+ for (const previousActivityId of previousActivityIds) {
102
+ await database.run('DELETE FROM activities WHERE activity_id = $activityId AND NOT EXISTS (SELECT 1 FROM activity_sources WHERE activity_id = $activityId)', { activityId: previousActivityId });
103
+ }
104
+ return;
105
+ }
106
+ }
107
+ if (source.external_id) {
108
+ await database.run(`INSERT INTO activity_links VALUES ($activitySourceId, $activityId, 'strong_external_id', 1.0, '{}', now())
109
+ ON CONFLICT (activity_source_id) DO UPDATE SET activity_id = excluded.activity_id, method = excluded.method, confidence = excluded.confidence, evidence_json = excluded.evidence_json, linked_at = now()`, { activitySourceId, activityId: source.activity_id });
110
+ return;
111
+ }
112
+ const candidates = await database.rows(`
113
+ SELECT source.activity_source_id, source.activity_id, source.provider, source.remote_activity_id, source.external_id,
114
+ activity.started_at_utc, activity.sport, summary.distance_m, summary.moving_s
115
+ FROM activity_sources source
116
+ JOIN activities activity USING (activity_id)
117
+ LEFT JOIN activity_summaries summary USING (activity_source_id)
118
+ WHERE source.activity_source_id <> $activitySourceId
119
+ AND source.provider <> $provider
120
+ AND activity.started_at_utc BETWEEN $startFloor AND $startCeiling
121
+ `, {
122
+ activitySourceId, provider: source.provider,
123
+ startFloor: new Date(timestamp(source.started_at_utc) - 90_000).toISOString(),
124
+ startCeiling: new Date(timestamp(source.started_at_utc) + 90_000).toISOString(),
125
+ });
126
+ const matched = candidates.map((candidate) => ({ candidate, result: qualifies(source, candidate) })).filter((item) => item.result !== null);
127
+ // A provider may already be strongly linked alongside a second source (for
128
+ // example Garmin plus Intervals). Those are one logical candidate, not an
129
+ // ambiguity that should prevent a matching Strava source from joining it.
130
+ const logicalMatches = new Map();
131
+ for (const match of matched) {
132
+ const previous = logicalMatches.get(match.candidate.activity_id);
133
+ if (!previous || match.result.score > previous.result.score)
134
+ logicalMatches.set(match.candidate.activity_id, match);
135
+ }
136
+ const uniqueMatches = [...logicalMatches.values()];
137
+ if (uniqueMatches.length !== 1) {
138
+ await database.run(`INSERT INTO activity_links VALUES ($activitySourceId, $activityId, 'source', NULL, $evidence, now())
139
+ ON CONFLICT (activity_source_id) DO UPDATE SET activity_id = excluded.activity_id, method = excluded.method, confidence = excluded.confidence, evidence_json = excluded.evidence_json, linked_at = now()`, { activitySourceId, activityId: source.activity_id, evidence: json({ candidates: uniqueMatches.length, matchingSources: matched.length, reason: uniqueMatches.length ? 'ambiguous' : 'no_qualifying_candidate' }) });
140
+ return;
141
+ }
142
+ const { candidate, result } = uniqueMatches[0];
143
+ const oldActivityId = source.activity_id;
144
+ await database.run('UPDATE activity_sources SET activity_id = $activityId WHERE activity_source_id = $activitySourceId', { activityId: candidate.activity_id, activitySourceId });
145
+ await database.run(`UPDATE activities SET link_state = 'fuzzy_high_confidence' WHERE activity_id = $activityId`, { activityId: candidate.activity_id });
146
+ await database.run(`INSERT INTO activity_links VALUES ($activitySourceId, $activityId, 'fuzzy_high_confidence', $confidence, $evidence, now())
147
+ ON CONFLICT (activity_source_id) DO UPDATE SET activity_id = excluded.activity_id, method = excluded.method, confidence = excluded.confidence, evidence_json = excluded.evidence_json, linked_at = now()`, { activitySourceId, activityId: candidate.activity_id, confidence: result.score, evidence: json({ ...result.evidence, matchedActivitySourceId: candidate.activity_source_id }) });
148
+ await database.run('DELETE FROM activities WHERE activity_id = $activityId AND NOT EXISTS (SELECT 1 FROM activity_sources WHERE activity_id = $activityId)', { activityId: oldActivityId });
149
+ }
150
+ export async function setManualActivityLink(database, activitySourceId, activityId) {
151
+ await database.run('UPDATE activity_sources SET activity_id = $activityId WHERE activity_source_id = $activitySourceId', { activityId, activitySourceId });
152
+ await database.run(`UPDATE activities SET link_state = 'manual' WHERE activity_id = $activityId`, { activityId });
153
+ await database.run(`INSERT INTO activity_links VALUES ($activitySourceId, $activityId, 'manual', 1.0, '{"command":"link"}', now())
154
+ ON CONFLICT (activity_source_id) DO UPDATE SET activity_id = excluded.activity_id, method = excluded.method, confidence = excluded.confidence, evidence_json = excluded.evidence_json, linked_at = now()`, { activitySourceId, activityId });
155
+ }
156
+ export async function unlinkActivitySource(database, activitySourceId) {
157
+ const source = (await database.rows('SELECT provider, remote_activity_id FROM activity_sources WHERE activity_source_id = $activitySourceId', { activitySourceId }))[0];
158
+ if (!source)
159
+ throw new Error(`No activity source exists for ${activitySourceId}.`);
160
+ const activityId = `${source.provider}:${source.remote_activity_id}`;
161
+ await database.run(`INSERT INTO activities (activity_id, link_state) VALUES ($activityId, 'unlinked') ON CONFLICT (activity_id) DO NOTHING`, { activityId });
162
+ await database.run('UPDATE activity_sources SET activity_id = $activityId WHERE activity_source_id = $activitySourceId', { activityId, activitySourceId });
163
+ await database.run(`UPDATE activity_links SET activity_id = $activityId, method = 'source', confidence = NULL, evidence_json = '{"command":"unlink"}', linked_at = now() WHERE activity_source_id = $activitySourceId`, { activityId, activitySourceId });
164
+ }
165
+ //# sourceMappingURL=linking.js.map
@@ -0,0 +1,5 @@
1
+ import type { CatenceDatabase } from '../storage/database.js';
2
+ import type { SourceEntity } from '../../contracts/staging.js';
3
+ export declare function importSourceEntity(database: CatenceDatabase, entity: SourceEntity): Promise<void>;
4
+ /** Re-evaluate source quality whenever an activity is imported or linked. */
5
+ export declare function refreshActivityQuality(database: CatenceDatabase, activityId: string): Promise<void>;