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 +96 -11
- package/agent-hooks.js +160 -0
- package/consult-core.js +44 -7
- package/escalation.js +28 -0
- package/fingerprint-source.js +14 -4
- package/history-import.js +202 -0
- package/hooks/agent-hook.mjs +160 -0
- package/hooks/pre-ship.mjs +43 -18
- package/hooks/session-start.mjs +8 -2
- package/install.mjs +23 -1
- package/learned.js +227 -0
- package/package.json +15 -3
- package/precedent.js +267 -0
- package/pull.js +14 -1
- package/receipts-sync.js +6 -1
- package/rules-files.js +278 -0
- package/server.js +11 -6
- package/server.json +2 -2
- package/ship-actions.js +151 -2
- package/ship-readout.js +12 -2
- package/weekly.js +63 -0
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
|
-
###
|
|
360
|
-
|
|
361
|
-
`npm run setup:code-hook`
|
|
362
|
-
PermissionRequest, PostToolUse
|
|
363
|
-
(
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
|
373
|
-
|
|
374
|
-
|
|
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
|
|
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.
|
package/fingerprint-source.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|