phaedo-mcp 0.2.1 → 0.3.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/NOTICE +17 -0
- package/README.md +51 -9
- package/action-binding.js +82 -0
- package/agreement.js +38 -0
- package/ap2.js +214 -0
- package/autonomy-promotion.js +280 -0
- package/build-package.mjs +25 -10
- package/capture-a11y.js +116 -0
- package/capture-local.js +85 -0
- package/capture-sync.js +99 -0
- package/consult-core.js +81 -19
- package/consult-policy.js +427 -33
- package/consult.js +25 -3
- package/delegation-sync.js +89 -0
- package/escalation-policy.sample.json +13 -0
- package/escalation.js +383 -0
- package/fingerprint-source.js +78 -4
- package/health.js +54 -0
- package/hooks/session-end.mjs +240 -0
- package/install.mjs +164 -21
- package/lib/capture-home.js +46 -0
- package/lib/domain.js +101 -0
- package/lib/is-main.js +40 -0
- package/lib/phaedo-crypto.js +17 -1
- package/package.json +26 -6
- package/pairing.js +5 -1
- package/phone-source.js +92 -12
- package/projection.js +4 -1
- package/pull.js +19 -3
- package/receipts-sync.js +197 -0
- package/receipts.js +69 -16
- package/server.js +305 -15
- package/server.json +4 -4
- package/signing-audit.js +111 -0
- package/signing-request.js +217 -0
- package/signing.js +176 -0
- package/vendor/authored-source.js +250 -0
- package/vendor/capabilities.js +203 -0
- package/vendor/capture/capture-core.js +67 -0
- package/vendor/capture/detectors.js +287 -0
- package/vendor/capture/exchanges.js +77 -0
- package/vendor/capture/model-detect.js +195 -0
- package/vendor/capture/owner.js +35 -0
- package/vendor/capture/package.json +4 -0
- package/vendor/capture/source-id.js +56 -0
- package/vendor/capture/state.js +148 -0
- package/vendor/capture/transcript.js +153 -0
- package/vendor/commitments.js +169 -0
- package/vendor/context-block.js +251 -18
- package/vendor/envelope.js +50 -4
- package/vendor/extraction/capture-combine.js +160 -0
- package/vendor/extraction/catalog.js +232 -0
- package/vendor/extraction/delta-lift.js +109 -0
- package/vendor/extraction/fingerprint-update.js +227 -0
- package/vendor/extraction/package.json +4 -0
- package/vendor/extraction/persist.js +40 -0
- package/vendor/extraction/reconcile.js +269 -0
- package/vendor/extraction/resolve.js +178 -0
- package/vendor/extraction/rule-promotion.js +137 -0
- package/vendor/kdf.js +20 -1
- package/vendor/protocol.js +7 -0
- package/vendored-files.mjs +82 -0
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
// §10.7 — fatigue-aware autonomy promotion.
|
|
2
|
+
//
|
|
3
|
+
// THE PROBLEM THIS SOLVES. Phaedo can add a gate and never remove one. The
|
|
4
|
+
// delegation-promotion channel learns only from DISAGREEMENTS
|
|
5
|
+
// (consult-core.js `buildDelegationPromotions`), and §10.5 permanently caps what
|
|
6
|
+
// an authored disposition may do at `bias_cautious`: "an authored disposition may
|
|
7
|
+
// make the agent MORE deferential, never less." Expanding autonomy requires the
|
|
8
|
+
// subject to hand-author a `kind:"authorization"` rule with a `match`. Almost
|
|
9
|
+
// nobody will. So in real use the oracle accumulates friction and never sheds it,
|
|
10
|
+
// and the 2026 approval-fatigue literature is unanimous about where that ends:
|
|
11
|
+
// approval stops being a decision and becomes a reflex, which is both a usability
|
|
12
|
+
// failure and an attack surface.
|
|
13
|
+
//
|
|
14
|
+
// THE OBJECTION, AND WHY IT SHAPES THE DESIGN. "Learn from approvals" is exactly
|
|
15
|
+
// what the honesty rail forbids — rubber stamps must not teach. That objection is
|
|
16
|
+
// correct, and it is the reason this module is not a counter. A subject clicking
|
|
17
|
+
// approve fourteen times in ninety seconds is not expressing a policy; they are
|
|
18
|
+
// tired. So the gate here is not "how many approvals" but "is this evidence of
|
|
19
|
+
// DELIBERATION":
|
|
20
|
+
//
|
|
21
|
+
// - spread over distinct days, not a single sitting (volume ≠ deliberation)
|
|
22
|
+
// - no contrary evidence at all: one rejection or modification in the class kills it
|
|
23
|
+
// - the oracle must already be tracking the subject well in that domain
|
|
24
|
+
// (agreement rate — the interlock the agreement read-out exists to provide)
|
|
25
|
+
// - not batch-marked: approvals recorded in one burst are bookkeeping, not consent
|
|
26
|
+
//
|
|
27
|
+
// AND WHAT IS PROPOSED IS DELIBERATELY SMALL. Reversible only. Never `high`
|
|
28
|
+
// magnitude. A priced class is bounded by an `amount_lt` CEILING set to the
|
|
29
|
+
// SECOND-highest amount approved, never the highest — one outlier must not set the
|
|
30
|
+
// bar (see spendCeiling). Always `require_binding: true`, so the relaxation cannot
|
|
31
|
+
// be unlocked by a fabricated descriptor. Always `expires_at`, so an autonomy grant
|
|
32
|
+
// the subject stops wanting lapses by itself.
|
|
33
|
+
//
|
|
34
|
+
// AND IT IS STILL ONLY A PROPOSAL. This writes nothing authored. It stages a
|
|
35
|
+
// `suggested_rules` entry the subject reviews and endorses, exactly like every
|
|
36
|
+
// other promotion channel — the authored-only invariant holds. The producer
|
|
37
|
+
// stages; the user authors.
|
|
38
|
+
//
|
|
39
|
+
// PURE: (receipts, fp, agreement, opts) -> suggestions[]. No I/O, no globals.
|
|
40
|
+
|
|
41
|
+
import { computeAgreement, DEFAULT_WINDOW_DAYS } from './agreement.js';
|
|
42
|
+
|
|
43
|
+
// ── The bar. Every one of these is a way of asking "was this deliberate?" ─────
|
|
44
|
+
export const MIN_APPROVALS = 5; // enough instances to be a pattern
|
|
45
|
+
export const MIN_DISTINCT_DAYS = 3; // ...spread across days, not one sitting
|
|
46
|
+
export const MIN_AGREEMENT = 0.8; // ...in a domain the oracle already tracks well
|
|
47
|
+
export const MIN_AGREEMENT_SCORED = 5; // ...on enough scored decisions to mean anything
|
|
48
|
+
export const BATCH_WINDOW_MS = 60_000; // marks this close together are bookkeeping
|
|
49
|
+
export const MAX_BATCH_FRACTION = 0.5; // ...and may not carry the majority of the evidence
|
|
50
|
+
export const DEFAULT_TTL_DAYS = 30; // a grant expires unless re-earned
|
|
51
|
+
|
|
52
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
53
|
+
const MAG_RANK = { low: 0, medium: 1, high: 2 };
|
|
54
|
+
const RANK_MAG = ['low', 'medium', 'high'];
|
|
55
|
+
|
|
56
|
+
const uuid = () =>
|
|
57
|
+
(typeof crypto !== 'undefined' && crypto.randomUUID) ? crypto.randomUUID()
|
|
58
|
+
: `aut-${Math.random().toString(36).slice(2, 10)}`;
|
|
59
|
+
|
|
60
|
+
const utcDay = (iso) => String(iso || '').slice(0, 10); // RFC 3339 → YYYY-MM-DD
|
|
61
|
+
|
|
62
|
+
// A promotable class is (domain × reversible-true). Magnitude is not part of the
|
|
63
|
+
// key — it is the CEILING we derive from what was actually observed, so a class
|
|
64
|
+
// seen only at `low` never proposes a rule that would fire at `medium`.
|
|
65
|
+
function classKey(domain) { return String(domain || '').toLowerCase(); }
|
|
66
|
+
|
|
67
|
+
// Receipts that count as evidence FOR autonomy in a class. Deliberately narrow:
|
|
68
|
+
// the oracle must have been decisive, the subject must have approved, and the
|
|
69
|
+
// action must be one we would ever propose relaxing.
|
|
70
|
+
function isPromotableApproval(r) {
|
|
71
|
+
const ad = r?.request?.action_descriptor || {};
|
|
72
|
+
if (r?.user_action !== 'approved') return false;
|
|
73
|
+
if (r?.response?.signal !== 'proceed' && r?.response?.signal !== 'proceed_with_note') return false;
|
|
74
|
+
if (ad.reversible !== true) return false; // irreversible is never auto-proceeded
|
|
75
|
+
if (ad.magnitude === 'high') return false; // nor is a major action
|
|
76
|
+
if (ad.amount !== undefined && !(typeof ad.amount === 'number' && Number.isFinite(ad.amount) && ad.amount >= 0))
|
|
77
|
+
return false; // a malformed amount is not evidence of anything
|
|
78
|
+
if (!ad.domain) return false; // an unscoped autoproceed rule is a blank cheque
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// The spend ceiling for a money class: the SECOND-highest amount approved, not the
|
|
83
|
+
// highest.
|
|
84
|
+
//
|
|
85
|
+
// Using the maximum would let ONE outlier set the bar — approve five $20 orders and a
|
|
86
|
+
// single $500 one, and the rule would thereafter auto-proceed anything up to $500.
|
|
87
|
+
// Requiring two approvals at a level before the rule reaches it is the money-shaped
|
|
88
|
+
// version of the "spread across days, not one sitting" rule: a bound has to be
|
|
89
|
+
// demonstrated more than once.
|
|
90
|
+
//
|
|
91
|
+
// Returns null when the class carries no amounts at all (then no ceiling is needed),
|
|
92
|
+
// or when fewer than two priced approvals exist (then no ceiling can be justified, so
|
|
93
|
+
// the class is not promotable).
|
|
94
|
+
export function spendCeiling(receipts) {
|
|
95
|
+
const amounts = receipts
|
|
96
|
+
.map((r) => r?.request?.action_descriptor?.amount)
|
|
97
|
+
.filter((a) => typeof a === 'number' && Number.isFinite(a));
|
|
98
|
+
if (!amounts.length) return null; // unpriced class — no ceiling needed
|
|
99
|
+
if (amounts.length < 2) return undefined; // one priced approval cannot justify a bound
|
|
100
|
+
const sorted = amounts.slice().sort((a, b) => b - a);
|
|
101
|
+
return sorted[1];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Anything that argues AGAINST relaxing this class. One is enough to disqualify:
|
|
105
|
+
// a class the subject has ever rejected or edited is not settled.
|
|
106
|
+
function isContraryEvidence(r, domain) {
|
|
107
|
+
const ad = r?.request?.action_descriptor || {};
|
|
108
|
+
if (classKey(ad.domain) !== domain) return false;
|
|
109
|
+
return r?.user_action === 'rejected' || r?.user_action === 'modified';
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Were these approvals RECORDED in a burst? Marking a screenful of receipts in one
|
|
113
|
+
// pass is bookkeeping, not consent — so if most of the supporting evidence was
|
|
114
|
+
// stamped inside one short window, it does not support a policy. Receipts without
|
|
115
|
+
// a mark timestamp (older stores) are treated as non-batch: absence of evidence
|
|
116
|
+
// is not evidence of fatigue, and the other gates still apply.
|
|
117
|
+
export function batchFraction(receipts, { windowMs = BATCH_WINDOW_MS } = {}) {
|
|
118
|
+
const stamps = receipts
|
|
119
|
+
.map((r) => Date.parse(r?.user_action_at))
|
|
120
|
+
.filter((t) => Number.isFinite(t))
|
|
121
|
+
.sort((a, b) => a - b);
|
|
122
|
+
if (stamps.length < 2) return 0;
|
|
123
|
+
let best = 1;
|
|
124
|
+
for (let i = 0; i < stamps.length; i++) {
|
|
125
|
+
let j = i;
|
|
126
|
+
while (j < stamps.length && stamps[j] - stamps[i] <= windowMs) j++;
|
|
127
|
+
best = Math.max(best, j - i);
|
|
128
|
+
}
|
|
129
|
+
return best / receipts.length;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function proposedText(domain, magMax, ceiling = null) {
|
|
133
|
+
const scope = magMax === 'low' ? 'small, reversible' : 'routine, reversible';
|
|
134
|
+
// The ceiling is the single most consequential term on a money rule, so it belongs
|
|
135
|
+
// in the sentence the subject reads — not only in the terms line beneath it.
|
|
136
|
+
// "up to", not "under" — the ceiling is INCLUSIVE now (M7), and this sentence is what the
|
|
137
|
+
// subject reads before endorsing. Prose that says "under 20" for a rule that fires AT 20
|
|
138
|
+
// misdescribes the authority being granted, on the one screen where accuracy is consent.
|
|
139
|
+
const under = ceiling !== null ? ` up to ${ceiling}` : '';
|
|
140
|
+
return `Let agents go ahead with ${scope} ${domain} actions${under} without asking me first.`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// (receipts, fp, opts) -> suggestion[] — the §10.5 authorizations worth PROPOSING.
|
|
144
|
+
//
|
|
145
|
+
// `fp` supplies what is already authored or already staged, so an endorsed or
|
|
146
|
+
// pending suggestion is never re-proposed (the no-nag rule every promotion
|
|
147
|
+
// channel follows).
|
|
148
|
+
export function buildAutonomyPromotions(receipts, fp, opts = {}) {
|
|
149
|
+
const {
|
|
150
|
+
now = Date.now(),
|
|
151
|
+
windowDays = DEFAULT_WINDOW_DAYS,
|
|
152
|
+
minApprovals = MIN_APPROVALS,
|
|
153
|
+
minDistinctDays = MIN_DISTINCT_DAYS,
|
|
154
|
+
minAgreement = MIN_AGREEMENT,
|
|
155
|
+
minAgreementScored = MIN_AGREEMENT_SCORED,
|
|
156
|
+
maxBatchFraction = MAX_BATCH_FRACTION,
|
|
157
|
+
ttlDays = DEFAULT_TTL_DAYS,
|
|
158
|
+
} = opts;
|
|
159
|
+
|
|
160
|
+
const list = Array.isArray(receipts) ? receipts : [];
|
|
161
|
+
const since = now - windowDays * DAY_MS;
|
|
162
|
+
const inWindow = list.filter((r) => {
|
|
163
|
+
const t = Date.parse(r?.created_at);
|
|
164
|
+
return Number.isFinite(t) && t >= since && t <= now;
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// Already covered? An authored autoproceed for the domain, or a live suggestion.
|
|
168
|
+
const authored = new Set(
|
|
169
|
+
(Array.isArray(fp?.standing_rules) ? fp.standing_rules : [])
|
|
170
|
+
.filter((r) => r?.kind === 'authorization' && r?.effect === 'autoproceed')
|
|
171
|
+
.flatMap((r) => (Array.isArray(r?.match?.domains) ? r.match.domains : []).map(classKey)),
|
|
172
|
+
);
|
|
173
|
+
const staged = new Set(
|
|
174
|
+
(Array.isArray(fp?.suggested_rules) ? fp.suggested_rules : [])
|
|
175
|
+
.filter((s) => s?.source === 'autonomy_promotion')
|
|
176
|
+
.flatMap((s) => (Array.isArray(s?.match?.domains) ? s.match.domains : []).map(classKey)),
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
const agreement = computeAgreement(list, { windowDays, now });
|
|
180
|
+
|
|
181
|
+
const byClass = new Map();
|
|
182
|
+
for (const r of inWindow) {
|
|
183
|
+
if (!isPromotableApproval(r)) continue;
|
|
184
|
+
const key = classKey(r.request.action_descriptor.domain);
|
|
185
|
+
const bucket = byClass.get(key) || { receipts: [], days: new Set(), magRank: 0, clients: new Set() };
|
|
186
|
+
bucket.receipts.push(r);
|
|
187
|
+
bucket.days.add(utcDay(r.created_at));
|
|
188
|
+
bucket.magRank = Math.max(bucket.magRank, MAG_RANK[r.request.action_descriptor.magnitude] ?? 0);
|
|
189
|
+
// §10.5 scoping (step 5): WHICH paired client's actions earned this. Recorded from
|
|
190
|
+
// the receipt's `client_id` — the serving client, established out-of-band with the
|
|
191
|
+
// phone — never from `agent_id`, which is the caller's claim about itself.
|
|
192
|
+
if (typeof r.client_id === 'string' && r.client_id) bucket.clients.add(r.client_id);
|
|
193
|
+
byClass.set(key, bucket);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const out = [];
|
|
197
|
+
for (const [domain, bucket] of byClass) {
|
|
198
|
+
if (authored.has(domain) || staged.has(domain)) continue; // no-nag
|
|
199
|
+
if (bucket.receipts.length < minApprovals) continue; // not yet a pattern
|
|
200
|
+
if (bucket.days.size < minDistinctDays) continue; // one sitting is not a policy
|
|
201
|
+
if (inWindow.some((r) => isContraryEvidence(r, domain))) continue; // any pushback disqualifies the class
|
|
202
|
+
if (batchFraction(bucket.receipts, opts) > maxBatchFraction) continue; // batch-marked = bookkeeping
|
|
203
|
+
|
|
204
|
+
// The interlock: don't hand autonomy to an oracle that isn't tracking this
|
|
205
|
+
// subject here. `(none)` is how computeAgreement keys undomained receipts.
|
|
206
|
+
const dom = agreement.domains[domain];
|
|
207
|
+
if (!dom || dom.scored < minAgreementScored || dom.rate === null || dom.rate < minAgreement) continue;
|
|
208
|
+
|
|
209
|
+
// Money needs a ceiling, and a ceiling needs to have been demonstrated twice.
|
|
210
|
+
// `undefined` means the class is priced but cannot justify a bound — skip it
|
|
211
|
+
// rather than propose an unbounded spend authorization.
|
|
212
|
+
const ceiling = spendCeiling(bucket.receipts);
|
|
213
|
+
if (ceiling === undefined) continue;
|
|
214
|
+
if (ceiling !== null && !(ceiling > 0)) continue; // a 0 ceiling can never match
|
|
215
|
+
|
|
216
|
+
const magMax = RANK_MAG[bucket.magRank] || 'low';
|
|
217
|
+
|
|
218
|
+
// §10.5 `agents` — scope the proposed grant to the clients that EARNED it.
|
|
219
|
+
//
|
|
220
|
+
// Only meaningful now that scoping is enforced (v0.1.53) and authorable on the
|
|
221
|
+
// phone (#197). Before that, writing this field would have shipped something that
|
|
222
|
+
// looked like a boundary and was not — the reason it was deliberately left off.
|
|
223
|
+
//
|
|
224
|
+
// Strictly narrowing versus the previous behaviour, which granted every paired
|
|
225
|
+
// client whatever any one of them earned. A client that contributed approvals but
|
|
226
|
+
// cannot be attributed (receipts written before v0.1.53 carry no `client_id`) is
|
|
227
|
+
// simply left out — it keeps asking, which is the fail-safe direction.
|
|
228
|
+
//
|
|
229
|
+
// The empty case is the one that matters: when NO receipt in the class can be
|
|
230
|
+
// attributed, the field is OMITTED rather than written as `[]`. Absent means
|
|
231
|
+
// unscoped (today's behaviour, preserved for pre-v0.1.53 history); `[]` means a
|
|
232
|
+
// scope naming nobody, which would stage a grant that can never fire. Getting
|
|
233
|
+
// those two confused is precisely the defect the v0.1.54 hardening pass caught.
|
|
234
|
+
const agents = [...bucket.clients].sort();
|
|
235
|
+
out.push({
|
|
236
|
+
suggestion_id: uuid(),
|
|
237
|
+
status: 'suggested',
|
|
238
|
+
kind: 'authorization',
|
|
239
|
+
source: 'autonomy_promotion',
|
|
240
|
+
proposed_text: proposedText(domain, magMax, ceiling),
|
|
241
|
+
match: {
|
|
242
|
+
domains: [domain],
|
|
243
|
+
reversible: true,
|
|
244
|
+
magnitude_max: magMax,
|
|
245
|
+
// INCLUSIVE (M7). This staged `amount_lt: ceiling`, and `ceiling` is the SECOND-HIGHEST
|
|
246
|
+
// amount approved — so five $20 approvals minted a rule matching `amount < 20`, which
|
|
247
|
+
// excludes every purchase that earned it. The grant was unfireable by construction, and
|
|
248
|
+
// silently: it appeared in the subject's rules, read correctly in prose, and never
|
|
249
|
+
// matched. §10.5 gained `amount_lte` for this.
|
|
250
|
+
...(ceiling !== null ? { amount_lte: ceiling } : {}),
|
|
251
|
+
},
|
|
252
|
+
effect: 'autoproceed',
|
|
253
|
+
// Non-negotiable on a PROPOSED relaxation: it may only apply to a request
|
|
254
|
+
// bound to a concrete call, and it lapses unless the subject re-earns it.
|
|
255
|
+
require_binding: true,
|
|
256
|
+
expires_at: new Date(now + ttlDays * DAY_MS).toISOString(),
|
|
257
|
+
...(agents.length ? { agents } : {}),
|
|
258
|
+
note: agents.length
|
|
259
|
+
// Say the grant is limited, without printing an opaque `c_…` the subject
|
|
260
|
+
// cannot recognise — only the phone holds the registry that names it.
|
|
261
|
+
? `you have approved every ${domain} action like this that Phaedo has seen — limited to the agent that earned it`
|
|
262
|
+
: `you have approved every ${domain} action like this that Phaedo has seen`,
|
|
263
|
+
evidence: {
|
|
264
|
+
observation_count: bucket.receipts.length,
|
|
265
|
+
distinct_days: bucket.days.size,
|
|
266
|
+
agreement_rate: dom.rate,
|
|
267
|
+
evidence_basis: 'observed',
|
|
268
|
+
last_observed: bucket.receipts[bucket.receipts.length - 1]?.created_at || null,
|
|
269
|
+
// Provenance for the audit: how many paired clients this evidence came from,
|
|
270
|
+
// and whether any of it predates client attribution. Distinct from `agents`,
|
|
271
|
+
// which is the enforceable scope.
|
|
272
|
+
client_count: bucket.clients.size,
|
|
273
|
+
unattributed: bucket.receipts.some((r) => !(typeof r.client_id === 'string' && r.client_id)),
|
|
274
|
+
},
|
|
275
|
+
created: new Date(now).toISOString(),
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
// Deterministic order — this rides a last-write-wins mailbox deposit.
|
|
279
|
+
return out.sort((a, b) => a.match.domains[0].localeCompare(b.match.domains[0]));
|
|
280
|
+
}
|
package/build-package.mjs
CHANGED
|
@@ -1,22 +1,37 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// Make the npm package self-contained. The MCP server reaches
|
|
3
|
-
// the repo root (one source of truth, shared with the extension): the injection
|
|
4
|
-
// renderer (context-block.js, via projection.js)
|
|
5
|
-
// (protocol/envelope/kdf/identity.js, via lib/phaedo-crypto.js)
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
2
|
+
// Make the npm package self-contained. The MCP server reaches three sets of files
|
|
3
|
+
// at the repo root (one source of truth, shared with the extension): the injection
|
|
4
|
+
// renderer (context-block.js, via projection.js), the classic crypto modules
|
|
5
|
+
// (protocol/envelope/kdf/identity.js, via lib/phaedo-crypto.js), and the capture
|
|
6
|
+
// stack — capture/* for the SessionEnd hook plus the extraction modules the hook
|
|
7
|
+
// and the mcp-local tier-3 fold need (loaded via lib/capture-home.js). The
|
|
8
|
+
// published package ships only mcp/, so this copies those root modules into
|
|
9
|
+
// mcp/vendor/ (capture/extraction keep their subdirectories — their imports are
|
|
10
|
+
// intra-directory relative, so the copies resolve each other unchanged); the
|
|
11
|
+
// consumers prefer the location per their own doc-comments. Runs as `prepack`
|
|
12
|
+
// (npm pack / publish), `pretest`, and `npm run build`. Idempotent; mcp/vendor/
|
|
13
|
+
// is gitignored (a build artifact).
|
|
10
14
|
|
|
11
15
|
import { copyFileSync, mkdirSync } from 'fs';
|
|
12
16
|
import { dirname, join, resolve } from 'path';
|
|
13
17
|
import { fileURLToPath } from 'url';
|
|
18
|
+
import { ROOT_FILES, CAPTURE_FILES, EXTRACTION_FILES } from './vendored-files.mjs';
|
|
14
19
|
|
|
15
20
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
16
21
|
const ROOT = resolve(HERE, '..');
|
|
17
22
|
const VENDOR = join(HERE, 'vendor');
|
|
18
|
-
|
|
23
|
+
// Single source of truth, shared with scripts/export-public-repo.mjs (they drifted once).
|
|
24
|
+
const FILES = ROOT_FILES;
|
|
25
|
+
// The capture hook + tier-3 fold closure. Keep in lockstep with what
|
|
26
|
+
// lib/capture-home.js consumers actually import — test-vendored-capture.mjs
|
|
27
|
+
// exercises the whole set from a rootless copy, so a missing module fails CI.
|
|
28
|
+
|
|
29
|
+
|
|
19
30
|
|
|
20
31
|
mkdirSync(VENDOR, { recursive: true });
|
|
21
32
|
for (const f of FILES) copyFileSync(resolve(ROOT, f), join(VENDOR, f));
|
|
22
|
-
|
|
33
|
+
mkdirSync(join(VENDOR, 'capture'), { recursive: true });
|
|
34
|
+
for (const f of CAPTURE_FILES) copyFileSync(resolve(ROOT, 'capture', f), join(VENDOR, 'capture', f));
|
|
35
|
+
mkdirSync(join(VENDOR, 'extraction'), { recursive: true });
|
|
36
|
+
for (const f of EXTRACTION_FILES) copyFileSync(resolve(ROOT, 'extraction', f), join(VENDOR, 'extraction', f));
|
|
37
|
+
process.stdout.write(`[phaedo-mcp] bundled ${FILES.length} root + ${CAPTURE_FILES.length} capture + ${EXTRACTION_FILES.length} extraction modules → mcp/vendor/\n`);
|
package/capture-a11y.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Phaedo — Track B a11y capture core (the Node side of the OS-accessibility helper).
|
|
3
|
+
//
|
|
4
|
+
// The native macOS helper (capture/spike-a11y-macos → the productized reader) observes a
|
|
5
|
+
// chat app's accessibility tree, reconstructs the visible transcript into {role, text}
|
|
6
|
+
// turns, and — on a response-complete SETTLE — pipes a JSON payload to THIS process on
|
|
7
|
+
// stdin. We convert the turns to the shared exchange_batch (capture/exchanges.js), fold
|
|
8
|
+
// them through the SAME spine the Claude Code hook uses (capture/capture-core.js →
|
|
9
|
+
// detectors → per-session state → delta lift), and deposit the signal delta to the
|
|
10
|
+
// relay's SUB-KEYED capture lane (mcp/capture-sync.js), encrypted under fp_sync_key.
|
|
11
|
+
//
|
|
12
|
+
// The native front-end does OS work; this owns the pairing + deposit, so the two tiers
|
|
13
|
+
// (a11y, later OCR) share one Node pipeline and differ only in how turns are obtained.
|
|
14
|
+
//
|
|
15
|
+
// DISCARD-RAW: the turns' text is counted and dropped — nothing but signal names +
|
|
16
|
+
// integers is persisted or transmitted (architecture/persona-vs-conversation-memory.md).
|
|
17
|
+
// FAIL-SAFE: any error exits 0 with no output; unpaired machines still accumulate local
|
|
18
|
+
// state (the mcp-local owner tier reads it), the deposit just no-ops.
|
|
19
|
+
//
|
|
20
|
+
// Payload (one settle = one invocation), stdin JSON:
|
|
21
|
+
// { conversation_id: string, // STABLE per chat → per-session REPLACE (idempotent)
|
|
22
|
+
// source_surface?: string, // default 'a11y-macos'
|
|
23
|
+
// captured_at?: string, // ISO; default now
|
|
24
|
+
// turns: [ { role:'user'|'assistant', text:string, id?, timestamp? }, ... ] }
|
|
25
|
+
|
|
26
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
27
|
+
import { dirname, resolve } from 'node:path';
|
|
28
|
+
import { loadCaptureModule } from './lib/capture-home.js';
|
|
29
|
+
import { isMainModule } from './lib/is-main.js';
|
|
30
|
+
import { loadPairingRecord } from './phone-source.js';
|
|
31
|
+
import { loadCapturePairing, CapturePairingError } from './hooks/session-end.mjs';
|
|
32
|
+
import { buildCaptureDelta, depositCaptureDelta } from './capture-sync.js';
|
|
33
|
+
|
|
34
|
+
const [
|
|
35
|
+
{ turnsToBatch },
|
|
36
|
+
{ foldBatchToSignals },
|
|
37
|
+
{ deriveSourceDeviceId },
|
|
38
|
+
] = await Promise.all([
|
|
39
|
+
'capture/exchanges.js',
|
|
40
|
+
'capture/capture-core.js',
|
|
41
|
+
'capture/source-id.js',
|
|
42
|
+
].map(loadCaptureModule));
|
|
43
|
+
|
|
44
|
+
const MCP_DIR = resolve(dirname(fileURLToPath(import.meta.url)));
|
|
45
|
+
const DEFAULT_SURFACE = 'a11y-macos';
|
|
46
|
+
const BUDGET_MS = 10000;
|
|
47
|
+
|
|
48
|
+
// Run one a11y capture payload → deposit. Dependency-injectable for tests.
|
|
49
|
+
// payload — the native helper's stdin JSON (see header).
|
|
50
|
+
// deps — { pairing, statePath, fetch, relayBase, now, mcpDir, env, captureModel }.
|
|
51
|
+
// Returns a result record; never throws.
|
|
52
|
+
export async function runA11yCapture(payload, deps = {}) {
|
|
53
|
+
const nowIso = deps.now || (payload && payload.captured_at) || new Date().toISOString();
|
|
54
|
+
const surface = (payload && payload.source_surface) || DEFAULT_SURFACE;
|
|
55
|
+
|
|
56
|
+
const turns = payload && Array.isArray(payload.turns) ? payload.turns : null;
|
|
57
|
+
if (!turns) return { deposited: false, reason: 'no-turns' };
|
|
58
|
+
const conversationId = (payload && (payload.conversation_id || payload.session_id)) || null;
|
|
59
|
+
|
|
60
|
+
const batch = turnsToBatch(turns, { sessionId: conversationId, platform: surface });
|
|
61
|
+
if (!batch.user_prompts.length) return { deposited: false, reason: 'no-exchanges' };
|
|
62
|
+
|
|
63
|
+
// Resolve the pairing whose key the draining owner can decrypt (same order as the hook:
|
|
64
|
+
// explicit override / legacy capture-pairing file / the MCP's own pairing.json). A
|
|
65
|
+
// PRESENT-but-broken capture-pairing file must not silently fall back to a different key
|
|
66
|
+
// — accumulate state locally and skip the deposit instead.
|
|
67
|
+
let pairing = deps.pairing;
|
|
68
|
+
if (pairing === undefined) {
|
|
69
|
+
try {
|
|
70
|
+
pairing = loadCapturePairing(deps.mcpDir || MCP_DIR, deps.env);
|
|
71
|
+
} catch (e) {
|
|
72
|
+
if (!(e instanceof CapturePairingError)) throw e;
|
|
73
|
+
process.stderr.write(`[phaedo capture:a11y] ${e.message}\n`);
|
|
74
|
+
const sid0 = deriveSourceDeviceId(null, surface);
|
|
75
|
+
await foldBatchToSignals(batch, { sourceId: sid0, surface, statePath: deps.statePath, sessionLocalId: conversationId, now: nowIso, captureModel: deps.captureModel, env: deps.env });
|
|
76
|
+
return { deposited: false, reason: 'capture-pairing-invalid', counted: batch.user_prompts.length };
|
|
77
|
+
}
|
|
78
|
+
if (!pairing) {
|
|
79
|
+
try { pairing = await loadPairingRecord(deps.mcpDir || MCP_DIR); } catch { pairing = null; }
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const sourceId = deriveSourceDeviceId(pairing, surface);
|
|
83
|
+
|
|
84
|
+
const { signals } = await foldBatchToSignals(batch, {
|
|
85
|
+
sourceId, surface, statePath: deps.statePath, sessionLocalId: conversationId,
|
|
86
|
+
now: nowIso, captureModel: deps.captureModel, env: deps.env,
|
|
87
|
+
});
|
|
88
|
+
if (!signals.length) return { deposited: false, reason: 'no-signal', counted: batch.user_prompts.length };
|
|
89
|
+
|
|
90
|
+
const delta = buildCaptureDelta(signals, { sourceSurface: surface, sourceDeviceId: sourceId, capturedAt: nowIso });
|
|
91
|
+
const res = await depositCaptureDelta(pairing, delta, { ...deps, now: () => nowIso });
|
|
92
|
+
if (res && res.deposited === false && (res.status === 401 || (res.reason && /^http-/.test(String(res.reason))))) {
|
|
93
|
+
process.stderr.write(`[phaedo capture:a11y] deposit rejected (${res.status || res.reason}) — re-pair so capture can sync.\n`);
|
|
94
|
+
}
|
|
95
|
+
return { ...res, counted: batch.user_prompts.length, signals: signals.length };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── script entry (the native helper spawns `node capture-a11y.js` and pipes JSON) ──────
|
|
99
|
+
const isMain = isMainModule(import.meta.url); // symlink-safe — see lib/is-main.js
|
|
100
|
+
if (isMain) {
|
|
101
|
+
const readStdin = () => new Promise((res) => {
|
|
102
|
+
let data = '';
|
|
103
|
+
process.stdin.on('data', (c) => { data += c; });
|
|
104
|
+
process.stdin.on('end', () => res(data));
|
|
105
|
+
process.stdin.on('error', () => res(''));
|
|
106
|
+
});
|
|
107
|
+
const run = async () => {
|
|
108
|
+
let payload = {};
|
|
109
|
+
try { payload = JSON.parse(await readStdin()); } catch { /* no/bad input → no-op */ }
|
|
110
|
+
await runA11yCapture(payload);
|
|
111
|
+
};
|
|
112
|
+
const timeout = new Promise((res) => setTimeout(res, BUDGET_MS));
|
|
113
|
+
Promise.race([run().catch(() => {}), timeout])
|
|
114
|
+
.then(() => process.exit(0))
|
|
115
|
+
.catch(() => process.exit(0)); // capture must never break the helper
|
|
116
|
+
}
|
package/capture-local.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// The mcp-local merge-owner tier (desktop-capture-deposit.md §4 tier 3).
|
|
2
|
+
//
|
|
3
|
+
// The phone-less, browser-less desktop/CLI user — the Claude Code developer, the
|
|
4
|
+
// audience desktop capture was built FOR — has nobody to drain what their capture
|
|
5
|
+
// hook accumulates: no phone to own the capture_to_owner lane, no extension to
|
|
6
|
+
// materialize it. But the hook and this server share a machine AND a state dir:
|
|
7
|
+
// the hook accumulates cumulative Path-A counters in capture/state.js
|
|
8
|
+
// (~/.phaedo-mcp/capture-state.json) even when unpaired. This module folds that
|
|
9
|
+
// local state into the fingerprint the server SERVES, at read time:
|
|
10
|
+
//
|
|
11
|
+
// - READ-TIME OVERLAY, no persistence. The lift is a cumulative snapshot
|
|
12
|
+
// (delta-lift's idempotency contract), so re-folding on every load converges
|
|
13
|
+
// to the same result — there is nothing to store and no race to manage.
|
|
14
|
+
// The local fingerprint.json is never rewritten; discard-raw holds.
|
|
15
|
+
// - OWNER-GATED. Only when this server is the merge owner per the presence
|
|
16
|
+
// precedence (capture/owner.js): a configured pairing record means a phone
|
|
17
|
+
// owns the lane (its merged fingerprint arrives through the normal pull),
|
|
18
|
+
// so folding again here would double-count the same signal. The caller
|
|
19
|
+
// (fingerprint-source.js) applies this tier only in local, unpaired mode.
|
|
20
|
+
// - WORKS IN THE PUBLISHED PACKAGE. The capture/extraction modules load via
|
|
21
|
+
// lib/capture-home.js (repo root in dev, mcp/vendor/ in the tarball —
|
|
22
|
+
// build-package.mjs vendors the closure). Loading stays dynamic and any
|
|
23
|
+
// import/IO failure still returns the payload unchanged — the fold is an
|
|
24
|
+
// enrichment, never a gate on serving.
|
|
25
|
+
//
|
|
26
|
+
// Re-broadcast (§5): none needed here. If a phone is paired later, the hook's
|
|
27
|
+
// normal capture_to_owner deposits carry the CUMULATIVE counts, so the phone
|
|
28
|
+
// inherits this machine's full history on its first drain — and this tier stops
|
|
29
|
+
// applying (owner-gated), so nothing forks.
|
|
30
|
+
|
|
31
|
+
import { loadCaptureModule } from './lib/capture-home.js';
|
|
32
|
+
|
|
33
|
+
// Build per-source snapshots from the hook's local capture state. Loading stays
|
|
34
|
+
// try-wrapped so a broken layout (neither repo root nor vendor/) degrades to
|
|
35
|
+
// "no snapshots" instead of failing the serve.
|
|
36
|
+
async function localSnapshots(now) {
|
|
37
|
+
let stateMod, liftMod;
|
|
38
|
+
try {
|
|
39
|
+
stateMod = await loadCaptureModule('capture/state.js');
|
|
40
|
+
liftMod = await loadCaptureModule('extraction/delta-lift.js');
|
|
41
|
+
} catch {
|
|
42
|
+
return []; // no capture home in this layout
|
|
43
|
+
}
|
|
44
|
+
const state = stateMod.loadState(stateMod.defaultStatePath());
|
|
45
|
+
const out = [];
|
|
46
|
+
for (const sourceId of Object.keys(state.surfaces || {})) {
|
|
47
|
+
const counts = stateMod.cumulativeCounts(state, sourceId);
|
|
48
|
+
const signals = liftMod.liftDeltaSignals(counts.behavioral_signals, now);
|
|
49
|
+
if (!signals.length) continue;
|
|
50
|
+
const updatedAt = state.surfaces[sourceId] && state.surfaces[sourceId].updated_at;
|
|
51
|
+
out.push({ surface: sourceId, captured_at: updatedAt || now, signals });
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Fold the local capture state into a vault payload's fingerprint and return the
|
|
57
|
+
// folded payload (or the input unchanged when there is nothing to fold / the
|
|
58
|
+
// modules are unavailable). Pure with respect to the input object.
|
|
59
|
+
export async function foldLocalCapture(vaultPayload, { now = new Date().toISOString() } = {}) {
|
|
60
|
+
if (!vaultPayload || typeof vaultPayload !== 'object' || !vaultPayload.phaedo_fingerprint) return vaultPayload;
|
|
61
|
+
try {
|
|
62
|
+
const sources = await localSnapshots(now);
|
|
63
|
+
if (!sources.length) return vaultPayload;
|
|
64
|
+
const { mergeExtractionInto } = await loadCaptureModule('extraction/persist.js');
|
|
65
|
+
// Empty own-update: the server has no counters of its own; the combine builds
|
|
66
|
+
// the delta update entirely from the source snapshots, then the standard
|
|
67
|
+
// reconcile folds it into the fingerprint (creating layers as needed).
|
|
68
|
+
const fp = mergeExtractionInto(vaultPayload.phaedo_fingerprint, {}, now, sources);
|
|
69
|
+
return { ...vaultPayload, phaedo_fingerprint: fp };
|
|
70
|
+
} catch {
|
|
71
|
+
return vaultPayload; // enrichment only — never break serving
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Is this server the capture merge owner? Thin wrapper over capture/owner.js so
|
|
76
|
+
// the precedence lives in exactly one place; an unloadable module (broken
|
|
77
|
+
// layout) → not owner (the fold simply doesn't apply).
|
|
78
|
+
export async function isLocalCaptureOwner(phonePaired) {
|
|
79
|
+
try {
|
|
80
|
+
const { isOwner } = await loadCaptureModule('capture/owner.js');
|
|
81
|
+
return isOwner('mcp-local', { phonePaired: !!phonePaired, extensionPresent: false });
|
|
82
|
+
} catch {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
package/capture-sync.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Capture-delta transport (capture surface → relay → merge owner).
|
|
2
|
+
//
|
|
3
|
+
// Track A of the desktop-capture plan. The Claude Code / Cowork capture hook reads the
|
|
4
|
+
// local session transcript, runs the surface-agnostic extraction engine, and deposits
|
|
5
|
+
// the resulting SIGNAL DELTA (never a whole fingerprint) into the relay's
|
|
6
|
+
// `capture_to_owner` mailbox lane, encrypted under fp_sync_key. Exactly one merge owner
|
|
7
|
+
// (phone → extension → mcp-local, capture/owner.js) drains and reconciles it per-source
|
|
8
|
+
// (capture/source-id.js), so concurrent capture on multiple surfaces accumulates instead
|
|
9
|
+
// of clobbering — docs/roadmap/desktop-capture-deposit.md.
|
|
10
|
+
//
|
|
11
|
+
// Mirrors delegation-sync.js in trust posture, but NOT in key sensitivity: this lane is
|
|
12
|
+
// drained by the PHONE, which tries each verified client's key until one decrypts, so an
|
|
13
|
+
// independently-paired MCP works fine here (unlike agent_to_ext — see delegation-sync.js).
|
|
14
|
+
// The relay only ever holds ciphertext; and the deposit is best-effort and
|
|
15
|
+
// isolated — a transport failure never breaks the session that produced the capture.
|
|
16
|
+
//
|
|
17
|
+
// The lane carries a DELTA, so a deposit can never overwrite signal the owner captured
|
|
18
|
+
// on its own; and per-source idempotency (source_device_id) means a re-deposit or a
|
|
19
|
+
// transient two-owner overlap is a no-op, not a double-count.
|
|
20
|
+
|
|
21
|
+
import { Phaedo, b64uDec } from './lib/phaedo-crypto.js';
|
|
22
|
+
|
|
23
|
+
const { Kdf, Envelope } = Phaedo;
|
|
24
|
+
const DEFAULT_RELAY = 'https://phaedo-relay.fly.dev';
|
|
25
|
+
|
|
26
|
+
export function relayBaseFromEnv(env = process.env) {
|
|
27
|
+
return env.PHAEDO_RELAY || DEFAULT_RELAY;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Build the canonical capture-delta payload from lifted signals (Q1: lift on the
|
|
31
|
+
// surface — the hook runs extraction and ships §5.4 signals, not raw counters).
|
|
32
|
+
// signals — the extraction engine's signal list (source-tagged; 'delta' snapshots
|
|
33
|
+
// are idempotent on re-lift).
|
|
34
|
+
export function buildCaptureDelta(signals, { sourceSurface, sourceDeviceId, capturedAt } = {}) {
|
|
35
|
+
return {
|
|
36
|
+
capture_delta_version: 'v0.1',
|
|
37
|
+
source_surface: sourceSurface || 'unknown',
|
|
38
|
+
source_device_id: sourceDeviceId || null,
|
|
39
|
+
captured_at: capturedAt || null,
|
|
40
|
+
delta: { signals: Array.isArray(signals) ? signals : [] },
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Wrap a capture-delta into the same envelope shape the owner's fp-mailbox drain
|
|
45
|
+
// expects: ciphertext over `{ kind:'capture_delta', data: <captureDelta> }`. Returns the
|
|
46
|
+
// deposit body `{ envelope }`. Pure crypto (no network) so the round-trip is testable.
|
|
47
|
+
export async function buildCaptureDeposit(pairKeyB64u, captureDelta, { now = () => new Date().toISOString() } = {}) {
|
|
48
|
+
const pairKey = b64uDec(pairKeyB64u);
|
|
49
|
+
let fpSyncKey;
|
|
50
|
+
try {
|
|
51
|
+
fpSyncKey = await Kdf.deriveFpSyncKey(pairKey);
|
|
52
|
+
} finally {
|
|
53
|
+
pairKey.fill(0);
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
const inner = { kind: 'capture_delta', data: captureDelta };
|
|
57
|
+
const plaintext = new TextEncoder().encode(JSON.stringify(inner));
|
|
58
|
+
const envelope = await Envelope.wrap(plaintext, fpSyncKey, { artifact_kind: 'capture_delta', created_at: now() });
|
|
59
|
+
plaintext.fill(0);
|
|
60
|
+
return { envelope };
|
|
61
|
+
} finally {
|
|
62
|
+
if (fpSyncKey && fpSyncKey.fill) fpSyncKey.fill(0);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Deposit a capture-delta to the relay's capture_to_owner slot. Best-effort: returns
|
|
67
|
+
// { deposited, ... } and never throws. `deps.fetch` is injectable for tests.
|
|
68
|
+
export async function depositCaptureDelta(pairing, captureDelta, deps = {}) {
|
|
69
|
+
const fetchFn = deps.fetch || globalThis.fetch;
|
|
70
|
+
const signals = captureDelta && captureDelta.delta && Array.isArray(captureDelta.delta.signals)
|
|
71
|
+
? captureDelta.delta.signals : [];
|
|
72
|
+
// Precise reasons so the caller can log WHY a deposit didn't happen.
|
|
73
|
+
if (!signals.length) return { deposited: false, reason: 'no-signal' };
|
|
74
|
+
if (!pairing || !pairing.pair_key) return { deposited: false, reason: 'unpaired' };
|
|
75
|
+
if (!pairing.relay_device_id) return { deposited: false, reason: 'no-relay-device-id' };
|
|
76
|
+
try {
|
|
77
|
+
const body = await buildCaptureDeposit(pairing.pair_key, captureDelta, deps);
|
|
78
|
+
// Sub-key the lane per source so a second surface on this device doesn't clobber the
|
|
79
|
+
// hook's deposit before the owner drains (desktop-capture-deposit.md). Falls back to
|
|
80
|
+
// the flat lane only if the delta somehow lacks a source id (pre-source-id builds).
|
|
81
|
+
const src = captureDelta && captureDelta.source_device_id;
|
|
82
|
+
const lane = src ? `capture_to_owner/${encodeURIComponent(src)}` : 'capture_to_owner';
|
|
83
|
+
const base = `${deps.relayBase || relayBaseFromEnv()}/fp-mailbox/${encodeURIComponent(pairing.relay_device_id)}/${lane}`;
|
|
84
|
+
// Per-device auth header (relay/auth.js). Without it the relay 401s → deposit no-ops
|
|
85
|
+
// (correct failure mode: the user re-pairs to capture the relay_device_secret).
|
|
86
|
+
const authHeader = pairing.relay_device_secret
|
|
87
|
+
? { Authorization: `Bearer ${pairing.relay_device_id}:${pairing.relay_device_secret}` }
|
|
88
|
+
: {};
|
|
89
|
+
const res = await fetchFn(base, {
|
|
90
|
+
method: 'POST',
|
|
91
|
+
headers: { 'Content-Type': 'application/json', ...authHeader },
|
|
92
|
+
body: JSON.stringify(body),
|
|
93
|
+
signal: AbortSignal.timeout(deps.timeoutMs || 8000),
|
|
94
|
+
});
|
|
95
|
+
return { deposited: !!(res && res.ok), status: res && res.status, count: signals.length };
|
|
96
|
+
} catch (e) {
|
|
97
|
+
return { deposited: false, reason: String(e && e.message || e) };
|
|
98
|
+
}
|
|
99
|
+
}
|