ofw-mcp 2.9.2 → 2.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/bundle.js +223 -71
- package/dist/index.js +1 -1
- package/dist/sync.js +7 -2
- package/dist/tools/_shared.js +25 -0
- package/dist/tools/draft-freshness.js +13 -2
- package/dist/tools/lifecycle.js +38 -12
- package/dist/tools/messages.js +264 -79
- package/package.json +1 -1
- package/server.json +2 -2
- package/skills/ofw/SKILL.md +5 -5
package/dist/tools/_shared.js
CHANGED
|
@@ -71,6 +71,31 @@ export function mapRecipients(items) {
|
|
|
71
71
|
export function hasRealView(recipients) {
|
|
72
72
|
return recipients.some((r) => r.viewedAt !== null && !r.viewedAt.startsWith('1970-01-01'));
|
|
73
73
|
}
|
|
74
|
+
/** The reply target OFW actually reports, whichever field it chose to put it in. */
|
|
75
|
+
export function threadedReplyTo(detail) {
|
|
76
|
+
return detail.replyToId ?? detail.inReplyTo ?? null;
|
|
77
|
+
}
|
|
78
|
+
/** True when the payload positively reports the message as threaded. */
|
|
79
|
+
export function reportsThreaded(detail) {
|
|
80
|
+
return threadedReplyTo(detail) !== null || detail.showContext === true;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* True when the payload POSITIVELY reports the message as unthreaded — the
|
|
84
|
+
* evidence bar a "reply linkage was dropped" warning must clear.
|
|
85
|
+
*
|
|
86
|
+
* Only `inReplyTo` and `showContext` count as evidence, because those are the
|
|
87
|
+
* fields OFW actually signals threading with. A present-but-null `replyToId`
|
|
88
|
+
* is NOT evidence: OFW routinely emits `replyToId: null` on items that ARE
|
|
89
|
+
* threaded (their linkage lives in `inReplyTo`), so treating it as a
|
|
90
|
+
* disconfirmation manufactures a false UNTHREADED warning on exactly OFW's
|
|
91
|
+
* normal shape. Total absence of all three fields is "not echoed", never
|
|
92
|
+
* "dropped".
|
|
93
|
+
*/
|
|
94
|
+
export function reportsUnthreaded(detail) {
|
|
95
|
+
if (reportsThreaded(detail))
|
|
96
|
+
return false;
|
|
97
|
+
return detail.inReplyTo !== undefined || detail.showContext !== undefined;
|
|
98
|
+
}
|
|
74
99
|
// True when the once-scraped list flags themselves say the message is read.
|
|
75
100
|
// `showNeverViewed === false` is OFW's reliable "has been viewed" signal (per
|
|
76
101
|
// CLAUDE.md); `read === true` is the inbox list's own flag. Both are only ever
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { parseLenient } from '@chrischall/mcp-utils';
|
|
3
|
-
import { ApiRecipientSchema, mapRecipients } from './_shared.js';
|
|
3
|
+
import { ApiRecipientSchema, mapRecipients, threadedReplyTo } from './_shared.js';
|
|
4
4
|
/** Thrown when the freshness check itself could not be completed. */
|
|
5
5
|
export class DraftFreshnessError extends Error {
|
|
6
6
|
}
|
|
@@ -37,8 +37,18 @@ export function draftRevision(d) {
|
|
|
37
37
|
const ServerDraftSchema = z.looseObject({
|
|
38
38
|
subject: z.string().optional(),
|
|
39
39
|
body: z.string().optional(),
|
|
40
|
+
// BOTH spellings of the threading echo (see ThreadingEcho in _shared.ts):
|
|
41
|
+
// OFW reports the reply target as `replyToId` on some payloads and as
|
|
42
|
+
// `inReplyTo` on others. The snapshot derives one value from whichever is
|
|
43
|
+
// present, so the revision hashed here matches the one ofw_save_draft
|
|
44
|
+
// computed from the same server state — a one-sided read produced revisions
|
|
45
|
+
// that disagreed about the same draft.
|
|
40
46
|
replyToId: z.number().nullable().optional(),
|
|
47
|
+
inReplyTo: z.number().nullable().optional(),
|
|
41
48
|
recipients: z.array(ApiRecipientSchema).optional(),
|
|
49
|
+
// Attachment fileIds — read so send-by-draft carries the draft's
|
|
50
|
+
// attachments onto the sent message (see DraftContent.files).
|
|
51
|
+
files: z.array(z.number()).optional(),
|
|
42
52
|
// Read for the LIFECYCLE answer (see tools/lifecycle.ts): which folder OFW
|
|
43
53
|
// itself says this id lives in right now. `existsOnServer` alone cannot
|
|
44
54
|
// distinguish "still a draft" from "was sent" — a sent draft still exists.
|
|
@@ -96,8 +106,9 @@ export async function fetchMessageSnapshot(client, id) {
|
|
|
96
106
|
content: {
|
|
97
107
|
subject: detail.subject ?? '',
|
|
98
108
|
body: detail.body ?? '',
|
|
99
|
-
replyToId: detail
|
|
109
|
+
replyToId: threadedReplyTo(detail),
|
|
100
110
|
recipients: mapRecipients(detail.recipients),
|
|
111
|
+
...(detail.files !== undefined ? { files: detail.files } : {}),
|
|
101
112
|
},
|
|
102
113
|
folderId: detail.folder?.id === undefined ? null : String(detail.folder.id),
|
|
103
114
|
folderName: detail.folder?.name ?? null,
|
package/dist/tools/lifecycle.js
CHANGED
|
@@ -61,25 +61,51 @@ export async function ensureFolderIdMap(client, store) {
|
|
|
61
61
|
return { map: cached, requests: 1 };
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* OFW's own display names for the three system folders, as observed on live
|
|
66
|
+
* detail payloads (`folder.name`). Keys are lower-cased for the comparison —
|
|
67
|
+
* this is a fixed vocabulary OFW controls, not user content, so a
|
|
68
|
+
* case-insensitive match is a normalization, not a guess. A Map, not a plain
|
|
69
|
+
* object: an object lookup reaches `Object.prototype`, so a folder named
|
|
70
|
+
* "constructor" would come back as a function and silently vanish from the
|
|
71
|
+
* JSON payload instead of classifying as `unknown`.
|
|
72
|
+
*/
|
|
73
|
+
const STATE_BY_FOLDER_NAME = new Map([
|
|
74
|
+
['drafts', 'draft'],
|
|
75
|
+
['sent', 'sent'],
|
|
76
|
+
['sent messages', 'sent'],
|
|
77
|
+
['inbox', 'received'],
|
|
78
|
+
]);
|
|
64
79
|
/**
|
|
65
80
|
* Map a live snapshot to a lifecycle state. Pure — the request already happened.
|
|
66
81
|
*
|
|
67
|
-
* A null snapshot means OFW returned 404 / an empty body, i.e. `deleted`.
|
|
68
|
-
*
|
|
69
|
-
*
|
|
82
|
+
* A null snapshot means OFW returned 404 / an empty body, i.e. `deleted`. The
|
|
83
|
+
* folder ID (matched against the persisted system-folder map) is the primary
|
|
84
|
+
* signal; when the id is unreported or unmapped, the folder NAME OFW itself put
|
|
85
|
+
* on the payload is a fallback with exactly the same authority — a snapshot
|
|
86
|
+
* reporting `folder.name: "Drafts"` answered the question, and declaring it
|
|
87
|
+
* `unknown` was a refusal to read the answer (observed live: every draft probe
|
|
88
|
+
* came back "unknown" while echoing the name "Drafts"). Only a folder that is
|
|
89
|
+
* unmappable by BOTH id and name is `unknown`: guessing beyond that is exactly
|
|
90
|
+
* how "still a draft" gets asserted about something that was sent.
|
|
70
91
|
*/
|
|
71
92
|
export function classifyState(snapshot, map) {
|
|
72
93
|
if (snapshot === null)
|
|
73
94
|
return 'deleted';
|
|
74
|
-
const { folderId } = snapshot;
|
|
75
|
-
if (folderId
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
95
|
+
const { folderId, folderName } = snapshot;
|
|
96
|
+
if (folderId !== null) {
|
|
97
|
+
if (map.drafts !== null && folderId === map.drafts)
|
|
98
|
+
return 'draft';
|
|
99
|
+
if (map.sent !== null && folderId === map.sent)
|
|
100
|
+
return 'sent';
|
|
101
|
+
if (map.inbox !== null && folderId === map.inbox)
|
|
102
|
+
return 'received';
|
|
103
|
+
}
|
|
104
|
+
if (folderName !== null) {
|
|
105
|
+
const byName = STATE_BY_FOLDER_NAME.get(folderName.trim().toLowerCase());
|
|
106
|
+
if (byName !== undefined)
|
|
107
|
+
return byName;
|
|
108
|
+
}
|
|
83
109
|
return 'unknown';
|
|
84
110
|
}
|
|
85
111
|
/**
|