catence 0.2.0 → 0.2.1-beta.2

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.
package/README.md CHANGED
@@ -31,7 +31,7 @@ Install Catence and create the first athlete. The default catalog home is `~/.ca
31
31
  Every `catence-data` command requires `--athlete <id>`; there is no default athlete. Pair it with `--home <dir>` to select the catalog when not using the default home.
32
32
 
33
33
  ```sh
34
- npm install --global catence@beta # or catence@latest for stable
34
+ npm install --global catence@latest # or catence@beta
35
35
  catence-data setup --athlete alex --label "Alex"
36
36
  ```
37
37
 
@@ -3,7 +3,7 @@
3
3
  * Keep these values synchronized with release/manifest.json; release checks
4
4
  * deliberately reject drift before a package can be published.
5
5
  */
6
- export declare const CATENCE_RUNTIME_VERSION = "0.2.0";
6
+ export declare const CATENCE_RUNTIME_VERSION = "0.2.1-beta.2";
7
7
  export declare const CATENCE_PROTOCOL_VERSION = 1;
8
8
  export type CatenceRuntimeCapabilities = {
9
9
  mcp: true;
@@ -3,7 +3,7 @@
3
3
  * Keep these values synchronized with release/manifest.json; release checks
4
4
  * deliberately reject drift before a package can be published.
5
5
  */
6
- export const CATENCE_RUNTIME_VERSION = '0.2.0';
6
+ export const CATENCE_RUNTIME_VERSION = '0.2.1-beta.2';
7
7
  export const CATENCE_PROTOCOL_VERSION = 1;
8
8
  export function catenceRuntimeHealth() {
9
9
  return {
@@ -1,4 +1,4 @@
1
- export type DatasetName = 'activities' | 'canonical_activities' | 'canonical_activity_facts' | 'activity_sources' | 'activity_summaries' | 'activity_intervals' | 'swim_lengths' | 'swim_sets' | 'activity_quality_flags' | 'daily_metrics' | 'daily_health' | 'training_metrics' | 'training_metric_observations' | 'wellness_samples' | 'health_sessions' | 'power_bests' | 'activity_decoupling' | 'nutrition_days' | 'nutrition_items' | 'events' | 'workouts' | 'workout_documents' | 'training_plans' | 'routes' | 'gear' | 'devices' | 'goals' | 'achievements' | 'messages' | 'source_entities' | 'activity_samples' | 'activity_links' | 'activity_segments' | 'segments' | 'segment_effort_history' | 'strava_gear';
1
+ export type DatasetName = 'activities' | 'canonical_activities' | 'canonical_activity_facts' | 'activity_sources' | 'activity_summaries' | 'activity_intervals' | 'swim_lengths' | 'swim_sets' | 'activity_quality_flags' | 'daily_metrics' | 'daily_health' | 'training_metrics' | 'training_metric_observations' | 'wellness_samples' | 'health_sessions' | 'power_bests' | 'activity_decoupling' | 'nutrition_days' | 'nutrition_items' | 'events' | 'workouts' | 'workout_documents' | 'training_plans' | 'routes' | 'gear' | 'devices' | 'goals' | 'achievements' | 'messages' | 'source_entities' | 'activity_samples' | 'activity_links' | 'activity_segments' | 'segments' | 'segment_effort_history' | 'strava_gear' | 'course_geometry';
2
2
  export type CatalogColumn = {
3
3
  name: string;
4
4
  type: string;
@@ -113,6 +113,10 @@ export const DATASET_CATALOG = {
113
113
  name: 'activity_samples', description: 'Registered normalized activity samples from immutable Parquet; callers cannot supply file paths.', dateColumn: 'timestamp_utc', provenanceColumns: ['provider', 'activity_source_id', 'content_hash'], permittedGroupings: ['provider', 'activity_source_id'], samplesOnly: true,
114
114
  columns: [column('provider', 'VARCHAR', { filterable: true, groupable: true }), column('content_hash', 'VARCHAR', { filterable: true, groupable: true }), column('activity_source_id', 'VARCHAR', { filterable: true, groupable: true }), column('timestamp_utc', 'TIMESTAMPTZ', { filterable: true }), column('elapsed_s', 'DOUBLE', { unit: 's', metric: true }), column('distance_m', 'DOUBLE', { unit: 'm', metric: true }), column('latitude', 'DOUBLE', { unit: 'degrees', metric: true }), column('longitude', 'DOUBLE', { unit: 'degrees', metric: true }), column('altitude_m', 'DOUBLE', { unit: 'm', metric: true }), column('heart_rate_bpm', 'DOUBLE', { unit: 'bpm', metric: true }), column('power_w', 'DOUBLE', { unit: 'W', metric: true }), column('cadence_rpm', 'DOUBLE', { unit: 'rpm', metric: true }), column('speed_mps', 'DOUBLE', { unit: 'm/s', metric: true }), column('temperature_c', 'DOUBLE', { unit: 'C', metric: true }), column('grade_pct', 'DOUBLE', { unit: '%', metric: true }), column('left_right_balance_pct', 'DOUBLE', { unit: '%', metric: true }), column('left_torque_effectiveness_pct', 'DOUBLE', { unit: '%', metric: true }), column('right_torque_effectiveness_pct', 'DOUBLE', { unit: '%', metric: true }), column('left_pedal_smoothness_pct', 'DOUBLE', { unit: '%', metric: true }), column('right_pedal_smoothness_pct', 'DOUBLE', { unit: '%', metric: true }), column('left_power_phase_start_deg', 'DOUBLE', { unit: 'degrees', metric: true }), column('left_power_phase_end_deg', 'DOUBLE', { unit: 'degrees', metric: true }), column('right_power_phase_start_deg', 'DOUBLE', { unit: 'degrees', metric: true }), column('right_power_phase_end_deg', 'DOUBLE', { unit: 'degrees', metric: true }), column('left_power_phase_peak_start_deg', 'DOUBLE', { unit: 'degrees', metric: true }), column('left_power_phase_peak_end_deg', 'DOUBLE', { unit: 'degrees', metric: true }), column('right_power_phase_peak_start_deg', 'DOUBLE', { unit: 'degrees', metric: true }), column('right_power_phase_peak_end_deg', 'DOUBLE', { unit: 'degrees', metric: true }), column('left_platform_center_offset_mm', 'DOUBLE', { unit: 'mm', metric: true }), column('right_platform_center_offset_mm', 'DOUBLE', { unit: 'mm', metric: true }), column('extras_json', 'JSON')],
115
115
  },
116
+ course_geometry: {
117
+ name: 'course_geometry', relation: 'course_geometry', description: 'Per-point Garmin course/route geometry (lat/lon/altitude_m/distance_m) normalized from a race event course. Resolve a courseId first with resolve_event_course, then query these rows for an elevation/height profile, gradient, or GPX-style track.', provenanceColumns: ['course_id', 'raw_object_hash'], permittedGroupings: ['course_id'],
118
+ columns: [column('course_id', 'VARCHAR', { filterable: true, groupable: true }), column('seq', 'INTEGER', { metric: true }), column('lat', 'DOUBLE', { unit: 'degrees', metric: true }), column('lon', 'DOUBLE', { unit: 'degrees', metric: true }), column('altitude_m', 'DOUBLE', { unit: 'm', metric: true }), column('distance_m', 'DOUBLE', { unit: 'm', metric: true }), column('raw_object_hash', 'VARCHAR')],
119
+ },
116
120
  };
117
121
  function domain(name, id, description, dateColumn) {
118
122
  return {
@@ -44,6 +44,26 @@ function normalizeRelation(name) {
44
44
  const relation = name.trim().replace(/\s+(?:as\s+)?(?:"[^"]+"|[A-Za-z_][A-Za-z0-9_]*)\s*$/i, '');
45
45
  return relation.replaceAll('"', '').split('.').at(-1).toLowerCase();
46
46
  }
47
+ // Physical base tables that are reachable only through cataloged views
48
+ // (e.g. the events/workouts/routes/… domain projections over domain_entities).
49
+ // They must be accepted when DuckDB resolves a cataloged view down to its base
50
+ // table after tableNames(), but must never be writable directly by name.
51
+ const INDIRECT_BASE_TABLES = new Set(['domain_entities']);
52
+ // dataset->named tool hints for the common dead-end relations, so a rejected
53
+ // query tells the agent where to go instead of just that it was denied.
54
+ const RELATION_HINTS = {
55
+ events: 'events is a domain projection; use aggregate_data on events or resolve_event_course(eventId) / search_context to read events and races.',
56
+ workouts: 'workouts is a domain projection; use aggregate_data on workouts or search_context to read workouts.',
57
+ workout_documents: 'workout_documents is a domain projection; use aggregate_data on workout_documents or search_context to read workout documents.',
58
+ training_plans: 'training_plans is a domain projection; use aggregate_data on training_plans or search_context to read training plans.',
59
+ routes: 'routes is a domain projection; use aggregate_data on routes or search_context to read routes.',
60
+ gear: 'gear is a domain projection; use aggregate_data on gear or search_context to read saved gear.',
61
+ devices: 'devices is a domain projection; use aggregate_data on devices or search_context to read devices.',
62
+ goals: 'goals is a domain projection; use aggregate_data on goals or search_context to read goals and challenges.',
63
+ achievements: 'achievements is a domain projection; use aggregate_data on achievements or search_context to read badges and personal records.',
64
+ messages: 'messages is a domain projection; use aggregate_data on messages or search_context to read provider messages.',
65
+ course_geometry: 'course_geometry is the per-point course track (lat/lon/altitude_m/distance_m); resolve a race course first with resolve_event_course, then query it for elevation/height profiles.',
66
+ };
47
67
  export async function queryReadOnlyData(repository, request) {
48
68
  const sql = safeSql(request.sql);
49
69
  let relations;
@@ -53,16 +73,28 @@ export async function queryReadOnlyData(repository, request) {
53
73
  catch (error) {
54
74
  throw new QueryValidationError(`SQL could not be inspected: ${error instanceof Error ? error.message : String(error)}`);
55
75
  }
56
- const allowed = new Set(Object.values(DATASET_CATALOG).filter((dataset) => !dataset.samplesOnly && dataset.relation).map((dataset) => dataset.relation.toLowerCase()));
76
+ // The catalog relation names form the strict allow-list for what a query may
77
+ // name directly (Check B below). Base tables reached only through cataloged
78
+ // views are additionally permitted after DuckDB resolves views for Check A,
79
+ // but they are never valid relation names in the SQL text.
80
+ const allowedNamed = new Set(Object.values(DATASET_CATALOG).filter((dataset) => !dataset.samplesOnly && dataset.relation).map((dataset) => dataset.relation.toLowerCase()));
81
+ const allowedForExpansion = new Set([...allowedNamed, ...INDIRECT_BASE_TABLES]);
82
+ const rejected = [];
57
83
  for (const relation of relations) {
58
- if (!allowed.has(normalizeRelation(relation)))
59
- throw new QueryValidationError(`Relation ${relation} is not in the read-only catalog.`);
84
+ const normalized = normalizeRelation(relation);
85
+ if (!allowedForExpansion.has(normalized))
86
+ rejected.push(relation);
87
+ }
88
+ if (rejected.length > 0) {
89
+ const name = normalizeRelation(rejected[0]);
90
+ throw new QueryValidationError(`Relation ${rejected[0]} is not in the read-only catalog.${RELATION_HINTS[name] ? ` ${RELATION_HINTS[name]}` : ''}`);
60
91
  }
61
92
  const ctes = new Set([...sql.matchAll(/(?:\bwith|,)\s*([A-Za-z_][A-Za-z0-9_]*)\s+as\s*\(/gi)].map((match) => match[1].toLowerCase()));
62
93
  for (const match of sql.matchAll(/\b(?:from|join)\s+([A-Za-z_][A-Za-z0-9_.]*)/gi)) {
63
94
  const relation = normalizeRelation(match[1]);
64
- if (!allowed.has(relation) && !ctes.has(relation))
65
- throw new QueryValidationError(`Relation ${match[1]} is not in the read-only catalog.`);
95
+ if (!allowedNamed.has(relation) && !ctes.has(relation)) {
96
+ throw new QueryValidationError(`Relation ${match[1]} is not in the read-only catalog.${RELATION_HINTS[relation] ? ` ${RELATION_HINTS[relation]}` : ''}`);
97
+ }
66
98
  }
67
99
  const values = request.values ?? {};
68
100
  if ('__catence_page_size' in values || '__catence_offset' in values)
@@ -31,7 +31,10 @@ const MCP_INSTRUCTIONS = [
31
31
  'Never conclude from VO2max alone; pull full indicator set (LT/FTP/power/race-prediction).',
32
32
  'When summary metric is NULL (e.g. avg_power), fall through to FIT-derived/samples layer before declaring absence.',
33
33
  'Before reusing any prior course/elevation profile, resolve event\'s courseId and diff it against past activity\'s course; different courseId => different profile.',
34
- 'If a needed tool is not visible in your current tool list (the host may truncate it to a subset), read the catence://tools resource to discover all available tools before concluding a capability is absent.',
34
+ 'If a needed tool is not visible in your current tool list (the host may truncate it to a subset), read the catence://tools resource or call discover_tools to inventory every available tool before concluding a capability is absent.',
35
+ 'To find a next or upcoming race: list dates with aggregate_data on the events dataset (group or filter by occurred_on), identify the event name via search_context or its payload, then call resolve_event_course(eventId) to resolve the courseId and whether that course geometry is synced.',
36
+ 'For a course elevation/height profile or GPX track: call resolve_event_course(eventId) first (it reports the courseId, synced geometry, and geometry sample count), then read the course_geometry dataset with read_series / aggregate_data / query_read_only_data for per-point altitude_m, lat, lon, and distance_m. Never claim a profile is absent before resolve_event_course returns.',
37
+ 'Never treat a tool error or empty result as a dead end: switch strategy or re-read the routing guidance before re-running the same query.',
35
38
  'For swim analysis: use find_activities (sports: ["lap_swimming"] or sport: "lap_swimming") to list pool sessions. Use get_swim_laps for per-length data (pace from duration_s/pool_length_m, SPL from stroke_count, SWOLF from duration_s+stroke_count). Use swim_progress_report for session-level SWOLF, fastest-100, and stroke cadence. Per-length SWOLF is derived when the provider does not supply it. Per-length distance is typically null in Garmin FIT; use pool_length_m for pace calculations.',
36
39
  ].join(' ');
37
40
  function textResult(value) {
@@ -143,9 +146,21 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
143
146
  // enumerate all tools even when the host harness truncates the visible tool list.
144
147
  const TOOL_INDEX = [];
145
148
  function registerTool(name, config, callback) {
146
- TOOL_INDEX.push({ name, title: config.title ?? name, description: config.description ?? '' });
149
+ TOOL_INDEX.push({ name, title: config.title ?? name, description: config.description ?? '', keywords: config.keywords ?? [] });
147
150
  server.registerTool(name, config, callback);
148
151
  }
152
+ // Keyword-rich routing map surfacing the recommended tool chain per question
153
+ // domain. Surfaced in catence://tools so a host can navigate without knowing
154
+ // every tool name up front.
155
+ const TOOL_ROUTING = [
156
+ { question: 'next race / upcoming event', steps: ['aggregate_data (events, by occurred_on)', 'search_context', 'resolve_event_course'] },
157
+ { question: 'course elevation / height profile / GPX track', steps: ['resolve_event_course', 'read_series / aggregate_data (course_geometry)'] },
158
+ { question: 'swim per-length analysis (pace / SPL / SWOLF)', steps: ['find_activities', 'get_swim_laps', 'swim_progress_report'] },
159
+ { question: 'segments / climbs / KOM / PR', steps: ['get_activity_segments', 'hydrate_strava_segment_history'] },
160
+ { question: 'race readiness / fitness targets', steps: ['readiness_baseline', 'analyze_series', 'aggregate_data'] },
161
+ { question: 'recovery / daily or weekly load', steps: ['review_daily_recovery_load', 'review_weekly_training', 'wellness_baselines'] },
162
+ { question: 'cycling FTP / VO₂max / power curve', steps: ['get_ftp_history', 'get_vo2max_history', 'power_coverage_report', 'power_curve_trend'] },
163
+ ];
149
164
  const promptAthleteSchema = catalogPaths ? { athleteId: athleteIdSchema } : {};
150
165
  if (catalogPaths) {
151
166
  registerTool('list_athletes', {
@@ -460,6 +475,7 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
460
475
  registerTool('resolve_event_course', {
461
476
  title: 'Resolve a race event course and its synced geometry',
462
477
  description: 'Given a Garmin/Intervals eventId, return the courseId it references and whether that course geometry has been synced. When geometry is absent, the result carries an explicit caveat so a prior course profile is never reused. Optionally pass pastActivityId to diff the event course against that activity\'s course.',
478
+ keywords: ['course', 'race course', 'event course', 'elevation profile', 'height profile', 'GPX track', 'course geometry', 'gradient', 'climb', 'GPS track'],
463
479
  inputSchema: { eventId: z.string().min(1), pastActivityId: z.string().min(1).optional() },
464
480
  }, tool('resolve_event_course', async (input) => useRepository(activePaths(), async (repository) => {
465
481
  const events = new EventsService(repository);
@@ -479,6 +495,7 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
479
495
  registerTool('find_activities', {
480
496
  title: 'Find activities and likely races',
481
497
  description: 'Find canonical activities by sport, distance, name, and date. Results are paginated and transparently flag likely-race signals without claiming provider-confirmed race metadata. Read-only.',
498
+ keywords: ['race', 'races', 'next race', 'events', 'upcoming event', 'activity list', 'swim sessions', 'pool sessions', 'laps', 'recent runs'],
482
499
  inputSchema: {
483
500
  sport: z.string().min(1).optional(),
484
501
  sports: z.array(z.string().min(1)).min(1).max(12).optional(),
@@ -491,16 +508,19 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
491
508
  registerTool('get_swim_laps', {
492
509
  title: 'Read explicit swim lengths and grouped sets',
493
510
  description: 'Return source-aware swim lengths when a provider actually supplied them, plus Garmin detected and Intervals.icu auto-detected sets. A missing length list is reported as unavailable; laps are never reconstructed from samples.',
511
+ keywords: ['swim', 'swimming', 'per-length', 'pace', 'SPL', 'SWOLF', 'pool', 'lengths', 'laps', 'cadence'],
494
512
  inputSchema: { activityId: z.string().min(1), provider: z.enum(['garmin', 'intervals']).optional() },
495
513
  }, tool('get_swim_laps', async (input) => useRepository(activePaths(), (repository) => new SwimmingService(repository).swimLaps(input))));
496
514
  registerTool('swim_progress_report', {
497
515
  title: 'Build a source-aware swim progress report',
498
516
  description: 'Compare Garmin swimming summaries and data completeness over a date range, optionally within one pool length. It does not derive pace trends from moving time divided by distance.',
517
+ keywords: ['swim', 'swimming', 'progress', 'SWOLF', 'fastest 100', 'cadence', 'pool length', 'swim trends'],
499
518
  inputSchema: { startDate: z.string().date().optional(), endDate: z.string().date().optional(), poolLengthM: z.number().positive().max(200).optional() },
500
519
  }, tool('swim_progress_report', async (input) => useRepository(activePaths(), (repository) => new SwimmingService(repository).swimProgressReport(input))));
501
520
  registerTool('get_activity_segments', {
502
521
  title: 'Hydrate and read an activity’s Strava segments',
503
522
  description: 'For a selected activity’s segments, climbs, KOM/PRs, or per-segment analysis: automatically hydrate the matching Strava activity before returning its persisted segment efforts. Use this before saying segment data is unavailable.',
523
+ keywords: ['segments', 'climbs', 'KOM', 'PR', 'grade by segment', 'Strava segments', 'segment efforts', 'per-segment analysis'],
504
524
  inputSchema: { activityId: z.string().min(1), refresh: z.boolean().optional(), limit: z.number().int().min(1).max(500).optional() },
505
525
  }, tool('get_activity_segments', async (input) => {
506
526
  const hydration = await hydrateActivity(activePaths(), input.activityId, input.refresh ?? false);
@@ -542,6 +562,7 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
542
562
  }, tool('query_read_only_data', async (input) => useRepository(activePaths(), (repository) => queryReadOnlyData(repository, input))));
543
563
  registerTool('search_context', {
544
564
  title: 'Search generated context', description: 'Search compact generated activity, plan, nutrition, and message context. Results identify authoritative follow-up tools rather than making numerical claims.',
565
+ keywords: ['event name', 'race name', 'course', 'elevation', 'GPX', 'activity summary', 'plan', 'nutrition', 'find the event'],
545
566
  inputSchema: { query: z.string().min(2).max(500), filters: z.array(z.object({ column: z.enum(['provider', 'entity_type', 'occurred_on']), op: z.enum(['eq', 'in', 'between']), value: z.unknown() })).max(20).optional(), limit: z.number().int().min(1).max(50).optional() },
546
567
  }, tool('search_context', async (input) => useRepository(activePaths(), (repository) => searchContext(repository, input))));
547
568
  registerTool('hydrate_strava_activity', {
@@ -628,6 +649,19 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
628
649
  const data = await hydrateStravaSegmentHistory(activePaths(), input.segmentId, input.refresh ?? false);
629
650
  return { data, coverage: await useRepository(activePaths(), (repository) => repository.coverage()), provenance: { provider: 'strava', operation: 'segment_effort_history', archivedBeforeNormalization: true }, caveats: ['Only the authenticated athlete’s historic segment efforts are requested.'] };
630
651
  }));
652
+ // Registered last so every tool above is present in the tool index before its
653
+ // one-line catalog is baked into this tool's description. Survives host tool
654
+ // truncation because tool descriptions are always visible to the model.
655
+ registerTool('discover_tools', {
656
+ title: 'Discover all Catence tools and routing',
657
+ description: [
658
+ 'Inventory the full Catence tool surface even when the host truncates its visible tool list. Query by question: next race/upcoming event (aggregate_data on events, search_context, resolve_event_course), course elevation/height profile/GPX (resolve_event_course then read_series or aggregate_data on course_geometry), swim per-length pace/SPL/SWOLF (find_activities, get_swim_laps, swim_progress_report), segments/climbs/KOM/PR (get_activity_segments), race readiness (readiness_baseline), recovery (review_daily_recovery_load, review_weekly_training), cycling FTP/VO₂max/power (get_ftp_history, get_vo2max_history, power_coverage_report, power_curve_trend).',
659
+ ...TOOL_INDEX.map((entry) => `${entry.name} — ${entry.title}`),
660
+ ].join('\n'),
661
+ }, tool('discover_tools', async () => ({
662
+ data: { count: TOOL_INDEX.length, routing: TOOL_ROUTING, tools: TOOL_INDEX.map((entry) => ({ name: entry.name, purpose: entry.title, keywords: entry.keywords })) },
663
+ provenance: { catalog: 'Catence tool index' }, query: {}, caveats: ['Use this to map a question to its named tool before falling back to SQL.'],
664
+ })));
631
665
  if (catalogPaths) {
632
666
  server.registerResource('athletes', 'catence://athletes', { title: 'Configured athlete roster', mimeType: 'application/json' }, async (uri) => {
633
667
  try {
@@ -680,7 +714,8 @@ export function createCatenceMcpServer(paths = resolveCatalogPaths(), dependenci
680
714
  return resource(uri, {
681
715
  count: TOOL_INDEX.length,
682
716
  note: 'If a needed tool is not visible in your current tool list (the host may truncate it), use this index to discover all available tools before concluding a capability is absent. SQL-only tables training_metric_observations and source_entities are reachable via query_read_only_data / describe_data.',
683
- tools: TOOL_INDEX.map((tool) => ({ name: tool.name, purpose: tool.title, description: tool.description })),
717
+ routing: TOOL_ROUTING,
718
+ tools: TOOL_INDEX.map((tool) => ({ name: tool.name, purpose: tool.title, description: tool.description, keywords: tool.keywords })),
684
719
  });
685
720
  });
686
721
  return server;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "catence",
3
- "version": "0.2.0",
3
+ "version": "0.2.1-beta.2",
4
4
  "description": "Local MCP access to normalized Garmin, Intervals.icu, and targeted Strava fitness data.",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "repository": {
@@ -82,8 +82,9 @@
82
82
  "vitest": "^3.2.4"
83
83
  },
84
84
  "overrides": {
85
- "fast-uri": "3.1.5",
85
+ "fast-uri": "3.1.7",
86
86
  "hono": "4.12.34",
87
- "nanoid": "3.3.18"
87
+ "nanoid": "3.3.18",
88
+ "qs": "6.16.0"
88
89
  }
89
90
  }