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,292 @@
1
+ import { QueryValidationError } from './catalog.js';
2
+ import { jsonSafe } from './repository.js';
3
+ /**
4
+ * A small, intentionally opinionated layer over Catence's general query
5
+ * surface. It keeps the common wellness/recovery questions compact while
6
+ * retaining the same normalized daily-health and canonical-training facts.
7
+ */
8
+ export const WELLNESS_METRICS = [
9
+ 'resting_hr_bpm',
10
+ 'hrv_ms',
11
+ 'sleep_seconds',
12
+ 'sleep_score',
13
+ 'stress',
14
+ 'body_battery',
15
+ 'readiness',
16
+ 'spo2_pct',
17
+ 'steps',
18
+ 'training_load',
19
+ 'activity_count',
20
+ 'distance_m',
21
+ 'moving_s',
22
+ ];
23
+ const DEFAULT_BASELINE_METRICS = ['resting_hr_bpm', 'hrv_ms', 'sleep_score', 'stress', 'body_battery', 'training_load'];
24
+ const DEFAULT_ANOMALY_METRICS = ['resting_hr_bpm', 'hrv_ms', 'sleep_score', 'stress', 'body_battery'];
25
+ function today() {
26
+ return new Date().toISOString().slice(0, 10);
27
+ }
28
+ function addDays(date, days) {
29
+ const timestamp = Date.parse(`${date}T00:00:00.000Z`);
30
+ return new Date(timestamp + days * 86_400_000).toISOString().slice(0, 10);
31
+ }
32
+ function subtractDays(date, days) {
33
+ return addDays(date, -days);
34
+ }
35
+ function mean(values) {
36
+ return values.reduce((sum, value) => sum + value, 0) / values.length;
37
+ }
38
+ function standardDeviation(values) {
39
+ const center = mean(values);
40
+ return Math.sqrt(values.reduce((sum, value) => sum + (value - center) ** 2, 0) / values.length);
41
+ }
42
+ function pearson(x, y) {
43
+ const xDeviation = standardDeviation(x);
44
+ const yDeviation = standardDeviation(y);
45
+ if (xDeviation === 0 || yDeviation === 0)
46
+ return null;
47
+ const xMean = mean(x);
48
+ const yMean = mean(y);
49
+ return x.reduce((sum, value, index) => sum + (value - xMean) * (y[index] - yMean), 0) / (x.length * xDeviation * yDeviation);
50
+ }
51
+ function ranks(values) {
52
+ const ordered = values.map((value, index) => ({ value, index })).sort((left, right) => left.value - right.value);
53
+ const result = new Array(values.length);
54
+ let index = 0;
55
+ while (index < ordered.length) {
56
+ let end = index;
57
+ while (end + 1 < ordered.length && ordered[end + 1].value === ordered[index].value)
58
+ end++;
59
+ const rank = (index + end + 2) / 2;
60
+ for (let cursor = index; cursor <= end; cursor++)
61
+ result[ordered[cursor].index] = rank;
62
+ index = end + 1;
63
+ }
64
+ return result;
65
+ }
66
+ function metricList(metrics, fallback) {
67
+ const selected = [...new Set(metrics?.length ? metrics : fallback)];
68
+ if (selected.length === 0 || selected.length > 12)
69
+ throw new QueryValidationError('Choose between one and twelve wellness metrics.');
70
+ for (const metric of selected) {
71
+ if (!WELLNESS_METRICS.includes(metric))
72
+ throw new QueryValidationError(`Unsupported wellness metric: ${metric}.`);
73
+ }
74
+ return selected;
75
+ }
76
+ function numberOrNull(value) {
77
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
78
+ }
79
+ function dateRange(startDate, endDate) {
80
+ const dates = [];
81
+ for (let date = startDate; date <= endDate; date = addDays(date, 1))
82
+ dates.push(date);
83
+ return dates;
84
+ }
85
+ export class WellnessService {
86
+ repository;
87
+ constructor(repository) {
88
+ this.repository = repository;
89
+ }
90
+ async correlate(request) {
91
+ this.assertMetric(request.metricA);
92
+ this.assertMetric(request.metricB);
93
+ const range = await this.resolveRange(request.startDate, request.endDate, 90);
94
+ const rows = await this.dailyRows(range.startDate, range.endDate);
95
+ const method = request.method ?? 'pearson';
96
+ const requestedLag = request.lagDays ?? 0;
97
+ if (!Number.isInteger(requestedLag) || requestedLag < -30 || requestedLag > 30)
98
+ throw new QueryValidationError('lagDays must be an integer from -30 to 30.');
99
+ const lags = request.scanLags ? Array.from({ length: 15 }, (_, index) => index - 7) : [requestedLag];
100
+ const results = lags.map((lagDays) => this.correlation(rows, request.metricA, request.metricB, lagDays, method));
101
+ const selected = request.scanLags
102
+ ? [...results].sort((left, right) => (right.correlation === null ? -1 : Math.abs(right.correlation)) - (left.correlation === null ? -1 : Math.abs(left.correlation)) || right.pairedDays - left.pairedDays)[0]
103
+ : results[0];
104
+ return jsonSafe({
105
+ data: request.scanLags ? { selected, scannedLags: results } : selected,
106
+ provenance: { relations: ['daily_health', 'canonical_activity_training'], dailyHealthProvider: 'Garmin when available' },
107
+ query: { ...request, startDate: range.startDate, endDate: range.endDate, method, lagDays: requestedLag, scanLags: request.scanLags ?? false },
108
+ caveats: [
109
+ 'A positive lag pairs metricA on date D with metricB on D plus lagDays; correlation is descriptive, not causal.',
110
+ 'Null or missing values are excluded pairwise. A day without training has zero training load only when a daily-health row exists; a wholly absent day remains missing.',
111
+ ],
112
+ });
113
+ }
114
+ async baselines(request) {
115
+ const metrics = metricList(request.metrics, DEFAULT_BASELINE_METRICS);
116
+ const windowDays = request.windowDays ?? 28;
117
+ if (!Number.isInteger(windowDays) || windowDays < 7 || windowDays > 365)
118
+ throw new QueryValidationError('windowDays must be an integer from 7 to 365.');
119
+ const range = await this.resolveRange(undefined, request.endDate, windowDays);
120
+ const rows = await this.dailyRows(range.startDate, range.endDate);
121
+ const data = metrics.map((metric) => {
122
+ const observations = rows.flatMap((row) => row[metric] === null ? [] : [{ date: row.date, value: row[metric] }]);
123
+ const values = observations.map((observation) => observation.value);
124
+ const latest = observations.at(-1) ?? null;
125
+ const average = values.length ? mean(values) : null;
126
+ const deviation = values.length > 1 ? standardDeviation(values) : null;
127
+ return {
128
+ metric,
129
+ observations: values.length,
130
+ missingDays: windowDays - values.length,
131
+ mean: average,
132
+ standardDeviation: deviation,
133
+ lowerBand: average === null || deviation === null ? null : average - deviation,
134
+ upperBand: average === null || deviation === null ? null : average + deviation,
135
+ latestDate: latest?.date ?? null,
136
+ latestValue: latest?.value ?? null,
137
+ latestZScore: latest === null || average === null || deviation === null || deviation === 0 ? null : (latest.value - average) / deviation,
138
+ };
139
+ });
140
+ return jsonSafe({
141
+ data,
142
+ provenance: { relations: ['daily_health', 'canonical_activity_training'], dailyHealthProvider: 'Garmin when available' },
143
+ query: { metrics, endDate: range.endDate, windowDays, startDate: range.startDate },
144
+ caveats: ['Baselines are descriptive trailing-window statistics, not medical or training recommendations.', 'Missing values are not treated as zero.'],
145
+ });
146
+ }
147
+ async anomalies(request) {
148
+ const metrics = metricList(request.metrics, DEFAULT_ANOMALY_METRICS);
149
+ const range = await this.resolveRange(request.startDate, request.endDate, 30);
150
+ const zThreshold = request.zThreshold ?? 2;
151
+ if (!Number.isFinite(zThreshold) || zThreshold < 1 || zThreshold > 5)
152
+ throw new QueryValidationError('zThreshold must be between 1 and 5.');
153
+ const rows = await this.dailyRows(range.startDate, range.endDate);
154
+ const flagged = new Map();
155
+ const summaries = metrics.map((metric) => {
156
+ const values = rows.map((row) => row[metric]).filter((value) => value !== null);
157
+ const average = values.length ? mean(values) : null;
158
+ const deviation = values.length > 1 ? standardDeviation(values) : null;
159
+ const anomalies = rows.flatMap((row) => {
160
+ const value = row[metric];
161
+ if (value === null || average === null || deviation === null || deviation === 0)
162
+ return [];
163
+ const zScore = (value - average) / deviation;
164
+ if (Math.abs(zScore) < zThreshold)
165
+ return [];
166
+ const anomaly = { metric, date: row.date, value, zScore, direction: zScore > 0 ? 'above_baseline' : 'below_baseline' };
167
+ flagged.set(row.date, [...(flagged.get(row.date) ?? []), anomaly]);
168
+ return [anomaly];
169
+ });
170
+ return { metric, observations: values.length, mean: average, standardDeviation: deviation, anomalies };
171
+ });
172
+ return jsonSafe({
173
+ data: {
174
+ metrics: summaries,
175
+ crossMetricDays: [...flagged.entries()].filter(([, items]) => items.length > 1).map(([date, items]) => ({ date, anomalies: items })),
176
+ },
177
+ provenance: { relations: ['daily_health', 'canonical_activity_training'], dailyHealthProvider: 'Garmin when available' },
178
+ query: { metrics, startDate: range.startDate, endDate: range.endDate, zThreshold },
179
+ caveats: ['Anomalies are statistical outliers in the selected period, not diagnoses or explanations.', 'Metrics with fewer than two observations, constant values, or missing days do not produce z-scores.'],
180
+ });
181
+ }
182
+ async coverage(request) {
183
+ const metrics = metricList(request.metrics, [...WELLNESS_METRICS]);
184
+ const range = await this.resolveRange(request.startDate, request.endDate, 90);
185
+ const rows = await this.dailyRows(range.startDate, range.endDate);
186
+ const byDate = new Map(rows.map((row) => [row.date, row]));
187
+ const dates = dateRange(range.startDate, range.endDate);
188
+ const data = metrics.map((metric) => {
189
+ const presentDates = dates.filter((date) => byDate.get(date)?.[metric] !== null);
190
+ return {
191
+ metric,
192
+ expectedDays: dates.length,
193
+ presentDays: presentDates.length,
194
+ missingDays: dates.length - presentDates.length,
195
+ firstPresentDate: presentDates[0] ?? null,
196
+ lastPresentDate: presentDates.at(-1) ?? null,
197
+ missingDates: dates.filter((date) => byDate.get(date)?.[metric] === null).slice(0, 100),
198
+ missingDatesTruncated: dates.length - presentDates.length > 100,
199
+ };
200
+ });
201
+ const errors = await this.repository.rows('SELECT count(*) AS count FROM normalization_errors WHERE resolved_at IS NULL');
202
+ return jsonSafe({
203
+ data: { metrics: data, unresolvedExtractionErrors: Number(errors[0]?.count ?? 0) },
204
+ provenance: { relations: ['daily_health', 'canonical_activity_training', 'normalization_errors'], dailyHealthProvider: 'Garmin when available' },
205
+ query: { metrics, startDate: range.startDate, endDate: range.endDate },
206
+ caveats: ['Coverage reports unavailable observations; it does not infer rest days, zero load, or negative health signals from missing records.', 'Only the first 100 missing dates per metric are returned.'],
207
+ });
208
+ }
209
+ assertMetric(metric) {
210
+ if (!WELLNESS_METRICS.includes(metric))
211
+ throw new QueryValidationError(`Unsupported wellness metric: ${metric}.`);
212
+ }
213
+ async resolveRange(startDate, endDate, defaultDays) {
214
+ const latest = await this.repository.rows(`
215
+ SELECT max(date) AS latest_date FROM (
216
+ SELECT metric_date AS date FROM daily_health
217
+ UNION ALL
218
+ SELECT cast(started_at_utc AS DATE) AS date FROM canonical_activity_training
219
+ ) AS known_dates
220
+ `);
221
+ const latestDate = latest[0]?.latest_date instanceof Date
222
+ ? latest[0].latest_date.toISOString().slice(0, 10)
223
+ : typeof latest[0]?.latest_date === 'string' ? latest[0].latest_date.slice(0, 10) : today();
224
+ const resolvedEndDate = endDate ?? latestDate;
225
+ const resolvedStartDate = startDate ?? subtractDays(resolvedEndDate, defaultDays - 1);
226
+ if (resolvedStartDate > resolvedEndDate)
227
+ throw new QueryValidationError('startDate must be on or before endDate.');
228
+ if (Date.parse(`${resolvedEndDate}T00:00:00Z`) - Date.parse(`${resolvedStartDate}T00:00:00Z`) > 730 * 86_400_000) {
229
+ throw new QueryValidationError('Wellness queries are limited to a two-year range. Narrow the requested dates.');
230
+ }
231
+ return { startDate: resolvedStartDate, endDate: resolvedEndDate };
232
+ }
233
+ async dailyRows(startDate, endDate) {
234
+ return this.repository.rows(`
235
+ WITH health AS (
236
+ SELECT cast(metric_date AS VARCHAR) AS date,
237
+ resting_hr_bpm, hrv_ms, sleep_seconds, sleep_score, stress, body_battery, readiness, spo2_pct, steps
238
+ FROM daily_health
239
+ WHERE metric_date BETWEEN cast($startDate AS DATE) AND cast($endDate AS DATE)
240
+ ), training AS (
241
+ SELECT cast(cast(started_at_utc AS DATE) AS VARCHAR) AS date,
242
+ sum(training_load) AS training_load,
243
+ count(*)::DOUBLE AS activity_count,
244
+ sum(distance_m) AS distance_m,
245
+ sum(moving_s) AS moving_s
246
+ FROM canonical_activity_training
247
+ WHERE cast(started_at_utc AS DATE) BETWEEN cast($startDate AS DATE) AND cast($endDate AS DATE)
248
+ GROUP BY 1
249
+ ), dates AS (
250
+ SELECT date FROM health UNION SELECT date FROM training
251
+ )
252
+ SELECT dates.date,
253
+ health.resting_hr_bpm, health.hrv_ms, health.sleep_seconds, health.sleep_score, health.stress,
254
+ health.body_battery, health.readiness, health.spo2_pct, health.steps,
255
+ CASE WHEN health.date IS NULL THEN NULL ELSE coalesce(training.training_load, 0) END AS training_load,
256
+ CASE WHEN health.date IS NULL THEN NULL ELSE coalesce(training.activity_count, 0) END AS activity_count,
257
+ CASE WHEN health.date IS NULL THEN NULL ELSE coalesce(training.distance_m, 0) END AS distance_m,
258
+ CASE WHEN health.date IS NULL THEN NULL ELSE coalesce(training.moving_s, 0) END AS moving_s
259
+ FROM dates
260
+ LEFT JOIN health USING (date)
261
+ LEFT JOIN training USING (date)
262
+ ORDER BY dates.date ASC
263
+ `, { startDate, endDate }).then((rows) => rows.map((row) => {
264
+ const normalized = { date: String(row.date) };
265
+ for (const metric of WELLNESS_METRICS)
266
+ normalized[metric] = numberOrNull(row[metric]);
267
+ return normalized;
268
+ }));
269
+ }
270
+ correlation(rows, metricA, metricB, lagDays, method) {
271
+ const byDate = new Map(rows.map((row) => [row.date, row]));
272
+ const pairs = rows.flatMap((row) => {
273
+ const other = byDate.get(addDays(row.date, lagDays));
274
+ const first = row[metricA];
275
+ const second = other?.[metricB] ?? null;
276
+ return first === null || second === null ? [] : [{ date: row.date, pairedDate: other.date, first, second }];
277
+ });
278
+ const x = pairs.map((pair) => pair.first);
279
+ const y = pairs.map((pair) => pair.second);
280
+ return {
281
+ metricA,
282
+ metricB,
283
+ lagDays,
284
+ method,
285
+ pairedDays: pairs.length,
286
+ correlation: pairs.length < 3 ? null : pearson(method === 'spearman' ? ranks(x) : x, method === 'spearman' ? ranks(y) : y),
287
+ firstPairedDate: pairs[0]?.date ?? null,
288
+ lastPairedDate: pairs.at(-1)?.date ?? null,
289
+ };
290
+ }
291
+ }
292
+ //# sourceMappingURL=wellness.js.map
@@ -0,0 +1,13 @@
1
+ import type { WriteDataStore } from '../../contracts/storage.js';
2
+ import type { DataFilter } from '../query/analytics.js';
3
+ import { ReadOnlyRepository } from '../query/repository.js';
4
+ export declare function buildRetrievalIndex(database: WriteDataStore): Promise<{
5
+ documents: number;
6
+ mode: 'full_text' | 'keyword';
7
+ watermark: string | null;
8
+ }>;
9
+ export declare function searchContext(repository: ReadOnlyRepository, request: {
10
+ query: string;
11
+ filters?: DataFilter[];
12
+ limit?: number;
13
+ }): Promise<Record<string, unknown>>;
@@ -0,0 +1,248 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { compileFilters } from '../query/analytics.js';
3
+ import { getDataset, QueryValidationError } from '../query/catalog.js';
4
+ import { jsonSafe } from '../query/repository.js';
5
+ function short(value) {
6
+ if (typeof value !== 'string')
7
+ return null;
8
+ const normalized = value.replace(/\s+/g, ' ').trim();
9
+ return normalized ? normalized.slice(0, 2_000) : null;
10
+ }
11
+ function dateText(value) {
12
+ if (value instanceof Date)
13
+ return value.toISOString().slice(0, 10);
14
+ if (typeof value === 'string')
15
+ return value.slice(0, 10) || null;
16
+ return null;
17
+ }
18
+ function documentId(document) {
19
+ return createHash('sha256').update(`${document.entityType}\u0000${document.entityId}\u0000${document.text}`).digest('hex');
20
+ }
21
+ function words(...parts) {
22
+ return parts.filter((part) => Boolean(part)).join('. ').replace(/\s+/g, ' ').trim();
23
+ }
24
+ function payloadObject(value) {
25
+ if (typeof value === 'string') {
26
+ try {
27
+ return JSON.parse(value);
28
+ }
29
+ catch {
30
+ return {};
31
+ }
32
+ }
33
+ return value && typeof value === 'object' ? value : {};
34
+ }
35
+ function selectedPayloadText(payload) {
36
+ const fields = ['name', 'title', 'description', 'notes', 'note', 'message', 'text', 'tags', 'workoutName', 'routeName', 'eventName'];
37
+ return fields.flatMap((field) => {
38
+ const value = payload[field];
39
+ if (Array.isArray(value))
40
+ return value.map(short).filter((entry) => Boolean(entry));
41
+ const result = short(value);
42
+ return result ? [result] : [];
43
+ });
44
+ }
45
+ export async function buildRetrievalIndex(database) {
46
+ const documents = [];
47
+ const activities = await database.rows(`
48
+ SELECT summary.activity_source_id, source.activity_id, source.provider, cast(activity.started_at_utc AS DATE) AS occurred_on,
49
+ activity.name, activity.sport, summary.distance_m, summary.moving_s, summary.training_load, summary.avg_hr, summary.avg_power, summary.weighted_power, summary.calories, summary.metrics_json
50
+ FROM activity_summaries summary
51
+ JOIN activity_sources source USING (activity_source_id)
52
+ JOIN activities activity USING (activity_id)
53
+ `);
54
+ for (const activity of activities) {
55
+ const facts = [
56
+ activity.sport ? `sport ${activity.sport}` : null,
57
+ activity.distance_m ? `${Number(activity.distance_m).toFixed(0)} m` : null,
58
+ activity.moving_s ? `${Math.round(Number(activity.moving_s) / 60)} min moving` : null,
59
+ activity.training_load ? `training load ${activity.training_load}` : null,
60
+ activity.avg_hr ? `average heart rate ${activity.avg_hr} bpm` : null,
61
+ activity.avg_power ? `average power ${activity.avg_power} W` : null,
62
+ activity.calories ? `${activity.calories} kcal` : null,
63
+ ].filter(Boolean).join(', ');
64
+ const text = words(short(activity.name), facts ? `Activity summary: ${facts}` : null);
65
+ if (text)
66
+ documents.push({ entityType: 'activity', entityId: String(activity.activity_id), activitySourceId: String(activity.activity_source_id), provider: String(activity.provider), occurredOn: dateText(activity.occurred_on), text, metadata: { sport: activity.sport, name: activity.name } });
67
+ }
68
+ const intervals = await database.rows(`
69
+ SELECT intervals.activity_source_id, source.activity_id, source.provider, cast(activity.started_at_utc AS DATE) AS occurred_on,
70
+ intervals.interval_key, intervals.label, intervals.start_s, intervals.end_s, intervals.avg_power, intervals.avg_hr
71
+ FROM activity_intervals intervals JOIN activity_sources source USING (activity_source_id) JOIN activities activity USING (activity_id)
72
+ `);
73
+ for (const interval of intervals) {
74
+ const text = words(short(interval.label), `Interval ${interval.interval_key}`, interval.avg_power ? `average power ${interval.avg_power} W` : null, interval.avg_hr ? `average heart rate ${interval.avg_hr} bpm` : null);
75
+ documents.push({ entityType: 'activity_interval', entityId: `${interval.activity_source_id}:${interval.interval_key}`, activitySourceId: String(interval.activity_source_id), provider: String(interval.provider), occurredOn: dateText(interval.occurred_on), text, metadata: { activityId: interval.activity_id, label: interval.label } });
76
+ }
77
+ const nutritionDays = await database.rows('SELECT provider, cast(nutrition_date AS VARCHAR) AS nutrition_date, energy_kcal, carbohydrates_g, protein_g, fat_g, hydration_ml FROM nutrition_days');
78
+ for (const day of nutritionDays) {
79
+ const parts = [day.energy_kcal ? `${day.energy_kcal} kcal` : null, day.carbohydrates_g ? `${day.carbohydrates_g} g carbohydrate` : null, day.protein_g ? `${day.protein_g} g protein` : null, day.fat_g ? `${day.fat_g} g fat` : null, day.hydration_ml ? `${day.hydration_ml} ml hydration` : null];
80
+ documents.push({ entityType: 'nutrition_day', entityId: `${day.provider}:${dateText(day.nutrition_date)}`, provider: String(day.provider), occurredOn: dateText(day.nutrition_date), text: words('Nutrition summary', ...parts), metadata: {} });
81
+ }
82
+ const nutritionItems = await database.rows('SELECT provider, remote_item_id, cast(nutrition_date AS VARCHAR) AS nutrition_date, meal, food_name, energy_kcal, carbohydrates_g, protein_g, fat_g FROM nutrition_items');
83
+ for (const item of nutritionItems) {
84
+ const text = words(short(item.food_name), short(item.meal), item.energy_kcal ? `${item.energy_kcal} kcal` : null, item.carbohydrates_g ? `${item.carbohydrates_g} g carbohydrate` : null, item.protein_g ? `${item.protein_g} g protein` : null);
85
+ if (text)
86
+ documents.push({ entityType: 'nutrition_item', entityId: `${item.provider}:${item.remote_item_id}`, provider: String(item.provider), occurredOn: dateText(item.nutrition_date), text, metadata: { meal: item.meal, foodName: item.food_name } });
87
+ }
88
+ const domain = await database.rows(`
89
+ SELECT provider, entity_type, remote_id, cast(occurred_on AS VARCHAR) AS occurred_on, payload_json
90
+ FROM domain_entities
91
+ WHERE entity_type IN ('event', 'workout', 'scheduled_workout', 'training_plan', 'route', 'message')
92
+ `);
93
+ for (const entity of domain) {
94
+ const fields = selectedPayloadText(payloadObject(entity.payload_json));
95
+ const entityType = String(entity.entity_type);
96
+ if (fields.length)
97
+ documents.push({ entityType, entityId: String(entity.remote_id), provider: String(entity.provider), occurredOn: dateText(entity.occurred_on), text: words(`${entityType.replaceAll('_', ' ')}`, ...fields), metadata: {} });
98
+ }
99
+ const weekly = await database.rows(`
100
+ SELECT date_trunc('week', started_at_utc)::DATE AS week, count(*)::INTEGER AS activities, sum(moving_s) AS moving_s, sum(training_load) AS training_load
101
+ FROM canonical_activity_training GROUP BY 1
102
+ `);
103
+ for (const week of weekly) {
104
+ const weekDate = dateText(week.week);
105
+ if (weekDate)
106
+ documents.push({ entityType: 'weekly_training_summary', entityId: weekDate, occurredOn: weekDate, text: words(`Week beginning ${weekDate}`, `${week.activities} activities`, week.moving_s ? `${Math.round(Number(week.moving_s) / 60)} minutes training` : null, week.training_load ? `training load ${week.training_load}` : null), metadata: {} });
107
+ }
108
+ const trainingMetrics = await database.rows(`
109
+ SELECT observation_id, provider, metric_name, sport, device_id, cast(observed_at AS VARCHAR) AS observed_at,
110
+ value_number, value_text, unit, source_type
111
+ FROM training_metric_observations
112
+ `);
113
+ for (const metric of trainingMetrics) {
114
+ const value = metric.value_number !== null && metric.value_number !== undefined
115
+ ? `${metric.value_number}${metric.unit ? ` ${metric.unit}` : ''}`
116
+ : short(metric.value_text);
117
+ const text = words(String(metric.metric_name).replaceAll('_', ' '), value ? `value ${value}` : null, metric.sport ? `sport ${metric.sport}` : null, metric.device_id ? `device ${metric.device_id}` : null, metric.source_type ? `source ${metric.source_type}` : null);
118
+ if (text)
119
+ documents.push({
120
+ entityType: 'training_metric', entityId: String(metric.observation_id), provider: String(metric.provider),
121
+ occurredOn: dateText(metric.observed_at), text, metadata: { metricName: metric.metric_name, sport: metric.sport, deviceId: metric.device_id },
122
+ });
123
+ }
124
+ const deduplicated = new Map(documents.map((document) => [documentId(document), document]));
125
+ const watermarkRows = await database.rows('SELECT max(cast(fetched_at AS VARCHAR)) AS watermark FROM raw_objects');
126
+ const watermark = watermarkRows[0]?.watermark ?? null;
127
+ let mode = 'keyword';
128
+ await database.run('BEGIN TRANSACTION');
129
+ try {
130
+ await database.run('DELETE FROM retrieval_documents');
131
+ for (const document of deduplicated.values()) {
132
+ const id = documentId(document);
133
+ await database.run(`INSERT INTO retrieval_documents (document_id, entity_type, entity_id, activity_source_id, provider, occurred_on, document_text, metadata_json, content_hash)
134
+ VALUES ($id, $entityType, $entityId, $activitySourceId, $provider, $occurredOn, $text, $metadata, $hash)`, {
135
+ id, entityType: document.entityType, entityId: document.entityId, activitySourceId: document.activitySourceId ?? null, provider: document.provider ?? null, occurredOn: document.occurredOn || null, text: document.text, metadata: JSON.stringify(document.metadata), hash: id,
136
+ });
137
+ }
138
+ await database.run(`INSERT INTO retrieval_index_state (index_name, status, mode, source_watermark, built_at, detail_json)
139
+ VALUES ('context', 'ready', $mode, $watermark, now(), $detail)
140
+ ON CONFLICT (index_name) DO UPDATE SET status = excluded.status, mode = excluded.mode, source_watermark = excluded.source_watermark, built_at = excluded.built_at, detail_json = excluded.detail_json`, {
141
+ mode, watermark, detail: JSON.stringify({ documentCount: deduplicated.size }),
142
+ });
143
+ await database.run('COMMIT');
144
+ }
145
+ catch (error) {
146
+ await database.run('ROLLBACK');
147
+ throw error;
148
+ }
149
+ mode = await tryBuildFts(database);
150
+ if (mode === 'full_text') {
151
+ await database.run("UPDATE retrieval_index_state SET mode = 'full_text' WHERE index_name = 'context'");
152
+ }
153
+ return { documents: deduplicated.size, mode, watermark };
154
+ }
155
+ async function tryBuildFts(database) {
156
+ try {
157
+ // No INSTALL: extensions must already be local. Failure intentionally falls back to keyword search.
158
+ await database.run('LOAD fts');
159
+ try {
160
+ await database.run("PRAGMA drop_fts_index('retrieval_documents')");
161
+ }
162
+ catch { /* index may not exist */ }
163
+ await database.run("PRAGMA create_fts_index('retrieval_documents', 'document_id', 'document_text')");
164
+ await database.rows("SELECT fts_main_retrieval_documents.match_bm25(document_id, 'catence') AS score FROM retrieval_documents LIMIT 1");
165
+ return 'full_text';
166
+ }
167
+ catch {
168
+ return 'keyword';
169
+ }
170
+ }
171
+ export async function searchContext(repository, request) {
172
+ const query = request.query.trim();
173
+ if (query.length < 2 || query.length > 500)
174
+ throw new QueryValidationError('search_context requires a 2–500 character query.');
175
+ const limit = Math.min(Math.max(request.limit ?? 10, 1), 50);
176
+ const state = await repository.rows("SELECT mode, status FROM retrieval_index_state WHERE index_name = 'context'");
177
+ const { where, values } = compileFilters(getDataset('source_entities'), [], undefined, undefined); // Creates a parameter-safe empty baseline; retrieval has its own filter allowlist below.
178
+ void where;
179
+ void values;
180
+ const metadataClauses = [];
181
+ const params = { query, limit };
182
+ for (const [index, filter] of (request.filters ?? []).entries()) {
183
+ if (!['provider', 'entity_type', 'occurred_on'].includes(filter.column) || !['eq', 'between', 'in'].includes(filter.op))
184
+ throw new QueryValidationError('search_context permits only provider, entity_type, and occurred_on eq/in/between filters.');
185
+ const column = `"${filter.column}"`;
186
+ const key = `filter${index}`;
187
+ if (filter.op === 'eq') {
188
+ metadataClauses.push(`${column} = $${key}`);
189
+ params[key] = filter.value;
190
+ }
191
+ else if (filter.op === 'between') {
192
+ if (!Array.isArray(filter.value) || filter.value.length !== 2)
193
+ throw new QueryValidationError('between requires two values.');
194
+ metadataClauses.push(`${column} BETWEEN $${key}a AND $${key}b`);
195
+ params[`${key}a`] = filter.value[0];
196
+ params[`${key}b`] = filter.value[1];
197
+ }
198
+ else {
199
+ if (!Array.isArray(filter.value) || filter.value.length === 0 || filter.value.length > 100)
200
+ throw new QueryValidationError('in requires 1–100 values.');
201
+ const placeholders = filter.value.map((value, itemIndex) => { const itemKey = `${key}_${itemIndex}`; params[itemKey] = value; return `$${itemKey}`; });
202
+ metadataClauses.push(`${column} IN (${placeholders.join(', ')})`);
203
+ }
204
+ }
205
+ const filterSql = metadataClauses.length ? ` AND ${metadataClauses.join(' AND ')}` : '';
206
+ const terms = query.split(/\s+/).filter(Boolean).slice(0, 12);
207
+ const keywordScore = terms.map((_, index) => `CASE WHEN lower(document_text) LIKE '%' || lower($term${index}) || '%' THEN 1 ELSE 0 END`).join(' + ');
208
+ terms.forEach((term, index) => { params[`term${index}`] = term; });
209
+ let rows;
210
+ let mode = 'keyword';
211
+ if (state[0]?.mode === 'full_text') {
212
+ try {
213
+ rows = await repository.rows(`SELECT document_id, entity_type, entity_id, activity_source_id, provider, occurred_on, left(document_text, 600) AS snippet, fts_main_retrieval_documents.match_bm25(document_id, $query) AS score FROM retrieval_documents WHERE fts_main_retrieval_documents.match_bm25(document_id, $query) IS NOT NULL${filterSql} ORDER BY score DESC LIMIT $limit`, params);
214
+ mode = 'full_text';
215
+ }
216
+ catch { /* a stale or unsupported local FTS extension safely uses the keyword path */ }
217
+ }
218
+ if (!rows) {
219
+ const { query: ignoredQuery, ...keywordParams } = params;
220
+ void ignoredQuery;
221
+ rows = await repository.rows(`SELECT document_id, entity_type, entity_id, activity_source_id, provider, occurred_on, left(document_text, 600) AS snippet, (${keywordScore}) AS score FROM retrieval_documents WHERE (${keywordScore}) > 0${filterSql} ORDER BY score DESC, occurred_on DESC NULLS LAST, document_id ASC LIMIT $limit`, keywordParams);
222
+ }
223
+ const indexStatus = state[0]?.status ?? 'stale';
224
+ const indexIsFresh = indexStatus === 'ready';
225
+ return jsonSafe({
226
+ data: rows.map((row) => ({ ...row, recommendedFollowUpTool: recommendedTool(String(row.entity_type)) })),
227
+ retrievalIndex: {
228
+ status: indexStatus,
229
+ fresh: indexIsFresh,
230
+ authoritativeAlternative: indexIsFresh ? null : 'Use find_activities or query_read_only_data for authoritative activity discovery until the index is rebuilt.',
231
+ },
232
+ provenance: { dataset: 'retrieval_documents', mode, indexStatus },
233
+ query: { query, filters: request.filters ?? [], limit },
234
+ caveats: !indexIsFresh
235
+ ? ['Index stale; use direct activity query (find_activities or query_read_only_data) for authoritative results. Run catence-data build-retrieval-index after sync.']
236
+ : ['Search results are context pointers; use the recommended tool for authoritative numeric facts.'],
237
+ });
238
+ }
239
+ function recommendedTool(entityType) {
240
+ if (entityType.startsWith('activity'))
241
+ return 'find_activities';
242
+ if (entityType.startsWith('nutrition'))
243
+ return 'aggregate_data';
244
+ if (entityType === 'weekly_training_summary' || entityType === 'training_metric')
245
+ return 'aggregate_data';
246
+ return 'query_read_only_data';
247
+ }
248
+ //# sourceMappingURL=index.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(): {
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;