ofw-mcp 2.8.0 → 2.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +44 -3
- package/dist/bundle.js +846 -123
- package/dist/cache/store.js +79 -1
- package/dist/config.js +18 -0
- package/dist/index.js +1 -1
- package/dist/sync.js +4 -0
- package/dist/timestamps.js +282 -0
- package/dist/tools/_shared.js +17 -3
- package/dist/tools/draft-freshness.js +46 -13
- package/dist/tools/lifecycle.js +277 -0
- package/dist/tools/messages.js +447 -135
- package/package.json +1 -1
- package/server.json +2 -2
- package/skills/ofw/SKILL.md +32 -13
package/dist/bundle.js
CHANGED
|
@@ -38407,7 +38407,7 @@ async function loginWithPassword(username, password) {
|
|
|
38407
38407
|
// package.json
|
|
38408
38408
|
var package_default = {
|
|
38409
38409
|
name: "ofw-mcp",
|
|
38410
|
-
version: "2.
|
|
38410
|
+
version: "2.9.1",
|
|
38411
38411
|
license: "MIT",
|
|
38412
38412
|
mcpName: "io.github.chrischall/ofw-mcp",
|
|
38413
38413
|
description: "OurFamilyWizard MCP server for Claude \u2014 developed and maintained by AI (Claude Code)",
|
|
@@ -38689,11 +38689,208 @@ var OFWClient = class {
|
|
|
38689
38689
|
};
|
|
38690
38690
|
var client = new OFWClient();
|
|
38691
38691
|
|
|
38692
|
+
// src/timestamps.ts
|
|
38693
|
+
var DEFAULT_DISPLAY_TZ = "America/New_York";
|
|
38694
|
+
function isValidTimeZone(tz) {
|
|
38695
|
+
try {
|
|
38696
|
+
new Intl.DateTimeFormat("en-US", { timeZone: tz });
|
|
38697
|
+
return true;
|
|
38698
|
+
} catch {
|
|
38699
|
+
return false;
|
|
38700
|
+
}
|
|
38701
|
+
}
|
|
38702
|
+
function displayTimeZone() {
|
|
38703
|
+
const configured = readEnvVar("DISPLAY_TZ");
|
|
38704
|
+
if (configured && isValidTimeZone(configured)) return configured;
|
|
38705
|
+
return DEFAULT_DISPLAY_TZ;
|
|
38706
|
+
}
|
|
38707
|
+
function offsetAt(instant, tz) {
|
|
38708
|
+
const w = wallPartsIn(instant, tz);
|
|
38709
|
+
const asUTC = Date.UTC(w.year, w.month - 1, w.day, w.hour, w.minute, w.second, instant.getUTCMilliseconds());
|
|
38710
|
+
const minutes = Math.round((asUTC - instant.getTime()) / 6e4);
|
|
38711
|
+
const sign = minutes < 0 ? "-" : "+";
|
|
38712
|
+
const abs = Math.abs(minutes);
|
|
38713
|
+
return `${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`;
|
|
38714
|
+
}
|
|
38715
|
+
var wallPartsFormatters = /* @__PURE__ */ new Map();
|
|
38716
|
+
var displayFormatters = /* @__PURE__ */ new Map();
|
|
38717
|
+
function wallPartsFormatter(tz) {
|
|
38718
|
+
let fmt = wallPartsFormatters.get(tz);
|
|
38719
|
+
if (!fmt) {
|
|
38720
|
+
fmt = buildWallPartsFormatter(tz);
|
|
38721
|
+
wallPartsFormatters.set(tz, fmt);
|
|
38722
|
+
}
|
|
38723
|
+
return fmt;
|
|
38724
|
+
}
|
|
38725
|
+
function wallPartsIn(instant, tz) {
|
|
38726
|
+
const parts = wallPartsFormatter(tz).formatToParts(instant);
|
|
38727
|
+
const out = {};
|
|
38728
|
+
for (const p of parts) {
|
|
38729
|
+
if (p.type !== "literal") out[p.type] = Number(p.value);
|
|
38730
|
+
}
|
|
38731
|
+
return out;
|
|
38732
|
+
}
|
|
38733
|
+
function buildWallPartsFormatter(tz) {
|
|
38734
|
+
return new Intl.DateTimeFormat("en-US", {
|
|
38735
|
+
timeZone: tz,
|
|
38736
|
+
year: "numeric",
|
|
38737
|
+
month: "2-digit",
|
|
38738
|
+
day: "2-digit",
|
|
38739
|
+
hour: "2-digit",
|
|
38740
|
+
minute: "2-digit",
|
|
38741
|
+
second: "2-digit",
|
|
38742
|
+
// h23 pins midnight to hour 00; without it some ICU builds render hour 24.
|
|
38743
|
+
hourCycle: "h23"
|
|
38744
|
+
});
|
|
38745
|
+
}
|
|
38746
|
+
function wallTimeToInstant(y, mo, d, h, mi, s, ms, tz) {
|
|
38747
|
+
let guess = Date.UTC(y, mo - 1, d, h, mi, s, ms);
|
|
38748
|
+
for (let i = 0; i < 2; i += 1) {
|
|
38749
|
+
const seen = wallPartsIn(new Date(guess), tz);
|
|
38750
|
+
const seenUTC = Date.UTC(seen.year, seen.month - 1, seen.day, seen.hour, seen.minute, seen.second, ms);
|
|
38751
|
+
const drift = Date.UTC(y, mo - 1, d, h, mi, s, ms) - seenUTC;
|
|
38752
|
+
if (drift === 0) break;
|
|
38753
|
+
guess += drift;
|
|
38754
|
+
}
|
|
38755
|
+
return new Date(guess);
|
|
38756
|
+
}
|
|
38757
|
+
function pad(n, width = 2) {
|
|
38758
|
+
return String(n).padStart(width, "0");
|
|
38759
|
+
}
|
|
38760
|
+
function isoWithOffset(instant, tz, offset) {
|
|
38761
|
+
const w = wallPartsIn(instant, tz);
|
|
38762
|
+
const msPart = instant.getUTCMilliseconds();
|
|
38763
|
+
const frac = msPart ? `.${pad(msPart, 3)}` : "";
|
|
38764
|
+
return `${pad(w.year, 4)}-${pad(w.month)}-${pad(w.day)}T${pad(w.hour)}:${pad(w.minute)}:${pad(w.second)}${frac}${offset}`;
|
|
38765
|
+
}
|
|
38766
|
+
function formatInstant(instant, tz = displayTimeZone()) {
|
|
38767
|
+
const offset = offsetAt(instant, tz);
|
|
38768
|
+
let fmt = displayFormatters.get(tz);
|
|
38769
|
+
if (!fmt) {
|
|
38770
|
+
fmt = new Intl.DateTimeFormat("en-US", {
|
|
38771
|
+
timeZone: tz,
|
|
38772
|
+
weekday: "short",
|
|
38773
|
+
month: "short",
|
|
38774
|
+
day: "numeric",
|
|
38775
|
+
year: "numeric",
|
|
38776
|
+
hour: "numeric",
|
|
38777
|
+
minute: "2-digit",
|
|
38778
|
+
timeZoneName: "short"
|
|
38779
|
+
});
|
|
38780
|
+
displayFormatters.set(tz, fmt);
|
|
38781
|
+
}
|
|
38782
|
+
return { iso: isoWithOffset(instant, tz, offset), display: fmt.format(instant) };
|
|
38783
|
+
}
|
|
38784
|
+
var TIMESTAMP_KEYS = /* @__PURE__ */ new Set([
|
|
38785
|
+
// OFW: naive local wall-clock from the API.
|
|
38786
|
+
"sentAt",
|
|
38787
|
+
"viewedAt",
|
|
38788
|
+
"modifiedAt",
|
|
38789
|
+
"createdAt",
|
|
38790
|
+
"dueAt",
|
|
38791
|
+
"occurredAt",
|
|
38792
|
+
// OFW: UTC instants we stamp ourselves.
|
|
38793
|
+
"fetchedBodyAt",
|
|
38794
|
+
"fetchedAt",
|
|
38795
|
+
"syncedAt",
|
|
38796
|
+
"downloadedAt",
|
|
38797
|
+
"recordedAt",
|
|
38798
|
+
"expiresAt",
|
|
38799
|
+
// Freshness/sync bookkeeping. These sit in the SAME object as `asOf`, so
|
|
38800
|
+
// omitting them left the freshness block emitting two zones at once — the
|
|
38801
|
+
// exact defect this module exists to remove. Enumerated from a sweep of
|
|
38802
|
+
// emitted field names rather than from the ones a bug report happened to
|
|
38803
|
+
// mention.
|
|
38804
|
+
"asOf",
|
|
38805
|
+
"checkedAt",
|
|
38806
|
+
"lastVerifiedAt",
|
|
38807
|
+
"oldestVerifiedAt",
|
|
38808
|
+
"lastServerSyncAt",
|
|
38809
|
+
"lastSyncAt",
|
|
38810
|
+
// OFW API inner shape: `date: { dateTime }`, `viewed: { dateTime }`.
|
|
38811
|
+
"dateTime",
|
|
38812
|
+
// Generic.
|
|
38813
|
+
"date",
|
|
38814
|
+
"updated",
|
|
38815
|
+
"lastModified",
|
|
38816
|
+
"expirationTime"
|
|
38817
|
+
]);
|
|
38818
|
+
var ZONE_NAME_KEYS = /* @__PURE__ */ new Set(["timeZone", "timezone"]);
|
|
38819
|
+
var RFC3339_WITH_OFFSET = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?(?:\.(\d{1,9}))?(Z|[+-]\d{2}:?\d{2})$/;
|
|
38820
|
+
var NAIVE_DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?(?:\.(\d{1,9}))?$/;
|
|
38821
|
+
var DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
|
|
38822
|
+
function isRealCalendarDate(p) {
|
|
38823
|
+
const utc = new Date(Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second));
|
|
38824
|
+
return utc.getUTCFullYear() === p.year && utc.getUTCMonth() === p.month - 1 && utc.getUTCDate() === p.day && utc.getUTCHours() === p.hour && utc.getUTCMinutes() === p.minute && utc.getUTCSeconds() === p.second;
|
|
38825
|
+
}
|
|
38826
|
+
function parseTimestampValue(key, value, assumeNaiveIn) {
|
|
38827
|
+
if (typeof value !== "string") return null;
|
|
38828
|
+
const raw = value.trim();
|
|
38829
|
+
if (raw === "" || DATE_ONLY.test(raw)) return null;
|
|
38830
|
+
if (RFC3339_WITH_OFFSET.test(raw)) {
|
|
38831
|
+
const parsed = new Date(raw.replace(" ", "T"));
|
|
38832
|
+
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
|
38833
|
+
}
|
|
38834
|
+
const naive = NAIVE_DATE_TIME.exec(raw);
|
|
38835
|
+
if (naive) {
|
|
38836
|
+
const [, y, mo, d, h, mi, s, frac] = naive;
|
|
38837
|
+
const ms = frac ? Number(frac.padEnd(3, "0").slice(0, 3)) : 0;
|
|
38838
|
+
const parts = {
|
|
38839
|
+
year: Number(y),
|
|
38840
|
+
month: Number(mo),
|
|
38841
|
+
day: Number(d),
|
|
38842
|
+
hour: Number(h),
|
|
38843
|
+
minute: Number(mi),
|
|
38844
|
+
second: Number(s ?? "0")
|
|
38845
|
+
};
|
|
38846
|
+
if (!isRealCalendarDate(parts)) return null;
|
|
38847
|
+
return wallTimeToInstant(
|
|
38848
|
+
parts.year,
|
|
38849
|
+
parts.month,
|
|
38850
|
+
parts.day,
|
|
38851
|
+
parts.hour,
|
|
38852
|
+
parts.minute,
|
|
38853
|
+
parts.second,
|
|
38854
|
+
ms,
|
|
38855
|
+
assumeNaiveIn
|
|
38856
|
+
);
|
|
38857
|
+
}
|
|
38858
|
+
return null;
|
|
38859
|
+
}
|
|
38860
|
+
function walk(node, tz) {
|
|
38861
|
+
if (Array.isArray(node)) {
|
|
38862
|
+
for (const item of node) walk(item, tz);
|
|
38863
|
+
return node;
|
|
38864
|
+
}
|
|
38865
|
+
if (node === null || typeof node !== "object") return node;
|
|
38866
|
+
const obj = node;
|
|
38867
|
+
for (const key of Object.keys(obj)) {
|
|
38868
|
+
const value = obj[key];
|
|
38869
|
+
if (value !== null && typeof value === "object") {
|
|
38870
|
+
walk(value, tz);
|
|
38871
|
+
continue;
|
|
38872
|
+
}
|
|
38873
|
+
if (ZONE_NAME_KEYS.has(key) || !TIMESTAMP_KEYS.has(key)) continue;
|
|
38874
|
+
const instant = parseTimestampValue(key, value, tz);
|
|
38875
|
+
if (!instant) continue;
|
|
38876
|
+
const { iso, display } = formatInstant(instant, tz);
|
|
38877
|
+
obj[key] = iso;
|
|
38878
|
+
obj[`${key}Display`] = display;
|
|
38879
|
+
}
|
|
38880
|
+
return obj;
|
|
38881
|
+
}
|
|
38882
|
+
function normalizeTimestampsInValue(value, tz = displayTimeZone()) {
|
|
38883
|
+
if (value === null || typeof value !== "object") return value;
|
|
38884
|
+
return walk(structuredClone(value), tz);
|
|
38885
|
+
}
|
|
38886
|
+
|
|
38692
38887
|
// src/tools/_shared.ts
|
|
38693
|
-
|
|
38888
|
+
function jsonResponse(data) {
|
|
38889
|
+
return textResult(normalizeTimestampsInValue(data));
|
|
38890
|
+
}
|
|
38694
38891
|
var textResponse = rawTextResult;
|
|
38695
38892
|
function jsonErrorResponse(data) {
|
|
38696
|
-
return { ...
|
|
38893
|
+
return { ...jsonResponse(data), isError: true };
|
|
38697
38894
|
}
|
|
38698
38895
|
var ApiRecipientSchema = external_exports.looseObject({
|
|
38699
38896
|
// Live OFW payloads key the recipient's id as `userId` (verified against a
|
|
@@ -38856,6 +39053,7 @@ async function resolveFolderIds(client2, store) {
|
|
|
38856
39053
|
};
|
|
38857
39054
|
await store.setMeta("drafts_folder_id", ids.drafts);
|
|
38858
39055
|
await store.setMeta("sent_folder_id", ids.sent);
|
|
39056
|
+
await store.setMeta("inbox_folder_id", ids.inbox);
|
|
38859
39057
|
return ids;
|
|
38860
39058
|
}
|
|
38861
39059
|
var ListItemSchema = external_exports.looseObject({
|
|
@@ -39249,6 +39447,9 @@ function getAllowMarkRead() {
|
|
|
39249
39447
|
function getFetchUnreadBodies() {
|
|
39250
39448
|
return parseBoolEnv("OFW_FETCH_UNREAD_BODIES");
|
|
39251
39449
|
}
|
|
39450
|
+
function getAutoRefreshStaleReads() {
|
|
39451
|
+
return parseBoolEnv("OFW_AUTO_REFRESH");
|
|
39452
|
+
}
|
|
39252
39453
|
function getDefaultInlineAttachments() {
|
|
39253
39454
|
return parseBoolEnv("OFW_INLINE_ATTACHMENTS");
|
|
39254
39455
|
}
|
|
@@ -39394,12 +39595,30 @@ var ServerDraftSchema = external_exports.looseObject({
|
|
|
39394
39595
|
subject: external_exports.string().optional(),
|
|
39395
39596
|
body: external_exports.string().optional(),
|
|
39396
39597
|
replyToId: external_exports.number().nullable().optional(),
|
|
39397
|
-
recipients: external_exports.array(ApiRecipientSchema).optional()
|
|
39598
|
+
recipients: external_exports.array(ApiRecipientSchema).optional(),
|
|
39599
|
+
// Read for the LIFECYCLE answer (see tools/lifecycle.ts): which folder OFW
|
|
39600
|
+
// itself says this id lives in right now. `existsOnServer` alone cannot
|
|
39601
|
+
// distinguish "still a draft" from "was sent" — a sent draft still exists.
|
|
39602
|
+
// `id` accepts BOTH spellings deliberately. This schema is parsed in
|
|
39603
|
+
// `mode: 'strict'` because it backs the destructive-draft guard, so a
|
|
39604
|
+
// present-but-mistyped field THROWS — and OFW is already inconsistent about
|
|
39605
|
+
// this exact field: the folders listing (`FoldersSchema` in sync.ts) types it
|
|
39606
|
+
// `z.string()`, while message detail has been observed returning a number.
|
|
39607
|
+
// Pinning one spelling here would turn a harmless representation change into
|
|
39608
|
+
// a hard failure of ofw_save_draft / ofw_delete_draft, which is the opposite
|
|
39609
|
+
// of what a strict boundary is for: it exists to stop us acting on a response
|
|
39610
|
+
// we cannot interpret, not to reject one we can. `folderId` is normalized to
|
|
39611
|
+
// a string below, so both spellings compare correctly downstream.
|
|
39612
|
+
folder: external_exports.looseObject({
|
|
39613
|
+
id: external_exports.union([external_exports.string(), external_exports.number()]).optional(),
|
|
39614
|
+
name: external_exports.string().optional()
|
|
39615
|
+
}).nullable().optional(),
|
|
39616
|
+
date: external_exports.looseObject({ dateTime: external_exports.string().optional() }).nullable().optional()
|
|
39398
39617
|
});
|
|
39399
39618
|
function isNotFound(e) {
|
|
39400
39619
|
return e instanceof Error && /OFW API error: 404\b/.test(e.message);
|
|
39401
39620
|
}
|
|
39402
|
-
async function
|
|
39621
|
+
async function fetchMessageSnapshot(client2, id) {
|
|
39403
39622
|
let raw;
|
|
39404
39623
|
try {
|
|
39405
39624
|
raw = await client2.request("GET", `/pub/v3/messages/${id}`);
|
|
@@ -39416,12 +39635,20 @@ async function fetchServerDraft(client2, id) {
|
|
|
39416
39635
|
mode: "strict"
|
|
39417
39636
|
});
|
|
39418
39637
|
return {
|
|
39419
|
-
|
|
39420
|
-
|
|
39421
|
-
|
|
39422
|
-
|
|
39638
|
+
content: {
|
|
39639
|
+
subject: detail.subject ?? "",
|
|
39640
|
+
body: detail.body ?? "",
|
|
39641
|
+
replyToId: detail.replyToId ?? null,
|
|
39642
|
+
recipients: mapRecipients(detail.recipients)
|
|
39643
|
+
},
|
|
39644
|
+
folderId: detail.folder?.id === void 0 ? null : String(detail.folder.id),
|
|
39645
|
+
folderName: detail.folder?.name ?? null,
|
|
39646
|
+
dateTime: detail.date?.dateTime ?? null
|
|
39423
39647
|
};
|
|
39424
39648
|
}
|
|
39649
|
+
async function fetchServerDraft(client2, id) {
|
|
39650
|
+
return (await fetchMessageSnapshot(client2, id))?.content ?? null;
|
|
39651
|
+
}
|
|
39425
39652
|
var SUBSTANTIVE_FIELDS = ["subject", "body", "recipients"];
|
|
39426
39653
|
function substantiveChanges(changed) {
|
|
39427
39654
|
return changed.filter((f) => SUBSTANTIVE_FIELDS.includes(f));
|
|
@@ -39505,6 +39732,171 @@ function staleDraftPayload(input) {
|
|
|
39505
39732
|
};
|
|
39506
39733
|
}
|
|
39507
39734
|
|
|
39735
|
+
// src/tools/lifecycle.ts
|
|
39736
|
+
var FOLDER_TYPE = {
|
|
39737
|
+
inbox: "INBOX",
|
|
39738
|
+
sent: "SENT_MESSAGES",
|
|
39739
|
+
drafts: "DRAFTS"
|
|
39740
|
+
};
|
|
39741
|
+
var FOLDER_ID_META_KEY = {
|
|
39742
|
+
inbox: "inbox_folder_id",
|
|
39743
|
+
sent: "sent_folder_id",
|
|
39744
|
+
drafts: "drafts_folder_id"
|
|
39745
|
+
};
|
|
39746
|
+
var FOLDERS = ["inbox", "sent", "drafts"];
|
|
39747
|
+
async function readFolderIdMap(store) {
|
|
39748
|
+
return {
|
|
39749
|
+
inbox: await store.getMeta(FOLDER_ID_META_KEY.inbox),
|
|
39750
|
+
sent: await store.getMeta(FOLDER_ID_META_KEY.sent),
|
|
39751
|
+
drafts: await store.getMeta(FOLDER_ID_META_KEY.drafts)
|
|
39752
|
+
};
|
|
39753
|
+
}
|
|
39754
|
+
async function persistFolderIds(store, systemFolders) {
|
|
39755
|
+
for (const folder of FOLDERS) {
|
|
39756
|
+
const entry = systemFolders.find((f) => f.folderType === FOLDER_TYPE[folder]);
|
|
39757
|
+
if (entry !== void 0) await store.setMeta(FOLDER_ID_META_KEY[folder], entry.id);
|
|
39758
|
+
}
|
|
39759
|
+
}
|
|
39760
|
+
async function ensureFolderIdMap(client2, store) {
|
|
39761
|
+
const cached2 = await readFolderIdMap(store);
|
|
39762
|
+
if (cached2.inbox !== null && cached2.sent !== null && cached2.drafts !== null) {
|
|
39763
|
+
return { map: cached2, requests: 0 };
|
|
39764
|
+
}
|
|
39765
|
+
try {
|
|
39766
|
+
const ids = await resolveFolderIds(client2, store);
|
|
39767
|
+
return { map: { inbox: ids.inbox, sent: ids.sent, drafts: ids.drafts }, requests: 1 };
|
|
39768
|
+
} catch {
|
|
39769
|
+
return { map: cached2, requests: 1 };
|
|
39770
|
+
}
|
|
39771
|
+
}
|
|
39772
|
+
function classifyState(snapshot, map2) {
|
|
39773
|
+
if (snapshot === null) return "deleted";
|
|
39774
|
+
const { folderId } = snapshot;
|
|
39775
|
+
if (folderId === null) return "unknown";
|
|
39776
|
+
if (map2.drafts !== null && folderId === map2.drafts) return "draft";
|
|
39777
|
+
if (map2.sent !== null && folderId === map2.sent) return "sent";
|
|
39778
|
+
if (map2.inbox !== null && folderId === map2.inbox) return "received";
|
|
39779
|
+
return "unknown";
|
|
39780
|
+
}
|
|
39781
|
+
function probeWouldStamp(cachedDraft, cachedMessage) {
|
|
39782
|
+
if (cachedDraft !== null) return false;
|
|
39783
|
+
if (cachedMessage === null) return true;
|
|
39784
|
+
if (cachedMessage.folder === "sent") return false;
|
|
39785
|
+
return !deriveRead(cachedMessage);
|
|
39786
|
+
}
|
|
39787
|
+
var SKIP_NOTE = 'Verifying this id requires fetching its detail from OurFamilyWizard, which would mark an unread inbox message as READ and stamp a co-parent-visible "First Viewed" time on the record. Ids already cached as drafts, as sent, or as already-read inbox messages are probed freely because none of those can stamp anything. Run ofw_sync_messages (it walks list pages, not bodies) or pass allowMarkRead:true.';
|
|
39788
|
+
function stateNote(state, cachedAsDraft, folderName) {
|
|
39789
|
+
if (state === "deleted") {
|
|
39790
|
+
return cachedAsDraft ? "This draft is in the local cache but NO LONGER EXISTS on OurFamilyWizard \u2014 it was sent or deleted elsewhere. Do not describe it as still unsent." : "Not found on OurFamilyWizard.";
|
|
39791
|
+
}
|
|
39792
|
+
if (state === "sent") {
|
|
39793
|
+
return cachedAsDraft ? "This id is cached as a DRAFT but OurFamilyWizard now has it in Sent \u2014 it was SENT (see sentAt). It is no longer a draft; saying it is still unsent would be false." : "This id is a sent message on OurFamilyWizard.";
|
|
39794
|
+
}
|
|
39795
|
+
if (state === "received") {
|
|
39796
|
+
return cachedAsDraft ? "This id is cached as a draft but OurFamilyWizard has it in the Inbox. Run ofw_sync_messages to reconcile." : "This id is an inbox message on OurFamilyWizard.";
|
|
39797
|
+
}
|
|
39798
|
+
if (state === "unknown") {
|
|
39799
|
+
return `OurFamilyWizard did not report a folder this tool can map${folderName === null ? "" : ` (it reported "${folderName}")`}, so what this id has become is NOT established. Treat it as unverified rather than assuming it is unchanged.`;
|
|
39800
|
+
}
|
|
39801
|
+
return void 0;
|
|
39802
|
+
}
|
|
39803
|
+
async function probeIds(client2, store, ids, opts) {
|
|
39804
|
+
const draftsById = new Map((await store.getDrafts(ids)).map((d) => [d.id, d]));
|
|
39805
|
+
const messagesById = new Map((await store.getMessages(ids)).map((m) => [m.id, m]));
|
|
39806
|
+
const prepared = ids.map((id) => {
|
|
39807
|
+
const cachedDraft = draftsById.get(id) ?? null;
|
|
39808
|
+
const cachedMessage = cachedDraft === null ? messagesById.get(id) ?? null : null;
|
|
39809
|
+
return {
|
|
39810
|
+
id,
|
|
39811
|
+
cachedDraft,
|
|
39812
|
+
cachedMessage,
|
|
39813
|
+
skip: !opts.allowMarkRead && probeWouldStamp(cachedDraft, cachedMessage)
|
|
39814
|
+
};
|
|
39815
|
+
});
|
|
39816
|
+
let requests = 0;
|
|
39817
|
+
let map2 = { inbox: null, sent: null, drafts: null };
|
|
39818
|
+
if (prepared.some((p) => !p.skip)) {
|
|
39819
|
+
const resolved = await ensureFolderIdMap(client2, store);
|
|
39820
|
+
map2 = resolved.map;
|
|
39821
|
+
requests += resolved.requests;
|
|
39822
|
+
}
|
|
39823
|
+
const keyById = new Map(
|
|
39824
|
+
(await store.getDraftLineageByIds(ids)).map((l) => [l.id, l.draftKey])
|
|
39825
|
+
);
|
|
39826
|
+
const items = [];
|
|
39827
|
+
for (const p of prepared) {
|
|
39828
|
+
if (p.skip) {
|
|
39829
|
+
items.push({ id: p.id, skipped: true, reason: "WOULD_MARK_READ", note: SKIP_NOTE });
|
|
39830
|
+
continue;
|
|
39831
|
+
}
|
|
39832
|
+
const probe = await probeOne(client2, p, map2, keyById.get(p.id) ?? null);
|
|
39833
|
+
requests += probe.requests;
|
|
39834
|
+
items.push(probe.item);
|
|
39835
|
+
}
|
|
39836
|
+
return { items, requests };
|
|
39837
|
+
}
|
|
39838
|
+
async function probeOne(client2, prepared, map2, draftKey) {
|
|
39839
|
+
const { id, cachedDraft } = prepared;
|
|
39840
|
+
let snapshot;
|
|
39841
|
+
try {
|
|
39842
|
+
snapshot = await fetchMessageSnapshot(client2, id);
|
|
39843
|
+
} catch (e) {
|
|
39844
|
+
return {
|
|
39845
|
+
requests: 1,
|
|
39846
|
+
item: {
|
|
39847
|
+
id,
|
|
39848
|
+
error: "FRESHNESS_CHECK_FAILED",
|
|
39849
|
+
message: e.message,
|
|
39850
|
+
inSync: null,
|
|
39851
|
+
note: "The freshness check itself failed, so nothing is confirmed either way."
|
|
39852
|
+
}
|
|
39853
|
+
};
|
|
39854
|
+
}
|
|
39855
|
+
const state = classifyState(snapshot, map2);
|
|
39856
|
+
const cacheRevision = cachedDraft === null ? null : draftRevision(cachedDraft);
|
|
39857
|
+
const serverRevision = snapshot === null ? null : draftRevision(snapshot.content);
|
|
39858
|
+
const viewedAt = snapshot?.content.recipients.find((r) => r.viewedAt !== null)?.viewedAt ?? null;
|
|
39859
|
+
let inSync;
|
|
39860
|
+
if (cachedDraft === null) inSync = null;
|
|
39861
|
+
else if (snapshot === null) inSync = false;
|
|
39862
|
+
else if (cacheRevision !== serverRevision) inSync = false;
|
|
39863
|
+
else if (state === "draft") inSync = true;
|
|
39864
|
+
else if (state === "unknown") inSync = null;
|
|
39865
|
+
else inSync = false;
|
|
39866
|
+
const notes = [];
|
|
39867
|
+
const stateN = stateNote(state, cachedDraft !== null, snapshot?.folderName ?? null);
|
|
39868
|
+
if (stateN !== void 0) notes.push(stateN);
|
|
39869
|
+
if (snapshot !== null && cachedDraft === null) {
|
|
39870
|
+
notes.push("Not in the drafts cache, so there is no cached copy to compare its content against (inSync is null, not false).");
|
|
39871
|
+
} else if (snapshot !== null && cacheRevision !== serverRevision) {
|
|
39872
|
+
notes.push("Content differs from the cache \u2014 it was edited on OurFamilyWizard since the last sync. Run ofw_sync_messages before reading or writing it.");
|
|
39873
|
+
}
|
|
39874
|
+
return {
|
|
39875
|
+
requests: 1,
|
|
39876
|
+
item: {
|
|
39877
|
+
id,
|
|
39878
|
+
state,
|
|
39879
|
+
folder: snapshot?.folderName ?? null,
|
|
39880
|
+
sentAt: state === "sent" ? snapshot?.dateTime ?? null : null,
|
|
39881
|
+
viewedAt,
|
|
39882
|
+
existsOnServer: snapshot !== null,
|
|
39883
|
+
cacheRevision,
|
|
39884
|
+
serverRevision,
|
|
39885
|
+
inSync,
|
|
39886
|
+
...draftKey !== null ? { draftKey } : {},
|
|
39887
|
+
...notes.length > 0 ? { note: notes.join(" ") } : {}
|
|
39888
|
+
}
|
|
39889
|
+
};
|
|
39890
|
+
}
|
|
39891
|
+
async function resolveDraftKey(store, draftKey) {
|
|
39892
|
+
const chain = await store.getDraftLineage(draftKey);
|
|
39893
|
+
if (chain.length === 0) return null;
|
|
39894
|
+
return { currentId: chain[chain.length - 1].id, ids: chain.map((r) => r.id) };
|
|
39895
|
+
}
|
|
39896
|
+
function newDraftKey() {
|
|
39897
|
+
return `dk_${crypto.randomUUID()}`;
|
|
39898
|
+
}
|
|
39899
|
+
|
|
39508
39900
|
// src/extract/inflate.ts
|
|
39509
39901
|
var MAX_DECOMPRESSED_BYTES = 32 * 1024 * 1024;
|
|
39510
39902
|
var DecompressionLimitError = class extends Error {
|
|
@@ -39996,7 +40388,7 @@ function orderedPages(objects) {
|
|
|
39996
40388
|
if (rootRef === void 0) return inFileOrder;
|
|
39997
40389
|
const ordered = [];
|
|
39998
40390
|
const seen = /* @__PURE__ */ new Set();
|
|
39999
|
-
const
|
|
40391
|
+
const walk2 = (num) => {
|
|
40000
40392
|
if (seen.has(num)) return;
|
|
40001
40393
|
seen.add(num);
|
|
40002
40394
|
const obj = objects.get(num);
|
|
@@ -40006,9 +40398,9 @@ function orderedPages(objects) {
|
|
|
40006
40398
|
return;
|
|
40007
40399
|
}
|
|
40008
40400
|
const kids = /\/Kids\s*\[([^\]]*)\]/.exec(obj.body)?.[1];
|
|
40009
|
-
if (kids) for (const kid of refsIn(kids))
|
|
40401
|
+
if (kids) for (const kid of refsIn(kids)) walk2(kid);
|
|
40010
40402
|
};
|
|
40011
|
-
|
|
40403
|
+
walk2(rootRef);
|
|
40012
40404
|
return ordered.length > 0 ? ordered : inFileOrder;
|
|
40013
40405
|
}
|
|
40014
40406
|
function streamBytes(bytes, obj) {
|
|
@@ -40547,7 +40939,10 @@ var MessageDetailSchema = external_exports.looseObject({
|
|
|
40547
40939
|
// The detail payload carries its own owning folder ({id, name}). We read the
|
|
40548
40940
|
// id to label a live-fetched message sent-vs-inbox instead of blindly
|
|
40549
40941
|
// defaulting to inbox — see the folder derivation in ofw_get_message.
|
|
40550
|
-
|
|
40942
|
+
// Same union as ServerDraftSchema's, for the same reason — OFW types this id
|
|
40943
|
+
// as a string on the folders listing and a number on message detail. Lenient
|
|
40944
|
+
// here, so a mismatch only warns, but it would warn on EVERY live fetch.
|
|
40945
|
+
folder: external_exports.looseObject({ id: external_exports.union([external_exports.string(), external_exports.number()]) }).optional()
|
|
40551
40946
|
});
|
|
40552
40947
|
var DetailFilesSchema = external_exports.looseObject({ files: external_exports.array(external_exports.number()).optional() });
|
|
40553
40948
|
var FolderCountsSchema = external_exports.looseObject({
|
|
@@ -40559,11 +40954,6 @@ var FolderCountsSchema = external_exports.looseObject({
|
|
|
40559
40954
|
count: external_exports.number().optional()
|
|
40560
40955
|
})).optional()
|
|
40561
40956
|
});
|
|
40562
|
-
var FOLDER_TYPE = {
|
|
40563
|
-
inbox: "INBOX",
|
|
40564
|
-
sent: "SENT_MESSAGES",
|
|
40565
|
-
drafts: "DRAFTS"
|
|
40566
|
-
};
|
|
40567
40957
|
var MAX_FRESHNESS_IDS = 25;
|
|
40568
40958
|
var UploadedFileSchema = external_exports.looseObject({
|
|
40569
40959
|
fileId: external_exports.number(),
|
|
@@ -40586,6 +40976,37 @@ async function draftsFreshness(cache) {
|
|
|
40586
40976
|
const cacheStatus = completed === "fresh" && freshness.staleness === "fresh" ? "fresh" : "unverified";
|
|
40587
40977
|
return { freshness, serverConfirmed: cacheStatus === "fresh", cacheStatus };
|
|
40588
40978
|
}
|
|
40979
|
+
var 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.';
|
|
40980
|
+
async function guardedCacheRead(o) {
|
|
40981
|
+
let value = await o.read();
|
|
40982
|
+
let refreshed = false;
|
|
40983
|
+
const unverifiable = (v) => o.isEmpty(v) && v.freshness.staleness !== "fresh";
|
|
40984
|
+
if (unverifiable(value) && o.autoRefresh) {
|
|
40985
|
+
await syncAll(o.client, {
|
|
40986
|
+
folders: o.folders,
|
|
40987
|
+
// Same ceiling ofw_sync_messages applies: an automatic refresh must never
|
|
40988
|
+
// stamp unread inbox messages as a side effect of a list read.
|
|
40989
|
+
fetchUnreadBodies: getAllowMarkRead() && getFetchUnreadBodies(),
|
|
40990
|
+
maxRequests: getSyncMaxRequests()
|
|
40991
|
+
}, o.cache);
|
|
40992
|
+
refreshed = true;
|
|
40993
|
+
value = await o.read();
|
|
40994
|
+
}
|
|
40995
|
+
return { value, refreshed, unverifiedEmpty: unverifiable(value) };
|
|
40996
|
+
}
|
|
40997
|
+
function unverifiedEmptyResponse(input) {
|
|
40998
|
+
const { freshness } = input;
|
|
40999
|
+
const age = freshness.ageSeconds === null ? "it has never been checked against OurFamilyWizard" : `it was last verified ${freshness.ageSeconds < 60 ? `${freshness.ageSeconds} sec` : `${Math.round(freshness.ageSeconds / 60)} min`} ago`;
|
|
41000
|
+
const refreshClause = input.refreshed ? " 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." : "";
|
|
41001
|
+
return jsonErrorResponse({
|
|
41002
|
+
result: "UNVERIFIED_EMPTY",
|
|
41003
|
+
reason: `No ${input.what} were found, but the backing cache is "${freshness.staleness}" \u2014 ${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}`,
|
|
41004
|
+
remedy: input.remedy,
|
|
41005
|
+
complete: false,
|
|
41006
|
+
freshness,
|
|
41007
|
+
...input.extra
|
|
41008
|
+
});
|
|
41009
|
+
}
|
|
40589
41010
|
function markReadVerdict(cached2, requested) {
|
|
40590
41011
|
const ceiling = getAllowMarkRead();
|
|
40591
41012
|
if (ceiling && (requested ?? true)) return null;
|
|
@@ -40613,15 +41034,16 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
|
|
|
40613
41034
|
return jsonResponse({ folders: data, freshness });
|
|
40614
41035
|
});
|
|
40615
41036
|
server.registerTool("ofw_list_messages", {
|
|
40616
|
-
description:
|
|
40617
|
-
annotations: { readOnlyHint:
|
|
41037
|
+
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 \u2014 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" \u2014 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.',
|
|
41038
|
+
annotations: { readOnlyHint: false },
|
|
40618
41039
|
inputSchema: {
|
|
40619
41040
|
folderId: external_exports.string().describe('Folder name: "inbox", "sent", or "both" (default "both")').optional(),
|
|
40620
41041
|
page: external_exports.number().int().min(1).describe("Page number (default 1)").optional(),
|
|
40621
41042
|
size: external_exports.number().int().min(1).describe("Messages per page (default 50)").optional(),
|
|
40622
41043
|
since: external_exports.string().describe("ISO date or datetime \u2014 only messages with sent_at >= since (inclusive)").optional(),
|
|
40623
41044
|
until: external_exports.string().describe("ISO date or datetime \u2014 only messages with sent_at < until (exclusive)").optional(),
|
|
40624
|
-
q: external_exports.string().describe("Substring match on subject AND body (case-insensitive). Use to find messages on a specific topic.").optional()
|
|
41045
|
+
q: external_exports.string().describe("Substring match on subject AND body (case-insensitive). Use to find messages on a specific topic.").optional(),
|
|
41046
|
+
autoRefresh: external_exports.boolean().describe(AUTO_REFRESH_DESC).optional()
|
|
40625
41047
|
}
|
|
40626
41048
|
}, async (args) => {
|
|
40627
41049
|
const page = args.page ?? 1;
|
|
@@ -40632,29 +41054,58 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
|
|
|
40632
41054
|
else if (folderArg === "sent") folder = "sent";
|
|
40633
41055
|
else if (folderArg === "both") folder = void 0;
|
|
40634
41056
|
else {
|
|
40635
|
-
return
|
|
40636
|
-
|
|
40637
|
-
|
|
40638
|
-
|
|
40639
|
-
|
|
40640
|
-
|
|
40641
|
-
note: 'folderId must be "inbox", "sent", or "both". Numeric OFW folder IDs are not supported by the cache. No lookup was performed \u2014 this empty result says nothing about what is in the cache.'
|
|
41057
|
+
return jsonErrorResponse({
|
|
41058
|
+
result: "INVALID_FOLDER",
|
|
41059
|
+
reason: `folderId must be "inbox", "sent", or "both" (got ${JSON.stringify(folderArg)}). Numeric OFW folder IDs are not supported by the cache.`,
|
|
41060
|
+
remedy: "Re-call with folderId omitted (searches both) or set to one of the three accepted names.",
|
|
41061
|
+
complete: false,
|
|
41062
|
+
note: 'No lookup was performed. This says NOTHING about what is in the cache \u2014 do not read it as "no messages".'
|
|
40642
41063
|
});
|
|
40643
41064
|
}
|
|
40644
41065
|
const cache = cacheProvider();
|
|
41066
|
+
const folders = folder === void 0 ? ["inbox", "sent"] : [folder];
|
|
40645
41067
|
const filter = { folder, since: args.since, until: args.until, q: args.q };
|
|
40646
|
-
const
|
|
40647
|
-
|
|
40648
|
-
|
|
40649
|
-
|
|
40650
|
-
|
|
41068
|
+
const { value, refreshed, unverifiedEmpty } = await guardedCacheRead({
|
|
41069
|
+
client: client2,
|
|
41070
|
+
cache,
|
|
41071
|
+
folders,
|
|
41072
|
+
autoRefresh: args.autoRefresh ?? getAutoRefreshStaleReads(),
|
|
41073
|
+
isEmpty: (v) => v.total === 0,
|
|
41074
|
+
read: async () => {
|
|
41075
|
+
const total2 = await cache.countMessages(filter);
|
|
41076
|
+
const messages2 = (await cache.listMessages({ ...filter, page, size })).map((m) => withReadState(m));
|
|
41077
|
+
const freshness2 = await buildFreshness(cache, { source: "cache", folders });
|
|
41078
|
+
return { messages: messages2, total: total2, freshness: freshness2 };
|
|
41079
|
+
}
|
|
40651
41080
|
});
|
|
40652
|
-
|
|
41081
|
+
if (unverifiedEmpty) {
|
|
41082
|
+
return unverifiedEmptyResponse({
|
|
41083
|
+
what: "messages matching these filters",
|
|
41084
|
+
freshness: value.freshness,
|
|
41085
|
+
refreshed,
|
|
41086
|
+
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.`,
|
|
41087
|
+
extra: { page, size, filters: { folderId: folderArg, since: args.since, until: args.until, q: args.q } }
|
|
41088
|
+
});
|
|
41089
|
+
}
|
|
41090
|
+
const { messages, total, freshness } = value;
|
|
41091
|
+
const fullSlice = page === 1 && messages.length === total;
|
|
41092
|
+
const complete = fullSlice && freshness.staleness === "fresh" && freshness.historyComplete;
|
|
41093
|
+
const payload = { messages, total, page, size, complete, freshness };
|
|
41094
|
+
if (!complete) {
|
|
41095
|
+
payload.completeNote = [
|
|
41096
|
+
!fullSlice ? `this page holds ${messages.length} of ${total} matching cached messages` : null,
|
|
41097
|
+
freshness.staleness !== "fresh" ? `the cache is "${freshness.staleness}", so newer messages may exist on OurFamilyWizard` : null,
|
|
41098
|
+
!freshness.historyComplete ? "older history is still being backfilled, so the cache does not yet hold every message" : null
|
|
41099
|
+
].filter((r) => r !== null).join("; ").concat(". Do not state a total or an absence from this result without resolving that first.");
|
|
41100
|
+
}
|
|
40653
41101
|
if (total === 0) {
|
|
40654
|
-
payload.note =
|
|
41102
|
+
payload.note = 'No messages match these filters, and the cache IS verified-fresh for these folders \u2014 so this is a real "nothing matched", not a stale-cache artefact. If you expected results, relax the filters.';
|
|
40655
41103
|
} else if (page * size < total) {
|
|
40656
41104
|
payload.note = `Showing ${(page - 1) * size + 1}\u2013${(page - 1) * size + messages.length} of ${total}. Increase 'page' to see more, or narrow with since/until/q.`;
|
|
40657
41105
|
}
|
|
41106
|
+
if (refreshed) {
|
|
41107
|
+
payload.autoRefreshed = true;
|
|
41108
|
+
}
|
|
40658
41109
|
return jsonResponse(payload);
|
|
40659
41110
|
});
|
|
40660
41111
|
server.registerTool("ofw_get_message", {
|
|
@@ -40689,6 +41140,11 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
|
|
|
40689
41140
|
// Concurrency token — pass as expectedRevision to ofw_save_draft /
|
|
40690
41141
|
// ofw_delete_draft to assert you are editing THIS version.
|
|
40691
41142
|
revision: draftRevision(draftRow),
|
|
41143
|
+
// Stable logical identity. Survives the create-then-delete id churn of
|
|
41144
|
+
// editing AND the transition to sent — pass it to ofw_status to ask
|
|
41145
|
+
// "what happened to the thing I was working on?". Null when this draft
|
|
41146
|
+
// was never written through this tool (e.g. authored in the web app).
|
|
41147
|
+
draftKey: (await cache.getDraftLineageById(draftRow.id))?.draftKey ?? null,
|
|
40692
41148
|
cacheStatus,
|
|
40693
41149
|
// False = this draft's existence and unsent status are remembered from
|
|
40694
41150
|
// a cache, not confirmed on OFW. Call ofw_check_freshness before
|
|
@@ -40844,6 +41300,7 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
|
|
|
40844
41300
|
}, SentDetailSchema, "ofw_send_message");
|
|
40845
41301
|
let persisted = null;
|
|
40846
41302
|
let verifyNote = null;
|
|
41303
|
+
let sentDraftKey = null;
|
|
40847
41304
|
if (newId !== null) {
|
|
40848
41305
|
verifyNote = verifyWriteLanded("message", { subject, body }, detail);
|
|
40849
41306
|
persisted = {
|
|
@@ -40860,6 +41317,16 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
|
|
|
40860
41317
|
listData: detail
|
|
40861
41318
|
};
|
|
40862
41319
|
await cache.upsertMessage(persisted);
|
|
41320
|
+
if (draftRef !== void 0) {
|
|
41321
|
+
const prior = await cache.getDraftLineageById(draftRef);
|
|
41322
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
41323
|
+
const key = prior?.draftKey ?? newDraftKey();
|
|
41324
|
+
if (prior === null) {
|
|
41325
|
+
await cache.recordDraftLineage({ id: draftRef, draftKey: key, previousId: null, recordedAt: now });
|
|
41326
|
+
}
|
|
41327
|
+
await cache.recordDraftLineage({ id: newId, draftKey: key, previousId: draftRef, recordedAt: now });
|
|
41328
|
+
sentDraftKey = key;
|
|
41329
|
+
}
|
|
40863
41330
|
for (const fileId of myFileIDs) {
|
|
40864
41331
|
const existing = await cache.getAttachment(fileId);
|
|
40865
41332
|
await cache.upsertAttachmentForMessage({
|
|
@@ -40881,7 +41348,7 @@ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
|
|
|
40881
41348
|
await deleteOFWMessages(client2, [draftRef]);
|
|
40882
41349
|
await cache.deleteDraft(draftRef);
|
|
40883
41350
|
}
|
|
40884
|
-
const responseObj = persisted
|
|
41351
|
+
const responseObj = persisted === null ? raw : { ...persisted, ...sentDraftKey !== null ? { draftKey: sentDraftKey, previousId: draftRef } : {} };
|
|
40885
41352
|
const text = responseObj ? JSON.stringify(responseObj, null, 2) : "Message sent successfully.";
|
|
40886
41353
|
const notes = [rewriteNote, verifyNote, unconfirmedNote].filter((n) => n !== null).join("\n\n");
|
|
40887
41354
|
return textResponse(notes ? `${notes}
|
|
@@ -40946,35 +41413,65 @@ ${JSON.stringify(
|
|
|
40946
41413
|
};
|
|
40947
41414
|
}
|
|
40948
41415
|
server.registerTool("ofw_list_drafts", {
|
|
40949
|
-
description:
|
|
40950
|
-
annotations: { readOnlyHint:
|
|
41416
|
+
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" \u2014 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).',
|
|
41417
|
+
annotations: { readOnlyHint: false },
|
|
40951
41418
|
inputSchema: {
|
|
40952
41419
|
page: external_exports.number().int().min(1).describe("Page number (default 1)").optional(),
|
|
40953
|
-
size: external_exports.number().int().min(1).describe("Drafts per page (default 50)").optional()
|
|
41420
|
+
size: external_exports.number().int().min(1).describe("Drafts per page (default 50)").optional(),
|
|
41421
|
+
autoRefresh: external_exports.boolean().describe(AUTO_REFRESH_DESC).optional()
|
|
40954
41422
|
}
|
|
40955
41423
|
}, async (args) => {
|
|
40956
41424
|
const page = args.page ?? 1;
|
|
40957
41425
|
const size = args.size ?? 50;
|
|
40958
41426
|
const cache = cacheProvider();
|
|
40959
|
-
const {
|
|
40960
|
-
|
|
40961
|
-
|
|
40962
|
-
|
|
40963
|
-
|
|
40964
|
-
|
|
40965
|
-
|
|
40966
|
-
|
|
40967
|
-
|
|
40968
|
-
|
|
40969
|
-
|
|
40970
|
-
|
|
40971
|
-
|
|
40972
|
-
|
|
41427
|
+
const { value, refreshed, unverifiedEmpty } = await guardedCacheRead({
|
|
41428
|
+
client: client2,
|
|
41429
|
+
cache,
|
|
41430
|
+
folders: ["drafts"],
|
|
41431
|
+
autoRefresh: args.autoRefresh ?? getAutoRefreshStaleReads(),
|
|
41432
|
+
isEmpty: (v) => v.total === 0,
|
|
41433
|
+
read: async () => {
|
|
41434
|
+
const { freshness: freshness2, serverConfirmed: serverConfirmed2, cacheStatus } = await draftsFreshness(cache);
|
|
41435
|
+
const rows = await cache.listDrafts({ page, size });
|
|
41436
|
+
const total2 = await cache.countDrafts();
|
|
41437
|
+
const keyById = new Map(
|
|
41438
|
+
(await cache.getDraftLineageByIds(rows.map((d) => d.id))).map((l) => [l.id, l.draftKey])
|
|
41439
|
+
);
|
|
41440
|
+
const drafts2 = rows.map((d) => ({
|
|
41441
|
+
...d,
|
|
41442
|
+
revision: draftRevision(d),
|
|
41443
|
+
draftKey: keyById.get(d.id) ?? null,
|
|
41444
|
+
cacheStatus,
|
|
41445
|
+
serverConfirmed: serverConfirmed2,
|
|
41446
|
+
asOf: freshness2.asOf
|
|
41447
|
+
}));
|
|
41448
|
+
return { drafts: drafts2, total: total2, freshness: freshness2, serverConfirmed: serverConfirmed2 };
|
|
41449
|
+
}
|
|
41450
|
+
});
|
|
41451
|
+
if (unverifiedEmpty) {
|
|
41452
|
+
return unverifiedEmptyResponse({
|
|
41453
|
+
what: "drafts",
|
|
41454
|
+
freshness: value.freshness,
|
|
41455
|
+
refreshed,
|
|
41456
|
+
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.',
|
|
41457
|
+
extra: { page, size }
|
|
40973
41458
|
});
|
|
40974
41459
|
}
|
|
40975
|
-
const
|
|
41460
|
+
const { drafts, total, freshness, serverConfirmed } = value;
|
|
41461
|
+
const fullSlice = page === 1 && drafts.length === total;
|
|
41462
|
+
const complete = serverConfirmed && fullSlice;
|
|
41463
|
+
const payload = { drafts, total, page, size, complete, freshness };
|
|
41464
|
+
if (!complete) {
|
|
41465
|
+
payload.completeNote = [
|
|
41466
|
+
!fullSlice ? `this page holds ${drafts.length} of ${total} cached drafts` : null,
|
|
41467
|
+
!serverConfirmed ? "the drafts cache has not been confirmed against OurFamilyWizard inside the freshness window" : null
|
|
41468
|
+
].filter((r) => r !== null).join("; ").concat(". Do NOT state a draft count from this result \u2014 call ofw_status(includeDraftInventory:true) for a live, complete one.");
|
|
41469
|
+
}
|
|
40976
41470
|
if (!serverConfirmed) {
|
|
40977
|
-
payload.note = 'serverConfirmed:false \u2014 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
|
|
41471
|
+
payload.note = 'serverConfirmed:false \u2014 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 \u2014 ofw_save_draft and ofw_delete_draft re-check the server and refuse a stale overwrite.';
|
|
41472
|
+
}
|
|
41473
|
+
if (refreshed) {
|
|
41474
|
+
payload.autoRefreshed = true;
|
|
40978
41475
|
}
|
|
40979
41476
|
return jsonResponse(payload);
|
|
40980
41477
|
});
|
|
@@ -41034,6 +41531,7 @@ ${JSON.stringify(
|
|
|
41034
41531
|
let replaceNote = null;
|
|
41035
41532
|
let verifyNote = null;
|
|
41036
41533
|
let newRevision = null;
|
|
41534
|
+
let draftKey = null;
|
|
41037
41535
|
const warnings = [];
|
|
41038
41536
|
if (newId !== null) {
|
|
41039
41537
|
verifyNote = verifyWriteLanded("draft", { subject: args.subject, body: args.body }, detail);
|
|
@@ -41050,6 +41548,29 @@ ${JSON.stringify(
|
|
|
41050
41548
|
};
|
|
41051
41549
|
await cache.upsertDraft(persisted);
|
|
41052
41550
|
newRevision = draftRevision(persisted);
|
|
41551
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
41552
|
+
if (args.messageId !== void 0) {
|
|
41553
|
+
const prior = await cache.getDraftLineageById(args.messageId);
|
|
41554
|
+
if (prior !== null) {
|
|
41555
|
+
draftKey = prior.draftKey;
|
|
41556
|
+
} else {
|
|
41557
|
+
draftKey = newDraftKey();
|
|
41558
|
+
await cache.recordDraftLineage({
|
|
41559
|
+
id: args.messageId,
|
|
41560
|
+
draftKey,
|
|
41561
|
+
previousId: null,
|
|
41562
|
+
recordedAt: now
|
|
41563
|
+
});
|
|
41564
|
+
}
|
|
41565
|
+
} else {
|
|
41566
|
+
draftKey = newDraftKey();
|
|
41567
|
+
}
|
|
41568
|
+
await cache.recordDraftLineage({
|
|
41569
|
+
id: newId,
|
|
41570
|
+
draftKey,
|
|
41571
|
+
previousId: args.messageId ?? null,
|
|
41572
|
+
recordedAt: now
|
|
41573
|
+
});
|
|
41053
41574
|
if (resolvedReplyTo !== null && effectiveReplyTo !== resolvedReplyTo) {
|
|
41054
41575
|
const rewrittenFrom = requestedReplyTo !== resolvedReplyTo ? ` (rewritten from ${requestedReplyTo})` : "";
|
|
41055
41576
|
const outcome = effectiveReplyTo === null ? "OurFamilyWizard did not thread this draft (its inReplyTo/showContext will be empty). The subject and body were saved; only the reply linkage was dropped." : `OurFamilyWizard re-targeted the reply to message ${effectiveReplyTo} instead. The draft IS threaded \u2014 to that message, not the one requested \u2014 and the inReplyTo in this response reflects where it actually landed.`;
|
|
@@ -41089,6 +41610,11 @@ ${JSON.stringify(
|
|
|
41089
41610
|
...persisted,
|
|
41090
41611
|
inReplyTo: persisted.replyToId,
|
|
41091
41612
|
revision: newRevision,
|
|
41613
|
+
// The id above is volatile — it changes on every edit. `draftKey` is
|
|
41614
|
+
// not: pass it to ofw_status to resolve the chain's CURRENT id, or to
|
|
41615
|
+
// find out that the draft was sent and when.
|
|
41616
|
+
draftKey,
|
|
41617
|
+
previousId: args.messageId ?? null,
|
|
41092
41618
|
cacheStatus: "fresh",
|
|
41093
41619
|
serverConfirmed: true,
|
|
41094
41620
|
...warnings.length > 0 ? { warnings } : {}
|
|
@@ -41126,25 +41652,46 @@ ${text}` : text);
|
|
|
41126
41652
|
${text}` : text);
|
|
41127
41653
|
});
|
|
41128
41654
|
server.registerTool("ofw_get_unread_sent", {
|
|
41129
|
-
description:
|
|
41130
|
-
annotations: { readOnlyHint:
|
|
41655
|
+
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.',
|
|
41656
|
+
annotations: { readOnlyHint: false },
|
|
41131
41657
|
inputSchema: {
|
|
41132
41658
|
page: external_exports.number().int().min(1).describe("Page (default 1)").optional(),
|
|
41133
|
-
size: external_exports.number().int().min(1).describe("Per page (default 50)").optional()
|
|
41659
|
+
size: external_exports.number().int().min(1).describe("Per page (default 50)").optional(),
|
|
41660
|
+
autoRefresh: external_exports.boolean().describe(AUTO_REFRESH_DESC).optional()
|
|
41134
41661
|
}
|
|
41135
41662
|
}, async (args) => {
|
|
41136
41663
|
const page = args.page ?? 1;
|
|
41137
41664
|
const size = args.size ?? 50;
|
|
41138
41665
|
const cache = cacheProvider();
|
|
41139
|
-
const
|
|
41140
|
-
|
|
41141
|
-
|
|
41142
|
-
|
|
41143
|
-
|
|
41144
|
-
|
|
41145
|
-
|
|
41666
|
+
const { value, refreshed, unverifiedEmpty } = await guardedCacheRead({
|
|
41667
|
+
client: client2,
|
|
41668
|
+
cache,
|
|
41669
|
+
folders: ["sent"],
|
|
41670
|
+
autoRefresh: args.autoRefresh ?? getAutoRefreshStaleReads(),
|
|
41671
|
+
// The guard is about the CACHE being empty, not the verdict. "You have
|
|
41672
|
+
// no sent messages" is an absence claim a stale cache cannot support;
|
|
41673
|
+
// "all of them are read" is a verdict over messages we did see, and it is
|
|
41674
|
+
// labelled by `freshness` and `complete` as before.
|
|
41675
|
+
isEmpty: (v) => v.total === 0,
|
|
41676
|
+
read: async () => {
|
|
41677
|
+
const sent2 = await cache.listMessages({ folder: "sent", page, size });
|
|
41678
|
+
const total2 = await cache.countMessages({ folder: "sent" });
|
|
41679
|
+
const freshness2 = await buildFreshness(cache, { source: "cache", folders: ["sent"] });
|
|
41680
|
+
return { sent: sent2, total: total2, freshness: freshness2 };
|
|
41681
|
+
}
|
|
41682
|
+
});
|
|
41683
|
+
if (unverifiedEmpty) {
|
|
41684
|
+
return unverifiedEmptyResponse({
|
|
41685
|
+
what: "sent messages in the local cache",
|
|
41686
|
+
freshness: value.freshness,
|
|
41687
|
+
refreshed,
|
|
41688
|
+
remedy: 'Call ofw_sync_messages(folders:["sent"]) and retry, or re-call with autoRefresh:true.',
|
|
41689
|
+
extra: { page, size }
|
|
41146
41690
|
});
|
|
41147
41691
|
}
|
|
41692
|
+
const { sent, total, freshness } = value;
|
|
41693
|
+
const fullSlice = page === 1 && sent.length === total;
|
|
41694
|
+
const complete = fullSlice && freshness.staleness === "fresh" && freshness.historyComplete;
|
|
41148
41695
|
const unread = [];
|
|
41149
41696
|
for (const msg of sent) {
|
|
41150
41697
|
const unreadBy = msg.recipients.filter((r) => r.viewedAt === null).map((r) => r.name);
|
|
@@ -41152,14 +41699,17 @@ ${text}` : text);
|
|
|
41152
41699
|
unread.push({ id: msg.id, subject: msg.subject, sentAt: msg.sentAt, unreadBy });
|
|
41153
41700
|
}
|
|
41154
41701
|
}
|
|
41702
|
+
const payload = { unread, scanned: sent.length, total, complete, freshness };
|
|
41703
|
+
if (!complete) {
|
|
41704
|
+
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.`;
|
|
41705
|
+
}
|
|
41155
41706
|
if (unread.length === 0) {
|
|
41156
|
-
|
|
41157
|
-
unread: [],
|
|
41158
|
-
freshness,
|
|
41159
|
-
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."
|
|
41160
|
-
});
|
|
41707
|
+
payload.message = "Every sent message scanned had been read as of the timestamp in `freshness.asOf`. A recipient may have read \u2014 or not read \u2014 a message since without the cache hearing about it.";
|
|
41161
41708
|
}
|
|
41162
|
-
|
|
41709
|
+
if (refreshed) {
|
|
41710
|
+
payload.autoRefreshed = true;
|
|
41711
|
+
}
|
|
41712
|
+
return jsonResponse(payload);
|
|
41163
41713
|
});
|
|
41164
41714
|
if (allowDrafts) server.registerTool("ofw_upload_attachment", {
|
|
41165
41715
|
description: `Upload a local file to OurFamilyWizard's "My Files" so it can be attached to a message. Returns the fileId \u2014 pass that to ofw_send_message or ofw_save_draft in myFileIDs to attach it. The file is uploaded as PRIVATE (visible only to you) by default; pass shareClass:"SHARED" to share with co-parents directly via the My Files area.`,
|
|
@@ -41316,12 +41866,12 @@ ${text}` : text);
|
|
|
41316
41866
|
return jsonResponse({ ...result, freshness });
|
|
41317
41867
|
});
|
|
41318
41868
|
server.registerTool("ofw_check_freshness", {
|
|
41319
|
-
description: 'Cheaply confirm whether the local cache still matches OurFamilyWizard, WITHOUT running a full sync. Use this before asserting anything about current state \u2014 especially "draft X is still sitting unsent"
|
|
41320
|
-
annotations: { readOnlyHint:
|
|
41869
|
+
description: 'Cheaply confirm whether the local cache still matches OurFamilyWizard, WITHOUT running a full sync. Use this before asserting anything about current state \u2014 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` \u2014 "draft" | "sent" | "received" | "deleted" | "unknown" \u2014 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.',
|
|
41870
|
+
annotations: { readOnlyHint: false },
|
|
41321
41871
|
inputSchema: {
|
|
41322
41872
|
folders: external_exports.array(external_exports.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(),
|
|
41323
|
-
messageIds: external_exports.array(external_exports.number()).describe(`Specific ids to verify against OFW (max ${MAX_FRESHNESS_IDS}).
|
|
41324
|
-
allowMarkRead: external_exports.boolean().describe(
|
|
41873
|
+
messageIds: external_exports.array(external_exports.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 \u2014 none of those can stamp the record. Anything else is skipped \u2014 see allowMarkRead.`).optional(),
|
|
41874
|
+
allowMarkRead: external_exports.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 \u2014 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()
|
|
41325
41875
|
}
|
|
41326
41876
|
}, async (args) => {
|
|
41327
41877
|
const cache = cacheProvider();
|
|
@@ -41339,6 +41889,7 @@ ${text}` : text);
|
|
|
41339
41889
|
{ label: "ofw-mcp", context: "GET /pub/v1/messageFolders (ofw_check_freshness)" }
|
|
41340
41890
|
);
|
|
41341
41891
|
const sys = data.systemFolders ?? [];
|
|
41892
|
+
await persistFolderIds(cache, sys);
|
|
41342
41893
|
for (const folder of wantFolders) {
|
|
41343
41894
|
const entry = sys.find((x) => x.folderType === FOLDER_TYPE[folder]);
|
|
41344
41895
|
const serverCount = entry?.totalCount ?? entry?.messageCount ?? entry?.count ?? null;
|
|
@@ -41359,52 +41910,9 @@ ${text}` : text);
|
|
|
41359
41910
|
});
|
|
41360
41911
|
}
|
|
41361
41912
|
}
|
|
41362
|
-
const
|
|
41363
|
-
|
|
41364
|
-
|
|
41365
|
-
if (cachedDraft === null && !allowMarkRead) {
|
|
41366
|
-
items.push({
|
|
41367
|
-
id,
|
|
41368
|
-
skipped: true,
|
|
41369
|
-
reason: "NOT_A_CACHED_DRAFT",
|
|
41370
|
-
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."
|
|
41371
|
-
});
|
|
41372
|
-
continue;
|
|
41373
|
-
}
|
|
41374
|
-
requestsUsed++;
|
|
41375
|
-
try {
|
|
41376
|
-
const server2 = await fetchServerDraft(client2, id);
|
|
41377
|
-
const cacheRevision = cachedDraft === null ? null : draftRevision(cachedDraft);
|
|
41378
|
-
if (server2 === null) {
|
|
41379
|
-
items.push({
|
|
41380
|
-
id,
|
|
41381
|
-
existsOnServer: false,
|
|
41382
|
-
inSync: false,
|
|
41383
|
-
cacheRevision,
|
|
41384
|
-
serverRevision: null,
|
|
41385
|
-
note: cachedDraft === null ? "Not found on OurFamilyWizard." : "This draft is in the local cache but NO LONGER EXISTS on OurFamilyWizard \u2014 it was sent or deleted elsewhere. Do not describe it as still unsent."
|
|
41386
|
-
});
|
|
41387
|
-
continue;
|
|
41388
|
-
}
|
|
41389
|
-
const serverRevision = draftRevision(server2);
|
|
41390
|
-
items.push({
|
|
41391
|
-
id,
|
|
41392
|
-
existsOnServer: true,
|
|
41393
|
-
cacheRevision,
|
|
41394
|
-
serverRevision,
|
|
41395
|
-
inSync: cacheRevision !== null && cacheRevision === serverRevision,
|
|
41396
|
-
...cacheRevision === null ? { note: "Exists on OurFamilyWizard but is not in the local cache." } : cacheRevision !== serverRevision ? { note: "Content differs from the cache \u2014 it was edited on OurFamilyWizard since the last sync. Run ofw_sync_messages before reading or writing it." } : {}
|
|
41397
|
-
});
|
|
41398
|
-
} catch (e) {
|
|
41399
|
-
items.push({
|
|
41400
|
-
id,
|
|
41401
|
-
error: "FRESHNESS_CHECK_FAILED",
|
|
41402
|
-
message: e.message,
|
|
41403
|
-
inSync: null,
|
|
41404
|
-
note: "The freshness check itself failed, so nothing is confirmed either way."
|
|
41405
|
-
});
|
|
41406
|
-
}
|
|
41407
|
-
}
|
|
41913
|
+
const probed = await probeIds(client2, cache, ids, { allowMarkRead });
|
|
41914
|
+
requestsUsed += probed.requests;
|
|
41915
|
+
const items = probed.items;
|
|
41408
41916
|
const payload = {
|
|
41409
41917
|
checkedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
41410
41918
|
requestsUsed,
|
|
@@ -41416,6 +41924,132 @@ ${text}` : text);
|
|
|
41416
41924
|
}
|
|
41417
41925
|
return jsonResponse(payload);
|
|
41418
41926
|
});
|
|
41927
|
+
server.registerTool("ofw_status", {
|
|
41928
|
+
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 \u2014 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?" \u2014 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.',
|
|
41929
|
+
annotations: { readOnlyHint: false },
|
|
41930
|
+
inputSchema: {
|
|
41931
|
+
ids: external_exports.array(external_exports.number()).describe(`Message/draft ids to resolve to a live state (combined with draftKeys, max ${MAX_FRESHNESS_IDS} probes per call).`).optional(),
|
|
41932
|
+
draftKeys: external_exports.array(external_exports.string()).describe("Stable draft keys (from ofw_save_draft / ofw_list_drafts) to resolve to their CURRENT id and state.").optional(),
|
|
41933
|
+
includeDraftInventory: external_exports.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(),
|
|
41934
|
+
allowMarkRead: external_exports.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 \u2014 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()
|
|
41935
|
+
}
|
|
41936
|
+
}, async (args) => {
|
|
41937
|
+
const cache = cacheProvider();
|
|
41938
|
+
const allowMarkRead = getAllowMarkRead() && (args.allowMarkRead ?? false);
|
|
41939
|
+
const requestedIds = args.ids ?? [];
|
|
41940
|
+
const requestedKeys = args.draftKeys ?? [];
|
|
41941
|
+
const wantInventory = args.includeDraftInventory ?? (requestedIds.length === 0 && requestedKeys.length === 0);
|
|
41942
|
+
const allTargets = [
|
|
41943
|
+
...requestedIds.map((id) => ({ kind: "id", id })),
|
|
41944
|
+
...requestedKeys.map((draftKey) => ({ kind: "draftKey", draftKey }))
|
|
41945
|
+
];
|
|
41946
|
+
const targets = allTargets.slice(0, MAX_FRESHNESS_IDS);
|
|
41947
|
+
const truncated = allTargets.length - targets.length;
|
|
41948
|
+
if (!wantInventory && targets.length === 0) {
|
|
41949
|
+
return jsonErrorResponse({
|
|
41950
|
+
result: "NOTHING_REQUESTED",
|
|
41951
|
+
reason: "ofw_status was called with includeDraftInventory:false and no ids or draftKeys, so nothing was checked.",
|
|
41952
|
+
remedy: "Call ofw_status() with no arguments for the full draft inventory, or pass ids / draftKeys.",
|
|
41953
|
+
complete: false
|
|
41954
|
+
});
|
|
41955
|
+
}
|
|
41956
|
+
let probeRequests = 0;
|
|
41957
|
+
const incomplete = [];
|
|
41958
|
+
let drafts;
|
|
41959
|
+
let inventoryComplete = true;
|
|
41960
|
+
let inventoryFreshness;
|
|
41961
|
+
if (wantInventory) {
|
|
41962
|
+
const sync = await syncAll(client2, {
|
|
41963
|
+
folders: ["drafts"],
|
|
41964
|
+
maxRequests: getSyncMaxRequests()
|
|
41965
|
+
}, cache);
|
|
41966
|
+
inventoryComplete = sync.refreshed.includes("drafts");
|
|
41967
|
+
const { freshness, cacheStatus, serverConfirmed } = await draftsFreshness(cache);
|
|
41968
|
+
inventoryFreshness = freshness;
|
|
41969
|
+
if (!serverConfirmed) inventoryComplete = false;
|
|
41970
|
+
const total = await cache.countDrafts();
|
|
41971
|
+
const rows = await cache.listDrafts({ page: 1, size: Math.max(total, 1) });
|
|
41972
|
+
const keyById = new Map(
|
|
41973
|
+
(await cache.getDraftLineageByIds(rows.map((d) => d.id))).map((l) => [l.id, l.draftKey])
|
|
41974
|
+
);
|
|
41975
|
+
drafts = rows.map((d) => ({
|
|
41976
|
+
id: d.id,
|
|
41977
|
+
draftKey: keyById.get(d.id) ?? null,
|
|
41978
|
+
subject: d.subject,
|
|
41979
|
+
revision: draftRevision(d),
|
|
41980
|
+
modifiedAt: d.modifiedAt,
|
|
41981
|
+
recipients: d.recipients,
|
|
41982
|
+
replyToId: d.replyToId,
|
|
41983
|
+
cacheStatus
|
|
41984
|
+
}));
|
|
41985
|
+
if (!inventoryComplete) {
|
|
41986
|
+
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");
|
|
41987
|
+
}
|
|
41988
|
+
}
|
|
41989
|
+
const requested = [];
|
|
41990
|
+
if (targets.length > 0) {
|
|
41991
|
+
const resolved = /* @__PURE__ */ new Map();
|
|
41992
|
+
for (const t of targets) {
|
|
41993
|
+
if (t.kind === "draftKey" && !resolved.has(t.draftKey)) {
|
|
41994
|
+
resolved.set(t.draftKey, await resolveDraftKey(cache, t.draftKey));
|
|
41995
|
+
}
|
|
41996
|
+
}
|
|
41997
|
+
const toProbe = /* @__PURE__ */ new Set();
|
|
41998
|
+
for (const t of targets) {
|
|
41999
|
+
if (t.kind === "id") toProbe.add(t.id);
|
|
42000
|
+
else {
|
|
42001
|
+
const chain = resolved.get(t.draftKey);
|
|
42002
|
+
if (chain !== null && chain !== void 0) toProbe.add(chain.currentId);
|
|
42003
|
+
}
|
|
42004
|
+
}
|
|
42005
|
+
const probed = await probeIds(client2, cache, [...toProbe], { allowMarkRead });
|
|
42006
|
+
probeRequests += probed.requests;
|
|
42007
|
+
const probes = new Map(probed.items.map((item) => [item.id, item]));
|
|
42008
|
+
for (const t of targets) {
|
|
42009
|
+
if (t.kind === "id") {
|
|
42010
|
+
requested.push(decorate(probes.get(t.id)));
|
|
42011
|
+
continue;
|
|
42012
|
+
}
|
|
42013
|
+
const chain = resolved.get(t.draftKey);
|
|
42014
|
+
if (chain === null || chain === void 0) {
|
|
42015
|
+
requested.push({
|
|
42016
|
+
draftKey: t.draftKey,
|
|
42017
|
+
state: "unknown",
|
|
42018
|
+
error: "UNKNOWN_DRAFT_KEY",
|
|
42019
|
+
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."
|
|
42020
|
+
});
|
|
42021
|
+
continue;
|
|
42022
|
+
}
|
|
42023
|
+
requested.push({
|
|
42024
|
+
draftKey: t.draftKey,
|
|
42025
|
+
currentId: chain.currentId,
|
|
42026
|
+
previousIds: chain.ids.slice(0, -1),
|
|
42027
|
+
...decorate(probes.get(chain.currentId))
|
|
42028
|
+
});
|
|
42029
|
+
}
|
|
42030
|
+
for (const entry of requested) {
|
|
42031
|
+
if (entry.skipped === true || entry.error !== void 0 || entry.state === "unknown") {
|
|
42032
|
+
incomplete.push(`id/key ${String(entry.draftKey ?? entry.id)} could not be resolved to a confirmed live state`);
|
|
42033
|
+
}
|
|
42034
|
+
}
|
|
42035
|
+
}
|
|
42036
|
+
if (truncated > 0) {
|
|
42037
|
+
incomplete.push(`${truncated} of ${allTargets.length} requested ids/draftKeys were not probed (per-call cap of ${MAX_FRESHNESS_IDS})`);
|
|
42038
|
+
}
|
|
42039
|
+
const complete = incomplete.length === 0;
|
|
42040
|
+
return jsonResponse({
|
|
42041
|
+
checkedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
42042
|
+
probeRequests,
|
|
42043
|
+
...drafts !== void 0 ? { drafts, draftCount: drafts.length, draftInventoryComplete: inventoryComplete } : {},
|
|
42044
|
+
...requested.length > 0 ? { requested } : {},
|
|
42045
|
+
complete,
|
|
42046
|
+
...complete ? {} : { incompleteReasons: incomplete, note: "complete:false \u2014 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." },
|
|
42047
|
+
...inventoryFreshness !== void 0 ? { freshness: inventoryFreshness } : {}
|
|
42048
|
+
});
|
|
42049
|
+
});
|
|
42050
|
+
}
|
|
42051
|
+
function decorate(item) {
|
|
42052
|
+
return item.state === "sent" ? { ...item, sentMessageId: item.id } : { ...item };
|
|
41419
42053
|
}
|
|
41420
42054
|
async function deleteOFWMessages(client2, ids) {
|
|
41421
42055
|
const form = new FormData();
|
|
@@ -41679,6 +42313,14 @@ function draftFromDb(r) {
|
|
|
41679
42313
|
listData: JSON.parse(r.list_data_json)
|
|
41680
42314
|
};
|
|
41681
42315
|
}
|
|
42316
|
+
function lineageFromDb(r) {
|
|
42317
|
+
return {
|
|
42318
|
+
id: r.id,
|
|
42319
|
+
draftKey: r.draft_key,
|
|
42320
|
+
previousId: r.previous_id,
|
|
42321
|
+
recordedAt: r.recorded_at
|
|
42322
|
+
};
|
|
42323
|
+
}
|
|
41682
42324
|
function attachmentFromDb(r) {
|
|
41683
42325
|
return {
|
|
41684
42326
|
fileId: r.file_id,
|
|
@@ -41734,6 +42376,17 @@ var SCHEMA_STATEMENTS = [
|
|
|
41734
42376
|
key TEXT PRIMARY KEY,
|
|
41735
42377
|
value TEXT NOT NULL
|
|
41736
42378
|
)`,
|
|
42379
|
+
// v3: draft identity chain. One row per OFW id, all the ids of one logical
|
|
42380
|
+
// document sharing a `draft_key`. Survives ofw_save_draft's create-then-delete
|
|
42381
|
+
// replacement AND the transition to a sent message, so "what happened to the
|
|
42382
|
+
// draft I was editing?" is answerable without guessing which id is current.
|
|
42383
|
+
`CREATE TABLE IF NOT EXISTS draft_lineage (
|
|
42384
|
+
id INTEGER PRIMARY KEY,
|
|
42385
|
+
draft_key TEXT NOT NULL,
|
|
42386
|
+
previous_id INTEGER,
|
|
42387
|
+
recorded_at TEXT NOT NULL
|
|
42388
|
+
)`,
|
|
42389
|
+
`CREATE INDEX IF NOT EXISTS idx_draft_lineage_key ON draft_lineage(draft_key, recorded_at, id)`,
|
|
41737
42390
|
// v2: attachments table. Idempotent — IF NOT EXISTS.
|
|
41738
42391
|
`CREATE TABLE IF NOT EXISTS attachments (
|
|
41739
42392
|
file_id INTEGER PRIMARY KEY,
|
|
@@ -41752,7 +42405,7 @@ var MIGRATIONS = [
|
|
|
41752
42405
|
// Resumable deep-sync cursor. Absent/NULL → SyncState.resumePage null.
|
|
41753
42406
|
"ALTER TABLE sync_state ADD COLUMN resume_page INTEGER"
|
|
41754
42407
|
];
|
|
41755
|
-
var SCHEMA_VERSION = "
|
|
42408
|
+
var SCHEMA_VERSION = "3";
|
|
41756
42409
|
function buildMessageFilter(opts) {
|
|
41757
42410
|
const wheres = [];
|
|
41758
42411
|
const params = [];
|
|
@@ -41937,6 +42590,10 @@ var OFWCacheCore = class {
|
|
|
41937
42590
|
);
|
|
41938
42591
|
return rows.map(draftFromDb);
|
|
41939
42592
|
}
|
|
42593
|
+
countDrafts() {
|
|
42594
|
+
const r = this.db.get("SELECT COUNT(*) as n FROM drafts", []);
|
|
42595
|
+
return r?.n ?? 0;
|
|
42596
|
+
}
|
|
41940
42597
|
deleteDraft(id) {
|
|
41941
42598
|
this.db.run("DELETE FROM drafts WHERE id = ?", [id]);
|
|
41942
42599
|
}
|
|
@@ -41944,6 +42601,57 @@ var OFWCacheCore = class {
|
|
|
41944
42601
|
const rows = this.db.all("SELECT id FROM drafts", []);
|
|
41945
42602
|
return rows.map((r) => r.id);
|
|
41946
42603
|
}
|
|
42604
|
+
/**
|
|
42605
|
+
* Link an id into a draft's identity chain. Upserts on id: re-recording the
|
|
42606
|
+
* same id (e.g. a retried save) rewrites its link rather than duplicating it,
|
|
42607
|
+
* so `getDraftLineage` can never report one id twice.
|
|
42608
|
+
*/
|
|
42609
|
+
recordDraftLineage(row) {
|
|
42610
|
+
this.db.run(
|
|
42611
|
+
`INSERT INTO draft_lineage (id, draft_key, previous_id, recorded_at) VALUES (?, ?, ?, ?)
|
|
42612
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
42613
|
+
draft_key=excluded.draft_key,
|
|
42614
|
+
previous_id=excluded.previous_id,
|
|
42615
|
+
recorded_at=excluded.recorded_at`,
|
|
42616
|
+
[
|
|
42617
|
+
row.id,
|
|
42618
|
+
requireString("draft_lineage.draftKey", row.draftKey),
|
|
42619
|
+
nullish3(row.previousId),
|
|
42620
|
+
requireString("draft_lineage.recordedAt", row.recordedAt)
|
|
42621
|
+
]
|
|
42622
|
+
);
|
|
42623
|
+
}
|
|
42624
|
+
getDraftLineageById(id) {
|
|
42625
|
+
const r = this.db.get("SELECT * FROM draft_lineage WHERE id = ?", [id]);
|
|
42626
|
+
return r ? lineageFromDb(r) : null;
|
|
42627
|
+
}
|
|
42628
|
+
/**
|
|
42629
|
+
* Batch read — one query for a whole page of drafts. On the Durable Object
|
|
42630
|
+
* backend each cache call is a subrequest, so a per-draft lookup would spend
|
|
42631
|
+
* the caller's sync budget on bookkeeping.
|
|
42632
|
+
*/
|
|
42633
|
+
getDraftLineageByIds(ids) {
|
|
42634
|
+
if (ids.length === 0) return [];
|
|
42635
|
+
const placeholders = ids.map(() => "?").join(", ");
|
|
42636
|
+
const rows = this.db.all(
|
|
42637
|
+
`SELECT * FROM draft_lineage WHERE id IN (${placeholders})`,
|
|
42638
|
+
ids
|
|
42639
|
+
);
|
|
42640
|
+
return rows.map(lineageFromDb);
|
|
42641
|
+
}
|
|
42642
|
+
/**
|
|
42643
|
+
* Every link in one chain, OLDEST FIRST — so the last element is the chain's
|
|
42644
|
+
* current id. Ordered by recorded_at then id: two links written inside the
|
|
42645
|
+
* same millisecond tie-break on id, and OFW mints ids monotonically, so the
|
|
42646
|
+
* newer replacement always sorts last.
|
|
42647
|
+
*/
|
|
42648
|
+
getDraftLineage(draftKey) {
|
|
42649
|
+
const rows = this.db.all(
|
|
42650
|
+
"SELECT * FROM draft_lineage WHERE draft_key = ? ORDER BY recorded_at ASC, id ASC",
|
|
42651
|
+
[draftKey]
|
|
42652
|
+
);
|
|
42653
|
+
return rows.map(lineageFromDb);
|
|
42654
|
+
}
|
|
41947
42655
|
getSyncState(folder) {
|
|
41948
42656
|
const r = this.db.get("SELECT last_sync_at, newest_id, resume_page FROM sync_state WHERE folder = ?", [folder]);
|
|
41949
42657
|
if (!r) return null;
|
|
@@ -42079,12 +42787,27 @@ var LocalCacheStore = class {
|
|
|
42079
42787
|
async listDrafts(opts) {
|
|
42080
42788
|
return this.core.listDrafts(opts);
|
|
42081
42789
|
}
|
|
42790
|
+
async countDrafts() {
|
|
42791
|
+
return this.core.countDrafts();
|
|
42792
|
+
}
|
|
42082
42793
|
async deleteDraft(id) {
|
|
42083
42794
|
this.core.deleteDraft(id);
|
|
42084
42795
|
}
|
|
42085
42796
|
async listDraftIds() {
|
|
42086
42797
|
return this.core.listDraftIds();
|
|
42087
42798
|
}
|
|
42799
|
+
async recordDraftLineage(row) {
|
|
42800
|
+
this.core.recordDraftLineage(row);
|
|
42801
|
+
}
|
|
42802
|
+
async getDraftLineageById(id) {
|
|
42803
|
+
return this.core.getDraftLineageById(id);
|
|
42804
|
+
}
|
|
42805
|
+
async getDraftLineageByIds(ids) {
|
|
42806
|
+
return this.core.getDraftLineageByIds(ids);
|
|
42807
|
+
}
|
|
42808
|
+
async getDraftLineage(draftKey) {
|
|
42809
|
+
return this.core.getDraftLineage(draftKey);
|
|
42810
|
+
}
|
|
42088
42811
|
async getSyncState(folder) {
|
|
42089
42812
|
return this.core.getSyncState(folder);
|
|
42090
42813
|
}
|
|
@@ -42188,7 +42911,7 @@ var nodeCacheProvider = () => nodeCache ??= OFWCache.open(getCacheDbPath());
|
|
|
42188
42911
|
var nodeAttachmentIO = new NodeAttachmentIO();
|
|
42189
42912
|
await runMcp({
|
|
42190
42913
|
name: "ofw",
|
|
42191
|
-
version: "2.
|
|
42914
|
+
version: "2.9.1",
|
|
42192
42915
|
// x-release-please-version
|
|
42193
42916
|
deps: client,
|
|
42194
42917
|
tools: [
|