ofw-mcp 2.6.7 → 2.7.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.
package/dist/config.js CHANGED
@@ -110,3 +110,28 @@ export function getSyncMaxRequests() {
110
110
  return Number.POSITIVE_INFINITY;
111
111
  return n;
112
112
  }
113
+ /** Default for getFreshnessTtlSeconds() when OFW_FRESHNESS_TTL_SECONDS is unset. */
114
+ export const DEFAULT_FRESHNESS_TTL_SECONDS = 300;
115
+ /**
116
+ * How long a folder's verified-against-OFW state stays labelled `fresh`.
117
+ *
118
+ * Read tools serve from the local cache, so every read result carries a
119
+ * `freshness` block saying when the data was last actually compared against
120
+ * OFW. Past this age the block downgrades to `unverified` and grows a warning,
121
+ * because a co-parent can send a message — or edit a draft in the OFW web app,
122
+ * which bumps no timestamp at all — at any moment without us hearing about it.
123
+ *
124
+ * Set OFW_FRESHNESS_TTL_SECONDS to a positive integer to tune it. Anything
125
+ * else (unset / blank / zero / negative / non-integer) falls back to the
126
+ * default: a bad value must not silently widen the window in which stale data
127
+ * is presented as current.
128
+ */
129
+ export function getFreshnessTtlSeconds() {
130
+ const raw = readEnvVar('OFW_FRESHNESS_TTL_SECONDS');
131
+ if (raw === undefined)
132
+ return DEFAULT_FRESHNESS_TTL_SECONDS;
133
+ const n = Number(raw);
134
+ if (!Number.isInteger(n) || n <= 0)
135
+ return DEFAULT_FRESHNESS_TTL_SECONDS;
136
+ return n;
137
+ }
package/dist/index.js CHANGED
@@ -35,7 +35,7 @@ const nodeAttachmentIO = new NodeAttachmentIO();
35
35
  // always succeeds before any credential check runs.
36
36
  await runMcp({
37
37
  name: 'ofw',
38
- version: '2.6.7', // x-release-please-version
38
+ version: '2.7.0', // x-release-please-version
39
39
  deps: client,
40
40
  tools: [
41
41
  registerUserTools,
package/dist/sync.js CHANGED
@@ -126,17 +126,19 @@ async function walkPages(client, folder, folderId, opts, store) {
126
126
  let page = opts.startPage;
127
127
  let newestId = null;
128
128
  let synced = 0;
129
+ let pagesFetched = 0;
129
130
  const unread = [];
130
131
  while (true) {
131
132
  // One unit per list-page fetch. Out of budget → pause and resume at `page`.
132
133
  if (!budget.take()) {
133
- return { synced, unread, newestId, done: false, nextPage: page };
134
+ return { synced, unread, newestId, pagesFetched, done: false, nextPage: page };
134
135
  }
135
136
  const path = `/pub/v3/messages?folders=${encodeURIComponent(folderId)}&page=${page}&size=50&sort=date&sortDirection=desc`;
136
137
  const list = parseLenient(ListResponseSchema, await client.request('GET', path), { label: 'ofw-mcp', context: `GET /pub/v3/messages?folders={${folder}}` });
138
+ pagesFetched++;
137
139
  const items = list.data ?? [];
138
140
  if (items.length === 0) {
139
- return { synced, unread, newestId, done: true, nextPage: null };
141
+ return { synced, unread, newestId, pagesFetched, done: true, nextPage: null };
140
142
  }
141
143
  // One batch read of this page's ids (S1) instead of a per-item getMessage.
142
144
  const existingById = new Map((await store.getMessages(items.map((it) => it.id))).map((row) => [row.id, row]));
@@ -225,14 +227,14 @@ async function walkPages(client, folder, folderId, opts, store) {
225
227
  if (pageBudgetHit) {
226
228
  // Paused mid-page. Resume at THIS page: the partial rows are cached, so
227
229
  // getMessages skips them next time and upserts are idempotent.
228
- return { synced, unread, newestId, done: false, nextPage: page };
230
+ return { synced, unread, newestId, pagesFetched, done: false, nextPage: page };
229
231
  }
230
232
  // Reached cached history (see `stopAtCachedPage`). Report THIS page as the
231
233
  // resume point rather than the next one: it costs one redundant (cheap,
232
234
  // all-cached) fetch if a backfill later starts here, and it cannot skip a
233
235
  // message the way an off-by-one `page + 1` could.
234
236
  if (opts.stopAtCachedPage && !pageHadNewItem) {
235
- return { synced, unread, newestId, done: true, nextPage: page };
237
+ return { synced, unread, newestId, pagesFetched, done: true, nextPage: page };
236
238
  }
237
239
  page++;
238
240
  }
@@ -273,12 +275,29 @@ export async function syncMessageFolder(client, folder, folderId, opts, store) {
273
275
  let done;
274
276
  let resumePage;
275
277
  if (!fwd.done) {
276
- // The forward pass itself ran out of budget, so it never reached cached
277
- // history — everything from `fwd.nextPage` down is unverified. Park the
278
- // backfill at whichever cursor is higher up the folder, so no page that
279
- // still owes us messages ends up above the resume point.
280
278
  done = false;
281
- resumePage = savedResume === null ? fwd.nextPage : Math.min(fwd.nextPage, savedResume);
279
+ if (fwd.pagesFetched === 0) {
280
+ // The budget was already spent when the forward pass started, so it
281
+ // fetched nothing and observed NOTHING about this folder. Leave the
282
+ // parked cursor exactly as it was: moving it on zero information is a
283
+ // pure loss.
284
+ //
285
+ // This was a real starvation bug. `fwd.nextPage` is just the start page
286
+ // (1) when nothing was fetched, so the `Math.min` below would silently
287
+ // reset a deep backfill — e.g. resumePage 87 → 1 — discarding 86 pages
288
+ // of progress. On the hosted Worker (OFW_SYNC_MAX_REQUESTS=40) a user
289
+ // with enough drafts to consume the whole budget, with drafts running
290
+ // first, hit this on EVERY call: inbox/sent never got budget, their
291
+ // cursor was reset every time, and the backfill could never advance.
292
+ resumePage = savedResume;
293
+ }
294
+ else {
295
+ // The forward pass did look, and ran out before reaching cached history
296
+ // — everything from `fwd.nextPage` down is unverified. Park the backfill
297
+ // at whichever cursor is higher up the folder, so no page that still
298
+ // owes us messages ends up above the resume point.
299
+ resumePage = savedResume === null ? fwd.nextPage : Math.min(fwd.nextPage, savedResume);
300
+ }
282
301
  }
283
302
  else if (fwd.nextPage === null) {
284
303
  // The forward pass walked clean off the end of the folder — by definition
@@ -304,12 +323,16 @@ export async function syncMessageFolder(client, folder, folderId, opts, store) {
304
323
  done = bf.done;
305
324
  resumePage = bf.done ? null : bf.nextPage;
306
325
  }
307
- await store.setSyncState(folder, {
308
- lastSyncAt: new Date().toISOString(),
309
- newestId,
310
- resumePage,
311
- });
312
- return { synced, unread, done };
326
+ const now = new Date().toISOString();
327
+ await store.setSyncState(folder, { lastSyncAt: now, newestId, resumePage });
328
+ // The FORWARD pass is what proves our picture of the present is current: it
329
+ // always starts at page 1 and stops only once it reaches cached history. Its
330
+ // completion — not the backfill's — is what makes reads `fresh`. Same `now`
331
+ // as lastSyncAt so a verified folder never looks a millisecond behind its own
332
+ // sync (buildFreshness downgrades when lastSyncAt runs ahead of verifiedAt).
333
+ if (fwd.done)
334
+ await markFolderVerified(store, folder, now);
335
+ return { synced, unread, done, verified: fwd.done };
313
336
  }
314
337
  const DraftListItemSchema = z.looseObject({
315
338
  id: z.number(),
@@ -334,6 +357,38 @@ export const DRAFTS_CACHE_STATUS_KEY = 'drafts_cache_status';
334
357
  export async function getDraftsCacheStatus(store) {
335
358
  return (await store.getMeta(DRAFTS_CACHE_STATUS_KEY)) === 'fresh' ? 'fresh' : 'unverified';
336
359
  }
360
+ export async function setDraftsCacheStatus(store, status) {
361
+ await store.setMeta(DRAFTS_CACHE_STATUS_KEY, status);
362
+ }
363
+ /**
364
+ * Meta key holding when a folder was last actually COMPARED against OFW.
365
+ *
366
+ * Deliberately distinct from `sync_state.last_sync_at`, which is written on
367
+ * every call including one that paused mid-walk — so `last_sync_at` alone
368
+ * cannot mean "this folder is current", only "we tried". This key advances
369
+ * only when the folder was verified:
370
+ *
371
+ * inbox/sent the FORWARD pass completed, i.e. it walked from page 1 down to
372
+ * cached history (or off the end of the folder). That is exactly
373
+ * the pass that proves no new message is missing. A parked
374
+ * BACKFILL does not hold it back: incomplete old history says
375
+ * nothing about whether our picture of the present is current,
376
+ * and letting it downgrade every read would make the whole
377
+ * freshness signal noise during a long backfill.
378
+ * drafts the full walk + reconciliation ran (the same moment
379
+ * DRAFTS_CACHE_STATUS_KEY goes 'fresh').
380
+ *
381
+ * Absent = never verified, which reads as `stale`, not `fresh`.
382
+ */
383
+ export function folderVerifiedAtKey(folder) {
384
+ return `folder_verified_at:${folder}`;
385
+ }
386
+ export async function getFolderVerifiedAt(store, folder) {
387
+ return (await store.getMeta(folderVerifiedAtKey(folder))) ?? null;
388
+ }
389
+ export async function markFolderVerified(store, folder, at = new Date().toISOString()) {
390
+ await store.setMeta(folderVerifiedAtKey(folder), at);
391
+ }
337
392
  export async function syncDrafts(client, draftsFolderId, store, budget) {
338
393
  // No budget → unbounded (local stdio): identical to the original walk.
339
394
  const b = budget ?? makeBudget(Number.POSITIVE_INFINITY);
@@ -342,7 +397,16 @@ export async function syncDrafts(client, draftsFolderId, store, budget) {
342
397
  // know the cache is not a trustworthy base — the count we return here is
343
398
  // "nothing applied", NOT "nothing changed on the server".
344
399
  const defer = async () => {
345
- await store.setMeta(DRAFTS_CACHE_STATUS_KEY, 'unverified');
400
+ await setDraftsCacheStatus(store, 'unverified');
401
+ // Record the ATTEMPT but not a verification: buildFreshness compares the
402
+ // two and downgrades when a sync ran without verifying this folder, so a
403
+ // deferred walk actively marks reads unverified rather than leaving them
404
+ // coasting on an older stamp.
405
+ await store.setSyncState('drafts', {
406
+ lastSyncAt: new Date().toISOString(),
407
+ newestId: null,
408
+ resumePage: null,
409
+ });
346
410
  return { synced: 0, done: false };
347
411
  };
348
412
  // The reconciliation step below DELETES any cached draft not seen in the
@@ -411,8 +475,12 @@ export async function syncDrafts(client, draftsFolderId, store, budget) {
411
475
  }
412
476
  // The complete walk fetched every draft's DETAIL and reconciled deletions, so
413
477
  // the cache is now known-equal to the server. Only here is `synced: 0`
414
- // truthful as "verified no changes".
415
- await store.setMeta(DRAFTS_CACHE_STATUS_KEY, 'fresh');
478
+ // truthful as "verified no changes" — and only here may reads report the
479
+ // drafts as server-confirmed.
480
+ const now = new Date().toISOString();
481
+ await setDraftsCacheStatus(store, 'fresh');
482
+ await store.setSyncState('drafts', { lastSyncAt: now, newestId: null, resumePage: null });
483
+ await markFolderVerified(store, 'drafts', now);
416
484
  return { synced, done: true };
417
485
  }
418
486
  export async function syncAll(client, opts, store) {
@@ -440,6 +508,21 @@ export async function syncAll(client, opts, store) {
440
508
  let unreadInbox = [];
441
509
  let done = true;
442
510
  let draftsUnverified = false;
511
+ const refreshed = [];
512
+ const notRefreshed = [];
513
+ // Same rule for every folder: a count is reported, and the folder is listed
514
+ // as refreshed, ONLY when it was actually diffed against OFW. A folder the
515
+ // budget never reached reports no number at all — `inbox: 0` reads as
516
+ // "verified, no new messages", and that lie is the whole bug this guards.
517
+ const record = (folder, verified, count) => {
518
+ if (verified) {
519
+ synced[folder] = count;
520
+ refreshed.push(folder);
521
+ }
522
+ else {
523
+ notRefreshed.push(folder);
524
+ }
525
+ };
443
526
  for (const folder of folders) {
444
527
  if (folder === 'inbox') {
445
528
  const r = await syncMessageFolder(client, 'inbox', ids.inbox, {
@@ -447,7 +530,7 @@ export async function syncAll(client, opts, store) {
447
530
  deep: opts.deep ?? false,
448
531
  budget,
449
532
  }, store);
450
- synced.inbox = r.synced;
533
+ record('inbox', r.verified, r.synced);
451
534
  unreadInbox = r.unread;
452
535
  if (!r.done)
453
536
  done = false;
@@ -458,7 +541,7 @@ export async function syncAll(client, opts, store) {
458
541
  deep: opts.deep ?? false,
459
542
  budget,
460
543
  }, store);
461
- synced.sent = r.synced;
544
+ record('sent', r.verified, r.synced);
462
545
  if (!r.done)
463
546
  done = false;
464
547
  }
@@ -468,9 +551,8 @@ export async function syncAll(client, opts, store) {
468
551
  // OFW. A deferred walk applied nothing, and reporting its `0` as
469
552
  // `drafts: 0` reads as "verified, no changes" — the exact lie that let a
470
553
  // server-side draft edit be overwritten. Omit the number instead.
471
- if (r.done)
472
- synced.drafts = r.synced;
473
- else {
554
+ record('drafts', r.done, r.synced);
555
+ if (!r.done) {
474
556
  draftsUnverified = true;
475
557
  done = false;
476
558
  }
@@ -483,9 +565,20 @@ export async function syncAll(client, opts, store) {
483
565
  if (unreadInbox.length > 0) {
484
566
  notes.push(`${unreadInbox.length} unread inbox messages cached without bodies. Call ofw_get_message(id) to read them — this will mark them as read on OFW.`);
485
567
  }
568
+ if (notRefreshed.length > 0) {
569
+ notes.push(`NOT checked against OurFamilyWizard on this call: ${notRefreshed.join(', ')}. No count is reported for ${notRefreshed.length > 1 ? 'those folders' : 'that folder'} — absence of a count means "not looked at", not "no changes". Cached contents may be behind the server; call ofw_sync_messages again to finish, or ofw_check_freshness for a cheap live confirmation.`);
570
+ }
486
571
  if (!done) {
487
572
  notes.push('Paused after the request budget to stay within the hosting limit; more pages remain — call ofw_sync_messages again with the same arguments to resume where it left off and continue the backfill.');
488
573
  }
489
574
  const note = notes.length > 0 ? notes.join('\n\n') : undefined;
490
- return { synced, unreadInbox, done, ...(note ? { note } : {}) };
575
+ return {
576
+ synced,
577
+ unreadInbox,
578
+ done,
579
+ syncComplete: done,
580
+ refreshed,
581
+ notRefreshed,
582
+ ...(note ? { note } : {}),
583
+ };
491
584
  }
@@ -38,8 +38,69 @@ const MIME_BY_EXT = {
38
38
  export function mimeFromName(name) {
39
39
  return MIME_BY_EXT[extname(name).toLowerCase()] ?? 'application/octet-stream';
40
40
  }
41
+ const OCTET_STREAM = 'application/octet-stream';
42
+ // The media types a host's inline image renderer accepts. Anything else — even
43
+ // a valid image type like image/heic — must go back as an EmbeddedResource, and
44
+ // a parameter suffix (image/png;charset=UTF-8) is rejected outright, which is
45
+ // exactly the bug this normalization boundary exists to prevent.
46
+ const HOST_RENDERABLE_IMAGE_MIMES = new Set([
47
+ 'image/png', 'image/jpeg', 'image/gif', 'image/webp',
48
+ ]);
49
+ /**
50
+ * Strip a MIME type down to its bare `type/subtype`: drop any `;`-delimited
51
+ * parameters (`charset`, `name`, …), lowercase, and trim. An empty/absent value
52
+ * becomes `application/octet-stream`. OFW hands back `image/png;charset=UTF-8`
53
+ * on binary attachments, and a host's image renderer rejects the parameter
54
+ * suffix — so no derived MIME must ever carry one.
55
+ */
56
+ export function normalizeMimeType(raw) {
57
+ if (!raw)
58
+ return OCTET_STREAM;
59
+ const bare = raw.split(';', 1)[0].trim().toLowerCase();
60
+ return bare || OCTET_STREAM;
61
+ }
62
+ /**
63
+ * Detect a host-renderable image type from the leading bytes (magic numbers).
64
+ * OFW's `Content-Type` is unreliable for binaries (it tacks a text `charset`
65
+ * onto them), so the actual bytes are the authoritative signal. Returns the
66
+ * bare media type, or null when the bytes aren't a PNG/JPEG/GIF/WEBP.
67
+ */
68
+ const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
69
+ const JPEG_MAGIC = Buffer.from([0xff, 0xd8, 0xff]);
70
+ export function sniffImageMime(bytes) {
71
+ if (bytes.length >= 8 && bytes.subarray(0, 8).equals(PNG_MAGIC))
72
+ return 'image/png';
73
+ if (bytes.length >= 3 && bytes.subarray(0, 3).equals(JPEG_MAGIC))
74
+ return 'image/jpeg';
75
+ if (bytes.length >= 6 && bytes.toString('ascii', 0, 4) === 'GIF8')
76
+ return 'image/gif';
77
+ if (bytes.length >= 12 &&
78
+ bytes.toString('ascii', 0, 4) === 'RIFF' && bytes.toString('ascii', 8, 12) === 'WEBP') {
79
+ return 'image/webp';
80
+ }
81
+ return null;
82
+ }
83
+ /**
84
+ * Resolve the MIME type to report for downloaded bytes, in priority order:
85
+ * magic-number sniff (bytes never lie) → parameter-stripped upstream header →
86
+ * filename extension. The result is always bare (never carries a `;` parameter).
87
+ */
88
+ export function resolveDownloadMime(bytes, headerMime, fileName) {
89
+ const sniffed = sniffImageMime(bytes);
90
+ if (sniffed)
91
+ return sniffed;
92
+ const fromHeader = normalizeMimeType(headerMime);
93
+ if (fromHeader !== OCTET_STREAM)
94
+ return fromHeader;
95
+ return mimeFromName(fileName);
96
+ }
97
+ /** True only for the bare media types a host renders as inline ImageContent. */
98
+ export function isHostRenderableImage(mime) {
99
+ return HOST_RENDERABLE_IMAGE_MIMES.has(mime);
100
+ }
41
101
  /** Disk-backed attachment I/O for the stdio/desktop server. */
42
102
  export class NodeAttachmentIO {
103
+ supportsDisk = true;
43
104
  async resolveUpload(path) {
44
105
  const abs = expandPath(path);
45
106
  const stat = statSync(abs); // throws if missing
@@ -0,0 +1,147 @@
1
+ import { getFolderVerifiedAt } from '../sync.js';
2
+ import { getFreshnessTtlSeconds } from '../config.js';
3
+ const RANK = { fresh: 0, unverified: 1, stale: 2 };
4
+ function worst(a, b) {
5
+ return RANK[a] >= RANK[b] ? a : b;
6
+ }
7
+ /** Human-readable age: seconds under a minute, else whole minutes. */
8
+ function describeAge(seconds) {
9
+ return seconds < 60 ? `${seconds} sec ago` : `${Math.round(seconds / 60)} min ago`;
10
+ }
11
+ /**
12
+ * Build the `freshness` block that every message/draft/folder read carries.
13
+ *
14
+ * The point is that the DATA announces its own age and reliability, so a
15
+ * caller cannot assert current state without either a fresh read or an
16
+ * explicit staleness caveat it has to surface. Nothing here depends on the
17
+ * model remembering to re-check.
18
+ *
19
+ * Across multiple folders the block reports the WORST staleness and the OLDEST
20
+ * asOf: a response is only as trustworthy as its least-verified input.
21
+ */
22
+ export async function buildFreshness(store, opts) {
23
+ const now = opts.now ?? new Date();
24
+ const ttl = opts.ttlSeconds ?? getFreshnessTtlSeconds();
25
+ // A cache read backed by NO folder has verified nothing, so nothing in the
26
+ // per-folder loop below can downgrade the 'fresh' initializer — it would
27
+ // return `staleness: 'fresh'` alongside `asOf: null`, self-contradictory by
28
+ // this module's own definition and the one shape that carries no warning at
29
+ // all. Reachable via ofw_sync_messages({folders: []}). Fail to `stale`: an
30
+ // empty scope is the least evidence possible, not the most.
31
+ // (A LIVE read with no folders is different and legitimate — that is
32
+ // ofw_list_message_folders, whose data came straight off the wire.)
33
+ const emptyScope = opts.source === 'cache' && opts.folders.length === 0;
34
+ let staleness = emptyScope ? 'stale' : 'fresh';
35
+ let oldestVerifiedAt = null;
36
+ let sawNeverVerified = false;
37
+ let lastServerSyncAt = null;
38
+ let historyComplete = true;
39
+ // An empty scope verified nothing, so it cannot claim a complete sync.
40
+ let syncComplete = !emptyScope;
41
+ const deferred = [];
42
+ const backfilling = [];
43
+ for (const folder of opts.folders) {
44
+ const verifiedAt = await getFolderVerifiedAt(store, folder);
45
+ const state = await store.getSyncState(folder);
46
+ if (state !== null && (lastServerSyncAt === null || state.lastSyncAt > lastServerSyncAt)) {
47
+ lastServerSyncAt = state.lastSyncAt;
48
+ }
49
+ // A parked backfill means old history is incomplete. It does NOT downgrade
50
+ // staleness: the forward pass runs from page 1 on every call, so the
51
+ // present is current even while history is still being walked. Letting a
52
+ // months-long backfill mark every read `unverified` would train the caller
53
+ // to ignore the warning entirely.
54
+ if (state !== null && state.resumePage !== null) {
55
+ historyComplete = false;
56
+ syncComplete = false;
57
+ backfilling.push(folder);
58
+ }
59
+ if (verifiedAt === null) {
60
+ sawNeverVerified = true;
61
+ staleness = worst(staleness, 'stale');
62
+ syncComplete = false;
63
+ continue;
64
+ }
65
+ if (oldestVerifiedAt === null || verifiedAt < oldestVerifiedAt)
66
+ oldestVerifiedAt = verifiedAt;
67
+ // A sync ran AFTER the last verification of this folder — i.e. it was
68
+ // attempted and skipped (budget exhausted before reaching it). Recency of
69
+ // the older stamp must not keep it `fresh`; the skip is itself evidence
70
+ // that we do not currently know this folder's state.
71
+ if (state !== null && state.lastSyncAt > verifiedAt) {
72
+ staleness = worst(staleness, 'unverified');
73
+ syncComplete = false;
74
+ deferred.push(folder);
75
+ continue;
76
+ }
77
+ // Clamp at 0: a verifiedAt in the future (clock skew between the machine
78
+ // that wrote it and this one) must not read as a large negative age that
79
+ // can never exceed the threshold — that would be a silent false `fresh`.
80
+ const age = Math.max(0, Math.floor((now.getTime() - Date.parse(verifiedAt)) / 1000));
81
+ if (age > ttl)
82
+ staleness = worst(staleness, 'unverified');
83
+ }
84
+ // A live read is current by construction — that is the whole point of paying
85
+ // for it — so it reports fresh regardless of what the cache looks like.
86
+ if (opts.source === 'live') {
87
+ const asOf = now.toISOString();
88
+ const block = {
89
+ source: 'live',
90
+ asOf,
91
+ ageSeconds: 0,
92
+ staleness: 'fresh',
93
+ lastServerSyncAt,
94
+ syncComplete,
95
+ historyComplete,
96
+ };
97
+ const liveReasons = [];
98
+ if (sawNeverVerified) {
99
+ liveReasons.push('the surrounding cache has never been checked against OurFamilyWizard, so anything you did NOT fetch in this call is unverified');
100
+ }
101
+ if (backfilling.length > 0) {
102
+ liveReasons.push(`older history is still being backfilled for ${backfilling.join(', ')}, so older messages may be missing from the cache`);
103
+ }
104
+ if (liveReasons.length > 0) {
105
+ block.warning = `Fetched live from OurFamilyWizard, so this data is current. Note that ${liveReasons.join('; ')}.`;
106
+ }
107
+ return block;
108
+ }
109
+ const asOf = sawNeverVerified ? null : oldestVerifiedAt;
110
+ const ageSeconds = asOf === null
111
+ ? null
112
+ : Math.max(0, Math.floor((now.getTime() - Date.parse(asOf)) / 1000));
113
+ const block = {
114
+ source: 'cache',
115
+ asOf,
116
+ ageSeconds,
117
+ staleness,
118
+ lastServerSyncAt,
119
+ syncComplete,
120
+ historyComplete,
121
+ };
122
+ const reasons = [];
123
+ if (emptyScope) {
124
+ reasons.push('this result is backed by no synced folder at all, so nothing about it has been verified');
125
+ }
126
+ if (sawNeverVerified) {
127
+ reasons.push('this data has never been checked against OurFamilyWizard');
128
+ }
129
+ if (deferred.length > 0) {
130
+ reasons.push(`the last sync did not finish checking ${deferred.join(', ')}`);
131
+ }
132
+ // Reported independently of the deferral: a response can be BOTH deferred
133
+ // and badly aged, and naming only the deferral understates how old it is.
134
+ if (asOf !== null && ageSeconds !== null && ageSeconds > ttl) {
135
+ reasons.push(`that is past the ${ttl}s freshness threshold`);
136
+ }
137
+ if (backfilling.length > 0) {
138
+ reasons.push(`older history is still being backfilled for ${backfilling.join(', ')}`);
139
+ }
140
+ if (reasons.length > 0) {
141
+ const served = asOf === null
142
+ ? 'Served from cache that was never verified against OurFamilyWizard'
143
+ : `Served from cache last verified ${describeAge(ageSeconds)}`;
144
+ block.warning = `${served}; ${reasons.join('; ')}. Re-read before asserting current state — call ofw_check_freshness for a cheap live confirmation, or ofw_sync_messages to refresh.`;
145
+ }
146
+ return block;
147
+ }