ofw-mcp 2.7.1 → 2.9.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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +58 -4
- package/dist/bundle.js +1651 -165
- package/dist/cache/store.js +79 -1
- package/dist/config.js +60 -0
- package/dist/extract/document.js +83 -0
- package/dist/extract/index.js +222 -0
- package/dist/extract/inflate.js +55 -0
- package/dist/extract/ooxml.js +58 -0
- package/dist/extract/pdf.js +278 -0
- package/dist/extract/presentation.js +54 -0
- package/dist/extract/spreadsheet.js +258 -0
- package/dist/extract/types.js +4 -0
- package/dist/extract/xml.js +61 -0
- package/dist/extract/zip.js +110 -0
- package/dist/index.js +1 -1
- package/dist/sync.js +11 -2
- package/dist/tools/delivery.js +99 -0
- package/dist/tools/draft-freshness.js +47 -9
- package/dist/tools/lifecycle.js +277 -0
- package/dist/tools/messages.js +571 -177
- package/package.json +1 -1
- package/server.json +14 -2
- package/skills/ofw/SKILL.md +34 -15
package/dist/tools/messages.js
CHANGED
|
@@ -2,11 +2,13 @@ import { z } from 'zod';
|
|
|
2
2
|
import { syncAll, fetchAttachmentMeta, fetchAttachmentMetaForMessage, getDraftsCacheStatus } from '../sync.js';
|
|
3
3
|
import { buildFreshness } from './freshness.js';
|
|
4
4
|
import { checkDraftFreshness, draftRevision, fetchServerDraft, staleDraftPayload, } from './draft-freshness.js';
|
|
5
|
+
import { FOLDER_TYPE, newDraftKey, persistFolderIds, probeIds, resolveDraftKey } from './lifecycle.js';
|
|
5
6
|
import { getFolderVerifiedAt } from '../sync.js';
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
7
|
+
import { buildInlineDelivery, tryExtract } from './delivery.js';
|
|
8
|
+
import { resolveDownloadMime } from './attachments.js';
|
|
9
|
+
import { getAllowMarkRead, getAttachmentsDir, getAutoRefreshStaleReads, getDefaultInlineAttachments, getFetchUnreadBodies, getSyncMaxRequests, getWriteMode, } from '../config.js';
|
|
8
10
|
import { basename, join } from 'node:path';
|
|
9
|
-
import { ApiRecipientSchema, expandPath, hasRealView, jsonErrorResponse, jsonResponse, mapRecipients, postMessageAndRefetch, textResponse, verifyWriteLanded, withReadState } from './_shared.js';
|
|
11
|
+
import { ApiRecipientSchema, deriveRead, expandPath, hasRealView, jsonErrorResponse, jsonResponse, mapRecipients, postMessageAndRefetch, textResponse, verifyWriteLanded, withReadState } from './_shared.js';
|
|
10
12
|
import { parseLenient } from '@chrischall/mcp-utils';
|
|
11
13
|
// Schemas for the load-bearing fields of each /pub/v3 response this file
|
|
12
14
|
// reads (issue #83). Loose: unknown keys pass through into cached listData.
|
|
@@ -44,7 +46,10 @@ const MessageDetailSchema = z.looseObject({
|
|
|
44
46
|
// The detail payload carries its own owning folder ({id, name}). We read the
|
|
45
47
|
// id to label a live-fetched message sent-vs-inbox instead of blindly
|
|
46
48
|
// defaulting to inbox — see the folder derivation in ofw_get_message.
|
|
47
|
-
|
|
49
|
+
// Same union as ServerDraftSchema's, for the same reason — OFW types this id
|
|
50
|
+
// as a string on the folders listing and a number on message detail. Lenient
|
|
51
|
+
// here, so a mismatch only warns, but it would warn on EVERY live fetch.
|
|
52
|
+
folder: z.looseObject({ id: z.union([z.string(), z.number()]) }).optional(),
|
|
48
53
|
});
|
|
49
54
|
// Attachment-backfill detail fetch reads only `files`.
|
|
50
55
|
const DetailFilesSchema = z.looseObject({ files: z.array(z.number()).optional() });
|
|
@@ -61,11 +66,6 @@ const FolderCountsSchema = z.looseObject({
|
|
|
61
66
|
count: z.number().optional(),
|
|
62
67
|
})).optional(),
|
|
63
68
|
});
|
|
64
|
-
const FOLDER_TYPE = {
|
|
65
|
-
inbox: 'INBOX',
|
|
66
|
-
sent: 'SENT_MESSAGES',
|
|
67
|
-
drafts: 'DRAFTS',
|
|
68
|
-
};
|
|
69
69
|
/**
|
|
70
70
|
* Cap on per-id probes in one ofw_check_freshness call.
|
|
71
71
|
*
|
|
@@ -125,6 +125,106 @@ async function draftsFreshness(cache) {
|
|
|
125
125
|
: 'unverified';
|
|
126
126
|
return { freshness, serverConfirmed: cacheStatus === 'fresh', cacheStatus };
|
|
127
127
|
}
|
|
128
|
+
/**
|
|
129
|
+
* Description shared by every read tool's `autoRefresh` argument, so the escape
|
|
130
|
+
* hatch from an UNVERIFIED_EMPTY refusal reads identically wherever it appears.
|
|
131
|
+
*/
|
|
132
|
+
const AUTO_REFRESH_DESC = 'If the result comes back EMPTY from a cache that is not verified-fresh, sync the backing folders first and answer from the refreshed cache instead of refusing. Defaults to the OFW_AUTO_REFRESH env var (false unless set), in which case the call refuses with result:"UNVERIFIED_EMPTY" and names the remedy. Costs OFW requests when it fires.';
|
|
133
|
+
/**
|
|
134
|
+
* Run a cached read, and never let it answer "nothing there" on the strength of
|
|
135
|
+
* a cache that cannot vouch for itself.
|
|
136
|
+
*
|
|
137
|
+
* The failure this closes: an empty result set carrying a 207-minute-old
|
|
138
|
+
* `freshness` block is shaped IDENTICALLY to a verified "nothing matched". Skim
|
|
139
|
+
* past the warning and the natural next sentence is "no, that message was never
|
|
140
|
+
* sent" — a false negative stated as fact about a court-visible record. A false
|
|
141
|
+
* negative is more dangerous than a refusal precisely because it reads as a
|
|
142
|
+
* definitive answer, so the bias is: refuse, and say how to get a real one.
|
|
143
|
+
*
|
|
144
|
+
* `autoRefresh` turns the refusal into a sync-then-retry. If that still cannot
|
|
145
|
+
* make the read verifiable (the budget paused, OFW was unreachable), the refusal
|
|
146
|
+
* fires anyway — a refresh that did not work must not be treated as one that did.
|
|
147
|
+
*
|
|
148
|
+
* Non-empty results are never touched: a stale cache that DID find something is
|
|
149
|
+
* evidence of presence, and its `freshness` block already labels its age.
|
|
150
|
+
*/
|
|
151
|
+
async function guardedCacheRead(o) {
|
|
152
|
+
let value = await o.read();
|
|
153
|
+
let refreshed = false;
|
|
154
|
+
const unverifiable = (v) => o.isEmpty(v) && v.freshness.staleness !== 'fresh';
|
|
155
|
+
if (unverifiable(value) && o.autoRefresh) {
|
|
156
|
+
await syncAll(o.client, {
|
|
157
|
+
folders: o.folders,
|
|
158
|
+
// Same ceiling ofw_sync_messages applies: an automatic refresh must never
|
|
159
|
+
// stamp unread inbox messages as a side effect of a list read.
|
|
160
|
+
fetchUnreadBodies: getAllowMarkRead() && getFetchUnreadBodies(),
|
|
161
|
+
maxRequests: getSyncMaxRequests(),
|
|
162
|
+
}, o.cache);
|
|
163
|
+
refreshed = true;
|
|
164
|
+
value = await o.read();
|
|
165
|
+
}
|
|
166
|
+
return { value, refreshed, unverifiedEmpty: unverifiable(value) };
|
|
167
|
+
}
|
|
168
|
+
/** The structured non-result returned instead of an unverifiable empty list. */
|
|
169
|
+
function unverifiedEmptyResponse(input) {
|
|
170
|
+
const { freshness } = input;
|
|
171
|
+
const age = freshness.ageSeconds === null
|
|
172
|
+
? 'it has never been checked against OurFamilyWizard'
|
|
173
|
+
: `it was last verified ${freshness.ageSeconds < 60 ? `${freshness.ageSeconds} sec` : `${Math.round(freshness.ageSeconds / 60)} min`} ago`;
|
|
174
|
+
const refreshClause = input.refreshed
|
|
175
|
+
? ' An automatic refresh ran on this call and did NOT make the result verifiable (the sync paused or skipped this folder), so the refusal stands.'
|
|
176
|
+
: '';
|
|
177
|
+
return jsonErrorResponse({
|
|
178
|
+
result: 'UNVERIFIED_EMPTY',
|
|
179
|
+
reason: `No ${input.what} were found, but the backing cache is "${freshness.staleness}" — ${age}. Refusing to report absence from unverified data: an empty result from a stale cache is indistinguishable from a verified "nothing there", and repeating it as one asserts a false negative about a legal record.${refreshClause}`,
|
|
180
|
+
remedy: input.remedy,
|
|
181
|
+
complete: false,
|
|
182
|
+
freshness,
|
|
183
|
+
...input.extra,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Decide whether fetching this message's body from OFW would stamp the record,
|
|
188
|
+
* and refuse when the caller (or the deployment) has opted out of that.
|
|
189
|
+
*
|
|
190
|
+
* Returns null to proceed, or a structured refusal to return as-is.
|
|
191
|
+
*
|
|
192
|
+
* Only ONE case actually stamps: fetching the body of an UNREAD INBOX message.
|
|
193
|
+
* Everything else is waved through, because refusing a read that changes
|
|
194
|
+
* nothing would be friction with no safety to show for it:
|
|
195
|
+
* - a SENT message — the "First Viewed" times on it belong to the recipient,
|
|
196
|
+
* and our own fetch never writes one;
|
|
197
|
+
* - an already-read inbox message — the stamp exists; re-reading cannot add
|
|
198
|
+
* a second one (`deriveRead` is monotonic, so this cannot flip back);
|
|
199
|
+
* - a cached body — this function is never reached, the cache served it.
|
|
200
|
+
*
|
|
201
|
+
* An id with NO cached row is refused: whether it would stamp is exactly what
|
|
202
|
+
* we cannot know without making the request that stamps it. Syncing first
|
|
203
|
+
* (which reads list pages, not bodies) resolves it.
|
|
204
|
+
*/
|
|
205
|
+
export function markReadVerdict(cached, requested) {
|
|
206
|
+
const ceiling = getAllowMarkRead();
|
|
207
|
+
if (ceiling && (requested ?? true))
|
|
208
|
+
return null;
|
|
209
|
+
const wouldStamp = cached === null
|
|
210
|
+
|| (cached.folder === 'inbox' && !deriveRead(cached));
|
|
211
|
+
if (!wouldStamp)
|
|
212
|
+
return null;
|
|
213
|
+
const because = ceiling
|
|
214
|
+
? 'you passed allowMarkRead:false'
|
|
215
|
+
: 'this server runs with OFW_ALLOW_MARK_READ=false';
|
|
216
|
+
return jsonErrorResponse({
|
|
217
|
+
error: 'MARK_READ_BLOCKED',
|
|
218
|
+
messageId: cached?.id ?? null,
|
|
219
|
+
reason: cached === null
|
|
220
|
+
? 'This id is not in the cache, so whether reading it would mark it read is unknowable without making the request that would.'
|
|
221
|
+
: 'This is an unread inbox message; fetching its body would mark it read on OurFamilyWizard.',
|
|
222
|
+
note: `Refused because ${because}. Reading a message for the first time stamps a "First Viewed" timestamp that your co-parent can see and that forms part of the record — it cannot be undone. To read it anyway, call again with allowMarkRead:true${ceiling ? '' : ' (which this deployment does not permit — clear OFW_ALLOW_MARK_READ to re-enable)'}.`,
|
|
223
|
+
...(cached === null
|
|
224
|
+
? { hint: 'Run ofw_sync_messages first: it walks list pages, not bodies, so it can tell you what this id is without stamping anything.' }
|
|
225
|
+
: { subject: cached.subject, fromUser: cached.fromUser, sentAt: cached.sentAt }),
|
|
226
|
+
});
|
|
227
|
+
}
|
|
128
228
|
export function registerMessageTools(server, client, cacheProvider, attachmentIO) {
|
|
129
229
|
// OFW_WRITE_MODE gate (see config.ts). Send lands on the court-visible
|
|
130
230
|
// record, so it is 'all'-only; draft-level writes (save/delete drafts,
|
|
@@ -142,8 +242,8 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
142
242
|
return jsonResponse({ folders: data, freshness });
|
|
143
243
|
});
|
|
144
244
|
server.registerTool('ofw_list_messages', {
|
|
145
|
-
description: 'List messages from the local OurFamilyWizard cache. Supports filtering by folder, date range, and a substring query on subject+body. Pagination is offset-based but if you know what you want (a date range, a topic), prefer the filters over walking pages — the cache may have 1000+ messages.
|
|
146
|
-
annotations: { readOnlyHint:
|
|
245
|
+
description: 'List messages from the local OurFamilyWizard cache. Supports filtering by folder, date range, and a substring query on subject+body. Pagination is offset-based but if you know what you want (a date range, a topic), prefer the filters over walking pages — the cache may have 1000+ messages. Returns an explicit `complete` boolean describing the RESULT SET: true means "this is every message on OurFamilyWizard matching these filters as of freshness.asOf" — check it before asserting a count. An empty result from a cache that is not verified-fresh is REFUSED (result:"UNVERIFIED_EMPTY") rather than reported as an absence; pass autoRefresh:true to sync and answer instead.',
|
|
246
|
+
annotations: { readOnlyHint: false },
|
|
147
247
|
inputSchema: {
|
|
148
248
|
folderId: z.string().describe('Folder name: "inbox", "sent", or "both" (default "both")').optional(),
|
|
149
249
|
page: z.number().int().min(1).describe('Page number (default 1)').optional(),
|
|
@@ -151,6 +251,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
151
251
|
since: z.string().describe('ISO date or datetime — only messages with sent_at >= since (inclusive)').optional(),
|
|
152
252
|
until: z.string().describe('ISO date or datetime — only messages with sent_at < until (exclusive)').optional(),
|
|
153
253
|
q: z.string().describe('Substring match on subject AND body (case-insensitive). Use to find messages on a specific topic.').optional(),
|
|
254
|
+
autoRefresh: z.boolean().describe(AUTO_REFRESH_DESC).optional(),
|
|
154
255
|
},
|
|
155
256
|
}, async (args) => {
|
|
156
257
|
const page = args.page ?? 1;
|
|
@@ -164,47 +265,84 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
164
265
|
else if (folderArg === 'both')
|
|
165
266
|
folder = undefined;
|
|
166
267
|
else {
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
return
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
note: 'folderId must be "inbox", "sent", or "both". Numeric OFW folder IDs are not supported by the cache. No lookup was performed — this empty result says nothing about what is in the cache.',
|
|
268
|
+
// A rejected argument is an ERROR, not a result. Returning `messages: []`
|
|
269
|
+
// here — even with a note attached — hands back the one shape this whole
|
|
270
|
+
// mechanism exists to eliminate: an empty list that looks like an answer.
|
|
271
|
+
return jsonErrorResponse({
|
|
272
|
+
result: 'INVALID_FOLDER',
|
|
273
|
+
reason: `folderId must be "inbox", "sent", or "both" (got ${JSON.stringify(folderArg)}). Numeric OFW folder IDs are not supported by the cache.`,
|
|
274
|
+
remedy: 'Re-call with folderId omitted (searches both) or set to one of the three accepted names.',
|
|
275
|
+
complete: false,
|
|
276
|
+
note: 'No lookup was performed. This says NOTHING about what is in the cache — do not read it as "no messages".',
|
|
177
277
|
});
|
|
178
278
|
}
|
|
179
279
|
const cache = cacheProvider();
|
|
280
|
+
const folders = folder === undefined ? ['inbox', 'sent'] : [folder];
|
|
180
281
|
const filter = { folder, since: args.since, until: args.until, q: args.q };
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
282
|
+
const { value, refreshed, unverifiedEmpty } = await guardedCacheRead({
|
|
283
|
+
client,
|
|
284
|
+
cache,
|
|
285
|
+
folders,
|
|
286
|
+
autoRefresh: args.autoRefresh ?? getAutoRefreshStaleReads(),
|
|
287
|
+
isEmpty: (v) => v.total === 0,
|
|
288
|
+
read: async () => {
|
|
289
|
+
const total = await cache.countMessages(filter);
|
|
290
|
+
// Reconcile each row's read state at read time: the cached list flags
|
|
291
|
+
// can be stale (a message read after it was first scraped), so `read`
|
|
292
|
+
// is derived from the record's own `viewedAt`/`fetchedBodyAt` and
|
|
293
|
+
// `listData` is forced to agree — see withReadState.
|
|
294
|
+
const messages = (await cache.listMessages({ ...filter, page, size })).map((m) => withReadState(m));
|
|
295
|
+
// Served from the local cache, so the result must say how old it is and
|
|
296
|
+
// whether anything vouches for it — a caller cannot state current state
|
|
297
|
+
// from this payload without either re-reading or surfacing the caveat.
|
|
298
|
+
const freshness = await buildFreshness(cache, { source: 'cache', folders });
|
|
299
|
+
return { messages, total, freshness };
|
|
300
|
+
},
|
|
193
301
|
});
|
|
194
|
-
|
|
302
|
+
if (unverifiedEmpty) {
|
|
303
|
+
return unverifiedEmptyResponse({
|
|
304
|
+
what: 'messages matching these filters',
|
|
305
|
+
freshness: value.freshness,
|
|
306
|
+
refreshed,
|
|
307
|
+
remedy: `Call ofw_sync_messages(folders:${JSON.stringify(folders)}) and retry, or re-call this tool with autoRefresh:true. ofw_check_freshness is the cheap live alternative when you only need to confirm a specific message.`,
|
|
308
|
+
extra: { page, size, filters: { folderId: folderArg, since: args.since, until: args.until, q: args.q } },
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
const { messages, total, freshness } = value;
|
|
312
|
+
// `complete` describes the RESULT SET, not the sync: true only when this
|
|
313
|
+
// payload holds every matching message the server has. `syncComplete` /
|
|
314
|
+
// `historyComplete` in `freshness` describe the walk that filled the cache
|
|
315
|
+
// and cannot answer "have I now seen all of them?" on their own — a caller
|
|
316
|
+
// needs one boolean to check before saying "you have N messages".
|
|
317
|
+
const fullSlice = page === 1 && messages.length === total;
|
|
318
|
+
const complete = fullSlice && freshness.staleness === 'fresh' && freshness.historyComplete;
|
|
319
|
+
const payload = { messages, total, page, size, complete, freshness };
|
|
320
|
+
if (!complete) {
|
|
321
|
+
payload.completeNote = [
|
|
322
|
+
!fullSlice ? `this page holds ${messages.length} of ${total} matching cached messages` : null,
|
|
323
|
+
freshness.staleness !== 'fresh' ? `the cache is "${freshness.staleness}", so newer messages may exist on OurFamilyWizard` : null,
|
|
324
|
+
!freshness.historyComplete ? 'older history is still being backfilled, so the cache does not yet hold every message' : null,
|
|
325
|
+
].filter((r) => r !== null)
|
|
326
|
+
.join('; ')
|
|
327
|
+
.concat('. Do not state a total or an absence from this result without resolving that first.');
|
|
328
|
+
}
|
|
195
329
|
if (total === 0) {
|
|
196
|
-
payload.note = 'No messages match these filters. If you expected results,
|
|
330
|
+
payload.note = 'No messages match these filters, and the cache IS verified-fresh for these folders — so this is a real "nothing matched", not a stale-cache artefact. If you expected results, relax the filters.';
|
|
197
331
|
}
|
|
198
332
|
else if (page * size < total) {
|
|
199
333
|
payload.note = `Showing ${(page - 1) * size + 1}–${(page - 1) * size + messages.length} of ${total}. Increase 'page' to see more, or narrow with since/until/q.`;
|
|
200
334
|
}
|
|
335
|
+
if (refreshed) {
|
|
336
|
+
payload.autoRefreshed = true;
|
|
337
|
+
}
|
|
201
338
|
return jsonResponse(payload);
|
|
202
339
|
});
|
|
203
340
|
server.registerTool('ofw_get_message', {
|
|
204
|
-
description: 'Get a single OurFamilyWizard message OR draft by ID. Reads from local cache when available; otherwise fetches from OFW
|
|
341
|
+
description: 'Get a single OurFamilyWizard message OR draft by ID. Reads from local cache when available; otherwise fetches from OFW — and for an UNREAD INBOX message that fetch marks it read and stamps a "First Viewed" time the co-parent can see, which is part of the record and cannot be undone. Pass allowMarkRead:false to refuse such a fetch instead (cached bodies, sent messages and already-read messages are unaffected, because none of them stamp anything). For ids that match a draft (in the drafts cache), the response carries folder="drafts" and the body/subject/recipients reflect the drafts cache (which ofw_sync_messages keeps fresh) — drafts have no `fromUser`, and `sentAt`/`fetchedBodyAt` mirror the draft\'s `modifiedAt`. For inbox/sent messages, folder is "inbox" or "sent" as before.',
|
|
205
342
|
annotations: { readOnlyHint: false },
|
|
206
343
|
inputSchema: {
|
|
207
344
|
messageId: z.string().describe('Message ID (also accepts draft IDs — drafts are routed via the drafts cache)'),
|
|
345
|
+
allowMarkRead: z.boolean().describe('Default true (the long-standing behaviour). Set false to refuse a fetch that would mark an unread INBOX message as READ on OurFamilyWizard — an irreversible, co-parent-visible change to the record. Reads that cannot stamp anything (a cached body, a sent message, an already-read message) still succeed. The server-wide OFW_ALLOW_MARK_READ=false is a ceiling this argument cannot raise.').optional(),
|
|
208
346
|
},
|
|
209
347
|
}, async (args) => {
|
|
210
348
|
const id = Number(args.messageId);
|
|
@@ -237,6 +375,11 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
237
375
|
// Concurrency token — pass as expectedRevision to ofw_save_draft /
|
|
238
376
|
// ofw_delete_draft to assert you are editing THIS version.
|
|
239
377
|
revision: draftRevision(draftRow),
|
|
378
|
+
// Stable logical identity. Survives the create-then-delete id churn of
|
|
379
|
+
// editing AND the transition to sent — pass it to ofw_status to ask
|
|
380
|
+
// "what happened to the thing I was working on?". Null when this draft
|
|
381
|
+
// was never written through this tool (e.g. authored in the web app).
|
|
382
|
+
draftKey: (await cache.getDraftLineageById(draftRow.id))?.draftKey ?? null,
|
|
240
383
|
cacheStatus,
|
|
241
384
|
// False = this draft's existence and unsent status are remembered from
|
|
242
385
|
// a cache, not confirmed on OFW. Call ofw_check_freshness before
|
|
@@ -299,6 +442,14 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
299
442
|
const freshness = await buildFreshness(cache, { source: 'cache', folders: [row.folder] });
|
|
300
443
|
return jsonResponse({ ...withReadState(row), attachments, freshness });
|
|
301
444
|
}
|
|
445
|
+
// Everything above this line was served without asking OFW for a body.
|
|
446
|
+
// This is the one path that fetches one — and fetching the body of an
|
|
447
|
+
// unread INBOX message marks it read on OFW, stamping a "First Viewed"
|
|
448
|
+
// time the co-parent can see. That is a court-visible, irreversible change
|
|
449
|
+
// made as a side effect of an ordinary read, so it gets an explicit gate.
|
|
450
|
+
const markReadCheck = markReadVerdict(cached, args.allowMarkRead);
|
|
451
|
+
if (markReadCheck !== null)
|
|
452
|
+
return markReadCheck;
|
|
302
453
|
const detail = parseLenient(MessageDetailSchema, await client.request('GET', `/pub/v3/messages/${encodeURIComponent(args.messageId)}`), { label: 'ofw-mcp', context: 'GET /pub/v3/messages/{id} (ofw_get_message)' });
|
|
303
454
|
// Derive the folder for a live-fetched message. A cached row (reached here
|
|
304
455
|
// only when its body was NULL) already knows its folder, so keep it.
|
|
@@ -415,6 +566,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
415
566
|
}, SentDetailSchema, 'ofw_send_message');
|
|
416
567
|
let persisted = null;
|
|
417
568
|
let verifyNote = null;
|
|
569
|
+
let sentDraftKey = null;
|
|
418
570
|
if (newId !== null) {
|
|
419
571
|
verifyNote = verifyWriteLanded('message', { subject, body }, detail);
|
|
420
572
|
persisted = {
|
|
@@ -431,6 +583,21 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
431
583
|
listData: detail,
|
|
432
584
|
};
|
|
433
585
|
await cache.upsertMessage(persisted);
|
|
586
|
+
// Extend the draft's identity chain onto the SENT message. Without this
|
|
587
|
+
// the chain would dead-end at the last draft id and "what happened to the
|
|
588
|
+
// draft I was editing?" would answer `deleted` — technically true of that
|
|
589
|
+
// id, and the exact wrong impression. With it, resolving the key lands on
|
|
590
|
+
// the sent message and reports state:"sent" with its sentAt.
|
|
591
|
+
if (draftRef !== undefined) {
|
|
592
|
+
const prior = await cache.getDraftLineageById(draftRef);
|
|
593
|
+
const now = new Date().toISOString();
|
|
594
|
+
const key = prior?.draftKey ?? newDraftKey();
|
|
595
|
+
if (prior === null) {
|
|
596
|
+
await cache.recordDraftLineage({ id: draftRef, draftKey: key, previousId: null, recordedAt: now });
|
|
597
|
+
}
|
|
598
|
+
await cache.recordDraftLineage({ id: newId, draftKey: key, previousId: draftRef, recordedAt: now });
|
|
599
|
+
sentDraftKey = key;
|
|
600
|
+
}
|
|
434
601
|
// Link attached files to the new message in the attachments cache.
|
|
435
602
|
// We may not have full metadata if the upload happened in a prior
|
|
436
603
|
// session — fall back to what we know.
|
|
@@ -461,7 +628,9 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
461
628
|
await deleteOFWMessages(client, [draftRef]);
|
|
462
629
|
await cache.deleteDraft(draftRef);
|
|
463
630
|
}
|
|
464
|
-
const responseObj = persisted
|
|
631
|
+
const responseObj = persisted === null
|
|
632
|
+
? raw
|
|
633
|
+
: { ...persisted, ...(sentDraftKey !== null ? { draftKey: sentDraftKey, previousId: draftRef } : {}) };
|
|
465
634
|
const text = responseObj ? JSON.stringify(responseObj, null, 2) : 'Message sent successfully.';
|
|
466
635
|
const notes = [rewriteNote, verifyNote, unconfirmedNote].filter((n) => n !== null).join('\n\n');
|
|
467
636
|
return textResponse(notes ? `${notes}\n\n${text}` : text);
|
|
@@ -480,10 +649,13 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
480
649
|
server = await fetchServerDraft(client, draftId);
|
|
481
650
|
}
|
|
482
651
|
catch (e) {
|
|
483
|
-
//
|
|
484
|
-
//
|
|
485
|
-
//
|
|
486
|
-
//
|
|
652
|
+
// Anything landing here means the check could not RUN. Most failures
|
|
653
|
+
// arrive as DraftFreshnessError from fetchServerDraft, but not all of
|
|
654
|
+
// them: a strict parseLenient mismatch on the server draft throws
|
|
655
|
+
// McpToolError instead. Both are caught, and both abort — which is the
|
|
656
|
+
// point. A failed check is not permission to proceed: a transient 5xx
|
|
657
|
+
// must not degrade into a blind overwrite. (The cast below only reads
|
|
658
|
+
// `.message`, which every Error carries.)
|
|
487
659
|
const reason = e.message;
|
|
488
660
|
if (force) {
|
|
489
661
|
return { ok: true, note: `WARNING: force:true — proceeded with ${action} on draft ${draftId} even though its current state could not be read from OurFamilyWizard (${reason}). Any newer server-side version was destroyed and is NOT recoverable from this response.` };
|
|
@@ -532,38 +704,72 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
532
704
|
};
|
|
533
705
|
}
|
|
534
706
|
server.registerTool('ofw_list_drafts', {
|
|
535
|
-
description: 'List draft messages from the local OurFamilyWizard cache.
|
|
536
|
-
annotations: { readOnlyHint:
|
|
707
|
+
description: 'List draft messages from the local OurFamilyWizard cache. Returns an explicit `complete` boolean describing the RESULT SET: true means "these are ALL the drafts on OurFamilyWizard as of freshness.asOf" — check it before saying "you have N drafts". Each draft carries its `draftKey` (stable across the create-then-delete churn of editing) when one is known. An empty result from a cache that is not verified-fresh is REFUSED (result:"UNVERIFIED_EMPTY"); pass autoRefresh:true to sync and answer instead. For a live, one-call answer prefer ofw_status(includeDraftInventory:true).',
|
|
708
|
+
annotations: { readOnlyHint: false },
|
|
537
709
|
inputSchema: {
|
|
538
710
|
page: z.number().int().min(1).describe('Page number (default 1)').optional(),
|
|
539
711
|
size: z.number().int().min(1).describe('Drafts per page (default 50)').optional(),
|
|
712
|
+
autoRefresh: z.boolean().describe(AUTO_REFRESH_DESC).optional(),
|
|
540
713
|
},
|
|
541
714
|
}, async (args) => {
|
|
542
715
|
const page = args.page ?? 1;
|
|
543
716
|
const size = args.size ?? 50;
|
|
544
717
|
const cache = cacheProvider();
|
|
545
|
-
const {
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
718
|
+
const { value, refreshed, unverifiedEmpty } = await guardedCacheRead({
|
|
719
|
+
client,
|
|
720
|
+
cache,
|
|
721
|
+
folders: ['drafts'],
|
|
722
|
+
autoRefresh: args.autoRefresh ?? getAutoRefreshStaleReads(),
|
|
723
|
+
isEmpty: (v) => v.total === 0,
|
|
724
|
+
read: async () => {
|
|
725
|
+
const { freshness, serverConfirmed, cacheStatus } = await draftsFreshness(cache);
|
|
726
|
+
const rows = await cache.listDrafts({ page, size });
|
|
727
|
+
const total = await cache.countDrafts();
|
|
728
|
+
// One batch lookup for the whole page — on the Durable Object backend a
|
|
729
|
+
// per-draft lineage read would be a subrequest each.
|
|
730
|
+
const keyById = new Map((await cache.getDraftLineageByIds(rows.map((d) => d.id))).map((l) => [l.id, l.draftKey]));
|
|
731
|
+
// Every draft carries the concurrency token to echo back on a write, its
|
|
732
|
+
// stable identity across edits, and whether the last sync actually
|
|
733
|
+
// compared this cache against OFW.
|
|
734
|
+
const drafts = rows.map((d) => ({
|
|
735
|
+
...d,
|
|
736
|
+
revision: draftRevision(d),
|
|
737
|
+
draftKey: keyById.get(d.id) ?? null,
|
|
738
|
+
cacheStatus,
|
|
739
|
+
serverConfirmed,
|
|
740
|
+
asOf: freshness.asOf,
|
|
741
|
+
}));
|
|
742
|
+
return { drafts, total, freshness, serverConfirmed };
|
|
743
|
+
},
|
|
744
|
+
});
|
|
745
|
+
if (unverifiedEmpty) {
|
|
746
|
+
return unverifiedEmptyResponse({
|
|
747
|
+
what: 'drafts',
|
|
748
|
+
freshness: value.freshness,
|
|
749
|
+
refreshed,
|
|
750
|
+
remedy: 'Call ofw_sync_messages(folders:["drafts"]) and retry, re-call with autoRefresh:true, or use ofw_status(includeDraftInventory:true) for a single live answer.',
|
|
751
|
+
extra: { page, size },
|
|
562
752
|
});
|
|
563
753
|
}
|
|
564
|
-
const
|
|
754
|
+
const { drafts, total, freshness, serverConfirmed } = value;
|
|
755
|
+
// True only when this payload IS the full server-side draft set: verified
|
|
756
|
+
// against OFW inside the freshness window AND not a slice of a larger list.
|
|
757
|
+
const fullSlice = page === 1 && drafts.length === total;
|
|
758
|
+
const complete = serverConfirmed && fullSlice;
|
|
759
|
+
const payload = { drafts, total, page, size, complete, freshness };
|
|
760
|
+
if (!complete) {
|
|
761
|
+
payload.completeNote = [
|
|
762
|
+
!fullSlice ? `this page holds ${drafts.length} of ${total} cached drafts` : null,
|
|
763
|
+
!serverConfirmed ? 'the drafts cache has not been confirmed against OurFamilyWizard inside the freshness window' : null,
|
|
764
|
+
].filter((r) => r !== null)
|
|
765
|
+
.join('; ')
|
|
766
|
+
.concat('. Do NOT state a draft count from this result — call ofw_status(includeDraftInventory:true) for a live, complete one.');
|
|
767
|
+
}
|
|
565
768
|
if (!serverConfirmed) {
|
|
566
|
-
payload.note = 'serverConfirmed:false — these drafts are remembered from the local cache, NOT confirmed to still exist unsent on OurFamilyWizard right now, and their bodies may be behind the server. Do not state that a draft "is still sitting unsent" on this basis; drafts edited or
|
|
769
|
+
payload.note = 'serverConfirmed:false — these drafts are remembered from the local cache, NOT confirmed to still exist unsent on OurFamilyWizard right now, and their bodies may be behind the server. Do not state that a draft "is still sitting unsent" on this basis; drafts edited, deleted or SENT in the OFW web app bump no timestamp, so the cache cannot detect it on its own. Call ofw_status / ofw_check_freshness (cheap, live) or ofw_sync_messages first. Writes are guarded regardless — ofw_save_draft and ofw_delete_draft re-check the server and refuse a stale overwrite.';
|
|
770
|
+
}
|
|
771
|
+
if (refreshed) {
|
|
772
|
+
payload.autoRefreshed = true;
|
|
567
773
|
}
|
|
568
774
|
return jsonResponse(payload);
|
|
569
775
|
});
|
|
@@ -628,6 +834,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
628
834
|
let replaceNote = null;
|
|
629
835
|
let verifyNote = null;
|
|
630
836
|
let newRevision = null;
|
|
837
|
+
let draftKey = null;
|
|
631
838
|
// Fields accepted on the write that the saved draft must carry — or their
|
|
632
839
|
// loss must be reported. Never a silent drop (Defect 3).
|
|
633
840
|
const warnings = [];
|
|
@@ -655,11 +862,48 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
655
862
|
// The revision is now computed from the server-authoritative detail, so it
|
|
656
863
|
// is the value a subsequent read/verify will observe (Defect 1).
|
|
657
864
|
newRevision = draftRevision(persisted);
|
|
865
|
+
// Carry the logical identity across the id change. Replacing a draft mints
|
|
866
|
+
// a NEW OFW id every time (create-then-delete — see the note below), so
|
|
867
|
+
// ten edits produced ten unrelated ids and there was no way to ask what
|
|
868
|
+
// became of the one you started with. The key is minted on first sight —
|
|
869
|
+
// including retroactively for the draft being replaced, so a draft that
|
|
870
|
+
// predates this mechanism joins a chain the moment it is edited.
|
|
871
|
+
const now = new Date().toISOString();
|
|
872
|
+
if (args.messageId !== undefined) {
|
|
873
|
+
const prior = await cache.getDraftLineageById(args.messageId);
|
|
874
|
+
if (prior !== null) {
|
|
875
|
+
draftKey = prior.draftKey;
|
|
876
|
+
}
|
|
877
|
+
else {
|
|
878
|
+
draftKey = newDraftKey();
|
|
879
|
+
await cache.recordDraftLineage({
|
|
880
|
+
id: args.messageId, draftKey, previousId: null, recordedAt: now,
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
else {
|
|
885
|
+
draftKey = newDraftKey();
|
|
886
|
+
}
|
|
887
|
+
await cache.recordDraftLineage({
|
|
888
|
+
id: newId,
|
|
889
|
+
draftKey,
|
|
890
|
+
previousId: args.messageId ?? null,
|
|
891
|
+
recordedAt: now,
|
|
892
|
+
});
|
|
658
893
|
// Audit every field the caller supplied against what actually landed, so a
|
|
659
894
|
// silent normalization becomes a visible warning rather than a surprise.
|
|
660
895
|
if (resolvedReplyTo !== null && effectiveReplyTo !== resolvedReplyTo) {
|
|
661
896
|
const rewrittenFrom = requestedReplyTo !== resolvedReplyTo ? ` (rewritten from ${requestedReplyTo})` : '';
|
|
662
|
-
|
|
897
|
+
// Two different outcomes reach this branch, and they need different
|
|
898
|
+
// warnings. OFW either DROPPED the link (null) or RE-TARGETED it to
|
|
899
|
+
// another message in the thread. Describing both as "did not thread
|
|
900
|
+
// this draft (its inReplyTo/showContext will be empty)" contradicted
|
|
901
|
+
// the non-null inReplyTo the same response echoes — and a warning the
|
|
902
|
+
// caller can see is false is a warning it learns to skip.
|
|
903
|
+
const outcome = effectiveReplyTo === null
|
|
904
|
+
? 'OurFamilyWizard did not thread this draft (its inReplyTo/showContext will be empty). The subject and body were saved; only the reply linkage was dropped.'
|
|
905
|
+
: `OurFamilyWizard re-targeted the reply to message ${effectiveReplyTo} instead. The draft IS threaded — to that message, not the one requested — and the inReplyTo in this response reflects where it actually landed.`;
|
|
906
|
+
warnings.push(`replyToId was requested as ${resolvedReplyTo}${rewrittenFrom} but the saved draft came back with replyToId ${effectiveReplyTo === null ? 'null' : effectiveReplyTo} — ${outcome} If threading matters, verify on ourfamilywizard.com.`);
|
|
663
907
|
}
|
|
664
908
|
// Only warn on recipients/attachments when the detail actually reported
|
|
665
909
|
// them — an omitted array is "not echoed", not "dropped", and crying wolf
|
|
@@ -703,6 +947,11 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
703
947
|
...persisted,
|
|
704
948
|
inReplyTo: persisted.replyToId,
|
|
705
949
|
revision: newRevision,
|
|
950
|
+
// The id above is volatile — it changes on every edit. `draftKey` is
|
|
951
|
+
// not: pass it to ofw_status to resolve the chain's CURRENT id, or to
|
|
952
|
+
// find out that the draft was sent and when.
|
|
953
|
+
draftKey,
|
|
954
|
+
previousId: args.messageId ?? null,
|
|
706
955
|
cacheStatus: 'fresh',
|
|
707
956
|
serverConfirmed: true,
|
|
708
957
|
...(warnings.length > 0 ? { warnings } : {}),
|
|
@@ -742,28 +991,49 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
742
991
|
return textResponse(guard.note ? `${guard.note}\n\n${text}` : text);
|
|
743
992
|
});
|
|
744
993
|
server.registerTool('ofw_get_unread_sent', {
|
|
745
|
-
description: 'List sent messages that have not been read by one or more recipients. Reads from local cache
|
|
746
|
-
annotations: { readOnlyHint:
|
|
994
|
+
description: 'List sent messages that have not been read by one or more recipients. Reads from local cache. Returns `complete` describing whether every sent message was scanned. An empty SENT cache that is not verified-fresh is REFUSED (result:"UNVERIFIED_EMPTY") rather than reported as "nothing sent"; pass autoRefresh:true to sync and answer instead.',
|
|
995
|
+
annotations: { readOnlyHint: false },
|
|
747
996
|
inputSchema: {
|
|
748
997
|
page: z.number().int().min(1).describe('Page (default 1)').optional(),
|
|
749
998
|
size: z.number().int().min(1).describe('Per page (default 50)').optional(),
|
|
999
|
+
autoRefresh: z.boolean().describe(AUTO_REFRESH_DESC).optional(),
|
|
750
1000
|
},
|
|
751
1001
|
}, async (args) => {
|
|
752
1002
|
const page = args.page ?? 1;
|
|
753
1003
|
const size = args.size ?? 50;
|
|
754
1004
|
const cache = cacheProvider();
|
|
755
|
-
const
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
1005
|
+
const { value, refreshed, unverifiedEmpty } = await guardedCacheRead({
|
|
1006
|
+
client,
|
|
1007
|
+
cache,
|
|
1008
|
+
folders: ['sent'],
|
|
1009
|
+
autoRefresh: args.autoRefresh ?? getAutoRefreshStaleReads(),
|
|
1010
|
+
// The guard is about the CACHE being empty, not the verdict. "You have
|
|
1011
|
+
// no sent messages" is an absence claim a stale cache cannot support;
|
|
1012
|
+
// "all of them are read" is a verdict over messages we did see, and it is
|
|
1013
|
+
// labelled by `freshness` and `complete` as before.
|
|
1014
|
+
isEmpty: (v) => v.total === 0,
|
|
1015
|
+
read: async () => {
|
|
1016
|
+
const sent = await cache.listMessages({ folder: 'sent', page, size });
|
|
1017
|
+
const total = await cache.countMessages({ folder: 'sent' });
|
|
1018
|
+
// "Nobody has read it yet" is a present-tense claim drawn entirely from
|
|
1019
|
+
// cached view timestamps, which only move when a sync refreshes them —
|
|
1020
|
+
// so it needs the same age label as any other cached read.
|
|
1021
|
+
const freshness = await buildFreshness(cache, { source: 'cache', folders: ['sent'] });
|
|
1022
|
+
return { sent, total, freshness };
|
|
1023
|
+
},
|
|
1024
|
+
});
|
|
1025
|
+
if (unverifiedEmpty) {
|
|
1026
|
+
return unverifiedEmptyResponse({
|
|
1027
|
+
what: 'sent messages in the local cache',
|
|
1028
|
+
freshness: value.freshness,
|
|
1029
|
+
refreshed,
|
|
1030
|
+
remedy: 'Call ofw_sync_messages(folders:["sent"]) and retry, or re-call with autoRefresh:true.',
|
|
1031
|
+
extra: { page, size },
|
|
765
1032
|
});
|
|
766
1033
|
}
|
|
1034
|
+
const { sent, total, freshness } = value;
|
|
1035
|
+
const fullSlice = page === 1 && sent.length === total;
|
|
1036
|
+
const complete = fullSlice && freshness.staleness === 'fresh' && freshness.historyComplete;
|
|
767
1037
|
const unread = [];
|
|
768
1038
|
for (const msg of sent) {
|
|
769
1039
|
const unreadBy = msg.recipients.filter((r) => r.viewedAt === null).map((r) => r.name);
|
|
@@ -771,14 +1041,17 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
771
1041
|
unread.push({ id: msg.id, subject: msg.subject, sentAt: msg.sentAt, unreadBy });
|
|
772
1042
|
}
|
|
773
1043
|
}
|
|
1044
|
+
const payload = { unread, scanned: sent.length, total, complete, freshness };
|
|
1045
|
+
if (!complete) {
|
|
1046
|
+
payload.completeNote = `This verdict covers the ${sent.length} of ${total} cached sent messages on this page${freshness.staleness === 'fresh' ? '' : `, from a cache that is "${freshness.staleness}"`}. It is not a statement about every message you have sent.`;
|
|
1047
|
+
}
|
|
774
1048
|
if (unread.length === 0) {
|
|
775
|
-
|
|
776
|
-
unread: [],
|
|
777
|
-
freshness,
|
|
778
|
-
message: 'All scanned sent messages had been read as of the timestamp in `freshness.asOf`. A recipient may have read a message since without the cache hearing about it.',
|
|
779
|
-
});
|
|
1049
|
+
payload.message = 'Every sent message scanned had been read as of the timestamp in `freshness.asOf`. A recipient may have read — or not read — a message since without the cache hearing about it.';
|
|
780
1050
|
}
|
|
781
|
-
|
|
1051
|
+
if (refreshed) {
|
|
1052
|
+
payload.autoRefreshed = true;
|
|
1053
|
+
}
|
|
1054
|
+
return jsonResponse(payload);
|
|
782
1055
|
});
|
|
783
1056
|
if (allowDrafts)
|
|
784
1057
|
server.registerTool('ofw_upload_attachment', {
|
|
@@ -825,13 +1098,16 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
825
1098
|
});
|
|
826
1099
|
});
|
|
827
1100
|
server.registerTool('ofw_download_attachment', {
|
|
828
|
-
description: 'Download an OFW message attachment by fileId
|
|
1101
|
+
description: 'Download an OFW message attachment by fileId and return content you can actually read. Inline delivery walks a ladder and returns the first rung that works: (1) host-renderable images (PNG/JPEG/GIF/WEBP) come back as ImageContent; (2) .xlsx/.csv/.tsv, .pdf, .docx, .pptx and text files come back as EXTRACTED CONTENT — per-sheet CSV, per-page/slide text, document text — in the response JSON under `extracted`; (3) anything else comes back as an EmbeddedResource blob of the raw bytes. The meta block names the rung as `deliveredVia` and, when it falls through to bytes, lists what was tried in `deliveryAttempts`. Reported mime types are always normalized to a bare media type (no charset/name parameters). In disk mode the bytes are saved to ~/Downloads/ofw-mcp/ and the response carries the absolute path; pass extract:true to ALSO get the extracted content in that response. The default for `inline` can be flipped server-side via the OFW_INLINE_ATTACHMENTS env var. On a hosted deployment with no filesystem, disk mode is unavailable, so inline is forced (forcedInline:true) rather than failing — a saveTo path never costs you the content. fileId comes from attachments[].fileId on ofw_get_message. Override disk destination with OFW_ATTACHMENTS_DIR or saveTo. Re-downloading to the same path is a no-op (disk mode only).',
|
|
829
1102
|
annotations: { readOnlyHint: false },
|
|
830
1103
|
inputSchema: {
|
|
831
1104
|
fileId: z.number().describe('Attachment file id (from ofw_get_message → attachments[].fileId)'),
|
|
832
|
-
inline: z.boolean().describe('If true, return
|
|
1105
|
+
inline: z.boolean().describe('If true, return content inline as MCP content blocks and skip the disk write. If false, write to disk and return the path — except on a hosted deployment with no filesystem, where inline is forced (forcedInline:true) so the content is still returned. If omitted, falls back to the OFW_INLINE_ATTACHMENTS env var (default: false = disk).').optional(),
|
|
833
1106
|
saveTo: z.string().describe('Absolute path or directory to write to. If a directory, the OFW filename is used. Default: ~/Downloads/ofw-mcp/<fileId>-<filename>. Ignored when inline is in effect.').optional(),
|
|
834
1107
|
force: z.boolean().describe('Re-download even if already on disk. Default false. Ignored when inline:true (inline always fetches fresh bytes, or reuses an on-disk copy if present).').optional(),
|
|
1108
|
+
extract: z.boolean().describe('Whether to extract readable content from the file. Default: on for inline delivery of any non-image type, off in disk mode. Set false to get the raw bytes inline instead of extracted text (e.g. to hash or re-upload the file); set true in disk mode to get both the saved path and the extracted content.').optional(),
|
|
1109
|
+
maxChars: z.number().int().min(500).max(500_000).describe('Ceiling on extracted characters (default 50000). Over it, content is clipped on a row/line boundary, `truncated` is set, and anything dropped whole is listed in `extracted.omitted`.').optional(),
|
|
1110
|
+
parts: z.string().describe('Which sheets / slides / pages to extract, e.g. "1-3,5" (1-based positions) or a sheet name like "2026". A bare number matches either a position or a name. Omit for everything. Unselected parts are listed in `extracted.omitted`.').optional(),
|
|
835
1111
|
},
|
|
836
1112
|
}, async (args) => {
|
|
837
1113
|
const fileId = args.fileId;
|
|
@@ -843,6 +1119,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
843
1119
|
// response is honest about it instead of silently ignoring the argument.
|
|
844
1120
|
const inline = requestedInline || !attachmentIO.supportsDisk;
|
|
845
1121
|
const forcedInline = inline && !requestedInline;
|
|
1122
|
+
const deliveryOptions = { extract: args.extract, maxChars: args.maxChars, parts: args.parts };
|
|
846
1123
|
let cached = await cache.getAttachment(fileId);
|
|
847
1124
|
if (!cached) {
|
|
848
1125
|
// Not in cache. Fetch metadata and store under the messageId=0
|
|
@@ -871,25 +1148,12 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
871
1148
|
// charset onto binaries), then fall back to the stripped header, then the
|
|
872
1149
|
// extension. A parameter suffix would make the host reject an image.
|
|
873
1150
|
const mimeType = resolveDownloadMime(bytes, headerMime, fileName);
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
const metaBlock = { type: 'text', text: JSON.stringify(meta, null, 2) };
|
|
881
|
-
// Only host-renderable image types go back as ImageContent (with the bare
|
|
882
|
-
// media type the renderer accepts); everything else — non-renderable
|
|
883
|
-
// images included — goes back as an EmbeddedResource so the caller always
|
|
884
|
-
// gets the bytes.
|
|
885
|
-
if (isHostRenderableImage(mimeType)) {
|
|
886
|
-
return { content: [metaBlock, { type: 'image', data: base64, mimeType }] };
|
|
887
|
-
}
|
|
888
|
-
return { content: [metaBlock, { type: 'resource', resource: {
|
|
889
|
-
uri: `ofw://attachment/${fileId}/${encodeURIComponent(fileName)}`,
|
|
890
|
-
mimeType,
|
|
891
|
-
blob: base64,
|
|
892
|
-
} }] };
|
|
1151
|
+
// The ladder decides between rendering, extracted content, and raw bytes
|
|
1152
|
+
// — see src/tools/delivery.ts. Whatever the host can draw, the caller
|
|
1153
|
+
// ends up holding something readable.
|
|
1154
|
+
return await buildInlineDelivery({
|
|
1155
|
+
fileId, fileName, mimeType, bytes, forcedInline, options: deliveryOptions,
|
|
1156
|
+
});
|
|
893
1157
|
}
|
|
894
1158
|
let dest;
|
|
895
1159
|
// The filename comes from OFW file metadata — i.e. it is controlled by the
|
|
@@ -906,25 +1170,38 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
906
1170
|
else {
|
|
907
1171
|
dest = join(getAttachmentsDir(), `${fileId}-${safeName}`);
|
|
908
1172
|
}
|
|
1173
|
+
// Disk mode extracts only on request: the caller already has a real file to
|
|
1174
|
+
// open, so extraction is an add-on here rather than the point.
|
|
1175
|
+
const extractOnDisk = args.extract === true;
|
|
909
1176
|
if (!args.force && cached.downloadedPath === dest) {
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
1177
|
+
// Nothing was re-fetched, so an extraction has to come off the copy on
|
|
1178
|
+
// disk. If that copy has gone missing, fall through and download again
|
|
1179
|
+
// rather than answering "already downloaded" with no content.
|
|
1180
|
+
const onDisk = extractOnDisk ? attachmentIO.readDownloaded(dest) : null;
|
|
1181
|
+
if (!extractOnDisk || onDisk) {
|
|
1182
|
+
// No bytes on hand for the plain no-op case: normalize the
|
|
1183
|
+
// cached/extension MIME (an empty buffer sniffs nothing) so a stored
|
|
1184
|
+
// `image/png;charset=…` still reports bare.
|
|
1185
|
+
const mimeType = resolveDownloadMime(onDisk ?? Buffer.alloc(0), cached.mimeType, cached.fileName);
|
|
1186
|
+
return jsonResponse({
|
|
1187
|
+
fileId, path: dest, mimeType,
|
|
1188
|
+
sizeBytes: cached.sizeBytes, fileName: cached.fileName, note: 'already downloaded',
|
|
1189
|
+
...(onDisk ? await tryExtract(onDisk, mimeType, cached.fileName, deliveryOptions) : {}),
|
|
1190
|
+
});
|
|
1191
|
+
}
|
|
917
1192
|
}
|
|
918
1193
|
const response = await client.requestBinary('GET', `/pub/v1/myfiles/${fileId}/data`);
|
|
919
1194
|
attachmentIO.writeDownload(dest, response.body);
|
|
920
1195
|
await cache.markAttachmentDownloaded(fileId, dest);
|
|
921
1196
|
const fileName = response.suggestedFileName ?? cached.fileName;
|
|
1197
|
+
const mimeType = resolveDownloadMime(response.body, response.contentType ?? cached.mimeType, fileName);
|
|
922
1198
|
return jsonResponse({
|
|
923
1199
|
fileId,
|
|
924
1200
|
path: dest,
|
|
925
|
-
mimeType
|
|
1201
|
+
mimeType,
|
|
926
1202
|
sizeBytes: response.body.length,
|
|
927
1203
|
fileName,
|
|
1204
|
+
...(extractOnDisk ? await tryExtract(response.body, mimeType, fileName, deliveryOptions) : {}),
|
|
928
1205
|
});
|
|
929
1206
|
});
|
|
930
1207
|
server.registerTool('ofw_sync_messages', {
|
|
@@ -932,7 +1209,7 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
932
1209
|
annotations: { readOnlyHint: false },
|
|
933
1210
|
inputSchema: {
|
|
934
1211
|
folders: z.array(z.enum(['inbox', 'sent', 'drafts'])).min(1).describe('Folders to sync (default: all three). Must be non-empty if given — an empty list would sync nothing while reporting success.').optional(),
|
|
935
|
-
fetchUnreadBodies: z.boolean().describe('If true, also fetch bodies for unread inbox messages
|
|
1212
|
+
fetchUnreadBodies: z.boolean().describe('If true, also fetch bodies for unread inbox messages — which marks each one READ on OurFamilyWizard and stamps a co-parent-visible "First Viewed" time that cannot be undone. Defaults to the OFW_FETCH_UNREAD_BODIES env var (false unless set), and is forced off entirely when OFW_ALLOW_MARK_READ=false.').optional(),
|
|
936
1213
|
deep: z.boolean().describe('If true, walk every OFW page until empty regardless of cache state. Use to backfill gaps. Default false.').optional(),
|
|
937
1214
|
maxRequests: z.number().int().min(1).describe('Maximum OFW requests this single call may make before pausing. When hit, the response reports done:false — call again with the same arguments to continue. Omit to use the server default (OFW_SYNC_MAX_REQUESTS, or unbounded on local installs).').optional(),
|
|
938
1215
|
},
|
|
@@ -940,7 +1217,10 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
940
1217
|
const cache = cacheProvider();
|
|
941
1218
|
const result = await syncAll(client, {
|
|
942
1219
|
folders: args.folders,
|
|
943
|
-
|
|
1220
|
+
// Default from OFW_FETCH_UNREAD_BODIES (false unless set), and capped by
|
|
1221
|
+
// the OFW_ALLOW_MARK_READ ceiling — fetching those bodies is exactly what
|
|
1222
|
+
// stamps a First Viewed time on every unread message it touches.
|
|
1223
|
+
fetchUnreadBodies: getAllowMarkRead() && (args.fetchUnreadBodies ?? getFetchUnreadBodies()),
|
|
944
1224
|
deep: args.deep,
|
|
945
1225
|
maxRequests: args.maxRequests ?? getSyncMaxRequests(),
|
|
946
1226
|
}, cache);
|
|
@@ -953,16 +1233,19 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
953
1233
|
return jsonResponse({ ...result, freshness });
|
|
954
1234
|
});
|
|
955
1235
|
server.registerTool('ofw_check_freshness', {
|
|
956
|
-
description: 'Cheaply confirm whether the local cache still matches OurFamilyWizard, WITHOUT running a full sync. Use this before asserting anything about current state — especially "draft X is still sitting unsent"
|
|
957
|
-
annotations: { readOnlyHint:
|
|
1236
|
+
description: 'Cheaply confirm whether the local cache still matches OurFamilyWizard, WITHOUT running a full sync. Use this before asserting anything about current state — especially "draft X is still sitting unsent". Costs one OFW request for the folder check plus one per messageId. For each folder it returns the live server count next to the cached count. For each id it returns a LIVE lifecycle `state` — "draft" | "sent" | "received" | "deleted" | "unknown" — alongside `folder`, `sentAt`, `existsOnServer` and a content comparison. `state` is the field that answers "is this still a draft?": a draft that has been SENT still exists on the server, so existsOnServer:true never distinguished the two. A cached draft whose state is no longer "draft" reports inSync:false even when its text is byte-identical. Content is compared by revision hash, because OFW draft timestamps do NOT change when a draft is edited in the web app. Does not fetch bodies into the cache, does not touch attachments, and does not depend on sync state. For draftKeys, or a full live draft inventory, use ofw_status.',
|
|
1237
|
+
annotations: { readOnlyHint: false },
|
|
958
1238
|
inputSchema: {
|
|
959
1239
|
folders: z.array(z.enum(['inbox', 'sent', 'drafts'])).min(1).describe('Folders to compare cached vs live counts for. Defaults to all three when messageIds is not given. Must be non-empty if given.').optional(),
|
|
960
|
-
messageIds: z.array(z.number()).describe(`Specific ids to verify against OFW (max ${MAX_FRESHNESS_IDS}).
|
|
961
|
-
allowMarkRead: z.boolean().describe('Default false. Probing an id
|
|
1240
|
+
messageIds: z.array(z.number()).describe(`Specific ids to verify against OFW (max ${MAX_FRESHNESS_IDS}). Ids cached as drafts, as sent messages, or as already-read inbox messages are probed freely — none of those can stamp the record. Anything else is skipped — see allowMarkRead.`).optional(),
|
|
1241
|
+
allowMarkRead: z.boolean().describe('Default false. Probing an id whose cached state cannot rule out an unread INBOX message requires fetching its detail, which marks it READ on OurFamilyWizard and stamps a co-parent-visible "First Viewed" time — irreversible. Such ids are skipped (reason:"WOULD_MARK_READ") unless you set this to true. The server-wide OFW_ALLOW_MARK_READ=false is a ceiling this cannot raise.').optional(),
|
|
962
1242
|
},
|
|
963
1243
|
}, async (args) => {
|
|
964
1244
|
const cache = cacheProvider();
|
|
965
|
-
|
|
1245
|
+
// The server-wide ceiling wins: a per-call allowMarkRead:true (or an
|
|
1246
|
+
// instruction injected into one) must not be able to stamp the record on a
|
|
1247
|
+
// deployment configured to never do so.
|
|
1248
|
+
const allowMarkRead = getAllowMarkRead() && (args.allowMarkRead ?? false);
|
|
966
1249
|
const requestedIds = args.messageIds ?? [];
|
|
967
1250
|
const ids = requestedIds.slice(0, MAX_FRESHNESS_IDS);
|
|
968
1251
|
// Folders default to "all three" only when the caller asked about nothing
|
|
@@ -975,6 +1258,11 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
975
1258
|
requestsUsed++;
|
|
976
1259
|
const data = parseLenient(FolderCountsSchema, await client.request('GET', '/pub/v1/messageFolders?includeFolderCounts=true'), { label: 'ofw-mcp', context: 'GET /pub/v1/messageFolders (ofw_check_freshness)' });
|
|
977
1260
|
const sys = data.systemFolders ?? [];
|
|
1261
|
+
// Take the folder ids while we have them. Without this a call asking
|
|
1262
|
+
// about BOTH folders and ids, on a cache that has never synced, fetched
|
|
1263
|
+
// this exact endpoint twice — once here for the counts and again inside
|
|
1264
|
+
// probeIds' ensureFolderIdMap for the ids it already had in hand.
|
|
1265
|
+
await persistFolderIds(cache, sys);
|
|
978
1266
|
for (const folder of wantFolders) {
|
|
979
1267
|
const entry = sys.find((x) => x.folderType === FOLDER_TYPE[folder]);
|
|
980
1268
|
const serverCount = entry?.totalCount ?? entry?.messageCount ?? entry?.count ?? null;
|
|
@@ -982,7 +1270,13 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
982
1270
|
? (await cache.listDraftIds()).length
|
|
983
1271
|
: await cache.countMessages({ folder });
|
|
984
1272
|
const state = await cache.getSyncState(folder);
|
|
985
|
-
|
|
1273
|
+
// Never synced at all is a DIFFERENT state from "backfill in progress",
|
|
1274
|
+
// and both leave historyComplete false. Conflating them told the caller
|
|
1275
|
+
// that older history was still being backfilled for a folder whose
|
|
1276
|
+
// backfill had never started — advice that reads as "wait it out" when
|
|
1277
|
+
// the real answer is "run a sync".
|
|
1278
|
+
const neverSynced = state === null;
|
|
1279
|
+
const historyComplete = !neverSynced && state.resumePage === null;
|
|
986
1280
|
// A partially backfilled folder legitimately holds fewer messages than
|
|
987
1281
|
// the server, so a count mismatch there proves nothing. Report both
|
|
988
1282
|
// numbers and leave the verdict null rather than crying wolf for the
|
|
@@ -1001,69 +1295,19 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
1001
1295
|
...(inSync === null
|
|
1002
1296
|
? { note: serverCount === null
|
|
1003
1297
|
? 'OFW did not report a count for this folder, so cached-vs-server cannot be compared. Use the per-id check instead.'
|
|
1004
|
-
:
|
|
1298
|
+
: neverSynced
|
|
1299
|
+
? 'This folder has never been synced, so the cache holds nothing to compare. Run ofw_sync_messages.'
|
|
1300
|
+
: 'Older history is still being backfilled, so a lower cachedCount is expected and does not indicate drift.' }
|
|
1005
1301
|
: {}),
|
|
1006
1302
|
});
|
|
1007
1303
|
}
|
|
1008
1304
|
}
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
// court-visible record. Refuse by default rather than quietly doing it.
|
|
1016
|
-
if (cachedDraft === null && !allowMarkRead) {
|
|
1017
|
-
items.push({
|
|
1018
|
-
id,
|
|
1019
|
-
skipped: true,
|
|
1020
|
-
reason: 'NOT_A_CACHED_DRAFT',
|
|
1021
|
-
note: 'Not in the drafts cache. Verifying it requires fetching its detail from OFW, which would mark an unread inbox message as READ on OurFamilyWizard. Pass allowMarkRead:true if that is acceptable.',
|
|
1022
|
-
});
|
|
1023
|
-
continue;
|
|
1024
|
-
}
|
|
1025
|
-
requestsUsed++;
|
|
1026
|
-
try {
|
|
1027
|
-
const server = await fetchServerDraft(client, id);
|
|
1028
|
-
const cacheRevision = cachedDraft === null ? null : draftRevision(cachedDraft);
|
|
1029
|
-
if (server === null) {
|
|
1030
|
-
items.push({
|
|
1031
|
-
id,
|
|
1032
|
-
existsOnServer: false,
|
|
1033
|
-
inSync: false,
|
|
1034
|
-
cacheRevision,
|
|
1035
|
-
serverRevision: null,
|
|
1036
|
-
note: cachedDraft === null
|
|
1037
|
-
? 'Not found on OurFamilyWizard.'
|
|
1038
|
-
: 'This draft is in the local cache but NO LONGER EXISTS on OurFamilyWizard — it was sent or deleted elsewhere. Do not describe it as still unsent.',
|
|
1039
|
-
});
|
|
1040
|
-
continue;
|
|
1041
|
-
}
|
|
1042
|
-
const serverRevision = draftRevision(server);
|
|
1043
|
-
items.push({
|
|
1044
|
-
id,
|
|
1045
|
-
existsOnServer: true,
|
|
1046
|
-
cacheRevision,
|
|
1047
|
-
serverRevision,
|
|
1048
|
-
inSync: cacheRevision !== null && cacheRevision === serverRevision,
|
|
1049
|
-
...(cacheRevision === null
|
|
1050
|
-
? { note: 'Exists on OurFamilyWizard but is not in the local cache.' }
|
|
1051
|
-
: cacheRevision !== serverRevision
|
|
1052
|
-
? { note: 'Content differs from the cache — it was edited on OurFamilyWizard since the last sync. Run ofw_sync_messages before reading or writing it.' }
|
|
1053
|
-
: {}),
|
|
1054
|
-
});
|
|
1055
|
-
}
|
|
1056
|
-
catch (e) {
|
|
1057
|
-
// A check that could not run must not read as "in sync".
|
|
1058
|
-
items.push({
|
|
1059
|
-
id,
|
|
1060
|
-
error: 'FRESHNESS_CHECK_FAILED',
|
|
1061
|
-
message: e.message,
|
|
1062
|
-
inSync: null,
|
|
1063
|
-
note: 'The freshness check itself failed, so nothing is confirmed either way.',
|
|
1064
|
-
});
|
|
1065
|
-
}
|
|
1066
|
-
}
|
|
1305
|
+
// One folder-id resolve for the whole batch, and only when at least one id
|
|
1306
|
+
// is actually going to be probed — a call whose every id is refused for
|
|
1307
|
+
// mark-read reasons must cost nothing at all.
|
|
1308
|
+
const probed = await probeIds(client, cache, ids, { allowMarkRead });
|
|
1309
|
+
requestsUsed += probed.requests;
|
|
1310
|
+
const items = probed.items;
|
|
1067
1311
|
const payload = {
|
|
1068
1312
|
checkedAt: new Date().toISOString(),
|
|
1069
1313
|
requestsUsed,
|
|
@@ -1075,6 +1319,156 @@ export function registerMessageTools(server, client, cacheProvider, attachmentIO
|
|
|
1075
1319
|
}
|
|
1076
1320
|
return jsonResponse(payload);
|
|
1077
1321
|
});
|
|
1322
|
+
server.registerTool('ofw_status', {
|
|
1323
|
+
description: 'ONE live call that answers "where does everything stand?". This is the call that should back any status summary about drafts or specific messages — never session memory, and never a cached read alone. With no arguments it returns the FULL current draft inventory, verified against OurFamilyWizard. Pass ids and/or draftKeys to get each one\'s live lifecycle `state` ("draft" | "sent" | "received" | "deleted" | "unknown") with `sentAt` and `viewedAt`. A draftKey is the stable identity ofw_save_draft returns: editing a draft mints a new OFW id every time (create-then-delete), so the key is the only way to ask "what happened to the thing I was working on?" — it resolves to the chain\'s current id and keeps resolving after the draft is SENT (state:"sent" with sentMessageId). The top-level `complete` is true ONLY when every part of this snapshot was verified live; if it is false, do not state a draft count or a lifecycle claim from this payload.',
|
|
1324
|
+
annotations: { readOnlyHint: false },
|
|
1325
|
+
inputSchema: {
|
|
1326
|
+
ids: z.array(z.number()).describe(`Message/draft ids to resolve to a live state (combined with draftKeys, max ${MAX_FRESHNESS_IDS} probes per call).`).optional(),
|
|
1327
|
+
draftKeys: z.array(z.string()).describe('Stable draft keys (from ofw_save_draft / ofw_list_drafts) to resolve to their CURRENT id and state.').optional(),
|
|
1328
|
+
includeDraftInventory: z.boolean().describe('Return the full current draft list, verified against OurFamilyWizard first. Defaults to TRUE when neither ids nor draftKeys is given (so a bare ofw_status() is a complete status snapshot), otherwise false.').optional(),
|
|
1329
|
+
allowMarkRead: z.boolean().describe('Default false. An id whose cached state cannot rule out an unread INBOX message can only be probed by fetching its detail, which marks it READ on OurFamilyWizard — irreversible and co-parent-visible. Those are skipped unless this is true. Cached drafts, sent messages and already-read messages are always probed. Capped by OFW_ALLOW_MARK_READ.').optional(),
|
|
1330
|
+
},
|
|
1331
|
+
}, async (args) => {
|
|
1332
|
+
const cache = cacheProvider();
|
|
1333
|
+
const allowMarkRead = getAllowMarkRead() && (args.allowMarkRead ?? false);
|
|
1334
|
+
const requestedIds = args.ids ?? [];
|
|
1335
|
+
const requestedKeys = args.draftKeys ?? [];
|
|
1336
|
+
// A bare ofw_status() is meant to be the "here is where everything stands"
|
|
1337
|
+
// call, so it defaults to the inventory. Asking about specific ids does not
|
|
1338
|
+
// silently spend a drafts sync you did not ask for.
|
|
1339
|
+
const wantInventory = args.includeDraftInventory
|
|
1340
|
+
?? (requestedIds.length === 0 && requestedKeys.length === 0);
|
|
1341
|
+
const allTargets = [
|
|
1342
|
+
...requestedIds.map((id) => ({ kind: 'id', id })),
|
|
1343
|
+
...requestedKeys.map((draftKey) => ({ kind: 'draftKey', draftKey })),
|
|
1344
|
+
];
|
|
1345
|
+
const targets = allTargets.slice(0, MAX_FRESHNESS_IDS);
|
|
1346
|
+
const truncated = allTargets.length - targets.length;
|
|
1347
|
+
// Nothing asked for, nothing checked — so there is nothing to be complete
|
|
1348
|
+
// ABOUT. Returning `complete: true` here would be a reassuring-looking
|
|
1349
|
+
// payload that verified precisely nothing, which is the failure mode this
|
|
1350
|
+
// whole tool exists to remove.
|
|
1351
|
+
if (!wantInventory && targets.length === 0) {
|
|
1352
|
+
return jsonErrorResponse({
|
|
1353
|
+
result: 'NOTHING_REQUESTED',
|
|
1354
|
+
reason: 'ofw_status was called with includeDraftInventory:false and no ids or draftKeys, so nothing was checked.',
|
|
1355
|
+
remedy: 'Call ofw_status() with no arguments for the full draft inventory, or pass ids / draftKeys.',
|
|
1356
|
+
complete: false,
|
|
1357
|
+
});
|
|
1358
|
+
}
|
|
1359
|
+
let probeRequests = 0;
|
|
1360
|
+
const incomplete = [];
|
|
1361
|
+
// ── Draft inventory ────────────────────────────────────────────────────
|
|
1362
|
+
let drafts;
|
|
1363
|
+
let inventoryComplete = true;
|
|
1364
|
+
let inventoryFreshness;
|
|
1365
|
+
if (wantInventory) {
|
|
1366
|
+
const sync = await syncAll(client, {
|
|
1367
|
+
folders: ['drafts'],
|
|
1368
|
+
maxRequests: getSyncMaxRequests(),
|
|
1369
|
+
}, cache);
|
|
1370
|
+
// `refreshed` is the only honest signal that the walk actually diffed
|
|
1371
|
+
// against OFW. A budget-paused walk applies nothing and returns no count
|
|
1372
|
+
// — reporting its inventory as complete would be the same lie as
|
|
1373
|
+
// `drafts: 0` from a deferred sync.
|
|
1374
|
+
inventoryComplete = sync.refreshed.includes('drafts');
|
|
1375
|
+
const { freshness, cacheStatus, serverConfirmed } = await draftsFreshness(cache);
|
|
1376
|
+
inventoryFreshness = freshness;
|
|
1377
|
+
if (!serverConfirmed)
|
|
1378
|
+
inventoryComplete = false;
|
|
1379
|
+
const total = await cache.countDrafts();
|
|
1380
|
+
const rows = await cache.listDrafts({ page: 1, size: Math.max(total, 1) });
|
|
1381
|
+
const keyById = new Map((await cache.getDraftLineageByIds(rows.map((d) => d.id))).map((l) => [l.id, l.draftKey]));
|
|
1382
|
+
drafts = rows.map((d) => ({
|
|
1383
|
+
id: d.id,
|
|
1384
|
+
draftKey: keyById.get(d.id) ?? null,
|
|
1385
|
+
subject: d.subject,
|
|
1386
|
+
revision: draftRevision(d),
|
|
1387
|
+
modifiedAt: d.modifiedAt,
|
|
1388
|
+
recipients: d.recipients,
|
|
1389
|
+
replyToId: d.replyToId,
|
|
1390
|
+
cacheStatus,
|
|
1391
|
+
}));
|
|
1392
|
+
if (!inventoryComplete) {
|
|
1393
|
+
incomplete.push('the drafts folder was not fully verified against OurFamilyWizard on this call (the request budget paused the walk), so this inventory may be missing or misreporting drafts');
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
// ── Per-id / per-key lifecycle ─────────────────────────────────────────
|
|
1397
|
+
const requested = [];
|
|
1398
|
+
if (targets.length > 0) {
|
|
1399
|
+
// Resolve keys to ids first so a key and a bare id naming the SAME
|
|
1400
|
+
// message are probed once, not twice.
|
|
1401
|
+
const resolved = new Map();
|
|
1402
|
+
for (const t of targets) {
|
|
1403
|
+
if (t.kind === 'draftKey' && !resolved.has(t.draftKey)) {
|
|
1404
|
+
resolved.set(t.draftKey, await resolveDraftKey(cache, t.draftKey));
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
const toProbe = new Set();
|
|
1408
|
+
for (const t of targets) {
|
|
1409
|
+
if (t.kind === 'id')
|
|
1410
|
+
toProbe.add(t.id);
|
|
1411
|
+
else {
|
|
1412
|
+
const chain = resolved.get(t.draftKey);
|
|
1413
|
+
if (chain !== null && chain !== undefined)
|
|
1414
|
+
toProbe.add(chain.currentId);
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
const probed = await probeIds(client, cache, [...toProbe], { allowMarkRead });
|
|
1418
|
+
probeRequests += probed.requests;
|
|
1419
|
+
const probes = new Map(probed.items.map((item) => [item.id, item]));
|
|
1420
|
+
for (const t of targets) {
|
|
1421
|
+
if (t.kind === 'id') {
|
|
1422
|
+
requested.push(decorate(probes.get(t.id)));
|
|
1423
|
+
continue;
|
|
1424
|
+
}
|
|
1425
|
+
const chain = resolved.get(t.draftKey);
|
|
1426
|
+
if (chain === null || chain === undefined) {
|
|
1427
|
+
requested.push({
|
|
1428
|
+
draftKey: t.draftKey,
|
|
1429
|
+
state: 'unknown',
|
|
1430
|
+
error: 'UNKNOWN_DRAFT_KEY',
|
|
1431
|
+
note: 'This draftKey has never been recorded in the local cache, so it cannot be resolved to a message id. Draft keys are minted by ofw_save_draft; a cache rebuilt or opened on another machine will not know an older key.',
|
|
1432
|
+
});
|
|
1433
|
+
continue;
|
|
1434
|
+
}
|
|
1435
|
+
requested.push({
|
|
1436
|
+
draftKey: t.draftKey,
|
|
1437
|
+
currentId: chain.currentId,
|
|
1438
|
+
previousIds: chain.ids.slice(0, -1),
|
|
1439
|
+
...decorate(probes.get(chain.currentId)),
|
|
1440
|
+
});
|
|
1441
|
+
}
|
|
1442
|
+
for (const entry of requested) {
|
|
1443
|
+
if (entry.skipped === true || entry.error !== undefined || entry.state === 'unknown') {
|
|
1444
|
+
incomplete.push(`id/key ${String(entry.draftKey ?? entry.id)} could not be resolved to a confirmed live state`);
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
if (truncated > 0) {
|
|
1449
|
+
incomplete.push(`${truncated} of ${allTargets.length} requested ids/draftKeys were not probed (per-call cap of ${MAX_FRESHNESS_IDS})`);
|
|
1450
|
+
}
|
|
1451
|
+
const complete = incomplete.length === 0;
|
|
1452
|
+
return jsonResponse({
|
|
1453
|
+
checkedAt: new Date().toISOString(),
|
|
1454
|
+
probeRequests,
|
|
1455
|
+
...(drafts !== undefined ? { drafts, draftCount: drafts.length, draftInventoryComplete: inventoryComplete } : {}),
|
|
1456
|
+
...(requested.length > 0 ? { requested } : {}),
|
|
1457
|
+
complete,
|
|
1458
|
+
...(complete
|
|
1459
|
+
? {}
|
|
1460
|
+
: { incompleteReasons: incomplete, note: 'complete:false — this snapshot is NOT a verified statement of current state. Do not report a draft count or say whether something was sent from it; resolve the reasons above (usually by calling ofw_sync_messages, or re-calling with allowMarkRead:true) and ask again.' }),
|
|
1461
|
+
...(inventoryFreshness !== undefined ? { freshness: inventoryFreshness } : {}),
|
|
1462
|
+
});
|
|
1463
|
+
});
|
|
1464
|
+
}
|
|
1465
|
+
/**
|
|
1466
|
+
* Add the derived fields a status entry wants on top of a raw lifecycle probe.
|
|
1467
|
+
* `sentMessageId` names the id the message ended up as once sent — the answer to
|
|
1468
|
+
* "the draft I was editing went where?" — and only exists for a `sent` verdict.
|
|
1469
|
+
*/
|
|
1470
|
+
function decorate(item) {
|
|
1471
|
+
return item.state === 'sent' ? { ...item, sentMessageId: item.id } : { ...item };
|
|
1078
1472
|
}
|
|
1079
1473
|
// OFW's bulk-delete endpoint takes a multipart form with `messageIds`.
|
|
1080
1474
|
// Used by both ofw_delete_draft and ofw_send_message (draft cleanup).
|