phaedo-mcp 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/NOTICE +17 -0
  2. package/README.md +51 -9
  3. package/action-binding.js +82 -0
  4. package/agreement.js +38 -0
  5. package/ap2.js +214 -0
  6. package/autonomy-promotion.js +280 -0
  7. package/build-package.mjs +25 -10
  8. package/capture-a11y.js +116 -0
  9. package/capture-local.js +85 -0
  10. package/capture-sync.js +99 -0
  11. package/consult-core.js +81 -19
  12. package/consult-policy.js +427 -33
  13. package/consult.js +25 -3
  14. package/delegation-sync.js +89 -0
  15. package/escalation-policy.sample.json +13 -0
  16. package/escalation.js +383 -0
  17. package/fingerprint-source.js +78 -4
  18. package/health.js +54 -0
  19. package/hooks/session-end.mjs +240 -0
  20. package/install.mjs +164 -21
  21. package/lib/capture-home.js +46 -0
  22. package/lib/domain.js +101 -0
  23. package/lib/is-main.js +40 -0
  24. package/lib/phaedo-crypto.js +17 -1
  25. package/package.json +26 -6
  26. package/pairing.js +5 -1
  27. package/phone-source.js +92 -12
  28. package/projection.js +4 -1
  29. package/pull.js +19 -3
  30. package/receipts-sync.js +197 -0
  31. package/receipts.js +69 -16
  32. package/server.js +305 -15
  33. package/server.json +4 -4
  34. package/signing-audit.js +111 -0
  35. package/signing-request.js +217 -0
  36. package/signing.js +176 -0
  37. package/vendor/authored-source.js +250 -0
  38. package/vendor/capabilities.js +203 -0
  39. package/vendor/capture/capture-core.js +67 -0
  40. package/vendor/capture/detectors.js +287 -0
  41. package/vendor/capture/exchanges.js +77 -0
  42. package/vendor/capture/model-detect.js +195 -0
  43. package/vendor/capture/owner.js +35 -0
  44. package/vendor/capture/package.json +4 -0
  45. package/vendor/capture/source-id.js +56 -0
  46. package/vendor/capture/state.js +148 -0
  47. package/vendor/capture/transcript.js +153 -0
  48. package/vendor/commitments.js +169 -0
  49. package/vendor/context-block.js +251 -18
  50. package/vendor/envelope.js +50 -4
  51. package/vendor/extraction/capture-combine.js +160 -0
  52. package/vendor/extraction/catalog.js +232 -0
  53. package/vendor/extraction/delta-lift.js +109 -0
  54. package/vendor/extraction/fingerprint-update.js +227 -0
  55. package/vendor/extraction/package.json +4 -0
  56. package/vendor/extraction/persist.js +40 -0
  57. package/vendor/extraction/reconcile.js +269 -0
  58. package/vendor/extraction/resolve.js +178 -0
  59. package/vendor/extraction/rule-promotion.js +137 -0
  60. package/vendor/kdf.js +20 -1
  61. package/vendor/protocol.js +7 -0
  62. package/vendored-files.mjs +82 -0
@@ -0,0 +1,153 @@
1
+ // Phaedo capture — Claude Code transcript → exchange_batch.
2
+ //
3
+ // Track A of the desktop-capture plan (docs/roadmap/desktop-capture-helper.md):
4
+ // the Claude Code / Cowork capture hook reads the local session JSONL and turns it
5
+ // into the SAME exchange_batch the extension's Loop 2 produces (loop2-capture
6
+ // buildBatchPayload shape), so the surface-agnostic extraction engine
7
+ // (extraction/orchestrator.js) consumes it unchanged.
8
+ //
9
+ // Pure parsing only — no model, no network, no persistence. Raw transcript text
10
+ // lives here only as the returned in-memory batch; the caller runs extraction and
11
+ // discards it (the discard-raw contract, architecture/persona-vs-conversation-memory.md).
12
+ //
13
+ // Claude Code writes one JSON object per line to
14
+ // ~/.claude/projects/<encoded-cwd>/<session-id>.jsonl. The fields we rely on are the
15
+ // stable ones (type, timestamp, sessionId, message.role, message.content); the format
16
+ // is internal and can drift, so every access is defensive and a malformed line is
17
+ // skipped rather than thrown (a broken transcript must never corrupt the fingerprint).
18
+
19
+ const PLATFORM = 'claude-code';
20
+
21
+ // Pull plain text out of a message.content that is either a string or an array of
22
+ // content blocks. Only 'text' blocks count — 'thinking', 'tool_use', 'tool_result'
23
+ // and images are not conversational prose and are ignored.
24
+ function contentText(content) {
25
+ if (typeof content === 'string') return content.trim();
26
+ if (!Array.isArray(content)) return '';
27
+ const parts = [];
28
+ for (const block of content) {
29
+ if (block && block.type === 'text' && typeof block.text === 'string') parts.push(block.text);
30
+ }
31
+ return parts.join('\n').trim();
32
+ }
33
+
34
+ // A user line is a genuine subject prompt only when it is a real typed human turn.
35
+ // EXCLUDES machine-authored records that Claude Code writes as user-role lines:
36
+ // - isMeta:true — resume text ("Continue from where you left off"), slash-command
37
+ // expansions, "[Request interrupted by user]", harness notices;
38
+ // - isSidechain:true — subagent (Task tool) turns, authored by the assistant;
39
+ // - isVisibleInTranscriptOnly / isCompactSummary — injected/synthetic context.
40
+ // Without this filter the detectors mine documentation and harness text and fabricate
41
+ // source='delta' signals that reach max confidence (the capture-review finding).
42
+ function isMachineAuthored(rec) {
43
+ return !!(rec && (rec.isMeta || rec.isSidechain || rec.isVisibleInTranscriptOnly || rec.isCompactSummary));
44
+ }
45
+ function isUserPrompt(rec) {
46
+ return rec && rec.type === 'user' && rec.message && rec.message.role !== 'assistant'
47
+ && !isMachineAuthored(rec);
48
+ }
49
+ function isAssistant(rec) {
50
+ return rec && rec.type === 'assistant' && rec.message && !rec.isSidechain;
51
+ }
52
+
53
+ // The active conversation path only. The transcript is an append-only uuid/parentUuid
54
+ // TREE: an esc-esc edit/rewind leaves the abandoned branch in the file alongside the
55
+ // retry. A linear scan emits an exchange for EVERY branch, so one edited correction is
56
+ // counted N times (the capture-review finding). We walk parentUuid from the leaf (the
57
+ // last record — the most recent append is always on the live path) back to the root and
58
+ // keep only that chain. Falls back to all records when the tree can't be reconstructed
59
+ // (missing uuids / older format) so nothing breaks.
60
+ function activePathRecords(recs) {
61
+ const byUuid = new Map();
62
+ for (const r of recs) if (r && r.uuid) byUuid.set(r.uuid, r);
63
+ // No usable tree → linear (current behavior, safe).
64
+ if (byUuid.size < 2) return recs;
65
+ let leaf = null;
66
+ for (let i = recs.length - 1; i >= 0; i--) { if (recs[i] && recs[i].uuid) { leaf = recs[i]; break; } }
67
+ if (!leaf) return recs;
68
+ const onPath = new Set();
69
+ let cur = leaf, guard = 0;
70
+ while (cur && guard++ < byUuid.size + 1) {
71
+ onPath.add(cur.uuid);
72
+ cur = cur.parentUuid ? byUuid.get(cur.parentUuid) : null;
73
+ }
74
+ // If the walk covered almost nothing (broken/mixed parent links), don't trust it.
75
+ if (onPath.size < 2) return recs;
76
+ return recs.filter((r) => !r || !r.uuid || onPath.has(r.uuid));
77
+ }
78
+
79
+ // Parse a raw JSONL string (or a pre-split array of lines) into records, skipping
80
+ // blank and malformed lines. Exported for callers that already hold the text.
81
+ export function parseJsonl(text) {
82
+ const lines = Array.isArray(text) ? text : String(text || '').split('\n');
83
+ const out = [];
84
+ for (const line of lines) {
85
+ const s = typeof line === 'string' ? line.trim() : '';
86
+ if (!s) continue;
87
+ try { out.push(JSON.parse(s)); } catch { /* skip a torn/partial line */ }
88
+ }
89
+ return out;
90
+ }
91
+
92
+ // Records → exchange_batch. Each user prompt is paired with the assistant text that
93
+ // follows it (concatenated across multiple assistant lines) up to the next user
94
+ // prompt. A trailing user prompt with no reply yet is dropped (incomplete exchange),
95
+ // matching the web path's complete-only shipping.
96
+ // records — array already parsed (parseJsonl), or a raw JSONL string.
97
+ // opts.sessionId — override; otherwise taken from the first record's sessionId.
98
+ // Returns { exchange_batch_version, session_local_id, exchanges: [...] }.
99
+ export function recordsToBatch(records, opts = {}) {
100
+ const parsed = typeof records === 'string' ? parseJsonl(records) : (Array.isArray(records) ? records : []);
101
+ const recs = activePathRecords(parsed); // drop abandoned edit/rewind branches
102
+ const sessionId = opts.sessionId
103
+ || recs.find((r) => r && r.sessionId)?.sessionId
104
+ || null;
105
+
106
+ const exchanges = [];
107
+ const user_prompts = []; // EVERY genuine user prompt (answered or not)
108
+ let pending = null; // the open user prompt awaiting a reply
109
+ let replyParts = [];
110
+
111
+ const flush = () => {
112
+ if (pending && replyParts.length) {
113
+ const ai = replyParts.join('\n').trim();
114
+ if (ai) {
115
+ exchanges.push({
116
+ exchange_id: pending.id,
117
+ session_id: sessionId,
118
+ platform: PLATFORM,
119
+ timestamp: pending.timestamp,
120
+ user_msg: pending.text,
121
+ ai_response: ai,
122
+ delta_annotations: {}, // Path-A detector output is filled in by the caller (slice 2)
123
+ });
124
+ }
125
+ }
126
+ pending = null;
127
+ replyParts = [];
128
+ };
129
+
130
+ for (const rec of recs) {
131
+ if (isUserPrompt(rec)) {
132
+ const text = contentText(rec.message.content);
133
+ if (!text) continue; // tool_result-only user line — not prose
134
+ flush(); // close any previous exchange first
135
+ user_prompts.push(text); // count Path-A per SENT prompt (extension parity)
136
+ pending = { id: rec.uuid || `ex-${exchanges.length}`, text, timestamp: rec.timestamp || null };
137
+ } else if (isAssistant(rec) && pending) {
138
+ const text = contentText(rec.message.content);
139
+ if (text) replyParts.push(text);
140
+ }
141
+ }
142
+ flush(); // close the final exchange
143
+
144
+ // exchanges = COMPLETE pairs (for the future model/Loop-2 path); user_prompts = every
145
+ // sent prompt (for Path-A counters — the web path counts at SEND time, so an
146
+ // interrupted / unanswered correction must still count, unlike exchange shipping).
147
+ return { exchange_batch_version: 'v0.1', session_local_id: sessionId, exchanges, user_prompts };
148
+ }
149
+
150
+ // Convenience: raw JSONL text → batch in one call.
151
+ export function transcriptToBatch(text, opts = {}) {
152
+ return recordsToBatch(parseJsonl(text), opts);
153
+ }
@@ -0,0 +1,169 @@
1
+ // §5.7 Commitments — decision STATE, projected into the §10.5 policy engine.
2
+ //
3
+ // The eight fingerprint layers are dispositional. Read the catalog: they model
4
+ // response length, tone, iteration style, ambiguity posture, evidence threshold —
5
+ // how a person decides. Even `temporal_and_context` is traits (early-project mode,
6
+ // urgency baseline, working mode), not situation. And §4.6 forbids episodic content
7
+ // in behavioral layers on purpose, so a behavioral model never quietly becomes a
8
+ // dossier.
9
+ //
10
+ // That leaves a hole an agent acting on someone's behalf falls straight into. The
11
+ // oracle can say "you would be cautious about this" when the true answer is "you
12
+ // already decided this" — you already told that vendor no, you are on leave until
13
+ // the 4th, that budget is frozen, you are mid-negotiation with those people. Style
14
+ // without state is how a proxy contradicts the person it represents.
15
+ //
16
+ // Commitments fill exactly that hole and nothing more. They are NOT memory: no
17
+ // recall, no retrieval, no conversation history. They are a short, authored,
18
+ // always-expiring list of things that should change a DECISION, consumed only at
19
+ // the consultation boundary.
20
+ //
21
+ // Three properties keep this from becoming a second product:
22
+ //
23
+ // 1. Authored only (§5.6). A producer MUST NOT infer a commitment. `source` is an
24
+ // enum of exactly `subject` so an inferred value is a schema violation.
25
+ // 2. Deferential only. `effect` cannot be `autoproceed`. Being bound by something
26
+ // is never a reason to hand an agent more latitude; autonomy expansion has one
27
+ // path (§10.5 authorization via §10.7) and this is not it.
28
+ // 3. Perishable. `expires_at` is REQUIRED. Decision state that outlives its
29
+ // situation silently blocks work forever, which is worse than not recording it.
30
+ //
31
+ // And they are never injected. The §9 projection is untouched — a commitment's text
32
+ // is episodic by nature (it names counterparties and amounts), so it goes to the
33
+ // consultation boundary, not into a context block that reaches a model verbatim.
34
+ // Even there the statement is withheld unless the subject marked it `disclose`.
35
+ //
36
+ // This module is the projection into the §10.5 rule shape the resolver already
37
+ // understands, which is what makes the whole feature small: precedence, expiry
38
+ // enforcement, domain matching and the deterministic pre-flight all work unchanged.
39
+ //
40
+ // Loaded as a CLASSIC script in popup.html (globalThis.PhaedoCommitments) and
41
+ // required by node tests + the MCP resolver — the dual pattern of sync-merge.js /
42
+ // rule-review.js.
43
+
44
+ (function () {
45
+ 'use strict';
46
+
47
+ var EFFECTS = ['escalate', 'decline', 'bias_cautious'];
48
+ var KINDS = ['commitment', 'constraint'];
49
+
50
+ function uuid() {
51
+ return (typeof crypto !== 'undefined' && crypto.randomUUID)
52
+ ? crypto.randomUUID()
53
+ : 'cm-' + Math.random().toString(36).slice(2, 10);
54
+ }
55
+
56
+ function isObj(x) { return x !== null && typeof x === 'object' && !Array.isArray(x); }
57
+
58
+ // Live entries only. A lapsed commitment is inert — same fail-safe direction as a
59
+ // lapsed §10.5 authorization: authority narrows, never widens, when time passes.
60
+ function activeCommitments(fp, now) {
61
+ now = (now == null) ? Date.now() : now;
62
+ var list = (fp && Array.isArray(fp.commitments)) ? fp.commitments : [];
63
+ return list.filter(function (c) {
64
+ if (!isObj(c) || !c.statement || EFFECTS.indexOf(c.effect) === -1) return false;
65
+ var t = Date.parse(c.expires_at);
66
+ return Number.isFinite(t) && t > now; // no expiry ⇒ NaN ⇒ inert (expires_at is required)
67
+ });
68
+ }
69
+
70
+ // The generic hint used when a commitment must not be disclosed. It tells the agent
71
+ // the truth — a standing decision covers this — without telling it WHAT the subject
72
+ // decided or with whom. That distinction is the point: an arbitrary agent learning
73
+ // "already told ACME no" is a disclosure, not a rationale.
74
+ var OPAQUE_NOTE = 'a standing commitment of yours covers this';
75
+
76
+ // Commitments → §10.5 policy rules. `disclose:false` (the default) substitutes the
77
+ // opaque note, so the resolver's existing "Per your standing rule: …" phrasing never
78
+ // leaks episodic text to a calling agent.
79
+ function commitmentsAsPolicies(fp, opts) {
80
+ opts = opts || {};
81
+ var now = (opts.now == null) ? Date.now() : opts.now;
82
+ return activeCommitments(fp, now).map(function (c) {
83
+ return {
84
+ id: c.commitment_id || null,
85
+ match: { domains: Array.isArray(c.domains) ? c.domains.filter(Boolean) : [] },
86
+ effect: c.effect,
87
+ note: c.disclose === true ? String(c.statement) : OPAQUE_NOTE,
88
+ expires_at: c.expires_at,
89
+ // Marks the rule as decision STATE rather than a standing policy. The resolver
90
+ // uses it for one thing: a `bias_cautious` commitment still says WHY it nudged.
91
+ // The generic "nudged per your preference" is fine for a disposition, but for a
92
+ // commitment it hides the only useful fact — that something the subject is
93
+ // currently bound by is in play.
94
+ rule_kind: 'commitment',
95
+ };
96
+ });
97
+ }
98
+
99
+ // Author a commitment (the subject's write — §5.6). Returns a NEW fingerprint, or
100
+ // the input unchanged when the entry would be invalid. Deliberately strict rather
101
+ // than coercing: a commitment that silently loses its expiry or its effect is a
102
+ // guardrail that quietly does nothing.
103
+ function addCommitment(fp, input, opts) {
104
+ opts = opts || {};
105
+ input = input || {};
106
+ var statement = String(input.statement == null ? '' : input.statement).trim();
107
+ if (!statement) return fp;
108
+ if (EFFECTS.indexOf(input.effect) === -1) return fp;
109
+ var t = Date.parse(input.expires_at);
110
+ if (!Number.isFinite(t)) return fp;
111
+ var now = opts.now || new Date().toISOString();
112
+ var entry = {
113
+ commitment_id: uuid(),
114
+ kind: KINDS.indexOf(input.kind) === -1 ? 'commitment' : input.kind,
115
+ statement: statement.slice(0, 500),
116
+ domains: Array.isArray(input.domains)
117
+ ? input.domains.map(function (d) { return String(d).trim(); }).filter(Boolean)
118
+ : [],
119
+ effect: input.effect,
120
+ disclose: input.disclose === true,
121
+ expires_at: new Date(t).toISOString(),
122
+ source: 'subject',
123
+ created_at: now,
124
+ updated_at: now,
125
+ };
126
+ var list = (fp && Array.isArray(fp.commitments)) ? fp.commitments : [];
127
+ var next = {};
128
+ for (var k in fp) if (Object.prototype.hasOwnProperty.call(fp, k)) next[k] = fp[k];
129
+ next.commitments = list.concat([entry]);
130
+ // Same signal authored rules use, so the §0008 sync merge keeps this device's write.
131
+ next.rules_updated_at = now;
132
+ return next;
133
+ }
134
+
135
+ // Remove one (the subject changed their mind, or it is done). Lapsed entries are
136
+ // ALSO dropped here — pruning on any write keeps the list from accumulating dead
137
+ // state without needing a background job.
138
+ function removeCommitment(fp, commitmentId, opts) {
139
+ opts = opts || {};
140
+ var now = opts.now || new Date().toISOString();
141
+ var list = (fp && Array.isArray(fp.commitments)) ? fp.commitments : [];
142
+ if (!list.length) return fp;
143
+ var nowMs = Date.parse(now);
144
+ var kept = list.filter(function (c) {
145
+ if (!isObj(c)) return false;
146
+ if (c.commitment_id === commitmentId) return false;
147
+ var t = Date.parse(c.expires_at);
148
+ return !(Number.isFinite(t) && Number.isFinite(nowMs) && t <= nowMs);
149
+ });
150
+ if (kept.length === list.length) return fp;
151
+ var next = {};
152
+ for (var k in fp) if (Object.prototype.hasOwnProperty.call(fp, k)) next[k] = fp[k];
153
+ next.commitments = kept;
154
+ next.rules_updated_at = now;
155
+ return next;
156
+ }
157
+
158
+ var api = {
159
+ EFFECTS: EFFECTS,
160
+ KINDS: KINDS,
161
+ OPAQUE_NOTE: OPAQUE_NOTE,
162
+ activeCommitments: activeCommitments,
163
+ commitmentsAsPolicies: commitmentsAsPolicies,
164
+ addCommitment: addCommitment,
165
+ removeCommitment: removeCommitment,
166
+ };
167
+ if (typeof globalThis !== 'undefined') globalThis.PhaedoCommitments = api;
168
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
169
+ })();