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.
- package/NOTICE +17 -0
- package/README.md +51 -9
- package/action-binding.js +82 -0
- package/agreement.js +38 -0
- package/ap2.js +214 -0
- package/autonomy-promotion.js +280 -0
- package/build-package.mjs +25 -10
- package/capture-a11y.js +116 -0
- package/capture-local.js +85 -0
- package/capture-sync.js +99 -0
- package/consult-core.js +81 -19
- package/consult-policy.js +427 -33
- package/consult.js +25 -3
- package/delegation-sync.js +89 -0
- package/escalation-policy.sample.json +13 -0
- package/escalation.js +383 -0
- package/fingerprint-source.js +78 -4
- package/health.js +54 -0
- package/hooks/session-end.mjs +240 -0
- package/install.mjs +164 -21
- package/lib/capture-home.js +46 -0
- package/lib/domain.js +101 -0
- package/lib/is-main.js +40 -0
- package/lib/phaedo-crypto.js +17 -1
- package/package.json +26 -6
- package/pairing.js +5 -1
- package/phone-source.js +92 -12
- package/projection.js +4 -1
- package/pull.js +19 -3
- package/receipts-sync.js +197 -0
- package/receipts.js +69 -16
- package/server.js +305 -15
- package/server.json +4 -4
- package/signing-audit.js +111 -0
- package/signing-request.js +217 -0
- package/signing.js +176 -0
- package/vendor/authored-source.js +250 -0
- package/vendor/capabilities.js +203 -0
- package/vendor/capture/capture-core.js +67 -0
- package/vendor/capture/detectors.js +287 -0
- package/vendor/capture/exchanges.js +77 -0
- package/vendor/capture/model-detect.js +195 -0
- package/vendor/capture/owner.js +35 -0
- package/vendor/capture/package.json +4 -0
- package/vendor/capture/source-id.js +56 -0
- package/vendor/capture/state.js +148 -0
- package/vendor/capture/transcript.js +153 -0
- package/vendor/commitments.js +169 -0
- package/vendor/context-block.js +251 -18
- package/vendor/envelope.js +50 -4
- package/vendor/extraction/capture-combine.js +160 -0
- package/vendor/extraction/catalog.js +232 -0
- package/vendor/extraction/delta-lift.js +109 -0
- package/vendor/extraction/fingerprint-update.js +227 -0
- package/vendor/extraction/package.json +4 -0
- package/vendor/extraction/persist.js +40 -0
- package/vendor/extraction/reconcile.js +269 -0
- package/vendor/extraction/resolve.js +178 -0
- package/vendor/extraction/rule-promotion.js +137 -0
- package/vendor/kdf.js +20 -1
- package/vendor/protocol.js +7 -0
- package/vendored-files.mjs +82 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Phaedo capture — reconstructed {role, text} turns → exchange_batch.
|
|
2
|
+
//
|
|
3
|
+
// The tier-agnostic front door for capture surfaces that read a RENDERED transcript
|
|
4
|
+
// (the Track B OS-accessibility helper: Claude Desktop's chat tab, later an OCR tier)
|
|
5
|
+
// rather than Claude Code's JSONL. It emits the SAME exchange_batch shape transcript.js
|
|
6
|
+
// produces (recordsToBatch), so everything downstream — detectors, state, delta-lift,
|
|
7
|
+
// deposit — consumes it unchanged. The only thing that differs per surface is how the
|
|
8
|
+
// turns are obtained; this module is where a11y/OCR/JSONL converge onto one pipeline.
|
|
9
|
+
//
|
|
10
|
+
// Pure: no model, no network, no persistence. The turns' raw text lives here only as the
|
|
11
|
+
// returned in-memory batch; the caller runs detectors and discards it (the discard-raw
|
|
12
|
+
// contract, architecture/persona-vs-conversation-memory.md).
|
|
13
|
+
//
|
|
14
|
+
// Turn shape (what the native front-end emits, one object per rendered turn IN ORDER):
|
|
15
|
+
// { role: 'user' | 'assistant', text: string, id?: string, timestamp?: string }
|
|
16
|
+
// Anything else (role 'system'/'tool', empty text, a leading assistant turn with no
|
|
17
|
+
// preceding user prompt) is ignored, mirroring transcript.js's isUserPrompt/isAssistant
|
|
18
|
+
// filtering — so a UI label or a streamed-but-partial turn can't fabricate an exchange.
|
|
19
|
+
|
|
20
|
+
const PLATFORM = 'a11y';
|
|
21
|
+
|
|
22
|
+
// Turns → exchange_batch. Each user turn is paired with the assistant text that follows
|
|
23
|
+
// it (concatenated across consecutive assistant turns) up to the next user turn. A
|
|
24
|
+
// trailing user turn with no reply yet is dropped (incomplete exchange), matching both
|
|
25
|
+
// the web path and transcript.js. `user_prompts` collects EVERY genuine user turn (Path-A
|
|
26
|
+
// counts at send time, so an unanswered correction still counts).
|
|
27
|
+
// turns — array of { role, text, id?, timestamp? }.
|
|
28
|
+
// opts.sessionId — a STABLE per-conversation id (the front-end derives it, e.g. from the
|
|
29
|
+
// first user message, so re-reading the same chat REPLACES that session's counts in
|
|
30
|
+
// state.js instead of double-counting — the same idempotency the JSONL hook relies on).
|
|
31
|
+
// opts.platform — surface tag for the exchange records (default 'a11y').
|
|
32
|
+
export function turnsToBatch(turns, opts = {}) {
|
|
33
|
+
const list = Array.isArray(turns) ? turns : [];
|
|
34
|
+
const sessionId = opts.sessionId || null;
|
|
35
|
+
const platform = opts.platform || PLATFORM;
|
|
36
|
+
|
|
37
|
+
const exchanges = [];
|
|
38
|
+
const user_prompts = [];
|
|
39
|
+
let pending = null;
|
|
40
|
+
let replyParts = [];
|
|
41
|
+
let seq = 0;
|
|
42
|
+
|
|
43
|
+
const flush = () => {
|
|
44
|
+
if (pending && replyParts.length) {
|
|
45
|
+
const ai = replyParts.join('\n').trim();
|
|
46
|
+
if (ai) {
|
|
47
|
+
exchanges.push({
|
|
48
|
+
exchange_id: pending.id,
|
|
49
|
+
session_id: sessionId,
|
|
50
|
+
platform,
|
|
51
|
+
timestamp: pending.timestamp,
|
|
52
|
+
user_msg: pending.text,
|
|
53
|
+
ai_response: ai,
|
|
54
|
+
delta_annotations: {}, // filled by the caller's detector pass (accumulateBatchCounts)
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
pending = null;
|
|
59
|
+
replyParts = [];
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
for (const t of list) {
|
|
63
|
+
if (!t || typeof t.text !== 'string') continue;
|
|
64
|
+
const text = t.text.trim();
|
|
65
|
+
if (!text) continue;
|
|
66
|
+
if (t.role === 'user') {
|
|
67
|
+
flush(); // close any previous exchange first
|
|
68
|
+
user_prompts.push(text); // count Path-A per sent prompt
|
|
69
|
+
pending = { id: (typeof t.id === 'string' && t.id) || `ex-${seq++}`, text, timestamp: t.timestamp || null };
|
|
70
|
+
} else if (t.role === 'assistant' && pending) {
|
|
71
|
+
replyParts.push(text); // a leading assistant turn (no pending) is ignored
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
flush(); // close the final exchange
|
|
75
|
+
|
|
76
|
+
return { exchange_batch_version: 'v0.1', session_local_id: sessionId, exchanges, user_prompts };
|
|
77
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// Phaedo capture — option (b): the model-backed correction detector.
|
|
2
|
+
//
|
|
3
|
+
// Claude Code's terse register under-triggers the chat-tuned Path-A regexes
|
|
4
|
+
// ("tldr pls", "why r u explaining this", "wall of text again"), so a local
|
|
5
|
+
// model backend enriches capture — but as a RICHER DETECTOR, not a second
|
|
6
|
+
// extraction pipeline. The model classifies ONLY the user messages the regexes
|
|
7
|
+
// MISSED against the SAME correction taxonomy (CORRECTION_PATTERNS' layer/signal
|
|
8
|
+
// pairs), and its hits increment the SAME per-session counters. Everything
|
|
9
|
+
// downstream — per-session REPLACE state (resume-safe), cumulative delta-lift,
|
|
10
|
+
// per-source snapshot deposit, owner-side combine — applies unchanged, and the
|
|
11
|
+
// owner's "source:'delta' only" deposit guard stays exactly right: a model hit
|
|
12
|
+
// is just a counter the regex would have incremented if it were smarter.
|
|
13
|
+
//
|
|
14
|
+
// Why not deposit source:'extraction' signals: reconcile merges extraction
|
|
15
|
+
// signals ADDITIVELY (mergeSignal sums counts), so a SessionEnd re-fire (clear /
|
|
16
|
+
// resume re-processes the full transcript) or a double-drain would inflate them.
|
|
17
|
+
// Counters + per-session replace already solve that; the model feeds them.
|
|
18
|
+
//
|
|
19
|
+
// Privacy: regex-missed user messages are sent ONLY to the configured LOCAL
|
|
20
|
+
// endpoint (Ollama / OpenAI-compatible — same trust boundary as the WebLLM
|
|
21
|
+
// extraction and the consult model judge), and nothing derived from them but
|
|
22
|
+
// signal names + integers is persisted or deposited (the discard-raw contract).
|
|
23
|
+
//
|
|
24
|
+
// Transport mirrors mcp/consult.js modelJudge (the consult Ollama plumbing):
|
|
25
|
+
// POST {url}/v1/chat/completions, strict-JSON reply, hard timeout. Opt-in via
|
|
26
|
+
// PHAEDO_CAPTURE_MODEL; everything here fails toward "regex counts only".
|
|
27
|
+
|
|
28
|
+
import { readFileSync } from 'node:fs';
|
|
29
|
+
import { join } from 'node:path';
|
|
30
|
+
import { homedir } from 'node:os';
|
|
31
|
+
import { CORRECTION_PATTERNS, detectCorrection } from './detectors.js';
|
|
32
|
+
|
|
33
|
+
// The classification taxonomy: unique (layer, signal) pairs from the mirrored
|
|
34
|
+
// correction table, so the model can only ever emit counters the deterministic
|
|
35
|
+
// path could also produce (and the parity test already locks against content.js).
|
|
36
|
+
export function correctionTaxonomy() {
|
|
37
|
+
const seen = new Map();
|
|
38
|
+
for (const { layer, signal } of CORRECTION_PATTERNS) {
|
|
39
|
+
if (!seen.has(signal)) seen.set(signal, { layer, signal });
|
|
40
|
+
}
|
|
41
|
+
return [...seen.values()];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const MAX_MODEL_PROMPTS = 40; // most recent regex-miss messages sent per session
|
|
45
|
+
const CLIP_CHARS = 1500; // per-message clip (corrections live in the first words)
|
|
46
|
+
|
|
47
|
+
// The regex-miss user messages of a batch (the only ones the model sees), most
|
|
48
|
+
// recent MAX_MODEL_PROMPTS. Same prompt-source fallback as accumulateBatchCounts.
|
|
49
|
+
export function regexMissPrompts(batch) {
|
|
50
|
+
const exchanges = (batch && batch.exchanges) || [];
|
|
51
|
+
const prompts = (batch && Array.isArray(batch.user_prompts))
|
|
52
|
+
? batch.user_prompts
|
|
53
|
+
: exchanges.map((e) => e.user_msg);
|
|
54
|
+
return prompts
|
|
55
|
+
.filter((t) => typeof t === 'string' && t.trim() && !detectCorrection(t))
|
|
56
|
+
.slice(-MAX_MODEL_PROMPTS)
|
|
57
|
+
.map((t) => t.slice(0, CLIP_CHARS));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function buildDetectPrompt(prompts) {
|
|
61
|
+
const names = correctionTaxonomy().map((t) => t.signal).join(', ');
|
|
62
|
+
const system =
|
|
63
|
+
'You classify a user\'s messages to an AI assistant, looking for CORRECTIONS — moments where ' +
|
|
64
|
+
'the user pushes back on HOW the assistant responded (length, format, tone, depth, autonomy, ' +
|
|
65
|
+
'risk posture), not on facts. Signals: ' + names + '. ' +
|
|
66
|
+
'The names are literal: too_long = the reply was too long / wants it shorter; wants_confirmation = ' +
|
|
67
|
+
'asks the assistant to check before acting; and so on. Reply with STRICT JSON only:\n' +
|
|
68
|
+
'{"hits": [{"m": <message number>, "signal": "<signal name>"}]}\n' +
|
|
69
|
+
'At most ONE signal per message — the dominant correction. Messages with no correction are ' +
|
|
70
|
+
'simply omitted. When unsure, omit (precision over recall). Output ONLY the JSON object.';
|
|
71
|
+
const user = prompts.map((t, i) => `[${i + 1}] ${t}`).join('\n\n');
|
|
72
|
+
return { system, user };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Parse + validate the model's reply. Returns { <1-based m>: signal } for valid,
|
|
76
|
+
// in-range, first-per-message hits with known signal names; {} on any junk.
|
|
77
|
+
export function parseDetectResponse(text, promptCount) {
|
|
78
|
+
let obj;
|
|
79
|
+
try {
|
|
80
|
+
const m = String(text).match(/\{[\s\S]*\}/);
|
|
81
|
+
if (!m) return {};
|
|
82
|
+
obj = JSON.parse(m[0]);
|
|
83
|
+
} catch { return {}; }
|
|
84
|
+
const valid = new Map(correctionTaxonomy().map((t) => [t.signal, t.layer]));
|
|
85
|
+
const out = {};
|
|
86
|
+
for (const hit of Array.isArray(obj?.hits) ? obj.hits : []) {
|
|
87
|
+
const m = hit && hit.m;
|
|
88
|
+
if (!Number.isInteger(m) || m < 1 || m > promptCount) continue;
|
|
89
|
+
if (out[m]) continue; // first hit per message wins (regex parity)
|
|
90
|
+
if (!valid.has(hit.signal)) continue; // unknown name → dropped, never invented
|
|
91
|
+
out[m] = hit.signal;
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Fold validated model hits into a session-counts object (accumulateBatchCounts
|
|
97
|
+
// shape). Pure — returns a new counts object; the input is not mutated. Only
|
|
98
|
+
// behavioral_signals move: positives/mental-models stay regex-only for now.
|
|
99
|
+
export function mergeModelCounts(counts, hits) {
|
|
100
|
+
const layerOf = new Map(correctionTaxonomy().map((t) => [t.signal, t.layer]));
|
|
101
|
+
const behavioral = {};
|
|
102
|
+
for (const [layer, sigs] of Object.entries((counts && counts.behavioral_signals) || {})) {
|
|
103
|
+
behavioral[layer] = { ...sigs };
|
|
104
|
+
}
|
|
105
|
+
let added = 0;
|
|
106
|
+
for (const signal of Object.values(hits || {})) {
|
|
107
|
+
const layer = layerOf.get(signal);
|
|
108
|
+
if (!layer) continue;
|
|
109
|
+
if (!behavioral[layer]) behavioral[layer] = {};
|
|
110
|
+
behavioral[layer][signal] = (behavioral[layer][signal] || 0) + 1;
|
|
111
|
+
added++;
|
|
112
|
+
}
|
|
113
|
+
return { counts: { ...counts, behavioral_signals: behavioral }, added };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Opt-in config, mirroring consultOptsFromEnv's conventions. `model` unset →
|
|
117
|
+
// the whole backend is off and the hook never touches the network for it.
|
|
118
|
+
export function captureModelOptsFromEnv(env = process.env) {
|
|
119
|
+
return {
|
|
120
|
+
model: env.PHAEDO_CAPTURE_MODEL || null,
|
|
121
|
+
modelUrl: env.PHAEDO_CAPTURE_MODEL_URL || 'http://localhost:11434',
|
|
122
|
+
apiKey: env.PHAEDO_CAPTURE_API_KEY || null,
|
|
123
|
+
timeoutMs: Number(env.PHAEDO_CAPTURE_MODEL_TIMEOUT_MS) || 15000,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// The durable on-switch: <state dir>/capture-model.json, written by
|
|
128
|
+
// `node install.mjs --capture-model <name>` (removed with `... off`). A shell
|
|
129
|
+
// `export` only reaches TERMINAL-launched sessions — Claude Desktop / Cowork
|
|
130
|
+
// launched from the Dock never read the shell profile, so their hooks would
|
|
131
|
+
// silently stay regex-only. The config file lives beside capture-state.json,
|
|
132
|
+
// which the hook reads anyway, so it reaches every launch path.
|
|
133
|
+
// Shape: { "model": "llama3.1:8b", "url"?, "api_key"?, "timeout_ms"? }.
|
|
134
|
+
export function captureModelConfigPath(env = process.env) {
|
|
135
|
+
const dir = env.PHAEDO_MCP_STATE_DIR || join(homedir(), '.phaedo-mcp');
|
|
136
|
+
return join(dir, 'capture-model.json');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Resolved capture-model config: env vars win (per-session override, and the
|
|
140
|
+
// original interface), else the config file, else off. A missing or malformed
|
|
141
|
+
// file is simply "off" — the hook must never fail on config.
|
|
142
|
+
export function captureModelOpts(env = process.env) {
|
|
143
|
+
const fromEnv = captureModelOptsFromEnv(env);
|
|
144
|
+
if (fromEnv.model) return fromEnv;
|
|
145
|
+
try {
|
|
146
|
+
const cfg = JSON.parse(readFileSync(captureModelConfigPath(env), 'utf8'));
|
|
147
|
+
if (cfg && typeof cfg.model === 'string' && cfg.model) {
|
|
148
|
+
return {
|
|
149
|
+
model: cfg.model,
|
|
150
|
+
modelUrl: (typeof cfg.url === 'string' && cfg.url) || fromEnv.modelUrl,
|
|
151
|
+
apiKey: (typeof cfg.api_key === 'string' && cfg.api_key) || null,
|
|
152
|
+
timeoutMs: Number(cfg.timeout_ms) || fromEnv.timeoutMs,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
} catch { /* no/broken file → off */ }
|
|
156
|
+
return fromEnv;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// One chat call against an Ollama / OpenAI-compatible endpoint (the consult
|
|
160
|
+
// modelJudge transport). Throws on any transport/HTTP failure; the caller
|
|
161
|
+
// treats every throw as "no enrichment".
|
|
162
|
+
export async function callChatModel(system, user, opts) {
|
|
163
|
+
const res = await fetch(`${String(opts.modelUrl).replace(/\/$/, '')}/v1/chat/completions`, {
|
|
164
|
+
method: 'POST',
|
|
165
|
+
headers: { 'Content-Type': 'application/json', ...(opts.apiKey ? { Authorization: `Bearer ${opts.apiKey}` } : {}) },
|
|
166
|
+
body: JSON.stringify({
|
|
167
|
+
model: opts.model,
|
|
168
|
+
messages: [{ role: 'system', content: system }, { role: 'user', content: user }],
|
|
169
|
+
temperature: 0,
|
|
170
|
+
stream: false,
|
|
171
|
+
}),
|
|
172
|
+
signal: AbortSignal.timeout(opts.timeoutMs || 15000),
|
|
173
|
+
});
|
|
174
|
+
if (!res.ok) throw new Error(`capture model endpoint ${res.status}`);
|
|
175
|
+
const data = await res.json();
|
|
176
|
+
return data?.choices?.[0]?.message?.content || '';
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// The enrichment pass: classify the regex-miss messages and fold the hits into
|
|
180
|
+
// the session counts. NEVER throws and never degrades the deterministic result —
|
|
181
|
+
// any failure (endpoint down, timeout, junk output) returns the input counts.
|
|
182
|
+
// opts — captureModelOptsFromEnv shape; opts.call is injectable for tests.
|
|
183
|
+
export async function enrichCountsWithModel(batch, counts, opts = {}) {
|
|
184
|
+
if (!opts.model) return { counts, added: 0 };
|
|
185
|
+
const prompts = regexMissPrompts(batch);
|
|
186
|
+
if (!prompts.length) return { counts, added: 0 };
|
|
187
|
+
try {
|
|
188
|
+
const { system, user } = buildDetectPrompt(prompts);
|
|
189
|
+
const call = opts.call || callChatModel;
|
|
190
|
+
const hits = parseDetectResponse(await call(system, user, opts), prompts.length);
|
|
191
|
+
return mergeModelCounts(counts, hits);
|
|
192
|
+
} catch {
|
|
193
|
+
return { counts, added: 0 }; // enrichment only — regex counts always land
|
|
194
|
+
}
|
|
195
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Phaedo capture — the merge-owner precedence predicate.
|
|
2
|
+
//
|
|
3
|
+
// Resolves open question Q3 of docs/roadmap/desktop-capture-deposit.md. A capture
|
|
4
|
+
// surface deposits a signal delta to the `capture_to_owner` mailbox lane; exactly ONE
|
|
5
|
+
// device drains and reconciles it. Which device is the owner is a presence-based
|
|
6
|
+
// precedence ROLE, not a hardwired device:
|
|
7
|
+
//
|
|
8
|
+
// 1. phone — if a phone is paired (canonical vault owner; re-broadcasts §5)
|
|
9
|
+
// 2. extension — else, if the extension is present (today's browser-primary user)
|
|
10
|
+
// 3. mcp-local — else (phone-less + browser-less desktop/CLI — the Claude Code dev)
|
|
11
|
+
//
|
|
12
|
+
// Anti-deadlock (the Q3 concern — "confirm the claim rule can't have BOTH yield"):
|
|
13
|
+
// the order is TOTAL and MONOTONIC in presence. The top tier (phone) never yields;
|
|
14
|
+
// each lower tier yields only when a STRICTLY HIGHER tier is present. So there is
|
|
15
|
+
// always exactly one owner and never zero — "both yield" is unreachable. A pairing
|
|
16
|
+
// still verifying counts as NOT present (fail toward the lower, always-available
|
|
17
|
+
// tier), so the extension keeps owning until the phone's pairing is verified, then
|
|
18
|
+
// yields; the brief two-owner overlap during that handoff is harmless because the
|
|
19
|
+
// reconcile is per-source idempotent (Q2 / §3).
|
|
20
|
+
|
|
21
|
+
export const TIERS = ['phone', 'extension', 'mcp-local'];
|
|
22
|
+
|
|
23
|
+
// presence — { phonePaired?: bool, extensionPresent?: bool }. Unknown/undefined
|
|
24
|
+
// treated as absent (fail toward the always-available mcp-local tier).
|
|
25
|
+
export function resolveMergeOwner(presence = {}) {
|
|
26
|
+
if (presence.phonePaired) return 'phone';
|
|
27
|
+
if (presence.extensionPresent) return 'extension';
|
|
28
|
+
return 'mcp-local';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Does `tier` own the capture lane given this presence? Exactly one tier is true for
|
|
32
|
+
// any presence, so a drainer calls isOwner(myTier, presence) to decide whether to drain.
|
|
33
|
+
export function isOwner(tier, presence = {}) {
|
|
34
|
+
return resolveMergeOwner(presence) === tier;
|
|
35
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
{
|
|
2
|
+
"//": "Declares capture/*.js as ES modules so Node (the Claude Code capture hook, tests) can import them directly. Surface-agnostic Track-A capture contract: transcript parsing, source-id derivation, merge-owner precedence. Node-only; nothing here ships in the browser extension.",
|
|
3
|
+
"type": "module"
|
|
4
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Phaedo capture — stable per-surface, per-MACHINE source device id.
|
|
2
|
+
//
|
|
3
|
+
// Resolves open question Q2 of docs/roadmap/desktop-capture-deposit.md. Every capture
|
|
4
|
+
// surface tags its deposits with a `source_device_id`; the merge owner's per-source
|
|
5
|
+
// idempotent reconcile (replace-that-source's-snapshot, never sum) depends on this id
|
|
6
|
+
// being (a) STABLE across process restarts AND pairing changes, and (b) UNIQUE per
|
|
7
|
+
// (machine, surface).
|
|
8
|
+
//
|
|
9
|
+
// The id is derived from a persistent random MACHINE id — NOT the pairing — for two
|
|
10
|
+
// reasons the review surfaced:
|
|
11
|
+
// 1. Pairing-derived ids CHANGE when the user pairs/re-pairs (or fixes a
|
|
12
|
+
// pairing.capture.json), so everything captured before that transition would be
|
|
13
|
+
// stranded under an orphaned key (unpaired→paired stranding).
|
|
14
|
+
// 2. Two machines that export the SAME extension pairing (desktop + laptop, per the
|
|
15
|
+
// runbook) would derive the IDENTICAL pairing-based id and clobber each other's
|
|
16
|
+
// cumulative snapshots on the owner (replace-per-source under-counts).
|
|
17
|
+
// A per-machine uuid persisted once in the state dir is stable across both, and
|
|
18
|
+
// distinct per machine — so each machine's snapshot accumulates as its own source.
|
|
19
|
+
|
|
20
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
21
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
22
|
+
import { dirname, join } from 'node:path';
|
|
23
|
+
import { homedir } from 'node:os';
|
|
24
|
+
|
|
25
|
+
function defaultMachineIdPath(env = process.env) {
|
|
26
|
+
const dir = env.PHAEDO_MCP_STATE_DIR || join(homedir(), '.phaedo-mcp');
|
|
27
|
+
return join(dir, 'machine-id');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Read (or first-time create) the persistent per-machine id. Best-effort: if the
|
|
31
|
+
// state dir is unwritable we fall back to a process-stable in-memory id (capture still
|
|
32
|
+
// works for the session; only cross-restart stability is lost on a read-only FS).
|
|
33
|
+
let _memMachineId = null;
|
|
34
|
+
export function machineId(env = process.env) {
|
|
35
|
+
const path = defaultMachineIdPath(env);
|
|
36
|
+
try {
|
|
37
|
+
return readFileSync(path, 'utf8').trim() || createAndStore(path);
|
|
38
|
+
} catch {
|
|
39
|
+
return createAndStore(path);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function createAndStore(path) {
|
|
43
|
+
const id = randomUUID();
|
|
44
|
+
try { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, id + '\n', 'utf8'); }
|
|
45
|
+
catch { if (!_memMachineId) _memMachineId = randomUUID(); return _memMachineId; }
|
|
46
|
+
return id;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// surface — a stable string naming the capture surface, e.g. 'claude-code' or 'a11y-macos'.
|
|
50
|
+
// The `pairing` param is kept for signature compatibility with callers but is NO LONGER
|
|
51
|
+
// used to derive the id (see the header): the id is per-machine + per-surface.
|
|
52
|
+
export function deriveSourceDeviceId(_pairing, surface, env = process.env) {
|
|
53
|
+
const tag = String(surface || 'unknown');
|
|
54
|
+
const digest = createHash('sha256').update(`${machineId(env)} ${tag}`).digest('base64url');
|
|
55
|
+
return `cap_${tag}_${digest.slice(0, 16)}`;
|
|
56
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Phaedo capture — cumulative per-surface counter state.
|
|
2
|
+
//
|
|
3
|
+
// delta-lift's idempotency contract requires that a deposited signal's
|
|
4
|
+
// observation_count be the surface's CUMULATIVE total (reconcile REPLACES a
|
|
5
|
+
// source='delta' signal on key match rather than summing). A hook run is
|
|
6
|
+
// per-session, so the hook needs durable local state that accumulates counts
|
|
7
|
+
// across sessions — this module is that state.
|
|
8
|
+
//
|
|
9
|
+
// Resume-safety: Claude Code fires SessionEnd more than once for the same
|
|
10
|
+
// session (clear / resume / logout), and each firing re-processes the FULL
|
|
11
|
+
// transcript. Counts are therefore stored PER SESSION and REPLACED on
|
|
12
|
+
// re-processing (never added), so a resumed session can only update its own
|
|
13
|
+
// contribution — no double-count. The cumulative total = baseline + Σ sessions;
|
|
14
|
+
// old sessions are evicted into the baseline beyond a cap so the file stays small.
|
|
15
|
+
//
|
|
16
|
+
// Contains COUNTS ONLY — signal names and integers, never conversation text
|
|
17
|
+
// (the discard-raw contract). Plaintext JSON is acceptable at this sensitivity
|
|
18
|
+
// (same class as the extension's phaedo_behavioral_signals counters); the state
|
|
19
|
+
// file lives in the MCP state dir (default ~/.phaedo-mcp/, 0600-style dir the
|
|
20
|
+
// server already uses for its key + cache).
|
|
21
|
+
|
|
22
|
+
import { readFileSync, writeFileSync, renameSync, mkdirSync } from 'node:fs';
|
|
23
|
+
import { dirname, join } from 'node:path';
|
|
24
|
+
import { homedir } from 'node:os';
|
|
25
|
+
|
|
26
|
+
const VERSION = 1;
|
|
27
|
+
const DEFAULT_MAX_SESSIONS = 200;
|
|
28
|
+
|
|
29
|
+
export function defaultStatePath(env = process.env) {
|
|
30
|
+
const dir = env.PHAEDO_MCP_STATE_DIR || join(homedir(), '.phaedo-mcp');
|
|
31
|
+
return join(dir, 'capture-state.json');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function emptyState() {
|
|
35
|
+
return { version: VERSION, surfaces: {} };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── pure helpers ─────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
function emptyCounts() {
|
|
41
|
+
return { behavioral_signals: {}, positive_count: 0, mental_models: {}, message_count: 0 };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// counts + counts → counts. Robust to a malformed source (null, wrong shape, string
|
|
45
|
+
// counters): a hand-edited or partially-written state file must never throw here — the
|
|
46
|
+
// throw happens AFTER saveState in the hook, so it would re-persist the bad entry and
|
|
47
|
+
// silently kill capture forever. Non-object sources are skipped; counters are coerced
|
|
48
|
+
// to finite numbers (a stray string '7' can't corrupt the sum into '07').
|
|
49
|
+
const num = (v) => (Number.isFinite(v) ? v : (Number.isFinite(Number(v)) ? Number(v) : 0));
|
|
50
|
+
export function mergeCounts(a, b) {
|
|
51
|
+
const out = emptyCounts();
|
|
52
|
+
for (const src of [a, b]) {
|
|
53
|
+
if (!src || typeof src !== 'object') continue;
|
|
54
|
+
for (const [layer, sigs] of Object.entries(src.behavioral_signals || {})) {
|
|
55
|
+
if (!sigs || typeof sigs !== 'object') continue;
|
|
56
|
+
if (!out.behavioral_signals[layer]) out.behavioral_signals[layer] = {};
|
|
57
|
+
for (const [sig, n] of Object.entries(sigs)) {
|
|
58
|
+
out.behavioral_signals[layer][sig] = (out.behavioral_signals[layer][sig] || 0) + num(n);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
for (const [fw, n] of Object.entries(src.mental_models || {})) {
|
|
62
|
+
out.mental_models[fw] = (out.mental_models[fw] || 0) + num(n);
|
|
63
|
+
}
|
|
64
|
+
out.positive_count += num(src.positive_count);
|
|
65
|
+
out.message_count += num(src.message_count);
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Record a session's counts for a surface, REPLACING any prior contribution from
|
|
71
|
+
// the same sessionId (resume-safe). Evicts oldest sessions into the baseline
|
|
72
|
+
// beyond maxSessions. Pure — returns a new state.
|
|
73
|
+
//
|
|
74
|
+
// Once a session is evicted its counts are frozen into the opaque baseline sum, which
|
|
75
|
+
// can't be decomposed — so if that SAME session re-fires (SessionEnd on clear/resume of
|
|
76
|
+
// a long-lived session outliving 200 others), re-adding it would DOUBLE-count it
|
|
77
|
+
// permanently. We track evicted ids and skip a re-fire of one: its counts stay as
|
|
78
|
+
// frozen in baseline (bounded staleness) rather than doubling. The evicted-id list is
|
|
79
|
+
// itself capped; an id that ages out of it is doubly-rare to re-fire.
|
|
80
|
+
export function applySession(state, sourceId, sessionId, counts, { now, maxSessions = DEFAULT_MAX_SESSIONS } = {}) {
|
|
81
|
+
const stamp = now || new Date().toISOString();
|
|
82
|
+
const prev = (state && state.surfaces) || {};
|
|
83
|
+
const surface = prev[sourceId] || { baseline: emptyCounts(), sessions: {}, evicted: [] };
|
|
84
|
+
const sid = String(sessionId || 'unknown');
|
|
85
|
+
const evicted = Array.isArray(surface.evicted) ? surface.evicted.slice() : [];
|
|
86
|
+
// Drop any malformed (null / non-object) session entry from a hand-edited file so the
|
|
87
|
+
// eviction sort below (sessions[x].updated_at) can't throw.
|
|
88
|
+
const priorSessions = {};
|
|
89
|
+
for (const [k, v] of Object.entries(surface.sessions || {})) if (v && typeof v === 'object') priorSessions[k] = v;
|
|
90
|
+
|
|
91
|
+
// A re-fire of an already-evicted session: don't re-add (its counts are in baseline).
|
|
92
|
+
if (evicted.includes(sid) && !priorSessions[sid]) {
|
|
93
|
+
return { version: VERSION, surfaces: { ...prev, [sourceId]: { ...surface, sessions: priorSessions, evicted, updated_at: stamp } } };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const sessions = { ...priorSessions, [sid]: { counts, updated_at: stamp } };
|
|
97
|
+
|
|
98
|
+
// Evict oldest (by updated_at) into baseline beyond the cap.
|
|
99
|
+
let baseline = surface.baseline || emptyCounts();
|
|
100
|
+
const ids = Object.keys(sessions);
|
|
101
|
+
if (ids.length > maxSessions) {
|
|
102
|
+
ids.sort((x, y) => String(sessions[x].updated_at).localeCompare(String(sessions[y].updated_at)));
|
|
103
|
+
for (const id of ids.slice(0, ids.length - maxSessions)) {
|
|
104
|
+
baseline = mergeCounts(baseline, sessions[id].counts);
|
|
105
|
+
delete sessions[id];
|
|
106
|
+
if (!evicted.includes(id)) evicted.push(id);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
while (evicted.length > maxSessions) evicted.shift(); // bound the evicted-id list
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
version: VERSION,
|
|
113
|
+
surfaces: { ...prev, [sourceId]: { baseline, sessions, evicted, updated_at: stamp } },
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// The cumulative counts for a surface: baseline + Σ sessions. This is what the
|
|
118
|
+
// hook lifts (observation_count = cumulative total, per delta-lift's contract).
|
|
119
|
+
export function cumulativeCounts(state, sourceId) {
|
|
120
|
+
const surface = state && state.surfaces && state.surfaces[sourceId];
|
|
121
|
+
if (!surface) return emptyCounts();
|
|
122
|
+
// Run the baseline through mergeCounts too, so a malformed/hand-edited baseline (null,
|
|
123
|
+
// string counters) is sanitized rather than returned raw. A null/malformed session
|
|
124
|
+
// entry is skipped (mergeCounts ignores a non-object src; guard the `.counts` access).
|
|
125
|
+
let out = mergeCounts(emptyCounts(), surface.baseline);
|
|
126
|
+
for (const s of Object.values(surface.sessions || {})) out = mergeCounts(out, s && s.counts);
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── IO (sync — the hook is a short-lived script) ─────────────────────────────
|
|
131
|
+
|
|
132
|
+
// Missing or corrupt file → empty state (a broken state file must never break a
|
|
133
|
+
// session or a capture; it just restarts the counters).
|
|
134
|
+
export function loadState(path) {
|
|
135
|
+
try {
|
|
136
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
137
|
+
if (parsed && parsed.version === VERSION && parsed.surfaces) return parsed;
|
|
138
|
+
} catch { /* fall through */ }
|
|
139
|
+
return emptyState();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Atomic: temp file + rename (same pattern as the receipts/auth stores).
|
|
143
|
+
export function saveState(path, state) {
|
|
144
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
145
|
+
const tmp = `${path}.tmp`;
|
|
146
|
+
writeFileSync(tmp, JSON.stringify(state), 'utf8');
|
|
147
|
+
renameSync(tmp, path);
|
|
148
|
+
}
|