phaedo-mcp 0.2.2 → 0.3.1
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 +182 -19
- package/action-binding.js +82 -0
- package/agreement.js +86 -2
- 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 +126 -8
- package/consult-policy.js +427 -33
- package/consult-runtime.js +60 -0
- package/consult.js +29 -1
- package/delegation-sync.js +95 -0
- package/escalation-policy.sample.json +13 -0
- package/escalation.js +383 -0
- package/fingerprint-source.js +112 -13
- package/health.js +54 -0
- package/hooks/pre-ship.mjs +409 -0
- package/hooks/session-end.mjs +244 -0
- package/hooks/stop-capture.mjs +140 -0
- package/install.mjs +226 -36
- 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 +29 -5
- package/phone-source.js +85 -12
- package/projection.js +4 -1
- package/pull.js +19 -3
- package/receipts-sync.js +197 -0
- package/receipts.js +34 -3
- package/server.js +323 -54
- package/server.json +3 -3
- package/ship-actions.js +164 -0
- package/ship-readout.js +106 -0
- 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 +184 -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 +45 -0
- package/vendor/extraction/reconcile.js +310 -0
- package/vendor/extraction/resolve.js +178 -0
- package/vendor/extraction/rule-promotion.js +103 -0
- package/vendor/kdf.js +20 -1
- package/vendor/protocol.js +7 -0
- package/vendor/signal-ledger.js +402 -0
- package/vendor/trait-phrases.js +656 -0
- package/vendored-files.mjs +86 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
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
|
+
|
|
50
|
+
// WHO wrote a user-role line, when the transcript says (2026-10-20). Newer Claude Code writes
|
|
51
|
+
// `origin` on user records: `{kind: "human"}` for a typed turn, another kind (e.g.
|
|
52
|
+
// "task-notification") or null for text the app injects — CI-monitor events, background-task
|
|
53
|
+
// notices, side-session reports. Those carry plain prose, so isMachineAuthored above let them
|
|
54
|
+
// through and the detectors read an injected "do not stop to report" as the SUBJECT's
|
|
55
|
+
// correction. Measured on the dev Mac since 2026-09-20: 363 human lines, 33 task notifications,
|
|
56
|
+
// 21 unmarked lines in marked files (scripted probes) — all of the latter counted as Randy's.
|
|
57
|
+
// - a transcript that carries origin markers → only `kind: "human"` lines are the subject's;
|
|
58
|
+
// - a transcript with none (older format) → today's behaviour, minus prose that opens with an
|
|
59
|
+
// app envelope tag, which no person types.
|
|
60
|
+
// Includes Phaedo's OWN injected profile (`<phaedo_user_profile>`, the SessionStart hook's
|
|
61
|
+
// projection): it describes the subject in their words ("too long", "be concise"), so read as a
|
|
62
|
+
// prompt it scored the profile as the subject correcting the AI — a loop feeding itself.
|
|
63
|
+
const MACHINE_ENVELOPE = /^<(task-notification|ci-monitor-event|side-sessions-event|system-reminder|command-(name|message|args)|local-command-stdout|user-prompt-submit-hook|phaedo_user_profile|phaedo_fingerprint)\b/i;
|
|
64
|
+
function hasOriginMarkers(recs) {
|
|
65
|
+
return recs.some((r) => r && r.type === 'user' && Object.prototype.hasOwnProperty.call(r, 'origin'));
|
|
66
|
+
}
|
|
67
|
+
function isSubjectAuthored(rec, text, marked) {
|
|
68
|
+
if (MACHINE_ENVELOPE.test(text)) return false; // an app envelope is never typed, marked or not
|
|
69
|
+
if (marked) return !!(rec.origin && typeof rec.origin === 'object' && rec.origin.kind === 'human');
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
function isAssistant(rec) {
|
|
73
|
+
return rec && rec.type === 'assistant' && rec.message && !rec.isSidechain;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// The active conversation path only. The transcript is an append-only uuid/parentUuid
|
|
77
|
+
// TREE: an esc-esc edit/rewind leaves the abandoned branch in the file alongside the
|
|
78
|
+
// retry. A linear scan emits an exchange for EVERY branch, so one edited correction is
|
|
79
|
+
// counted N times (the capture-review finding). We walk parentUuid from the leaf (the
|
|
80
|
+
// last record — the most recent append is always on the live path) back to the root and
|
|
81
|
+
// keep only that chain. Falls back to all records when the tree can't be reconstructed
|
|
82
|
+
// (missing uuids / older format) so nothing breaks.
|
|
83
|
+
function activePathRecords(recs) {
|
|
84
|
+
const byUuid = new Map();
|
|
85
|
+
for (const r of recs) if (r && r.uuid) byUuid.set(r.uuid, r);
|
|
86
|
+
// No usable tree → linear (current behavior, safe).
|
|
87
|
+
if (byUuid.size < 2) return recs;
|
|
88
|
+
let leaf = null;
|
|
89
|
+
for (let i = recs.length - 1; i >= 0; i--) { if (recs[i] && recs[i].uuid) { leaf = recs[i]; break; } }
|
|
90
|
+
if (!leaf) return recs;
|
|
91
|
+
const onPath = new Set();
|
|
92
|
+
let cur = leaf, guard = 0;
|
|
93
|
+
while (cur && guard++ < byUuid.size + 1) {
|
|
94
|
+
onPath.add(cur.uuid);
|
|
95
|
+
cur = cur.parentUuid ? byUuid.get(cur.parentUuid) : null;
|
|
96
|
+
}
|
|
97
|
+
// If the walk covered almost nothing (broken/mixed parent links), don't trust it.
|
|
98
|
+
if (onPath.size < 2) return recs;
|
|
99
|
+
return recs.filter((r) => !r || !r.uuid || onPath.has(r.uuid));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Parse a raw JSONL string (or a pre-split array of lines) into records, skipping
|
|
103
|
+
// blank and malformed lines. Exported for callers that already hold the text.
|
|
104
|
+
export function parseJsonl(text) {
|
|
105
|
+
const lines = Array.isArray(text) ? text : String(text || '').split('\n');
|
|
106
|
+
const out = [];
|
|
107
|
+
for (const line of lines) {
|
|
108
|
+
const s = typeof line === 'string' ? line.trim() : '';
|
|
109
|
+
if (!s) continue;
|
|
110
|
+
try { out.push(JSON.parse(s)); } catch { /* skip a torn/partial line */ }
|
|
111
|
+
}
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Records → exchange_batch. Each user prompt is paired with the assistant text that
|
|
116
|
+
// follows it (concatenated across multiple assistant lines) up to the next user
|
|
117
|
+
// prompt. A trailing user prompt with no reply yet is dropped (incomplete exchange),
|
|
118
|
+
// matching the web path's complete-only shipping.
|
|
119
|
+
// records — array already parsed (parseJsonl), or a raw JSONL string.
|
|
120
|
+
// opts.sessionId — override; otherwise taken from the first record's sessionId.
|
|
121
|
+
// Returns { exchange_batch_version, session_local_id, exchanges: [...] }.
|
|
122
|
+
export function recordsToBatch(records, opts = {}) {
|
|
123
|
+
const parsed = typeof records === 'string' ? parseJsonl(records) : (Array.isArray(records) ? records : []);
|
|
124
|
+
const recs = activePathRecords(parsed); // drop abandoned edit/rewind branches
|
|
125
|
+
const sessionId = opts.sessionId
|
|
126
|
+
|| recs.find((r) => r && r.sessionId)?.sessionId
|
|
127
|
+
|| null;
|
|
128
|
+
|
|
129
|
+
const marked = hasOriginMarkers(recs);
|
|
130
|
+
const exchanges = [];
|
|
131
|
+
const user_prompts = []; // EVERY genuine user prompt (answered or not)
|
|
132
|
+
const user_prompt_times = []; // parallel to user_prompts: each prompt's timestamp (or null)
|
|
133
|
+
let pending = null; // the open user prompt awaiting a reply
|
|
134
|
+
let replyParts = [];
|
|
135
|
+
|
|
136
|
+
const flush = () => {
|
|
137
|
+
if (pending && replyParts.length) {
|
|
138
|
+
const ai = replyParts.join('\n').trim();
|
|
139
|
+
if (ai) {
|
|
140
|
+
exchanges.push({
|
|
141
|
+
exchange_id: pending.id,
|
|
142
|
+
session_id: sessionId,
|
|
143
|
+
platform: PLATFORM,
|
|
144
|
+
timestamp: pending.timestamp,
|
|
145
|
+
user_msg: pending.text,
|
|
146
|
+
ai_response: ai,
|
|
147
|
+
delta_annotations: {}, // Path-A detector output is filled in by the caller (slice 2)
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
pending = null;
|
|
152
|
+
replyParts = [];
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
for (const rec of recs) {
|
|
156
|
+
if (isUserPrompt(rec)) {
|
|
157
|
+
const text = contentText(rec.message.content);
|
|
158
|
+
if (!text) continue; // tool_result-only user line — not prose
|
|
159
|
+
if (!isSubjectAuthored(rec, text, marked)) continue; // app-injected prose — not the subject
|
|
160
|
+
// Pasted blocks are someone else's words — terminal output, a log, a document — that the
|
|
161
|
+
// subject quoted, not typed. The app wraps them; keep only what was typed around them.
|
|
162
|
+
const typed = text.replace(/<pasted_content\b[^>]*>[\s\S]*?<\/pasted_content\b[^>]*>/gi, ' ').trim();
|
|
163
|
+
if (!typed) continue;
|
|
164
|
+
flush(); // close any previous exchange first
|
|
165
|
+
user_prompts.push(typed); // count Path-A per SENT prompt (extension parity)
|
|
166
|
+
user_prompt_times.push(rec.timestamp || null);
|
|
167
|
+
pending = { id: rec.uuid || `ex-${exchanges.length}`, text: typed, timestamp: rec.timestamp || null };
|
|
168
|
+
} else if (isAssistant(rec) && pending) {
|
|
169
|
+
const text = contentText(rec.message.content);
|
|
170
|
+
if (text) replyParts.push(text);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
flush(); // close the final exchange
|
|
174
|
+
|
|
175
|
+
// exchanges = COMPLETE pairs (for the future model/Loop-2 path); user_prompts = every
|
|
176
|
+
// sent prompt (for Path-A counters — the web path counts at SEND time, so an
|
|
177
|
+
// interrupted / unanswered correction must still count, unlike exchange shipping).
|
|
178
|
+
return { exchange_batch_version: 'v0.1', session_local_id: sessionId, exchanges, user_prompts, user_prompt_times };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Convenience: raw JSONL text → batch in one call.
|
|
182
|
+
export function transcriptToBatch(text, opts = {}) {
|
|
183
|
+
return recordsToBatch(parseJsonl(text), opts);
|
|
184
|
+
}
|
|
@@ -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
|
+
})();
|