memoryrouter-codex 1.6.1 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +67 -32
- package/bin/cli.cjs +33 -6
- package/lib/hooks.cjs +47 -8
- package/lib/installer.cjs +80 -19
- package/lib/managed.cjs +28 -4
- package/package.json +2 -2
- package/runtime/api.cjs +5 -1
- package/runtime/config.cjs +38 -3
- package/runtime/constants.cjs +1 -1
- package/runtime/hook.cjs +62 -47
- package/runtime/mcp-stdio.cjs +12 -13
- package/runtime/memory.cjs +108 -41
- package/runtime/project.cjs +171 -23
- package/runtime/vault-binding.cjs +51 -0
package/runtime/project.cjs
CHANGED
|
@@ -1,50 +1,198 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
// Project identity for Codex hooks and the local MCP fallback.
|
|
4
|
+
//
|
|
5
|
+
// Since 1.7.0 MemoryRouter is one vault per user: every Codex session, every
|
|
6
|
+
// Claude Code session, every repository, and every MemoryRouter surface (the
|
|
7
|
+
// hosted MCP, ChatGPT, Claude) read and write the same core vault. The hooks
|
|
8
|
+
// therefore send NO session_id (the API treats session_id as a hard vault
|
|
9
|
+
// partition, so any session_id would wall memories off again).
|
|
10
|
+
//
|
|
11
|
+
// The repository identity below is still computed, byte-for-byte the same as
|
|
12
|
+
// Claude Code (memoryrouter-claude lib/project.js): the same overrides, the
|
|
13
|
+
// same git remote normalization, the same `ccp_` SHA-256 prefix. It is only
|
|
14
|
+
// metadata now: it is stamped on Codex captures and used locally to label
|
|
15
|
+
// which recalled memories came from the current repository.
|
|
16
|
+
//
|
|
17
|
+
// History: 1.1.0 to 1.6.1 sent `mrcodex:<proj>:<codex session uuid>` (one pile
|
|
18
|
+
// per Codex session, so a new session recalled nothing), and memoryrouter-claude
|
|
19
|
+
// up to 2.6.1 used the per-repo `ccp_` id as a partition (one pile per repo).
|
|
20
|
+
// Both kinds of pile were merged into each user's core vault server-side when
|
|
21
|
+
// 1.7.0 shipped, so nothing saved earlier is lost.
|
|
22
|
+
//
|
|
23
|
+
// Only the opaque hash leaves the machine; local paths and remotes stay local.
|
|
24
|
+
// Keep this file self-contained: it is copied into the Codex runtime
|
|
25
|
+
// directory and must not require anything outside runtime/.
|
|
26
|
+
|
|
27
|
+
const fs = require('fs');
|
|
3
28
|
const path = require('path');
|
|
4
29
|
const crypto = require('crypto');
|
|
5
30
|
const { execFileSync } = require('child_process');
|
|
6
31
|
|
|
7
|
-
|
|
32
|
+
const PROJECT_CONFIG = '.memoryrouter.json';
|
|
33
|
+
|
|
34
|
+
function runGit(args, cwd) {
|
|
8
35
|
try {
|
|
9
|
-
return execFileSync('git',
|
|
36
|
+
return execFileSync('git', args, {
|
|
37
|
+
cwd,
|
|
10
38
|
encoding: 'utf8',
|
|
11
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
12
39
|
timeout: 2000,
|
|
40
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
13
41
|
}).trim();
|
|
14
|
-
} catch {
|
|
42
|
+
} catch {
|
|
43
|
+
return '';
|
|
44
|
+
}
|
|
15
45
|
}
|
|
16
46
|
|
|
47
|
+
// Back-compat helper used by older tests and callers: git -C <cwd> <args>.
|
|
48
|
+
function git(cwd, args) {
|
|
49
|
+
return runGit(args, cwd);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function realpathSafe(value) {
|
|
53
|
+
const resolved = path.resolve(value || process.cwd());
|
|
54
|
+
try {
|
|
55
|
+
return fs.realpathSync.native(resolved);
|
|
56
|
+
} catch {
|
|
57
|
+
return resolved;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Identical to memoryrouter-claude normalizeRemote(). Do not "improve" one
|
|
62
|
+
// side without the other: any difference splits the shared project memory.
|
|
17
63
|
function normalizeRemote(remote) {
|
|
18
64
|
let value = String(remote || '').trim();
|
|
19
|
-
|
|
65
|
+
if (!value) return '';
|
|
66
|
+
|
|
67
|
+
// SCP-like git syntax: git@github.com:Org/Repo.git
|
|
68
|
+
const scp = value.match(/^(?:[^@/]+@)?([^:/]+):(.+)$/);
|
|
69
|
+
if (scp && !/^[a-z][a-z0-9+.-]*:\/\//i.test(value)) {
|
|
70
|
+
value = `ssh://${scp[1]}/${scp[2]}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
20
73
|
try {
|
|
21
74
|
const url = new URL(value);
|
|
75
|
+
const host = url.hostname.toLowerCase();
|
|
76
|
+
const port = url.port && !((url.protocol === 'https:' && url.port === '443') || (url.protocol === 'ssh:' && url.port === '22'))
|
|
77
|
+
? `:${url.port}`
|
|
78
|
+
: '';
|
|
79
|
+
const pathname = decodeURIComponent(url.pathname).replace(/^\/+|\/+$/g, '').replace(/\.git$/i, '');
|
|
80
|
+
return host && pathname ? `${host}${port}/${pathname}`.toLowerCase() : '';
|
|
81
|
+
} catch {
|
|
82
|
+
return value
|
|
83
|
+
.replace(/^[^@/]+@/, '')
|
|
84
|
+
.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '')
|
|
85
|
+
.replace(/[?#].*$/, '')
|
|
86
|
+
.replace(/^\/+|\/+$/g, '')
|
|
87
|
+
.replace(/\.git$/i, '')
|
|
88
|
+
.toLowerCase();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function readProjectConfig(root) {
|
|
93
|
+
try {
|
|
94
|
+
const parsed = JSON.parse(fs.readFileSync(path.join(root, PROJECT_CONFIG), 'utf8'));
|
|
95
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
|
96
|
+
} catch {
|
|
97
|
+
return {};
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function hashIdentity(canonical) {
|
|
102
|
+
return crypto.createHash('sha256').update(canonical).digest('hex');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Pre-1.7 Codex-only handle. Kept so envelopes written by older releases are
|
|
106
|
+
// still recognised as belonging to this repository.
|
|
107
|
+
function legacyHandle(root, rawRemote, commonDirRaw) {
|
|
108
|
+
let remote = String(rawRemote || '').trim();
|
|
109
|
+
remote = remote.replace(/^([^@/]+)@([^:]+):/, 'ssh://$2/');
|
|
110
|
+
try {
|
|
111
|
+
const url = new URL(remote);
|
|
22
112
|
url.username = '';
|
|
23
113
|
url.password = '';
|
|
24
|
-
|
|
114
|
+
remote = `${url.protocol}//${url.host}${url.pathname}`;
|
|
25
115
|
} catch { /* local/file-like remote */ }
|
|
26
|
-
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function resolveProject(cwd = process.cwd()) {
|
|
30
|
-
const absoluteCwd = path.resolve(cwd);
|
|
31
|
-
const root = git(absoluteCwd, ['rev-parse', '--show-toplevel']) || absoluteCwd;
|
|
32
|
-
const remote = normalizeRemote(git(root, ['remote', 'get-url', 'origin']));
|
|
33
|
-
const commonDir = git(root, ['rev-parse', '--git-common-dir']);
|
|
116
|
+
remote = remote.replace(/\\/g, '/').replace(/\.git\/?$/i, '').replace(/\/+$/, '').toLowerCase();
|
|
34
117
|
const canonical = remote
|
|
35
118
|
? `remote:${remote}`
|
|
36
|
-
:
|
|
37
|
-
? `git:${path.resolve(root,
|
|
119
|
+
: commonDirRaw
|
|
120
|
+
? `git:${path.resolve(root, commonDirRaw)}`
|
|
38
121
|
: `path:${root}`;
|
|
39
|
-
const digest = crypto.createHash('sha256')
|
|
40
|
-
|
|
41
|
-
|
|
122
|
+
const digest = crypto.createHash('sha256').update(`memoryrouter-codex-project-v1\0${canonical}`).digest('hex');
|
|
123
|
+
return `proj_${digest.slice(0, 20)}`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function resolveProject(cwd = process.cwd(), options = {}) {
|
|
127
|
+
const env = options.env || process.env;
|
|
128
|
+
const config = options.config || {};
|
|
129
|
+
const requestedRoot = realpathSafe(cwd || process.cwd());
|
|
130
|
+
const gitRootRaw = runGit(['rev-parse', '--show-toplevel'], requestedRoot);
|
|
131
|
+
const root = realpathSafe(gitRootRaw || requestedRoot);
|
|
132
|
+
const projectConfig = readProjectConfig(root);
|
|
133
|
+
const ignored = fs.existsSync(path.join(root, '.memoryrouterignore')) || projectConfig.enabled === false;
|
|
134
|
+
|
|
135
|
+
const explicit = env.MEMORYROUTER_PROJECT_ID
|
|
136
|
+
|| projectConfig.projectId
|
|
137
|
+
|| projectConfig.project
|
|
138
|
+
|| config.projectId
|
|
139
|
+
|| config.project;
|
|
140
|
+
|
|
141
|
+
const originUrl = runGit(['config', '--get', 'remote.origin.url'], root);
|
|
142
|
+
const anyRemoteUrl = originUrl || runGit(['config', '--get-regexp', '^remote\\..*\\.url$'], root).split(/\s+/).pop();
|
|
143
|
+
const remote = normalizeRemote(anyRemoteUrl);
|
|
144
|
+
|
|
145
|
+
const commonDirRaw = gitRootRaw
|
|
146
|
+
? runGit(['rev-parse', '--path-format=absolute', '--git-common-dir'], root)
|
|
147
|
+
: '';
|
|
148
|
+
const commonDir = commonDirRaw ? realpathSafe(commonDirRaw) : '';
|
|
149
|
+
|
|
150
|
+
let source;
|
|
151
|
+
let canonical;
|
|
152
|
+
if (explicit && String(explicit).trim()) {
|
|
153
|
+
source = 'explicit';
|
|
154
|
+
canonical = `explicit:${String(explicit).trim().toLowerCase()}`;
|
|
155
|
+
} else if (remote) {
|
|
156
|
+
source = 'git-remote';
|
|
157
|
+
canonical = `git:${remote}`;
|
|
158
|
+
} else if (commonDir) {
|
|
159
|
+
source = 'git-common-dir';
|
|
160
|
+
canonical = `git-local:${commonDir}`;
|
|
161
|
+
} else {
|
|
162
|
+
source = 'directory';
|
|
163
|
+
canonical = `dir:${root}`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const id = `ccp_${hashIdentity(canonical).slice(0, 24)}`;
|
|
167
|
+
|
|
42
168
|
return {
|
|
43
|
-
handle:
|
|
169
|
+
handle: id,
|
|
170
|
+
id,
|
|
44
171
|
root,
|
|
45
|
-
source
|
|
46
|
-
portable:
|
|
172
|
+
source,
|
|
173
|
+
portable: source === 'git-remote' || source === 'explicit',
|
|
174
|
+
label: path.basename(root) || 'workspace',
|
|
175
|
+
ignored,
|
|
176
|
+
legacyHandle: legacyHandle(gitRootRaw || path.resolve(cwd || process.cwd()), originUrl, gitRootRaw ? runGit(['rev-parse', '--git-common-dir'], root) : ''),
|
|
47
177
|
};
|
|
48
178
|
}
|
|
49
179
|
|
|
50
|
-
|
|
180
|
+
// The MemoryRouter session_id (vault partition) the hooks use. Always
|
|
181
|
+
// undefined since 1.7.0: one vault per user, no per-session or per-repo walls.
|
|
182
|
+
// Kept as a function so callers and tests have one place that states the rule.
|
|
183
|
+
function sessionIdFor() {
|
|
184
|
+
return undefined;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
module.exports = {
|
|
188
|
+
PROJECT_CONFIG,
|
|
189
|
+
git,
|
|
190
|
+
runGit,
|
|
191
|
+
realpathSafe,
|
|
192
|
+
normalizeRemote,
|
|
193
|
+
readProjectConfig,
|
|
194
|
+
hashIdentity,
|
|
195
|
+
legacyHandle,
|
|
196
|
+
resolveProject,
|
|
197
|
+
sessionIdFor,
|
|
198
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// One user = one vault. The vault a user selects when connecting MemoryRouter
|
|
3
|
+
// over OAuth (the MCP login used by Claude Code, Codex, ChatGPT, Claude) must
|
|
4
|
+
// be the same vault the automatic hooks write to with the Memory Key. The auth
|
|
5
|
+
// server compares the two: GET /v1/vault-binding with the Memory Key as Bearer.
|
|
6
|
+
// Kept self-contained (copied into the Codex runtime directory).
|
|
7
|
+
// The response never contains a key, only opaque vault refs and names.
|
|
8
|
+
|
|
9
|
+
const AUTH_BASE = 'https://auth.memoryrouter.ai';
|
|
10
|
+
|
|
11
|
+
function authBase(env = process.env) {
|
|
12
|
+
return String(env.MEMORYROUTER_AUTH_BASE || AUTH_BASE).replace(/\/+$/, '');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function checkVaultBinding(key, options = {}) {
|
|
16
|
+
const env = options.env || process.env;
|
|
17
|
+
const controller = new AbortController();
|
|
18
|
+
const timer = setTimeout(() => controller.abort(), options.timeoutMs || 6000);
|
|
19
|
+
try {
|
|
20
|
+
const res = await fetch(`${authBase(env)}/v1/vault-binding`, {
|
|
21
|
+
headers: { Authorization: `Bearer ${key}`, Accept: 'application/json' },
|
|
22
|
+
signal: controller.signal,
|
|
23
|
+
});
|
|
24
|
+
let data = null;
|
|
25
|
+
try { data = await res.json(); } catch { /* not json */ }
|
|
26
|
+
if (!res.ok || !data || typeof data.status !== 'string') {
|
|
27
|
+
return { status: 'unknown', httpStatus: res.status, message: `vault check unavailable (HTTP ${res.status})` };
|
|
28
|
+
}
|
|
29
|
+
return data;
|
|
30
|
+
} catch (err) {
|
|
31
|
+
return { status: 'unknown', message: `vault check unavailable (${err && err.name === 'AbortError' ? 'timeout' : (err && err.message) || 'network error'})` };
|
|
32
|
+
} finally {
|
|
33
|
+
clearTimeout(timer);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function vaultLabel(binding) {
|
|
38
|
+
const vault = binding && binding.vault;
|
|
39
|
+
if (!vault) return 'this vault';
|
|
40
|
+
return vault.name ? `"${vault.name}" (${vault.ref})` : vault.ref;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
class VaultMismatchError extends Error {
|
|
44
|
+
constructor(binding) {
|
|
45
|
+
super(`${binding.message || 'This Memory Key is not the vault you selected in OAuth.'}\n Nothing was installed. Run init again with the Memory Key of the vault you selected (app.memoryrouter.ai), or reconnect MemoryRouter and select ${vaultLabel(binding)}.`);
|
|
46
|
+
this.name = 'VaultMismatchError';
|
|
47
|
+
this.binding = binding;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = { AUTH_BASE, authBase, checkVaultBinding, vaultLabel, VaultMismatchError };
|