phaedo-mcp 0.3.7 → 0.3.13

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/README.md CHANGED
@@ -123,6 +123,51 @@ will then tell you there's no profile until you pair your phone.
123
123
  You can disconnect this computer at any time from the **Agents** screen on your
124
124
  phone.
125
125
 
126
+ ### Learn from your ChatGPT or Claude history
127
+
128
+ ```bash
129
+ npm run import -- ~/Downloads/your-export.zip
130
+ ```
131
+
132
+ Get the file from ChatGPT (Settings → Data controls → Export data) or Claude (Settings → Privacy →
133
+ Export data); each emails a zip. Phaedo reads the last year of conversations (`--all` for
134
+ everything, `--dry-run` to count first) through the same on-device detectors as Claude Code
135
+ capture, keeps only the counts, and discards the text — delete the export afterwards. Importing
136
+ the same file again replaces, never doubles. Paired: the patterns go to your phone. Not paired:
137
+ they become your starter profile. In the Claude Code plugin, it's `/phaedo:import`.
138
+
139
+ ### See what Phaedo learned — keep it or remove it
140
+
141
+ ```bash
142
+ npm run learned
143
+ ```
144
+
145
+ Lists what Phaedo inferred from how you work (not your interview answers or your rules), one
146
+ plain sentence each, and flags pairs that disagree ("favor speed" vs "favor quality").
147
+ `npm run learned -- --remove 3,5` stops those shaping answers and decisions on this computer —
148
+ the list is applied every time the profile loads, so a pattern your sessions keep showing stays
149
+ removed. `--keep all` marks the rest reviewed; `--forgotten` and `--restore <n>` undo.
150
+ `/phaedo:status` says when there are new things to look at. To remove something from the phone's
151
+ profile itself, use the Phaedo app. In the Claude Code plugin, it's `/phaedo:learned`.
152
+
153
+ ### Give agents without MCP the same profile
154
+
155
+ Codex, Copilot CLI, Gemini CLI and Antigravity each read a personal instructions file at the
156
+ start of every session. One command writes a short version of your profile (your rules and how
157
+ you like answers) into each one found on this computer:
158
+
159
+ ```bash
160
+ npm run rules
161
+ ```
162
+
163
+ It goes into `~/.codex/AGENTS.md`, `~/.copilot/copilot-instructions.md` and
164
+ `~/.gemini/GEMINI.md` (shared by Gemini CLI and Antigravity), inside a marked block; the rest
165
+ of each file is left alone. Cursor has no personal file, so name a project:
166
+ `npm run rules -- --project ~/code/app` writes `.cursor/rules/phaedo.mdc` and keeps it out of
167
+ git. Phaedo refreshes these files whenever a Claude Code session starts with Phaedo on, and
168
+ only those you set up. `npm run rules -- --dry-run` shows the text first;
169
+ `npm run rules -- --remove` takes it out. In the Claude Code plugin, it's `/phaedo:rules`.
170
+
126
171
  ---
127
172
 
128
173
  ## Reference
@@ -356,22 +401,41 @@ detectors, and a model call can never break or stall a session (30s budget, hook
356
401
  exits 0). Prefer the `setup:capture-model` config file over an env var — the file reaches
357
402
  Dock-launched Claude Desktop, a shell `export` does not.
358
403
 
359
- ### Consult before shipping, from Claude Code (`hooks/pre-ship.mjs`)
360
-
361
- `npm run setup:code-hook` also installs a **ship hook** on Bash (PreToolUse,
362
- PermissionRequest, PostToolUse, PostToolUseFailure). Before an agent merges a PR
363
- (`gh pr merge`), deploys (`fly deploy`), builds or submits a phone app (`eas build` /
364
- `eas submit`) or pushes straight to `main`, the hook runs a full `phaedo_consult` on it
365
- (`ship-actions.js` names these; every other command is ignored in ~50 ms) and writes a
366
- §10.6 receipt, `agent_id: claude-code/ship-hook`.
404
+ ### Your personal reviewer in Claude Code (`hooks/pre-ship.mjs`)
405
+
406
+ `npm run setup:code-hook` (or the Claude plugin) installs this hook on PreToolUse,
407
+ PermissionRequest, PostToolUse and PostToolUseFailure, for shell commands and for
408
+ connected-app (MCP) tool calls. Before an agent does something people disagree about, the
409
+ hook runs a full `phaedo_consult` on it and writes a §10.6 receipt, `agent_id:
410
+ claude-code/ship-hook`. `ship-actions.js` names what counts:
411
+
412
+ - **Shell:** merge a PR (`gh pr merge`), deploy (`fly deploy`, `vercel --prod`, `netlify deploy
413
+ --prod`, `firebase deploy`, `terraform apply|destroy`, `kubectl apply|delete`), build or
414
+ submit a phone app (`eas build` / `eas submit`), push straight to `main`, force-push any
415
+ branch, delete a remote branch, open a PR, comment on or review a PR or issue, publish a
416
+ package (`npm`/`pnpm`/`yarn`/`bun publish`, `cargo publish`, `twine upload`, `gem push`;
417
+ never `--dry-run`), create a GitHub release.
418
+ - **Connected apps:** any tool whose name says it sends, replies, forwards, posts, publishes,
419
+ comments, shares or invites; pays, purchases, transfers or checks out; deletes, purges,
420
+ destroys or trashes; or merges — through any MCP server except Phaedo's own. The receipt
421
+ records who or what the call targets (recipient, channel, subject, amount), never a message
422
+ body.
423
+
424
+ Every other command or tool is ignored in about 50 ms.
425
+
426
+ **Why it matters in auto mode.** Auto mode's reviewer judges an action by its type and lets
427
+ routine ones through. Phaedo asks the other question — would *this* person want to see this
428
+ one? — and when the answer is yes it returns `ask`, which forces a real prompt even in auto
429
+ mode, with the reason in your terms: "it can't be undone", "it spends $250", or "your rule:
430
+ ask me before posting in #eng". When the answer is no, it stays quiet.
367
431
 
368
432
  - **It only ever adds friction.** It never returns `allow`. A permissive answer leaves
369
433
  Claude Code's own permission flow untouched; `escalate` / `clarify` / `decline` returns
370
434
  `ask`, so the action comes to you with one plain line saying why.
371
435
  - **Outcomes are yours, never inferred.** The hook records `approved` only when it has
372
- evidence you were asked: Claude Code fired PermissionRequest for that command, and the
373
- command then ran. That holds in every mode except auto / dontAsk, where no person
374
- answers. Decline and the agent records `rejected` (Claude Code fires no hook for a
436
+ evidence you were asked: Claude Code fired PermissionRequest for that call, and the
437
+ call then ran. In auto mode that counts only when Phaedo itself asked (the prompt it forced
438
+ is yours; any other is the classifier's); in dontAsk, never. Decline and the agent records `rejected` (Claude Code fires no hook for a
375
439
  decline). No prompt shown, nothing recorded; the agent is told so, and records your
376
440
  reaction only if you give one.
377
441
  - **What a merge changes.** Before `gh pr merge` the hook reads the PR from GitHub (read-only):
@@ -385,6 +449,27 @@ PermissionRequest, PostToolUse, PostToolUseFailure). Before an agent merges a PR
385
449
  - **Fail-safe:** any error, timeout (12 s) or unusable profile → no output, the command
386
450
  runs as if the hook weren't there.
387
451
 
452
+ ### The same check in Cursor, Copilot CLI, Codex and Gemini CLI (`hooks/agent-hook.mjs`)
453
+
454
+ ```bash
455
+ cd ~/phaedo/mcp && npm run setup:agent-hooks
456
+ ```
457
+
458
+ This adds Phaedo's check to each of these agents found on this computer, beside any hooks you
459
+ already have: `~/.cursor/hooks.json`, `~/.copilot/hooks/phaedo.json`, `~/.codex/hooks.json`
460
+ and `~/.gemini/settings.json`. The same actions are reviewed, with the same consult and the
461
+ same decision records (each names the agent that asked).
462
+
463
+ - **Cursor and Copilot CLI** can show a confirmation prompt, so you get one, with the reason.
464
+ - **Codex and Gemini CLI** can't prompt from a hook, so Phaedo pauses the action once with the
465
+ reason; the agent asks you in the chat, and the same command run again within 15 minutes goes
466
+ through. It never blocks twice in a row and never says yes on your behalf.
467
+ - **Codex** skips new hooks until you trust them: run `/hooks` in Codex once and trust Phaedo's.
468
+ - **Antigravity** has no hooks, so it gets your profile (`npm run rules`) but not this check.
469
+ - Outcomes aren't recorded automatically in these agents yet; `npm run ship-readout` shows
470
+ what they asked.
471
+ - `npm run unsetup` (uninstall) takes them all out.
472
+
388
473
  ## Consult at decision points (`phaedo_consult`, §10)
389
474
 
390
475
  Beyond injecting style, an agent can **ask the fingerprint how the user's pattern
package/agent-hooks.js ADDED
@@ -0,0 +1,160 @@
1
+ // Phaedo — wire the merge check (hooks/agent-hook.mjs) into Cursor, Copilot CLI, Codex and
2
+ // Gemini CLI. Each keeps its hooks in its own user-level file; Phaedo's entry is recognised by
3
+ // the script path, merged in beside whatever is there, and removed on uninstall.
4
+ //
5
+ // Cursor ~/.cursor/hooks.json beforeShellExecution + beforeMCPExecution
6
+ // Copilot ~/.copilot/hooks/phaedo.json preToolUse (a file of Phaedo's own)
7
+ // Codex ~/.codex/hooks.json PreToolUse (new hooks need trusting in /hooks)
8
+ // Gemini ~/.gemini/settings.json hooks.BeforeTool
9
+ //
10
+ // Antigravity documents no hooks, so it gets the profile (rules-files.js) but no merge check.
11
+ // The merge functions are PURE (config in, config out); applyAgentHooks does the file I/O.
12
+
13
+ import { homedir } from 'os';
14
+ import { join, resolve, dirname } from 'path';
15
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, copyFileSync, rmSync } from 'fs';
16
+ import { fileURLToPath } from 'url';
17
+ import { MCP_HOOK_MATCHER } from './ship-actions.js';
18
+
19
+ export const AGENT_HOOK_PATH = join(dirname(fileURLToPath(import.meta.url)), 'hooks', 'agent-hook.mjs');
20
+ export const MARKER = 'hooks/agent-hook.mjs';
21
+ export const TIMEOUT_S = 15; // above the hook's own 12 s budget
22
+ const SHIP_MATCHER = `Bash|${MCP_HOOK_MATCHER}`;
23
+ // Gemini names MCP tools mcp_<server>_<tool> (one underscore), so the same verbs, one prefix.
24
+ const GEMINI_MATCHER = `run_shell_command|${MCP_HOOK_MATCHER.replace(/^mcp__/, 'mcp_')}`;
25
+
26
+ const norm = (s) => String(s ?? '').replace(/\\/g, '/');
27
+ const ours = (cmd) => norm(cmd).includes(MARKER);
28
+ export const agentCommand = (agent, { nodePath = process.execPath, hookPath = AGENT_HOOK_PATH } = {}) => `"${nodePath}" "${hookPath}" ${agent}`;
29
+
30
+ /** Where each agent keeps its hooks, for this home and environment. */
31
+ export function hookFiles({ home = homedir(), env = process.env } = {}) {
32
+ const codexHome = env.CODEX_HOME ? resolve(env.CODEX_HOME) : join(home, '.codex');
33
+ const copilotHome = env.COPILOT_HOME ? resolve(env.COPILOT_HOME) : join(home, '.copilot');
34
+ return {
35
+ cursor: { label: 'Cursor', dir: join(home, '.cursor'), file: join(home, '.cursor', 'hooks.json') },
36
+ copilot: { label: 'Copilot CLI', dir: copilotHome, file: join(copilotHome, 'hooks', 'phaedo.json'), ownFile: true },
37
+ codex: { label: 'Codex', dir: codexHome, file: join(codexHome, 'hooks.json'), note: 'In Codex, run /hooks once and trust the Phaedo hook — Codex skips new hooks until you do.' },
38
+ gemini: { label: 'Gemini CLI', dir: join(home, '.gemini'), file: join(home, '.gemini', 'settings.json') },
39
+ };
40
+ }
41
+
42
+ const clone = (o) => JSON.parse(JSON.stringify(o ?? {}));
43
+ const arr = (v) => (Array.isArray(v) ? v : []);
44
+
45
+ // Cursor and Copilot: { hooks: { event: [ {command…} ] } } — one flat entry per hook.
46
+ function mergeFlat(config, events, entry, cmdOf) {
47
+ const c = clone(config);
48
+ c.version ??= 1;
49
+ c.hooks = c.hooks && typeof c.hooks === 'object' ? c.hooks : {};
50
+ for (const ev of events) {
51
+ const rest = arr(c.hooks[ev]).filter((h) => !ours(cmdOf(h)));
52
+ c.hooks[ev] = [...rest, entry];
53
+ }
54
+ return c;
55
+ }
56
+ function removeFlat(config, events, cmdOf) {
57
+ const c = clone(config);
58
+ if (!c.hooks || typeof c.hooks !== 'object') return c;
59
+ for (const ev of events) {
60
+ if (!Array.isArray(c.hooks[ev])) continue;
61
+ c.hooks[ev] = c.hooks[ev].filter((h) => !ours(cmdOf(h)));
62
+ if (!c.hooks[ev].length) delete c.hooks[ev];
63
+ }
64
+ return c;
65
+ }
66
+
67
+ // Codex and Gemini: { hooks: { Event: [ { matcher, hooks: [ {command…} ] } ] } } — Claude-style groups.
68
+ function mergeGrouped(container, event, group) {
69
+ const c = clone(container);
70
+ c.hooks = c.hooks && typeof c.hooks === 'object' ? c.hooks : {};
71
+ const rest = removeGrouped({ hooks: { [event]: arr(c.hooks[event]) } }, event).hooks[event] || [];
72
+ c.hooks[event] = [...rest, group];
73
+ return c;
74
+ }
75
+ function removeGrouped(container, event) {
76
+ const c = clone(container);
77
+ if (!c.hooks || !Array.isArray(c.hooks[event])) return c;
78
+ c.hooks[event] = c.hooks[event]
79
+ .map((g) => ({ ...g, hooks: arr(g?.hooks).filter((h) => !ours(h?.command)) }))
80
+ .filter((g) => g.hooks.length);
81
+ if (!c.hooks[event].length) delete c.hooks[event];
82
+ return c;
83
+ }
84
+
85
+ const CURSOR_EVENTS = ['beforeShellExecution', 'beforeMCPExecution'];
86
+
87
+ /** The agent's config with Phaedo's hook in (replacing any older Phaedo entry). PURE. */
88
+ export function mergeAgentHook(agent, config, opts = {}) {
89
+ const cmd = agentCommand(agent, opts);
90
+ switch (agent) {
91
+ case 'cursor':
92
+ return mergeFlat(config, CURSOR_EVENTS, { command: cmd, timeout: TIMEOUT_S }, (h) => h?.command);
93
+ case 'copilot':
94
+ return mergeFlat(config, ['preToolUse'], { type: 'command', bash: cmd, powershell: `& ${cmd}`, timeoutSec: TIMEOUT_S }, (h) => h?.bash ?? h?.command ?? h?.powershell);
95
+ case 'codex':
96
+ return mergeGrouped(config, 'PreToolUse', { matcher: SHIP_MATCHER, hooks: [{ type: 'command', command: cmd, timeout: TIMEOUT_S, statusMessage: 'Checking with Phaedo' }] });
97
+ case 'gemini':
98
+ return mergeGrouped(config, 'BeforeTool', { matcher: GEMINI_MATCHER, hooks: [{ type: 'command', command: cmd, timeout: TIMEOUT_S * 1000, name: 'phaedo-merge-check' }] });
99
+ default: throw new Error(`unknown agent: ${agent}`);
100
+ }
101
+ }
102
+
103
+ /** The agent's config with every Phaedo hook entry taken out. PURE. */
104
+ export function removeAgentHook(agent, config) {
105
+ switch (agent) {
106
+ case 'cursor': return removeFlat(config, CURSOR_EVENTS, (h) => h?.command);
107
+ case 'copilot': return removeFlat(config, ['preToolUse'], (h) => h?.bash ?? h?.command ?? h?.powershell);
108
+ case 'codex': return removeGrouped(config, 'PreToolUse');
109
+ case 'gemini': return removeGrouped(config, 'BeforeTool');
110
+ default: throw new Error(`unknown agent: ${agent}`);
111
+ }
112
+ }
113
+
114
+ function readJson(path) {
115
+ if (!existsSync(path)) return { config: {}, existed: false };
116
+ const raw = readFileSync(path, 'utf8');
117
+ if (!raw.trim()) return { config: {}, existed: true };
118
+ const config = JSON.parse(raw); // throws on comments or damage — the caller refuses to touch it
119
+ if (!config || typeof config !== 'object' || Array.isArray(config)) throw new Error('not a JSON object');
120
+ return { config, existed: true };
121
+ }
122
+
123
+ /**
124
+ * Install or remove the merge check for every agent found (or `only`). Returns result
125
+ * records in install.mjs's shape: { name, status, detail }.
126
+ */
127
+ export function applyAgentHooks({ action, dryRun = false, home = homedir(), env = process.env, only = null, nodePath, hookPath } = {}) {
128
+ const out = [];
129
+ for (const [agent, t] of Object.entries(hookFiles({ home, env }))) {
130
+ if (only && !only.includes(agent)) continue;
131
+ const name = `${t.label} merge check`;
132
+ if (action === 'install' && !existsSync(t.dir)) continue; // not installed here
133
+ let read;
134
+ try { read = readJson(t.file); } catch (e) {
135
+ out.push({ name, status: 'error', detail: `${t.file} isn't plain JSON (${e.message}); not touched` });
136
+ continue;
137
+ }
138
+ if (action === 'uninstall') {
139
+ if (!read.existed) continue;
140
+ const next = removeAgentHook(agent, read.config);
141
+ if (JSON.stringify(next) === JSON.stringify(read.config)) continue;
142
+ const empty = t.ownFile && !Object.keys(next.hooks || {}).length;
143
+ if (!dryRun) {
144
+ if (empty) rmSync(t.file);
145
+ else { copyFileSync(t.file, `${t.file}.phaedo.bak`); writeFileSync(t.file, JSON.stringify(next, null, 2) + '\n'); }
146
+ }
147
+ out.push({ name, status: dryRun ? 'would-remove' : 'removed', detail: t.file });
148
+ continue;
149
+ }
150
+ const next = mergeAgentHook(agent, read.config, { nodePath, hookPath });
151
+ if (JSON.stringify(next) === JSON.stringify(read.config)) { out.push({ name, status: 'unchanged', detail: 'already configured' }); continue; }
152
+ if (!dryRun) {
153
+ if (read.existed) copyFileSync(t.file, `${t.file}.phaedo.bak`);
154
+ mkdirSync(dirname(t.file), { recursive: true });
155
+ writeFileSync(t.file, JSON.stringify(next, null, 2) + '\n');
156
+ }
157
+ out.push({ name, status: dryRun ? 'would-write' : 'configured', detail: t.file, ...(t.note ? { restartHint: t.note } : {}) });
158
+ }
159
+ return out;
160
+ }
package/consult-core.js CHANGED
@@ -489,16 +489,53 @@ function resolveActionApproval(vd, n, suppressed) {
489
489
  } else {
490
490
  signal = p.score >= 0.7 ? 'proceed_with_note' : 'proceed';
491
491
  }
492
- const domain = n.domain ? ` in the ${n.domain} domain` : '';
493
- const posture = p.score >= 0.55 ? 'favors caution and human review'
494
- : p.score <= 0.4 ? 'favors acting and adjusting'
495
- : 'weighs the main risks before committing';
496
- const hint = irreversible
497
- ? `On irreversible${mag >= 2 ? ', high-magnitude' : ''} actions${domain}, the subject ${posture}.`
498
- : `On reversible actions${domain}, the subject ${posture}.`;
492
+ const hint = actionApprovalHint({ signal, score: p.score, irreversible, mag, thinEvidence, domain: n.domain });
499
493
  return { signal, hint, coverage: p.coverage, found: p.found, withheld: p.withheld, signalConfidence: p.signalConfidence };
500
494
  }
501
495
 
496
+ // The rationale must explain the SIGNAL, not just the subject's general posture. It used to
497
+ // state the posture alone, so a cautious subject's low-stakes, reversible action came back
498
+ // "proceed_with_note — the subject favors caution and human review": an answer and a reason
499
+ // pointing opposite ways, which an agent can't act on and a person can't trust. Now the sentence
500
+ // says how this person leans in this situation, then what that means for this action.
501
+ export function actionApprovalHint({ signal, score, irreversible, mag, thinEvidence, domain }) {
502
+ const where = domain ? ` in the ${domain} domain` : '';
503
+ const situation = irreversible
504
+ ? `On irreversible${mag >= 2 ? ', high-stakes' : ''} actions${where}`
505
+ : mag >= 2 ? `On high-stakes actions${where}`
506
+ : domain ? `In the ${domain} domain` : 'In general'; // the ending names the low stakes, once
507
+ const lean = score >= 0.55 ? 'cautious' : score <= 0.4 ? 'acting' : 'balanced';
508
+ const posture = lean === 'cautious' ? 'favors caution and human review'
509
+ : lean === 'acting' ? 'favors acting and adjusting'
510
+ : 'weighs the main risks before committing';
511
+ const lowStakes = !irreversible && mag < 2;
512
+ // Each ending carries its own connector: "but" when the answer goes against the lean.
513
+ let consequence;
514
+ switch (signal) {
515
+ case 'proceed':
516
+ consequence = lean === 'cautious' ? ', but this one is reversible and low-stakes, so going ahead fits'
517
+ : lean === 'balanced' ? ', and this one is reversible and low-stakes, so going ahead fits'
518
+ : ', so going ahead fits';
519
+ break;
520
+ case 'proceed_with_note':
521
+ consequence = lean !== 'cautious' ? ', so go ahead and tell them what you did'
522
+ : lowStakes ? ', but this one is reversible and low-stakes, so go ahead and tell them what you did'
523
+ : ', but the evidence you gave is enough to go ahead; tell them what you did';
524
+ break;
525
+ case 'clarify':
526
+ consequence = thinEvidence ? `, ${lean === 'acting' ? 'but' : 'and'} no evidence was offered, so ask before acting`
527
+ : lean === 'acting' ? ', but this one is unclear enough to ask before acting'
528
+ : ', so ask before acting';
529
+ break;
530
+ case 'escalate':
531
+ consequence = lean === 'acting' ? ', but the stakes here call for checking with them first' : ', so check with them first';
532
+ break;
533
+ default:
534
+ consequence = '';
535
+ }
536
+ return `${situation}, the subject ${posture}${consequence}.`;
537
+ }
538
+
502
539
  function resolveDomainRiskCheck(vd, n, suppressed) {
503
540
  const p = profile(vd, RELEVANT_CUES.domain_risk_check, false, n.domain, suppressed);
504
541
  const high = p.score >= 0.55;
package/escalation.js CHANGED
@@ -181,9 +181,37 @@ export function buildEscalationPayload(consultRequest = {}, consultResponse = {}
181
181
  },
182
182
  signal: clip(consultResponse.signal, 40),
183
183
  rationale_hint: clip(consultResponse.rationale_hint, 280),
184
+ why_asked: whyAsked(consultRequest, consultResponse),
184
185
  };
185
186
  }
186
187
 
188
+ // WHY YOU'RE SEEING THIS — one plain line for the person on the phone, not the agent.
189
+ // `rationale_hint` is written to the agent ("the subject favors caution… so check with them
190
+ // first"); shown to the subject it reads as Phaedo talking about them in the third person, and it
191
+ // never says what TRIGGERED the interruption. Pushary's default prompt rate flip-flopped twice in
192
+ // October because users couldn't tell why they were being asked; a visible reason on every card
193
+ // is the fix. PURE, and built only from what is already in the payload: the agent's descriptor,
194
+ // the signal, and — when a standing rule decided it — that rule's own note. Never vault content.
195
+ export function whyAsked(consultRequest = {}, consultResponse = {}) {
196
+ const ad = (consultRequest.action_descriptor && typeof consultRequest.action_descriptor === 'object') ? consultRequest.action_descriptor : {};
197
+ const hint = typeof consultResponse.rationale_hint === 'string' ? consultResponse.rationale_hint : '';
198
+ const rule = /^Per your standing rule: (.+?)\.?$/.exec(hint.trim());
199
+ if (rule) return `One of your rules says to check with you: ${rule[1]}.`.slice(0, 200);
200
+ const reasons = [];
201
+ if (ad.reversible === false) reasons.push("it can't be undone");
202
+ if (typeof ad.amount === 'number' && isFinite(ad.amount) && ad.amount > 0) {
203
+ reasons.push(`it spends $${ad.amount.toLocaleString('en-US', { maximumFractionDigits: 2 })}`);
204
+ } else if (ad.magnitude === 'high') reasons.push("it's high-stakes");
205
+ const what = reasons.length ? reasons.join(' and ') : null;
206
+ const signal = String(consultResponse.signal || '');
207
+ if (signal === 'clarify') {
208
+ return what ? `Phaedo asked because ${what}, and it isn't clear enough what you'd want.`
209
+ : "Phaedo asked because it isn't clear enough what you'd want here.";
210
+ }
211
+ return what ? `Phaedo asked because ${what}, and you usually want to see those yourself.`
212
+ : "Phaedo asked because it wasn't confident you'd want this done without you.";
213
+ }
214
+
187
215
  // Seal the payload under fp_sync_key into the deposit body { request_id, envelope,
188
216
  // window_ms }. PURE crypto (no network) so the round-trip is testable in isolation,
189
217
  // mirroring delegation-sync.js buildDelegationDeposit.
@@ -19,6 +19,7 @@ import { foldLocalCapture, isLocalCaptureOwner, localCaptureSessionCount } from
19
19
  import { Capabilities, AuthoredSource } from './lib/phaedo-crypto.js';
20
20
  import { PhaedoError } from './errors.js';
21
21
  import { configDir, isDirectoryEdition } from './lib/config-dir.js';
22
+ import { applyForgotten, readLearnedState } from './learned.js';
22
23
 
23
24
  // §4.1 protocol-identity normalization (consumer defense-in-depth, 2026-06-16).
24
25
  // A stored fingerprint from a producer that predates identity stamping (or whose
@@ -255,7 +256,16 @@ export function sourceOf(vd) {
255
256
  return s || { live: true, cachedAt: null, ageMs: 0 };
256
257
  }
257
258
 
258
- export async function loadFingerprint(baseDir) {
259
+ // What the subject removed in `npm run learned` is taken out on EVERY read, so injection,
260
+ // consult and the rules files all stop using it — even when the sessions keep showing it.
261
+ // `unfiltered` is for that review screen alone, which has to list what was removed.
262
+ const forgetting = (vd, { unfiltered = false } = {}) => (unfiltered ? vd : applyForgotten(vd, readLearnedState(defaultStateDir())));
263
+
264
+ export async function loadFingerprint(baseDir, opts) {
265
+ return forgetting(await loadFingerprintUnfiltered(baseDir), opts);
266
+ }
267
+
268
+ async function loadFingerprintUnfiltered(baseDir) {
259
269
  // Explicit local source wins over phone pairing: if PHAEDO_FINGERPRINT names a
260
270
  // file, honor it and skip the phone entirely. Pointing at a concrete fingerprint
261
271
  // is an unambiguous "use THIS" — it lets a user force a local file and makes the
@@ -345,10 +355,10 @@ export async function loadFingerprint(baseDir) {
345
355
  // there.) Falls through to a live pull ONLY when no cache exists at all (first run on this
346
356
  // machine), which seeds it; run one phaedo_consult after pairing to seed without an
347
357
  // escalation in flight.
348
- export async function loadFingerprintPreferCache(baseDir) {
358
+ export async function loadFingerprintPreferCache(baseDir, opts) {
349
359
  if (!process.env.PHAEDO_FINGERPRINT && hasPairingRecord(baseDir)) {
350
360
  const cached = await readCache(defaultStateDir());
351
- if (cached) return stamp(foldConfigHome(normalizeIdentity(cached.vault)), cached);
361
+ if (cached) return forgetting(stamp(foldConfigHome(normalizeIdentity(cached.vault)), cached), opts);
352
362
  }
353
- return loadFingerprint(baseDir);
363
+ return loadFingerprint(baseDir, opts);
354
364
  }
@@ -0,0 +1,202 @@
1
+ #!/usr/bin/env node
2
+ // Phaedo — learn from your ChatGPT or Claude history, from the export file each one gives you.
3
+ //
4
+ // npm run import -- ~/Downloads/chatgpt-export.zip
5
+ // npm run import -- ~/Downloads/claude-export.zip --all # every conversation, not just the last year
6
+ // npm run import -- conversations.json --dry-run # count what it would read, change nothing
7
+ //
8
+ // Get the file: ChatGPT → Settings → Data controls → Export data; Claude → Settings → Privacy →
9
+ // Export data. Each emails a zip; its conversations.json is all this reads.
10
+ //
11
+ // Each conversation becomes {role, text} turns and goes through the SAME spine the Claude Code
12
+ // hook and the Mac reader use (capture/exchanges.js → capture-core.js: the deterministic
13
+ // detectors → per-session state → delta lift), as its own session keyed by the conversation's id,
14
+ // so importing the same export twice replaces rather than doubles. One deposit to the phone at
15
+ // the end when paired; a starter profile on this computer when not.
16
+ //
17
+ // DISCARD-RAW, as everywhere in capture: the text is read in memory, counted, and dropped.
18
+ // Nothing derived from it but signal names and integers is kept or sent. Not in the directory
19
+ // edition (its policy forbids reading chat history).
20
+
21
+ import { execFileSync } from 'child_process';
22
+ import { readFileSync, existsSync } from 'fs';
23
+ import { resolve, dirname, extname } from 'path';
24
+ import { fileURLToPath } from 'url';
25
+ import { isMainModule } from './lib/is-main.js';
26
+
27
+ export const DEFAULT_DAYS = 365;
28
+ export const SURFACES = Object.freeze({ chatgpt: 'chatgpt-import', claude: 'claude-import' });
29
+ const DAY = 24 * 60 * 60 * 1000;
30
+
31
+ const text = (v) => (typeof v === 'string' ? v : '');
32
+ const iso = (t) => {
33
+ if (typeof t === 'number' && Number.isFinite(t)) return new Date(t < 1e12 ? t * 1000 : t).toISOString();
34
+ const p = Date.parse(t);
35
+ return Number.isFinite(p) ? new Date(p).toISOString() : null;
36
+ };
37
+
38
+ /** Which service an export came from, by shape. PURE. */
39
+ export function detectFormat(list) {
40
+ const first = Array.isArray(list) ? list.find((c) => c && typeof c === 'object') : null;
41
+ if (!first) return null;
42
+ if (first.mapping && typeof first.mapping === 'object') return 'chatgpt';
43
+ if (Array.isArray(first.chat_messages)) return 'claude';
44
+ return null;
45
+ }
46
+
47
+ // ChatGPT stores each conversation as a tree (every regenerate or edit is a branch). The
48
+ // conversation the person actually had is the path from `current_node` up to the root.
49
+ function chatgptTurns(c) {
50
+ const map = c.mapping || {};
51
+ let id = c.current_node && map[c.current_node] ? c.current_node : null;
52
+ if (!id) { // no current_node: the deepest leaf of the first branch
53
+ id = Object.keys(map).find((k) => !map[k]?.parent) || null;
54
+ while (id && Array.isArray(map[id]?.children) && map[id].children.length) id = map[id].children[0];
55
+ }
56
+ const path = [];
57
+ const seen = new Set();
58
+ while (id && map[id] && !seen.has(id)) { seen.add(id); path.push(map[id]); id = map[id].parent; }
59
+ path.reverse();
60
+ const turns = [];
61
+ for (const node of path) {
62
+ const m = node.message;
63
+ const role = m?.author?.role;
64
+ if (role !== 'user' && role !== 'assistant') continue;
65
+ const ct = m.content?.content_type;
66
+ if (ct && ct !== 'text' && ct !== 'multimodal_text') continue; // code runs, browsing, tool output
67
+ const body = (Array.isArray(m.content?.parts) ? m.content.parts : []).map(text).join('\n').trim();
68
+ if (body) turns.push({ role, text: body, id: m.id || node.id, timestamp: iso(m.create_time) });
69
+ }
70
+ return turns;
71
+ }
72
+
73
+ function claudeTurns(c) {
74
+ const turns = [];
75
+ for (const m of Array.isArray(c.chat_messages) ? c.chat_messages : []) {
76
+ const role = m?.sender === 'human' ? 'user' : m?.sender === 'assistant' ? 'assistant' : null;
77
+ if (!role) continue;
78
+ const parts = Array.isArray(m.content) ? m.content.filter((p) => p?.type === 'text').map((p) => text(p.text)) : [];
79
+ const body = (parts.length ? parts.join('\n') : text(m.text)).trim();
80
+ if (body) turns.push({ role, text: body, id: m.uuid, timestamp: iso(m.created_at) });
81
+ }
82
+ return turns;
83
+ }
84
+
85
+ /**
86
+ * The export's conversations as [{ id, at, turns }], newest first, within `days` of `now`
87
+ * (null = all). Never throws on a malformed entry; skips it. PURE.
88
+ */
89
+ export function conversationsFrom(list, format, { now = Date.now(), days = DEFAULT_DAYS } = {}) {
90
+ const since = days == null ? -Infinity : now - days * DAY;
91
+ const out = [];
92
+ for (const c of Array.isArray(list) ? list : []) {
93
+ if (!c || typeof c !== 'object') continue;
94
+ const id = format === 'chatgpt' ? (c.conversation_id || c.id) : c.uuid;
95
+ const at = format === 'chatgpt' ? iso(c.update_time ?? c.create_time) : iso(c.updated_at ?? c.created_at);
96
+ if (!id || !at || Date.parse(at) < since || Date.parse(at) > now) continue;
97
+ const turns = format === 'chatgpt' ? chatgptTurns(c) : claudeTurns(c);
98
+ if (turns.some((t) => t.role === 'user') && turns.some((t) => t.role === 'assistant')) out.push({ id: String(id), at, turns });
99
+ }
100
+ return out.sort((a, b) => Date.parse(b.at) - Date.parse(a.at));
101
+ }
102
+
103
+ /** conversations.json from a .zip (system unzip, else bsdtar) or a .json path. */
104
+ export function readExport(path) {
105
+ const p = resolve(path);
106
+ if (!existsSync(p)) throw Object.assign(new Error(`no file at ${p}`), { code: 'ENOENT' });
107
+ if (extname(p).toLowerCase() !== '.zip') return JSON.parse(readFileSync(p, 'utf8'));
108
+ const opts = { encoding: 'utf8', maxBuffer: 2 * 1024 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] };
109
+ for (const [cmd, args] of [['unzip', ['-p', p, 'conversations.json']], ['tar', ['-xOf', p, 'conversations.json']]]) {
110
+ try { const raw = execFileSync(cmd, args, opts); if (raw.trim()) return JSON.parse(raw); } catch { /* try the next */ }
111
+ }
112
+ throw new Error("couldn't find conversations.json in that zip — is it the ChatGPT or Claude data export?");
113
+ }
114
+
115
+ /**
116
+ * Fold every conversation through the capture spine and deposit once. deps: { fold, deposit,
117
+ * sourceId, statePath, now } — the real ones in the CLI, fakes in tests. Returns a summary.
118
+ */
119
+ export async function importConversations(convos, format, deps) {
120
+ const surface = SURFACES[format];
121
+ let signals = [];
122
+ let messages = 0;
123
+ for (const c of convos) {
124
+ const r = await deps.fold(c, surface);
125
+ messages += r.counted || 0;
126
+ if (Array.isArray(r.signals)) signals = r.signals; // cumulative: the last fold holds them all
127
+ }
128
+ const deposit = signals.length && deps.deposit ? await deps.deposit(signals, surface) : null;
129
+ return { conversations: convos.length, messages, signals: signals.length, deposited: Boolean(deposit?.deposited), deposit };
130
+ }
131
+
132
+ const plural = (n, w) => `${n.toLocaleString('en-US')} ${w}${n === 1 ? '' : 's'}`;
133
+
134
+ /** The result in plain words. PURE. */
135
+ export function summaryText(r, { service, days, dryRun = false, paired = false }) {
136
+ const span = days == null ? '' : ` from the last ${days === 365 ? 'year' : `${days} days`}`;
137
+ if (!r.conversations) return `No ${service} conversations${span} to read in that file.`;
138
+ const head = `${dryRun ? 'Would read' : 'Read'} ${plural(r.conversations, 'conversation')}${span} from ${service} (${plural(r.messages, 'message')} of yours).`;
139
+ if (dryRun) return `${head} Nothing was changed.`;
140
+ const learned = r.signals
141
+ ? `Phaedo found ${plural(r.signals, 'pattern')} in how you work${paired ? (r.deposited ? ' and sent them to your phone' : '; they\'ll reach your phone with the next sync') : ''}.`
142
+ : 'Phaedo found nothing new in how you work from these.';
143
+ return `${head} ${learned} Only counts were kept; you can delete the export file now. To see and prune what it learned: npm run learned`;
144
+ }
145
+
146
+ // ── CLI ───────────────────────────────────────────────────────────────────────
147
+
148
+ async function main(argv) {
149
+ const file = argv.find((a) => !a.startsWith('--'));
150
+ const say = (s) => process.stdout.write(s + '\n');
151
+ if (!file) {
152
+ say('Usage: npm run import -- <ChatGPT or Claude export .zip, or its conversations.json> [--all] [--days N] [--dry-run]');
153
+ return 1;
154
+ }
155
+ const opt = (f) => { const i = argv.indexOf(f); return i >= 0 ? argv[i + 1] : null; };
156
+ const days = argv.includes('--all') ? null : (Number(opt('--days')) > 0 ? Number(opt('--days')) : DEFAULT_DAYS);
157
+ const dryRun = argv.includes('--dry-run');
158
+
159
+ let list;
160
+ try { list = readExport(file); } catch (e) { say(`✗ ${e.code === 'ENOENT' ? `No file at ${file}.` : e.message}`); return 1; }
161
+ const format = detectFormat(list);
162
+ if (!format) { say("✗ That doesn't look like a ChatGPT or Claude data export (no conversations in the shape either one uses)."); return 1; }
163
+ const service = format === 'chatgpt' ? 'ChatGPT' : 'Claude';
164
+ const convos = conversationsFrom(list, format, { days });
165
+ list = null; // the raw export is not needed past this point
166
+
167
+ if (dryRun) {
168
+ const messages = convos.reduce((n, c) => n + c.turns.filter((t) => t.role === 'user').length, 0);
169
+ say(summaryText({ conversations: convos.length, messages, signals: 0 }, { service, days, dryRun: true }));
170
+ return 0;
171
+ }
172
+
173
+ const MCP_DIR = dirname(fileURLToPath(import.meta.url));
174
+ const { loadCaptureModule } = await import('./lib/capture-home.js');
175
+ const [{ turnsToBatch }, { foldBatchToSignals }, { deriveSourceDeviceId }] = await Promise.all(
176
+ ['capture/exchanges.js', 'capture/capture-core.js', 'capture/source-id.js'].map(loadCaptureModule));
177
+ const { loadPairingRecord } = await import('./phone-source.js');
178
+ const { buildCaptureDelta, depositCaptureDelta } = await import('./capture-sync.js');
179
+ let pairing = null;
180
+ try { pairing = await loadPairingRecord(MCP_DIR); } catch { pairing = null; }
181
+ const now = new Date().toISOString();
182
+ const sourceId = deriveSourceDeviceId(pairing, SURFACES[format]);
183
+
184
+ const r = await importConversations(convos, format, {
185
+ async fold(c, surface) {
186
+ const batch = turnsToBatch(c.turns, { sessionId: `${surface}:${c.id}`, platform: surface });
187
+ // Regex detectors only: a local model over a whole history would take hours.
188
+ const out = await foldBatchToSignals(batch, { sourceId, surface, sessionLocalId: `${surface}:${c.id}`, now, captureModel: null });
189
+ return { signals: out.signals, counted: batch.user_prompts.length };
190
+ },
191
+ deposit: pairing ? async (signals, surface) => {
192
+ const delta = buildCaptureDelta(signals, { sourceSurface: surface, sourceDeviceId: sourceId, capturedAt: now });
193
+ return depositCaptureDelta(pairing, delta, { now: () => now });
194
+ } : null,
195
+ });
196
+ say(summaryText(r, { service, days, paired: Boolean(pairing) }));
197
+ return 0;
198
+ }
199
+
200
+ if (isMainModule(import.meta.url)) {
201
+ main(process.argv.slice(2)).then((c) => process.exit(c), (e) => { process.stderr.write(`✗ ${e.message}\n`); process.exit(1); });
202
+ }