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.
@@ -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
- function git(cwd, args) {
32
+ const PROJECT_CONFIG = '.memoryrouter.json';
33
+
34
+ function runGit(args, cwd) {
8
35
  try {
9
- return execFileSync('git', ['-C', cwd, ...args], {
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 { return ''; }
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
- value = value.replace(/^([^@/]+)@([^:]+):/, 'ssh://$2/');
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
- value = `${url.protocol}//${url.host}${url.pathname}`;
114
+ remote = `${url.protocol}//${url.host}${url.pathname}`;
25
115
  } catch { /* local/file-like remote */ }
26
- return value.replace(/\\/g, '/').replace(/\.git\/?$/i, '').replace(/\/+$/, '').toLowerCase();
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
- : commonDir
37
- ? `git:${path.resolve(root, commonDir)}`
119
+ : commonDirRaw
120
+ ? `git:${path.resolve(root, commonDirRaw)}`
38
121
  : `path:${root}`;
39
- const digest = crypto.createHash('sha256')
40
- .update(`memoryrouter-codex-project-v1\0${canonical}`)
41
- .digest('hex');
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: `proj_${digest.slice(0, 20)}`,
169
+ handle: id,
170
+ id,
44
171
  root,
45
- source: remote ? 'git-remote' : commonDir ? 'git-common-dir' : 'path',
46
- portable: Boolean(remote),
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
- module.exports = { git, normalizeRemote, resolveProject };
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 };