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.
Files changed (62) hide show
  1. package/NOTICE +17 -0
  2. package/README.md +51 -9
  3. package/action-binding.js +82 -0
  4. package/agreement.js +38 -0
  5. package/ap2.js +214 -0
  6. package/autonomy-promotion.js +280 -0
  7. package/build-package.mjs +25 -10
  8. package/capture-a11y.js +116 -0
  9. package/capture-local.js +85 -0
  10. package/capture-sync.js +99 -0
  11. package/consult-core.js +81 -19
  12. package/consult-policy.js +427 -33
  13. package/consult.js +25 -3
  14. package/delegation-sync.js +89 -0
  15. package/escalation-policy.sample.json +13 -0
  16. package/escalation.js +383 -0
  17. package/fingerprint-source.js +78 -4
  18. package/health.js +54 -0
  19. package/hooks/session-end.mjs +240 -0
  20. package/install.mjs +164 -21
  21. package/lib/capture-home.js +46 -0
  22. package/lib/domain.js +101 -0
  23. package/lib/is-main.js +40 -0
  24. package/lib/phaedo-crypto.js +17 -1
  25. package/package.json +26 -6
  26. package/pairing.js +5 -1
  27. package/phone-source.js +92 -12
  28. package/projection.js +4 -1
  29. package/pull.js +19 -3
  30. package/receipts-sync.js +197 -0
  31. package/receipts.js +69 -16
  32. package/server.js +305 -15
  33. package/server.json +4 -4
  34. package/signing-audit.js +111 -0
  35. package/signing-request.js +217 -0
  36. package/signing.js +176 -0
  37. package/vendor/authored-source.js +250 -0
  38. package/vendor/capabilities.js +203 -0
  39. package/vendor/capture/capture-core.js +67 -0
  40. package/vendor/capture/detectors.js +287 -0
  41. package/vendor/capture/exchanges.js +77 -0
  42. package/vendor/capture/model-detect.js +195 -0
  43. package/vendor/capture/owner.js +35 -0
  44. package/vendor/capture/package.json +4 -0
  45. package/vendor/capture/source-id.js +56 -0
  46. package/vendor/capture/state.js +148 -0
  47. package/vendor/capture/transcript.js +153 -0
  48. package/vendor/commitments.js +169 -0
  49. package/vendor/context-block.js +251 -18
  50. package/vendor/envelope.js +50 -4
  51. package/vendor/extraction/capture-combine.js +160 -0
  52. package/vendor/extraction/catalog.js +232 -0
  53. package/vendor/extraction/delta-lift.js +109 -0
  54. package/vendor/extraction/fingerprint-update.js +227 -0
  55. package/vendor/extraction/package.json +4 -0
  56. package/vendor/extraction/persist.js +40 -0
  57. package/vendor/extraction/reconcile.js +269 -0
  58. package/vendor/extraction/resolve.js +178 -0
  59. package/vendor/extraction/rule-promotion.js +137 -0
  60. package/vendor/kdf.js +20 -1
  61. package/vendor/protocol.js +7 -0
  62. 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 two sets of files at
3
- // the repo root (one source of truth, shared with the extension): the injection
4
- // renderer (context-block.js, via projection.js) and the classic crypto modules
5
- // (protocol/envelope/kdf/identity.js, via lib/phaedo-crypto.js). The published
6
- // package ships only mcp/, so this copies those root modules into mcp/vendor/;
7
- // projection.js and lib/phaedo-crypto.js prefer the vendored copies when present
8
- // and fall back to the repo root in dev. Runs as `prepack` (npm pack / publish)
9
- // and `npm run build`. Idempotent; mcp/vendor/ is gitignored (a build artifact).
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
- const FILES = ['context-block.js', 'protocol.js', 'envelope.js', 'kdf.js', 'identity.js'];
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
- process.stdout.write(`[phaedo-mcp] bundled ${FILES.length} root modules → mcp/vendor/\n`);
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`);
@@ -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
+ }
@@ -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
+ }
@@ -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
+ }