ofw-mcp 2.6.6 → 2.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +29 -1
- package/dist/bundle.js +720 -127
- package/dist/config.js +25 -0
- package/dist/index.js +1 -1
- package/dist/sync.js +161 -23
- package/dist/tools/_shared.js +32 -13
- package/dist/tools/attachments.js +61 -0
- package/dist/tools/draft-freshness.js +166 -0
- package/dist/tools/freshness.js +147 -0
- package/dist/tools/messages.js +410 -40
- package/package.json +1 -1
- package/server.json +2 -2
- package/skills/ofw/SKILL.md +16 -1
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { getFolderVerifiedAt } from '../sync.js';
|
|
2
|
+
import { getFreshnessTtlSeconds } from '../config.js';
|
|
3
|
+
const RANK = { fresh: 0, unverified: 1, stale: 2 };
|
|
4
|
+
function worst(a, b) {
|
|
5
|
+
return RANK[a] >= RANK[b] ? a : b;
|
|
6
|
+
}
|
|
7
|
+
/** Human-readable age: seconds under a minute, else whole minutes. */
|
|
8
|
+
function describeAge(seconds) {
|
|
9
|
+
return seconds < 60 ? `${seconds} sec ago` : `${Math.round(seconds / 60)} min ago`;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Build the `freshness` block that every message/draft/folder read carries.
|
|
13
|
+
*
|
|
14
|
+
* The point is that the DATA announces its own age and reliability, so a
|
|
15
|
+
* caller cannot assert current state without either a fresh read or an
|
|
16
|
+
* explicit staleness caveat it has to surface. Nothing here depends on the
|
|
17
|
+
* model remembering to re-check.
|
|
18
|
+
*
|
|
19
|
+
* Across multiple folders the block reports the WORST staleness and the OLDEST
|
|
20
|
+
* asOf: a response is only as trustworthy as its least-verified input.
|
|
21
|
+
*/
|
|
22
|
+
export async function buildFreshness(store, opts) {
|
|
23
|
+
const now = opts.now ?? new Date();
|
|
24
|
+
const ttl = opts.ttlSeconds ?? getFreshnessTtlSeconds();
|
|
25
|
+
// A cache read backed by NO folder has verified nothing, so nothing in the
|
|
26
|
+
// per-folder loop below can downgrade the 'fresh' initializer — it would
|
|
27
|
+
// return `staleness: 'fresh'` alongside `asOf: null`, self-contradictory by
|
|
28
|
+
// this module's own definition and the one shape that carries no warning at
|
|
29
|
+
// all. Reachable via ofw_sync_messages({folders: []}). Fail to `stale`: an
|
|
30
|
+
// empty scope is the least evidence possible, not the most.
|
|
31
|
+
// (A LIVE read with no folders is different and legitimate — that is
|
|
32
|
+
// ofw_list_message_folders, whose data came straight off the wire.)
|
|
33
|
+
const emptyScope = opts.source === 'cache' && opts.folders.length === 0;
|
|
34
|
+
let staleness = emptyScope ? 'stale' : 'fresh';
|
|
35
|
+
let oldestVerifiedAt = null;
|
|
36
|
+
let sawNeverVerified = false;
|
|
37
|
+
let lastServerSyncAt = null;
|
|
38
|
+
let historyComplete = true;
|
|
39
|
+
// An empty scope verified nothing, so it cannot claim a complete sync.
|
|
40
|
+
let syncComplete = !emptyScope;
|
|
41
|
+
const deferred = [];
|
|
42
|
+
const backfilling = [];
|
|
43
|
+
for (const folder of opts.folders) {
|
|
44
|
+
const verifiedAt = await getFolderVerifiedAt(store, folder);
|
|
45
|
+
const state = await store.getSyncState(folder);
|
|
46
|
+
if (state !== null && (lastServerSyncAt === null || state.lastSyncAt > lastServerSyncAt)) {
|
|
47
|
+
lastServerSyncAt = state.lastSyncAt;
|
|
48
|
+
}
|
|
49
|
+
// A parked backfill means old history is incomplete. It does NOT downgrade
|
|
50
|
+
// staleness: the forward pass runs from page 1 on every call, so the
|
|
51
|
+
// present is current even while history is still being walked. Letting a
|
|
52
|
+
// months-long backfill mark every read `unverified` would train the caller
|
|
53
|
+
// to ignore the warning entirely.
|
|
54
|
+
if (state !== null && state.resumePage !== null) {
|
|
55
|
+
historyComplete = false;
|
|
56
|
+
syncComplete = false;
|
|
57
|
+
backfilling.push(folder);
|
|
58
|
+
}
|
|
59
|
+
if (verifiedAt === null) {
|
|
60
|
+
sawNeverVerified = true;
|
|
61
|
+
staleness = worst(staleness, 'stale');
|
|
62
|
+
syncComplete = false;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (oldestVerifiedAt === null || verifiedAt < oldestVerifiedAt)
|
|
66
|
+
oldestVerifiedAt = verifiedAt;
|
|
67
|
+
// A sync ran AFTER the last verification of this folder — i.e. it was
|
|
68
|
+
// attempted and skipped (budget exhausted before reaching it). Recency of
|
|
69
|
+
// the older stamp must not keep it `fresh`; the skip is itself evidence
|
|
70
|
+
// that we do not currently know this folder's state.
|
|
71
|
+
if (state !== null && state.lastSyncAt > verifiedAt) {
|
|
72
|
+
staleness = worst(staleness, 'unverified');
|
|
73
|
+
syncComplete = false;
|
|
74
|
+
deferred.push(folder);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
// Clamp at 0: a verifiedAt in the future (clock skew between the machine
|
|
78
|
+
// that wrote it and this one) must not read as a large negative age that
|
|
79
|
+
// can never exceed the threshold — that would be a silent false `fresh`.
|
|
80
|
+
const age = Math.max(0, Math.floor((now.getTime() - Date.parse(verifiedAt)) / 1000));
|
|
81
|
+
if (age > ttl)
|
|
82
|
+
staleness = worst(staleness, 'unverified');
|
|
83
|
+
}
|
|
84
|
+
// A live read is current by construction — that is the whole point of paying
|
|
85
|
+
// for it — so it reports fresh regardless of what the cache looks like.
|
|
86
|
+
if (opts.source === 'live') {
|
|
87
|
+
const asOf = now.toISOString();
|
|
88
|
+
const block = {
|
|
89
|
+
source: 'live',
|
|
90
|
+
asOf,
|
|
91
|
+
ageSeconds: 0,
|
|
92
|
+
staleness: 'fresh',
|
|
93
|
+
lastServerSyncAt,
|
|
94
|
+
syncComplete,
|
|
95
|
+
historyComplete,
|
|
96
|
+
};
|
|
97
|
+
const liveReasons = [];
|
|
98
|
+
if (sawNeverVerified) {
|
|
99
|
+
liveReasons.push('the surrounding cache has never been checked against OurFamilyWizard, so anything you did NOT fetch in this call is unverified');
|
|
100
|
+
}
|
|
101
|
+
if (backfilling.length > 0) {
|
|
102
|
+
liveReasons.push(`older history is still being backfilled for ${backfilling.join(', ')}, so older messages may be missing from the cache`);
|
|
103
|
+
}
|
|
104
|
+
if (liveReasons.length > 0) {
|
|
105
|
+
block.warning = `Fetched live from OurFamilyWizard, so this data is current. Note that ${liveReasons.join('; ')}.`;
|
|
106
|
+
}
|
|
107
|
+
return block;
|
|
108
|
+
}
|
|
109
|
+
const asOf = sawNeverVerified ? null : oldestVerifiedAt;
|
|
110
|
+
const ageSeconds = asOf === null
|
|
111
|
+
? null
|
|
112
|
+
: Math.max(0, Math.floor((now.getTime() - Date.parse(asOf)) / 1000));
|
|
113
|
+
const block = {
|
|
114
|
+
source: 'cache',
|
|
115
|
+
asOf,
|
|
116
|
+
ageSeconds,
|
|
117
|
+
staleness,
|
|
118
|
+
lastServerSyncAt,
|
|
119
|
+
syncComplete,
|
|
120
|
+
historyComplete,
|
|
121
|
+
};
|
|
122
|
+
const reasons = [];
|
|
123
|
+
if (emptyScope) {
|
|
124
|
+
reasons.push('this result is backed by no synced folder at all, so nothing about it has been verified');
|
|
125
|
+
}
|
|
126
|
+
if (sawNeverVerified) {
|
|
127
|
+
reasons.push('this data has never been checked against OurFamilyWizard');
|
|
128
|
+
}
|
|
129
|
+
if (deferred.length > 0) {
|
|
130
|
+
reasons.push(`the last sync did not finish checking ${deferred.join(', ')}`);
|
|
131
|
+
}
|
|
132
|
+
// Reported independently of the deferral: a response can be BOTH deferred
|
|
133
|
+
// and badly aged, and naming only the deferral understates how old it is.
|
|
134
|
+
if (asOf !== null && ageSeconds !== null && ageSeconds > ttl) {
|
|
135
|
+
reasons.push(`that is past the ${ttl}s freshness threshold`);
|
|
136
|
+
}
|
|
137
|
+
if (backfilling.length > 0) {
|
|
138
|
+
reasons.push(`older history is still being backfilled for ${backfilling.join(', ')}`);
|
|
139
|
+
}
|
|
140
|
+
if (reasons.length > 0) {
|
|
141
|
+
const served = asOf === null
|
|
142
|
+
? 'Served from cache that was never verified against OurFamilyWizard'
|
|
143
|
+
: `Served from cache last verified ${describeAge(ageSeconds)}`;
|
|
144
|
+
block.warning = `${served}; ${reasons.join('; ')}. Re-read before asserting current state — call ofw_check_freshness for a cheap live confirmation, or ofw_sync_messages to refresh.`;
|
|
145
|
+
}
|
|
146
|
+
return block;
|
|
147
|
+
}
|