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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +18 -3
- package/dist/auth-password.js +8 -1
- package/dist/bundle.js +1177 -568
- package/dist/cache/node.js +85 -0
- package/dist/cache/store.js +480 -0
- package/dist/client.js +22 -4
- package/dist/config.js +42 -0
- package/dist/index.js +13 -2
- package/dist/ofw-auth.js +26 -0
- package/dist/sync.js +258 -61
- package/dist/tools/_shared.js +23 -6
- package/dist/tools/attachments.js +66 -0
- package/dist/tools/calendar.js +145 -29
- package/dist/tools/messages.js +95 -83
- package/package.json +14 -5
- package/server.json +8 -2
- package/skills/ofw/SKILL.md +2 -0
- package/skills/ofw-fpx/SKILL.md +106 -0
- package/skills/ofw-fpx/references/requests.md +252 -0
- package/dist/cache.js +0 -345
package/dist/cache.js
DELETED
|
@@ -1,345 +0,0 @@
|
|
|
1
|
-
import { DatabaseSync } from 'node:sqlite';
|
|
2
|
-
import { mkdirSync, chmodSync, existsSync } from 'node:fs';
|
|
3
|
-
import { dirname } from 'node:path';
|
|
4
|
-
import { getCacheDbPath } from './config.js';
|
|
5
|
-
let instance = null;
|
|
6
|
-
const SCHEMA_V1 = `
|
|
7
|
-
CREATE TABLE IF NOT EXISTS messages (
|
|
8
|
-
id INTEGER PRIMARY KEY,
|
|
9
|
-
folder TEXT NOT NULL,
|
|
10
|
-
subject TEXT NOT NULL,
|
|
11
|
-
from_user TEXT NOT NULL,
|
|
12
|
-
sent_at TEXT NOT NULL,
|
|
13
|
-
recipients_json TEXT NOT NULL,
|
|
14
|
-
body TEXT,
|
|
15
|
-
fetched_body_at TEXT,
|
|
16
|
-
reply_to_id INTEGER,
|
|
17
|
-
chain_root_id INTEGER,
|
|
18
|
-
list_data_json TEXT NOT NULL,
|
|
19
|
-
last_seen_at TEXT NOT NULL
|
|
20
|
-
);
|
|
21
|
-
CREATE INDEX IF NOT EXISTS idx_messages_folder_sent_at ON messages(folder, sent_at DESC);
|
|
22
|
-
CREATE INDEX IF NOT EXISTS idx_messages_chain_root ON messages(chain_root_id);
|
|
23
|
-
|
|
24
|
-
CREATE TABLE IF NOT EXISTS drafts (
|
|
25
|
-
id INTEGER PRIMARY KEY,
|
|
26
|
-
subject TEXT NOT NULL,
|
|
27
|
-
body TEXT NOT NULL,
|
|
28
|
-
recipients_json TEXT NOT NULL,
|
|
29
|
-
reply_to_id INTEGER,
|
|
30
|
-
modified_at TEXT NOT NULL,
|
|
31
|
-
list_data_json TEXT NOT NULL
|
|
32
|
-
);
|
|
33
|
-
|
|
34
|
-
CREATE TABLE IF NOT EXISTS sync_state (
|
|
35
|
-
folder TEXT PRIMARY KEY,
|
|
36
|
-
last_sync_at TEXT NOT NULL,
|
|
37
|
-
newest_id INTEGER
|
|
38
|
-
);
|
|
39
|
-
|
|
40
|
-
CREATE TABLE IF NOT EXISTS meta (
|
|
41
|
-
key TEXT PRIMARY KEY,
|
|
42
|
-
value TEXT NOT NULL
|
|
43
|
-
);
|
|
44
|
-
`;
|
|
45
|
-
// v2: add attachments table. Idempotent — IF NOT EXISTS.
|
|
46
|
-
const SCHEMA_V2 = `
|
|
47
|
-
CREATE TABLE IF NOT EXISTS attachments (
|
|
48
|
-
file_id INTEGER PRIMARY KEY,
|
|
49
|
-
file_name TEXT NOT NULL,
|
|
50
|
-
label TEXT NOT NULL,
|
|
51
|
-
mime_type TEXT NOT NULL,
|
|
52
|
-
size_bytes INTEGER,
|
|
53
|
-
metadata_json TEXT NOT NULL,
|
|
54
|
-
message_ids_json TEXT NOT NULL, -- JSON array of message ids that reference this file
|
|
55
|
-
downloaded_path TEXT, -- absolute path on disk if/when downloaded
|
|
56
|
-
downloaded_at TEXT,
|
|
57
|
-
fetched_metadata_at TEXT NOT NULL
|
|
58
|
-
);
|
|
59
|
-
`;
|
|
60
|
-
function migrate(db) {
|
|
61
|
-
db.exec(SCHEMA_V1);
|
|
62
|
-
db.exec(SCHEMA_V2);
|
|
63
|
-
db.prepare('INSERT INTO meta(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value').run('schema_version', '2');
|
|
64
|
-
}
|
|
65
|
-
// The cache holds full co-parenting message history — keep it private to the
|
|
66
|
-
// owning user. Modes are asserted on every open (not just creation): mkdirSync
|
|
67
|
-
// `mode` and SQLite's default file mode only apply when the path is first
|
|
68
|
-
// created, so a pre-existing dir/db keeps whatever (world-readable) mode it
|
|
69
|
-
// had. The -wal/-shm siblings appear and disappear with WAL checkpoints, hence
|
|
70
|
-
// the existence check.
|
|
71
|
-
function enforceCachePermissions(dbPath) {
|
|
72
|
-
chmodSync(dirname(dbPath), 0o700);
|
|
73
|
-
chmodSync(dbPath, 0o600);
|
|
74
|
-
for (const sibling of [`${dbPath}-wal`, `${dbPath}-shm`]) {
|
|
75
|
-
if (existsSync(sibling))
|
|
76
|
-
chmodSync(sibling, 0o600);
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
export function openCache() {
|
|
80
|
-
if (instance)
|
|
81
|
-
return instance;
|
|
82
|
-
const path = getCacheDbPath();
|
|
83
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
84
|
-
const db = new DatabaseSync(path);
|
|
85
|
-
// First pass: lock down dir + db before WAL siblings exist.
|
|
86
|
-
enforceCachePermissions(path);
|
|
87
|
-
db.exec('PRAGMA journal_mode = WAL');
|
|
88
|
-
db.exec('PRAGMA foreign_keys = ON');
|
|
89
|
-
migrate(db);
|
|
90
|
-
// Second pass: the migration writes created -wal/-shm — lock those down too.
|
|
91
|
-
enforceCachePermissions(path);
|
|
92
|
-
instance = { db };
|
|
93
|
-
return instance;
|
|
94
|
-
}
|
|
95
|
-
export function closeCache() {
|
|
96
|
-
if (instance) {
|
|
97
|
-
instance.db.close();
|
|
98
|
-
instance = null;
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
function rowFromDb(r) {
|
|
102
|
-
return {
|
|
103
|
-
id: r.id,
|
|
104
|
-
folder: r.folder,
|
|
105
|
-
subject: r.subject,
|
|
106
|
-
fromUser: r.from_user,
|
|
107
|
-
sentAt: r.sent_at,
|
|
108
|
-
recipients: JSON.parse(r.recipients_json),
|
|
109
|
-
body: r.body,
|
|
110
|
-
fetchedBodyAt: r.fetched_body_at,
|
|
111
|
-
replyToId: r.reply_to_id,
|
|
112
|
-
chainRootId: r.chain_root_id,
|
|
113
|
-
listData: JSON.parse(r.list_data_json),
|
|
114
|
-
};
|
|
115
|
-
}
|
|
116
|
-
// node:sqlite rejects `undefined` as a bound parameter ("Provided value cannot
|
|
117
|
-
// be bound"). Normalize undefined to null for nullable columns so callers
|
|
118
|
-
// don't have to remember; throw with a useful error for NOT NULL fields that
|
|
119
|
-
// somehow arrived as undefined.
|
|
120
|
-
function nullish(v) {
|
|
121
|
-
return v === undefined ? null : v;
|
|
122
|
-
}
|
|
123
|
-
function requireString(field, v) {
|
|
124
|
-
if (typeof v === 'string')
|
|
125
|
-
return v;
|
|
126
|
-
throw new Error(`cache: ${field} is required (got ${v === undefined ? 'undefined' : 'null'})`);
|
|
127
|
-
}
|
|
128
|
-
export function upsertMessage(row) {
|
|
129
|
-
const { db } = openCache();
|
|
130
|
-
db.prepare(`INSERT INTO messages (
|
|
131
|
-
id, folder, subject, from_user, sent_at, recipients_json,
|
|
132
|
-
body, fetched_body_at, reply_to_id, chain_root_id, list_data_json, last_seen_at
|
|
133
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
134
|
-
ON CONFLICT(id) DO UPDATE SET
|
|
135
|
-
folder=excluded.folder,
|
|
136
|
-
subject=excluded.subject,
|
|
137
|
-
from_user=excluded.from_user,
|
|
138
|
-
sent_at=excluded.sent_at,
|
|
139
|
-
recipients_json=excluded.recipients_json,
|
|
140
|
-
body=excluded.body,
|
|
141
|
-
fetched_body_at=excluded.fetched_body_at,
|
|
142
|
-
reply_to_id=excluded.reply_to_id,
|
|
143
|
-
chain_root_id=excluded.chain_root_id,
|
|
144
|
-
list_data_json=excluded.list_data_json,
|
|
145
|
-
last_seen_at=excluded.last_seen_at`).run(row.id, requireString('messages.folder', row.folder), requireString('messages.subject', row.subject), requireString('messages.fromUser', row.fromUser), requireString('messages.sentAt', row.sentAt), JSON.stringify(row.recipients ?? []), nullish(row.body), nullish(row.fetchedBodyAt), nullish(row.replyToId), nullish(row.chainRootId), JSON.stringify(row.listData ?? null), new Date().toISOString());
|
|
146
|
-
}
|
|
147
|
-
export function getMessage(id) {
|
|
148
|
-
const { db } = openCache();
|
|
149
|
-
const r = db.prepare('SELECT * FROM messages WHERE id = ?').get(id);
|
|
150
|
-
return r ? rowFromDb(r) : null;
|
|
151
|
-
}
|
|
152
|
-
/**
|
|
153
|
-
* Remove a row from the `messages` table. Used by syncDrafts to evict
|
|
154
|
-
* stale rows that were cached when a draft was previously read through
|
|
155
|
-
* `ofw_get_message` (which would have wrongly classified it as `inbox`)
|
|
156
|
-
* — the drafts table is the authoritative source for that id now.
|
|
157
|
-
*/
|
|
158
|
-
export function deleteMessage(id) {
|
|
159
|
-
const { db } = openCache();
|
|
160
|
-
db.prepare('DELETE FROM messages WHERE id = ?').run(id);
|
|
161
|
-
}
|
|
162
|
-
// Build the WHERE clause + bound params for message queries. listMessages and
|
|
163
|
-
// countMessages share this so the filter semantics can't drift.
|
|
164
|
-
function buildMessageFilter(opts) {
|
|
165
|
-
const wheres = [];
|
|
166
|
-
const params = [];
|
|
167
|
-
if (opts.folder !== undefined) {
|
|
168
|
-
wheres.push('folder = ?');
|
|
169
|
-
params.push(opts.folder);
|
|
170
|
-
}
|
|
171
|
-
if (opts.since !== undefined) {
|
|
172
|
-
wheres.push('sent_at >= ?');
|
|
173
|
-
params.push(opts.since);
|
|
174
|
-
}
|
|
175
|
-
if (opts.until !== undefined) {
|
|
176
|
-
wheres.push('sent_at < ?');
|
|
177
|
-
params.push(opts.until);
|
|
178
|
-
}
|
|
179
|
-
if (opts.q !== undefined && opts.q.length > 0) {
|
|
180
|
-
const pattern = `%${opts.q}%`;
|
|
181
|
-
wheres.push('(subject LIKE ? OR body LIKE ?)');
|
|
182
|
-
params.push(pattern, pattern);
|
|
183
|
-
}
|
|
184
|
-
return {
|
|
185
|
-
where: wheres.length > 0 ? `WHERE ${wheres.join(' AND ')}` : '',
|
|
186
|
-
params,
|
|
187
|
-
};
|
|
188
|
-
}
|
|
189
|
-
export function listMessages(opts) {
|
|
190
|
-
const { db } = openCache();
|
|
191
|
-
const { where, params } = buildMessageFilter(opts);
|
|
192
|
-
const offset = (opts.page - 1) * opts.size;
|
|
193
|
-
const rows = db.prepare(`SELECT * FROM messages ${where}
|
|
194
|
-
ORDER BY sent_at DESC, id DESC
|
|
195
|
-
LIMIT ? OFFSET ?`).all(...params, opts.size, offset);
|
|
196
|
-
return rows.map(rowFromDb);
|
|
197
|
-
}
|
|
198
|
-
export function countMessages(opts) {
|
|
199
|
-
const { db } = openCache();
|
|
200
|
-
const { where, params } = buildMessageFilter(opts);
|
|
201
|
-
const r = db.prepare(`SELECT COUNT(*) as n FROM messages ${where}`)
|
|
202
|
-
.get(...params);
|
|
203
|
-
/* v8 ignore next -- SELECT COUNT(*) always returns exactly one row; the ?./?? are defensive */
|
|
204
|
-
return r?.n ?? 0;
|
|
205
|
-
}
|
|
206
|
-
function draftFromDb(r) {
|
|
207
|
-
return {
|
|
208
|
-
id: r.id,
|
|
209
|
-
subject: r.subject,
|
|
210
|
-
body: r.body,
|
|
211
|
-
recipients: JSON.parse(r.recipients_json),
|
|
212
|
-
replyToId: r.reply_to_id,
|
|
213
|
-
modifiedAt: r.modified_at,
|
|
214
|
-
listData: JSON.parse(r.list_data_json),
|
|
215
|
-
};
|
|
216
|
-
}
|
|
217
|
-
export function upsertDraft(row) {
|
|
218
|
-
const { db } = openCache();
|
|
219
|
-
db.prepare(`INSERT INTO drafts (id, subject, body, recipients_json, reply_to_id, modified_at, list_data_json)
|
|
220
|
-
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
221
|
-
ON CONFLICT(id) DO UPDATE SET
|
|
222
|
-
subject=excluded.subject,
|
|
223
|
-
body=excluded.body,
|
|
224
|
-
recipients_json=excluded.recipients_json,
|
|
225
|
-
reply_to_id=excluded.reply_to_id,
|
|
226
|
-
modified_at=excluded.modified_at,
|
|
227
|
-
list_data_json=excluded.list_data_json`).run(row.id, requireString('drafts.subject', row.subject), requireString('drafts.body', row.body), JSON.stringify(row.recipients ?? []), nullish(row.replyToId), requireString('drafts.modifiedAt', row.modifiedAt), JSON.stringify(row.listData ?? null));
|
|
228
|
-
}
|
|
229
|
-
export function getDraft(id) {
|
|
230
|
-
const { db } = openCache();
|
|
231
|
-
const r = db.prepare('SELECT * FROM drafts WHERE id = ?').get(id);
|
|
232
|
-
return r ? draftFromDb(r) : null;
|
|
233
|
-
}
|
|
234
|
-
export function listDrafts(opts) {
|
|
235
|
-
const { db } = openCache();
|
|
236
|
-
const offset = (opts.page - 1) * opts.size;
|
|
237
|
-
const rows = db.prepare('SELECT * FROM drafts ORDER BY modified_at DESC, id DESC LIMIT ? OFFSET ?').all(opts.size, offset);
|
|
238
|
-
return rows.map(draftFromDb);
|
|
239
|
-
}
|
|
240
|
-
export function deleteDraft(id) {
|
|
241
|
-
const { db } = openCache();
|
|
242
|
-
db.prepare('DELETE FROM drafts WHERE id = ?').run(id);
|
|
243
|
-
}
|
|
244
|
-
export function listDraftIds() {
|
|
245
|
-
const { db } = openCache();
|
|
246
|
-
const rows = db.prepare('SELECT id FROM drafts').all();
|
|
247
|
-
return rows.map((r) => r.id);
|
|
248
|
-
}
|
|
249
|
-
export function getSyncState(folder) {
|
|
250
|
-
const { db } = openCache();
|
|
251
|
-
const r = db.prepare('SELECT last_sync_at, newest_id FROM sync_state WHERE folder = ?')
|
|
252
|
-
.get(folder);
|
|
253
|
-
if (!r)
|
|
254
|
-
return null;
|
|
255
|
-
return { lastSyncAt: r.last_sync_at, newestId: r.newest_id };
|
|
256
|
-
}
|
|
257
|
-
export function setSyncState(folder, state) {
|
|
258
|
-
const { db } = openCache();
|
|
259
|
-
db.prepare(`INSERT INTO sync_state (folder, last_sync_at, newest_id) VALUES (?, ?, ?)
|
|
260
|
-
ON CONFLICT(folder) DO UPDATE SET
|
|
261
|
-
last_sync_at = excluded.last_sync_at,
|
|
262
|
-
newest_id = excluded.newest_id`).run(folder, state.lastSyncAt, state.newestId);
|
|
263
|
-
}
|
|
264
|
-
export function getMeta(key) {
|
|
265
|
-
const { db } = openCache();
|
|
266
|
-
const r = db.prepare('SELECT value FROM meta WHERE key = ?')
|
|
267
|
-
.get(key);
|
|
268
|
-
return r ? r.value : null;
|
|
269
|
-
}
|
|
270
|
-
export function setMeta(key, value) {
|
|
271
|
-
const { db } = openCache();
|
|
272
|
-
db.prepare('INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value').run(key, value);
|
|
273
|
-
}
|
|
274
|
-
export function findLatestReplyTip(replyToId) {
|
|
275
|
-
const { db } = openCache();
|
|
276
|
-
const parent = db.prepare('SELECT id, folder, chain_root_id FROM messages WHERE id = ?').get(replyToId);
|
|
277
|
-
if (!parent)
|
|
278
|
-
return replyToId;
|
|
279
|
-
const chainRoot = parent.chain_root_id ?? parent.id;
|
|
280
|
-
const tip = db.prepare(`SELECT id FROM messages
|
|
281
|
-
WHERE folder = 'sent' AND chain_root_id = ?
|
|
282
|
-
ORDER BY id DESC LIMIT 1`).get(chainRoot);
|
|
283
|
-
return tip ? tip.id : replyToId;
|
|
284
|
-
}
|
|
285
|
-
function attachmentFromDb(r) {
|
|
286
|
-
return {
|
|
287
|
-
fileId: r.file_id,
|
|
288
|
-
fileName: r.file_name,
|
|
289
|
-
label: r.label,
|
|
290
|
-
mimeType: r.mime_type,
|
|
291
|
-
sizeBytes: r.size_bytes,
|
|
292
|
-
metadata: JSON.parse(r.metadata_json),
|
|
293
|
-
messageIds: JSON.parse(r.message_ids_json),
|
|
294
|
-
downloadedPath: r.downloaded_path,
|
|
295
|
-
downloadedAt: r.downloaded_at,
|
|
296
|
-
};
|
|
297
|
-
}
|
|
298
|
-
export function getAttachment(fileId) {
|
|
299
|
-
const { db } = openCache();
|
|
300
|
-
const r = db.prepare('SELECT * FROM attachments WHERE file_id = ?').get(fileId);
|
|
301
|
-
return r ? attachmentFromDb(r) : null;
|
|
302
|
-
}
|
|
303
|
-
export function listAttachmentsForMessage(messageId) {
|
|
304
|
-
const { db } = openCache();
|
|
305
|
-
// SQLite JSON1 contains check
|
|
306
|
-
const rows = db.prepare(`SELECT * FROM attachments
|
|
307
|
-
WHERE EXISTS (SELECT 1 FROM json_each(message_ids_json) WHERE value = ?)
|
|
308
|
-
ORDER BY file_id`).all(messageId);
|
|
309
|
-
return rows.map(attachmentFromDb);
|
|
310
|
-
}
|
|
311
|
-
export function upsertAttachmentForMessage(input) {
|
|
312
|
-
const { db } = openCache();
|
|
313
|
-
const existing = db.prepare('SELECT message_ids_json FROM attachments WHERE file_id = ?')
|
|
314
|
-
.get(input.fileId);
|
|
315
|
-
// messageId === 0 is the "metadata-only, not yet linked to a message"
|
|
316
|
-
// sentinel used by upload-without-send and download-by-id. Don't
|
|
317
|
-
// pollute the array with it — leave the list empty / unchanged.
|
|
318
|
-
const prior = existing ? JSON.parse(existing.message_ids_json) : [];
|
|
319
|
-
let messageIds;
|
|
320
|
-
if (input.messageId === 0) {
|
|
321
|
-
messageIds = prior;
|
|
322
|
-
}
|
|
323
|
-
else if (prior.includes(input.messageId)) {
|
|
324
|
-
messageIds = prior;
|
|
325
|
-
}
|
|
326
|
-
else {
|
|
327
|
-
messageIds = [...prior, input.messageId];
|
|
328
|
-
}
|
|
329
|
-
db.prepare(`INSERT INTO attachments (
|
|
330
|
-
file_id, file_name, label, mime_type, size_bytes,
|
|
331
|
-
metadata_json, message_ids_json, fetched_metadata_at
|
|
332
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
333
|
-
ON CONFLICT(file_id) DO UPDATE SET
|
|
334
|
-
file_name=excluded.file_name,
|
|
335
|
-
label=excluded.label,
|
|
336
|
-
mime_type=excluded.mime_type,
|
|
337
|
-
size_bytes=excluded.size_bytes,
|
|
338
|
-
metadata_json=excluded.metadata_json,
|
|
339
|
-
message_ids_json=excluded.message_ids_json,
|
|
340
|
-
fetched_metadata_at=excluded.fetched_metadata_at`).run(input.fileId, requireString('attachments.fileName', input.fileName), requireString('attachments.label', input.label), requireString('attachments.mimeType', input.mimeType), nullish(input.sizeBytes), JSON.stringify(input.metadata ?? null), JSON.stringify(messageIds), new Date().toISOString());
|
|
341
|
-
}
|
|
342
|
-
export function markAttachmentDownloaded(fileId, path) {
|
|
343
|
-
const { db } = openCache();
|
|
344
|
-
db.prepare('UPDATE attachments SET downloaded_path = ?, downloaded_at = ? WHERE file_id = ?').run(path, new Date().toISOString(), fileId);
|
|
345
|
-
}
|