incremnt 0.6.1 → 0.7.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.
package/src/lib.js CHANGED
@@ -16,6 +16,21 @@ import { createTransport } from './transport.js';
16
16
  import { formatPretty, formatHelp } from './format.js';
17
17
  import { printLogo } from './logo.js';
18
18
  import { runBrowseCli } from './browse.js';
19
+ import { ValidationError, validateOptions } from './validate.js';
20
+ import { projectFields } from './fields.js';
21
+ import { buildWriteRequest } from './remote.js';
22
+
23
+ const schemaById = new Map([
24
+ ...commandSchema.map((c) => [c.id, c]),
25
+ ...writeCommandSchema.map((c) => [c.id, c])
26
+ ]);
27
+
28
+ function pageAllRecords(payload, commandEntry) {
29
+ if (Array.isArray(payload)) return payload;
30
+ const key = commandEntry?.pageAllKey;
31
+ if (key && Array.isArray(payload?.[key])) return payload[key];
32
+ return [payload];
33
+ }
19
34
 
20
35
  function parseArgs(argv) {
21
36
  const commandTokens = [];
@@ -73,15 +88,7 @@ export async function runCli(argv, stdout, stderr) {
73
88
  })[command] ?? command;
74
89
 
75
90
  const sessionState = await readSessionState();
76
- const hasTransport = Boolean(
77
- sessionState?.session?.transport?.baseUrl
78
- || sessionState?.session?.transport?.fixturePath
79
- );
80
- const isAuthenticated = Boolean(
81
- sessionState?.session
82
- && !isSessionExpired(sessionState.session)
83
- && hasTransport
84
- );
91
+ const isAuthenticated = Boolean(sessionState?.session && !isSessionExpired(sessionState.session));
85
92
 
86
93
  if (!command || options.help) {
87
94
  await printLogo(stdout);
@@ -353,9 +360,59 @@ export async function runCli(argv, stdout, stderr) {
353
360
  const wantJson = options.json || !(stdout.isTTY ?? false);
354
361
  const explicitJson = Boolean(options.json);
355
362
 
363
+ // Reject --dry-run on any command that doesn't declare dryRun support
364
+ // (reads, unknown commands, and writes without dryRun: true). Without this
365
+ // guard, --dry-run is silently dropped and the underlying command runs.
366
+ if (options['dry-run'] && !schemaById.get(normalizedCommand)?.dryRun) {
367
+ const cmdLabel = schemaById.get(normalizedCommand)?.command ?? command ?? normalizedCommand;
368
+ const message = `--dry-run is not supported for ${cmdLabel}.`;
369
+ if (explicitJson) {
370
+ stdout.write(`${JSON.stringify({ error: message, code: 'UNSUPPORTED_DRY_RUN' }, null, 2)}\n`);
371
+ } else {
372
+ stderr.write(`${message}\n`);
373
+ }
374
+ return 1;
375
+ }
376
+
356
377
  if (writeCommands.has(normalizedCommand)) {
378
+ let validated;
379
+ try {
380
+ validated = validateOptions(schemaById.get(normalizedCommand), options);
381
+ } catch (error) {
382
+ if (error instanceof ValidationError) {
383
+ if (explicitJson) {
384
+ stdout.write(`${JSON.stringify({ error: error.message, code: error.code, option: error.option }, null, 2)}\n`);
385
+ } else {
386
+ stderr.write(`${error.message}\n`);
387
+ }
388
+ return 1;
389
+ }
390
+ throw error;
391
+ }
392
+
393
+ const cmdEntry = schemaById.get(normalizedCommand);
394
+ if (options['dry-run']) {
395
+ // Outer guard already verified cmdEntry.dryRun is true here.
396
+ try {
397
+ const request = await buildWriteRequest(normalizedCommand, validated, sessionState);
398
+ stdout.write(`${JSON.stringify({
399
+ dryRun: true,
400
+ command: cmdEntry.command,
401
+ request
402
+ }, null, 2)}\n`);
403
+ return 0;
404
+ } catch (error) {
405
+ if (explicitJson) {
406
+ stdout.write(`${JSON.stringify({ error: error.message, code: error.code ?? null }, null, 2)}\n`);
407
+ } else {
408
+ stderr.write(`${error.message}\n`);
409
+ }
410
+ return 1;
411
+ }
412
+ }
413
+
357
414
  try {
358
- const payload = await transport.executeWriteCommand(normalizedCommand, options);
415
+ const payload = await transport.executeWriteCommand(normalizedCommand, validated);
359
416
  if (wantJson) {
360
417
  stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
361
418
  } else {
@@ -386,13 +443,59 @@ export async function runCli(argv, stdout, stderr) {
386
443
  return 1;
387
444
  }
388
445
 
446
+ let validatedRead;
389
447
  try {
390
- const payload = await transport.executeReadCommand(normalizedCommand, options);
448
+ validatedRead = validateOptions(schemaById.get(normalizedCommand), options);
449
+ } catch (error) {
450
+ if (error instanceof ValidationError) {
451
+ if (explicitJson) {
452
+ stdout.write(`${JSON.stringify({ error: error.message, code: error.code, option: error.option }, null, 2)}\n`);
453
+ } else {
454
+ stderr.write(`${error.message}\n`);
455
+ }
456
+ return 1;
457
+ }
458
+ throw error;
459
+ }
460
+
461
+ const readCmdEntry = schemaById.get(normalizedCommand);
462
+
463
+ if (options.fields !== undefined && !readCmdEntry?.supportsFields) {
464
+ const message = `--fields is not supported for ${readCmdEntry?.command ?? normalizedCommand}.`;
465
+ if (explicitJson) {
466
+ stdout.write(`${JSON.stringify({ error: message, code: 'UNSUPPORTED_FIELDS' }, null, 2)}\n`);
467
+ } else {
468
+ stderr.write(`${message}\n`);
469
+ }
470
+ return 1;
471
+ }
472
+
473
+ if (options['page-all'] && !readCmdEntry?.supportsPageAll) {
474
+ const message = `--page-all is not supported for ${readCmdEntry?.command ?? normalizedCommand}.`;
475
+ if (explicitJson) {
476
+ stdout.write(`${JSON.stringify({ error: message, code: 'UNSUPPORTED_PAGE_ALL' }, null, 2)}\n`);
477
+ } else {
478
+ stderr.write(`${message}\n`);
479
+ }
480
+ return 1;
481
+ }
482
+
483
+ try {
484
+ const payload = await transport.executeReadCommand(normalizedCommand, validatedRead);
485
+ const projected = options.fields !== undefined ? projectFields(payload, options.fields) : payload;
486
+
487
+ if (options['page-all'] && readCmdEntry?.supportsPageAll) {
488
+ for (const record of pageAllRecords(projected, readCmdEntry)) {
489
+ stdout.write(`${JSON.stringify(record)}\n`);
490
+ }
491
+ return 0;
492
+ }
493
+
391
494
  if (wantJson) {
392
- stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
495
+ stdout.write(`${JSON.stringify(projected, null, 2)}\n`);
393
496
  } else {
394
- const pretty = formatPretty(normalizedCommand, payload);
395
- stdout.write(`${pretty ?? JSON.stringify(payload, null, 2)}\n`);
497
+ const pretty = formatPretty(normalizedCommand, projected);
498
+ stdout.write(`${pretty ?? JSON.stringify(projected, null, 2)}\n`);
396
499
  }
397
500
 
398
501
  return 0;
package/src/mcp.js CHANGED
@@ -11,6 +11,9 @@ import { commandSchema, writeCommands, writeCommandSchema } from './contract.js'
11
11
  import { listCoachReadTools } from './queries.js';
12
12
  import { readSessionState } from './state.js';
13
13
  import { createTransport } from './transport.js';
14
+ import { ValidationError, validateOptions } from './validate.js';
15
+ import { projectFields } from './fields.js';
16
+ import { buildWriteRequest } from './remote.js';
14
17
 
15
18
  const globalScope = Function('return this')();
16
19
 
@@ -24,6 +27,14 @@ function commandShape(cmd) {
24
27
  shape[opt.name] = field;
25
28
  }
26
29
 
30
+ if (cmd.supportsFields) {
31
+ shape.fields = z.string().optional().describe('Comma-separated top-level keys to keep in the response. Cuts payload size for context-bound agents.');
32
+ }
33
+
34
+ if (cmd.dryRun) {
35
+ shape['dry-run'] = z.boolean().optional().describe('Preview the request without sending. Returns { dryRun: true, command, request }.');
36
+ }
37
+
27
38
  return shape;
28
39
  }
29
40
 
@@ -57,9 +68,43 @@ export function registerMcpTools(server, {
57
68
  readSessionStateFn = readSessionState,
58
69
  createTransportFn = createTransport
59
70
  } = {}) {
60
- for (const cmd of [...commandSchema, ...writeCommandSchema]) {
61
- server.tool(cmd.id, cmd.description, commandShape(cmd), async (args) => {
71
+ for (const cmd of [...commandSchema, ...writeCommandSchema].filter((entry) => entry.mcp !== false)) {
72
+ const description = cmd.agentNotes
73
+ ? `${cmd.description}\n\nNotes for agents:\n${cmd.agentNotes}`
74
+ : cmd.description;
75
+ server.tool(cmd.id, description, commandShape(cmd), async (args, extra) => {
62
76
  try {
77
+ // Reject --dry-run on tools that don't support it. Zod strips unknown
78
+ // fields silently, so without this guard a caller passing dry-run on
79
+ // an unsupported tool would execute the real mutation. Check the raw
80
+ // request when available; fall back to args.
81
+ const rawDryRun = extra?.request?.params?.arguments?.['dry-run'] ?? args?.['dry-run'];
82
+ if (rawDryRun && !cmd.dryRun) {
83
+ return {
84
+ content: [{
85
+ type: 'text',
86
+ text: JSON.stringify({ error: `--dry-run is not supported for ${cmd.command}.`, code: 'UNSUPPORTED_DRY_RUN' }, null, 2)
87
+ }],
88
+ isError: true
89
+ };
90
+ }
91
+
92
+ let validated;
93
+ try {
94
+ validated = validateOptions(cmd, args);
95
+ } catch (error) {
96
+ if (error instanceof ValidationError) {
97
+ return {
98
+ content: [{
99
+ type: 'text',
100
+ text: JSON.stringify({ error: error.message, code: error.code, option: error.option }, null, 2)
101
+ }],
102
+ isError: true
103
+ };
104
+ }
105
+ throw error;
106
+ }
107
+
63
108
  const sessionState = await readSessionStateFn();
64
109
  const transport = await createTransportFn({}, sessionState);
65
110
 
@@ -70,12 +115,23 @@ export function registerMcpTools(server, {
70
115
  };
71
116
  }
72
117
 
118
+ if (cmd.dryRun && validated['dry-run']) {
119
+ const request = await buildWriteRequest(cmd.id, validated, sessionState);
120
+ return {
121
+ content: [{ type: 'text', text: JSON.stringify({ dryRun: true, command: cmd.command, request }, null, 2) }]
122
+ };
123
+ }
124
+
73
125
  const result = writeCommands.has(cmd.id)
74
- ? await transport.executeWriteCommand(cmd.id, args)
75
- : await transport.executeReadCommand(cmd.id, args);
126
+ ? await transport.executeWriteCommand(cmd.id, validated)
127
+ : await transport.executeReadCommand(cmd.id, validated);
128
+
129
+ const projected = cmd.supportsFields && validated.fields !== undefined
130
+ ? projectFields(result, validated.fields)
131
+ : result;
76
132
 
77
133
  return {
78
- content: [{ type: 'text', text: JSON.stringify(result, null, 2) }]
134
+ content: [{ type: 'text', text: JSON.stringify(projected, null, 2) }]
79
135
  };
80
136
  } catch (error) {
81
137
  const message = error && error.message ? error.message : String(error);
package/src/queries.js CHANGED
@@ -3610,7 +3610,42 @@ function askToolMetadata(tools = [], provenance = []) {
3610
3610
  };
3611
3611
  }
3612
3612
 
3613
- export function askRoutedContext(snapshot, question, { exclude = new Set(), coachFacts = null } = {}) {
3613
+ function appendCoachObservationsContextBeforeExcludeNote(lines, observations, exclude = new Set()) {
3614
+ if (exclude.has('coach_observations')) return [];
3615
+ const usable = (Array.isArray(observations) ? observations : [])
3616
+ .filter((observation) => observation?.id && observation?.summary)
3617
+ .slice(0, 3);
3618
+ if (usable.length === 0) return [];
3619
+
3620
+ const note = buildExcludeNote(exclude);
3621
+ const noteAtEnd = note && lines.at(-1) === note;
3622
+ if (noteAtEnd) {
3623
+ lines.pop();
3624
+ if (lines.at(-1) === '') lines.pop();
3625
+ }
3626
+ const section = [
3627
+ '',
3628
+ 'Coach observations (derived from training data, not user-stated facts):'
3629
+ ];
3630
+ for (const observation of usable) {
3631
+ const parts = [
3632
+ `[${observation.kind ?? 'observation'}] ${observation.summary}`,
3633
+ observation.actionText ? `Action: ${observation.actionText}` : null,
3634
+ observation.sourceComponent ? `component=${observation.sourceComponent}` : null,
3635
+ `confidence=${Number(observation.confidence ?? 0).toFixed(2)}`,
3636
+ `observation-id=${observation.id}`
3637
+ ].filter(Boolean);
3638
+ section.push(` ${parts.join(' ')}`);
3639
+ }
3640
+ lines.push(...section);
3641
+ if (noteAtEnd) {
3642
+ lines.push('');
3643
+ lines.push(note);
3644
+ }
3645
+ return usable.map((observation) => observation.id);
3646
+ }
3647
+
3648
+ export function askRoutedContext(snapshot, question, { exclude = new Set(), coachFacts = null, coachObservations = null } = {}) {
3614
3649
  const { route, namedExercises } = routeAskQuestion(snapshot, question);
3615
3650
  let effectiveRoute = route;
3616
3651
  let fallbackRoute = null;
@@ -3657,6 +3692,7 @@ export function askRoutedContext(snapshot, question, { exclude = new Set(), coac
3657
3692
  const factLines = built.context.split('\n');
3658
3693
  const includedFacts = rankedCoachFactsForAsk(snapshot, question, effectiveRoute, { facts: coachFacts });
3659
3694
  const includedCoachFactIds = appendCoachFactsContextBeforeExcludeNote(factLines, includedFacts, exclude);
3695
+ const includedCoachObservationIds = appendCoachObservationsContextBeforeExcludeNote(factLines, coachObservations, exclude);
3660
3696
  const includedCoachFactKinds = uniqueArray(includedFacts.map((fact) => fact.kind));
3661
3697
  const includedCoachFactSources = uniqueArray(includedFacts.map((fact) => {
3662
3698
  const sourceSessionId = String(fact.sourceSessionId ?? '');
@@ -3666,7 +3702,11 @@ export function askRoutedContext(snapshot, question, { exclude = new Set(), coac
3666
3702
  }).filter(Boolean));
3667
3703
  built = {
3668
3704
  context: factLines.join('\n'),
3669
- sections: includedFacts.length > 0 ? [...built.sections, 'coach_facts'] : built.sections
3705
+ sections: [
3706
+ ...built.sections,
3707
+ ...(includedFacts.length > 0 ? ['coach_facts'] : []),
3708
+ ...(includedCoachObservationIds.length > 0 ? ['coach_observations'] : [])
3709
+ ]
3670
3710
  };
3671
3711
 
3672
3712
  return {
@@ -3683,6 +3723,8 @@ export function askRoutedContext(snapshot, question, { exclude = new Set(), coac
3683
3723
  coachFactIds: includedCoachFactIds,
3684
3724
  coachFactKinds: includedCoachFactKinds,
3685
3725
  coachFactSources: includedCoachFactSources,
3726
+ includedCoachObservationIds,
3727
+ coachObservationIds: includedCoachObservationIds,
3686
3728
  contextCharCount: built.context.length,
3687
3729
  ...toolMetadata
3688
3730
  }
@@ -4071,12 +4113,51 @@ export function healthSummary(snapshot, days = 14) {
4071
4113
  nights: recentSleep.length
4072
4114
  },
4073
4115
  bodyWeight: (() => {
4074
- const recent = (metrics.bodyWeight ?? []).filter((m) => m.date >= cutoff);
4075
- if (recent.length === 0) return { latest: null, readings: 0 };
4116
+ const profileWeightKg = Number(snapshot.user?.weightKg);
4117
+ const resolvedProfileWeightKg = Number.isFinite(profileWeightKg) && profileWeightKg > 0
4118
+ ? Math.round(profileWeightKg * 10) / 10
4119
+ : null;
4120
+ const rows = (metrics.bodyWeight ?? [])
4121
+ .filter((m) => m?.date && Number.isFinite(Number(m.value ?? m.weight)))
4122
+ .map((m) => ({
4123
+ date: String(m.date).slice(0, 10),
4124
+ value: Number(m.value ?? m.weight)
4125
+ }))
4126
+ .sort((a, b) => a.date.localeCompare(b.date));
4127
+ const recent = rows.filter((m) => m.date >= cutoff);
4128
+ if (recent.length === 0 && resolvedProfileWeightKg != null) {
4129
+ return {
4130
+ latest: { value: resolvedProfileWeightKg, date: null, source: 'profile', stale: false },
4131
+ trend: null,
4132
+ readings: 0,
4133
+ totalReadings: rows.length
4134
+ };
4135
+ }
4136
+ if (recent.length === 0) {
4137
+ const latestKnown = rows.at(-1);
4138
+ if (!latestKnown) return { latest: null, readings: 0, totalReadings: 0 };
4139
+ return {
4140
+ latest: {
4141
+ value: Math.round(latestKnown.value * 10) / 10,
4142
+ date: latestKnown.date,
4143
+ source: 'healthkit',
4144
+ stale: true
4145
+ },
4146
+ trend: null,
4147
+ readings: 0,
4148
+ totalReadings: rows.length
4149
+ };
4150
+ }
4076
4151
  return {
4077
- latest: { value: Math.round(recent.at(-1).value * 10) / 10, date: recent.at(-1).date },
4152
+ latest: {
4153
+ value: Math.round(recent.at(-1).value * 10) / 10,
4154
+ date: recent.at(-1).date,
4155
+ source: 'healthkit',
4156
+ stale: false
4157
+ },
4078
4158
  trend: Math.round((recent.at(-1).value - recent[0].value) * 10) / 10,
4079
- readings: recent.length
4159
+ readings: recent.length,
4160
+ totalReadings: rows.length
4080
4161
  };
4081
4162
  })(),
4082
4163
  respiratoryRate: (() => {