ofw-mcp 2.4.4 → 2.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bundle.js CHANGED
@@ -34931,6 +34931,7 @@ var KNOWN_CAPABILITIES = /* @__PURE__ */ new Set([
34931
34931
  "capture_request_header",
34932
34932
  "capture_redirect",
34933
34933
  "read_indexed_db",
34934
+ "read_dom",
34934
34935
  "download"
34935
34936
  ]);
34936
34937
 
@@ -35232,6 +35233,44 @@ function assertIndexedDbScopesArray(value, label) {
35232
35233
  }
35233
35234
  }
35234
35235
  }
35236
+ var DOM_SELECTOR_RE = /^[^-]{1,512}$/;
35237
+ var DOM_ATTRIBUTE_RE = /^[A-Za-z_:][A-Za-z0-9_:.\-]{0,127}$/;
35238
+ function assertDomSelectorsArray(value, label) {
35239
+ if (!Array.isArray(value)) {
35240
+ throw new ProtocolError(`${label}: expected array, got ${typeof value}`);
35241
+ }
35242
+ const seen = /* @__PURE__ */ new Set();
35243
+ for (let i = 0; i < value.length; i++) {
35244
+ const entry = value[i];
35245
+ assertObject(entry, `${label}[${i}]`);
35246
+ if (entry.name === void 0) {
35247
+ throw new ProtocolError(`${label}[${i}].name: missing`);
35248
+ }
35249
+ if (entry.selector === void 0) {
35250
+ throw new ProtocolError(`${label}[${i}].selector: missing`);
35251
+ }
35252
+ if (typeof entry.name !== "string" || !SCOPE_KEY_RE.test(entry.name)) {
35253
+ throw new ProtocolError(`${label}[${i}].name: invalid ${JSON.stringify(entry.name)}`);
35254
+ }
35255
+ if (typeof entry.selector !== "string" || !DOM_SELECTOR_RE.test(entry.selector)) {
35256
+ throw new ProtocolError(`${label}[${i}].selector: invalid ${JSON.stringify(entry.selector)}`);
35257
+ }
35258
+ if (entry.attribute !== void 0) {
35259
+ if (typeof entry.attribute !== "string" || !DOM_ATTRIBUTE_RE.test(entry.attribute)) {
35260
+ throw new ProtocolError(`${label}[${i}].attribute: invalid ${JSON.stringify(entry.attribute)}`);
35261
+ }
35262
+ }
35263
+ if (seen.has(entry.name)) {
35264
+ throw new ProtocolError(`${label}: duplicate name ${JSON.stringify(entry.name)}`);
35265
+ }
35266
+ seen.add(entry.name);
35267
+ for (const k of Object.keys(entry)) {
35268
+ if (k !== "name" && k !== "selector" && k !== "attribute") {
35269
+ throw new ProtocolError(`${label}[${i}]: unexpected field ${JSON.stringify(k)}`);
35270
+ }
35271
+ }
35272
+ }
35273
+ }
35235
35274
  function validateFrame(raw) {
35236
35275
  assertObject(raw, "frame");
35237
35276
  const t = raw.type;
@@ -35307,6 +35346,9 @@ function validateHello(raw) {
35307
35346
  if (raw.sessionStoragePointers !== void 0) {
35308
35347
  assertStoragePointersArray(raw.sessionStoragePointers, "hello.sessionStoragePointers", raw.sessionStorageKeys);
35309
35348
  }
35349
+ if (raw.domSelectors !== void 0) {
35350
+ assertDomSelectorsArray(raw.domSelectors, "hello.domSelectors");
35351
+ }
35310
35352
  assertBase64(raw.identityX25519Pub, "hello.identityX25519Pub");
35311
35353
  assertBase64(raw.identityEd25519Pub, "hello.identityEd25519Pub");
35312
35354
  assertBase64(raw.sessionNonce, "hello.sessionNonce");
@@ -35541,6 +35583,21 @@ function validateInnerRequest(raw) {
35541
35583
  }
35542
35584
  return raw;
35543
35585
  }
35586
+ if (raw.op === "read_dom") {
35587
+ assertObject(raw.init, "inner.init");
35588
+ if (raw.init.origin === void 0)
35589
+ throw new ProtocolError("inner.init.origin: missing");
35590
+ if (raw.init.names === void 0)
35591
+ throw new ProtocolError("inner.init.names: missing");
35592
+ assertHttpsOriginOnly(raw.init.origin, "inner.init.origin");
35593
+ assertNonEmptyKeyArray(raw.init.names, "inner.init.names");
35594
+ for (const k of Object.keys(raw.init)) {
35595
+ if (k !== "origin" && k !== "names") {
35596
+ throw new ProtocolError(`inner.init: unexpected field ${JSON.stringify(k)} on read_dom`);
35597
+ }
35598
+ }
35599
+ return raw;
35600
+ }
35544
35601
  if (raw.op === "download") {
35545
35602
  assertObject(raw.init, "inner.init");
35546
35603
  if (raw.init.url === void 0) {
@@ -35566,7 +35623,7 @@ function validateInnerRequest(raw) {
35566
35623
  }
35567
35624
  return raw;
35568
35625
  }
35569
- throw new ProtocolError(`inner.op: must be one of "fetch", "read_cookies", "read_local_storage", "read_session_storage", "capture_request_header", "capture_redirect", "read_indexed_db", "download"; got ${JSON.stringify(raw.op)}`);
35626
+ throw new ProtocolError(`inner.op: must be one of "fetch", "read_cookies", "read_local_storage", "read_session_storage", "capture_request_header", "capture_redirect", "read_indexed_db", "read_dom", "download"; got ${JSON.stringify(raw.op)}`);
35570
35627
  }
35571
35628
  function assertNonEmptyKeyArray(value, label) {
35572
35629
  if (!Array.isArray(value)) {
@@ -35651,6 +35708,13 @@ function validateInnerResponse(raw) {
35651
35708
  assertObject(raw.values, "inner.values");
35652
35709
  return raw;
35653
35710
  }
35711
+ if (op === "read_dom") {
35712
+ if (raw.values === void 0) {
35713
+ throw new ProtocolError("inner.values: missing on read_dom response");
35714
+ }
35715
+ assertStringMap(raw.values, "inner.values");
35716
+ return raw;
35717
+ }
35654
35718
  if (op === "download") {
35655
35719
  assertObject(raw.value, "inner.value");
35656
35720
  assertString(raw.value.path, "inner.value.path");
@@ -35990,6 +36054,13 @@ async function buildServerHello(opts) {
35990
36054
  jsonPointer: d.jsonPointer
35991
36055
  }));
35992
36056
  }
36057
+ if (opts.domSelectors && opts.domSelectors.length > 0) {
36058
+ hello.domSelectors = opts.domSelectors.map((d) => ({
36059
+ name: d.name,
36060
+ selector: d.selector,
36061
+ ...d.attribute !== void 0 ? { attribute: d.attribute } : {}
36062
+ }));
36063
+ }
35993
36064
  return hello;
35994
36065
  }
35995
36066
 
@@ -36079,7 +36150,8 @@ async function startHost(opts) {
36079
36150
  captureHeaders: opts.ownCaptureHeaders,
36080
36151
  indexedDbScopes: opts.ownIndexedDbScopes,
36081
36152
  localStoragePointers: opts.ownLocalStoragePointers,
36082
- sessionStoragePointers: opts.ownSessionStoragePointers
36153
+ sessionStoragePointers: opts.ownSessionStoragePointers,
36154
+ domSelectors: opts.ownDomSelectors
36083
36155
  });
36084
36156
  const ownSessionNonce = fromB64(ownHello.sessionNonce);
36085
36157
  let extensionWs = null;
@@ -36314,6 +36386,7 @@ async function startPeer(opts) {
36314
36386
  sessionStorageKeys: opts.sessionStorageKeys,
36315
36387
  captureHeaders: opts.captureHeaders,
36316
36388
  indexedDbScopes: opts.indexedDbScopes,
36389
+ domSelectors: opts.domSelectors,
36317
36390
  localStoragePointers: opts.localStoragePointers,
36318
36391
  sessionStoragePointers: opts.sessionStoragePointers
36319
36392
  });
@@ -36721,6 +36794,11 @@ var FetchproxyServer = class {
36721
36794
  key: d.key,
36722
36795
  jsonPointer: d.jsonPointer
36723
36796
  })),
36797
+ domSelectors: (opts.domSelectors ?? []).map((d) => ({
36798
+ name: d.name,
36799
+ selector: d.selector,
36800
+ ...d.attribute !== void 0 ? { attribute: d.attribute } : {}
36801
+ })),
36724
36802
  // 0.8.0+: timer + lazy-revive default to ON. Every realty MCP
36725
36803
  // adapter was about to set these to the same numbers anyway; the
36726
36804
  // back-door is `0` (explicit opt-out) if a caller genuinely wants
@@ -36841,6 +36919,7 @@ var FetchproxyServer = class {
36841
36919
  ownIndexedDbScopes: this.opts.indexedDbScopes,
36842
36920
  ownLocalStoragePointers: this.opts.localStoragePointers,
36843
36921
  ownSessionStoragePointers: this.opts.sessionStoragePointers,
36922
+ ownDomSelectors: this.opts.domSelectors,
36844
36923
  onPairCode: this.opts.onPairCode
36845
36924
  });
36846
36925
  this.hostHandle.onOwnInner((inner) => this.onInner(inner));
@@ -36868,7 +36947,8 @@ var FetchproxyServer = class {
36868
36947
  captureHeaders: this.opts.captureHeaders,
36869
36948
  indexedDbScopes: this.opts.indexedDbScopes,
36870
36949
  localStoragePointers: this.opts.localStoragePointers,
36871
- sessionStoragePointers: this.opts.sessionStoragePointers
36950
+ sessionStoragePointers: this.opts.sessionStoragePointers,
36951
+ domSelectors: this.opts.domSelectors
36872
36952
  });
36873
36953
  this.peerHandle.onInner((inner) => this.onInner(inner));
36874
36954
  this.peerHandle.onRenegotiate(() => {
@@ -37843,6 +37923,46 @@ var FetchproxyServer = class {
37843
37923
  await this.sendInnerFrame(inner);
37844
37924
  return this._withVerbTimeout(pending, this.pendingIdb, id, origin);
37845
37925
  }
37926
+ /**
37927
+ * 1.4.0+: read declared DOM values from the user's signed-in tab.
37928
+ * Requires `'read_dom'` in capabilities AND every requested `name` to
37929
+ * match a declared `domSelectors` entry. The extension reads each
37930
+ * declared selector from the matched tab's DOM (isolated-world
37931
+ * `querySelector`, value or attribute) — no page-JS execution.
37932
+ *
37933
+ * Returns a `Record<string, string>` of `name → value`, with names
37934
+ * whose element (or attribute) was absent omitted. Throws
37935
+ * `FetchproxyProtocolError` on bridge failures and a plain `Error` on
37936
+ * developer mistakes (undeclared capability, undeclared name).
37937
+ */
37938
+ async readDom(opts) {
37939
+ if (!this.opts.capabilities.includes("read_dom")) {
37940
+ throw new Error('FetchproxyServer.readDom(): MCP did not declare "read_dom" in capabilities');
37941
+ }
37942
+ await this.ensureConnected();
37943
+ this.throwIfPendingPair();
37944
+ if (!Array.isArray(opts.names) || opts.names.length === 0) {
37945
+ throw new Error("FetchproxyServer.readDom: opts.names must be a non-empty array");
37946
+ }
37947
+ this.assertScopeSubset(opts.names, this.opts.domSelectors.map((d) => d.name), "domSelectors");
37948
+ if (opts.subdomain !== void 0)
37949
+ assertSubdomainLabel(opts.subdomain);
37950
+ const baseDomain = this.resolveBaseDomain(opts.domain);
37951
+ const host = opts.subdomain ? `${opts.subdomain}.${baseDomain}` : baseDomain;
37952
+ const origin = `https://${host}`;
37953
+ const id = this.nextRequestId++;
37954
+ const inner = {
37955
+ type: "request",
37956
+ id,
37957
+ op: "read_dom",
37958
+ init: { origin, names: [...opts.names] }
37959
+ };
37960
+ const pending = new Promise((resolve2, reject) => {
37961
+ this.pendingStorage.set(id, { resolve: resolve2, reject });
37962
+ });
37963
+ await this.sendInnerFrame(inner);
37964
+ return this._withVerbTimeout(pending, this.pendingStorage, id, origin);
37965
+ }
37846
37966
  assertScopeSubset(requested, declared, label) {
37847
37967
  const undeclared = undeclaredKeys(requested, declared);
37848
37968
  if (undeclared.length > 0) {
@@ -37914,7 +38034,7 @@ var FetchproxyServer = class {
37914
38034
  if (storageCb) {
37915
38035
  this.pendingStorage.delete(inner.id);
37916
38036
  if (inner.ok) {
37917
- if ((inner.op === "read_local_storage" || inner.op === "read_session_storage") && inner.values) {
38037
+ if ((inner.op === "read_local_storage" || inner.op === "read_session_storage" || inner.op === "read_dom") && inner.values) {
37918
38038
  storageCb.resolve({ ...inner.values });
37919
38039
  } else {
37920
38040
  storageCb.reject(new FetchproxyProtocolError(`unexpected ${String(inner.op)} response on storage awaiter`));
@@ -38272,8 +38392,13 @@ async function loginWithPassword(username, password) {
38272
38392
  }
38273
38393
  const contentType = response.headers.get("content-type") ?? "";
38274
38394
  if (!contentType.includes("application/json")) {
38395
+ if (contentType.includes("text/html")) {
38396
+ throw new Error(
38397
+ "OFW login failed \u2014 your OurFamilyWizard email or password was not accepted. Check them and try again."
38398
+ );
38399
+ }
38275
38400
  const body = await response.text();
38276
- throw new Error(`OFW login returned unexpected response (${contentType}): ${body.substring(0, 200)}`);
38401
+ throw new Error(`OFW login returned unexpected response (${contentType || "no content-type"}): ${body.substring(0, 200)}`);
38277
38402
  }
38278
38403
  const data = await response.json();
38279
38404
  return {
@@ -38285,7 +38410,7 @@ async function loginWithPassword(username, password) {
38285
38410
  // package.json
38286
38411
  var package_default = {
38287
38412
  name: "ofw-mcp",
38288
- version: "2.4.4",
38413
+ version: "2.6.3",
38289
38414
  license: "MIT",
38290
38415
  mcpName: "io.github.chrischall/ofw-mcp",
38291
38416
  description: "OurFamilyWizard MCP server for Claude \u2014 developed and maintained by AI (Claude Code)",
@@ -38314,21 +38439,30 @@ var package_default = {
38314
38439
  dev: "node --env-file=.env dist/index.js",
38315
38440
  test: "vitest run",
38316
38441
  "test:coverage": "vitest run --coverage",
38317
- "test:watch": "vitest"
38442
+ "test:watch": "vitest",
38443
+ "worker:dev": "wrangler dev",
38444
+ "worker:deploy": "wrangler deploy",
38445
+ "worker:test": "vitest run --config vitest.workers.config.ts"
38318
38446
  },
38319
38447
  dependencies: {
38320
- "@chrischall/mcp-utils": "^0.12.0",
38448
+ "@chrischall/mcp-utils": "^0.13.0",
38321
38449
  "@fetchproxy/bootstrap": "^1.3.0",
38322
38450
  "@modelcontextprotocol/sdk": "^1.29.0",
38323
38451
  dotenv: "^17.4.2",
38324
38452
  zod: "^4.4.3"
38325
38453
  },
38326
38454
  devDependencies: {
38455
+ "@chrischall/mcp-connector": "^0.1.0",
38456
+ "@cloudflare/vitest-pool-workers": "^0.18.4",
38457
+ "@cloudflare/workers-oauth-provider": "^0.0.11",
38458
+ "@cloudflare/workers-types": "^5.20260708.1",
38327
38459
  "@types/node": "^26.0.0",
38328
38460
  "@vitest/coverage-v8": "^4.1.7",
38461
+ agents: "^0.17.3",
38329
38462
  esbuild: "^0.28.0",
38330
- typescript: "^6.0.3",
38331
- vitest: "^4.1.7"
38463
+ typescript: "^7.0.2",
38464
+ vitest: "^4.1.7",
38465
+ wrangler: "^4.110.0"
38332
38466
  }
38333
38467
  };
38334
38468
 
@@ -38394,8 +38528,11 @@ async function resolveAuth() {
38394
38528
  }
38395
38529
 
38396
38530
  // src/client.ts
38397
- var __dirname = dirname(fileURLToPath(import.meta.url));
38398
- await loadDotenvSafely({ path: join3(__dirname, "..", ".env") });
38531
+ try {
38532
+ const dir = dirname(fileURLToPath(import.meta.url));
38533
+ await loadDotenvSafely({ path: join3(dir, "..", ".env") });
38534
+ } catch {
38535
+ }
38399
38536
  function parseContentDispositionFilename(cd) {
38400
38537
  const extMatch = /filename\*=(?:UTF-8'')?([^;]+)/i.exec(cd);
38401
38538
  if (extMatch) {
@@ -38428,6 +38565,16 @@ var OFWClient = class {
38428
38565
  // already-expired placeholder token so the first request drives the refresh
38429
38566
  // callback — i.e. the original "log in on first request" behavior.
38430
38567
  tokenManager;
38568
+ // Optional injected auth resolver. When set, the refresh callback uses it
38569
+ // instead of the module-level global `resolveAuth` (env-var → fetchproxy
38570
+ // priority). A hosted per-user deployment injects its own resolver so each
38571
+ // request carries that user's credentials — see the Cloudflare Worker
38572
+ // deployment. Left undefined by the stdio path, which falls back to the
38573
+ // global resolver, keeping that behaviour byte-for-byte identical.
38574
+ authResolver;
38575
+ constructor(opts) {
38576
+ this.authResolver = opts?.resolveAuth;
38577
+ }
38431
38578
  getTokenManager() {
38432
38579
  if (!this.tokenManager) {
38433
38580
  this.tokenManager = new TokenManager({
@@ -38439,7 +38586,7 @@ var OFWClient = class {
38439
38586
  // path uses (the 401-replay covers a wrong guess). We re-arm the
38440
38587
  // sentinel so the manager can refresh again later.
38441
38588
  refresh: async () => {
38442
- const { token, expiresAt } = await resolveAuth();
38589
+ const { token, expiresAt } = await (this.authResolver ?? resolveAuth)();
38443
38590
  return {
38444
38591
  accessToken: token,
38445
38592
  refreshToken: OFW_REFRESH_SENTINEL,
@@ -38553,11 +38700,14 @@ var ApiRecipientSchema = external_exports.looseObject({
38553
38700
  viewed: external_exports.looseObject({ dateTime: external_exports.string() }).nullable().optional()
38554
38701
  });
38555
38702
  function mapRecipients(items) {
38556
- return (items ?? []).map((r) => ({
38557
- userId: r.user?.id ?? 0,
38558
- name: r.user?.name ?? "",
38559
- viewedAt: r.viewed?.dateTime ?? null
38560
- }));
38703
+ return (items ?? []).map((r) => {
38704
+ const dt = r.viewed?.dateTime;
38705
+ const viewedAt = typeof dt === "string" && !dt.startsWith("1970-01-01") ? dt : null;
38706
+ return { userId: r.user?.id ?? 0, name: r.user?.name ?? "", viewedAt };
38707
+ });
38708
+ }
38709
+ function hasRealView(recipients) {
38710
+ return recipients.some((r) => r.viewedAt !== null && !r.viewedAt.startsWith("1970-01-01"));
38561
38711
  }
38562
38712
  var expandPath2 = expandPath;
38563
38713
  function verifyWriteLanded(kind, sent, persisted) {
@@ -38609,390 +38759,6 @@ function registerUserTools(server, client2) {
38609
38759
  });
38610
38760
  }
38611
38761
 
38612
- // src/cache.ts
38613
- import { DatabaseSync } from "node:sqlite";
38614
- import { mkdirSync, chmodSync, existsSync } from "node:fs";
38615
- import { dirname as dirname2 } from "node:path";
38616
-
38617
- // src/config.ts
38618
- import { createHash } from "node:crypto";
38619
- import { homedir as homedir3 } from "node:os";
38620
- import { join as join4 } from "node:path";
38621
- function readCacheIdentity() {
38622
- return readEnvVar("OFW_CACHE_IDENTITY") ?? readEnvVar("OFW_USERNAME") ?? "_default";
38623
- }
38624
- function getCacheDir() {
38625
- const override = process.env.OFW_CACHE_DIR;
38626
- if (override && override.trim().length > 0) return override.trim();
38627
- return join4(homedir3(), ".cache", "ofw-mcp");
38628
- }
38629
- function getCacheDbPath() {
38630
- const identity = readCacheIdentity();
38631
- const hash2 = createHash("sha256").update(identity).digest("hex").slice(0, 16);
38632
- return join4(getCacheDir(), `${hash2}.db`);
38633
- }
38634
- function getAttachmentsDir() {
38635
- const override = process.env.OFW_ATTACHMENTS_DIR;
38636
- if (override && override.trim().length > 0) return override.trim();
38637
- return join4(homedir3(), "Downloads", "ofw-mcp");
38638
- }
38639
- function getWriteMode() {
38640
- const raw = process.env.OFW_WRITE_MODE;
38641
- if (typeof raw !== "string" || raw.trim().length === 0) return "all";
38642
- const mode = raw.trim().toLowerCase();
38643
- if (mode === "none" || mode === "drafts" || mode === "all") return mode;
38644
- console.error(
38645
- `[ofw-mcp] Unrecognized OFW_WRITE_MODE "${raw.trim()}" \u2014 failing closed to "none" (no write tools registered). Valid values: none, drafts, all.`
38646
- );
38647
- return "none";
38648
- }
38649
- function getDefaultInlineAttachments() {
38650
- return parseBoolEnv("OFW_INLINE_ATTACHMENTS");
38651
- }
38652
-
38653
- // src/cache.ts
38654
- var instance = null;
38655
- var SCHEMA_V1 = `
38656
- CREATE TABLE IF NOT EXISTS messages (
38657
- id INTEGER PRIMARY KEY,
38658
- folder TEXT NOT NULL,
38659
- subject TEXT NOT NULL,
38660
- from_user TEXT NOT NULL,
38661
- sent_at TEXT NOT NULL,
38662
- recipients_json TEXT NOT NULL,
38663
- body TEXT,
38664
- fetched_body_at TEXT,
38665
- reply_to_id INTEGER,
38666
- chain_root_id INTEGER,
38667
- list_data_json TEXT NOT NULL,
38668
- last_seen_at TEXT NOT NULL
38669
- );
38670
- CREATE INDEX IF NOT EXISTS idx_messages_folder_sent_at ON messages(folder, sent_at DESC);
38671
- CREATE INDEX IF NOT EXISTS idx_messages_chain_root ON messages(chain_root_id);
38672
-
38673
- CREATE TABLE IF NOT EXISTS drafts (
38674
- id INTEGER PRIMARY KEY,
38675
- subject TEXT NOT NULL,
38676
- body TEXT NOT NULL,
38677
- recipients_json TEXT NOT NULL,
38678
- reply_to_id INTEGER,
38679
- modified_at TEXT NOT NULL,
38680
- list_data_json TEXT NOT NULL
38681
- );
38682
-
38683
- CREATE TABLE IF NOT EXISTS sync_state (
38684
- folder TEXT PRIMARY KEY,
38685
- last_sync_at TEXT NOT NULL,
38686
- newest_id INTEGER
38687
- );
38688
-
38689
- CREATE TABLE IF NOT EXISTS meta (
38690
- key TEXT PRIMARY KEY,
38691
- value TEXT NOT NULL
38692
- );
38693
- `;
38694
- var SCHEMA_V2 = `
38695
- CREATE TABLE IF NOT EXISTS attachments (
38696
- file_id INTEGER PRIMARY KEY,
38697
- file_name TEXT NOT NULL,
38698
- label TEXT NOT NULL,
38699
- mime_type TEXT NOT NULL,
38700
- size_bytes INTEGER,
38701
- metadata_json TEXT NOT NULL,
38702
- message_ids_json TEXT NOT NULL, -- JSON array of message ids that reference this file
38703
- downloaded_path TEXT, -- absolute path on disk if/when downloaded
38704
- downloaded_at TEXT,
38705
- fetched_metadata_at TEXT NOT NULL
38706
- );
38707
- `;
38708
- function migrate(db) {
38709
- db.exec(SCHEMA_V1);
38710
- db.exec(SCHEMA_V2);
38711
- db.prepare(
38712
- "INSERT INTO meta(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value"
38713
- ).run("schema_version", "2");
38714
- }
38715
- function enforceCachePermissions(dbPath) {
38716
- chmodSync(dirname2(dbPath), 448);
38717
- chmodSync(dbPath, 384);
38718
- for (const sibling of [`${dbPath}-wal`, `${dbPath}-shm`]) {
38719
- if (existsSync(sibling)) chmodSync(sibling, 384);
38720
- }
38721
- }
38722
- function openCache() {
38723
- if (instance) return instance;
38724
- const path = getCacheDbPath();
38725
- mkdirSync(dirname2(path), { recursive: true });
38726
- const db = new DatabaseSync(path);
38727
- enforceCachePermissions(path);
38728
- db.exec("PRAGMA journal_mode = WAL");
38729
- db.exec("PRAGMA foreign_keys = ON");
38730
- migrate(db);
38731
- enforceCachePermissions(path);
38732
- instance = { db };
38733
- return instance;
38734
- }
38735
- function rowFromDb(r) {
38736
- return {
38737
- id: r.id,
38738
- folder: r.folder,
38739
- subject: r.subject,
38740
- fromUser: r.from_user,
38741
- sentAt: r.sent_at,
38742
- recipients: JSON.parse(r.recipients_json),
38743
- body: r.body,
38744
- fetchedBodyAt: r.fetched_body_at,
38745
- replyToId: r.reply_to_id,
38746
- chainRootId: r.chain_root_id,
38747
- listData: JSON.parse(r.list_data_json)
38748
- };
38749
- }
38750
- function nullish3(v) {
38751
- return v === void 0 ? null : v;
38752
- }
38753
- function requireString(field, v) {
38754
- if (typeof v === "string") return v;
38755
- throw new Error(`cache: ${field} is required (got ${v === void 0 ? "undefined" : "null"})`);
38756
- }
38757
- function upsertMessage(row) {
38758
- const { db } = openCache();
38759
- db.prepare(
38760
- `INSERT INTO messages (
38761
- id, folder, subject, from_user, sent_at, recipients_json,
38762
- body, fetched_body_at, reply_to_id, chain_root_id, list_data_json, last_seen_at
38763
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
38764
- ON CONFLICT(id) DO UPDATE SET
38765
- folder=excluded.folder,
38766
- subject=excluded.subject,
38767
- from_user=excluded.from_user,
38768
- sent_at=excluded.sent_at,
38769
- recipients_json=excluded.recipients_json,
38770
- body=excluded.body,
38771
- fetched_body_at=excluded.fetched_body_at,
38772
- reply_to_id=excluded.reply_to_id,
38773
- chain_root_id=excluded.chain_root_id,
38774
- list_data_json=excluded.list_data_json,
38775
- last_seen_at=excluded.last_seen_at`
38776
- ).run(
38777
- row.id,
38778
- requireString("messages.folder", row.folder),
38779
- requireString("messages.subject", row.subject),
38780
- requireString("messages.fromUser", row.fromUser),
38781
- requireString("messages.sentAt", row.sentAt),
38782
- JSON.stringify(row.recipients ?? []),
38783
- nullish3(row.body),
38784
- nullish3(row.fetchedBodyAt),
38785
- nullish3(row.replyToId),
38786
- nullish3(row.chainRootId),
38787
- JSON.stringify(row.listData ?? null),
38788
- (/* @__PURE__ */ new Date()).toISOString()
38789
- );
38790
- }
38791
- function getMessage(id) {
38792
- const { db } = openCache();
38793
- const r = db.prepare("SELECT * FROM messages WHERE id = ?").get(id);
38794
- return r ? rowFromDb(r) : null;
38795
- }
38796
- function deleteMessage(id) {
38797
- const { db } = openCache();
38798
- db.prepare("DELETE FROM messages WHERE id = ?").run(id);
38799
- }
38800
- function buildMessageFilter(opts) {
38801
- const wheres = [];
38802
- const params = [];
38803
- if (opts.folder !== void 0) {
38804
- wheres.push("folder = ?");
38805
- params.push(opts.folder);
38806
- }
38807
- if (opts.since !== void 0) {
38808
- wheres.push("sent_at >= ?");
38809
- params.push(opts.since);
38810
- }
38811
- if (opts.until !== void 0) {
38812
- wheres.push("sent_at < ?");
38813
- params.push(opts.until);
38814
- }
38815
- if (opts.q !== void 0 && opts.q.length > 0) {
38816
- const pattern = `%${opts.q}%`;
38817
- wheres.push("(subject LIKE ? OR body LIKE ?)");
38818
- params.push(pattern, pattern);
38819
- }
38820
- return {
38821
- where: wheres.length > 0 ? `WHERE ${wheres.join(" AND ")}` : "",
38822
- params
38823
- };
38824
- }
38825
- function listMessages(opts) {
38826
- const { db } = openCache();
38827
- const { where, params } = buildMessageFilter(opts);
38828
- const offset = (opts.page - 1) * opts.size;
38829
- const rows = db.prepare(
38830
- `SELECT * FROM messages ${where}
38831
- ORDER BY sent_at DESC, id DESC
38832
- LIMIT ? OFFSET ?`
38833
- ).all(...params, opts.size, offset);
38834
- return rows.map(rowFromDb);
38835
- }
38836
- function countMessages(opts) {
38837
- const { db } = openCache();
38838
- const { where, params } = buildMessageFilter(opts);
38839
- const r = db.prepare(`SELECT COUNT(*) as n FROM messages ${where}`).get(...params);
38840
- return r?.n ?? 0;
38841
- }
38842
- function draftFromDb(r) {
38843
- return {
38844
- id: r.id,
38845
- subject: r.subject,
38846
- body: r.body,
38847
- recipients: JSON.parse(r.recipients_json),
38848
- replyToId: r.reply_to_id,
38849
- modifiedAt: r.modified_at,
38850
- listData: JSON.parse(r.list_data_json)
38851
- };
38852
- }
38853
- function upsertDraft(row) {
38854
- const { db } = openCache();
38855
- db.prepare(
38856
- `INSERT INTO drafts (id, subject, body, recipients_json, reply_to_id, modified_at, list_data_json)
38857
- VALUES (?, ?, ?, ?, ?, ?, ?)
38858
- ON CONFLICT(id) DO UPDATE SET
38859
- subject=excluded.subject,
38860
- body=excluded.body,
38861
- recipients_json=excluded.recipients_json,
38862
- reply_to_id=excluded.reply_to_id,
38863
- modified_at=excluded.modified_at,
38864
- list_data_json=excluded.list_data_json`
38865
- ).run(
38866
- row.id,
38867
- requireString("drafts.subject", row.subject),
38868
- requireString("drafts.body", row.body),
38869
- JSON.stringify(row.recipients ?? []),
38870
- nullish3(row.replyToId),
38871
- requireString("drafts.modifiedAt", row.modifiedAt),
38872
- JSON.stringify(row.listData ?? null)
38873
- );
38874
- }
38875
- function getDraft(id) {
38876
- const { db } = openCache();
38877
- const r = db.prepare("SELECT * FROM drafts WHERE id = ?").get(id);
38878
- return r ? draftFromDb(r) : null;
38879
- }
38880
- function listDrafts(opts) {
38881
- const { db } = openCache();
38882
- const offset = (opts.page - 1) * opts.size;
38883
- const rows = db.prepare(
38884
- "SELECT * FROM drafts ORDER BY modified_at DESC, id DESC LIMIT ? OFFSET ?"
38885
- ).all(opts.size, offset);
38886
- return rows.map(draftFromDb);
38887
- }
38888
- function deleteDraft(id) {
38889
- const { db } = openCache();
38890
- db.prepare("DELETE FROM drafts WHERE id = ?").run(id);
38891
- }
38892
- function listDraftIds() {
38893
- const { db } = openCache();
38894
- const rows = db.prepare("SELECT id FROM drafts").all();
38895
- return rows.map((r) => r.id);
38896
- }
38897
- function setSyncState(folder, state) {
38898
- const { db } = openCache();
38899
- db.prepare(
38900
- `INSERT INTO sync_state (folder, last_sync_at, newest_id) VALUES (?, ?, ?)
38901
- ON CONFLICT(folder) DO UPDATE SET
38902
- last_sync_at = excluded.last_sync_at,
38903
- newest_id = excluded.newest_id`
38904
- ).run(folder, state.lastSyncAt, state.newestId);
38905
- }
38906
- function setMeta(key, value) {
38907
- const { db } = openCache();
38908
- db.prepare(
38909
- "INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value"
38910
- ).run(key, value);
38911
- }
38912
- function findLatestReplyTip(replyToId) {
38913
- const { db } = openCache();
38914
- const parent = db.prepare(
38915
- "SELECT id, folder, chain_root_id FROM messages WHERE id = ?"
38916
- ).get(replyToId);
38917
- if (!parent) return replyToId;
38918
- const chainRoot = parent.chain_root_id ?? parent.id;
38919
- const tip = db.prepare(
38920
- `SELECT id FROM messages
38921
- WHERE folder = 'sent' AND chain_root_id = ?
38922
- ORDER BY id DESC LIMIT 1`
38923
- ).get(chainRoot);
38924
- return tip ? tip.id : replyToId;
38925
- }
38926
- function attachmentFromDb(r) {
38927
- return {
38928
- fileId: r.file_id,
38929
- fileName: r.file_name,
38930
- label: r.label,
38931
- mimeType: r.mime_type,
38932
- sizeBytes: r.size_bytes,
38933
- metadata: JSON.parse(r.metadata_json),
38934
- messageIds: JSON.parse(r.message_ids_json),
38935
- downloadedPath: r.downloaded_path,
38936
- downloadedAt: r.downloaded_at
38937
- };
38938
- }
38939
- function getAttachment(fileId) {
38940
- const { db } = openCache();
38941
- const r = db.prepare("SELECT * FROM attachments WHERE file_id = ?").get(fileId);
38942
- return r ? attachmentFromDb(r) : null;
38943
- }
38944
- function listAttachmentsForMessage(messageId) {
38945
- const { db } = openCache();
38946
- const rows = db.prepare(
38947
- `SELECT * FROM attachments
38948
- WHERE EXISTS (SELECT 1 FROM json_each(message_ids_json) WHERE value = ?)
38949
- ORDER BY file_id`
38950
- ).all(messageId);
38951
- return rows.map(attachmentFromDb);
38952
- }
38953
- function upsertAttachmentForMessage(input) {
38954
- const { db } = openCache();
38955
- const existing = db.prepare("SELECT message_ids_json FROM attachments WHERE file_id = ?").get(input.fileId);
38956
- const prior = existing ? JSON.parse(existing.message_ids_json) : [];
38957
- let messageIds;
38958
- if (input.messageId === 0) {
38959
- messageIds = prior;
38960
- } else if (prior.includes(input.messageId)) {
38961
- messageIds = prior;
38962
- } else {
38963
- messageIds = [...prior, input.messageId];
38964
- }
38965
- db.prepare(
38966
- `INSERT INTO attachments (
38967
- file_id, file_name, label, mime_type, size_bytes,
38968
- metadata_json, message_ids_json, fetched_metadata_at
38969
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
38970
- ON CONFLICT(file_id) DO UPDATE SET
38971
- file_name=excluded.file_name,
38972
- label=excluded.label,
38973
- mime_type=excluded.mime_type,
38974
- size_bytes=excluded.size_bytes,
38975
- metadata_json=excluded.metadata_json,
38976
- message_ids_json=excluded.message_ids_json,
38977
- fetched_metadata_at=excluded.fetched_metadata_at`
38978
- ).run(
38979
- input.fileId,
38980
- requireString("attachments.fileName", input.fileName),
38981
- requireString("attachments.label", input.label),
38982
- requireString("attachments.mimeType", input.mimeType),
38983
- nullish3(input.sizeBytes),
38984
- JSON.stringify(input.metadata ?? null),
38985
- JSON.stringify(messageIds),
38986
- (/* @__PURE__ */ new Date()).toISOString()
38987
- );
38988
- }
38989
- function markAttachmentDownloaded(fileId, path) {
38990
- const { db } = openCache();
38991
- db.prepare(
38992
- "UPDATE attachments SET downloaded_path = ?, downloaded_at = ? WHERE file_id = ?"
38993
- ).run(path, (/* @__PURE__ */ new Date()).toISOString(), fileId);
38994
- }
38995
-
38996
38762
  // src/sync.ts
38997
38763
  var FileMetaSchema = external_exports.looseObject({
38998
38764
  fileId: external_exports.number(),
@@ -39002,13 +38768,13 @@ var FileMetaSchema = external_exports.looseObject({
39002
38768
  // MIME
39003
38769
  fileSize: external_exports.number().optional()
39004
38770
  });
39005
- async function fetchAttachmentMeta(client2, fileId, messageId) {
38771
+ async function fetchAttachmentMeta(client2, fileId, messageId, store) {
39006
38772
  const meta3 = parseLenient(
39007
38773
  FileMetaSchema,
39008
38774
  await client2.request("GET", `/pub/v1/myfiles/${fileId}`),
39009
38775
  { label: "ofw-mcp", context: "GET /pub/v1/myfiles/{fileId}" }
39010
38776
  );
39011
- upsertAttachmentForMessage({
38777
+ await store.upsertAttachmentForMessage({
39012
38778
  fileId: meta3.fileId ?? fileId,
39013
38779
  fileName: meta3.fileName ?? `file-${fileId}`,
39014
38780
  label: meta3.label ?? meta3.fileName ?? `file-${fileId}`,
@@ -39018,13 +38784,33 @@ async function fetchAttachmentMeta(client2, fileId, messageId) {
39018
38784
  messageId
39019
38785
  });
39020
38786
  }
39021
- async function fetchAttachmentMetaForMessage(client2, messageId, fileIds) {
39022
- await Promise.allSettled(fileIds.map((fid) => fetchAttachmentMeta(client2, fid, messageId)));
38787
+ async function fetchAttachmentMetaForMessage(client2, messageId, fileIds, store) {
38788
+ await Promise.allSettled(fileIds.map((fid) => fetchAttachmentMeta(client2, fid, messageId, store)));
38789
+ }
38790
+ function makeBudget(max) {
38791
+ let remaining = max;
38792
+ return {
38793
+ take() {
38794
+ if (remaining <= 0) return false;
38795
+ remaining -= 1;
38796
+ return true;
38797
+ }
38798
+ };
38799
+ }
38800
+ async function fetchAttachmentMetaBudgeted(client2, messageId, fileIds, store, budget) {
38801
+ const affordable = [];
38802
+ for (const fid of fileIds) {
38803
+ if (!budget.take()) break;
38804
+ affordable.push(fid);
38805
+ }
38806
+ if (affordable.length > 0) {
38807
+ await fetchAttachmentMetaForMessage(client2, messageId, affordable, store);
38808
+ }
39023
38809
  }
39024
38810
  var FoldersSchema = external_exports.looseObject({
39025
38811
  systemFolders: external_exports.array(external_exports.looseObject({ id: external_exports.string(), folderType: external_exports.string() })).optional()
39026
38812
  });
39027
- async function resolveFolderIds(client2) {
38813
+ async function resolveFolderIds(client2, store) {
39028
38814
  const data = parseLenient(
39029
38815
  FoldersSchema,
39030
38816
  await client2.request("GET", "/pub/v1/messageFolders?includeFolderCounts=true"),
@@ -39041,7 +38827,8 @@ async function resolveFolderIds(client2) {
39041
38827
  sent: find("SENT_MESSAGES"),
39042
38828
  drafts: find("DRAFTS")
39043
38829
  };
39044
- setMeta("drafts_folder_id", ids.drafts);
38830
+ await store.setMeta("drafts_folder_id", ids.drafts);
38831
+ await store.setMeta("sent_folder_id", ids.sent);
39045
38832
  return ids;
39046
38833
  }
39047
38834
  var ListItemSchema = external_exports.looseObject({
@@ -39055,14 +38842,22 @@ var ListItemSchema = external_exports.looseObject({
39055
38842
  var ListResponseSchema = external_exports.looseObject({ data: external_exports.array(ListItemSchema).optional() });
39056
38843
  var DetailResponseSchema = external_exports.looseObject({
39057
38844
  body: external_exports.string().optional(),
39058
- files: external_exports.array(external_exports.number()).optional()
38845
+ files: external_exports.array(external_exports.number()).optional(),
38846
+ // The detail endpoint carries the REAL recipient view timestamps (the list
38847
+ // endpoint only has an epoch placeholder) — used by the view-status refresh.
38848
+ recipients: external_exports.array(ApiRecipientSchema).optional()
39059
38849
  });
39060
- async function syncMessageFolder(client2, folder, folderId, opts) {
39061
- let page = 1;
39062
- let synced = 0;
38850
+ var maxId = (a, b) => a === null ? b : b === null ? a : Math.max(a, b);
38851
+ async function walkPages(client2, folder, folderId, opts, store) {
38852
+ const budget = opts.budget;
38853
+ let page = opts.startPage;
39063
38854
  let newestId = null;
38855
+ let synced = 0;
39064
38856
  const unread = [];
39065
38857
  while (true) {
38858
+ if (!budget.take()) {
38859
+ return { synced, unread, newestId, done: false, nextPage: page };
38860
+ }
39066
38861
  const path = `/pub/v3/messages?folders=${encodeURIComponent(folderId)}&page=${page}&size=50&sort=date&sortDirection=desc`;
39067
38862
  const list = parseLenient(
39068
38863
  ListResponseSchema,
@@ -39070,19 +38865,46 @@ async function syncMessageFolder(client2, folder, folderId, opts) {
39070
38865
  { label: "ofw-mcp", context: `GET /pub/v3/messages?folders={${folder}}` }
39071
38866
  );
39072
38867
  const items = list.data ?? [];
39073
- if (items.length === 0) break;
38868
+ if (items.length === 0) {
38869
+ return { synced, unread, newestId, done: true, nextPage: null };
38870
+ }
38871
+ const existingById = new Map(
38872
+ (await store.getMessages(items.map((it) => it.id))).map((row) => [row.id, row])
38873
+ );
38874
+ const toUpsert = [];
39074
38875
  let pageHadNewItem = false;
38876
+ let pageBudgetHit = false;
39075
38877
  for (const item of items) {
39076
38878
  if (newestId === null || item.id > newestId) newestId = item.id;
39077
- const existing = getMessage(item.id);
39078
- if (existing) continue;
38879
+ const existing = existingById.get(item.id);
38880
+ if (existing) {
38881
+ if (folder === "sent" && item.showNeverViewed === false && !hasRealView(existing.recipients)) {
38882
+ if (!budget.take()) {
38883
+ pageBudgetHit = true;
38884
+ break;
38885
+ }
38886
+ const detail = parseLenient(
38887
+ DetailResponseSchema,
38888
+ await client2.request("GET", `/pub/v3/messages/${item.id}`),
38889
+ { label: "ofw-mcp", context: "GET /pub/v3/messages/{id} (view-status refresh)" }
38890
+ );
38891
+ toUpsert.push({ ...existing, recipients: mapRecipients(detail.recipients), listData: item });
38892
+ synced++;
38893
+ }
38894
+ continue;
38895
+ }
39079
38896
  pageHadNewItem = true;
39080
38897
  const isInboxUnread = folder === "inbox" && item.showNeverViewed === true;
39081
38898
  const shouldFetchBody = !isInboxUnread || opts.fetchUnreadBodies;
39082
38899
  let body = null;
39083
38900
  let fetchedBodyAt = null;
39084
38901
  let detailFileIds = [];
38902
+ let detailRecipients;
39085
38903
  if (shouldFetchBody) {
38904
+ if (!budget.take()) {
38905
+ pageBudgetHit = true;
38906
+ break;
38907
+ }
39086
38908
  const detail = parseLenient(
39087
38909
  DetailResponseSchema,
39088
38910
  await client2.request("GET", `/pub/v3/messages/${item.id}`),
@@ -39090,6 +38912,7 @@ async function syncMessageFolder(client2, folder, folderId, opts) {
39090
38912
  );
39091
38913
  body = detail.body ?? "";
39092
38914
  fetchedBodyAt = (/* @__PURE__ */ new Date()).toISOString();
38915
+ detailRecipients = detail.recipients;
39093
38916
  if (Array.isArray(detail.files) && detail.files.length > 0) {
39094
38917
  detailFileIds = detail.files;
39095
38918
  }
@@ -39107,27 +38930,72 @@ async function syncMessageFolder(client2, folder, folderId, opts) {
39107
38930
  subject: item.subject ?? "(no subject)",
39108
38931
  fromUser: item.from?.name ?? "",
39109
38932
  sentAt: item.date?.dateTime ?? (/* @__PURE__ */ new Date()).toISOString(),
39110
- recipients: mapRecipients(item.recipients),
38933
+ recipients: mapRecipients(detailRecipients ?? item.recipients),
39111
38934
  body,
39112
38935
  fetchedBodyAt,
39113
38936
  replyToId: null,
39114
38937
  chainRootId: null,
39115
38938
  listData: item
39116
38939
  };
39117
- upsertMessage(row);
38940
+ toUpsert.push(row);
39118
38941
  synced++;
39119
38942
  if (detailFileIds.length > 0) {
39120
- await fetchAttachmentMetaForMessage(client2, item.id, detailFileIds);
38943
+ await fetchAttachmentMetaBudgeted(client2, item.id, detailFileIds, store, budget);
39121
38944
  }
39122
38945
  }
39123
- if (!opts.deep && !pageHadNewItem) break;
38946
+ await store.upsertMessages(toUpsert);
38947
+ if (pageBudgetHit) {
38948
+ return { synced, unread, newestId, done: false, nextPage: page };
38949
+ }
38950
+ if (opts.stopAtCachedPage && !pageHadNewItem) {
38951
+ return { synced, unread, newestId, done: true, nextPage: page };
38952
+ }
39124
38953
  page++;
39125
38954
  }
39126
- setSyncState(folder, {
38955
+ }
38956
+ async function syncMessageFolder(client2, folder, folderId, opts, store) {
38957
+ const budget = opts.budget ?? makeBudget(Number.POSITIVE_INFINITY);
38958
+ const saved = await store.getSyncState(folder);
38959
+ const savedResume = saved?.resumePage ?? null;
38960
+ const fwd = await walkPages(client2, folder, folderId, {
38961
+ startPage: 1,
38962
+ stopAtCachedPage: true,
38963
+ fetchUnreadBodies: opts.fetchUnreadBodies,
38964
+ budget
38965
+ }, store);
38966
+ let synced = fwd.synced;
38967
+ const unread = [...fwd.unread];
38968
+ let newestId = maxId(saved?.newestId ?? null, fwd.newestId);
38969
+ let done;
38970
+ let resumePage;
38971
+ if (!fwd.done) {
38972
+ done = false;
38973
+ resumePage = savedResume === null ? fwd.nextPage : Math.min(fwd.nextPage, savedResume);
38974
+ } else if (fwd.nextPage === null) {
38975
+ done = true;
38976
+ resumePage = null;
38977
+ } else if (savedResume === null && !opts.deep) {
38978
+ done = true;
38979
+ resumePage = null;
38980
+ } else {
38981
+ const bf = await walkPages(client2, folder, folderId, {
38982
+ startPage: savedResume ?? fwd.nextPage,
38983
+ stopAtCachedPage: false,
38984
+ fetchUnreadBodies: opts.fetchUnreadBodies,
38985
+ budget
38986
+ }, store);
38987
+ synced += bf.synced;
38988
+ unread.push(...bf.unread);
38989
+ newestId = maxId(newestId, bf.newestId);
38990
+ done = bf.done;
38991
+ resumePage = bf.done ? null : bf.nextPage;
38992
+ }
38993
+ await store.setSyncState(folder, {
39127
38994
  lastSyncAt: (/* @__PURE__ */ new Date()).toISOString(),
39128
- newestId
38995
+ newestId,
38996
+ resumePage
39129
38997
  });
39130
- return { synced, unread };
38998
+ return { synced, unread, done };
39131
38999
  }
39132
39000
  var DraftListItemSchema = external_exports.looseObject({
39133
39001
  id: external_exports.number(),
@@ -39141,10 +39009,12 @@ var DraftDetailSchema = external_exports.looseObject({
39141
39009
  body: external_exports.string().optional(),
39142
39010
  subject: external_exports.string().optional()
39143
39011
  });
39144
- async function syncDrafts(client2, draftsFolderId) {
39012
+ async function syncDrafts(client2, draftsFolderId, store, budget) {
39013
+ const b = budget ?? makeBudget(Number.POSITIVE_INFINITY);
39145
39014
  const items = [];
39146
39015
  let page = 1;
39147
39016
  while (true) {
39017
+ if (!b.take()) return { synced: 0, done: false };
39148
39018
  const path = `/pub/v3/messages?folders=${encodeURIComponent(draftsFolderId)}&page=${page}&size=50&sort=date&sortDirection=desc`;
39149
39019
  const list = parseLenient(
39150
39020
  DraftListResponseSchema,
@@ -39156,81 +39026,149 @@ async function syncDrafts(client2, draftsFolderId) {
39156
39026
  if (pageItems.length < 50) break;
39157
39027
  page++;
39158
39028
  }
39159
- const seenIds = /* @__PURE__ */ new Set();
39160
- let synced = 0;
39029
+ const rows = [];
39161
39030
  for (const item of items) {
39162
- seenIds.add(item.id);
39163
- const modifiedAt = item.date?.dateTime ?? (/* @__PURE__ */ new Date()).toISOString();
39164
- const existing = getDraft(item.id);
39031
+ if (!b.take()) return { synced: 0, done: false };
39165
39032
  const detail = parseLenient(
39166
39033
  DraftDetailSchema,
39167
39034
  await client2.request("GET", `/pub/v3/messages/${item.id}`),
39168
39035
  { label: "ofw-mcp", context: "GET /pub/v3/messages/{id} (drafts sync)" }
39169
39036
  );
39170
- const row = {
39037
+ rows.push({
39171
39038
  id: item.id,
39172
39039
  subject: detail.subject ?? item.subject ?? "(no subject)",
39173
39040
  body: detail.body ?? "",
39174
39041
  recipients: mapRecipients(item.recipients),
39175
39042
  replyToId: item.replyToId ?? null,
39176
- modifiedAt,
39043
+ modifiedAt: item.date?.dateTime ?? (/* @__PURE__ */ new Date()).toISOString(),
39177
39044
  listData: item
39178
- };
39179
- upsertDraft(row);
39180
- if (getMessage(item.id)) deleteMessage(item.id);
39045
+ });
39046
+ }
39047
+ const ids = items.map((it) => it.id);
39048
+ const existingById = new Map((await store.getDrafts(ids)).map((d) => [d.id, d]));
39049
+ await store.upsertDrafts(rows);
39050
+ for (const stale of await store.getMessages(ids)) {
39051
+ await store.deleteMessage(stale.id);
39052
+ }
39053
+ let synced = 0;
39054
+ for (const row of rows) {
39055
+ const existing = existingById.get(row.id);
39181
39056
  if (!existing || existing.body !== row.body || existing.subject !== row.subject || existing.replyToId !== row.replyToId) {
39182
39057
  synced++;
39183
39058
  }
39184
39059
  }
39185
- for (const id of listDraftIds()) {
39186
- if (!seenIds.has(id)) deleteDraft(id);
39060
+ const seenIds = new Set(ids);
39061
+ for (const id of await store.listDraftIds()) {
39062
+ if (!seenIds.has(id)) await store.deleteDraft(id);
39187
39063
  }
39188
- return { synced };
39064
+ return { synced, done: true };
39189
39065
  }
39190
- async function syncAll(client2, opts) {
39066
+ async function syncAll(client2, opts, store) {
39191
39067
  const folders = opts.folders ?? ["inbox", "sent", "drafts"];
39192
- const ids = await resolveFolderIds(client2);
39068
+ const budget = makeBudget(opts.maxRequests ?? Number.POSITIVE_INFINITY);
39069
+ budget.take();
39070
+ const ids = await resolveFolderIds(client2, store);
39193
39071
  const synced = {};
39194
39072
  let unreadInbox = [];
39073
+ let done = true;
39195
39074
  for (const folder of folders) {
39196
39075
  if (folder === "inbox") {
39197
39076
  const r = await syncMessageFolder(client2, "inbox", ids.inbox, {
39198
39077
  fetchUnreadBodies: opts.fetchUnreadBodies ?? false,
39199
- deep: opts.deep ?? false
39200
- });
39078
+ deep: opts.deep ?? false,
39079
+ budget
39080
+ }, store);
39201
39081
  synced.inbox = r.synced;
39202
39082
  unreadInbox = r.unread;
39083
+ if (!r.done) done = false;
39203
39084
  } else if (folder === "sent") {
39204
39085
  const r = await syncMessageFolder(client2, "sent", ids.sent, {
39205
39086
  fetchUnreadBodies: false,
39206
- deep: opts.deep ?? false
39207
- });
39087
+ deep: opts.deep ?? false,
39088
+ budget
39089
+ }, store);
39208
39090
  synced.sent = r.synced;
39091
+ if (!r.done) done = false;
39209
39092
  } else if (folder === "drafts") {
39210
- const r = await syncDrafts(client2, ids.drafts);
39093
+ const r = await syncDrafts(client2, ids.drafts, store, budget);
39211
39094
  synced.drafts = r.synced;
39095
+ if (!r.done) done = false;
39212
39096
  }
39213
39097
  }
39214
- const note = unreadInbox.length > 0 ? `${unreadInbox.length} unread inbox messages cached without bodies. Call ofw_get_message(id) to read them \u2014 this will mark them as read on OFW.` : void 0;
39215
- return { synced, unreadInbox, ...note ? { note } : {} };
39098
+ const notes = [];
39099
+ if (unreadInbox.length > 0) {
39100
+ notes.push(`${unreadInbox.length} unread inbox messages cached without bodies. Call ofw_get_message(id) to read them \u2014 this will mark them as read on OFW.`);
39101
+ }
39102
+ if (!done) {
39103
+ notes.push("Paused after the request budget to stay within the hosting limit; more pages remain \u2014 call ofw_sync_messages again with the same arguments to resume where it left off and continue the backfill.");
39104
+ }
39105
+ const note = notes.length > 0 ? notes.join("\n\n") : void 0;
39106
+ return { synced, unreadInbox, done, ...note ? { note } : {} };
39216
39107
  }
39217
39108
 
39218
- // src/tools/messages.ts
39219
- import { mkdirSync as mkdirSync2, readFileSync, statSync, writeFileSync } from "node:fs";
39220
- import { basename, dirname as dirname3, extname, join as join5 } from "node:path";
39221
- var DateSchema = external_exports.looseObject({ dateTime: external_exports.string() });
39222
- var SentDetailSchema = external_exports.looseObject({
39223
- subject: external_exports.string().optional(),
39224
- body: external_exports.string().optional(),
39225
- date: DateSchema.optional(),
39226
- from: external_exports.looseObject({ name: external_exports.string().optional() }).optional(),
39227
- recipients: external_exports.array(ApiRecipientSchema).optional()
39228
- });
39229
- var SavedDraftDetailSchema = external_exports.looseObject({
39230
- subject: external_exports.string().optional(),
39231
- body: external_exports.string().optional(),
39232
- date: DateSchema.optional(),
39233
- replyToId: external_exports.number().nullable().optional(),
39109
+ // src/config.ts
39110
+ import { createHash } from "node:crypto";
39111
+ import { homedir as homedir3 } from "node:os";
39112
+ import { join as join4 } from "node:path";
39113
+ function readCacheIdentity() {
39114
+ return readEnvVar("OFW_CACHE_IDENTITY") ?? readEnvVar("OFW_USERNAME") ?? "_default";
39115
+ }
39116
+ function getCacheDir() {
39117
+ const override = process.env.OFW_CACHE_DIR;
39118
+ if (override && override.trim().length > 0) return override.trim();
39119
+ return join4(homedir3(), ".cache", "ofw-mcp");
39120
+ }
39121
+ function getCacheDbPath() {
39122
+ const identity = readCacheIdentity();
39123
+ const hash2 = createHash("sha256").update(identity).digest("hex").slice(0, 16);
39124
+ return join4(getCacheDir(), `${hash2}.db`);
39125
+ }
39126
+ function getAttachmentsDir() {
39127
+ const override = process.env.OFW_ATTACHMENTS_DIR;
39128
+ if (override && override.trim().length > 0) return override.trim();
39129
+ return join4(homedir3(), "Downloads", "ofw-mcp");
39130
+ }
39131
+ function getWriteMode() {
39132
+ const raw = process.env.OFW_WRITE_MODE;
39133
+ if (typeof raw !== "string" || raw.trim().length === 0) return "all";
39134
+ const mode = raw.trim().toLowerCase();
39135
+ if (mode === "none" || mode === "drafts" || mode === "all") return mode;
39136
+ console.error(
39137
+ `[ofw-mcp] Unrecognized OFW_WRITE_MODE "${raw.trim()}" \u2014 failing closed to "none" (no write tools registered). Valid values: none, drafts, all.`
39138
+ );
39139
+ return "none";
39140
+ }
39141
+ function getCalendarWritesAllowed() {
39142
+ const mode = getWriteMode();
39143
+ if (mode === "all") return true;
39144
+ return mode === "drafts" && parseBoolEnv("OFW_CALENDAR_WRITES");
39145
+ }
39146
+ function getDefaultInlineAttachments() {
39147
+ return parseBoolEnv("OFW_INLINE_ATTACHMENTS");
39148
+ }
39149
+ function getSyncMaxRequests() {
39150
+ const raw = readEnvVar("OFW_SYNC_MAX_REQUESTS");
39151
+ if (raw === void 0) return Number.POSITIVE_INFINITY;
39152
+ const n = Number(raw);
39153
+ if (!Number.isInteger(n) || n <= 0) return Number.POSITIVE_INFINITY;
39154
+ return n;
39155
+ }
39156
+
39157
+ // src/tools/messages.ts
39158
+ import { basename, join as join5 } from "node:path";
39159
+ var DateSchema = external_exports.looseObject({ dateTime: external_exports.string() });
39160
+ var SentDetailSchema = external_exports.looseObject({
39161
+ subject: external_exports.string().optional(),
39162
+ body: external_exports.string().optional(),
39163
+ date: DateSchema.optional(),
39164
+ from: external_exports.looseObject({ name: external_exports.string().optional() }).optional(),
39165
+ recipients: external_exports.array(ApiRecipientSchema).optional()
39166
+ });
39167
+ var SavedDraftDetailSchema = external_exports.looseObject({
39168
+ subject: external_exports.string().optional(),
39169
+ body: external_exports.string().optional(),
39170
+ date: DateSchema.optional(),
39171
+ replyToId: external_exports.number().nullable().optional(),
39234
39172
  recipients: external_exports.array(ApiRecipientSchema).optional()
39235
39173
  });
39236
39174
  var MessageDetailSchema = external_exports.looseObject({
@@ -39240,7 +39178,11 @@ var MessageDetailSchema = external_exports.looseObject({
39240
39178
  date: DateSchema,
39241
39179
  from: external_exports.looseObject({ name: external_exports.string().optional() }).optional(),
39242
39180
  files: external_exports.array(external_exports.number()).optional(),
39243
- recipients: external_exports.array(ApiRecipientSchema).optional()
39181
+ recipients: external_exports.array(ApiRecipientSchema).optional(),
39182
+ // The detail payload carries its own owning folder ({id, name}). We read the
39183
+ // id to label a live-fetched message sent-vs-inbox instead of blindly
39184
+ // defaulting to inbox — see the folder derivation in ofw_get_message.
39185
+ folder: external_exports.looseObject({ id: external_exports.number() }).optional()
39244
39186
  });
39245
39187
  var DetailFilesSchema = external_exports.looseObject({ files: external_exports.array(external_exports.number()).optional() });
39246
39188
  var UploadedFileSchema = external_exports.looseObject({
@@ -39251,33 +39193,6 @@ var UploadedFileSchema = external_exports.looseObject({
39251
39193
  sizeInBytes: external_exports.number().optional(),
39252
39194
  shareClass: external_exports.string().optional()
39253
39195
  });
39254
- var MIME_BY_EXT = {
39255
- ".pdf": "application/pdf",
39256
- ".png": "image/png",
39257
- ".jpg": "image/jpeg",
39258
- ".jpeg": "image/jpeg",
39259
- ".gif": "image/gif",
39260
- ".webp": "image/webp",
39261
- ".heic": "image/heic",
39262
- ".txt": "text/plain",
39263
- ".md": "text/markdown",
39264
- ".csv": "text/csv",
39265
- ".html": "text/html",
39266
- ".htm": "text/html",
39267
- ".json": "application/json",
39268
- ".xml": "application/xml",
39269
- ".doc": "application/msword",
39270
- ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
39271
- ".xls": "application/vnd.ms-excel",
39272
- ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
39273
- ".ppt": "application/vnd.ms-powerpoint",
39274
- ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
39275
- ".zip": "application/zip",
39276
- ".ics": "text/calendar"
39277
- };
39278
- function mimeFromName(name) {
39279
- return MIME_BY_EXT[extname(name).toLowerCase()] ?? "application/octet-stream";
39280
- }
39281
39196
  function listDataHintsAtFiles(listData) {
39282
39197
  if (typeof listData !== "object" || listData === null) return false;
39283
39198
  const ld = listData;
@@ -39285,7 +39200,7 @@ function listDataHintsAtFiles(listData) {
39285
39200
  if (Array.isArray(ld.files)) return ld.files.length > 0;
39286
39201
  return false;
39287
39202
  }
39288
- function registerMessageTools(server, client2) {
39203
+ function registerMessageTools(server, client2, cacheProvider, attachmentIO) {
39289
39204
  const writeMode = getWriteMode();
39290
39205
  const allowSend = writeMode === "all";
39291
39206
  const allowDrafts = writeMode !== "none";
@@ -39321,9 +39236,10 @@ function registerMessageTools(server, client2) {
39321
39236
  note: 'folderId must be "inbox", "sent", or "both". Numeric OFW folder IDs are not supported by the cache.'
39322
39237
  });
39323
39238
  }
39239
+ const cache = cacheProvider();
39324
39240
  const filter = { folder, since: args.since, until: args.until, q: args.q };
39325
- const total = countMessages(filter);
39326
- const messages = listMessages({ ...filter, page, size });
39241
+ const total = await cache.countMessages(filter);
39242
+ const messages = await cache.listMessages({ ...filter, page, size });
39327
39243
  const payload = { messages, total, page, size };
39328
39244
  if (total === 0) {
39329
39245
  payload.note = "No messages match these filters. If you expected results, check ofw_sync_messages was run, or relax the filters.";
@@ -39340,7 +39256,8 @@ function registerMessageTools(server, client2) {
39340
39256
  }
39341
39257
  }, async (args) => {
39342
39258
  const id = Number(args.messageId);
39343
- const draftRow = getDraft(id);
39259
+ const cache = cacheProvider();
39260
+ const draftRow = await cache.getDraft(id);
39344
39261
  if (draftRow !== null) {
39345
39262
  return jsonResponse({
39346
39263
  id: draftRow.id,
@@ -39360,10 +39277,28 @@ function registerMessageTools(server, client2) {
39360
39277
  attachments: []
39361
39278
  });
39362
39279
  }
39363
- const cached2 = getMessage(id);
39280
+ const cached2 = await cache.getMessage(id);
39364
39281
  if (cached2 && cached2.body !== null) {
39365
- let attachments2 = listAttachmentsForMessage(id);
39366
- if (attachments2.length === 0 && listDataHintsAtFiles(cached2.listData)) {
39282
+ let row2 = cached2;
39283
+ if (cached2.folder === "sent" && !hasRealView(cached2.recipients)) {
39284
+ try {
39285
+ const detail2 = parseLenient(
39286
+ MessageDetailSchema,
39287
+ await client2.request("GET", `/pub/v3/messages/${id}`),
39288
+ { label: "ofw-mcp", context: "GET /pub/v3/messages/{id} (view-status refresh)" }
39289
+ );
39290
+ const recipients = mapRecipients(detail2.recipients);
39291
+ row2 = {
39292
+ ...cached2,
39293
+ recipients,
39294
+ listData: { ...cached2.listData, showNeverViewed: !hasRealView(recipients) }
39295
+ };
39296
+ await cache.upsertMessage(row2);
39297
+ } catch {
39298
+ }
39299
+ }
39300
+ let attachments2 = await cache.listAttachmentsForMessage(id);
39301
+ if (attachments2.length === 0 && listDataHintsAtFiles(row2.listData)) {
39367
39302
  try {
39368
39303
  const detail2 = parseLenient(
39369
39304
  DetailFilesSchema,
@@ -39371,20 +39306,26 @@ function registerMessageTools(server, client2) {
39371
39306
  { label: "ofw-mcp", context: "GET /pub/v3/messages/{id} (attachment backfill)" }
39372
39307
  );
39373
39308
  if (Array.isArray(detail2.files) && detail2.files.length > 0) {
39374
- await fetchAttachmentMetaForMessage(client2, id, detail2.files);
39375
- attachments2 = listAttachmentsForMessage(id);
39309
+ await fetchAttachmentMetaForMessage(client2, id, detail2.files, cache);
39310
+ attachments2 = await cache.listAttachmentsForMessage(id);
39376
39311
  }
39377
39312
  } catch {
39378
39313
  }
39379
39314
  }
39380
- return jsonResponse({ ...cached2, attachments: attachments2 });
39315
+ return jsonResponse({ ...row2, attachments: attachments2 });
39381
39316
  }
39382
39317
  const detail = parseLenient(
39383
39318
  MessageDetailSchema,
39384
39319
  await client2.request("GET", `/pub/v3/messages/${encodeURIComponent(args.messageId)}`),
39385
39320
  { label: "ofw-mcp", context: "GET /pub/v3/messages/{id} (ofw_get_message)" }
39386
39321
  );
39387
- const folder = cached2?.folder ?? "inbox";
39322
+ let folder = cached2?.folder ?? "inbox";
39323
+ if (!cached2) {
39324
+ const sentFolderId = await cache.getMeta("sent_folder_id");
39325
+ if (sentFolderId !== null && detail.folder?.id != null && String(detail.folder.id) === sentFolderId) {
39326
+ folder = "sent";
39327
+ }
39328
+ }
39388
39329
  const row = {
39389
39330
  id: detail.id,
39390
39331
  folder,
@@ -39398,11 +39339,11 @@ function registerMessageTools(server, client2) {
39398
39339
  chainRootId: cached2?.chainRootId ?? null,
39399
39340
  listData: cached2?.listData ?? detail
39400
39341
  };
39401
- upsertMessage(row);
39342
+ await cache.upsertMessage(row);
39402
39343
  if (Array.isArray(detail.files) && detail.files.length > 0) {
39403
- await fetchAttachmentMetaForMessage(client2, detail.id, detail.files);
39344
+ await fetchAttachmentMetaForMessage(client2, detail.id, detail.files, cache);
39404
39345
  }
39405
- const attachments = listAttachmentsForMessage(detail.id);
39346
+ const attachments = await cache.listAttachmentsForMessage(detail.id);
39406
39347
  return jsonResponse({ ...row, attachments });
39407
39348
  });
39408
39349
  if (allowSend) server.registerTool("ofw_send_message", {
@@ -39422,6 +39363,7 @@ function registerMessageTools(server, client2) {
39422
39363
  throw new Error(`messageId (${args.messageId}) and draftId (${args.draftId}) refer to different drafts; pass only one.`);
39423
39364
  }
39424
39365
  const draftRef = args.messageId ?? args.draftId;
39366
+ const cache = cacheProvider();
39425
39367
  let subject = args.subject;
39426
39368
  let body = args.body;
39427
39369
  let recipientIds = args.recipientIds;
@@ -39430,7 +39372,7 @@ function registerMessageTools(server, client2) {
39430
39372
  let draftFound = false;
39431
39373
  if (draftRef !== void 0) {
39432
39374
  draftLookupAttempted = true;
39433
- const draft = getDraft(draftRef);
39375
+ const draft = await cache.getDraft(draftRef);
39434
39376
  if (draft !== null) {
39435
39377
  draftFound = true;
39436
39378
  subject = subject ?? draft.subject;
@@ -39459,11 +39401,11 @@ function registerMessageTools(server, client2) {
39459
39401
  let chainRootId = null;
39460
39402
  let rewriteNote = null;
39461
39403
  if (requestedReplyTo !== null) {
39462
- resolvedReplyTo = findLatestReplyTip(requestedReplyTo);
39404
+ resolvedReplyTo = await cache.findLatestReplyTip(requestedReplyTo);
39463
39405
  if (resolvedReplyTo !== requestedReplyTo) {
39464
39406
  rewriteNote = `replyToId rewritten from ${requestedReplyTo} to ${resolvedReplyTo} (later reply in same thread found in sent cache).`;
39465
39407
  }
39466
- const parent = getMessage(resolvedReplyTo);
39408
+ const parent = await cache.getMessage(resolvedReplyTo);
39467
39409
  chainRootId = parent?.chainRootId ?? parent?.id ?? requestedReplyTo;
39468
39410
  }
39469
39411
  const myFileIDs = args.myFileIDs ?? [];
@@ -39493,10 +39435,10 @@ function registerMessageTools(server, client2) {
39493
39435
  chainRootId,
39494
39436
  listData: detail
39495
39437
  };
39496
- upsertMessage(persisted);
39438
+ await cache.upsertMessage(persisted);
39497
39439
  for (const fileId of myFileIDs) {
39498
- const existing = getAttachment(fileId);
39499
- upsertAttachmentForMessage({
39440
+ const existing = await cache.getAttachment(fileId);
39441
+ await cache.upsertAttachmentForMessage({
39500
39442
  fileId,
39501
39443
  fileName: existing?.fileName ?? `file-${fileId}`,
39502
39444
  label: existing?.label ?? existing?.fileName ?? `file-${fileId}`,
@@ -39513,7 +39455,7 @@ function registerMessageTools(server, client2) {
39513
39455
  unconfirmedNote = `WARNING: OFW's send response did not include a message id, so the send could not be confirmed. ${draftClause} ourfamilywizard.com to see whether the message went out before retrying.`;
39514
39456
  } else if (draftRef !== void 0) {
39515
39457
  await deleteOFWMessages(client2, [draftRef]);
39516
- deleteDraft(draftRef);
39458
+ await cache.deleteDraft(draftRef);
39517
39459
  }
39518
39460
  const responseObj = persisted ?? raw;
39519
39461
  const text = responseObj ? JSON.stringify(responseObj, null, 2) : "Message sent successfully.";
@@ -39532,7 +39474,7 @@ ${text}` : text);
39532
39474
  }, async (args) => {
39533
39475
  const page = args.page ?? 1;
39534
39476
  const size = args.size ?? 50;
39535
- const drafts = listDrafts({ page, size });
39477
+ const drafts = await cacheProvider().listDrafts({ page, size });
39536
39478
  const payload = drafts.length === 0 ? { drafts: [], note: "Cache empty. Call ofw_sync_messages to populate." } : { drafts };
39537
39479
  return jsonResponse(payload);
39538
39480
  });
@@ -39548,11 +39490,12 @@ ${text}` : text);
39548
39490
  myFileIDs: external_exports.array(external_exports.number()).describe("Attachment file ids (from ofw_upload_attachment)").optional()
39549
39491
  }
39550
39492
  }, async (args) => {
39493
+ const cache = cacheProvider();
39551
39494
  const requestedReplyTo = args.replyToId ?? null;
39552
39495
  let resolvedReplyTo = requestedReplyTo;
39553
39496
  let rewriteNote = null;
39554
39497
  if (requestedReplyTo !== null) {
39555
- resolvedReplyTo = findLatestReplyTip(requestedReplyTo);
39498
+ resolvedReplyTo = await cache.findLatestReplyTip(requestedReplyTo);
39556
39499
  if (resolvedReplyTo !== requestedReplyTo) {
39557
39500
  rewriteNote = `replyToId rewritten from ${requestedReplyTo} to ${resolvedReplyTo} (later reply in same thread found in sent cache).`;
39558
39501
  }
@@ -39587,11 +39530,11 @@ ${text}` : text);
39587
39530
  modifiedAt: detail.date?.dateTime ?? (/* @__PURE__ */ new Date()).toISOString(),
39588
39531
  listData: detail
39589
39532
  };
39590
- upsertDraft(persisted);
39533
+ await cache.upsertDraft(persisted);
39591
39534
  if (args.messageId !== void 0 && args.messageId !== newId) {
39592
39535
  try {
39593
39536
  await deleteOFWMessages(client2, [args.messageId]);
39594
- deleteDraft(args.messageId);
39537
+ await cache.deleteDraft(args.messageId);
39595
39538
  replaceNote = `NOTE: ofw_save_draft replaced draft ${args.messageId} via create-then-delete. The new draft id is ${newId}; the old draft has been deleted. (OFW's update-in-place endpoint silently no-ops on subsequent updates, so we never use it. If you cached the old id anywhere, replace it with the new one.)`;
39596
39539
  } catch (e) {
39597
39540
  replaceNote = `WARNING: New draft ${newId} created successfully, but failed to delete the old draft (${args.messageId}): ${e.message}. You may want to clean it up manually with ofw_delete_draft.`;
@@ -39613,7 +39556,7 @@ ${text}` : text);
39613
39556
  }
39614
39557
  }, async (args) => {
39615
39558
  const data = await deleteOFWMessages(client2, [args.messageId]);
39616
- deleteDraft(args.messageId);
39559
+ await cacheProvider().deleteDraft(args.messageId);
39617
39560
  return data ? jsonResponse(data) : textResponse("Draft deleted.");
39618
39561
  });
39619
39562
  server.registerTool("ofw_get_unread_sent", {
@@ -39626,7 +39569,7 @@ ${text}` : text);
39626
39569
  }, async (args) => {
39627
39570
  const page = args.page ?? 1;
39628
39571
  const size = args.size ?? 50;
39629
- const sent = listMessages({ folder: "sent", page, size });
39572
+ const sent = await cacheProvider().listMessages({ folder: "sent", page, size });
39630
39573
  if (sent.length === 0) {
39631
39574
  return jsonResponse({ note: "Sent cache is empty. Call ofw_sync_messages to populate." });
39632
39575
  }
@@ -39652,13 +39595,9 @@ ${text}` : text);
39652
39595
  description: external_exports.string().describe("Description shown in OFW My Files (default: filename)").optional()
39653
39596
  }
39654
39597
  }, async (args) => {
39655
- const abs = expandPath2(args.path);
39656
- const stat = statSync(abs);
39657
- if (!stat.isFile()) throw new Error(`Not a file: ${abs}`);
39658
- const fileName = basename(abs);
39659
- const mime = mimeFromName(fileName);
39598
+ const { blob, fileName, mimeType: mime, sizeBytes } = await attachmentIO.resolveUpload(args.path);
39660
39599
  const form = new FormData();
39661
- form.append("file", await fileBlob(abs, { type: mime }), fileName);
39600
+ form.append("file", blob, fileName);
39662
39601
  form.append("source", "message");
39663
39602
  form.append("description", args.description ?? fileName);
39664
39603
  form.append("label", args.label ?? fileName);
@@ -39669,12 +39608,12 @@ ${text}` : text);
39669
39608
  await client2.request("POST", "/pub/v3/myfiles/multipart", form),
39670
39609
  { label: "ofw-mcp", context: "POST /pub/v3/myfiles/multipart (ofw_upload_attachment)", mode: "strict" }
39671
39610
  );
39672
- upsertAttachmentForMessage({
39611
+ await cacheProvider().upsertAttachmentForMessage({
39673
39612
  fileId: meta3.fileId,
39674
39613
  fileName: meta3.fileName ?? fileName,
39675
39614
  label: meta3.label ?? args.label ?? fileName,
39676
39615
  mimeType: meta3.fileType ?? mime,
39677
- sizeBytes: typeof meta3.sizeInBytes === "number" ? meta3.sizeInBytes : stat.size,
39616
+ sizeBytes: typeof meta3.sizeInBytes === "number" ? meta3.sizeInBytes : sizeBytes,
39678
39617
  metadata: meta3,
39679
39618
  messageId: 0
39680
39619
  });
@@ -39682,7 +39621,7 @@ ${text}` : text);
39682
39621
  fileId: meta3.fileId,
39683
39622
  fileName: meta3.fileName ?? fileName,
39684
39623
  mimeType: meta3.fileType ?? mime,
39685
- sizeBytes: meta3.sizeInBytes ?? stat.size,
39624
+ sizeBytes: meta3.sizeInBytes ?? sizeBytes,
39686
39625
  shareClass: meta3.shareClass ?? args.shareClass ?? "PRIVATE",
39687
39626
  note: "Pass this fileId to ofw_send_message or ofw_save_draft in myFileIDs to attach it."
39688
39627
  });
@@ -39698,11 +39637,12 @@ ${text}` : text);
39698
39637
  }
39699
39638
  }, async (args) => {
39700
39639
  const fileId = args.fileId;
39640
+ const cache = cacheProvider();
39701
39641
  const inline = args.inline ?? getDefaultInlineAttachments();
39702
- let cached2 = getAttachment(fileId);
39642
+ let cached2 = await cache.getAttachment(fileId);
39703
39643
  if (!cached2) {
39704
- await fetchAttachmentMeta(client2, fileId, 0);
39705
- cached2 = getAttachment(fileId);
39644
+ await fetchAttachmentMeta(client2, fileId, 0, cache);
39645
+ cached2 = await cache.getAttachment(fileId);
39706
39646
  if (!cached2) throw new Error(`failed to fetch metadata for fileId ${fileId}`);
39707
39647
  }
39708
39648
  if (inline) {
@@ -39710,10 +39650,7 @@ ${text}` : text);
39710
39650
  let mimeType = cached2.mimeType;
39711
39651
  let fileName = cached2.fileName;
39712
39652
  if (cached2.downloadedPath) {
39713
- try {
39714
- bytes = readFileSync(cached2.downloadedPath);
39715
- } catch {
39716
- }
39653
+ bytes = attachmentIO.readDownloaded(cached2.downloadedPath);
39717
39654
  }
39718
39655
  if (bytes === null) {
39719
39656
  const response2 = await client2.requestBinary("GET", `/pub/v1/myfiles/${fileId}/data`);
@@ -39758,9 +39695,8 @@ ${text}` : text);
39758
39695
  });
39759
39696
  }
39760
39697
  const response = await client2.requestBinary("GET", `/pub/v1/myfiles/${fileId}/data`);
39761
- mkdirSync2(dirname3(dest), { recursive: true });
39762
- writeFileSync(dest, response.body);
39763
- markAttachmentDownloaded(fileId, dest);
39698
+ attachmentIO.writeDownload(dest, response.body);
39699
+ await cache.markAttachmentDownloaded(fileId, dest);
39764
39700
  return jsonResponse({
39765
39701
  fileId,
39766
39702
  path: dest,
@@ -39770,19 +39706,21 @@ ${text}` : text);
39770
39706
  });
39771
39707
  });
39772
39708
  server.registerTool("ofw_sync_messages", {
39773
- description: "Sync messages from OurFamilyWizard into the local cache. Returns counts per folder and a list of unread inbox messages whose bodies were NOT fetched (to avoid mark-as-read on OFW). Call ofw_get_message(id) on those to read them. Pass deep:true to walk all OFW pages instead of stopping at the first all-cached page (use to backfill suspected gaps).",
39709
+ description: "Sync messages from OurFamilyWizard into the local cache. Returns counts per folder and a list of unread inbox messages whose bodies were NOT fetched (to avoid mark-as-read on OFW). Call ofw_get_message(id) on those to read them. EVERY call re-checks the newest page first, so new messages are picked up promptly even while an old-history backfill is still running; only then does it spend what is left of its budget advancing that backfill. Pass deep:true to walk all OFW pages instead of stopping at the first all-cached page (use to backfill suspected gaps). Sync is BOUNDED and RESUMABLE: on hosted deployments a per-call OFW-request budget (env OFW_SYNC_MAX_REQUESTS, or the maxRequests argument) caps how far one call walks; when the budget is hit the response reports done:false with a note \u2014 call again with the SAME arguments to resume. done:false means older history is still being backfilled; it does NOT mean recent messages are missing. Local installs are unbounded by default (done is always true).",
39774
39710
  annotations: { readOnlyHint: false },
39775
39711
  inputSchema: {
39776
39712
  folders: external_exports.array(external_exports.enum(["inbox", "sent", "drafts"])).describe("Folders to sync (default: all three)").optional(),
39777
39713
  fetchUnreadBodies: external_exports.boolean().describe("If true, also fetch bodies for unread inbox messages (will mark them as read on OFW). Default false.").optional(),
39778
- deep: external_exports.boolean().describe("If true, walk every OFW page until empty regardless of cache state. Use to backfill gaps. Default false.").optional()
39714
+ deep: external_exports.boolean().describe("If true, walk every OFW page until empty regardless of cache state. Use to backfill gaps. Default false.").optional(),
39715
+ maxRequests: external_exports.number().int().min(1).describe("Maximum OFW requests this single call may make before pausing. When hit, the response reports done:false \u2014 call again with the same arguments to continue. Omit to use the server default (OFW_SYNC_MAX_REQUESTS, or unbounded on local installs).").optional()
39779
39716
  }
39780
39717
  }, async (args) => {
39781
39718
  const result = await syncAll(client2, {
39782
39719
  folders: args.folders,
39783
39720
  fetchUnreadBodies: args.fetchUnreadBodies,
39784
- deep: args.deep
39785
- });
39721
+ deep: args.deep,
39722
+ maxRequests: args.maxRequests ?? getSyncMaxRequests()
39723
+ }, cacheProvider());
39786
39724
  return jsonResponse(result);
39787
39725
  });
39788
39726
  }
@@ -39793,8 +39731,87 @@ async function deleteOFWMessages(client2, ids) {
39793
39731
  }
39794
39732
 
39795
39733
  // src/tools/calendar.ts
39734
+ var ofwDate = external_exports.looseObject({ dateTime: external_exports.string() });
39735
+ var userRef = external_exports.looseObject({ userId: external_exports.number() });
39736
+ var eventDetailSchema = external_exports.looseObject({
39737
+ eventRecurrenceId: external_exports.number(),
39738
+ title: external_exports.string(),
39739
+ allDay: external_exports.boolean(),
39740
+ publicFlag: external_exports.boolean(),
39741
+ startDate: ofwDate,
39742
+ endDate: ofwDate,
39743
+ location: external_exports.string().nullish(),
39744
+ notes: external_exports.string().nullish(),
39745
+ reminderMinutes: external_exports.number().nullish(),
39746
+ children: external_exports.array(userRef).nullish(),
39747
+ eventParent: userRef.nullish(),
39748
+ dropOffParent: userRef.nullish(),
39749
+ pickUpParent: userRef.nullish()
39750
+ });
39751
+ var eventWriteFields = {
39752
+ startDate: external_exports.string().describe("Start date YYYY-MM-DD"),
39753
+ endDate: external_exports.string().describe("End date YYYY-MM-DD (default: startDate)").optional(),
39754
+ startTime: external_exports.string().describe("Start time HH:mm, 24-hour (required unless allDay)").optional(),
39755
+ endTime: external_exports.string().describe("End time HH:mm, 24-hour (required unless allDay)").optional(),
39756
+ allDay: external_exports.boolean().optional(),
39757
+ privateEvent: external_exports.boolean().describe("true = visible only to you; default false = shared with co-parent").optional(),
39758
+ location: external_exports.string().optional(),
39759
+ notes: external_exports.string().optional(),
39760
+ reminderMinutes: external_exports.number().int().min(0).optional(),
39761
+ children: external_exports.array(external_exports.number()).describe("Child userIds to tag (see ofw_get_profile)").optional(),
39762
+ eventParentId: external_exports.number().describe("userId of the parent the event is 'for'").optional(),
39763
+ dropOffParentId: external_exports.number().describe("userId of the drop-off parent").optional(),
39764
+ pickUpParentId: external_exports.number().describe("userId of the pick-up parent").optional()
39765
+ };
39766
+ function buildEventPayload(a) {
39767
+ const allDay = a.allDay ?? false;
39768
+ if (!allDay && (!a.startTime || !a.endTime)) {
39769
+ throw new Error("startTime and endTime (HH:mm) are required unless allDay is true");
39770
+ }
39771
+ const payload = {
39772
+ title: a.title,
39773
+ startDate: a.startDate,
39774
+ endDate: a.endDate ?? a.startDate,
39775
+ // The web form always sends times; for all-day events it uses 01:00/02:00
39776
+ // placeholders that OFW ignores.
39777
+ startTime: a.startTime ?? "01:00",
39778
+ endTime: a.endTime ?? "02:00",
39779
+ allDay,
39780
+ publicFlag: !(a.privateEvent ?? false)
39781
+ };
39782
+ if (a.location) payload.location = a.location;
39783
+ if (a.notes) payload.notes = a.notes;
39784
+ if (a.reminderMinutes !== void 0) payload.reminderMinutes = String(a.reminderMinutes);
39785
+ if (a.children !== void 0) payload.children = a.children;
39786
+ if (a.eventParentId !== void 0) payload.eventParentId = String(a.eventParentId);
39787
+ if (a.dropOffParentId !== void 0) payload.dropOffParentId = String(a.dropOffParentId);
39788
+ if (a.pickUpParentId !== void 0) payload.pickUpParentId = String(a.pickUpParentId);
39789
+ return payload;
39790
+ }
39791
+ function detailToWriteArgs(d) {
39792
+ const [startDate, startClock] = d.startDate.dateTime.split("T");
39793
+ const [endDate, endClock] = d.endDate.dateTime.split("T");
39794
+ return {
39795
+ title: d.title,
39796
+ startDate,
39797
+ endDate,
39798
+ startTime: (startClock ?? "01:00:00").slice(0, 5),
39799
+ endTime: (endClock ?? "02:00:00").slice(0, 5),
39800
+ allDay: d.allDay,
39801
+ privateEvent: !d.publicFlag,
39802
+ location: d.location ?? void 0,
39803
+ notes: d.notes ?? void 0,
39804
+ reminderMinutes: d.reminderMinutes ?? void 0,
39805
+ // Untagged (nullish or empty) → undefined, so the merged PUT omits the
39806
+ // field (omission preserves; only a CALLER-supplied [] should clear).
39807
+ children: d.children?.length ? d.children.map((c) => c.userId) : void 0,
39808
+ eventParentId: d.eventParent?.userId,
39809
+ dropOffParentId: d.dropOffParent?.userId,
39810
+ pickUpParentId: d.pickUpParent?.userId
39811
+ };
39812
+ }
39796
39813
  function registerCalendarTools(server, client2) {
39797
- const allowWrites = getWriteMode() === "all";
39814
+ const allowWrites = getCalendarWritesAllowed();
39798
39815
  server.registerTool("ofw_list_events", {
39799
39816
  description: "List OurFamilyWizard calendar events in a date range",
39800
39817
  annotations: { readOnlyHint: true },
@@ -39812,51 +39829,62 @@ function registerCalendarTools(server, client2) {
39812
39829
  return jsonResponse(data);
39813
39830
  });
39814
39831
  if (allowWrites) server.registerTool("ofw_create_event", {
39815
- description: "Create a calendar event in OurFamilyWizard",
39832
+ description: "Create a calendar event in OurFamilyWizard. Unless privateEvent is true, the event is immediately visible to the co-parent \u2014 there is no draft stage.",
39816
39833
  annotations: { destructiveHint: false },
39817
39834
  inputSchema: {
39818
39835
  title: external_exports.string(),
39819
- startDate: external_exports.string().describe("ISO datetime string"),
39820
- endDate: external_exports.string().describe("ISO datetime string"),
39821
- allDay: external_exports.boolean().optional(),
39822
- location: external_exports.string().optional(),
39823
- reminder: external_exports.string().describe('Reminder setting (e.g. "1 hour before")').optional(),
39824
- privateEvent: external_exports.boolean().optional(),
39825
- eventFor: external_exports.string().describe("neither | parent1 | parent2").optional(),
39826
- dropOffParent: external_exports.string().optional(),
39827
- pickUpParent: external_exports.string().optional(),
39828
- children: external_exports.array(external_exports.number()).describe("Array of child IDs").optional()
39836
+ ...eventWriteFields
39829
39837
  }
39830
39838
  }, async (args) => {
39831
- const data = await client2.request("POST", "/pub/v1/calendar/events", args);
39832
- return jsonResponse(data);
39839
+ const raw = await client2.request("POST", "/pub/v3/events", buildEventPayload(args));
39840
+ const event = parseLenient(eventDetailSchema, raw, { label: "ofw-mcp", context: "POST /pub/v3/events", mode: "strict" });
39841
+ return jsonResponse({
39842
+ note: `Event created. Use eventRecurrenceId ${event.eventRecurrenceId} as eventId for ofw_update_event/ofw_delete_event.`,
39843
+ event
39844
+ });
39833
39845
  });
39834
39846
  if (allowWrites) server.registerTool("ofw_update_event", {
39835
- description: "Update an existing OurFamilyWizard calendar event",
39847
+ description: "Update an existing OurFamilyWizard calendar event. Fetches the event, applies the given changes, and writes the merged result back (OFW has no partial update).",
39836
39848
  annotations: { destructiveHint: true },
39837
39849
  inputSchema: {
39838
- eventId: external_exports.string(),
39850
+ eventId: external_exports.string().describe("Event id \u2014 the `id` from ofw_list_events / eventRecurrenceId from ofw_create_event"),
39839
39851
  title: external_exports.string().optional(),
39840
- startDate: external_exports.string().optional(),
39841
- endDate: external_exports.string().optional(),
39842
- allDay: external_exports.boolean().optional(),
39843
- location: external_exports.string().optional(),
39844
- reminder: external_exports.string().optional(),
39845
- privateEvent: external_exports.boolean().optional()
39852
+ startDate: eventWriteFields.startDate.optional(),
39853
+ endDate: eventWriteFields.endDate,
39854
+ startTime: eventWriteFields.startTime,
39855
+ endTime: eventWriteFields.endTime,
39856
+ allDay: eventWriteFields.allDay,
39857
+ privateEvent: eventWriteFields.privateEvent,
39858
+ location: eventWriteFields.location,
39859
+ notes: eventWriteFields.notes,
39860
+ reminderMinutes: eventWriteFields.reminderMinutes,
39861
+ children: external_exports.array(external_exports.number()).describe("Child userIds to tag; pass [] to remove all child tags (omit to keep current tags)").optional(),
39862
+ eventParentId: eventWriteFields.eventParentId,
39863
+ dropOffParentId: eventWriteFields.dropOffParentId,
39864
+ pickUpParentId: eventWriteFields.pickUpParentId
39846
39865
  }
39847
39866
  }, async (args) => {
39848
- const { eventId, ...updateData } = args;
39849
- const data = await client2.request("PUT", `/pub/v1/calendar/events/${encodeURIComponent(eventId)}`, updateData);
39850
- return jsonResponse(data);
39867
+ const { eventId, ...changes } = args;
39868
+ const id = encodeURIComponent(eventId);
39869
+ const rawDetail = await client2.request("GET", `/pub/v3/events/${id}`);
39870
+ const current = parseLenient(eventDetailSchema, rawDetail, { label: "ofw-mcp", context: `GET /pub/v3/events/${eventId}`, mode: "strict" });
39871
+ const defined = Object.fromEntries(Object.entries(changes).filter(([, v]) => v !== void 0));
39872
+ const merged = { ...detailToWriteArgs(current), ...defined };
39873
+ await client2.request("PUT", `/pub/v3/events/${id}`, buildEventPayload(merged));
39874
+ const rawAfter = await client2.request("GET", `/pub/v3/events/${id}`);
39875
+ const event = parseLenient(eventDetailSchema, rawAfter, { label: "ofw-mcp", context: `GET /pub/v3/events/${eventId} (post-update)`, mode: "strict" });
39876
+ return jsonResponse({ note: "Event updated; returning re-fetched event state.", event });
39851
39877
  });
39852
39878
  if (allowWrites) server.registerTool("ofw_delete_event", {
39853
39879
  description: "Delete an OurFamilyWizard calendar event",
39854
39880
  annotations: { destructiveHint: true },
39855
39881
  inputSchema: {
39856
- eventId: external_exports.string().describe("Event ID to delete")
39882
+ eventId: external_exports.string().describe("Event id \u2014 the `id` from ofw_list_events / eventRecurrenceId from ofw_create_event"),
39883
+ includeFuture: external_exports.boolean().describe("For repeating events: also delete future occurrences (default false)").optional()
39857
39884
  }
39858
39885
  }, async (args) => {
39859
- await client2.request("DELETE", `/pub/v1/calendar/events/${encodeURIComponent(args.eventId)}`);
39886
+ const includeFuture = args.includeFuture ?? false;
39887
+ await client2.request("DELETE", `/pub/v3/events/${encodeURIComponent(args.eventId)}?includeFuture=${includeFuture}`);
39860
39888
  return textResponse(`Event ${args.eventId} deleted`);
39861
39889
  });
39862
39890
  }
@@ -39926,6 +39954,584 @@ function registerJournalTools(server, client2) {
39926
39954
  });
39927
39955
  }
39928
39956
 
39957
+ // src/cache/node.ts
39958
+ import { DatabaseSync } from "node:sqlite";
39959
+ import { mkdirSync, chmodSync, existsSync } from "node:fs";
39960
+ import { dirname as dirname2 } from "node:path";
39961
+
39962
+ // src/cache/store.ts
39963
+ function rowFromDb(r) {
39964
+ return {
39965
+ id: r.id,
39966
+ folder: r.folder,
39967
+ subject: r.subject,
39968
+ fromUser: r.from_user,
39969
+ sentAt: r.sent_at,
39970
+ recipients: JSON.parse(r.recipients_json),
39971
+ body: r.body,
39972
+ fetchedBodyAt: r.fetched_body_at,
39973
+ replyToId: r.reply_to_id,
39974
+ chainRootId: r.chain_root_id,
39975
+ listData: JSON.parse(r.list_data_json)
39976
+ };
39977
+ }
39978
+ function draftFromDb(r) {
39979
+ return {
39980
+ id: r.id,
39981
+ subject: r.subject,
39982
+ body: r.body,
39983
+ recipients: JSON.parse(r.recipients_json),
39984
+ replyToId: r.reply_to_id,
39985
+ modifiedAt: r.modified_at,
39986
+ listData: JSON.parse(r.list_data_json)
39987
+ };
39988
+ }
39989
+ function attachmentFromDb(r) {
39990
+ return {
39991
+ fileId: r.file_id,
39992
+ fileName: r.file_name,
39993
+ label: r.label,
39994
+ mimeType: r.mime_type,
39995
+ sizeBytes: r.size_bytes,
39996
+ metadata: JSON.parse(r.metadata_json),
39997
+ messageIds: JSON.parse(r.message_ids_json),
39998
+ downloadedPath: r.downloaded_path,
39999
+ downloadedAt: r.downloaded_at
40000
+ };
40001
+ }
40002
+ function nullish3(v) {
40003
+ return v === void 0 ? null : v;
40004
+ }
40005
+ function requireString(field, v) {
40006
+ if (typeof v === "string") return v;
40007
+ throw new Error(`cache: ${field} is required (got ${v === void 0 ? "undefined" : "null"})`);
40008
+ }
40009
+ var SCHEMA_STATEMENTS = [
40010
+ `CREATE TABLE IF NOT EXISTS messages (
40011
+ id INTEGER PRIMARY KEY,
40012
+ folder TEXT NOT NULL,
40013
+ subject TEXT NOT NULL,
40014
+ from_user TEXT NOT NULL,
40015
+ sent_at TEXT NOT NULL,
40016
+ recipients_json TEXT NOT NULL,
40017
+ body TEXT,
40018
+ fetched_body_at TEXT,
40019
+ reply_to_id INTEGER,
40020
+ chain_root_id INTEGER,
40021
+ list_data_json TEXT NOT NULL,
40022
+ last_seen_at TEXT NOT NULL
40023
+ )`,
40024
+ `CREATE INDEX IF NOT EXISTS idx_messages_folder_sent_at ON messages(folder, sent_at DESC)`,
40025
+ `CREATE INDEX IF NOT EXISTS idx_messages_chain_root ON messages(chain_root_id)`,
40026
+ `CREATE TABLE IF NOT EXISTS drafts (
40027
+ id INTEGER PRIMARY KEY,
40028
+ subject TEXT NOT NULL,
40029
+ body TEXT NOT NULL,
40030
+ recipients_json TEXT NOT NULL,
40031
+ reply_to_id INTEGER,
40032
+ modified_at TEXT NOT NULL,
40033
+ list_data_json TEXT NOT NULL
40034
+ )`,
40035
+ `CREATE TABLE IF NOT EXISTS sync_state (
40036
+ folder TEXT PRIMARY KEY,
40037
+ last_sync_at TEXT NOT NULL,
40038
+ newest_id INTEGER
40039
+ )`,
40040
+ `CREATE TABLE IF NOT EXISTS meta (
40041
+ key TEXT PRIMARY KEY,
40042
+ value TEXT NOT NULL
40043
+ )`,
40044
+ // v2: attachments table. Idempotent — IF NOT EXISTS.
40045
+ `CREATE TABLE IF NOT EXISTS attachments (
40046
+ file_id INTEGER PRIMARY KEY,
40047
+ file_name TEXT NOT NULL,
40048
+ label TEXT NOT NULL,
40049
+ mime_type TEXT NOT NULL,
40050
+ size_bytes INTEGER,
40051
+ metadata_json TEXT NOT NULL,
40052
+ message_ids_json TEXT NOT NULL,
40053
+ downloaded_path TEXT,
40054
+ downloaded_at TEXT,
40055
+ fetched_metadata_at TEXT NOT NULL
40056
+ )`
40057
+ ];
40058
+ var MIGRATIONS = [
40059
+ // Resumable deep-sync cursor. Absent/NULL → SyncState.resumePage null.
40060
+ "ALTER TABLE sync_state ADD COLUMN resume_page INTEGER"
40061
+ ];
40062
+ var SCHEMA_VERSION = "2";
40063
+ function buildMessageFilter(opts) {
40064
+ const wheres = [];
40065
+ const params = [];
40066
+ if (opts.folder !== void 0) {
40067
+ wheres.push("folder = ?");
40068
+ params.push(opts.folder);
40069
+ }
40070
+ if (opts.since !== void 0) {
40071
+ wheres.push("sent_at >= ?");
40072
+ params.push(opts.since);
40073
+ }
40074
+ if (opts.until !== void 0) {
40075
+ wheres.push("sent_at < ?");
40076
+ params.push(opts.until);
40077
+ }
40078
+ if (opts.q !== void 0 && opts.q.length > 0) {
40079
+ const pattern = `%${opts.q}%`;
40080
+ wheres.push("(subject LIKE ? OR body LIKE ?)");
40081
+ params.push(pattern, pattern);
40082
+ }
40083
+ return {
40084
+ where: wheres.length > 0 ? `WHERE ${wheres.join(" AND ")}` : "",
40085
+ params
40086
+ };
40087
+ }
40088
+ var OFWCacheCore = class {
40089
+ constructor(db) {
40090
+ this.db = db;
40091
+ for (const stmt of SCHEMA_STATEMENTS) this.db.execScript(stmt);
40092
+ for (const stmt of MIGRATIONS) {
40093
+ try {
40094
+ this.db.execScript(stmt);
40095
+ } catch {
40096
+ }
40097
+ }
40098
+ this.db.run(
40099
+ "INSERT INTO meta(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value",
40100
+ ["schema_version", SCHEMA_VERSION]
40101
+ );
40102
+ }
40103
+ db;
40104
+ upsertMessage(row) {
40105
+ this.db.run(
40106
+ `INSERT INTO messages (
40107
+ id, folder, subject, from_user, sent_at, recipients_json,
40108
+ body, fetched_body_at, reply_to_id, chain_root_id, list_data_json, last_seen_at
40109
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
40110
+ ON CONFLICT(id) DO UPDATE SET
40111
+ folder=excluded.folder,
40112
+ subject=excluded.subject,
40113
+ from_user=excluded.from_user,
40114
+ sent_at=excluded.sent_at,
40115
+ recipients_json=excluded.recipients_json,
40116
+ body=excluded.body,
40117
+ fetched_body_at=excluded.fetched_body_at,
40118
+ reply_to_id=excluded.reply_to_id,
40119
+ chain_root_id=excluded.chain_root_id,
40120
+ list_data_json=excluded.list_data_json,
40121
+ last_seen_at=excluded.last_seen_at`,
40122
+ [
40123
+ row.id,
40124
+ requireString("messages.folder", row.folder),
40125
+ requireString("messages.subject", row.subject),
40126
+ requireString("messages.fromUser", row.fromUser),
40127
+ requireString("messages.sentAt", row.sentAt),
40128
+ JSON.stringify(row.recipients ?? []),
40129
+ nullish3(row.body),
40130
+ nullish3(row.fetchedBodyAt),
40131
+ nullish3(row.replyToId),
40132
+ nullish3(row.chainRootId),
40133
+ JSON.stringify(row.listData ?? null),
40134
+ (/* @__PURE__ */ new Date()).toISOString()
40135
+ ]
40136
+ );
40137
+ }
40138
+ /**
40139
+ * Batch upsert every row in a single transaction — one round-trip's worth of
40140
+ * work (crucial on the Durable Object backend, where each RPC is a subrequest).
40141
+ * Empty array is a no-op (no transaction opened).
40142
+ */
40143
+ upsertMessages(rows) {
40144
+ if (rows.length === 0) return;
40145
+ this.db.transaction(() => {
40146
+ for (const row of rows) this.upsertMessage(row);
40147
+ });
40148
+ }
40149
+ getMessage(id) {
40150
+ const r = this.db.get("SELECT * FROM messages WHERE id = ?", [id]);
40151
+ return r ? rowFromDb(r) : null;
40152
+ }
40153
+ /**
40154
+ * Batch read: one `SELECT ... WHERE id IN (...)` returning the present rows
40155
+ * (absent ids are simply omitted — order is not guaranteed). Empty ids returns
40156
+ * `[]` without querying.
40157
+ */
40158
+ getMessages(ids) {
40159
+ if (ids.length === 0) return [];
40160
+ const placeholders = ids.map(() => "?").join(", ");
40161
+ const rows = this.db.all(
40162
+ `SELECT * FROM messages WHERE id IN (${placeholders})`,
40163
+ ids
40164
+ );
40165
+ return rows.map(rowFromDb);
40166
+ }
40167
+ /**
40168
+ * Remove a row from the `messages` table. Used by syncDrafts to evict
40169
+ * stale rows that were cached when a draft was previously read through
40170
+ * `ofw_get_message` (which would have wrongly classified it as `inbox`)
40171
+ * — the drafts table is the authoritative source for that id now.
40172
+ */
40173
+ deleteMessage(id) {
40174
+ this.db.run("DELETE FROM messages WHERE id = ?", [id]);
40175
+ }
40176
+ listMessages(opts) {
40177
+ const { where, params } = buildMessageFilter(opts);
40178
+ const offset = (opts.page - 1) * opts.size;
40179
+ const rows = this.db.all(
40180
+ `SELECT * FROM messages ${where}
40181
+ ORDER BY sent_at DESC, id DESC
40182
+ LIMIT ? OFFSET ?`,
40183
+ [...params, opts.size, offset]
40184
+ );
40185
+ return rows.map(rowFromDb);
40186
+ }
40187
+ countMessages(opts) {
40188
+ const { where, params } = buildMessageFilter(opts);
40189
+ const r = this.db.get(`SELECT COUNT(*) as n FROM messages ${where}`, params);
40190
+ return r?.n ?? 0;
40191
+ }
40192
+ upsertDraft(row) {
40193
+ this.db.run(
40194
+ `INSERT INTO drafts (id, subject, body, recipients_json, reply_to_id, modified_at, list_data_json)
40195
+ VALUES (?, ?, ?, ?, ?, ?, ?)
40196
+ ON CONFLICT(id) DO UPDATE SET
40197
+ subject=excluded.subject,
40198
+ body=excluded.body,
40199
+ recipients_json=excluded.recipients_json,
40200
+ reply_to_id=excluded.reply_to_id,
40201
+ modified_at=excluded.modified_at,
40202
+ list_data_json=excluded.list_data_json`,
40203
+ [
40204
+ row.id,
40205
+ requireString("drafts.subject", row.subject),
40206
+ requireString("drafts.body", row.body),
40207
+ JSON.stringify(row.recipients ?? []),
40208
+ nullish3(row.replyToId),
40209
+ requireString("drafts.modifiedAt", row.modifiedAt),
40210
+ JSON.stringify(row.listData ?? null)
40211
+ ]
40212
+ );
40213
+ }
40214
+ /** Batch upsert every draft in a single transaction. Empty array is a no-op. */
40215
+ upsertDrafts(rows) {
40216
+ if (rows.length === 0) return;
40217
+ this.db.transaction(() => {
40218
+ for (const row of rows) this.upsertDraft(row);
40219
+ });
40220
+ }
40221
+ getDraft(id) {
40222
+ const r = this.db.get("SELECT * FROM drafts WHERE id = ?", [id]);
40223
+ return r ? draftFromDb(r) : null;
40224
+ }
40225
+ /**
40226
+ * Batch read: one `SELECT ... WHERE id IN (...)` returning the present drafts
40227
+ * (absent ids omitted — order not guaranteed). Empty ids returns `[]` without
40228
+ * querying.
40229
+ */
40230
+ getDrafts(ids) {
40231
+ if (ids.length === 0) return [];
40232
+ const placeholders = ids.map(() => "?").join(", ");
40233
+ const rows = this.db.all(
40234
+ `SELECT * FROM drafts WHERE id IN (${placeholders})`,
40235
+ ids
40236
+ );
40237
+ return rows.map(draftFromDb);
40238
+ }
40239
+ listDrafts(opts) {
40240
+ const offset = (opts.page - 1) * opts.size;
40241
+ const rows = this.db.all(
40242
+ "SELECT * FROM drafts ORDER BY modified_at DESC, id DESC LIMIT ? OFFSET ?",
40243
+ [opts.size, offset]
40244
+ );
40245
+ return rows.map(draftFromDb);
40246
+ }
40247
+ deleteDraft(id) {
40248
+ this.db.run("DELETE FROM drafts WHERE id = ?", [id]);
40249
+ }
40250
+ listDraftIds() {
40251
+ const rows = this.db.all("SELECT id FROM drafts", []);
40252
+ return rows.map((r) => r.id);
40253
+ }
40254
+ getSyncState(folder) {
40255
+ const r = this.db.get("SELECT last_sync_at, newest_id, resume_page FROM sync_state WHERE folder = ?", [folder]);
40256
+ if (!r) return null;
40257
+ return { lastSyncAt: r.last_sync_at, newestId: r.newest_id, resumePage: r.resume_page ?? null };
40258
+ }
40259
+ setSyncState(folder, state) {
40260
+ this.db.run(
40261
+ `INSERT INTO sync_state (folder, last_sync_at, newest_id, resume_page) VALUES (?, ?, ?, ?)
40262
+ ON CONFLICT(folder) DO UPDATE SET
40263
+ last_sync_at = excluded.last_sync_at,
40264
+ newest_id = excluded.newest_id,
40265
+ resume_page = excluded.resume_page`,
40266
+ [folder, state.lastSyncAt, nullish3(state.newestId), nullish3(state.resumePage)]
40267
+ );
40268
+ }
40269
+ getMeta(key) {
40270
+ const r = this.db.get("SELECT value FROM meta WHERE key = ?", [key]);
40271
+ return r ? r.value : null;
40272
+ }
40273
+ setMeta(key, value) {
40274
+ this.db.run(
40275
+ "INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value",
40276
+ [key, value]
40277
+ );
40278
+ }
40279
+ findLatestReplyTip(replyToId) {
40280
+ const parent = this.db.get("SELECT id, folder, chain_root_id FROM messages WHERE id = ?", [replyToId]);
40281
+ if (!parent) return replyToId;
40282
+ const chainRoot = parent.chain_root_id ?? parent.id;
40283
+ const tip = this.db.get(
40284
+ `SELECT id FROM messages
40285
+ WHERE folder = 'sent' AND chain_root_id = ?
40286
+ ORDER BY id DESC LIMIT 1`,
40287
+ [chainRoot]
40288
+ );
40289
+ return tip ? tip.id : replyToId;
40290
+ }
40291
+ getAttachment(fileId) {
40292
+ const r = this.db.get("SELECT * FROM attachments WHERE file_id = ?", [fileId]);
40293
+ return r ? attachmentFromDb(r) : null;
40294
+ }
40295
+ listAttachmentsForMessage(messageId) {
40296
+ const rows = this.db.all(
40297
+ `SELECT * FROM attachments
40298
+ WHERE EXISTS (SELECT 1 FROM json_each(message_ids_json) WHERE value = ?)
40299
+ ORDER BY file_id`,
40300
+ [messageId]
40301
+ );
40302
+ return rows.map(attachmentFromDb);
40303
+ }
40304
+ upsertAttachmentForMessage(input) {
40305
+ const existing = this.db.get("SELECT message_ids_json FROM attachments WHERE file_id = ?", [input.fileId]);
40306
+ const prior = existing ? JSON.parse(existing.message_ids_json) : [];
40307
+ let messageIds;
40308
+ if (input.messageId === 0) {
40309
+ messageIds = prior;
40310
+ } else if (prior.includes(input.messageId)) {
40311
+ messageIds = prior;
40312
+ } else {
40313
+ messageIds = [...prior, input.messageId];
40314
+ }
40315
+ this.db.run(
40316
+ `INSERT INTO attachments (
40317
+ file_id, file_name, label, mime_type, size_bytes,
40318
+ metadata_json, message_ids_json, fetched_metadata_at
40319
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
40320
+ ON CONFLICT(file_id) DO UPDATE SET
40321
+ file_name=excluded.file_name,
40322
+ label=excluded.label,
40323
+ mime_type=excluded.mime_type,
40324
+ size_bytes=excluded.size_bytes,
40325
+ metadata_json=excluded.metadata_json,
40326
+ message_ids_json=excluded.message_ids_json,
40327
+ fetched_metadata_at=excluded.fetched_metadata_at`,
40328
+ [
40329
+ input.fileId,
40330
+ requireString("attachments.fileName", input.fileName),
40331
+ requireString("attachments.label", input.label),
40332
+ requireString("attachments.mimeType", input.mimeType),
40333
+ nullish3(input.sizeBytes),
40334
+ JSON.stringify(input.metadata ?? null),
40335
+ JSON.stringify(messageIds),
40336
+ (/* @__PURE__ */ new Date()).toISOString()
40337
+ ]
40338
+ );
40339
+ }
40340
+ markAttachmentDownloaded(fileId, path) {
40341
+ this.db.run("UPDATE attachments SET downloaded_path = ?, downloaded_at = ? WHERE file_id = ?", [
40342
+ path,
40343
+ (/* @__PURE__ */ new Date()).toISOString(),
40344
+ fileId
40345
+ ]);
40346
+ }
40347
+ };
40348
+ var LocalCacheStore = class {
40349
+ constructor(core) {
40350
+ this.core = core;
40351
+ }
40352
+ core;
40353
+ async upsertMessage(row) {
40354
+ this.core.upsertMessage(row);
40355
+ }
40356
+ async upsertMessages(rows) {
40357
+ this.core.upsertMessages(rows);
40358
+ }
40359
+ async getMessage(id) {
40360
+ return this.core.getMessage(id);
40361
+ }
40362
+ async getMessages(ids) {
40363
+ return this.core.getMessages(ids);
40364
+ }
40365
+ async deleteMessage(id) {
40366
+ this.core.deleteMessage(id);
40367
+ }
40368
+ async listMessages(opts) {
40369
+ return this.core.listMessages(opts);
40370
+ }
40371
+ async countMessages(opts) {
40372
+ return this.core.countMessages(opts);
40373
+ }
40374
+ async upsertDraft(row) {
40375
+ this.core.upsertDraft(row);
40376
+ }
40377
+ async upsertDrafts(rows) {
40378
+ this.core.upsertDrafts(rows);
40379
+ }
40380
+ async getDraft(id) {
40381
+ return this.core.getDraft(id);
40382
+ }
40383
+ async getDrafts(ids) {
40384
+ return this.core.getDrafts(ids);
40385
+ }
40386
+ async listDrafts(opts) {
40387
+ return this.core.listDrafts(opts);
40388
+ }
40389
+ async deleteDraft(id) {
40390
+ this.core.deleteDraft(id);
40391
+ }
40392
+ async listDraftIds() {
40393
+ return this.core.listDraftIds();
40394
+ }
40395
+ async getSyncState(folder) {
40396
+ return this.core.getSyncState(folder);
40397
+ }
40398
+ async setSyncState(folder, state) {
40399
+ this.core.setSyncState(folder, state);
40400
+ }
40401
+ async getMeta(key) {
40402
+ return this.core.getMeta(key);
40403
+ }
40404
+ async setMeta(key, value) {
40405
+ this.core.setMeta(key, value);
40406
+ }
40407
+ async findLatestReplyTip(replyToId) {
40408
+ return this.core.findLatestReplyTip(replyToId);
40409
+ }
40410
+ async getAttachment(fileId) {
40411
+ return this.core.getAttachment(fileId);
40412
+ }
40413
+ async listAttachmentsForMessage(messageId) {
40414
+ return this.core.listAttachmentsForMessage(messageId);
40415
+ }
40416
+ async upsertAttachmentForMessage(input) {
40417
+ this.core.upsertAttachmentForMessage(input);
40418
+ }
40419
+ async markAttachmentDownloaded(fileId, path) {
40420
+ this.core.markAttachmentDownloaded(fileId, path);
40421
+ }
40422
+ };
40423
+
40424
+ // src/cache/node.ts
40425
+ var NodeSqlDriver = class {
40426
+ constructor(db) {
40427
+ this.db = db;
40428
+ }
40429
+ db;
40430
+ execScript(sql) {
40431
+ this.db.exec(sql);
40432
+ }
40433
+ run(sql, params) {
40434
+ this.db.prepare(sql).run(...params);
40435
+ }
40436
+ get(sql, params) {
40437
+ return this.db.prepare(sql).get(...params);
40438
+ }
40439
+ all(sql, params) {
40440
+ return this.db.prepare(sql).all(...params);
40441
+ }
40442
+ transaction(fn) {
40443
+ this.db.exec("BEGIN");
40444
+ try {
40445
+ fn();
40446
+ this.db.exec("COMMIT");
40447
+ } catch (e) {
40448
+ this.db.exec("ROLLBACK");
40449
+ throw e;
40450
+ }
40451
+ }
40452
+ };
40453
+ function enforceCachePermissions(dbPath) {
40454
+ chmodSync(dirname2(dbPath), 448);
40455
+ chmodSync(dbPath, 384);
40456
+ for (const sibling of [`${dbPath}-wal`, `${dbPath}-shm`]) {
40457
+ if (existsSync(sibling)) chmodSync(sibling, 384);
40458
+ }
40459
+ }
40460
+ var OFWCache = class _OFWCache extends LocalCacheStore {
40461
+ constructor(db, core) {
40462
+ super(core);
40463
+ this.db = db;
40464
+ }
40465
+ db;
40466
+ static open(path) {
40467
+ const memory = path === ":memory:";
40468
+ if (!memory) mkdirSync(dirname2(path), { recursive: true });
40469
+ const db = new DatabaseSync(path);
40470
+ if (!memory) enforceCachePermissions(path);
40471
+ db.exec("PRAGMA journal_mode = WAL");
40472
+ db.exec("PRAGMA foreign_keys = ON");
40473
+ const core = new OFWCacheCore(new NodeSqlDriver(db));
40474
+ if (!memory) enforceCachePermissions(path);
40475
+ return new _OFWCache(db, core);
40476
+ }
40477
+ close() {
40478
+ this.db.close();
40479
+ }
40480
+ };
40481
+
40482
+ // src/tools/attachments.ts
40483
+ import { readFileSync, statSync, mkdirSync as mkdirSync2, writeFileSync } from "node:fs";
40484
+ import { basename as basename2, dirname as dirname3, extname } from "node:path";
40485
+ var MIME_BY_EXT = {
40486
+ ".pdf": "application/pdf",
40487
+ ".png": "image/png",
40488
+ ".jpg": "image/jpeg",
40489
+ ".jpeg": "image/jpeg",
40490
+ ".gif": "image/gif",
40491
+ ".webp": "image/webp",
40492
+ ".heic": "image/heic",
40493
+ ".txt": "text/plain",
40494
+ ".md": "text/markdown",
40495
+ ".csv": "text/csv",
40496
+ ".html": "text/html",
40497
+ ".htm": "text/html",
40498
+ ".json": "application/json",
40499
+ ".xml": "application/xml",
40500
+ ".doc": "application/msword",
40501
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
40502
+ ".xls": "application/vnd.ms-excel",
40503
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
40504
+ ".ppt": "application/vnd.ms-powerpoint",
40505
+ ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
40506
+ ".zip": "application/zip",
40507
+ ".ics": "text/calendar"
40508
+ };
40509
+ function mimeFromName(name) {
40510
+ return MIME_BY_EXT[extname(name).toLowerCase()] ?? "application/octet-stream";
40511
+ }
40512
+ var NodeAttachmentIO = class {
40513
+ async resolveUpload(path) {
40514
+ const abs = expandPath(path);
40515
+ const stat = statSync(abs);
40516
+ if (!stat.isFile()) throw new Error(`Not a file: ${abs}`);
40517
+ const fileName = basename2(abs);
40518
+ const mimeType = mimeFromName(fileName);
40519
+ const blob = await fileBlob(abs, { type: mimeType });
40520
+ return { blob, fileName, mimeType, sizeBytes: stat.size };
40521
+ }
40522
+ readDownloaded(path) {
40523
+ try {
40524
+ return readFileSync(path);
40525
+ } catch {
40526
+ return null;
40527
+ }
40528
+ }
40529
+ writeDownload(dest, bytes) {
40530
+ mkdirSync2(dirname3(dest), { recursive: true });
40531
+ writeFileSync(dest, bytes);
40532
+ }
40533
+ };
40534
+
39929
40535
  // src/index.ts
39930
40536
  var originalEmit = process.emit.bind(process);
39931
40537
  process.emit = function(event, ...args) {
@@ -39937,14 +40543,17 @@ process.emit = function(event, ...args) {
39937
40543
  }
39938
40544
  return originalEmit(event, ...args);
39939
40545
  };
40546
+ var nodeCache;
40547
+ var nodeCacheProvider = () => nodeCache ??= OFWCache.open(getCacheDbPath());
40548
+ var nodeAttachmentIO = new NodeAttachmentIO();
39940
40549
  await runMcp({
39941
40550
  name: "ofw",
39942
- version: "2.4.4",
40551
+ version: "2.6.3",
39943
40552
  // x-release-please-version
39944
40553
  deps: client,
39945
40554
  tools: [
39946
40555
  registerUserTools,
39947
- registerMessageTools,
40556
+ (server, deps) => registerMessageTools(server, deps, nodeCacheProvider, nodeAttachmentIO),
39948
40557
  registerCalendarTools,
39949
40558
  registerExpenseTools,
39950
40559
  registerJournalTools