prism-mcp-server 20.16.0 → 20.17.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/README.md CHANGED
@@ -138,6 +138,22 @@ or by re-enabling after each run.
138
138
  <details>
139
139
  <summary>Release history (optional)</summary>
140
140
 
141
+ ## What's New in v20.17.0
142
+
143
+ ### Cross-Machine Session Handoff — End-to-End Encrypted
144
+
145
+ - **Resume a session on any of your machines.** With `prism sync enable` (paid,
146
+ off by default), each `session_save_handoff` seals the handoff to all your
147
+ account's device keys and relays the CIPHERTEXT; another machine pulls with
148
+ `sync_pull_handoff` (or `prism sync pull <project>`) and opens it locally.
149
+ - **The relay stores ciphertext only** — X25519 + AES-256-GCM sealed
150
+ envelopes. No key that opens a handoff ever exists server-side. The channel
151
+ is deliberately separate from savings sync, which carries counters only.
152
+ - **TOFU device pinning** surfaces a compromised relay: sealing to a key this
153
+ machine has never seen warns loudly, keyed on the client-derived recipient
154
+ id so a swapped key can't hide behind a familiar device name.
155
+ - `prism sync status|devices` to inspect; revoke a lost machine from the portal.
156
+
141
157
  ## What's New in v20.16.0
142
158
 
143
159
  ### See What Local Serving Saves You — Meterable, Auditable, Team-Wide
package/dist/cli.js CHANGED
@@ -1134,6 +1134,81 @@ program
1134
1134
  process.exit(1);
1135
1135
  }
1136
1136
  });
1137
+ // ─── prism sync ───────────────────────────────────────────────
1138
+ // Cross-machine handoff sync controls. Push happens automatically on
1139
+ // session_save_handoff once enabled; everything E2E lives in src/crypto/.
1140
+ program
1141
+ .command('sync <action> [project]')
1142
+ .description('Cross-machine handoff sync: enable | disable | status | pull <project> | devices')
1143
+ .action(async (action, project) => {
1144
+ try {
1145
+ switch (action) {
1146
+ case 'enable':
1147
+ case 'disable': {
1148
+ const { setSetting } = await import('./storage/configStorage.js');
1149
+ await setSetting('PRISM_HANDOFF_SYNC', action === 'enable' ? '1' : '0');
1150
+ console.log(action === 'enable'
1151
+ ? 'Handoff sync enabled. On each session_save_handoff, the handoff is sealed to your '
1152
+ + 'account devices (end-to-end encrypted — the relay stores ciphertext only) and uploaded. '
1153
+ + 'Paid plans; disable anytime with: prism sync disable'
1154
+ : 'Handoff sync disabled. Nothing further leaves this machine on this channel.');
1155
+ return;
1156
+ }
1157
+ case 'status': {
1158
+ const enabled = process.env.PRISM_HANDOFF_SYNC === '1' ||
1159
+ (process.env.PRISM_HANDOFF_SYNC !== '0' &&
1160
+ ['1', 'true'].includes((await (await import('./storage/configStorage.js')).getSetting('PRISM_HANDOFF_SYNC', '')).trim().toLowerCase()));
1161
+ const { loadOrCreateDeviceIdentity } = await import('./crypto/deviceKeys.js');
1162
+ const id = loadOrCreateDeviceIdentity();
1163
+ console.log(`Handoff sync: ${enabled ? 'ENABLED' : 'disabled'}`);
1164
+ console.log(`This device: ${id.keyId}${id.created ? ' (key created just now)' : ''}`);
1165
+ return;
1166
+ }
1167
+ case 'pull': {
1168
+ if (!project) {
1169
+ console.error('Usage: prism sync pull <project>');
1170
+ process.exit(1);
1171
+ }
1172
+ const { pullHandoff, renderPulledHandoff } = await import('./sync/handoffSync.js');
1173
+ const r = await pullHandoff(project);
1174
+ console.log(renderPulledHandoff(r));
1175
+ if (!r.ok)
1176
+ process.exit(1);
1177
+ return;
1178
+ }
1179
+ case 'devices': {
1180
+ const { getSynaluxJwt } = await import('./utils/synaluxJwt.js');
1181
+ const jwt = await getSynaluxJwt();
1182
+ if (!jwt) {
1183
+ console.error('No portal credentials — sign in first.');
1184
+ process.exit(1);
1185
+ }
1186
+ const base = (process.env.PRISM_SYNALUX_BASE_URL?.trim() || process.env.SYNALUX_BASE_URL?.trim() ||
1187
+ (await import('./config.js')).PRISM_SYNALUX_BASE_URL || '').replace(/\/+$/, '');
1188
+ const res = await fetch(`${base}/api/v1/prism/sync/devices`, {
1189
+ headers: { Authorization: `Bearer ${jwt}` }, signal: AbortSignal.timeout(10_000)
1190
+ });
1191
+ if (!res.ok) {
1192
+ console.error(`Device list unavailable (HTTP ${res.status}).`);
1193
+ process.exit(1);
1194
+ }
1195
+ const data = await res.json();
1196
+ for (const d of data.devices) {
1197
+ console.log(`${d.revoked ? '✗ (revoked)' : '✓'} ${d.device_id} ${d.label ?? ''} last seen ${d.last_seen_at}`);
1198
+ }
1199
+ console.log('Revoke a lost machine via the portal dashboard.');
1200
+ return;
1201
+ }
1202
+ default:
1203
+ console.error(`Unknown action "${action}" — expected enable | disable | status | pull | devices.`);
1204
+ process.exit(1);
1205
+ }
1206
+ }
1207
+ catch (err) {
1208
+ console.error(`sync failed: ${err instanceof Error ? err.message : String(err)}`);
1209
+ process.exit(1);
1210
+ }
1211
+ });
1137
1212
  // ─── prism update-models ──────────────────────────────────────
1138
1213
  // Standalone model convergence: pull each installed prism-coder tier from
1139
1214
  // the registry and repair its local alias. connect runs this automatically;
package/dist/server.js CHANGED
@@ -124,7 +124,7 @@ ONBOARDING_WIZARD_TOOL, EXTRACT_ENTITIES_TOOL, API_ANALYTICS_TOOL, BACKUP_DATABA
124
124
  // v15.5: Knowledge Ingestion
125
125
  KNOWLEDGE_INGEST_TOOL,
126
126
  // v19.2: Inference Metrics
127
- INFERENCE_METRICS_TOOL, SAVINGS_TOOL, sessionSaveLedgerHandler, sessionSaveHandoffHandler, sessionLoadContextHandler, sessionBootstrapHandler, sessionRoutePromptHandler, knowledgeSearchHandler, knowledgeForgetHandler,
127
+ INFERENCE_METRICS_TOOL, SAVINGS_TOOL, SYNC_PULL_HANDOFF_TOOL, sessionSaveLedgerHandler, sessionSaveHandoffHandler, sessionLoadContextHandler, sessionBootstrapHandler, sessionRoutePromptHandler, knowledgeSearchHandler, knowledgeForgetHandler,
128
128
  // ─── v0.4.0: New tool handlers ───
129
129
  compactLedgerHandler, sessionSearchMemoryHandler, backfillEmbeddingsHandler, sessionBackfillLinksHandler, sessionSynthesizeEdgesHandler, sessionCognitiveRouteHandler,
130
130
  // ─── v2.0: Time Travel handlers ───
@@ -241,6 +241,7 @@ function buildSessionMemoryTools() {
241
241
  INFERENCE_METRICS_TOOL, // inference_metrics — read-only session delegation stats
242
242
  // ─── v20.16: Local-serving meter ───
243
243
  SAVINGS_TOOL, // local_savings — tokens displaced by local serving
244
+ SYNC_PULL_HANDOFF_TOOL, // sync_pull_handoff — E2E cross-machine handoff pull
244
245
  ];
245
246
  }
246
247
  // ─── v0.4.0: Resource Subscription Tracking ──────────────────────
@@ -771,7 +772,9 @@ export function createServer() {
771
772
  // REVIEWER NOTE: v0.4.0 passes the server reference so the
772
773
  // handler can trigger resource update notifications after
773
774
  // a successful save. See notifyResourceUpdate() above.
774
- result = await sessionSaveHandoffHandler(args, server);
775
+ result = await sessionSaveHandoffHandler(args, server); // Handoff sync (paid, opt-in, fail-soft): seal to the account's
776
+ // devices and relay ciphertext. Gated entirely inside the module.
777
+ void import("./sync/handoffSync.js").then(m => m.pushHandoffFromArgs(args)).catch(() => { });
775
778
  break;
776
779
  case "session_load_context":
777
780
  if (!SESSION_MEMORY_ENABLED)
@@ -1031,6 +1034,12 @@ export function createServer() {
1031
1034
  case "inference_metrics":
1032
1035
  result = await inferenceMetricsHandler(args);
1033
1036
  break;
1037
+ case "sync_pull_handoff": {
1038
+ const { pullHandoff, renderPulledHandoff } = await import("./sync/handoffSync.js");
1039
+ const pr = await pullHandoff(String(args?.project ?? ""));
1040
+ result = { content: [{ type: "text", text: renderPulledHandoff(pr) }], ...(pr.ok ? {} : { isError: true }) };
1041
+ break;
1042
+ }
1034
1043
  case "local_savings":
1035
1044
  result = await savingsHandler(args);
1036
1045
  break;
@@ -0,0 +1,309 @@
1
+ /**
2
+ * Cross-machine handoff sync — the Phase-2 engine on top of src/crypto/.
3
+ *
4
+ * Flow: on session_save_handoff (and only then), if the user opted in, this
5
+ * seals the handoff to ALL of the account's active device public keys and
6
+ * PUTs the ciphertext to the portal relay. Another machine pulls, opens with
7
+ * its own device key, and resumes. The relay stores ciphertext only — see
8
+ * src/crypto/syncEnvelope.ts for the properties that make that architectural.
9
+ *
10
+ * Consent model (mirrors savingsSync, deliberately):
11
+ * 1. OPT-IN — PRISM_HANDOFF_SYNC (env or stored setting), default OFF.
12
+ * 2. PAID — client-side plan gate; the portal enforces the real one.
13
+ * 3. FAIL-SOFT — sync never breaks a save; failures debugLog and return.
14
+ *
15
+ * Trust boundary, handled rather than hand-waved: the DEVICE LIST comes from
16
+ * the portal, and a compromised portal could inject an attacker's public key
17
+ * to receive readable copies of future blobs. Mitigation is TOFU pinning:
18
+ * every device keyId this machine has ever sealed to is remembered locally
19
+ * (~/.prism-mcp/known-sync-devices.json); when the set GROWS, the push still
20
+ * succeeds (users legitimately add machines) but the result carries a
21
+ * loud warning naming the new keyIds, and the warning repeats until the user
22
+ * has seen a push after the growth. Silent key injection is the attack;
23
+ * unnoticed growth is what TOFU removes.
24
+ */
25
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
26
+ import { homedir } from "node:os";
27
+ import { join, resolve } from "node:path";
28
+ import { debugLog } from "../utils/logger.js";
29
+ import { getEntitlements } from "../utils/entitlements.js";
30
+ import { getSynaluxJwt } from "../utils/synaluxJwt.js";
31
+ import { getSetting } from "../storage/configStorage.js";
32
+ import { loadOrCreateDeviceIdentity } from "../crypto/deviceKeys.js";
33
+ import { sealFor, openSealed, isSealedEnvelope, EnvelopeError, keyIdOf } from "../crypto/syncEnvelope.js";
34
+ import { PRISM_SYNALUX_BASE_URL } from "../config.js";
35
+ const TIMEOUT_MS = 10_000;
36
+ const PROJECT_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/;
37
+ function baseUrl() {
38
+ return (process.env.PRISM_SYNALUX_BASE_URL?.trim() ||
39
+ process.env.SYNALUX_BASE_URL?.trim() ||
40
+ PRISM_SYNALUX_BASE_URL ||
41
+ "").replace(/\/+$/, "");
42
+ }
43
+ async function syncEnabled() {
44
+ if (process.env.PRISM_HANDOFF_SYNC === "1")
45
+ return true;
46
+ if (process.env.PRISM_HANDOFF_SYNC === "0")
47
+ return false;
48
+ const setting = (await getSetting("PRISM_HANDOFF_SYNC", "")).trim();
49
+ return setting === "1" || setting.toLowerCase() === "true";
50
+ }
51
+ function aadFor(project) {
52
+ return `prism-sync:v1:${project}:handoff`;
53
+ }
54
+ // ── TOFU pinning ─────────────────────────────────────────────────────────────
55
+ function pinPath() {
56
+ const dir = process.env.PRISM_DATA_DIR
57
+ ? resolve(process.env.PRISM_DATA_DIR)
58
+ : resolve(homedir(), ".prism-mcp");
59
+ return join(dir, "known-sync-devices.json");
60
+ }
61
+ function readPins() {
62
+ try {
63
+ const p = pinPath();
64
+ if (!existsSync(p))
65
+ return new Set();
66
+ const parsed = JSON.parse(readFileSync(p, "utf8"));
67
+ return new Set(Array.isArray(parsed.device_ids)
68
+ ? parsed.device_ids.filter((d) => typeof d === "string")
69
+ : []);
70
+ }
71
+ catch {
72
+ // Unreadable pins fail toward WARNING (everything looks new), never
73
+ // toward silence.
74
+ return new Set();
75
+ }
76
+ }
77
+ function writePins(pins) {
78
+ try {
79
+ writeFileSync(pinPath(), JSON.stringify({ device_ids: [...pins].sort() }, null, 2) + "\n", { mode: 0o600 });
80
+ }
81
+ catch (e) {
82
+ debugLog(`[handoff-sync] pin write failed: ${e instanceof Error ? e.message : e}`);
83
+ }
84
+ }
85
+ let registeredThisProcess = false;
86
+ async function ensureRegistered(jwt, fetchImpl) {
87
+ if (registeredThisProcess)
88
+ return true;
89
+ const device = loadOrCreateDeviceIdentity();
90
+ const res = await fetchImpl(`${baseUrl()}/api/v1/prism/sync/devices`, {
91
+ method: "POST",
92
+ headers: { "Authorization": `Bearer ${jwt}`, "Content-Type": "application/json" },
93
+ body: JSON.stringify({
94
+ device_id: device.keyId,
95
+ public_key: device.rawPublicKey.toString("base64"),
96
+ }),
97
+ signal: AbortSignal.timeout(TIMEOUT_MS),
98
+ });
99
+ if (!res.ok) {
100
+ debugLog(`[handoff-sync] device registration failed: HTTP ${res.status}`);
101
+ return false;
102
+ }
103
+ registeredThisProcess = true;
104
+ return true;
105
+ }
106
+ /** Test hook. */
107
+ export function _resetHandoffSyncForTest() {
108
+ registeredThisProcess = false;
109
+ }
110
+ async function fetchActiveDevices(jwt, fetchImpl) {
111
+ const res = await fetchImpl(`${baseUrl()}/api/v1/prism/sync/devices`, {
112
+ headers: { "Authorization": `Bearer ${jwt}` },
113
+ signal: AbortSignal.timeout(TIMEOUT_MS),
114
+ });
115
+ if (!res.ok)
116
+ return null;
117
+ const data = (await res.json());
118
+ if (!Array.isArray(data.devices))
119
+ return null;
120
+ const out = [];
121
+ for (const d of data.devices) {
122
+ if (typeof d !== "object" || d === null)
123
+ continue;
124
+ const r = d;
125
+ if (typeof r.device_id !== "string" || typeof r.public_key !== "string" || r.revoked)
126
+ continue;
127
+ // The adversary this whole module names is a portal that lies in THIS
128
+ // response. It cannot be trusted to tell the truth about which key
129
+ // belongs to which device_id, so the asserted device_id is not used as
130
+ // an identity anywhere downstream: re-derive the kid from the key that
131
+ // will actually receive the blob. A portal that reuses a pinned
132
+ // device_id with a swapped key produces a DIFFERENT derivedKid here, so
133
+ // TOFU sees a new device and warns — the exact injection the header
134
+ // promises to surface. A portal that also forges the device_id to match
135
+ // its swapped key still surfaces, because the pin is on the derived kid.
136
+ let rawKey;
137
+ try {
138
+ rawKey = Buffer.from(r.public_key, "base64");
139
+ }
140
+ catch {
141
+ continue;
142
+ }
143
+ if (rawKey.length !== 32)
144
+ continue;
145
+ let derivedKid;
146
+ try {
147
+ derivedKid = keyIdOf(rawKey);
148
+ }
149
+ catch {
150
+ continue;
151
+ }
152
+ out.push({ rawKey, derivedKid, assertedId: r.device_id });
153
+ }
154
+ return out;
155
+ }
156
+ export async function pushHandoff(project, handoff, fetchImpl = fetch) {
157
+ try {
158
+ if (!(await syncEnabled()))
159
+ return { pushed: false, reason: "disabled" };
160
+ if (!PROJECT_RE.test(project))
161
+ return { pushed: false, reason: "bad_project" };
162
+ const ent = await getEntitlements();
163
+ if (ent.plan === "free")
164
+ return { pushed: false, reason: "free_plan" };
165
+ if (!baseUrl())
166
+ return { pushed: false, reason: "no_base_url" };
167
+ const jwt = await getSynaluxJwt();
168
+ if (!jwt)
169
+ return { pushed: false, reason: "no_jwt" };
170
+ if (!(await ensureRegistered(jwt, fetchImpl))) {
171
+ return { pushed: false, reason: "registration_failed" };
172
+ }
173
+ const devices = await fetchActiveDevices(jwt, fetchImpl);
174
+ if (!devices || devices.length === 0) {
175
+ return { pushed: false, reason: "device_list_unavailable" };
176
+ }
177
+ const self = loadOrCreateDeviceIdentity();
178
+ // Always seal to SELF from local key material, regardless of the portal
179
+ // list. A hostile (or merely stale) portal that omits this device would
180
+ // otherwise make the origin unable to open its own handoff — a
181
+ // self-inflicted lockout. Deduped by derived kid so a portal that DOES
182
+ // list us is not a double recipient.
183
+ const targets = new Map();
184
+ targets.set(self.keyId, self.rawPublicKey);
185
+ for (const d of devices)
186
+ targets.set(d.derivedKid, d.rawKey);
187
+ // TOFU keys on the DERIVED kid — the same value sealFor puts in the
188
+ // envelope as the recipient — so a swapped key is a new identity here,
189
+ // never a silent reuse of a pinned device_id. Self is never "new".
190
+ const pins = readPins();
191
+ const newDevices = [...targets.keys()].filter((kid) => kid !== self.keyId && !pins.has(kid));
192
+ const payload = Buffer.from(JSON.stringify({
193
+ v: 1,
194
+ project,
195
+ saved_at: new Date().toISOString(),
196
+ origin_device_id: self.keyId,
197
+ handoff,
198
+ }), "utf8");
199
+ const envelope = sealFor([...targets.values()], payload, aadFor(project));
200
+ const res = await fetchImpl(`${baseUrl()}/api/v1/prism/sync/blob`, {
201
+ method: "PUT",
202
+ headers: { "Authorization": `Bearer ${jwt}`, "Content-Type": "application/json" },
203
+ body: JSON.stringify({ project, kind: "handoff", envelope, origin_device_id: self.keyId }),
204
+ signal: AbortSignal.timeout(TIMEOUT_MS),
205
+ });
206
+ if (!res.ok) {
207
+ debugLog(`[handoff-sync] relay rejected blob: HTTP ${res.status}`);
208
+ return { pushed: false, reason: "portal_rejected" };
209
+ }
210
+ // Pin only AFTER a successful push, so a failed push re-warns next time.
211
+ if (newDevices.length > 0) {
212
+ for (const id of newDevices)
213
+ pins.add(id);
214
+ writePins(pins);
215
+ debugLog(`[handoff-sync] ⚠ sealed to ${newDevices.length} device(s) this machine had never seen: ${newDevices.join(", ")}`);
216
+ }
217
+ return { pushed: true, reason: "ok", sealed_to: targets.size, ...(newDevices.length ? { new_devices: newDevices } : {}) };
218
+ }
219
+ catch (e) {
220
+ debugLog(`[handoff-sync] push failed: ${e instanceof Error ? e.message : e}`);
221
+ return { pushed: false, reason: "error" };
222
+ }
223
+ }
224
+ /** Fire-and-forget adapter for the session_save_handoff dispatch site. */
225
+ export async function pushHandoffFromArgs(args) {
226
+ if (typeof args !== "object" || args === null)
227
+ return;
228
+ const o = args;
229
+ const project = typeof o.project === "string" ? o.project : null;
230
+ if (!project)
231
+ return;
232
+ const result = await pushHandoff(project, o);
233
+ if (result.pushed && result.new_devices?.length) {
234
+ console.error(`[handoff-sync] ⚠ NEW sync device(s) on your account: ${result.new_devices.join(", ")}. ` +
235
+ `If you did not add a machine, revoke it: prism sync devices`);
236
+ }
237
+ }
238
+ export async function pullHandoff(project, fetchImpl = fetch) {
239
+ try {
240
+ if (!(await syncEnabled()))
241
+ return { ok: false, reason: "disabled" };
242
+ if (!PROJECT_RE.test(project))
243
+ return { ok: false, reason: "bad_project" };
244
+ if (!baseUrl())
245
+ return { ok: false, reason: "no_base_url" };
246
+ const jwt = await getSynaluxJwt();
247
+ if (!jwt)
248
+ return { ok: false, reason: "no_jwt" };
249
+ const res = await fetchImpl(`${baseUrl()}/api/v1/prism/sync/blob?project=${encodeURIComponent(project)}`, { headers: { "Authorization": `Bearer ${jwt}` }, signal: AbortSignal.timeout(TIMEOUT_MS) });
250
+ if (res.status === 404)
251
+ return { ok: false, reason: "no_blob" };
252
+ if (res.status === 401 || res.status === 402 || res.status === 403) {
253
+ return { ok: false, reason: "not_entitled" };
254
+ }
255
+ if (!res.ok)
256
+ return { ok: false, reason: "portal_error" };
257
+ const body = (await res.json());
258
+ if (!isSealedEnvelope(body.envelope))
259
+ return { ok: false, reason: "bad_envelope" };
260
+ const self = loadOrCreateDeviceIdentity();
261
+ let plaintext;
262
+ try {
263
+ plaintext = openSealed(body.envelope, self.privateKey, self.rawPublicKey, aadFor(project));
264
+ }
265
+ catch (e) {
266
+ if (e instanceof EnvelopeError && /not a recipient/.test(e.message)) {
267
+ // Sealed before this device existed (or after it was revoked).
268
+ // The NEXT save from any current device includes this machine.
269
+ return { ok: false, reason: "not_recipient" };
270
+ }
271
+ return { ok: false, reason: "bad_envelope" };
272
+ }
273
+ const payload = JSON.parse(plaintext.toString("utf8"));
274
+ if (!payload || payload.v !== 1 || payload.project !== project) {
275
+ return { ok: false, reason: "bad_envelope" };
276
+ }
277
+ return {
278
+ ok: true, reason: "ok", payload,
279
+ origin_device_id: typeof body.origin_device_id === "string" ? body.origin_device_id : undefined,
280
+ updated_at: typeof body.updated_at === "string" ? body.updated_at : undefined,
281
+ };
282
+ }
283
+ catch (e) {
284
+ debugLog(`[handoff-sync] pull failed: ${e instanceof Error ? e.message : e}`);
285
+ return { ok: false, reason: "error" };
286
+ }
287
+ }
288
+ /** Human rendering for the tool/CLI pull surfaces. */
289
+ export function renderPulledHandoff(r) {
290
+ if (!r.ok || !r.payload) {
291
+ const why = {
292
+ disabled: "Handoff sync is off on this machine. Enable: prism sync enable",
293
+ no_blob: "No synced handoff exists for this project yet.",
294
+ not_recipient: "A handoff exists but was sealed before this device joined — it will include this machine after the next save elsewhere.",
295
+ not_entitled: "Cross-machine sync needs a paid plan and a signed-in account.",
296
+ no_jwt: "No portal credentials on this machine — sign in first.",
297
+ bad_envelope: "The stored blob failed authentication — refusing to use it.",
298
+ };
299
+ return `🔄 ${why[r.reason] ?? `Handoff sync unavailable (${r.reason}).`}`;
300
+ }
301
+ const p = r.payload;
302
+ const lines = [
303
+ `🔄 Synced handoff for ${p.project}`,
304
+ ` From device ${p.origin_device_id} at ${p.saved_at}`,
305
+ "",
306
+ JSON.stringify(p.handoff, null, 2),
307
+ ];
308
+ return lines.join("\n");
309
+ }
@@ -63,7 +63,7 @@ export { verifyBehaviorHandler } from "./behavioralVerifierHandler.js";
63
63
  // Chunks source code, generates Q&A via Claude Haiku, stores in knowledge graph.
64
64
  // Three entry points: MCP tool, REST API, GitHub webhook.
65
65
  export { KNOWLEDGE_INGEST_TOOL } from "./ingestDefinitions.js";
66
- export { INFERENCE_METRICS_TOOL, SAVINGS_TOOL } from "./sessionMemoryDefinitions.js";
66
+ export { INFERENCE_METRICS_TOOL, SAVINGS_TOOL, SYNC_PULL_HANDOFF_TOOL } from "./sessionMemoryDefinitions.js";
67
67
  export { knowledgeIngestHandler, handleGitHubWebhook, ingestKnowledge, isIngestArgs } from "./ingestHandler.js";
68
68
  // ── v15.4: prism_infer — local-first inference (RAM-gated cascade) ──
69
69
  // Always available. Saves caller's cloud tokens by routing to local
@@ -1952,6 +1952,22 @@ export const INFERENCE_METRICS_TOOL = {
1952
1952
  },
1953
1953
  },
1954
1954
  };
1955
+ // ─── v20.17: Cross-machine handoff sync ────────────────────
1956
+ export const SYNC_PULL_HANDOFF_TOOL = {
1957
+ name: "sync_pull_handoff",
1958
+ description: "Pulls this account's synced handoff for a project from the E2E relay and " +
1959
+ "opens it with THIS machine's device key. The relay stores ciphertext only; " +
1960
+ "a handoff is readable here only if it was sealed to this device. Requires " +
1961
+ "handoff sync enabled (prism sync enable), a paid plan, and a signed-in " +
1962
+ "account. Push happens automatically on session_save_handoff.",
1963
+ inputSchema: {
1964
+ type: "object",
1965
+ properties: {
1966
+ project: { type: "string", description: "Project whose handoff to pull." },
1967
+ },
1968
+ required: ["project"],
1969
+ },
1970
+ };
1955
1971
  // ─── v20.16: Local-serving meter ───────────────────────────
1956
1972
  export const SAVINGS_TOOL = {
1957
1973
  name: "local_savings",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prism-mcp-server",
3
- "version": "20.16.0",
3
+ "version": "20.17.0",
4
4
  "mcpName": "io.github.dcostenco/prism-coder",
5
5
  "description": "Persistent session memory for AI coding agents that never leaves your machine — including the on-device model that reasons over it. Restores your prior decisions, open TODOs, and changed files across sessions; adds associative recall of related past work, semantic drift detection, and local inference. Local-first by default. Works with Claude Code, Cursor, and Codex.",
6
6
  "module": "index.ts",