Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .muse-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"schemaVersion": 1,
"name": "supermemory",
"displayName": "Supermemory",
"version": "0.1.1",
"version": "0.1.2",
"description": "Persistent memory across Muse Code sessions using Supermemory.",
"compat": {
"source": "native",
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,15 @@ Get a key at [app.supermemory.ai](https://app.supermemory.ai).
}
```

| Option | Description |
| --- | --- |
| `baseUrl` | Supermemory API URL for every project, e.g. a self-hosted server |
| `mcpUrl` | MCP endpoint to use when `baseUrl` is not the hosted API |

Muse clears the environment for hooks and the MCP proxy, so set a self-hosted URL here rather than in your shell. The order is `SUPERMEMORY_API_URL`, then the project's `baseUrl` (a committed project config overrides this file), then this one, then `https://api.supermemory.ai`.

With a self-hosted `baseUrl`, the MCP proxy stays off unless `mcpUrl` (or `SUPERMEMORY_MCP_URL`) names an MCP endpoint, and a missing key does not open the hosted browser login: write the server's key to `~/.supermemory-muse/credentials.json`. A key in `~/.supermemory-claude/credentials.json` is also used, so it must belong to the same server.

**Project** — `.muse/supermemory.json`

```json
Expand Down
6 changes: 3 additions & 3 deletions commands/status.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ description: Show Supermemory authentication and connection status
Report the user's Supermemory status for this Muse Code session.

1. Read `~/.supermemory-muse/credentials.json` (may not exist). Never print the full API key — show at most the first 6 and last 4 characters. Also check `~/.supermemory-claude/credentials.json` as a fallback. The key source is env `SUPERMEMORY_API_KEY` / `SUPERMEMORY_MUSE_API_KEY` when set, otherwise those files.
2. Probe real connectivity with the resolved key:
2. Resolve the base URL: `SUPERMEMORY_API_URL`, else `baseUrl` in the project config at the repo root (`.muse/supermemory.json`, or `.claude/.supermemory-claude/config.json` if that one is absent), else `baseUrl` in `~/.supermemory-muse/settings.json`, else `https://api.supermemory.ai`. Probe real connectivity with the resolved key against that URL (never send a self-hosted key to the hosted API):

```
curl -sS -o /dev/null -w '%{http_code}' -m 8 -X POST "${SUPERMEMORY_API_URL:-https://api.supermemory.ai}/v4/profile" \
curl -sS -o /dev/null -w '%{http_code}' -m 8 -X POST "$BASE_URL/v4/profile" \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" -H "x-sm-source: muse-code" \
-d '{"containerTag":"<this project's container tag>","q":"connectivity probe"}'
```
Expand All @@ -18,4 +18,4 @@ Interpret loudly: `200` → reachable and the key works; `401`/`403` → reachab
3. Call the `whoAmI` MCP tool if the supermemory MCP server is connected.
4. Report: authenticated or not, key source, the active project container tag, API reachability, and MCP reachability.

If not authenticated, tell the user a new session will open the browser login automatically, or they can write `{"apiKey":"sm_..."}` to `~/.supermemory-muse/credentials.json`.
If not authenticated, tell the user a new session will open the browser login automatically, or they can write `{"apiKey":"sm_..."}` to `~/.supermemory-muse/credentials.json`. With a self-hosted base URL there is no browser login; the key has to be written to that file.
32 changes: 30 additions & 2 deletions hooks/lib/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,16 @@ const DEFAULT_SETTINGS = {
recallDirective: null,
};

function readSettingsText(file) {
return fs.readFileSync(file, 'utf-8').replace(/^\uFEFF/, '');
}

function loadSettings() {
const settings = { ...DEFAULT_SETTINGS };
const file = settingsFile();
try {
if (fs.existsSync(file)) {
Object.assign(settings, JSON.parse(fs.readFileSync(file, 'utf-8')));
Object.assign(settings, JSON.parse(readSettingsText(file)));
}
} catch (err) {
console.error(`Settings: Failed to load ${file}: ${err.message}`);
Expand Down Expand Up @@ -60,10 +64,29 @@ function normalizeBaseUrl(baseUrl) {
}
}

// A settings.json that sets baseUrl but does not parse must not fall back to
// the hosted API, or a self-hosted key and transcripts would be sent there.
function globalBaseUrl() {
const file = settingsFile();
if (!fs.existsSync(file)) return null;
const text = readSettingsText(file);
try {
return JSON.parse(text)?.baseUrl || null;
} catch (err) {
if (text.includes('baseUrl')) {
throw new Error(`Invalid ${file}: ${err.message}`);
}
return null;
}
}

function getBaseUrl(cwd, projectConfig) {
projectConfig = projectConfig || loadProjectConfig(cwd || process.cwd());
const configured =
process.env.SUPERMEMORY_API_URL || projectConfig?.baseUrl || BASE_URL;
process.env.SUPERMEMORY_API_URL ||
projectConfig?.baseUrl ||
globalBaseUrl() ||
BASE_URL;
const normalized = normalizeBaseUrl(configured);
if (!normalized) {
throw new Error('Invalid baseUrl: expected an absolute http(s) URL');
Expand All @@ -89,7 +112,12 @@ function getRecallConfig(cwd) {
};
}

function isCustomBaseUrl(baseUrl) {
return new URL(baseUrl).hostname !== new URL(BASE_URL).hostname;
}

module.exports = {
isCustomBaseUrl,
settingsDir,
settingsFile,
DEFAULT_SETTINGS,
Expand Down
22 changes: 19 additions & 3 deletions hooks/session-start.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
const { getProfile } = require('./lib/api');
const { getContainerTag, getProjectName } = require('./lib/container-tag');
const { loadProjectConfig } = require('./lib/project-config');
const { loadSettings, getApiKey, getBaseUrl, debugLog } = require('./lib/settings');
const {
loadSettings,
getApiKey,
getBaseUrl,
isCustomBaseUrl,
debugLog,
} = require('./lib/settings');
const { BRAND, bold, gray } = require('./lib/colors');
const { readStdin, writeOutput } = require('./lib/stdin');
const { startAuthFlow, AUTH_BASE_URL } = require('./lib/auth');
Expand Down Expand Up @@ -71,10 +77,22 @@ async function main() {

debugLog(settings, 'SessionStart', { cwd, projectName, containerTag });

const baseUrl = getBaseUrl(cwd, projectConfig);

let apiKey;
try {
apiKey = getApiKey(cwd, projectConfig);
} catch {
if (isCustomBaseUrl(baseUrl)) {
// The hosted browser login cannot issue a key for a self-hosted server.
output(
`<supermemory-status>
No API key for ${baseUrl}. Write {"apiKey":"..."} to ~/.supermemory-muse/credentials.json.
</supermemory-status>`,
[],
);
return;
}
try {
apiKey = await startAuthFlow();
} catch (authErr) {
Expand All @@ -90,8 +108,6 @@ Or write an API key to ~/.supermemory-muse/credentials.json as {"apiKey":"sm_...
}
}

const baseUrl = getBaseUrl(cwd, projectConfig);

let profileResult = null;
let apiError = null;
try {
Expand Down
40 changes: 34 additions & 6 deletions mcp/proxy.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,33 @@
#!/usr/bin/env node
const readline = require('node:readline');
const { getContainerTag } = require('../hooks/lib/container-tag');
const { getApiKey } = require('../hooks/lib/settings');
const {
getApiKey,
getBaseUrl,
isCustomBaseUrl,
loadSettings,
} = require('../hooks/lib/settings');

const DEFAULT_MCP_URL = 'https://mcp.supermemory.ai/mcp';

// A self-hosted baseUrl must not send its key to the hosted MCP server.
function resolveMcpUrl(cwd) {
const explicit = process.env.SUPERMEMORY_MCP_URL || loadSettings().mcpUrl;
if (explicit) return { url: explicit };
try {
if (isCustomBaseUrl(getBaseUrl(cwd))) {
return {
url: null,
reason:
'baseUrl points at a self-hosted server. Set mcpUrl in ~/.supermemory-muse/settings.json to enable it.',
};
}
} catch (err) {
return { url: null, reason: err.message };
}
return { url: DEFAULT_MCP_URL };
}

const MCP_URL =
process.env.SUPERMEMORY_MCP_URL || 'https://mcp.supermemory.ai/mcp';
const REQUEST_TIMEOUT_MS = 30000;

const REPO_SCOPED_TOOLS = new Set([
Expand Down Expand Up @@ -66,15 +89,15 @@ function emitSseData(text) {
}
}

async function forward(message, apiKey) {
async function forward(message, apiKey, mcpUrl) {
const headers = {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
};
if (sessionId) headers['Mcp-Session-Id'] = sessionId;

const response = await fetch(MCP_URL, {
const response = await fetch(mcpUrl, {
method: 'POST',
headers,
body: JSON.stringify(message),
Expand Down Expand Up @@ -108,6 +131,7 @@ async function forward(message, apiKey) {

async function main() {
const cwd = process.cwd();
const mcp = resolveMcpUrl(cwd);
let apiKey = null;
let keyError = null;
let repoContainerTag = null;
Expand Down Expand Up @@ -135,6 +159,10 @@ async function main() {
}

queue = queue.then(async () => {
if (!mcp.url) {
sendError(message.id, -32002, `Supermemory MCP is off: ${mcp.reason}`);
return;
}
if (keyError) {
sendError(
message.id,
Expand All @@ -145,7 +173,7 @@ async function main() {
}
try {
injectRepoContainerTag(message, repoContainerTag);
await forward(message, apiKey);
await forward(message, apiKey, mcp.url);
} catch (err) {
sendError(message.id, -32000, `Supermemory MCP proxy error: ${err.message}`);
}
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "muse-supermemory",
"version": "0.1.1",
"version": "0.1.2",
"description": "Muse Code plugin by Supermemory AI",
"private": true,
"type": "commonjs",
Expand Down
13 changes: 13 additions & 0 deletions test/no-network.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Preload for tests that run hook scripts in a child process: no request may
// leave the machine and no browser may open.
const childProcess = require('node:child_process');

globalThis.fetch = async (url) => {
throw new Error(`network disabled in tests: ${url}`);
};

childProcess.execFile = (command, args, options, callback) => {
const done = typeof options === 'function' ? options : callback;
process.stderr.write(`browser launch disabled: ${command}\n`);
if (done) done(new Error('browser launch disabled in tests'));
};
Loading