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 +16 -0
- package/dist/cli.js +75 -0
- package/dist/server.js +11 -2
- package/dist/sync/handoffSync.js +309 -0
- package/dist/tools/index.js +1 -1
- package/dist/tools/sessionMemoryDefinitions.js +16 -0
- package/package.json +1 -1
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
|
+
}
|
package/dist/tools/index.js
CHANGED
|
@@ -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.
|
|
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",
|