incremnt 0.7.0 → 0.7.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.
@@ -22,6 +22,9 @@ const DEFAULT_RATE_LIMIT_RULES = {
22
22
  'weekly-checkin-ack': 30,
23
23
  'weekly-checkin-start': 10,
24
24
  'weekly-digest-current': 30,
25
+ 'coach-observations-current': 30,
26
+ 'coach-observations-seen': 30,
27
+ 'coach-observations-dismiss': 30,
25
28
  'dev-login': 10,
26
29
  'device-start': 20,
27
30
  'device-poll': 300,
@@ -682,6 +685,47 @@ function createRateLimiter({
682
685
  };
683
686
  }
684
687
 
688
+ function parseLimit(value, { defaultValue, max }) {
689
+ const parsed = value ? Number.parseInt(value, 10) : defaultValue;
690
+ return Number.isFinite(parsed) && parsed > 0 ? Math.min(parsed, max) : defaultValue;
691
+ }
692
+
693
+ function coachObservationSourceTriggerForScoreSnapshots(snapshots) {
694
+ if (!Array.isArray(snapshots)) return undefined;
695
+ const reasons = snapshots.map((snapshot) => snapshot?.triggerReason);
696
+ if (reasons.length === 0 || reasons.some((reason) => !reason)) return undefined;
697
+ if (reasons.includes('sessionSave')) return 'session_completed';
698
+ if (reasons.every((reason) => reason === 'historicalBackfill')) {
699
+ return 'manual_backfill';
700
+ }
701
+ if (reasons.every((reason) => reason === 'dailyRefresh')) {
702
+ return 'daily_refresh';
703
+ }
704
+ return undefined;
705
+ }
706
+
707
+ function coachObservationGenerationMetadata(result, sourceTrigger) {
708
+ return {
709
+ attempted: true,
710
+ generated: Number(result?.generated ?? 0),
711
+ persisted: Number(result?.persisted ?? 0),
712
+ skippedReason: result?.skippedReason ?? null,
713
+ sourceTrigger: sourceTrigger ?? null
714
+ };
715
+ }
716
+
717
+ function normalizeAskCoachObservationFollowUp(value) {
718
+ if (!value || typeof value !== 'object') return null;
719
+ const id = String(value.id ?? '').trim();
720
+ return id ? { id: id.slice(0, 128) } : null;
721
+ }
722
+
723
+ function selectAskCoachObservationFollowUp(requested, observations) {
724
+ if (!requested) return null;
725
+ return (Array.isArray(observations) ? observations : [])
726
+ .find((observation) => String(observation?.id ?? '') === requested.id);
727
+ }
728
+
685
729
  function routeRequest(url, method) {
686
730
  const pathname = url.pathname;
687
731
 
@@ -1055,6 +1099,28 @@ function routeRequest(url, method) {
1055
1099
  return { command: 'weekly-digest-current', options: {} };
1056
1100
  }
1057
1101
 
1102
+ if (pathname === '/cli/coach-observations/current') {
1103
+ return {
1104
+ command: 'coach-observations-current',
1105
+ options: {
1106
+ limit: url.searchParams.get('limit') ?? undefined,
1107
+ refresh: url.searchParams.get('refresh') ?? undefined
1108
+ }
1109
+ };
1110
+ }
1111
+
1112
+ {
1113
+ const coachObservationActionMatch = pathname.match(/^\/cli\/coach-observations\/([^/]+)\/(seen|dismiss)$/);
1114
+ if (coachObservationActionMatch) {
1115
+ return {
1116
+ command: `coach-observations-${coachObservationActionMatch[2]}`,
1117
+ options: {
1118
+ id: decodeURIComponent(coachObservationActionMatch[1])
1119
+ }
1120
+ };
1121
+ }
1122
+ }
1123
+
1058
1124
  if (pathname === '/cli/health/ai') {
1059
1125
  return {
1060
1126
  command: 'health-ai',
@@ -1512,6 +1578,27 @@ async function readJsonBody(request) {
1512
1578
  }
1513
1579
  }
1514
1580
 
1581
+ async function readOptionalJsonBody(request) {
1582
+ const chunks = [];
1583
+ let totalSize = 0;
1584
+ for await (const chunk of request) {
1585
+ totalSize += chunk.length;
1586
+ if (totalSize > MAX_BODY_BYTES) {
1587
+ throw new Error('Request body too large.');
1588
+ }
1589
+ chunks.push(chunk);
1590
+ }
1591
+
1592
+ const raw = Buffer.concat(chunks).toString('utf8');
1593
+ if (!raw.trim()) return {};
1594
+
1595
+ try {
1596
+ return JSON.parse(raw);
1597
+ } catch {
1598
+ throw new Error('Invalid JSON in request body.');
1599
+ }
1600
+ }
1601
+
1515
1602
  async function readUrlEncodedBody(request) {
1516
1603
  const chunks = [];
1517
1604
  let totalSize = 0;
@@ -1992,6 +2079,10 @@ export function createSyncServiceRequestHandler({
1992
2079
  insertScoreSnapshotsForAccount = null,
1993
2080
  listScoreSnapshotsForAccount = null,
1994
2081
  getCurrentWeeklyScoreDigestForAccount = null,
2082
+ generateCoachObservationsForAccount = null,
2083
+ listCurrentCoachObservationsForAccount = null,
2084
+ markCoachObservationSeenForAccount = null,
2085
+ dismissCoachObservationForAccount = null,
1995
2086
  // Social
1996
2087
  social = null,
1997
2088
  onError = null
@@ -2858,6 +2949,22 @@ export function createSyncServiceRequestHandler({
2858
2949
  return;
2859
2950
  }
2860
2951
  const result = await insertScoreSnapshotsForAccount(account, body.snapshots);
2952
+ if (generateCoachObservationsForAccount) {
2953
+ const sourceTrigger = coachObservationSourceTriggerForScoreSnapshots(body.snapshots);
2954
+ try {
2955
+ const generationResult = await generateCoachObservationsForAccount(account, { sourceTrigger });
2956
+ result.coachObservations = coachObservationGenerationMetadata(generationResult, sourceTrigger);
2957
+ } catch (error) {
2958
+ console.error('Coach observation generation after score upload failed:', error.message);
2959
+ result.coachObservations = {
2960
+ attempted: true,
2961
+ generated: 0,
2962
+ persisted: 0,
2963
+ skippedReason: 'generation_failed',
2964
+ sourceTrigger
2965
+ };
2966
+ }
2967
+ }
2861
2968
  json(response, 200, result);
2862
2969
  return;
2863
2970
  } catch (error) {
@@ -3496,6 +3603,138 @@ export function createSyncServiceRequestHandler({
3496
3603
  return;
3497
3604
  }
3498
3605
 
3606
+ if (route.command === 'coach-observations-current') {
3607
+ if (request.method !== 'GET') {
3608
+ methodNotAllowed(response, 'Use GET for /cli/coach-observations/current.');
3609
+ return;
3610
+ }
3611
+ if (route.options.refresh && route.options.refresh !== 'morning_open') {
3612
+ badRequest(response, 'Unsupported coach observations refresh value.');
3613
+ return;
3614
+ }
3615
+ if (!listCurrentCoachObservationsForAccount) {
3616
+ json(response, 503, { error: 'Coach observations not available' });
3617
+ return;
3618
+ }
3619
+ try {
3620
+ const limit = parseLimit(route.options.limit, { defaultValue: 5, max: 20 });
3621
+ let refresh = null;
3622
+ if (route.options.refresh === 'morning_open') {
3623
+ const writeAccount = writeAuthenticator
3624
+ ? await writeAuthenticator(requestToken)
3625
+ : requestToken === token
3626
+ ? account
3627
+ : null;
3628
+ if (!writeAccount) {
3629
+ unauthorized(response, request);
3630
+ return;
3631
+ }
3632
+ const sourceTrigger = 'daily_refresh';
3633
+ if (generateCoachObservationsForAccount) {
3634
+ try {
3635
+ const generationResult = await generateCoachObservationsForAccount(writeAccount, {
3636
+ sourceTrigger,
3637
+ refresh: route.options.refresh
3638
+ });
3639
+ refresh = coachObservationGenerationMetadata(generationResult, sourceTrigger);
3640
+ } catch (error) {
3641
+ console.error('Coach observation generation on morning open failed:', error.message);
3642
+ refresh = {
3643
+ attempted: true,
3644
+ generated: 0,
3645
+ persisted: 0,
3646
+ skippedReason: 'generation_failed',
3647
+ sourceTrigger
3648
+ };
3649
+ }
3650
+ } else {
3651
+ refresh = {
3652
+ attempted: false,
3653
+ generated: 0,
3654
+ persisted: 0,
3655
+ skippedReason: 'generation_unavailable',
3656
+ sourceTrigger
3657
+ };
3658
+ }
3659
+ }
3660
+ const observations = await listCurrentCoachObservationsForAccount(account, {
3661
+ limit
3662
+ });
3663
+ json(response, 200, {
3664
+ observations: observations ?? [],
3665
+ ...(refresh ? { refresh } : {})
3666
+ });
3667
+ } catch (err) {
3668
+ console.error('Coach observations read error:', err.message);
3669
+ json(response, 500, { error: 'Failed to read coach observations' });
3670
+ }
3671
+ return;
3672
+ }
3673
+
3674
+ if (route.command === 'coach-observations-seen') {
3675
+ if (request.method !== 'POST') {
3676
+ methodNotAllowed(response, 'Use POST for /cli/coach-observations/:id/seen.');
3677
+ return;
3678
+ }
3679
+ if (!markCoachObservationSeenForAccount) {
3680
+ json(response, 503, { error: 'Coach observations not available' });
3681
+ return;
3682
+ }
3683
+ let body = {};
3684
+ try {
3685
+ body = await readOptionalJsonBody(request);
3686
+ } catch {
3687
+ badRequest(response, 'Invalid request body.');
3688
+ return;
3689
+ }
3690
+ const seenAt = body?.seenAt ? new Date(body.seenAt) : null;
3691
+ if (seenAt && Number.isNaN(seenAt.getTime())) {
3692
+ badRequest(response, 'Invalid seenAt.');
3693
+ return;
3694
+ }
3695
+ try {
3696
+ const observation = await markCoachObservationSeenForAccount(account, route.options.id, { seenAt });
3697
+ if (!observation) {
3698
+ notFound(response, 'Observation not found');
3699
+ return;
3700
+ }
3701
+ json(response, 200, { observation });
3702
+ } catch (err) {
3703
+ console.error('Coach observation seen error:', err.message);
3704
+ json(response, 500, { error: 'Failed to mark observation seen' });
3705
+ }
3706
+ return;
3707
+ }
3708
+
3709
+ if (route.command === 'coach-observations-dismiss') {
3710
+ if (request.method !== 'POST') {
3711
+ methodNotAllowed(response, 'Use POST for /cli/coach-observations/:id/dismiss.');
3712
+ return;
3713
+ }
3714
+ if (!dismissCoachObservationForAccount) {
3715
+ json(response, 503, { error: 'Coach observations not available' });
3716
+ return;
3717
+ }
3718
+ try {
3719
+ await readOptionalJsonBody(request);
3720
+ } catch {
3721
+ badRequest(response, 'Invalid request body.');
3722
+ return;
3723
+ }
3724
+ try {
3725
+ const observation = await dismissCoachObservationForAccount(account, route.options.id, {});
3726
+ if (!observation) {
3727
+ notFound(response, 'Observation not found');
3728
+ return;
3729
+ }
3730
+ json(response, 200, { observation });
3731
+ } catch (err) {
3732
+ console.error('Coach observation dismiss error:', err.message);
3733
+ json(response, 500, { error: 'Failed to dismiss observation' });
3734
+ }
3735
+ return;
3736
+ }
3737
+
3499
3738
  let snapshot;
3500
3739
  try {
3501
3740
  snapshot = loadSnapshotForAccount
@@ -4140,6 +4379,7 @@ export function createSyncServiceRequestHandler({
4140
4379
 
4141
4380
  const queries = await import('./queries.js');
4142
4381
  const exclude = parseExclude(body?.exclude);
4382
+ const requestedCoachObservation = normalizeAskCoachObservationFollowUp(body?.coachObservation);
4143
4383
  let coachFacts = [];
4144
4384
  if (listCoachFactsForAccount) {
4145
4385
  try {
@@ -4166,10 +4406,21 @@ export function createSyncServiceRequestHandler({
4166
4406
  history: enrichedSnapshots
4167
4407
  };
4168
4408
  }
4409
+ let coachObservations = [];
4410
+ if (listCurrentCoachObservationsForAccount) {
4411
+ try {
4412
+ coachObservations = await listCurrentCoachObservationsForAccount(account, { limit: requestedCoachObservation ? 10 : 3 }) ?? [];
4413
+ } catch (observationErr) {
4414
+ console.error('Coach observations read error (ask):', observationErr.message);
4415
+ }
4416
+ }
4417
+ const coachObservationFollowUp = selectAskCoachObservationFollowUp(requestedCoachObservation, coachObservations);
4169
4418
 
4170
- const routedContext = queries.askRoutedContext
4171
- ? queries.askRoutedContext(snapshot, question, { exclude, coachFacts })
4172
- : { context: queries.askContext(snapshot, { exclude }), metadata: { route: 'legacy' } };
4419
+ const routedContext = coachObservationFollowUp && queries.askObservationFollowUpContext
4420
+ ? queries.askObservationFollowUpContext(snapshot, question, coachObservationFollowUp, { exclude, coachFacts })
4421
+ : queries.askRoutedContext
4422
+ ? queries.askRoutedContext(snapshot, question, { exclude, coachFacts, coachObservations })
4423
+ : { context: queries.askContext(snapshot, { exclude }), metadata: { route: 'legacy' } };
4173
4424
  const persistedKind = persistedConversation?.kind ?? (conversationId?.startsWith('weekly-checkin:') ? 'weekly-checkin' : 'ask');
4174
4425
  const incrementScorePrelude = formatIncrementScorePrelude(scoreSnapshots);
4175
4426
 
@@ -4198,7 +4449,8 @@ export function createSyncServiceRequestHandler({
4198
4449
  historyTurnCount: canonicalHistory.filter((m) => m.role === 'user').length,
4199
4450
  coachFactsIncluded: (routedContext.metadata?.includedCoachFactIds ?? []).length > 0,
4200
4451
  coachFactIds: routedContext.metadata?.includedCoachFactIds ?? [],
4201
- coachFactKinds: routedContext.metadata?.coachFactKinds ?? []
4452
+ coachFactKinds: routedContext.metadata?.coachFactKinds ?? [],
4453
+ coachObservationIds: routedContext.metadata?.includedCoachObservationIds ?? []
4202
4454
  }
4203
4455
  });
4204
4456
 
@@ -4255,6 +4507,15 @@ export function createSyncServiceRequestHandler({
4255
4507
  });
4256
4508
  });
4257
4509
  }
4510
+ if (markCoachObservationSeenForAccount) {
4511
+ for (const observationId of routedContext.metadata?.includedCoachObservationIds ?? []) {
4512
+ try {
4513
+ await markCoachObservationSeenForAccount(account, observationId, {});
4514
+ } catch (seenErr) {
4515
+ console.error('Failed to mark coach observation seen:', seenErr.message);
4516
+ }
4517
+ }
4518
+ }
4258
4519
  const updatedUserTurns = updatedMessages.filter((m) => m.role === 'user').length;
4259
4520
  if (
4260
4521
  persistedKind === 'weekly-checkin' &&