phaedo-mcp 0.3.19 → 0.3.22
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/cache.js +11 -0
- package/fingerprint-source.js +8 -1
- package/hooks/pre-ship.mjs +13 -5
- package/package.json +3 -2
- package/precedent.js +106 -3
- package/profile-inbox.js +114 -0
- package/pull.js +2 -1
- package/receipts-sync.js +7 -2
- package/server.js +35 -6
- package/server.json +2 -2
- package/weekly.js +43 -3
package/cache.js
CHANGED
|
@@ -58,6 +58,17 @@ export async function clearCache(stateDir) {
|
|
|
58
58
|
try { await rm(join(stateDir, 'cache.enc'), { force: true }); } catch { /* nothing to clear */ }
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
// When the at-rest copy was made (ms), without decrypting it; null when there is none. The phone
|
|
62
|
+
// shows this age (receipts digest `copy_at`), so the subject can see the copy keeping up.
|
|
63
|
+
export async function readCacheTime(stateDir) {
|
|
64
|
+
try {
|
|
65
|
+
const { cached_at } = JSON.parse(await readFile(join(stateDir, 'cache.enc'), 'utf8'));
|
|
66
|
+
return Number.isFinite(cached_at) && cached_at > 0 ? cached_at : null;
|
|
67
|
+
} catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
61
72
|
// Decrypt the cache → { vault, cachedAt } or null (absent/corrupt/undecryptable
|
|
62
73
|
// → treated as no cache, since it's recoverable from the phone).
|
|
63
74
|
export async function readCache(stateDir) {
|
package/fingerprint-source.js
CHANGED
|
@@ -15,6 +15,7 @@ import { resolve, dirname } from 'path';
|
|
|
15
15
|
import { fileURLToPath } from 'url';
|
|
16
16
|
import { hasPairingRecord, loadPairingRecord, loadFingerprintFromPhone, PhoneError } from './phone-source.js';
|
|
17
17
|
import { defaultStateDir, writeCache, readCache, clearCache } from './cache.js';
|
|
18
|
+
import { maybeDrainProfileCopy } from './profile-inbox.js';
|
|
18
19
|
import { foldLocalCapture, isLocalCaptureOwner, localCaptureSessionCount } from './capture-local.js';
|
|
19
20
|
import { Capabilities, AuthoredSource } from './lib/phaedo-crypto.js';
|
|
20
21
|
import { PhaedoError } from './errors.js';
|
|
@@ -307,8 +308,11 @@ async function loadFingerprintUnfiltered(baseDir) {
|
|
|
307
308
|
await clearCache(stateDir);
|
|
308
309
|
throw err;
|
|
309
310
|
}
|
|
311
|
+
// Phone unreachable → the copy the phone last pushed for us, if it is newer (profile-inbox.js),
|
|
312
|
+
// then the encrypted cache (may be stale).
|
|
313
|
+
await maybeDrainProfileCopy(async () => rec, stateDir);
|
|
310
314
|
const cached = await readCache(stateDir);
|
|
311
|
-
if (cached) return stamp(foldConfigHome(normalizeIdentity(cached.vault)), cached);
|
|
315
|
+
if (cached) return stamp(foldConfigHome(normalizeIdentity(cached.vault)), cached);
|
|
312
316
|
throw err; // no phone + no cache → cannot serve
|
|
313
317
|
}
|
|
314
318
|
}
|
|
@@ -371,6 +375,9 @@ async function loadFingerprintUnfiltered(baseDir) {
|
|
|
371
375
|
// escalation in flight.
|
|
372
376
|
export async function loadFingerprintPreferCache(baseDir, opts) {
|
|
373
377
|
if (!process.env.PHAEDO_FINGERPRINT && hasPairingRecord(baseDir)) {
|
|
378
|
+
// The phone pushes a fresh copy whenever its app opens or the profile changes; take it first
|
|
379
|
+
// (throttled, fail-fast — this is the ship hook's path). See profile-inbox.js.
|
|
380
|
+
await maybeDrainProfileCopy(() => loadPairingRecord(baseDir), defaultStateDir());
|
|
374
381
|
const cached = await readCache(defaultStateDir());
|
|
375
382
|
if (cached) return forgetting(stamp(foldConfigHome(normalizeIdentity(cached.vault)), cached), opts);
|
|
376
383
|
}
|
package/hooks/pre-ship.mjs
CHANGED
|
@@ -328,8 +328,8 @@ export function unpromptedContext(receiptId) {
|
|
|
328
328
|
|
|
329
329
|
export function staleContext(days, mcpDir) {
|
|
330
330
|
return `Also: Phaedo's copy of the user's profile is ${days} days old (their phone has not been ` +
|
|
331
|
-
`reached since). Mention once, in one line, that
|
|
332
|
-
|
|
331
|
+
`reached since). Mention once, in one line, that opening the Phaedo app on their phone refreshes ` +
|
|
332
|
+
`it within minutes, or they can run \`cd ${mcpDir} && npm run pull\` and approve on their phone.`;
|
|
333
333
|
}
|
|
334
334
|
|
|
335
335
|
/**
|
|
@@ -421,8 +421,10 @@ export async function runShipHook(input, deps) {
|
|
|
421
421
|
const extra = [];
|
|
422
422
|
if (ci) extra.push({ cue: 'ciState', value: ci, confidence: null, basis: ci === 'unknown' ? null : 'observed' });
|
|
423
423
|
if (ci && deps.prFacts) extra.push({ cue: 'diffStakes', value: stakesValue(stakes), confidence: null, basis: stakes ? 'observed' : null });
|
|
424
|
-
|
|
425
|
-
|
|
424
|
+
// First, not last: buildReceipt keeps only the first 16 drivers, and the weekly summary on the
|
|
425
|
+
// phone counts this one ("your standing rule decided N of them", weekly.js).
|
|
426
|
+
const lead = grant ? [{ cue: 'standingGrant', value: granted ? 'answered' : `held:${grant.why}`, confidence: null, basis: 'observed' }] : [];
|
|
427
|
+
const allDrivers = (extra.length || lead.length) ? [...lead, ...(drivers || []), ...extra] : drivers;
|
|
426
428
|
const receiptId = await deps.writeReceipt({ request, response, drivers: allDrivers });
|
|
427
429
|
if (!receiptId) return null; // no receipt = nothing to learn from; stay out of the way
|
|
428
430
|
|
|
@@ -504,6 +506,7 @@ export async function realDeps({ agentId = AGENT_ID, statePath } = {}) {
|
|
|
504
506
|
const { resolveConsultation, consultDrivers, withRevision, checkAuthorization } = await import('../consult.js');
|
|
505
507
|
const { buildReceipt, appendReceipt, receiptsCapFromEnv, readReceipts } = await import('../receipts.js');
|
|
506
508
|
const { sourceOf } = await import('../fingerprint-source.js');
|
|
509
|
+
const { precedentAdjustment } = await import('../precedent.js');
|
|
507
510
|
const { defaultStateDir } = await import('../cache.js');
|
|
508
511
|
const stateDir = defaultStateDir();
|
|
509
512
|
const quiet = () => {};
|
|
@@ -529,9 +532,14 @@ export async function realDeps({ agentId = AGENT_ID, statePath } = {}) {
|
|
|
529
532
|
async consult(request) {
|
|
530
533
|
const vd = await loadConsultVd(MCP_DIR, { preferCache: true, warn: quiet, onRead: (plain) => { readSource = sourceOf(plain); } });
|
|
531
534
|
consultedVd = vd;
|
|
532
|
-
|
|
535
|
+
let response = await resolveConsultation(vd, request, opts);
|
|
533
536
|
let drivers = [];
|
|
534
537
|
try { drivers = withRevision(consultDrivers(vd, request, opts), response); } catch { /* provenance is non-critical */ }
|
|
538
|
+
// Your past decisions in this situation move the confidence only (precedent.js Phase 3).
|
|
539
|
+
try {
|
|
540
|
+
const adj = precedentAdjustment(await readReceipts(stateDir), { request, drivers, response });
|
|
541
|
+
if (adj) { response = { ...response, confidence: adj.confidence }; drivers = [adj.driver, ...drivers]; }
|
|
542
|
+
} catch { /* precedent is non-critical */ }
|
|
535
543
|
// The plain read's provenance — `sourceOf(vd)` on the overlay always says "live", which is
|
|
536
544
|
// also why the 14-day refresh nudge below never fired before 0.3.17.
|
|
537
545
|
return { response, drivers, source: readSource };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "phaedo-mcp",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.22",
|
|
4
4
|
"private": false,
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
"server.js",
|
|
37
37
|
"server.json",
|
|
38
38
|
"fingerprint-source.js",
|
|
39
|
+
"profile-inbox.js",
|
|
39
40
|
"projection.js",
|
|
40
41
|
"errors.js",
|
|
41
42
|
"consult.js",
|
|
@@ -100,7 +101,7 @@
|
|
|
100
101
|
"unsetup": "node install.mjs --uninstall",
|
|
101
102
|
"pair": "node pair.js",
|
|
102
103
|
"pull": "node pull.js",
|
|
103
|
-
"test": "node test-smoke.mjs && node test-no-profile.mjs && node test-phone-boundary.mjs && node test-cache.mjs && node test-pairing.mjs && node test-revoke.mjs && node test-install.mjs && node test-consult.mjs && node test-consult-policy.mjs && node test-amount-bands.mjs && node test-resolver-revision.mjs && node test-ship-actions.mjs && node test-ship-hook.mjs && node test-ship-readout.mjs && node test-action-binding.mjs && node test-autonomy-promotion.mjs && node test-ap2.mjs && node test-delegation-sync.mjs && node test-delegation-relay.mjs && node test-capture-sync.mjs && node test-capture-hook.mjs && node test-stop-capture.mjs && node test-capture-local.mjs && node test-capture-a11y.mjs && node test-vendored-capture.mjs && node test-escalation.mjs && node test-domain.mjs && node test-receipts.mjs && node test-receipts-sync.mjs && node test-agreement.mjs && node test-weekly.mjs && node test-asks.mjs && node test-precedent.mjs && node test-rules-files.mjs && node test-agent-hook.mjs && node test-learned.mjs && node test-history-import.mjs && node test-calibration.mjs && node test-identity.mjs && node test-signing.mjs && node test-sign-request.mjs && node test-signing-audit.mjs && node test-delegation-domain-fragmentation.mjs && node test-stale-authority.mjs && node test-config-home-fold.mjs && node test-precedence-and-ceiling.mjs && node test-package-closure.mjs && node test-plugin.mjs",
|
|
104
|
+
"test": "node test-smoke.mjs && node test-no-profile.mjs && node test-phone-boundary.mjs && node test-cache.mjs && node test-profile-inbox.mjs && node test-pairing.mjs && node test-revoke.mjs && node test-install.mjs && node test-consult.mjs && node test-consult-policy.mjs && node test-amount-bands.mjs && node test-resolver-revision.mjs && node test-ship-actions.mjs && node test-ship-hook.mjs && node test-ship-readout.mjs && node test-action-binding.mjs && node test-autonomy-promotion.mjs && node test-ap2.mjs && node test-delegation-sync.mjs && node test-delegation-relay.mjs && node test-capture-sync.mjs && node test-capture-hook.mjs && node test-stop-capture.mjs && node test-capture-local.mjs && node test-capture-a11y.mjs && node test-vendored-capture.mjs && node test-escalation.mjs && node test-domain.mjs && node test-receipts.mjs && node test-receipts-sync.mjs && node test-agreement.mjs && node test-weekly.mjs && node test-asks.mjs && node test-precedent.mjs && node test-rules-files.mjs && node test-agent-hook.mjs && node test-learned.mjs && node test-history-import.mjs && node test-calibration.mjs && node test-identity.mjs && node test-signing.mjs && node test-sign-request.mjs && node test-signing-audit.mjs && node test-delegation-domain-fragmentation.mjs && node test-stale-authority.mjs && node test-config-home-fold.mjs && node test-precedence-and-ceiling.mjs && node test-package-closure.mjs && node test-plugin.mjs",
|
|
104
105
|
"receipts": "node receipts.js list",
|
|
105
106
|
"agreement": "node agreement.js",
|
|
106
107
|
"ship-readout": "node ship-readout.js",
|
package/precedent.js
CHANGED
|
@@ -6,8 +6,21 @@
|
|
|
6
6
|
// derived view that would say so — over the §10.6 receipt store, never a stored artifact.
|
|
7
7
|
//
|
|
8
8
|
// PURE: (receipts, opts) -> report. No I/O, `now` injected — the agreement.js discipline.
|
|
9
|
-
//
|
|
10
|
-
//
|
|
9
|
+
// Readout: `npm run precedent`.
|
|
10
|
+
//
|
|
11
|
+
// PHASE 3 (2026-11-03, Randy: "Yes, all four" — design doc §9 decisions 2–5). A consult now reads
|
|
12
|
+
// the precedent for its own situation, and it moves the answer's CONFIDENCE only:
|
|
13
|
+
// - the key is CONSULT_FIELDS: domain × kind × reversible × magnitude × amount band × binding.
|
|
14
|
+
// The amount bands are the subject's own edges; size/touched stay out until most receipts
|
|
15
|
+
// carry them (0009 Decided 1). On the reference store this groups exactly as the coarse key;
|
|
16
|
+
// - N = 5 within 180 days (the defaults below);
|
|
17
|
+
// - the SIGNAL never changes — not escalate → decisive, never anything → autoproceed (rail 4);
|
|
18
|
+
// - confidence is RAISED only once rejections are measured for the shape (MIN_SUPPORT marked
|
|
19
|
+
// decisions since "no" capture shipped, NO_CAPTURE_SINCE). Before that, "no" is undercounted
|
|
20
|
+
// and every shape looks agreeable, so precedent may only LOWER confidence (0009 Decided 3);
|
|
21
|
+
// - the counts reach NO agent. Disclosure is scoped to agents the subject allowed (decision 2),
|
|
22
|
+
// and in this version that set is empty — the counts stay on the device, in the receipt's
|
|
23
|
+
// `precedent` driver. Only the moved confidence crosses, a field every answer already has.
|
|
11
24
|
//
|
|
12
25
|
// The SHAPE is the situation key — banded features only, a closed vocabulary per field, so
|
|
13
26
|
// nothing a receipt holds on-device (summary, exact amount, counterparty, ids, previews) can
|
|
@@ -42,6 +55,17 @@ export const DEFAULT_WINDOW_DAYS = 180;
|
|
|
42
55
|
export const MIN_SUPPORT = 5;
|
|
43
56
|
export const BATCH_MIN = 3; // this many marks inside BATCH_WINDOW_MS = a burst
|
|
44
57
|
export const PREFLIGHT_VIA = 'phaedo_check_authorization';
|
|
58
|
+
// The key a consult matches on (Phase 3): every field but size/touched.
|
|
59
|
+
export const CONSULT_FIELDS = Object.freeze(['domain', 'kind', 'reversible', 'magnitude', 'amount_band', 'binding']);
|
|
60
|
+
// Claude Code's ship hook began recording a denied prompt here (phaedo-mcp 0.3.14). Before it,
|
|
61
|
+
// a "no" reached the store only if the agent remembered to record one.
|
|
62
|
+
export const NO_CAPTURE_SINCE = Date.parse('2026-10-27T00:00:00Z');
|
|
63
|
+
export const RECENT_MISS_DAYS = 30;
|
|
64
|
+
const RAISE_AT = 0.9; // this share of your outcomes agree with the answer → more sure
|
|
65
|
+
const LOWER_BELOW = 0.6; // under this share → less sure
|
|
66
|
+
const RAISE_BY = 0.1;
|
|
67
|
+
const LOWER_FACTOR = 0.75;
|
|
68
|
+
const CEILING = 0.95; // precedent alone never makes an inferred answer certain
|
|
45
69
|
|
|
46
70
|
const DAY = 24 * 60 * 60 * 1000;
|
|
47
71
|
const OUTCOMES = new Set(['approved', 'rejected', 'modified']);
|
|
@@ -173,6 +197,9 @@ export function precedentFor(receipts, shape, {
|
|
|
173
197
|
if (main.length < minSupport) return null;
|
|
174
198
|
const pre = match.filter(({ r }) => r.via === PREFLIGHT_VIA);
|
|
175
199
|
const { observed, user_actions, oracle_agreement, most_recent_divergence_days_ago } = tally(main, now);
|
|
200
|
+
// The bias disclosed beside the figure (0009 Decided 3): enough of these decisions postdate
|
|
201
|
+
// "no" capture that a rejection rate is measured, not missing.
|
|
202
|
+
const rejections_measured = main.filter(({ t }) => t >= NO_CAPTURE_SINCE).length >= minSupport;
|
|
176
203
|
return {
|
|
177
204
|
shape: projectShape(shape, fields),
|
|
178
205
|
shape_revision: SHAPE_REVISION,
|
|
@@ -181,10 +208,46 @@ export function precedentFor(receipts, shape, {
|
|
|
181
208
|
oracle_agreement,
|
|
182
209
|
window_days: windowDays,
|
|
183
210
|
most_recent_divergence_days_ago,
|
|
211
|
+
rejections_measured,
|
|
184
212
|
preflight: { observed: pre.length, user_actions: tally(pre, now).user_actions },
|
|
185
213
|
};
|
|
186
214
|
}
|
|
187
215
|
|
|
216
|
+
const round2 = (x) => Math.round(x * 100) / 100;
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* How the subject's past decisions in this situation bear on a consult's answer (Phase 3).
|
|
220
|
+
* `request` is the consult request, `drivers` its provenance (the amount band rides there),
|
|
221
|
+
* `response` the answer after every guard. Returns null when precedent has nothing to say —
|
|
222
|
+
* an undecisive signal, no confidence, or under minimum support — else
|
|
223
|
+
* { confidence, effect: 'raised' | 'lowered' | 'held', driver }
|
|
224
|
+
* where `driver` is the on-device receipt entry: `precedent`, value `<effect>:<agreeing>/<observed>`.
|
|
225
|
+
* Never touches the signal (rail 4). Pure.
|
|
226
|
+
*/
|
|
227
|
+
export function precedentAdjustment(receipts, { request, drivers, response } = {}, opts = {}) {
|
|
228
|
+
const signal = response?.signal;
|
|
229
|
+
if (!DECISIVE.includes(signal) || typeof response.confidence !== 'number') return null;
|
|
230
|
+
const p = precedentFor(receipts, shapeOf({ request, drivers }), { ...opts, fields: CONSULT_FIELDS });
|
|
231
|
+
if (!p) return null;
|
|
232
|
+
const agreeing = signal === 'decline' ? p.user_actions.rejected : p.user_actions.approved;
|
|
233
|
+
const share = agreeing / p.observed;
|
|
234
|
+
const recentMiss = p.most_recent_divergence_days_ago !== null && p.most_recent_divergence_days_ago <= RECENT_MISS_DAYS;
|
|
235
|
+
let effect = 'held';
|
|
236
|
+
let confidence = response.confidence;
|
|
237
|
+
if (share < LOWER_BELOW || recentMiss) {
|
|
238
|
+
effect = 'lowered';
|
|
239
|
+
confidence = round2(confidence * LOWER_FACTOR);
|
|
240
|
+
} else if (share >= RAISE_AT && p.rejections_measured && confidence < CEILING) {
|
|
241
|
+
effect = 'raised';
|
|
242
|
+
confidence = round2(Math.min(CEILING, confidence + RAISE_BY));
|
|
243
|
+
}
|
|
244
|
+
return {
|
|
245
|
+
confidence,
|
|
246
|
+
effect,
|
|
247
|
+
driver: { cue: 'precedent', value: `${effect}:${agreeing}/${p.observed}`, confidence: null, basis: 'observed' },
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
188
251
|
/**
|
|
189
252
|
* The divergence episodes — the only thing 0009's principle says is worth keeping: receipts
|
|
190
253
|
* where a decisive prediction did not match what the subject did. Banded, newest first.
|
|
@@ -236,6 +299,46 @@ export function precedentTable(receipts, {
|
|
|
236
299
|
};
|
|
237
300
|
}
|
|
238
301
|
|
|
302
|
+
/**
|
|
303
|
+
* Did precedent help? (Randy, 2026-11-05: "improve what we built".) Over the receipts it touched
|
|
304
|
+
* since `since`: how often it made an answer less sure, more sure or left it, and — among those
|
|
305
|
+
* the subject has since marked — how often that was right. Lowering was right when the subject
|
|
306
|
+
* decided against the answer; raising was right when they decided with it. Pure.
|
|
307
|
+
*/
|
|
308
|
+
export function precedentCheck(receipts, { now = Date.now(), since = 0 } = {}) {
|
|
309
|
+
const out = {
|
|
310
|
+
lowered: { n: 0, marked: 0, right: 0 },
|
|
311
|
+
raised: { n: 0, marked: 0, right: 0 },
|
|
312
|
+
held: { n: 0, marked: 0, right: 0 },
|
|
313
|
+
};
|
|
314
|
+
for (const r of Array.isArray(receipts) ? receipts : []) {
|
|
315
|
+
const t = Date.parse(r?.created_at);
|
|
316
|
+
if (!Number.isFinite(t) || t < since || t > now) continue;
|
|
317
|
+
const d = Array.isArray(r?.drivers) ? r.drivers.find((x) => x?.cue === 'precedent') : null;
|
|
318
|
+
const effect = typeof d?.value === 'string' ? d.value.split(':')[0] : null;
|
|
319
|
+
if (!out[effect]) continue;
|
|
320
|
+
out[effect].n++;
|
|
321
|
+
if (!OUTCOMES.has(r?.user_action) || !DECISIVE.includes(r?.response?.signal)) continue;
|
|
322
|
+
out[effect].marked++;
|
|
323
|
+
const sided = agrees(r.response.signal, r.user_action);
|
|
324
|
+
if (effect === 'lowered' ? !sided : sided) out[effect].right++;
|
|
325
|
+
}
|
|
326
|
+
return out;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export function formatCheck(c) {
|
|
330
|
+
const total = c.lowered.n + c.raised.n + c.held.n;
|
|
331
|
+
if (!total) return 'Your past decisions have not touched an answer yet.';
|
|
332
|
+
const line = (label, x, rightWord) => x.n
|
|
333
|
+
? ` ${label} ${x.n}× — ${x.marked ? `${x.right} of ${x.marked} you decided were ${rightWord}` : 'none decided yet'}`
|
|
334
|
+
: null;
|
|
335
|
+
return ['How your past decisions changed answers:',
|
|
336
|
+
line('Less sure', c.lowered, 'right to doubt'),
|
|
337
|
+
line('More sure', c.raised, 'right to trust'),
|
|
338
|
+
line('Unchanged', c.held, 'as answered'),
|
|
339
|
+
].filter(Boolean).join('\n');
|
|
340
|
+
}
|
|
341
|
+
|
|
239
342
|
const show = (shape) => Object.entries(shape).filter(([, v]) => v !== null && v !== 'none' && v !== '(none)').map(([k, v]) => `${k}=${v}`).join(' ');
|
|
240
343
|
|
|
241
344
|
export function formatTable(t) {
|
|
@@ -263,5 +366,5 @@ if (isMainModule(import.meta.url)) {
|
|
|
263
366
|
const { defaultStateDir } = await import('./cache.js');
|
|
264
367
|
const receipts = await readReceipts(defaultStateDir()).catch(() => []);
|
|
265
368
|
const t = precedentTable(receipts, { windowDays: num('--days', DEFAULT_WINDOW_DAYS), minSupport: num('--min', MIN_SUPPORT), fields: fields.length ? fields : SHAPE_FIELDS });
|
|
266
|
-
process.stdout.write(formatTable(t) + '\n');
|
|
369
|
+
process.stdout.write(formatTable(t) + '\n\n' + formatCheck(precedentCheck(receipts)) + '\n');
|
|
267
370
|
}
|
package/profile-inbox.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// The phone's pushed profile copy (relay lane `profile_to_agent/<client_id>`, 2026-11-02).
|
|
2
|
+
//
|
|
3
|
+
// The live read (phone-source.js) needs the phone's LAN server up and a Face ID tap, so on the
|
|
4
|
+
// reference Mac it almost never completed and the at-rest copy went days stale — past the 7 days
|
|
5
|
+
// a standing rule may run on (lib/rule-leeway.js). The phone now leaves a fresh copy for this
|
|
6
|
+
// agent whenever its app comes to the foreground or the profile changes (mobile
|
|
7
|
+
// agentProfile.js / agentProfilePush.ts), sealed under this agent's own fp_sync_key. Here we pick
|
|
8
|
+
// it up and make it the at-rest copy.
|
|
9
|
+
//
|
|
10
|
+
// What keeps this safe:
|
|
11
|
+
// - It refreshes the CACHE only. A pushed copy is never "live" (sourceOf stays live:false), so
|
|
12
|
+
// the stale-authority guard and the rule leeway treat it exactly as they treat any cache.
|
|
13
|
+
// - Its age is the PHONE's `generated_at`, not when we drained it, so a copy that sat on the
|
|
14
|
+
// relay for days is days old here too. A time in the future is clamped to now.
|
|
15
|
+
// - Newest wins: a copy no newer than the one we hold is ignored, so the relay cannot roll the
|
|
16
|
+
// profile back to before a rule was removed by replaying an older deposit.
|
|
17
|
+
// - Sealed + tagged: only this agent's pair key opens it, and `kind: 'agent_profile'` stops
|
|
18
|
+
// anything else sealed under that key (our own receipts digest) being read as a profile.
|
|
19
|
+
//
|
|
20
|
+
// Best-effort and throttled (CHECK_GAP_MS, shared across processes through a small state file,
|
|
21
|
+
// since the ship hook runs as a fresh process per tool call): a relay failure leaves the copy we
|
|
22
|
+
// have and never fails the read that asked.
|
|
23
|
+
|
|
24
|
+
import { readFile, writeFile, mkdir } from 'fs/promises';
|
|
25
|
+
import { join } from 'path';
|
|
26
|
+
import { Phaedo, b64uDec } from './lib/phaedo-crypto.js';
|
|
27
|
+
import { readCache, writeCache } from './cache.js';
|
|
28
|
+
import { toVaultPayload } from './phone-source.js';
|
|
29
|
+
import { relayBaseFromEnv } from './receipts-sync.js';
|
|
30
|
+
|
|
31
|
+
const { Kdf, Envelope } = Phaedo;
|
|
32
|
+
export const AGENT_PROFILE_KIND = 'agent_profile';
|
|
33
|
+
export const CHECK_GAP_MS = 10 * 60 * 1000;
|
|
34
|
+
const SEGMENT_RE = /^[A-Za-z0-9_.-]{1,128}$/; // relay/mailbox.js LANE_SUB_RE
|
|
35
|
+
const STATE_FILE = 'profile-inbox.json';
|
|
36
|
+
const TIMEOUT_MS = 3000; // on the ship hook's path: fail fast
|
|
37
|
+
|
|
38
|
+
/** Open a pushed copy → { vault, generatedAt } or null (wrong key, wrong kind, malformed). */
|
|
39
|
+
export async function parseAgentProfileDeposit(pairKeyB64u, envelope) {
|
|
40
|
+
if (!envelope || !pairKeyB64u) return null;
|
|
41
|
+
const pairKey = b64uDec(pairKeyB64u);
|
|
42
|
+
let key;
|
|
43
|
+
try { key = await Kdf.deriveFpSyncKey(pairKey); } finally { pairKey.fill(0); }
|
|
44
|
+
try {
|
|
45
|
+
const plaintext = await Envelope.unwrap(envelope, key);
|
|
46
|
+
let inner;
|
|
47
|
+
try { inner = JSON.parse(new TextDecoder().decode(plaintext)); } finally { if (plaintext.fill) plaintext.fill(0); }
|
|
48
|
+
if (!inner || inner.kind !== AGENT_PROFILE_KIND || !inner.data || typeof inner.data !== 'object') return null;
|
|
49
|
+
const generatedAt = Date.parse(inner.generated_at);
|
|
50
|
+
if (!Number.isFinite(generatedAt)) return null;
|
|
51
|
+
return { vault: toVaultPayload(inner), generatedAt };
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
} finally {
|
|
55
|
+
if (key && key.fill) key.fill(0);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Should a pushed copy replace the cache? Only a strictly newer one. Returns the time to stamp. */
|
|
60
|
+
export function acceptCopy(generatedAt, cachedAt, now = Date.now()) {
|
|
61
|
+
if (!Number.isFinite(generatedAt)) return null;
|
|
62
|
+
const at = Math.min(generatedAt, now); // a phone clock ahead of ours cannot buy freshness
|
|
63
|
+
if (Number.isFinite(cachedAt) && at <= cachedAt) return null;
|
|
64
|
+
return at;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Fetch this agent's pushed copy and, when it is newer than the cache, make it the cache.
|
|
69
|
+
* → { updated: true, generatedAt } | { updated: false, reason }.
|
|
70
|
+
*/
|
|
71
|
+
export async function drainProfileCopy(pairing, stateDir, deps = {}) {
|
|
72
|
+
const fetchFn = deps.fetch || globalThis.fetch;
|
|
73
|
+
const now = deps.now ? deps.now() : Date.now();
|
|
74
|
+
if (!pairing || !pairing.pair_key) return { updated: false, reason: 'unpaired' };
|
|
75
|
+
if (!pairing.relay_device_id) return { updated: false, reason: 'no-relay-device-id' };
|
|
76
|
+
if (typeof pairing.client_id !== 'string' || !SEGMENT_RE.test(pairing.client_id)) return { updated: false, reason: 'no-client-id' };
|
|
77
|
+
try {
|
|
78
|
+
const url = `${deps.relayBase || relayBaseFromEnv()}/fp-mailbox/${encodeURIComponent(pairing.relay_device_id)}/profile_to_agent/${pairing.client_id}`;
|
|
79
|
+
const authHeader = pairing.relay_device_secret
|
|
80
|
+
? { Authorization: `Bearer ${pairing.relay_device_id}:${pairing.relay_device_secret}` }
|
|
81
|
+
: {};
|
|
82
|
+
const res = await fetchFn(url, { method: 'GET', headers: authHeader, signal: AbortSignal.timeout(deps.timeoutMs || TIMEOUT_MS) });
|
|
83
|
+
// 404 = nothing pushed yet; 400 = a relay that predates the lane.
|
|
84
|
+
if (!res || !res.ok) return { updated: false, reason: `http-${res && res.status}` };
|
|
85
|
+
const body = await res.json().catch(() => null);
|
|
86
|
+
const copy = await parseAgentProfileDeposit(pairing.pair_key, body && body.envelope);
|
|
87
|
+
if (!copy) return { updated: false, reason: 'unreadable' };
|
|
88
|
+
const cached = await readCache(stateDir);
|
|
89
|
+
const at = acceptCopy(copy.generatedAt, cached ? Number(cached.cachedAt) : NaN, now);
|
|
90
|
+
if (at == null) return { updated: false, reason: 'not-newer' };
|
|
91
|
+
await writeCache(stateDir, copy.vault, at);
|
|
92
|
+
return { updated: true, generatedAt: at };
|
|
93
|
+
} catch (e) {
|
|
94
|
+
return { updated: false, reason: String(e && e.message || e) };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Throttled `drainProfileCopy`: at most once per CHECK_GAP_MS across every process on this Mac. */
|
|
99
|
+
export async function maybeDrainProfileCopy(loadPairing, stateDir, deps = {}) {
|
|
100
|
+
const now = deps.now ? deps.now() : Date.now();
|
|
101
|
+
const path = join(stateDir, STATE_FILE);
|
|
102
|
+
try {
|
|
103
|
+
const last = Number(JSON.parse(await readFile(path, 'utf8'))?.checked_at);
|
|
104
|
+
if (Number.isFinite(last) && now >= last && now - last < CHECK_GAP_MS) return { updated: false, reason: 'recent' };
|
|
105
|
+
} catch { /* first check, or an unreadable file: check now */ }
|
|
106
|
+
try {
|
|
107
|
+
await mkdir(stateDir, { recursive: true, mode: 0o700 });
|
|
108
|
+
// Claimed BEFORE the request, so the processes that start together do not all ask.
|
|
109
|
+
await writeFile(path, JSON.stringify({ checked_at: now }), { mode: 0o600 });
|
|
110
|
+
} catch { /* a read-only state dir still gets its check */ }
|
|
111
|
+
let pairing = null;
|
|
112
|
+
try { pairing = await loadPairing(); } catch { return { updated: false, reason: 'unpaired' }; }
|
|
113
|
+
return drainProfileCopy(pairing, stateDir, deps);
|
|
114
|
+
}
|
package/pull.js
CHANGED
|
@@ -58,7 +58,8 @@ loadFingerprint(BASE_DIR)
|
|
|
58
58
|
`⚠ Could NOT reach your phone — served a CACHED copy${hours != null ? ` about ${hours}h old` : ''} ` +
|
|
59
59
|
`(fingerprint ${fp.fingerprint_id || '(no id)'}, ${layers} layer(s)).\n` +
|
|
60
60
|
` Anything you changed since then — including a revoked authorization — is NOT reflected.\n` +
|
|
61
|
-
`
|
|
61
|
+
` Opening Phaedo on your phone sends this computer a fresh copy (picked up within 10 minutes);\n` +
|
|
62
|
+
` to read it live instead, keep the app open, approve the card, and run this again.\n`
|
|
62
63
|
);
|
|
63
64
|
process.stdout.write(await week() + review(vd));
|
|
64
65
|
process.exit(2); // distinct from 1 (hard failure) — stale, not broken
|
package/receipts-sync.js
CHANGED
|
@@ -78,7 +78,7 @@ function digestFields(r) {
|
|
|
78
78
|
// Pure-crypto: take the local receipts list, build a §10.4-safe digest of the most
|
|
79
79
|
// recent `cap` entries, and seal it under fp_sync_key. Returns `{ envelope }` — the
|
|
80
80
|
// same shape `agent_to_ext` (delegation-sync) uses, so the relay's mailbox accepts it.
|
|
81
|
-
export async function buildReceiptsDeposit(pairKeyB64u, receipts, { cap = DIGEST_CAP, now = () => new Date().toISOString() } = {}) {
|
|
81
|
+
export async function buildReceiptsDeposit(pairKeyB64u, receipts, { cap = DIGEST_CAP, now = () => new Date().toISOString(), copyAt = null } = {}) {
|
|
82
82
|
const list = Array.isArray(receipts) ? receipts : [];
|
|
83
83
|
// Most-recent N (receipts are append-only, ordered by creation in the store).
|
|
84
84
|
const head = list.slice(-cap).map(digestFields).filter(Boolean);
|
|
@@ -100,7 +100,12 @@ export async function buildReceiptsDeposit(pairKeyB64u, receipts, { cap = DIGEST
|
|
|
100
100
|
// plus the sentence the MCP already shows in /phaedo:status, so both surfaces say the same.
|
|
101
101
|
const w = weeklyHandled(list, { now: Number.isFinite(agreementAt) ? agreementAt : Date.now() });
|
|
102
102
|
const week = { ...w, summary: formatWeekly(w) };
|
|
103
|
-
|
|
103
|
+
// How fresh this computer's profile copy is (cache.js readCacheTime), so the phone can show it.
|
|
104
|
+
// Omitted when there is no copy; never later than this digest.
|
|
105
|
+
const copy = Number.isFinite(copyAt) && copyAt > 0
|
|
106
|
+
? { copy_at: new Date(Math.min(copyAt, Number.isFinite(agreementAt) ? agreementAt : Date.now())).toISOString() }
|
|
107
|
+
: {};
|
|
108
|
+
const inner = { kind: 'receipts_digest', data: { receipts: head, agreement, week, generated_at: generatedAt, ...copy } };
|
|
104
109
|
const plaintext = new TextEncoder().encode(JSON.stringify(inner));
|
|
105
110
|
const envelope = await Envelope.wrap(plaintext, fpSyncKey, { artifact_kind: 'receipts_digest', created_at: now() });
|
|
106
111
|
plaintext.fill(0);
|
package/server.js
CHANGED
|
@@ -29,8 +29,10 @@ import { depositSignRequest, pollSignOnce } from './signing-request.js';
|
|
|
29
29
|
import { buildSigningAuditRecord, appendSigningAudit } from './signing-audit.js';
|
|
30
30
|
import { buildInjectionResponse, errorResponse, PhaedoError } from './projection.js';
|
|
31
31
|
import { resolveConsultation, checkAuthorization, standingRuleRelaxes, CONSULTATION_TYPES, consultDrivers, withRevision } from './consult.js';
|
|
32
|
-
import { defaultStateDir } from './cache.js';
|
|
32
|
+
import { defaultStateDir, readCacheTime } from './cache.js';
|
|
33
33
|
import { ruleLeeway, RULE_FRESH_DAYS } from './lib/rule-leeway.js';
|
|
34
|
+
import { standingRuleDriver } from './weekly.js';
|
|
35
|
+
import { precedentAdjustment } from './precedent.js';
|
|
34
36
|
import { buildConsultOpts, loadConsultVd } from './consult-runtime.js';
|
|
35
37
|
import { buildReceipt, appendReceipt, receiptsCapFromEnv, readReceipts, markOutcome, USER_ACTIONS } from './receipts.js';
|
|
36
38
|
|
|
@@ -102,6 +104,26 @@ async function guardFacts(vd, req, ruleDecided = ruleRelaxes(vd, req)) {
|
|
|
102
104
|
}
|
|
103
105
|
}
|
|
104
106
|
|
|
107
|
+
// The receipt's note that one of the subject's standing rules let this go ahead (weekly.js counts
|
|
108
|
+
// it for the phone). First in the list, since buildReceipt keeps only the first 16 drivers.
|
|
109
|
+
// Situational precedent (precedent.js Phase 3): the subject's past decisions in this situation
|
|
110
|
+
// may move the answer's confidence, never its signal. The counts stay on the receipt (the
|
|
111
|
+
// `precedent` driver); no agent is allowed them yet. Best-effort: a failure leaves the answer as is.
|
|
112
|
+
async function withPrecedent(req, response, drivers) {
|
|
113
|
+
try {
|
|
114
|
+
const adj = precedentAdjustment(await readReceipts(defaultStateDir()), { request: req, drivers, response });
|
|
115
|
+
if (!adj) return { response, drivers };
|
|
116
|
+
return { response: { ...response, confidence: adj.confidence }, drivers: [adj.driver, ...drivers] };
|
|
117
|
+
} catch {
|
|
118
|
+
return { response, drivers };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function ruleAnsweredDriver(vd, req, response) {
|
|
123
|
+
if (response?.signal !== 'proceed') return [];
|
|
124
|
+
try { return standingRuleDriver(checkAuthorization(vd, req, CONSULT_OPTS), response); } catch { return []; }
|
|
125
|
+
}
|
|
126
|
+
|
|
105
127
|
function withStaleAuthorityGuard(response, vd, { ruleDecided = true, leeway = false } = {}) {
|
|
106
128
|
const src = sourceOf(vd);
|
|
107
129
|
const out = { ...response };
|
|
@@ -151,9 +173,11 @@ async function maybeDepositReceiptsDigest() {
|
|
|
151
173
|
// AND "an existing receipt got marked" (so the next phone drain reflects the mark).
|
|
152
174
|
const latest = receipts[receipts.length - 1];
|
|
153
175
|
const tally = receipts.reduce((acc, r) => { acc[r.user_action ?? 'null'] = (acc[r.user_action ?? 'null'] || 0) + 1; return acc; }, {});
|
|
154
|
-
|
|
176
|
+
// The copy's time rides along, so a fresher copy alone is worth a new digest.
|
|
177
|
+
const copyAt = await readCacheTime(defaultStateDir());
|
|
178
|
+
const sig = `${latest.receipt_id}|${JSON.stringify(tally)}|${copyAt}`;
|
|
155
179
|
if (sig === _lastReceiptsSig) return;
|
|
156
|
-
const res = await depositReceiptsDigest(await loadPairingRecord(BASE_DIR), receipts);
|
|
180
|
+
const res = await depositReceiptsDigest(await loadPairingRecord(BASE_DIR), receipts, { copyAt });
|
|
157
181
|
if (res && res.deposited) _lastReceiptsSig = sig;
|
|
158
182
|
else if (res && !res.deposited && res.reason !== 'no-receipts')
|
|
159
183
|
process.stderr.write(`[phaedo-mcp] receipts digest deposit skipped: ${res.reason || 'http ' + res.status}\n`);
|
|
@@ -372,11 +396,13 @@ server.registerTool(
|
|
|
372
396
|
// profile with no downgrade and no provenance. That is the 2026-07-25 device defect (a
|
|
373
397
|
// revoked $500 grant honoured from a 22h cache) still live on the paths that matter most.
|
|
374
398
|
const raw = await resolveConsultation(vd, args || {}, CONSULT_OPTS);
|
|
375
|
-
|
|
399
|
+
let response = withStaleAuthorityGuard(raw, vd, await guardFacts(vd, args || {}));
|
|
376
400
|
// Provenance for the local audit receipt only — the cues that drove this
|
|
377
401
|
// (never in `response`; §10.4). Best-effort: a failure must not block the answer.
|
|
378
402
|
let drivers = [];
|
|
379
403
|
try { drivers = withRevision(consultDrivers(vd, args || {}, CONSULT_OPTS), raw); } catch { /* provenance is non-critical */ }
|
|
404
|
+
({ response, drivers } = await withPrecedent(args || {}, response, drivers));
|
|
405
|
+
drivers = [...ruleAnsweredDriver(vd, args || {}, response), ...drivers];
|
|
380
406
|
const receiptId = await emitReceipt('phaedo_consult', args, response, drivers); // every resolution, abstains included
|
|
381
407
|
if (receiptId) response.receipt_id = receiptId; // the handle for phaedo_record_outcome
|
|
382
408
|
await maybeDepositDelegationPromotions(vd); // act-as-me promotion (best-effort)
|
|
@@ -454,7 +480,8 @@ server.registerTool(
|
|
|
454
480
|
// receipt. A no-match is not a decision — no receipt (keeps the store from
|
|
455
481
|
// filling with probes; the follow-up phaedo_consult writes its own).
|
|
456
482
|
if (matchedPreGuard) {
|
|
457
|
-
const rid = await emitReceipt('phaedo_check_authorization', req, { signal: response.signal, confidence: 1.0, deference_level: 'high' }
|
|
483
|
+
const rid = await emitReceipt('phaedo_check_authorization', req, { signal: response.signal, confidence: 1.0, deference_level: 'high' },
|
|
484
|
+
ruleAnsweredDriver(vd, req, response));
|
|
458
485
|
if (rid) response.receipt_id = rid;
|
|
459
486
|
await maybeDepositReceiptsDigest(); // G1+G2 audit channel — only on a matched preflight (no-match writes no receipt)
|
|
460
487
|
}
|
|
@@ -588,9 +615,11 @@ server.registerTool(
|
|
|
588
615
|
// and then told the agent the signal was not blocking — i.e. "just proceed", and never
|
|
589
616
|
// wake the subject, on authority that may have been revoked hours earlier.
|
|
590
617
|
const raw = await resolveConsultation(vd, req, CONSULT_OPTS);
|
|
591
|
-
|
|
618
|
+
let response = withStaleAuthorityGuard(raw, vd, await guardFacts(vd, req));
|
|
592
619
|
let drivers = [];
|
|
593
620
|
try { drivers = withRevision(consultDrivers(vd, req, CONSULT_OPTS), raw); } catch { /* non-critical */ }
|
|
621
|
+
({ response, drivers } = await withPrecedent(req, response, drivers));
|
|
622
|
+
drivers = [...ruleAnsweredDriver(vd, req, response), ...drivers];
|
|
594
623
|
const escReceiptId = await emitReceipt('phaedo_escalate', req, response, drivers); // the decision being escalated
|
|
595
624
|
await maybeDepositReceiptsDigest(); // G1+G2 audit channel
|
|
596
625
|
|
package/server.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
3
|
"name": "io.github.phaedo-labs/phaedo-mcp",
|
|
4
4
|
"description": "Make AI apps answer your way, check with you before acting, and get approvals on your phone.",
|
|
5
|
-
"version": "0.3.
|
|
5
|
+
"version": "0.3.22",
|
|
6
6
|
"repository": {
|
|
7
7
|
"url": "https://github.com/phaedo-labs/phaedo-mcp",
|
|
8
8
|
"source": "github",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"registryType": "npm",
|
|
14
14
|
"registryBaseUrl": "https://registry.npmjs.org",
|
|
15
15
|
"identifier": "phaedo-mcp",
|
|
16
|
-
"version": "0.3.
|
|
16
|
+
"version": "0.3.22",
|
|
17
17
|
"transport": {
|
|
18
18
|
"type": "stdio"
|
|
19
19
|
}
|
package/weekly.js
CHANGED
|
@@ -10,7 +10,13 @@
|
|
|
10
10
|
// came_to_you — Phaedo said check with you (escalate / clarify)
|
|
11
11
|
// unsure — Phaedo didn't know (insufficient_signal); the agent fell back to its own default
|
|
12
12
|
//
|
|
13
|
-
// "Handled" is went_ahead + stopped.
|
|
13
|
+
// "Handled" is went_ahead + stopped. `by_rule` is the part of went_ahead that one of the subject's
|
|
14
|
+
// standing rules decided — the receipt carries a `standingRule` (server) or `standingGrant` (ship
|
|
15
|
+
// hook) driver saying so — which is how they see a rule they accepted actually being used.
|
|
16
|
+
// `less_sure` / `more_sure` count the answers the subject's own past decisions moved (precedent.js
|
|
17
|
+
// Phase 3: the receipt's `precedent` driver says `lowered:` / `raised:`), so they see that working
|
|
18
|
+
// too — without the counts themselves, which never leave this computer.
|
|
19
|
+
// Pure over the receipts list; the CLI reads the local store.
|
|
14
20
|
//
|
|
15
21
|
// npm run week
|
|
16
22
|
|
|
@@ -19,6 +25,7 @@ import { defaultStateDir } from './cache.js';
|
|
|
19
25
|
import { isMainModule } from './lib/is-main.js';
|
|
20
26
|
|
|
21
27
|
export const WEEK_DAYS = 7;
|
|
28
|
+
export const SUMMARY_MAX = 240; // mobile/src/lib/receiptsSync.ts parseWeek
|
|
22
29
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
23
30
|
const BUCKET = {
|
|
24
31
|
proceed: 'went_ahead', proceed_with_note: 'went_ahead',
|
|
@@ -27,14 +34,40 @@ const BUCKET = {
|
|
|
27
34
|
insufficient_signal: 'unsure',
|
|
28
35
|
};
|
|
29
36
|
|
|
37
|
+
// The driver cues that mean "a standing rule answered this" (server.js ruleAnsweredDriver,
|
|
38
|
+
// hooks/pre-ship.mjs). A held grant is recorded as `held:<why>`, so only 'answered' counts.
|
|
39
|
+
export const RULE_CUES = new Set(['standingRule', 'standingGrant']);
|
|
40
|
+
export function answeredByRule(r) {
|
|
41
|
+
return Array.isArray(r?.drivers) && r.drivers.some((d) => RULE_CUES.has(d?.cue) && d?.value === 'answered');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The server's driver for a receipt a standing rule decided: an authorization that matched and
|
|
45
|
+
* said go ahead, AND an answer that still says go ahead after the stale-authority guard — one the
|
|
46
|
+
* guard withheld went to the subject instead, so the rule did not decide it. */
|
|
47
|
+
export function standingRuleDriver(auth, response) {
|
|
48
|
+
return auth?.authorization_matched === true && auth.signal === 'proceed' && response?.signal === 'proceed'
|
|
49
|
+
? [{ cue: 'standingRule', value: 'answered', confidence: 1, basis: 'authored' }] : [];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** What the subject's past decisions did to this answer: 'lowered' | 'raised' | 'held' | null. */
|
|
53
|
+
export function precedentEffect(r) {
|
|
54
|
+
const d = Array.isArray(r?.drivers) ? r.drivers.find((x) => x?.cue === 'precedent') : null;
|
|
55
|
+
const effect = typeof d?.value === 'string' ? d.value.split(':')[0] : null;
|
|
56
|
+
return effect === 'lowered' || effect === 'raised' || effect === 'held' ? effect : null;
|
|
57
|
+
}
|
|
58
|
+
|
|
30
59
|
export function weeklyHandled(receipts, { now = Date.now(), days = WEEK_DAYS } = {}) {
|
|
31
60
|
const since = now - days * DAY_MS;
|
|
32
|
-
const out = { days, went_ahead: 0, stopped: 0, came_to_you: 0, unsure: 0 };
|
|
61
|
+
const out = { days, went_ahead: 0, by_rule: 0, stopped: 0, came_to_you: 0, unsure: 0, less_sure: 0, more_sure: 0 };
|
|
33
62
|
for (const r of Array.isArray(receipts) ? receipts : []) {
|
|
34
63
|
const t = Date.parse(r?.created_at);
|
|
35
64
|
if (!Number.isFinite(t) || t < since || t > now) continue;
|
|
36
65
|
const b = BUCKET[r?.response?.signal];
|
|
37
66
|
if (b) out[b]++;
|
|
67
|
+
if (b === 'went_ahead' && answeredByRule(r)) out.by_rule++;
|
|
68
|
+
const p = precedentEffect(r);
|
|
69
|
+
if (p === 'lowered') out.less_sure++;
|
|
70
|
+
else if (p === 'raised') out.more_sure++;
|
|
38
71
|
}
|
|
39
72
|
out.handled = out.went_ahead + out.stopped;
|
|
40
73
|
out.total = out.handled + out.came_to_you + out.unsure;
|
|
@@ -42,6 +75,7 @@ export function weeklyHandled(receipts, { now = Date.now(), days = WEEK_DAYS } =
|
|
|
42
75
|
}
|
|
43
76
|
|
|
44
77
|
const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
78
|
+
const times = (n) => (n === 1 ? 'once' : n === 2 ? 'twice' : `${n} times`);
|
|
45
79
|
|
|
46
80
|
/** One plain sentence (two at most) for a person. */
|
|
47
81
|
export function formatWeekly(w) {
|
|
@@ -53,8 +87,14 @@ export function formatWeekly(w) {
|
|
|
53
87
|
? `This week Phaedo handled ${plural(w.handled, 'decision')} without interrupting you (${parts.join(', ')})`
|
|
54
88
|
: 'This week Phaedo handled no decisions on its own';
|
|
55
89
|
const asked = w.came_to_you ? ` and brought ${w.came_to_you} to you` : '';
|
|
90
|
+
const rule = w.by_rule ? ` Your standing rule decided ${w.handled === 1 ? 'it' : `${w.by_rule === 1 ? 'one' : w.by_rule} of them`}.` : '';
|
|
56
91
|
const unsure = w.unsure ? ` ${w.unsure === 1 ? 'One more was' : `${w.unsure} more were`} too unclear to call.` : '';
|
|
57
|
-
|
|
92
|
+
const sure = [w.less_sure ? `less sure ${times(w.less_sure)}` : '', w.more_sure ? `surer ${times(w.more_sure)}` : '']
|
|
93
|
+
.filter(Boolean).join(', ');
|
|
94
|
+
const past = sure ? ` Past choices made it ${sure}.` : '';
|
|
95
|
+
// The phone shows SUMMARY_MAX characters; this sentence is the one to drop rather than be cut.
|
|
96
|
+
const full = `${handled}${asked}.${rule}${past}${unsure}`;
|
|
97
|
+
return full.length <= SUMMARY_MAX ? full : `${handled}${asked}.${rule}${unsure}`;
|
|
58
98
|
}
|
|
59
99
|
|
|
60
100
|
if (isMainModule(import.meta.url)) {
|