prism-mcp-server 20.21.11 → 20.21.13
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 +33 -1
- package/dist/cli.js +3 -6
- package/dist/dashboard/accountRouter.js +25 -2
- package/dist/dashboard/dashboardAccess.js +222 -41
- package/dist/dashboard/server.js +8 -2
- package/dist/dashboard/ui.js +70 -5
- package/dist/tools/ledgerHandlers.js +20 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -70,6 +70,13 @@ Prism works locally without an account, API key, or cloud subscription. Add a
|
|
|
70
70
|
Synalux subscription when you want cloud memory, paid-tier skills, or team
|
|
71
71
|
features.
|
|
72
72
|
|
|
73
|
+
To check your plan, open **⚙ Account & Settings → Account** in the dashboard.
|
|
74
|
+
Free users see **Start 14-day trial**; paid users see **Manage subscription**.
|
|
75
|
+
During a trial, the same panel shows the paid tier and exact end date. No card
|
|
76
|
+
is required to start. Add payment details before the end date to continue; if
|
|
77
|
+
you do not, the subscription cancels and local Prism Free remains available.
|
|
78
|
+
You can also compare plans directly at [synalux.ai/pricing#prism-plans](https://synalux.ai/pricing#prism-plans).
|
|
79
|
+
|
|
73
80
|
After a few sessions, ask what it's been worth:
|
|
74
81
|
|
|
75
82
|
```bash
|
|
@@ -151,6 +158,27 @@ or by re-enabling after each run.
|
|
|
151
158
|
<details>
|
|
152
159
|
<summary>Release history (optional)</summary>
|
|
153
160
|
|
|
161
|
+
## What's New in v20.21.13
|
|
162
|
+
|
|
163
|
+
### Paid plans and trials are clear in Account & Settings
|
|
164
|
+
|
|
165
|
+
- Free users can open the Prism plan comparison and start a 14-day trial.
|
|
166
|
+
- Trial users see their paid tier, exact end date, and the action to add payment
|
|
167
|
+
details before automatic cancellation.
|
|
168
|
+
- Active and payment-recovery states lead to the correct hosted billing action;
|
|
169
|
+
an unavailable verification is labeled instead of being presented as paid.
|
|
170
|
+
- Local Prism Free remains available without an account or subscription.
|
|
171
|
+
|
|
172
|
+
## What's New in v20.21.12
|
|
173
|
+
|
|
174
|
+
### Multiple MCP hosts keep a working dashboard link
|
|
175
|
+
|
|
176
|
+
- Each running Prism host now registers its local dashboard independently.
|
|
177
|
+
- `prism dashboard` and the startup message verify the newest candidates and
|
|
178
|
+
fall back to an older live dashboard when a newer host stops.
|
|
179
|
+
- Instance cleanup cannot remove another host's registration, and the open
|
|
180
|
+
message preserves the linked account and plan description.
|
|
181
|
+
|
|
154
182
|
## What's New in v20.21.11
|
|
155
183
|
|
|
156
184
|
### Linked accounts persist across dashboard refreshes
|
|
@@ -1301,7 +1329,11 @@ All on-device models are free to run locally via Ollama on every tier. A subscri
|
|
|
1301
1329
|
| Analytics dashboard | -- | ✅ | ✅ | ✅ |
|
|
1302
1330
|
| HIPAA BAA | -- | -- | -- | ✅ |
|
|
1303
1331
|
|
|
1304
|
-
14-day
|
|
1332
|
+
Paid plans start with a 14-day trial. No card is required to start; add payment
|
|
1333
|
+
details before the trial ends to continue, or the subscription cancels
|
|
1334
|
+
automatically and local Prism Free remains available. View the exact deadline
|
|
1335
|
+
under **⚙ Account & Settings → Account** in `prism dashboard`. 25+ seats:
|
|
1336
|
+
[contact sales](https://synalux.ai/support)
|
|
1305
1337
|
|
|
1306
1338
|
---
|
|
1307
1339
|
|
package/dist/cli.js
CHANGED
|
@@ -15,7 +15,7 @@ import { runBrowserCli } from './browserCli.js';
|
|
|
15
15
|
import { filterPrismMemoryContext } from './utils/memoryQuality.js';
|
|
16
16
|
import { isRecoverableStartupStorageError } from './utils/startupRecovery.js';
|
|
17
17
|
import { verifyBehaviorHandler } from './tools/behavioralVerifierHandler.js';
|
|
18
|
-
import {
|
|
18
|
+
import { dashboardOpenSuccessMessage, findRunningDashboardAccessState, openDashboardUrl, } from './dashboard/dashboardAccess.js';
|
|
19
19
|
const program = new Command();
|
|
20
20
|
/** Build the stable `prism load --json` envelope from depth-specific context. */
|
|
21
21
|
export function buildLoadJsonOutput(project, data, level, metadata) {
|
|
@@ -121,16 +121,13 @@ program
|
|
|
121
121
|
.option('--print', 'Print the current local dashboard link instead of opening a browser')
|
|
122
122
|
.action(async (options) => {
|
|
123
123
|
try {
|
|
124
|
-
const { url
|
|
125
|
-
if (!(await isLocalDashboardRunning(url, probeKey))) {
|
|
126
|
-
throw new Error('The recorded Prism dashboard is not running. Restart your connected MCP host first.');
|
|
127
|
-
}
|
|
124
|
+
const { url } = await findRunningDashboardAccessState();
|
|
128
125
|
if (options.print) {
|
|
129
126
|
console.log(url);
|
|
130
127
|
return;
|
|
131
128
|
}
|
|
132
129
|
openDashboardUrl(url);
|
|
133
|
-
console.log(
|
|
130
|
+
console.log(dashboardOpenSuccessMessage());
|
|
134
131
|
}
|
|
135
132
|
catch (error) {
|
|
136
133
|
console.error(`Dashboard unavailable: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -6,6 +6,11 @@ import { upgradeInsecureCloudUrl } from "../utils/secureUrl.js";
|
|
|
6
6
|
const DEFAULT_PORTAL_URL = "https://synalux.ai";
|
|
7
7
|
const MAX_BODY_BYTES = 16 * 1024;
|
|
8
8
|
const VALID_PLANS = new Set(["free", "standard", "advanced", "enterprise"]);
|
|
9
|
+
const VALID_BILLING_STATUSES = new Set([
|
|
10
|
+
"free", "trialing", "active", "past_due", "unpaid", "canceled",
|
|
11
|
+
"incomplete", "incomplete_expired", "paused", "managed", "unknown", "sync_pending",
|
|
12
|
+
]);
|
|
13
|
+
const VALID_PAID_PLANS = new Set(["standard", "advanced", "enterprise"]);
|
|
9
14
|
let accountMutationQueue = Promise.resolve();
|
|
10
15
|
async function serializeAccountMutation(operation) {
|
|
11
16
|
const previous = accountMutationQueue;
|
|
@@ -94,7 +99,10 @@ function signedOutAccount(origin) {
|
|
|
94
99
|
name: null,
|
|
95
100
|
role_key: null,
|
|
96
101
|
plan: "free",
|
|
97
|
-
|
|
102
|
+
subscription_plan: null,
|
|
103
|
+
billing_status: "free",
|
|
104
|
+
trial_ends_at: null,
|
|
105
|
+
billing: { action: "upgrade", url: `${origin}/pricing#prism-plans` },
|
|
98
106
|
auth_url: authUrl(origin),
|
|
99
107
|
};
|
|
100
108
|
}
|
|
@@ -112,13 +120,28 @@ function normalizeAccount(value, origin) {
|
|
|
112
120
|
const url = typeof billingRaw.url === "string" && isAllowedBillingUrl(billingRaw.url, origin)
|
|
113
121
|
? billingRaw.url
|
|
114
122
|
: null;
|
|
123
|
+
const rawBillingStatus = typeof raw.billing_status === "string" && VALID_BILLING_STATUSES.has(raw.billing_status)
|
|
124
|
+
? raw.billing_status
|
|
125
|
+
: plan === "free" ? "free" : "unknown";
|
|
126
|
+
const subscriptionPlan = typeof raw.subscription_plan === "string" && VALID_PAID_PLANS.has(raw.subscription_plan)
|
|
127
|
+
? raw.subscription_plan
|
|
128
|
+
: null;
|
|
129
|
+
const billingStatus = rawBillingStatus === "sync_pending" && subscriptionPlan === null
|
|
130
|
+
? "unknown"
|
|
131
|
+
: rawBillingStatus;
|
|
132
|
+
const trialEndsAt = typeof raw.trial_ends_at === "string" && Number.isFinite(Date.parse(raw.trial_ends_at))
|
|
133
|
+
? new Date(raw.trial_ends_at).toISOString()
|
|
134
|
+
: null;
|
|
115
135
|
return {
|
|
116
136
|
signed_in: raw.signed_in === true,
|
|
117
137
|
configured: true,
|
|
118
138
|
name: typeof raw.name === "string" ? raw.name.slice(0, 160) : null,
|
|
119
139
|
role_key: typeof raw.role_key === "string" ? raw.role_key.slice(0, 80) : null,
|
|
120
140
|
plan: plan,
|
|
141
|
+
subscription_plan: subscriptionPlan,
|
|
121
142
|
plan_source: raw.plan_source === "managed" ? "managed" : "stripe",
|
|
143
|
+
billing_status: billingStatus,
|
|
144
|
+
trial_ends_at: billingStatus === "trialing" || billingStatus === "sync_pending" ? trialEndsAt : null,
|
|
122
145
|
billing: { action, url },
|
|
123
146
|
auth_url: authUrl(origin),
|
|
124
147
|
};
|
|
@@ -230,7 +253,7 @@ export async function openDashboardBilling(overrides = {}) {
|
|
|
230
253
|
const deps = { ...defaultDeps, ...overrides };
|
|
231
254
|
const origin = await portalOrigin(deps);
|
|
232
255
|
if (await applyStoredSignOut(deps) || !deps.usablePortalKey()) {
|
|
233
|
-
return { url: `${origin}/pricing`, action: "upgrade" };
|
|
256
|
+
return { url: `${origin}/pricing#prism-plans`, action: "upgrade" };
|
|
234
257
|
}
|
|
235
258
|
let jwt = await deps.getJwt();
|
|
236
259
|
if (!jwt || isSynaluxSignedOut())
|
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
|
-
import { chmodSync, closeSync, constants as fsConstants, fchmodSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
2
|
+
import { chmodSync, closeSync, constants as fsConstants, fchmodSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
3
3
|
import { randomBytes } from "node:crypto";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
|
-
import { join } from "node:path";
|
|
5
|
+
import { basename, join } from "node:path";
|
|
6
6
|
import { createDashboardProbeRequest, DASHBOARD_PROBE_PATH, generateDashboardProbeKey, validateDashboardProbeKey, verifyDashboardProbeResponse, } from "./dashboardProbe.js";
|
|
7
7
|
const DASHBOARD_URL_FILE = "dashboard.url";
|
|
8
|
+
const DASHBOARD_REGISTRY_DIRECTORY = "dashboard-instances";
|
|
8
9
|
const MAX_DASHBOARD_URL_BYTES = 4096;
|
|
10
|
+
const DASHBOARD_INSTANCE_ID = /^[a-f0-9]{32}$/;
|
|
9
11
|
export function dashboardAccessUrlPath(home = homedir()) {
|
|
10
12
|
return join(home, ".prism-mcp", DASHBOARD_URL_FILE);
|
|
11
13
|
}
|
|
14
|
+
export function dashboardAccessRegistryPath(home = homedir()) {
|
|
15
|
+
return join(home, ".prism-mcp", DASHBOARD_REGISTRY_DIRECTORY);
|
|
16
|
+
}
|
|
12
17
|
function dashboardAccessDirectory(home) {
|
|
13
18
|
const directory = join(home, ".prism-mcp");
|
|
14
19
|
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
@@ -20,6 +25,18 @@ function dashboardAccessDirectory(home) {
|
|
|
20
25
|
chmodSync(directory, 0o700);
|
|
21
26
|
return directory;
|
|
22
27
|
}
|
|
28
|
+
function dashboardRegistryDirectory(home) {
|
|
29
|
+
dashboardAccessDirectory(home);
|
|
30
|
+
const directory = dashboardAccessRegistryPath(home);
|
|
31
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
32
|
+
const stat = lstatSync(directory);
|
|
33
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
34
|
+
throw new Error("Local dashboard registry is not a regular directory");
|
|
35
|
+
}
|
|
36
|
+
if (process.platform !== "win32")
|
|
37
|
+
chmodSync(directory, 0o700);
|
|
38
|
+
return directory;
|
|
39
|
+
}
|
|
23
40
|
function validateDashboardUrl(raw) {
|
|
24
41
|
const value = raw.trim();
|
|
25
42
|
if (!value || Buffer.byteLength(value, "utf8") > MAX_DASHBOARD_URL_BYTES) {
|
|
@@ -49,22 +66,11 @@ function validateDashboardUrl(raw) {
|
|
|
49
66
|
}
|
|
50
67
|
return parsed.toString();
|
|
51
68
|
}
|
|
52
|
-
|
|
53
|
-
* Persist the current local dashboard link for the `prism dashboard` command.
|
|
54
|
-
* The token is a localhost capability, so the file is owner-only and symlinks
|
|
55
|
-
* present at validation time are rejected rather than followed. This is not a
|
|
56
|
-
* boundary against another process running as the same OS user: that process
|
|
57
|
-
* can already read or replace owner-only Prism state.
|
|
58
|
-
*/
|
|
59
|
-
export function writeDashboardAccessUrl(url, home = homedir(), probeKey = generateDashboardProbeKey()) {
|
|
60
|
-
const validated = validateDashboardUrl(url);
|
|
61
|
-
const validatedProbeKey = validateDashboardProbeKey(probeKey);
|
|
62
|
-
const directory = dashboardAccessDirectory(home);
|
|
63
|
-
const filePath = dashboardAccessUrlPath(home);
|
|
69
|
+
function writeOwnerOnlyJson(filePath, directory, value, label) {
|
|
64
70
|
try {
|
|
65
71
|
const existing = lstatSync(filePath);
|
|
66
72
|
if (!existing.isFile() || existing.isSymbolicLink()) {
|
|
67
|
-
throw new Error(
|
|
73
|
+
throw new Error(`${label} path is not a regular file`);
|
|
68
74
|
}
|
|
69
75
|
}
|
|
70
76
|
catch (error) {
|
|
@@ -77,14 +83,14 @@ export function writeDashboardAccessUrl(url, home = homedir(), probeKey = genera
|
|
|
77
83
|
// symlink cannot redirect the token write even on platforms without
|
|
78
84
|
// O_NOFOLLOW. The containing directory follows the same-OS-user trust model
|
|
79
85
|
// documented above.
|
|
80
|
-
const tempPath = join(directory, `.${
|
|
86
|
+
const tempPath = join(directory, `.${basename(filePath)}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`);
|
|
81
87
|
const noFollow = process.platform === "win32" ? 0 : fsConstants.O_NOFOLLOW;
|
|
82
88
|
const fd = openSync(tempPath, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | noFollow, 0o600);
|
|
83
89
|
let tempExists = true;
|
|
84
90
|
try {
|
|
85
91
|
if (process.platform !== "win32")
|
|
86
92
|
fchmodSync(fd, 0o600);
|
|
87
|
-
writeFileSync(fd, `${JSON.stringify(
|
|
93
|
+
writeFileSync(fd, `${JSON.stringify(value)}\n`, "utf8");
|
|
88
94
|
}
|
|
89
95
|
finally {
|
|
90
96
|
closeSync(fd);
|
|
@@ -105,9 +111,7 @@ export function writeDashboardAccessUrl(url, home = homedir(), probeKey = genera
|
|
|
105
111
|
}
|
|
106
112
|
return filePath;
|
|
107
113
|
}
|
|
108
|
-
|
|
109
|
-
dashboardAccessDirectory(home);
|
|
110
|
-
const filePath = dashboardAccessUrlPath(home);
|
|
114
|
+
function readOwnerOnlyJson(filePath, label, missingMessage) {
|
|
111
115
|
const noFollow = process.platform === "win32" ? 0 : fsConstants.O_NOFOLLOW;
|
|
112
116
|
let fd;
|
|
113
117
|
try {
|
|
@@ -115,11 +119,10 @@ export function readDashboardAccessState(home = homedir()) {
|
|
|
115
119
|
}
|
|
116
120
|
catch (error) {
|
|
117
121
|
const code = error.code;
|
|
118
|
-
if (code === "ENOENT")
|
|
119
|
-
throw new Error(
|
|
120
|
-
}
|
|
122
|
+
if (code === "ENOENT" && missingMessage)
|
|
123
|
+
throw new Error(missingMessage);
|
|
121
124
|
if (code === "ELOOP")
|
|
122
|
-
throw new Error(
|
|
125
|
+
throw new Error(`${label} path is not a regular file`);
|
|
123
126
|
throw error;
|
|
124
127
|
}
|
|
125
128
|
try {
|
|
@@ -129,40 +132,215 @@ export function readDashboardAccessState(home = homedir()) {
|
|
|
129
132
|
// race on POSIX without broadening the same-OS-user trust boundary.
|
|
130
133
|
const stat = fstatSync(fd);
|
|
131
134
|
if (!stat.isFile())
|
|
132
|
-
throw new Error(
|
|
135
|
+
throw new Error(`${label} path is not a regular file`);
|
|
133
136
|
if (process.platform !== "win32" && (stat.mode & 0o077) !== 0) {
|
|
134
|
-
throw new Error(
|
|
137
|
+
throw new Error(`${label} file permissions are unsafe`);
|
|
135
138
|
}
|
|
136
139
|
const raw = readFileSync(fd, "utf8");
|
|
137
140
|
if (!raw || Buffer.byteLength(raw, "utf8") > MAX_DASHBOARD_URL_BYTES) {
|
|
138
|
-
throw new Error(
|
|
141
|
+
throw new Error(`${label} is missing or invalid`);
|
|
139
142
|
}
|
|
140
|
-
let parsed;
|
|
141
143
|
try {
|
|
142
|
-
|
|
144
|
+
return JSON.parse(raw);
|
|
143
145
|
}
|
|
144
146
|
catch {
|
|
145
|
-
throw new Error(
|
|
146
|
-
}
|
|
147
|
-
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
148
|
-
throw new Error("Local dashboard link is invalid");
|
|
149
|
-
}
|
|
150
|
-
const state = parsed;
|
|
151
|
-
if (state.version !== 1 || typeof state.url !== "string" || typeof state.probe_key !== "string") {
|
|
152
|
-
throw new Error("Local dashboard link is invalid");
|
|
147
|
+
throw new Error(`${label} is invalid`);
|
|
153
148
|
}
|
|
154
|
-
return {
|
|
155
|
-
url: validateDashboardUrl(state.url),
|
|
156
|
-
probeKey: validateDashboardProbeKey(state.probe_key),
|
|
157
|
-
};
|
|
158
149
|
}
|
|
159
150
|
finally {
|
|
160
151
|
closeSync(fd);
|
|
161
152
|
}
|
|
162
153
|
}
|
|
154
|
+
function parseDashboardAccessState(parsed, label) {
|
|
155
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
156
|
+
throw new Error(`${label} is invalid`);
|
|
157
|
+
}
|
|
158
|
+
const state = parsed;
|
|
159
|
+
if (state.version !== 1 || typeof state.url !== "string" || typeof state.probe_key !== "string") {
|
|
160
|
+
throw new Error(`${label} is invalid`);
|
|
161
|
+
}
|
|
162
|
+
return {
|
|
163
|
+
url: validateDashboardUrl(state.url),
|
|
164
|
+
probeKey: validateDashboardProbeKey(state.probe_key),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Persist the latest local dashboard link for older Prism clients. Current
|
|
169
|
+
* clients discover live dashboards from the per-instance registry below.
|
|
170
|
+
* The token is a localhost capability, so the file is owner-only and symlinks
|
|
171
|
+
* present at validation time are rejected rather than followed. This is not a
|
|
172
|
+
* boundary against another process running as the same OS user: that process
|
|
173
|
+
* can already read or replace owner-only Prism state.
|
|
174
|
+
*/
|
|
175
|
+
export function writeDashboardAccessUrl(url, home = homedir(), probeKey = generateDashboardProbeKey()) {
|
|
176
|
+
const validated = validateDashboardUrl(url);
|
|
177
|
+
const validatedProbeKey = validateDashboardProbeKey(probeKey);
|
|
178
|
+
const directory = dashboardAccessDirectory(home);
|
|
179
|
+
return writeOwnerOnlyJson(dashboardAccessUrlPath(home), directory, { version: 1, url: validated, probe_key: validatedProbeKey }, "Local dashboard link");
|
|
180
|
+
}
|
|
181
|
+
export function readDashboardAccessState(home = homedir()) {
|
|
182
|
+
dashboardAccessDirectory(home);
|
|
183
|
+
return parseDashboardAccessState(readOwnerOnlyJson(dashboardAccessUrlPath(home), "Local dashboard link", "No current local dashboard link. Restart your connected MCP host first."), "Local dashboard link");
|
|
184
|
+
}
|
|
163
185
|
export function readDashboardAccessUrl(home = homedir()) {
|
|
164
186
|
return readDashboardAccessState(home).url;
|
|
165
187
|
}
|
|
188
|
+
function validateDashboardInstanceId(value) {
|
|
189
|
+
if (!DASHBOARD_INSTANCE_ID.test(value)) {
|
|
190
|
+
throw new Error("Local dashboard instance ID is invalid");
|
|
191
|
+
}
|
|
192
|
+
return value;
|
|
193
|
+
}
|
|
194
|
+
function validateRegistrationInteger(value, label, allowZero = false) {
|
|
195
|
+
if (!Number.isSafeInteger(value) || (allowZero ? value < 0 : value < 1)) {
|
|
196
|
+
throw new Error(`Local dashboard ${label} is invalid`);
|
|
197
|
+
}
|
|
198
|
+
return value;
|
|
199
|
+
}
|
|
200
|
+
function parseDashboardAccessRecord(parsed, recordPath, expectedInstanceId) {
|
|
201
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
202
|
+
throw new Error("Local dashboard registry record is invalid");
|
|
203
|
+
}
|
|
204
|
+
const record = parsed;
|
|
205
|
+
if (record.version !== 1 ||
|
|
206
|
+
typeof record.instance_id !== "string" ||
|
|
207
|
+
typeof record.pid !== "number" ||
|
|
208
|
+
typeof record.registered_at_ms !== "number" ||
|
|
209
|
+
typeof record.url !== "string" ||
|
|
210
|
+
typeof record.probe_key !== "string") {
|
|
211
|
+
throw new Error("Local dashboard registry record is invalid");
|
|
212
|
+
}
|
|
213
|
+
const instanceId = validateDashboardInstanceId(record.instance_id);
|
|
214
|
+
if (instanceId !== expectedInstanceId) {
|
|
215
|
+
throw new Error("Local dashboard registry record is invalid");
|
|
216
|
+
}
|
|
217
|
+
return {
|
|
218
|
+
instanceId,
|
|
219
|
+
pid: validateRegistrationInteger(record.pid, "process ID"),
|
|
220
|
+
registeredAtMs: validateRegistrationInteger(record.registered_at_ms, "registration time", true),
|
|
221
|
+
url: validateDashboardUrl(record.url),
|
|
222
|
+
probeKey: validateDashboardProbeKey(record.probe_key),
|
|
223
|
+
recordPath,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
function readDashboardAccessRecord(recordPath, instanceId) {
|
|
227
|
+
return parseDashboardAccessRecord(readOwnerOnlyJson(recordPath, "Local dashboard registry record"), recordPath, instanceId);
|
|
228
|
+
}
|
|
229
|
+
function unregisterDashboardAccessRecord(record) {
|
|
230
|
+
let current;
|
|
231
|
+
try {
|
|
232
|
+
current = readDashboardAccessRecord(record.recordPath, record.instanceId);
|
|
233
|
+
}
|
|
234
|
+
catch (error) {
|
|
235
|
+
if (error.code === "ENOENT")
|
|
236
|
+
return;
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
if (current.pid !== record.pid ||
|
|
240
|
+
current.registeredAtMs !== record.registeredAtMs ||
|
|
241
|
+
current.url !== record.url ||
|
|
242
|
+
current.probeKey !== record.probeKey) {
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
try {
|
|
246
|
+
unlinkSync(record.recordPath);
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
// Best effort: an unreadable registry must not block another live instance.
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
function isDashboardProcessAlive(pid) {
|
|
253
|
+
try {
|
|
254
|
+
process.kill(pid, 0);
|
|
255
|
+
return true;
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
// EPERM proves the process exists but is owned by a different principal.
|
|
259
|
+
return error.code === "EPERM";
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
/** Register one running dashboard without replacing records for other hosts. */
|
|
263
|
+
export function registerDashboardAccessUrl(url, home = homedir(), probeKey = generateDashboardProbeKey(), options = {}) {
|
|
264
|
+
const validatedUrl = validateDashboardUrl(url);
|
|
265
|
+
const validatedProbeKey = validateDashboardProbeKey(probeKey);
|
|
266
|
+
const instanceId = validateDashboardInstanceId(options.instanceId ?? randomBytes(16).toString("hex"));
|
|
267
|
+
const pid = validateRegistrationInteger(options.pid ?? process.pid, "process ID");
|
|
268
|
+
const registeredAtMs = validateRegistrationInteger(options.registeredAtMs ?? Date.now(), "registration time", true);
|
|
269
|
+
// Preserve the singleton file for clients released before the registry.
|
|
270
|
+
writeDashboardAccessUrl(validatedUrl, home, validatedProbeKey);
|
|
271
|
+
const directory = dashboardRegistryDirectory(home);
|
|
272
|
+
const recordPath = join(directory, `${instanceId}.json`);
|
|
273
|
+
const record = {
|
|
274
|
+
instanceId,
|
|
275
|
+
pid,
|
|
276
|
+
registeredAtMs,
|
|
277
|
+
url: validatedUrl,
|
|
278
|
+
probeKey: validatedProbeKey,
|
|
279
|
+
recordPath,
|
|
280
|
+
};
|
|
281
|
+
writeOwnerOnlyJson(recordPath, directory, {
|
|
282
|
+
version: 1,
|
|
283
|
+
instance_id: instanceId,
|
|
284
|
+
pid,
|
|
285
|
+
registered_at_ms: registeredAtMs,
|
|
286
|
+
url: validatedUrl,
|
|
287
|
+
probe_key: validatedProbeKey,
|
|
288
|
+
}, "Local dashboard registry record");
|
|
289
|
+
return {
|
|
290
|
+
recordPath,
|
|
291
|
+
state: { url: validatedUrl, probeKey: validatedProbeKey },
|
|
292
|
+
unregister: () => unregisterDashboardAccessRecord(record),
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
function readDashboardAccessRecords(home) {
|
|
296
|
+
const directory = dashboardRegistryDirectory(home);
|
|
297
|
+
const records = [];
|
|
298
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
299
|
+
const match = /^([a-f0-9]{32})\.json$/.exec(entry.name);
|
|
300
|
+
if (!match || !entry.isFile() || entry.isSymbolicLink())
|
|
301
|
+
continue;
|
|
302
|
+
try {
|
|
303
|
+
records.push(readDashboardAccessRecord(join(directory, entry.name), match[1]));
|
|
304
|
+
}
|
|
305
|
+
catch {
|
|
306
|
+
// A malformed or unsafe record can never become a dashboard capability.
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return records.sort((left, right) => right.registeredAtMs - left.registeredAtMs || right.instanceId.localeCompare(left.instanceId));
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Return the newest cryptographically verified dashboard that is still alive.
|
|
313
|
+
* Dead registry entries are removed one at a time; a surviving instance is
|
|
314
|
+
* never deleted just because a newer process stopped.
|
|
315
|
+
*/
|
|
316
|
+
export async function findRunningDashboardAccessState(home = homedir(), fetcher = fetch) {
|
|
317
|
+
const records = readDashboardAccessRecords(home);
|
|
318
|
+
const probed = new Set();
|
|
319
|
+
for (const record of records) {
|
|
320
|
+
const key = `${record.url}\n${record.probeKey}`;
|
|
321
|
+
probed.add(key);
|
|
322
|
+
if (await isLocalDashboardRunning(record.url, record.probeKey, fetcher)) {
|
|
323
|
+
return { url: record.url, probeKey: record.probeKey };
|
|
324
|
+
}
|
|
325
|
+
// One timeout is not proof that a live host is dead. Remove the record only
|
|
326
|
+
// when the owning PID is also gone; otherwise preserve it for a later retry.
|
|
327
|
+
if (!isDashboardProcessAlive(record.pid))
|
|
328
|
+
unregisterDashboardAccessRecord(record);
|
|
329
|
+
}
|
|
330
|
+
// A singleton-only record was written by Prism versions before this registry.
|
|
331
|
+
try {
|
|
332
|
+
const legacy = readDashboardAccessState(home);
|
|
333
|
+
const key = `${legacy.url}\n${legacy.probeKey}`;
|
|
334
|
+
if (!probed.has(key) && await isLocalDashboardRunning(legacy.url, legacy.probeKey, fetcher)) {
|
|
335
|
+
return legacy;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
catch (error) {
|
|
339
|
+
if (records.length === 0)
|
|
340
|
+
throw error;
|
|
341
|
+
}
|
|
342
|
+
throw new Error("No registered Prism dashboard is running. Restart your connected MCP host first.");
|
|
343
|
+
}
|
|
166
344
|
export async function isLocalDashboardRunning(dashboardUrl, probeKey, fetcher = fetch) {
|
|
167
345
|
const validated = validateDashboardUrl(dashboardUrl);
|
|
168
346
|
const challenge = createDashboardProbeRequest(probeKey);
|
|
@@ -190,6 +368,9 @@ export async function isLocalDashboardRunning(dashboardUrl, probeKey, fetcher =
|
|
|
190
368
|
return false;
|
|
191
369
|
}
|
|
192
370
|
}
|
|
371
|
+
export function dashboardOpenSuccessMessage() {
|
|
372
|
+
return "Opened the current local Prism dashboard in your default browser. Your Synalux account and plan are unchanged.";
|
|
373
|
+
}
|
|
193
374
|
export function dashboardOpenCommand(url, platform = process.platform) {
|
|
194
375
|
const validated = validateDashboardUrl(url);
|
|
195
376
|
if (platform === "darwin")
|
package/dist/dashboard/server.js
CHANGED
|
@@ -26,7 +26,7 @@ import { activeStorageBackend } from "../storage/index.js";
|
|
|
26
26
|
import { readDashboardLedger } from "./ledgerReader.js";
|
|
27
27
|
import { PRISM_USER_ID, SERVER_CONFIG } from "../config.js";
|
|
28
28
|
import { renderDashboardHTML, renderDashboardLocalOpenHTML } from "./ui.js";
|
|
29
|
-
import {
|
|
29
|
+
import { registerDashboardAccessUrl } from "./dashboardAccess.js";
|
|
30
30
|
import { createDashboardProbeResponse, DASHBOARD_PROBE_PATH, generateDashboardProbeKey, verifyDashboardProbeRequest, } from "./dashboardProbe.js";
|
|
31
31
|
import { computeIntentHealth } from "./intentHealth.js";
|
|
32
32
|
import { getAllSettings, setSetting, getSetting, getSettingSync } from "../storage/configStorage.js";
|
|
@@ -1488,7 +1488,13 @@ self.addEventListener('message', (e) => {
|
|
|
1488
1488
|
// Non-fatal — just means the user has to know the port
|
|
1489
1489
|
}
|
|
1490
1490
|
try {
|
|
1491
|
-
|
|
1491
|
+
const registration = registerDashboardAccessUrl(dashboardUrl, os.homedir(), DASHBOARD_PROBE_KEY);
|
|
1492
|
+
const unregister = registration.unregister;
|
|
1493
|
+
httpServer.once("close", () => {
|
|
1494
|
+
unregister();
|
|
1495
|
+
process.removeListener("exit", unregister);
|
|
1496
|
+
});
|
|
1497
|
+
process.once("exit", unregister);
|
|
1492
1498
|
}
|
|
1493
1499
|
catch (error) {
|
|
1494
1500
|
console.error(`[Dashboard] Could not save local opener link: ${error instanceof Error ? error.message : String(error)}`);
|
package/dist/dashboard/ui.js
CHANGED
|
@@ -449,6 +449,8 @@ export function renderDashboardHTML(version) {
|
|
|
449
449
|
.identity-chip .role-icon { font-size: 0.9rem; }
|
|
450
450
|
.identity-chip .identity-label { max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
451
451
|
.identity-chip .plan-mini { font-size: 0.58rem; letter-spacing: 0.06em; text-transform: uppercase; color: var(--accent-green); }
|
|
452
|
+
.identity-chip .plan-mini.trial { color: var(--accent-amber); }
|
|
453
|
+
.identity-chip .plan-mini.attention { color: #fb7185; }
|
|
452
454
|
.main-tabs { overflow-x: auto; scrollbar-width: thin; }
|
|
453
455
|
.main-tabs .s-tab { flex: 0 0 auto; }
|
|
454
456
|
/* Settings modal tab bar */
|
|
@@ -549,6 +551,8 @@ export function renderDashboardHTML(version) {
|
|
|
549
551
|
}
|
|
550
552
|
.account-badge.role { color: var(--accent-cyan); background: rgba(6,182,212,0.1); border-color: rgba(6,182,212,0.22); }
|
|
551
553
|
.account-badge.managed { color: var(--accent-amber); background: rgba(245,158,11,0.1); border-color: rgba(245,158,11,0.22); }
|
|
554
|
+
.account-badge.trial { color: var(--accent-green); background: rgba(34,197,94,0.1); border-color: rgba(34,197,94,0.3); }
|
|
555
|
+
.account-badge.attention { color: #fb7185; background: rgba(244,63,94,0.1); border-color: rgba(244,63,94,0.3); }
|
|
552
556
|
.account-name { font-size: 1.35rem; line-height: 1.2; font-weight: 700; color: var(--text-primary); }
|
|
553
557
|
.account-summary { margin-top: 0.45rem; color: var(--text-secondary); font-size: 0.82rem; line-height: 1.55; max-width: 540px; }
|
|
554
558
|
.account-actions { display: flex; flex-wrap: wrap; gap: 0.65rem; margin-top: 1rem; }
|
|
@@ -2118,6 +2122,49 @@ function accountPlanSummary(plan) {
|
|
|
2118
2122
|
};
|
|
2119
2123
|
return summaries[plan] || summaries.free;
|
|
2120
2124
|
}
|
|
2125
|
+
function accountTrialEndLabel(value) {
|
|
2126
|
+
if (!value) return '';
|
|
2127
|
+
var date = new Date(value);
|
|
2128
|
+
if (isNaN(date.getTime())) return '';
|
|
2129
|
+
return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
|
|
2130
|
+
}
|
|
2131
|
+
function accountBillingBadge(account) {
|
|
2132
|
+
if (account.billing_status === 'trialing') return '<span class="account-badge trial">Trial active</span>';
|
|
2133
|
+
if (account.billing_status === 'sync_pending') return '<span class="account-badge attention">Access update pending</span>';
|
|
2134
|
+
if (['past_due', 'unpaid', 'incomplete', 'paused'].indexOf(account.billing_status) >= 0) {
|
|
2135
|
+
return '<span class="account-badge attention">Payment needs attention</span>';
|
|
2136
|
+
}
|
|
2137
|
+
if (account.billing_status === 'unknown') return '<span class="account-badge attention">Billing status unavailable</span>';
|
|
2138
|
+
if (account.billing_status === 'active') return '<span class="account-badge trial">Paid</span>';
|
|
2139
|
+
return '';
|
|
2140
|
+
}
|
|
2141
|
+
function accountSubscriptionSummary(account) {
|
|
2142
|
+
if (account.billing_status === 'trialing') {
|
|
2143
|
+
var end = accountTrialEndLabel(account.trial_ends_at);
|
|
2144
|
+
return accountPlanLabel(account.plan) + ' trial is active' + (end ? ' through ' + end : '') +
|
|
2145
|
+
'. Add payment details before the trial ends to continue; otherwise it cancels automatically.';
|
|
2146
|
+
}
|
|
2147
|
+
if (['past_due', 'unpaid', 'incomplete', 'paused'].indexOf(account.billing_status) >= 0) {
|
|
2148
|
+
var billedPlan = account.subscription_plan
|
|
2149
|
+
? accountPlanLabel(account.subscription_plan) + ' plan'
|
|
2150
|
+
: account.plan === 'free' ? 'paid subscription' : accountPlanLabel(account.plan) + ' plan';
|
|
2151
|
+
return 'Your ' + billedPlan + ' needs billing attention. Update payment details to restore or keep cloud features active.';
|
|
2152
|
+
}
|
|
2153
|
+
if (account.billing_status === 'sync_pending') {
|
|
2154
|
+
var syncingPlan = accountPlanLabel(account.subscription_plan || account.plan);
|
|
2155
|
+
var syncTrialEnd = accountTrialEndLabel(account.trial_ends_at);
|
|
2156
|
+
return 'Stripe confirms your ' + syncingPlan + ' subscription' +
|
|
2157
|
+
(syncTrialEnd ? ' with a trial through ' + syncTrialEnd : '') +
|
|
2158
|
+
'. Synalux is updating access; current access remains ' + accountPlanLabel(account.plan) + '. Retry shortly or manage the subscription.';
|
|
2159
|
+
}
|
|
2160
|
+
if (account.billing_status === 'canceled' || account.billing_status === 'incomplete_expired') {
|
|
2161
|
+
return 'This paid subscription is no longer active. Local Prism Free remains available.';
|
|
2162
|
+
}
|
|
2163
|
+
if (account.billing_status === 'unknown') {
|
|
2164
|
+
return 'Prism could not verify the current trial or payment status. Open billing to review the subscription; local features remain available.';
|
|
2165
|
+
}
|
|
2166
|
+
return accountPlanSummary(account.plan);
|
|
2167
|
+
}
|
|
2121
2168
|
function renderPlanLadder(plan) {
|
|
2122
2169
|
var tiers = [
|
|
2123
2170
|
['free', 'Free', 'Local'],
|
|
@@ -2132,14 +2179,29 @@ function renderPlanLadder(plan) {
|
|
|
2132
2179
|
}
|
|
2133
2180
|
function renderAccountChip(account, error) {
|
|
2134
2181
|
var chip = document.getElementById('identityChip');
|
|
2182
|
+
var planStatus;
|
|
2183
|
+
var planStatusClass;
|
|
2135
2184
|
if (!chip)
|
|
2136
2185
|
return;
|
|
2137
2186
|
if (error) {
|
|
2138
2187
|
chip.innerHTML = '<span class="role-icon">⚠️</span><span class="identity-label">Account</span>';
|
|
2139
2188
|
}
|
|
2140
2189
|
else if (account && account.signed_in) {
|
|
2190
|
+
planStatus = account.billing_status === 'sync_pending'
|
|
2191
|
+
? accountPlanLabel(account.subscription_plan || account.plan) + ' syncing'
|
|
2192
|
+
: account.billing_status === 'trialing' ? ' trial' :
|
|
2193
|
+
['past_due', 'unpaid', 'incomplete', 'paused'].indexOf(account.billing_status) >= 0
|
|
2194
|
+
? (account.plan === 'free' ? 'Payment due' : ' payment due') :
|
|
2195
|
+
account.billing_status === 'unknown' ? ' status unavailable' : '';
|
|
2196
|
+
planStatusClass = account.billing_status === 'trialing' ? ' trial' :
|
|
2197
|
+
['past_due', 'unpaid', 'incomplete', 'paused', 'unknown', 'sync_pending'].indexOf(account.billing_status) >= 0 ? ' attention' : '';
|
|
2198
|
+
var chipPlanLabel = account.billing_status === 'sync_pending'
|
|
2199
|
+
? planStatus
|
|
2200
|
+
: account.plan === 'free' && planStatus === 'Payment due'
|
|
2201
|
+
? planStatus
|
|
2202
|
+
: accountPlanLabel(account.plan) + planStatus;
|
|
2141
2203
|
chip.innerHTML = '<span class="role-icon">🤖</span><span class="identity-label">' + escapeHtml(account.name || 'Synalux user') + '</span>' +
|
|
2142
|
-
'<span class="plan-mini">' + escapeHtml(
|
|
2204
|
+
'<span class="plan-mini' + planStatusClass + '">' + escapeHtml(chipPlanLabel) + '</span>';
|
|
2143
2205
|
}
|
|
2144
2206
|
else {
|
|
2145
2207
|
chip.innerHTML = '<span class="role-icon">🆓</span><span class="identity-label">Free</span>';
|
|
@@ -2180,14 +2242,17 @@ function renderAccountPanel(account, error) {
|
|
|
2180
2242
|
return;
|
|
2181
2243
|
}
|
|
2182
2244
|
role = account.role_key || 'user';
|
|
2183
|
-
billingLabel = account.
|
|
2184
|
-
account.
|
|
2245
|
+
billingLabel = account.billing_status === 'trialing' ? 'Add payment details' :
|
|
2246
|
+
account.billing_status === 'sync_pending' ? 'Manage subscription' :
|
|
2247
|
+
['past_due', 'unpaid', 'incomplete', 'paused'].indexOf(account.billing_status) >= 0 ? 'Update payment details' :
|
|
2248
|
+
account.billing && account.billing.action === 'manage' ? 'Manage subscription' :
|
|
2249
|
+
account.billing && account.billing.action === 'included' ? 'View plans' : 'Start 14-day trial';
|
|
2185
2250
|
managedBadge = account.plan_source === 'managed' ? '<span class="account-badge managed">Managed</span>' : '';
|
|
2186
2251
|
panel.innerHTML = '<div class="account-card"><div class="account-card-head"><div>' +
|
|
2187
2252
|
'<div class="account-badges"><span class="account-badge">' + escapeHtml(accountPlanLabel(account.plan)) + '</span>' +
|
|
2188
|
-
'<span class="account-badge role">' + escapeHtml(role) + '</span>' + managedBadge + '</div>' +
|
|
2253
|
+
'<span class="account-badge role">' + escapeHtml(role) + '</span>' + managedBadge + accountBillingBadge(account) + '</div>' +
|
|
2189
2254
|
'<div class="account-name">' + escapeHtml(account.name || 'Synalux user') + '</div>' +
|
|
2190
|
-
'<div class="account-summary">' +
|
|
2255
|
+
'<div class="account-summary">' + escapeHtml(accountSubscriptionSummary(account)) + '</div></div></div>' +
|
|
2191
2256
|
'<div class="account-actions"><button class="account-action" id="accountBillingButton" onclick="openAccountBilling()">' + billingLabel + '</button>' +
|
|
2192
2257
|
'<button class="account-action danger" id="accountSignOutButton" onclick="signOutAccount()">Sign out</button></div>' +
|
|
2193
2258
|
'<div class="account-status" id="accountStatus"></div></div>' + renderPlanLadder(account.plan);
|
|
@@ -36,6 +36,7 @@ import { renderFloorDigestBlock, skillDigestFromSource, splitSkillFrontmatter }
|
|
|
36
36
|
import { mergeHandoff, dbToHandoffSchema, sanitizeForMerge } from "../utils/crdtMerge.js";
|
|
37
37
|
import { resolveProject } from "../utils/projectResolver.js";
|
|
38
38
|
import { getUpdateNotice } from "../updateNotice.js";
|
|
39
|
+
import { findRunningDashboardAccessState } from "../dashboard/dashboardAccess.js";
|
|
39
40
|
// The running server's own version, for the update-available notice. A read
|
|
40
41
|
// failure must never affect startup: an empty string fails the notice's
|
|
41
42
|
// semver gate, so no notice renders rather than a bogus "running 0.0.0".
|
|
@@ -610,14 +611,29 @@ function freeTierUpgradeLine(tier) {
|
|
|
610
611
|
* a private localhost capability, so startup advertises the local CLI opener
|
|
611
612
|
* rather than copying that link into agent context.
|
|
612
613
|
*/
|
|
613
|
-
async function
|
|
614
|
+
export async function readDashboardUrlForStartup(home = os.homedir()) {
|
|
615
|
+
const explicitPort = (process.env.PRISM_DASHBOARD_PORT || "").trim();
|
|
616
|
+
// Current Prism versions keep one signed record per dashboard instance. The
|
|
617
|
+
// newest record may belong to a host that just stopped, so use the same
|
|
618
|
+
// verified fallback selection as `prism dashboard` before consulting legacy
|
|
619
|
+
// singleton state. An explicit port remains authoritative for this process.
|
|
620
|
+
if (!explicitPort) {
|
|
621
|
+
try {
|
|
622
|
+
const state = await findRunningDashboardAccessState(home);
|
|
623
|
+
return new URL(state.url).origin;
|
|
624
|
+
}
|
|
625
|
+
catch {
|
|
626
|
+
// Continue into the legacy port probe for older installations and the
|
|
627
|
+
// startup instant before this process has written its registry record.
|
|
628
|
+
}
|
|
629
|
+
}
|
|
614
630
|
// Precedence: explicit env override > recorded port file > default. The
|
|
615
631
|
// file is written by whatever dashboard ran last and persists across boots,
|
|
616
632
|
// so it must never outrank configuration the operator set for THIS process.
|
|
617
|
-
let port =
|
|
633
|
+
let port = explicitPort;
|
|
618
634
|
if (!port) {
|
|
619
635
|
try {
|
|
620
|
-
const recorded = fs.readFileSync(nodePath.join(
|
|
636
|
+
const recorded = fs.readFileSync(nodePath.join(home, ".prism-mcp", "dashboard.port"), "utf8").trim();
|
|
621
637
|
if (/^\d{2,5}$/.test(recorded))
|
|
622
638
|
port = recorded;
|
|
623
639
|
}
|
|
@@ -2338,7 +2354,7 @@ export async function sessionBootstrapHandler(args = {}, options = {}) {
|
|
|
2338
2354
|
? greeting
|
|
2339
2355
|
: `${greeting}\n\n${identityBlock}${updateNotice ? `\n${updateNotice}` : ""}`;
|
|
2340
2356
|
if (projects.length === 0) {
|
|
2341
|
-
const dashboardUrl = await
|
|
2357
|
+
const dashboardUrl = await readDashboardUrlForStartup();
|
|
2342
2358
|
const dashboardLine = dashboardUrl
|
|
2343
2359
|
? `- 🎛️ **Dashboard:** run \`prism dashboard\` — opens locally with no Synalux account required`
|
|
2344
2360
|
: `- 🎛️ **Dashboard:** not running — start Prism's dashboard to configure projects, identity, and context depth`;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "prism-mcp-server",
|
|
3
|
-
"version": "20.21.
|
|
3
|
+
"version": "20.21.13",
|
|
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",
|