prism-mcp-server 20.21.5 → 20.21.7
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/dist/connect.js +208 -7
- package/dist/dashboard/dashboardToken.js +13 -7
- package/dist/dashboard/graphRouter.js +12 -2
- package/dist/dashboard/ledgerReader.js +9 -0
- package/dist/dashboard/server.js +7 -14
- package/dist/dashboard/ui.js +7 -1
- package/dist/server.js +60 -0
- package/dist/storage/synalux.js +66 -0
- package/package.json +1 -1
package/dist/connect.js
CHANGED
|
@@ -591,7 +591,13 @@ function serializeClaudeStartupBlock(newline) {
|
|
|
591
591
|
].join(newline);
|
|
592
592
|
}
|
|
593
593
|
/** Install or refresh Claude Code's native, hook-free first-turn instruction. */
|
|
594
|
-
export function configureClaudeNativeStartup(homeDir = homedir(), dryRun = false, beforeCommit
|
|
594
|
+
export function configureClaudeNativeStartup(homeDir = homedir(), dryRun = false, beforeCommit,
|
|
595
|
+
/** Refresh an existing managed block and nothing else. With this set the
|
|
596
|
+
* install branch is unreachable, so a caller that did not receive the
|
|
597
|
+
* operator's consent (the unattended startup refresh) cannot create a
|
|
598
|
+
* block — not through a marker that only appears in prose, and not through
|
|
599
|
+
* a marker removed between a caller's check and this read. */
|
|
600
|
+
refreshOnly = false) {
|
|
595
601
|
const instructionPath = join(homeDir, ".claude", "CLAUDE.md");
|
|
596
602
|
let writePath = instructionPath;
|
|
597
603
|
let symlinkPath;
|
|
@@ -613,6 +619,11 @@ export function configureClaudeNativeStartup(homeDir = homedir(), dryRun = false
|
|
|
613
619
|
const newline = currentText.includes("\r\n") ? "\r\n" : "\n";
|
|
614
620
|
const startRanges = findExactLineRanges(currentText, CLAUDE_STARTUP_MANAGED_START);
|
|
615
621
|
const endRanges = findExactLineRanges(currentText, CLAUDE_STARTUP_MANAGED_END);
|
|
622
|
+
// Not ours: no opening marker at all. Under refreshOnly that is the ordinary
|
|
623
|
+
// case (a host the operator never connected), not an ambiguity to shout about.
|
|
624
|
+
if (refreshOnly && startRanges.length === 0) {
|
|
625
|
+
return { path: instructionPath, status: "unmanaged" };
|
|
626
|
+
}
|
|
616
627
|
if (startRanges.length !== endRanges.length || startRanges.length > 1) {
|
|
617
628
|
throw new Error(`Claude instructions contain ambiguous Prism startup ownership markers: ${instructionPath}`);
|
|
618
629
|
}
|
|
@@ -631,6 +642,8 @@ export function configureClaudeNativeStartup(homeDir = homedir(), dryRun = false
|
|
|
631
642
|
action = "refresh";
|
|
632
643
|
}
|
|
633
644
|
else {
|
|
645
|
+
if (refreshOnly)
|
|
646
|
+
return { path: instructionPath, status: "unmanaged" };
|
|
634
647
|
const separator = currentText.length === 0
|
|
635
648
|
? ""
|
|
636
649
|
: currentText.endsWith("\n") || currentText.endsWith("\r")
|
|
@@ -688,7 +701,9 @@ function legacyGeminiStartupBlock(newline) {
|
|
|
688
701
|
].join(newline);
|
|
689
702
|
}
|
|
690
703
|
/** Install or refresh Gemini CLI's native, hook-free first-turn instruction. */
|
|
691
|
-
export function configureGeminiNativeStartup(homeDir = homedir(), dryRun = false, beforeCommit
|
|
704
|
+
export function configureGeminiNativeStartup(homeDir = homedir(), dryRun = false, beforeCommit,
|
|
705
|
+
/** See configureClaudeNativeStartup. */
|
|
706
|
+
refreshOnly = false) {
|
|
692
707
|
const instructionPath = join(homeDir, ".gemini", "GEMINI.md");
|
|
693
708
|
let writePath = instructionPath;
|
|
694
709
|
let symlinkPath;
|
|
@@ -710,6 +725,11 @@ export function configureGeminiNativeStartup(homeDir = homedir(), dryRun = false
|
|
|
710
725
|
const newline = currentText.includes("\r\n") ? "\r\n" : "\n";
|
|
711
726
|
const startRanges = findExactLineRanges(currentText, GEMINI_STARTUP_MANAGED_START);
|
|
712
727
|
const endRanges = findExactLineRanges(currentText, GEMINI_STARTUP_MANAGED_END);
|
|
728
|
+
// Not ours: no opening marker at all. Under refreshOnly that is the ordinary
|
|
729
|
+
// case (a host the operator never connected), not an ambiguity to shout about.
|
|
730
|
+
if (refreshOnly && startRanges.length === 0) {
|
|
731
|
+
return { path: instructionPath, status: "unmanaged" };
|
|
732
|
+
}
|
|
713
733
|
if (startRanges.length !== endRanges.length || startRanges.length > 1) {
|
|
714
734
|
throw new Error(`Gemini instructions contain ambiguous Prism startup ownership markers: ${instructionPath}`);
|
|
715
735
|
}
|
|
@@ -728,6 +748,8 @@ export function configureGeminiNativeStartup(homeDir = homedir(), dryRun = false
|
|
|
728
748
|
action = "refresh";
|
|
729
749
|
}
|
|
730
750
|
else {
|
|
751
|
+
if (refreshOnly)
|
|
752
|
+
return { path: instructionPath, status: "unmanaged" };
|
|
731
753
|
const legacyBlock = legacyGeminiStartupBlock(newline);
|
|
732
754
|
if (currentText.startsWith(legacyBlock)) {
|
|
733
755
|
nextText = managedBlock + newline + currentText.slice(legacyBlock.length);
|
|
@@ -757,7 +779,9 @@ function serializeCodexStartupBlock(newline) {
|
|
|
757
779
|
].join(newline);
|
|
758
780
|
}
|
|
759
781
|
/** Install or refresh Codex's official global, hook-free first-turn instruction. */
|
|
760
|
-
export function configureCodexNativeStartup(homeDir, dryRun = false, beforeCommit, env = homeDir === undefined ? process.env : {}
|
|
782
|
+
export function configureCodexNativeStartup(homeDir, dryRun = false, beforeCommit, env = homeDir === undefined ? process.env : {},
|
|
783
|
+
/** See configureClaudeNativeStartup. */
|
|
784
|
+
refreshOnly = false) {
|
|
761
785
|
const userHome = homeDir ?? homedir();
|
|
762
786
|
const configuredCodexHome = env.CODEX_HOME?.trim();
|
|
763
787
|
const codexHome = configuredCodexHome ? resolve(configuredCodexHome) : join(userHome, ".codex");
|
|
@@ -782,6 +806,11 @@ export function configureCodexNativeStartup(homeDir, dryRun = false, beforeCommi
|
|
|
782
806
|
const newline = currentText.includes("\r\n") ? "\r\n" : "\n";
|
|
783
807
|
const startRanges = findExactLineRanges(currentText, CODEX_STARTUP_MANAGED_START);
|
|
784
808
|
const endRanges = findExactLineRanges(currentText, CODEX_STARTUP_MANAGED_END);
|
|
809
|
+
// Not ours: no opening marker at all. Under refreshOnly that is the ordinary
|
|
810
|
+
// case (a host the operator never connected), not an ambiguity to shout about.
|
|
811
|
+
if (refreshOnly && startRanges.length === 0) {
|
|
812
|
+
return { path: instructionPath, status: "unmanaged" };
|
|
813
|
+
}
|
|
785
814
|
if (startRanges.length !== endRanges.length || startRanges.length > 1) {
|
|
786
815
|
throw new Error(`Codex instructions contain ambiguous Prism startup ownership markers: ${instructionPath}`);
|
|
787
816
|
}
|
|
@@ -800,6 +829,8 @@ export function configureCodexNativeStartup(homeDir, dryRun = false, beforeCommi
|
|
|
800
829
|
action = "refresh";
|
|
801
830
|
}
|
|
802
831
|
else {
|
|
832
|
+
if (refreshOnly)
|
|
833
|
+
return { path: instructionPath, status: "unmanaged" };
|
|
803
834
|
const separator = currentText.length === 0
|
|
804
835
|
? ""
|
|
805
836
|
: currentText.endsWith("\n") || currentText.endsWith("\r")
|
|
@@ -844,7 +875,128 @@ function configureJsonAgentPolicy(configPath, label, mutate, dryRun, beforeCommi
|
|
|
844
875
|
writeTextAtomically(writePath, `${JSON.stringify(config, null, 2)}\n`, originalText, beforeCommit, symlinkPath);
|
|
845
876
|
return { path: configPath, status: action === "install" ? "installed" : "refreshed" };
|
|
846
877
|
}
|
|
847
|
-
/**
|
|
878
|
+
/** Rewrite a managed startup block whose content differs from what THIS binary
|
|
879
|
+
* writes, and nothing else. Not "older": there is no version ordering in the
|
|
880
|
+
* block, so a pinned older install can rewrite what a newer one wrote.
|
|
881
|
+
*
|
|
882
|
+
* The instruction files are the one delivery channel that does not travel with
|
|
883
|
+
* the package: the MCP `initialize` instructions and every tool description
|
|
884
|
+
* update the moment the server binary does, but a native instruction file keeps
|
|
885
|
+
* whatever text `prism connect` last wrote. Released 20.21.0 changed that text —
|
|
886
|
+
* it used to tell hosts to pass `cloud_fallback: false`, which made a paid
|
|
887
|
+
* plan's escalation unreachable — and a machine that never re-runs connect keeps
|
|
888
|
+
* telling its host the retracted thing. `prism update` deliberately never
|
|
889
|
+
* touches host configuration, autoupdate runs `update`, and npm's ignore-scripts
|
|
890
|
+
* blocks the postinstall refresh, so on an ordinary machine nothing heals it.
|
|
891
|
+
*
|
|
892
|
+
* This is the narrow, safe subset of connect, and the narrowness is the point:
|
|
893
|
+
*
|
|
894
|
+
* - MARKER-GATED. A file without the ownership marker is left byte-for-byte
|
|
895
|
+
* alone. Connect is still the only thing that can FIRST install a block;
|
|
896
|
+
* consent is never inferred from a server start.
|
|
897
|
+
* - Startup blocks ONLY. It never touches MCP host registration — that is what
|
|
898
|
+
* connect's "close target hosts before registration" warning is about, since
|
|
899
|
+
* a live host rewrites its own config. These files DO have another writer —
|
|
900
|
+
* Gemini CLI writes GEMINI.md on a remember request, Claude Code writes
|
|
901
|
+
* CLAUDE.md on /init — so only the marker-delimited block is replaced,
|
|
902
|
+
* marker lines included, and the file is re-checked immediately before
|
|
903
|
+
* committing; a write landing inside that final window would still be
|
|
904
|
+
* lost.
|
|
905
|
+
* - Content-addressed, so it is a no-op once current: the block is compared
|
|
906
|
+
* byte-for-byte and only a difference writes.
|
|
907
|
+
*
|
|
908
|
+
* The host reads its instruction file when a session starts, so a refresh made
|
|
909
|
+
* during this session lands on the NEXT one. That is the cost of healing
|
|
910
|
+
* without asking, and it is one session. */
|
|
911
|
+
export function refreshManagedStartupBlocks(options = {}) {
|
|
912
|
+
const homeDir = options.homeDir ?? homedir();
|
|
913
|
+
const env = options.env ?? process.env;
|
|
914
|
+
// The opt-out lives here, next to the writing, so it is one decision and a
|
|
915
|
+
// test can exercise it without starting a server.
|
|
916
|
+
if (env.PRISM_NO_STARTUP_REFRESH === "1")
|
|
917
|
+
return [];
|
|
918
|
+
const dryRun = !!options.dryRun;
|
|
919
|
+
const codexHome = env.CODEX_HOME?.trim() ? resolve(env.CODEX_HOME.trim()) : join(homeDir, ".codex");
|
|
920
|
+
const targets = [
|
|
921
|
+
{ host: "claude-code", path: join(homeDir, ".claude", "CLAUDE.md"), configure: () => configureClaudeNativeStartup(homeDir, dryRun, undefined, true) },
|
|
922
|
+
{ host: "gemini", path: join(homeDir, ".gemini", "GEMINI.md"), configure: () => configureGeminiNativeStartup(homeDir, dryRun, undefined, true) },
|
|
923
|
+
{ host: "codex", path: join(codexHome, "AGENTS.md"), configure: () => configureCodexNativeStartup(homeDir, dryRun, undefined, env, true) },
|
|
924
|
+
];
|
|
925
|
+
const results = [];
|
|
926
|
+
// Two hosts can resolve to ONE file — GEMINI.md symlinked to CLAUDE.md is a
|
|
927
|
+
// common single-file setup — and Claude and Gemini serialize the SAME
|
|
928
|
+
// ownership marker, so without this each start rewrote that file twice and
|
|
929
|
+
// never converged: two "refreshed" lines on every start, forever. Whoever
|
|
930
|
+
// gets there first owns it; the second reports it as already handled.
|
|
931
|
+
// Identity is the FILE, not the path: statSync follows symlinks, and dev:ino
|
|
932
|
+
// is shared by hard links too, which a path comparison misses entirely (an
|
|
933
|
+
// atomic replace would then break the link and hand both hosts a "refreshed"
|
|
934
|
+
// they should never have got). Resolved ONCE per target and reused, so the
|
|
935
|
+
// decision below is the one that was actually measured; a target retargeted
|
|
936
|
+
// after this point is the same inherent window as the compare-to-rename one
|
|
937
|
+
// documented on writeTextAtomically.
|
|
938
|
+
const identityOf = (path) => {
|
|
939
|
+
try {
|
|
940
|
+
const info = statSync(path);
|
|
941
|
+
return `${info.dev}:${info.ino}`;
|
|
942
|
+
}
|
|
943
|
+
catch {
|
|
944
|
+
return `path:${path}`; // absent or unreadable: the path itself is identity enough
|
|
945
|
+
}
|
|
946
|
+
};
|
|
947
|
+
const resolved = targets.map(target => ({ target, identity: identityOf(target.path) }));
|
|
948
|
+
const shared = new Map();
|
|
949
|
+
for (const { target, identity } of resolved) {
|
|
950
|
+
shared.set(identity, [...(shared.get(identity) ?? []), target.host]);
|
|
951
|
+
}
|
|
952
|
+
for (const { target, identity } of resolved) {
|
|
953
|
+
const sharing = shared.get(identity) ?? [target.host];
|
|
954
|
+
if (sharing.length > 1) {
|
|
955
|
+
// One file, two hosts. Claude and Gemini serialize the SAME ownership
|
|
956
|
+
// marker but DIFFERENT instructions (the tool is named differently for
|
|
957
|
+
// each), so there is no content that satisfies both: healing it would
|
|
958
|
+
// hand one host the other's block, and before this check each start
|
|
959
|
+
// rewrote the file once per host, forever. Whose block it should be is
|
|
960
|
+
// the operator's call, not a background process's.
|
|
961
|
+
results.push({
|
|
962
|
+
host: target.host,
|
|
963
|
+
path: target.path,
|
|
964
|
+
status: "failed",
|
|
965
|
+
detail: `shared with ${sharing.filter(h => h !== target.host).join(", ")} (one file cannot hold both blocks); run: prism connect`,
|
|
966
|
+
});
|
|
967
|
+
continue;
|
|
968
|
+
}
|
|
969
|
+
try {
|
|
970
|
+
// No independent pre-read. The configurator's own exact-line marker
|
|
971
|
+
// recognition decides, from ONE snapshot: a second, weaker check here
|
|
972
|
+
// (a substring scan) would call a file managed because the marker
|
|
973
|
+
// appears in its prose, and the window between two reads is a way to
|
|
974
|
+
// lose the marker after the check. With refreshOnly the install branch
|
|
975
|
+
// is unreachable either way.
|
|
976
|
+
const outcome = target.configure();
|
|
977
|
+
results.push({
|
|
978
|
+
host: target.host,
|
|
979
|
+
path: outcome.path,
|
|
980
|
+
// A dry run must not claim a write it did not make: would-refresh is
|
|
981
|
+
// carried through as itself. installed/would-install are unreachable
|
|
982
|
+
// under refreshOnly, so anything else is a completed refresh.
|
|
983
|
+
status: outcome.status === "unchanged" || outcome.status === "unmanaged" || outcome.status === "would-refresh"
|
|
984
|
+
? outcome.status
|
|
985
|
+
: "refreshed",
|
|
986
|
+
});
|
|
987
|
+
}
|
|
988
|
+
catch (error) {
|
|
989
|
+
// A self-heal must never be the reason a server fails to start.
|
|
990
|
+
results.push({
|
|
991
|
+
host: target.host,
|
|
992
|
+
path: target.path,
|
|
993
|
+
status: "failed",
|
|
994
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
995
|
+
});
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
return results;
|
|
999
|
+
}
|
|
848
1000
|
export function configureClaudeAgentPolicy(homeDir = homedir(), dryRun = false, beforeCommit) {
|
|
849
1001
|
const configPath = join(homeDir, ".claude", "settings.json");
|
|
850
1002
|
return configureJsonAgentPolicy(configPath, "Claude agent settings", (config) => {
|
|
@@ -1098,6 +1250,7 @@ function getHostDefinitions(homeDir, platform, env, includeSystemPaths) {
|
|
|
1098
1250
|
label: "Gemini CLI",
|
|
1099
1251
|
format: "json",
|
|
1100
1252
|
configPath: join(homeDir, ".gemini", "settings.json"),
|
|
1253
|
+
additionalConfigPaths: antigravityMcpConfigPaths(homeDir),
|
|
1101
1254
|
detectionPaths: [join(homeDir, ".gemini")],
|
|
1102
1255
|
executables: ["gemini"],
|
|
1103
1256
|
}, {
|
|
@@ -1152,6 +1305,22 @@ function executableExists(name, platform, pathEnv) {
|
|
|
1152
1305
|
}
|
|
1153
1306
|
return false;
|
|
1154
1307
|
}
|
|
1308
|
+
/**
|
|
1309
|
+
* Antigravity has used three user-level MCP locations across releases. Keep
|
|
1310
|
+
* the Gemini CLI settings registration as the primary result, and converge
|
|
1311
|
+
* only Antigravity paths whose parent directory already exists. That avoids
|
|
1312
|
+
* creating an unrelated host config on a Gemini-CLI-only installation while
|
|
1313
|
+
* repairing an existing Antigravity installation, including the legacy IDE
|
|
1314
|
+
* path that can otherwise continue launching an old server indefinitely.
|
|
1315
|
+
*/
|
|
1316
|
+
function antigravityMcpConfigPaths(homeDir) {
|
|
1317
|
+
const geminiDir = join(homeDir, ".gemini");
|
|
1318
|
+
return [
|
|
1319
|
+
join(geminiDir, "config", "mcp_config.json"),
|
|
1320
|
+
join(geminiDir, "antigravity", "mcp_config.json"),
|
|
1321
|
+
join(geminiDir, "antigravity-ide", "mcp_config.json"),
|
|
1322
|
+
].filter((configPath) => existsSync(configPath) || existsSync(dirname(configPath)));
|
|
1323
|
+
}
|
|
1155
1324
|
function buildMcpEntry(nodePath, serverPath, env) {
|
|
1156
1325
|
const storage = env.PRISM_STORAGE ?? "auto";
|
|
1157
1326
|
if (!CONNECT_STORAGE_BACKENDS.includes(storage)) {
|
|
@@ -1179,10 +1348,42 @@ function registerHost(definition, entry, dryRun, refresh, beforeCommit) {
|
|
|
1179
1348
|
if (definition.configurationError) {
|
|
1180
1349
|
return result(definition, "error", definition.configurationError);
|
|
1181
1350
|
}
|
|
1182
|
-
|
|
1183
|
-
|
|
1351
|
+
const configDefinitions = [
|
|
1352
|
+
definition,
|
|
1353
|
+
...(definition.additionalConfigPaths ?? []).map((configPath) => ({ ...definition, configPath })),
|
|
1354
|
+
];
|
|
1355
|
+
const registerOne = (configDefinition) => {
|
|
1356
|
+
if (configDefinition.format === "codex-toml") {
|
|
1357
|
+
return registerCodexTomlHost(configDefinition, entry, dryRun, refresh, beforeCommit);
|
|
1358
|
+
}
|
|
1359
|
+
return registerJsonHost(configDefinition, entry, dryRun, refresh, beforeCommit);
|
|
1360
|
+
};
|
|
1361
|
+
const primary = registerOne(configDefinitions[0]);
|
|
1362
|
+
if (primary.status === "error")
|
|
1363
|
+
return primary;
|
|
1364
|
+
const registrations = [primary, ...configDefinitions.slice(1).map(registerOne)];
|
|
1365
|
+
const failures = registrations.slice(1).filter((registration) => registration.status === "error");
|
|
1366
|
+
if (failures.length > 0) {
|
|
1367
|
+
return {
|
|
1368
|
+
...primary,
|
|
1369
|
+
status: "error",
|
|
1370
|
+
startupCompatible: false,
|
|
1371
|
+
message: `additional config registration failed: ${failures.map((failure) => `${failure.path}: ${failure.message || "unknown error"}`).join("; ")}`,
|
|
1372
|
+
};
|
|
1184
1373
|
}
|
|
1185
|
-
|
|
1374
|
+
const additionalChanges = registrations.slice(1).filter((registration) => registration.status !== "existing");
|
|
1375
|
+
const incompatible = registrations.filter((registration) => !registration.startupCompatible);
|
|
1376
|
+
const additionalMessage = additionalChanges.length > 0
|
|
1377
|
+
? `also ${additionalChanges.map((registration) => `${registration.status} ${registration.path}`).join(", ")}`
|
|
1378
|
+
: undefined;
|
|
1379
|
+
const incompatibleMessage = incompatible.length > 0
|
|
1380
|
+
? `retained incompatible or unmanaged registration(s): ${incompatible.map((registration) => registration.path).join(", ")}`
|
|
1381
|
+
: undefined;
|
|
1382
|
+
return {
|
|
1383
|
+
...primary,
|
|
1384
|
+
startupCompatible: registrations.every((registration) => registration.startupCompatible),
|
|
1385
|
+
message: [primary.message, additionalMessage, incompatibleMessage].filter(Boolean).join("; ") || undefined,
|
|
1386
|
+
};
|
|
1186
1387
|
}
|
|
1187
1388
|
function registerJsonHost(definition, entry, dryRun, refresh, beforeCommit) {
|
|
1188
1389
|
const { configPath } = definition;
|
|
@@ -20,24 +20,30 @@ export function resolveDashboardToken(cfg) {
|
|
|
20
20
|
return randomBytes(32).toString("hex");
|
|
21
21
|
}
|
|
22
22
|
/** Extract the prism_dashboard_token cookie value, if present. */
|
|
23
|
-
export function
|
|
23
|
+
export function dashboardTokenCookieName(port) {
|
|
24
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
25
|
+
throw new Error('Invalid dashboard port');
|
|
26
|
+
return `prism_dashboard_token_${port}`;
|
|
27
|
+
}
|
|
28
|
+
export function tokenFromCookie(cookieHeader, cookieName = 'prism_dashboard_token') {
|
|
24
29
|
// Capture the whole value (any run of non-";", non-space) so pinned tokens
|
|
25
30
|
// with hyphens/underscores match; the name is anchored to start-or-"; " so a
|
|
26
31
|
// look-alike cookie (evil_prism_dashboard_token=…) cannot match.
|
|
27
|
-
const
|
|
28
|
-
|
|
32
|
+
const entry = (cookieHeader || '').split(';').map(c => c.trim()).find(c => c.startsWith(`${cookieName}=`));
|
|
33
|
+
const value = entry?.slice(cookieName.length + 1);
|
|
34
|
+
return value && !/\s/.test(value) ? value : null;
|
|
29
35
|
}
|
|
30
36
|
/**
|
|
31
37
|
* True when the request presents the active token via the cookie, the
|
|
32
38
|
* X-Prism-Dashboard-Token header, or a ?token= query param. Every comparison is
|
|
33
39
|
* timing-safe and only runs against the single active token.
|
|
34
40
|
*/
|
|
35
|
-
export function requestHasToken(headers, queryToken, activeToken) {
|
|
36
|
-
const candidates = [tokenFromCookie(headers.cookie), headers.headerToken ?? null, queryToken];
|
|
41
|
+
export function requestHasToken(headers, queryToken, activeToken, cookieName = 'prism_dashboard_token') {
|
|
42
|
+
const candidates = [tokenFromCookie(headers.cookie, cookieName), tokenFromCookie(headers.cookie), headers.headerToken ?? null, queryToken];
|
|
37
43
|
return candidates.some((c) => c !== null && safeCompare(c, activeToken));
|
|
38
44
|
}
|
|
39
45
|
/** Build the Set-Cookie value that stores the token for a browser session. */
|
|
40
|
-
export function buildTokenCookie(token, maxAgeMs, secure) {
|
|
41
|
-
return (
|
|
46
|
+
export function buildTokenCookie(token, maxAgeMs, secure, cookieName = 'prism_dashboard_token') {
|
|
47
|
+
return (`${cookieName}=${token}; Path=/; HttpOnly; SameSite=Strict; ` +
|
|
42
48
|
`Max-Age=${Math.floor(maxAgeMs / 1000)}${secure ? "; Secure" : ""}`);
|
|
43
49
|
}
|
|
@@ -65,16 +65,26 @@ export async function handleGraphRoutes(url, req, res, getStorageSafe) {
|
|
|
65
65
|
else {
|
|
66
66
|
params.limit = "200"; // Bump limit when exploring specific filters
|
|
67
67
|
}
|
|
68
|
+
let createdAfter;
|
|
68
69
|
if (project)
|
|
69
70
|
params.project = `eq.${project}`;
|
|
70
71
|
if (days) {
|
|
71
72
|
const past = new Date();
|
|
72
73
|
past.setDate(past.getDate() - parseInt(days, 10));
|
|
73
|
-
|
|
74
|
+
createdAfter = past.toISOString();
|
|
75
|
+
params.created_at = `gte.${createdAfter}`;
|
|
74
76
|
}
|
|
75
77
|
if (min_importance)
|
|
76
78
|
params.importance = `gte.${parseInt(min_importance, 10)}`;
|
|
77
|
-
const
|
|
79
|
+
const graphLimit = !project && !days && !min_importance ? 30 : 200;
|
|
80
|
+
const entries = s.getDashboardGraphEntries
|
|
81
|
+
? await s.getDashboardGraphEntries({
|
|
82
|
+
...(project ? { project } : {}),
|
|
83
|
+
...(createdAfter ? { createdAfter } : {}),
|
|
84
|
+
...(min_importance ? { minImportance: parseInt(min_importance, 10) } : {}),
|
|
85
|
+
limit: graphLimit,
|
|
86
|
+
})
|
|
87
|
+
: await s.getLedgerEntries(params);
|
|
78
88
|
const now = Date.now();
|
|
79
89
|
// ── Decay tracking: aggregate per-keyword and per-project ──
|
|
80
90
|
// For each keyword/project, track the *freshest* entry's decay info
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Paid clients have Portal credentials, not direct Supabase credentials. */
|
|
2
|
+
export async function readDashboardLedger(storage, backend, project, order, limit) {
|
|
3
|
+
if (backend !== "synalux") {
|
|
4
|
+
return storage.getLedgerEntries({ project: `eq.${project}`, order, limit: String(limit) });
|
|
5
|
+
}
|
|
6
|
+
if (!storage.getDashboardLedger)
|
|
7
|
+
throw new Error("Cloud dashboard ledger reader unavailable");
|
|
8
|
+
return storage.getDashboardLedger(project, order, limit);
|
|
9
|
+
}
|
package/dist/dashboard/server.js
CHANGED
|
@@ -22,7 +22,8 @@ import * as os from "os";
|
|
|
22
22
|
import * as fs from "fs";
|
|
23
23
|
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
|
24
24
|
import { createServer, getAllPossibleTools } from "../server.js";
|
|
25
|
-
import { getStorage } from "../storage/index.js";
|
|
25
|
+
import { getStorage, activeStorageBackend } from "../storage/index.js";
|
|
26
|
+
import { readDashboardLedger } from "./ledgerReader.js";
|
|
26
27
|
import { PRISM_USER_ID, SERVER_CONFIG } from "../config.js";
|
|
27
28
|
import { renderDashboardHTML } from "./ui.js";
|
|
28
29
|
import { computeIntentHealth } from "./intentHealth.js";
|
|
@@ -34,7 +35,7 @@ import { redactSettings } from "../tools/commonHelpers.js";
|
|
|
34
35
|
import { handleGraphRoutes } from "./graphRouter.js";
|
|
35
36
|
import { isDashboardSettingKeyAllowed, isDashboardSettingValueAllowed } from "./settingsPolicy.js";
|
|
36
37
|
import { isTrustedRequest, isRebindGuardedPath } from "./hostGuard.js";
|
|
37
|
-
import { resolveDashboardToken, requestHasToken, buildTokenCookie, } from "./dashboardToken.js";
|
|
38
|
+
import { resolveDashboardToken, requestHasToken, buildTokenCookie, dashboardTokenCookieName, } from "./dashboardToken.js";
|
|
38
39
|
import { safeCompare, generateToken, isAuthenticated, createRateLimiter, initJWKS, } from "./authUtils.js";
|
|
39
40
|
const PORT = parseInt(process.env.PRISM_DASHBOARD_PORT || "3000", 10);
|
|
40
41
|
/** Read HTTP request body as string (Buffer-based to avoid GC thrash on large imports) */
|
|
@@ -238,7 +239,7 @@ return false;}
|
|
|
238
239
|
reqUrl.searchParams.delete("token");
|
|
239
240
|
const cleanTarget = reqUrl.pathname + (reqUrl.search ? reqUrl.search : "");
|
|
240
241
|
res.writeHead(302, {
|
|
241
|
-
"Set-Cookie": buildTokenCookie(DASHBOARD_TOKEN, SESSION_TTL_MS, COOKIE_SECURE),
|
|
242
|
+
"Set-Cookie": buildTokenCookie(DASHBOARD_TOKEN, SESSION_TTL_MS, COOKIE_SECURE, dashboardTokenCookieName(req.socket.localPort)),
|
|
242
243
|
Location: cleanTarget,
|
|
243
244
|
});
|
|
244
245
|
return res.end();
|
|
@@ -247,7 +248,7 @@ return false;}
|
|
|
247
248
|
!requestHasToken({
|
|
248
249
|
cookie: req.headers.cookie,
|
|
249
250
|
headerToken: req.headers["x-prism-dashboard-token"] || null,
|
|
250
|
-
}, qToken, DASHBOARD_TOKEN)) {
|
|
251
|
+
}, qToken, DASHBOARD_TOKEN, dashboardTokenCookieName(req.socket.localPort))) {
|
|
251
252
|
res.writeHead(401, { "Content-Type": "application/json" });
|
|
252
253
|
return res.end(JSON.stringify({
|
|
253
254
|
error: "Dashboard token required — open the tokenized URL printed in the Prism startup log.",
|
|
@@ -482,11 +483,7 @@ return false;}
|
|
|
482
483
|
return res.end(JSON.stringify({ error: "Storage initializing..." }));
|
|
483
484
|
}
|
|
484
485
|
const context = await s.loadContext(projectName, "deep", PRISM_USER_ID);
|
|
485
|
-
const ledger = await s.
|
|
486
|
-
project: `eq.${projectName}`,
|
|
487
|
-
order: "created_at.desc",
|
|
488
|
-
limit: "20",
|
|
489
|
-
});
|
|
486
|
+
const ledger = await readDashboardLedger(s, activeStorageBackend, projectName, "created_at.desc", 20);
|
|
490
487
|
let history = [];
|
|
491
488
|
try {
|
|
492
489
|
history = await s.getHistory(projectName, PRISM_USER_ID, 10);
|
|
@@ -824,11 +821,7 @@ return false;}
|
|
|
824
821
|
}
|
|
825
822
|
// Gather data (mirrors sessionExportMemoryHandler)
|
|
826
823
|
const ctx = await s.loadContext(projectName, "deep", PRISM_USER_ID);
|
|
827
|
-
const rawLedger = await s.
|
|
828
|
-
project: `eq.${projectName}`,
|
|
829
|
-
order: "created_at.asc",
|
|
830
|
-
limit: "1000",
|
|
831
|
-
});
|
|
824
|
+
const rawLedger = await readDashboardLedger(s, activeStorageBackend, projectName, "created_at.asc", 1000);
|
|
832
825
|
// Strip binary embedding fields
|
|
833
826
|
const cleanLedger = rawLedger.map(({ embedding: _e, embedding_compressed: _ec, ...rest }) => rest);
|
|
834
827
|
const rawSettings = await getAllSettings();
|
package/dist/dashboard/ui.js
CHANGED
|
@@ -712,6 +712,7 @@ export function renderDashboardHTML(version) {
|
|
|
712
712
|
<button class="settings-btn" onclick="openSettings()" title="Settings">⚙️</button>
|
|
713
713
|
</div>
|
|
714
714
|
</header>
|
|
715
|
+
<p id="projectLoadError" role="alert" style="display:none; color:var(--accent-rose);"></p>
|
|
715
716
|
|
|
716
717
|
<div class="main-tabs" style="display:flex; gap: 1rem; border-bottom: 1px solid var(--border-glass); margin-bottom: 1.5rem; padding-bottom: 0;">
|
|
717
718
|
<button class="s-tab active" id="mtab-project" onclick="switchMainTab('project')" style="font-size: 1rem;">📁 Project View</button>
|
|
@@ -2049,6 +2050,7 @@ function loadIdentityChip() {
|
|
|
2049
2050
|
return [4 /*yield*/, res.json()];
|
|
2050
2051
|
case 2:
|
|
2051
2052
|
data = _a.sent();
|
|
2053
|
+
if (!res.ok) throw new Error(data.error || ('Request failed (' + res.status + ')'));
|
|
2052
2054
|
select = document.getElementById('projectSelect');
|
|
2053
2055
|
if (data.projects && data.projects.length > 0) {
|
|
2054
2056
|
select.innerHTML = '<option value="">— Select a project —</option>' +
|
|
@@ -2097,6 +2099,8 @@ function loadIdentityChip() {
|
|
|
2097
2099
|
case 3:
|
|
2098
2100
|
e_2 = _a.sent();
|
|
2099
2101
|
document.getElementById('projectSelect').innerHTML = '<option value="">Error loading projects</option>';
|
|
2102
|
+
document.getElementById('projectLoadError').textContent = e_2.message || 'Error loading projects';
|
|
2103
|
+
document.getElementById('projectLoadError').style.display = 'block';
|
|
2100
2104
|
return [3 /*break*/, 4];
|
|
2101
2105
|
case 4:
|
|
2102
2106
|
// Load identity chip once settings are available
|
|
@@ -2918,7 +2922,9 @@ function loadGraph() {
|
|
|
2918
2922
|
}
|
|
2919
2923
|
MAX_NODES = 200;
|
|
2920
2924
|
if (data.nodes.length > MAX_NODES) {
|
|
2921
|
-
|
|
2925
|
+
// Keep the project/category/keyword ordering truthy because the
|
|
2926
|
+
// comparator falls back with a high priority for unknown groups.
|
|
2927
|
+
priority = { project: 1, category: 2, keyword: 3 };
|
|
2922
2928
|
data.nodes.sort(function (a, b) { return (priority[a.group] || 9) - (priority[b.group] || 9); });
|
|
2923
2929
|
kept = new Set(data.nodes.slice(0, MAX_NODES).map(function (n) { return n.id; }));
|
|
2924
2930
|
data.nodes = data.nodes.slice(0, MAX_NODES);
|
package/dist/server.js
CHANGED
|
@@ -1307,6 +1307,66 @@ export async function startServer() {
|
|
|
1307
1307
|
const transport = new StdioServerTransport();
|
|
1308
1308
|
await server.connect(transport);
|
|
1309
1309
|
console.error(`[Prism] MCP Server successfully started and listening on stdio...`);
|
|
1310
|
+
// Heal a startup block whose content differs from what this binary writes.
|
|
1311
|
+
// (Not "older": nothing orders versions, so a pinned older install can
|
|
1312
|
+
// rewrite what a newer one wrote.) The instruction files are the
|
|
1313
|
+
// one channel that does not travel with the package — `initialize`
|
|
1314
|
+
// instructions and every tool description update with the binary, a native
|
|
1315
|
+
// instruction file keeps whatever connect last wrote — and nothing on an
|
|
1316
|
+
// ordinary machine re-runs connect: `prism update` never touches host
|
|
1317
|
+
// configuration by design, autoupdate runs `update`, and the postinstall
|
|
1318
|
+
// path covers only the prompt-routing hook and is routinely disabled by
|
|
1319
|
+
// npm's ignore-scripts. 20.21.0 is the release that
|
|
1320
|
+
// proved the cost: the old text told hosts to pass `cloud_fallback: false`,
|
|
1321
|
+
// which made a paid plan's escalation unreachable.
|
|
1322
|
+
//
|
|
1323
|
+
// Refresh-only, so this can only ever rewrite a block the operator already
|
|
1324
|
+
// consented to by running connect once: the install branch is unreachable
|
|
1325
|
+
// from here, and a file without exactly one ordered marker pair is left
|
|
1326
|
+
// byte-for-byte alone. Startup blocks only — never MCP registration, which
|
|
1327
|
+
// is what connect's "close your hosts first" warning is about. These files
|
|
1328
|
+
// do have another writer (Gemini CLI writes GEMINI.md on a remember
|
|
1329
|
+
// request, Claude Code writes CLAUDE.md on /init), so the refresh replaces
|
|
1330
|
+
// only its own marker-delimited block, marker lines included, and re-checks
|
|
1331
|
+
// immediately before committing. After the transport is connected, so the handshake is never
|
|
1332
|
+
// held behind disk I/O. PRISM_NO_STARTUP_REFRESH=1 opts out.
|
|
1333
|
+
//
|
|
1334
|
+
// DEFERRED, and unref'd, for two reasons. `connect.js` is a large module
|
|
1335
|
+
// (the whole CLI surface, TOML parser included) and importing it is
|
|
1336
|
+
// synchronous CPU work: awaited here it competes with the FIRST tool call,
|
|
1337
|
+
// which on a cold host is the one that has to do a storage round trip. And
|
|
1338
|
+
// an unref'd timer can never hold the process open. The block is read by the
|
|
1339
|
+
// host at session start, so it was always landing on the next session — a
|
|
1340
|
+
// couple of seconds changes nothing about when the fix arrives.
|
|
1341
|
+
if (process.env.PRISM_NO_STARTUP_REFRESH !== "1") {
|
|
1342
|
+
setTimeout(() => { void refreshStartupBlocksInBackground(); }, 2_000).unref();
|
|
1343
|
+
}
|
|
1344
|
+
await resumeServerStartup(server);
|
|
1345
|
+
}
|
|
1346
|
+
async function refreshStartupBlocksInBackground() {
|
|
1347
|
+
try {
|
|
1348
|
+
const { refreshManagedStartupBlocks } = await import("./connect.js");
|
|
1349
|
+
for (const result of refreshManagedStartupBlocks()) {
|
|
1350
|
+
if (result.status === "refreshed") {
|
|
1351
|
+
// The host read this file when the session began, so the corrected
|
|
1352
|
+
// text applies from the next one.
|
|
1353
|
+
console.error(`[Prism] refreshed the ${result.host} startup block to match this version: ${result.path} (applies from your next session)`);
|
|
1354
|
+
}
|
|
1355
|
+
else if (result.status === "failed") {
|
|
1356
|
+
console.error(`[Prism] could not refresh the ${result.host} startup block (${result.detail}); run: prism connect`);
|
|
1357
|
+
}
|
|
1358
|
+
else if (process.env.PRISM_DEBUG) {
|
|
1359
|
+
console.error(`[Prism] startup block ${result.host}: ${result.status}${result.detail ? ` (${result.detail})` : ""}`);
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
catch (error) {
|
|
1364
|
+
// Never the reason a server fails to start.
|
|
1365
|
+
if (process.env.PRISM_DEBUG)
|
|
1366
|
+
console.error(`[Prism] startup refresh skipped: ${error instanceof Error ? error.message : error}`);
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
async function resumeServerStartup(server) {
|
|
1310
1370
|
// Start the authoritative tier-skill refresh only after the MCP transport is
|
|
1311
1371
|
// connected. session_load_context awaits this same single-flight promise,
|
|
1312
1372
|
// while the initialize handshake is never held behind portal I/O. The
|
package/dist/storage/synalux.js
CHANGED
|
@@ -451,6 +451,46 @@ export class SynaluxStorage extends SupabaseStorage {
|
|
|
451
451
|
const entries = Array.isArray(result.entries) ? result.entries : [];
|
|
452
452
|
return entries;
|
|
453
453
|
}
|
|
454
|
+
/**
|
|
455
|
+
* Read the bounded, authenticated ledger projection used by the dashboard
|
|
456
|
+
* graph. This is deliberately separate from getLedgerEntries: the latter
|
|
457
|
+
* has many PostgREST callers that are not representable by the portal API.
|
|
458
|
+
*/
|
|
459
|
+
async getDashboardGraphEntries(params) {
|
|
460
|
+
if (!Number.isInteger(params.limit) || params.limit < 1 || params.limit > 200) {
|
|
461
|
+
throw new Error("Invalid dashboard graph limit");
|
|
462
|
+
}
|
|
463
|
+
if (params.project !== undefined && (!params.project.trim() || params.project.length > 100)) {
|
|
464
|
+
throw new Error("Invalid dashboard graph project");
|
|
465
|
+
}
|
|
466
|
+
if (params.createdAfter !== undefined
|
|
467
|
+
&& (Number.isNaN(new Date(params.createdAfter).getTime())
|
|
468
|
+
|| new Date(params.createdAfter).toISOString() !== params.createdAfter)) {
|
|
469
|
+
throw new Error("Invalid dashboard graph timestamp");
|
|
470
|
+
}
|
|
471
|
+
if (params.minImportance !== undefined
|
|
472
|
+
&& (!Number.isInteger(params.minImportance)
|
|
473
|
+
|| params.minImportance < -2147483648 || params.minImportance > 2147483647)) {
|
|
474
|
+
throw new Error("Invalid dashboard graph importance");
|
|
475
|
+
}
|
|
476
|
+
if (params.keywords !== undefined
|
|
477
|
+
&& (params.keywords.length === 0 || params.keywords.length > 50
|
|
478
|
+
|| params.keywords.some((keyword) => !keyword || keyword.length > 200))) {
|
|
479
|
+
throw new Error("Invalid dashboard graph keywords");
|
|
480
|
+
}
|
|
481
|
+
const result = await this.portalPost("/api/v1/prism/memory", {
|
|
482
|
+
action: "dashboard_ledger",
|
|
483
|
+
...(params.project !== undefined ? { project: params.project.trim() } : {}),
|
|
484
|
+
...(params.createdAfter !== undefined ? { created_after: params.createdAfter } : {}),
|
|
485
|
+
...(params.minImportance !== undefined ? { min_importance: params.minImportance } : {}),
|
|
486
|
+
...(params.keywords !== undefined ? { keywords: params.keywords } : {}),
|
|
487
|
+
limit: params.limit,
|
|
488
|
+
});
|
|
489
|
+
if (!Array.isArray(result.ledger)) {
|
|
490
|
+
throw new Error("Dashboard graph contract drift: ledger[] is required");
|
|
491
|
+
}
|
|
492
|
+
return result.ledger;
|
|
493
|
+
}
|
|
454
494
|
// ─── Project inventory + export ──────────────────────────────
|
|
455
495
|
// Both portal actions shipped in Phase 3 but the client was never
|
|
456
496
|
// wired: listProjects and the export path fell through to
|
|
@@ -503,6 +543,32 @@ export class SynaluxStorage extends SupabaseStorage {
|
|
|
503
543
|
}
|
|
504
544
|
return rows;
|
|
505
545
|
}
|
|
546
|
+
/** Read the requested dashboard window without the backup's 10k-row cap. */
|
|
547
|
+
async getDashboardLedger(project, order, limit) {
|
|
548
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 1000)
|
|
549
|
+
throw new Error("Invalid dashboard ledger limit");
|
|
550
|
+
let offset = 0;
|
|
551
|
+
for (let attempt = 0; attempt < 4; attempt++) {
|
|
552
|
+
const result = await this.portalPost("/api/v1/prism/memory", {
|
|
553
|
+
action: "export_memory", project, offset, limit,
|
|
554
|
+
});
|
|
555
|
+
const page = result.page;
|
|
556
|
+
const total = page?.total;
|
|
557
|
+
if (!Array.isArray(result.ledger) || typeof total !== "number" || !Number.isSafeInteger(total) || total < 0) {
|
|
558
|
+
throw new Error("Dashboard export contract drift: ledger and total are required");
|
|
559
|
+
}
|
|
560
|
+
const target = order === "created_at.desc" ? Math.max(0, total - limit) : 0;
|
|
561
|
+
if (offset !== target) {
|
|
562
|
+
offset = target;
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
const rows = result.ledger;
|
|
566
|
+
const direction = order === "created_at.asc" ? 1 : -1;
|
|
567
|
+
return [...rows].sort((a, b) => direction * (String(a.created_at).localeCompare(String(b.created_at))
|
|
568
|
+
|| String(a.id).localeCompare(String(b.id))));
|
|
569
|
+
}
|
|
570
|
+
throw new Error("Dashboard ledger changed during read; retry");
|
|
571
|
+
}
|
|
506
572
|
// ─── Time Travel ─────────────────────────────────────────────
|
|
507
573
|
// Phase 3 Tier B: route memory_history through portal instead of
|
|
508
574
|
// falling through to SupabaseStorage (which requires a direct
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "prism-mcp-server",
|
|
3
|
-
"version": "20.21.
|
|
3
|
+
"version": "20.21.7",
|
|
4
4
|
"mcpName": "io.github.dcostenco/prism-coder",
|
|
5
5
|
"description": "Persistent session memory for AI coding agents that never leaves your machine \u2014 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",
|