memoryrouter-codex 1.0.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/LICENSE +21 -0
- package/README.md +225 -0
- package/RESEARCH.md +57 -0
- package/bin/cli.cjs +102 -0
- package/lib/hooks.cjs +103 -0
- package/lib/installer.cjs +235 -0
- package/lib/managed.cjs +168 -0
- package/package.json +39 -0
- package/runtime/api.cjs +80 -0
- package/runtime/config.cjs +166 -0
- package/runtime/constants.cjs +23 -0
- package/runtime/hook.cjs +260 -0
- package/runtime/mcp-stdio.cjs +160 -0
- package/runtime/memory.cjs +207 -0
- package/runtime/project.cjs +50 -0
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { execFileSync } = require('child_process');
|
|
7
|
+
const { codexHome, dataHome, loadConfig, saveConfig, configPath } = require('../runtime/config.cjs');
|
|
8
|
+
const { resolveProject } = require('../runtime/project.cjs');
|
|
9
|
+
const { MCP_ENDPOINT, INTEGRATION_VERSION } = require('../runtime/constants.cjs');
|
|
10
|
+
const {
|
|
11
|
+
installToml, uninstallToml, installAgents, uninstallAgents, readText, replaceBlock,
|
|
12
|
+
hasUnmanagedMemoryRouterTable, TOML_START, TOML_END, AGENTS_START, AGENTS_END,
|
|
13
|
+
} = require('./managed.cjs');
|
|
14
|
+
const { installHooks, uninstallHooks, isOwnedHook, parseHooks } = require('./hooks.cjs');
|
|
15
|
+
|
|
16
|
+
function resolveLayout(options = {}) {
|
|
17
|
+
const env = options.env || process.env;
|
|
18
|
+
const scope = options.scope || 'user';
|
|
19
|
+
if (!['user', 'project'].includes(scope)) throw new Error('--scope must be user or project');
|
|
20
|
+
if (scope === 'user') {
|
|
21
|
+
const home = codexHome(env);
|
|
22
|
+
return {
|
|
23
|
+
scope,
|
|
24
|
+
root: home,
|
|
25
|
+
configFile: path.join(home, 'config.toml'),
|
|
26
|
+
hooksFile: path.join(home, 'hooks.json'),
|
|
27
|
+
agentsFile: path.join(home, 'AGENTS.md'),
|
|
28
|
+
runtimeDir: path.join(home, 'memoryrouter', 'runtime'),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
const project = resolveProject(options.projectDir || process.cwd());
|
|
32
|
+
return {
|
|
33
|
+
scope,
|
|
34
|
+
root: project.root,
|
|
35
|
+
project,
|
|
36
|
+
configFile: path.join(project.root, '.codex', 'config.toml'),
|
|
37
|
+
hooksFile: path.join(project.root, '.codex', 'hooks.json'),
|
|
38
|
+
agentsFile: path.join(project.root, 'AGENTS.md'),
|
|
39
|
+
runtimeDir: path.join(project.root, '.codex', 'memoryrouter', 'runtime'),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function copyRuntime(runtimeDir) {
|
|
44
|
+
const source = path.resolve(__dirname, '..', 'runtime');
|
|
45
|
+
fs.mkdirSync(runtimeDir, { recursive: true });
|
|
46
|
+
for (const name of fs.readdirSync(source)) {
|
|
47
|
+
if (!name.endsWith('.cjs')) continue;
|
|
48
|
+
fs.copyFileSync(path.join(source, name), path.join(runtimeDir, name));
|
|
49
|
+
}
|
|
50
|
+
fs.writeFileSync(path.join(runtimeDir, 'VERSION'), `${INTEGRATION_VERSION}\n`, 'utf8');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function preflight(layout, options = {}) {
|
|
54
|
+
const configText = readText(layout.configFile);
|
|
55
|
+
// Validate marker pairs before any file is changed.
|
|
56
|
+
replaceBlock(configText, TOML_START, TOML_END, '');
|
|
57
|
+
if (hasUnmanagedMemoryRouterTable(configText) && !options.force) {
|
|
58
|
+
throw new Error(`An unmanaged [mcp_servers.memoryrouter] table already exists in ${layout.configFile}. Re-run with --force after reviewing the migration backup.`);
|
|
59
|
+
}
|
|
60
|
+
if (options.hooks !== false) parseHooks(layout.hooksFile);
|
|
61
|
+
if (options.agents !== false) replaceBlock(readText(layout.agentsFile), AGENTS_START, AGENTS_END, '');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function init(options = {}) {
|
|
65
|
+
const layout = resolveLayout(options);
|
|
66
|
+
const auth = options.auth || 'oauth';
|
|
67
|
+
if (!['oauth', 'api-key'].includes(auth)) throw new Error('--auth must be oauth or api-key');
|
|
68
|
+
preflight(layout, options);
|
|
69
|
+
copyRuntime(layout.runtimeDir);
|
|
70
|
+
const results = [];
|
|
71
|
+
results.push(installToml(layout.configFile, { auth, scope: layout.scope, runtimeDir: layout.runtimeDir, force: options.force }));
|
|
72
|
+
if (options.hooks !== false) results.push(installHooks(layout.hooksFile, layout.runtimeDir, layout.scope));
|
|
73
|
+
if (options.agents !== false) results.push(installAgents(layout.agentsFile));
|
|
74
|
+
|
|
75
|
+
if (options.apiKey) {
|
|
76
|
+
const current = loadConfig(options.env);
|
|
77
|
+
saveConfig({ ...current, apiKey: options.apiKey }, options.env);
|
|
78
|
+
}
|
|
79
|
+
const manifest = {
|
|
80
|
+
version: INTEGRATION_VERSION,
|
|
81
|
+
installedAt: new Date().toISOString(),
|
|
82
|
+
scope: layout.scope,
|
|
83
|
+
auth,
|
|
84
|
+
configFile: layout.configFile,
|
|
85
|
+
hooksFile: options.hooks === false ? null : layout.hooksFile,
|
|
86
|
+
agentsFile: options.agents === false ? null : layout.agentsFile,
|
|
87
|
+
runtimeDir: layout.runtimeDir,
|
|
88
|
+
};
|
|
89
|
+
const manifestDir = path.join(dataHome(options.env), 'installations');
|
|
90
|
+
fs.mkdirSync(manifestDir, { recursive: true, mode: 0o700 });
|
|
91
|
+
const manifestName = Buffer.from(`${layout.scope}:${layout.root}`).toString('base64url');
|
|
92
|
+
fs.writeFileSync(path.join(manifestDir, `${manifestName}.json`), `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 });
|
|
93
|
+
return { layout, auth, results, automaticMemory: Boolean(options.apiKey || process.env.MEMORYROUTER_API_KEY || loadConfig(options.env).apiKey) };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function uninstall(options = {}) {
|
|
97
|
+
const layout = resolveLayout(options);
|
|
98
|
+
// Refuse partial removal when an owned surface is malformed.
|
|
99
|
+
replaceBlock(readText(layout.configFile), TOML_START, TOML_END, '');
|
|
100
|
+
parseHooks(layout.hooksFile);
|
|
101
|
+
replaceBlock(readText(layout.agentsFile), AGENTS_START, AGENTS_END, '');
|
|
102
|
+
const results = [
|
|
103
|
+
uninstallToml(layout.configFile),
|
|
104
|
+
uninstallHooks(layout.hooksFile),
|
|
105
|
+
uninstallAgents(layout.agentsFile),
|
|
106
|
+
];
|
|
107
|
+
fs.rmSync(path.dirname(layout.runtimeDir), { recursive: true, force: true });
|
|
108
|
+
return { layout, results };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function parseVersion(text) {
|
|
112
|
+
const match = String(text || '').match(/(\d+)\.(\d+)\.(\d+)/);
|
|
113
|
+
return match ? match.slice(1).map(Number) : null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function versionAtLeast(actual, minimum) {
|
|
117
|
+
if (!actual) return false;
|
|
118
|
+
for (let i = 0; i < 3; i += 1) {
|
|
119
|
+
if (actual[i] > minimum[i]) return true;
|
|
120
|
+
if (actual[i] < minimum[i]) return false;
|
|
121
|
+
}
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function codexInfo(cwd, env = process.env) {
|
|
126
|
+
try {
|
|
127
|
+
const output = execFileSync('codex', ['--version'], { cwd, env, encoding: 'utf8', timeout: 5000 }).trim();
|
|
128
|
+
return { installed: true, output, version: parseVersion(output) };
|
|
129
|
+
} catch { return { installed: false, output: null, version: null }; }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function fetchJson(url, timeoutMs = 5000, init = {}) {
|
|
133
|
+
const controller = new AbortController();
|
|
134
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
135
|
+
try {
|
|
136
|
+
const response = await fetch(url, { ...init, signal: controller.signal });
|
|
137
|
+
let data = null;
|
|
138
|
+
try { data = await response.json(); } catch { /* no json */ }
|
|
139
|
+
return { ok: response.ok, status: response.status, data };
|
|
140
|
+
} finally { clearTimeout(timer); }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function probeMcpProtocol() {
|
|
144
|
+
return fetchJson(MCP_ENDPOINT, 6000, {
|
|
145
|
+
method: 'POST',
|
|
146
|
+
headers: { 'content-type': 'application/json' },
|
|
147
|
+
body: JSON.stringify({
|
|
148
|
+
jsonrpc: '2.0', id: 1, method: 'initialize',
|
|
149
|
+
params: { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: { name: 'memoryrouter-codex-doctor', version: INTEGRATION_VERSION } },
|
|
150
|
+
}),
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function addCheck(checks, name, level, message, details) {
|
|
155
|
+
checks.push({ name, level, message, ...(details ? { details } : {}) });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function doctor(options = {}) {
|
|
159
|
+
const layout = resolveLayout(options);
|
|
160
|
+
const checks = [];
|
|
161
|
+
const configText = readText(layout.configFile);
|
|
162
|
+
const hooksText = readText(layout.hooksFile);
|
|
163
|
+
const agentsText = readText(layout.agentsFile);
|
|
164
|
+
const runtimeVersion = readText(path.join(layout.runtimeDir, 'VERSION')).trim();
|
|
165
|
+
const codex = codexInfo(layout.root, options.env);
|
|
166
|
+
|
|
167
|
+
if (!codex.installed) addCheck(checks, 'codex-version', 'warn', 'Codex CLI is not on PATH; app/IDE configuration can still exist, but CLI behavior was not verified.');
|
|
168
|
+
else if (!versionAtLeast(codex.version, [0, 145, 0])) addCheck(checks, 'codex-version', 'error', `${codex.output} is too old for the lifecycle hooks used here. Upgrade to Codex 0.145.0 or newer.`);
|
|
169
|
+
else addCheck(checks, 'codex-version', 'pass', `${codex.output} supports current hooks and remote MCP configuration.`);
|
|
170
|
+
|
|
171
|
+
addCheck(checks, 'mcp-config', configText.includes(TOML_START) && configText.includes('[mcp_servers.memoryrouter]') ? 'pass' : 'error',
|
|
172
|
+
configText.includes(TOML_START) ? 'Managed MemoryRouter MCP configuration is present.' : 'Managed MemoryRouter MCP configuration is missing.');
|
|
173
|
+
addCheck(checks, 'runtime', runtimeVersion === INTEGRATION_VERSION ? 'pass' : 'error',
|
|
174
|
+
runtimeVersion ? `Installed runtime version is ${runtimeVersion}; package version is ${INTEGRATION_VERSION}.` : 'Managed runtime is missing.');
|
|
175
|
+
|
|
176
|
+
let hooks = null;
|
|
177
|
+
try { hooks = hooksText ? JSON.parse(hooksText) : null; } catch { /* handled */ }
|
|
178
|
+
const owned = hooks ? Object.values(hooks.hooks || {}).flat().flatMap(group => group.hooks || []).filter(isOwnedHook) : [];
|
|
179
|
+
addCheck(checks, 'hooks', owned.length >= 7 ? 'pass' : 'error', owned.length ? `${owned.length} managed lifecycle hooks found.` : 'Managed lifecycle hooks are missing or hooks.json is invalid.');
|
|
180
|
+
addCheck(checks, 'agents', agentsText.includes(AGENTS_START) ? 'pass' : 'warn', agentsText.includes(AGENTS_START) ? 'Managed AGENTS.md guidance is present.' : 'Managed AGENTS.md guidance is missing.');
|
|
181
|
+
|
|
182
|
+
const cfg = loadConfig(options.env);
|
|
183
|
+
const key = process.env.MEMORYROUTER_API_KEY || cfg.apiKey;
|
|
184
|
+
if (!key) addCheck(checks, 'automatic-auth', 'warn', 'No MemoryRouter API key is available. Remote OAuth tools can work after `codex mcp login memoryrouter`, but automatic hook retrieval/capture is disabled.');
|
|
185
|
+
else {
|
|
186
|
+
try {
|
|
187
|
+
const { stats } = require('../runtime/api.cjs');
|
|
188
|
+
const response = options.offline ? { ok: true, status: 0 } : await stats(key, { apiBase: cfg.apiBase, timeoutMs: 6000, retries: 0 });
|
|
189
|
+
addCheck(checks, 'automatic-auth', response.ok ? 'pass' : 'error', options.offline ? 'API key is configured (network check skipped).' : response.ok ? 'API key is valid; automatic retrieval/capture is active.' : `API key validation failed (HTTP ${response.status}).`);
|
|
190
|
+
} catch (error) { addCheck(checks, 'automatic-auth', 'error', `API key validation failed: ${error.message}`); }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (configText.includes('auth = "oauth"')) {
|
|
194
|
+
if (options.offline) {
|
|
195
|
+
addCheck(checks, 'oauth-discovery', 'warn', 'OAuth discovery check skipped in offline mode.');
|
|
196
|
+
addCheck(checks, 'oauth-session', 'info', 'Browser consent/token issuance is user-specific. Run `codex mcp login memoryrouter`; then `/mcp verbose`. Doctor does not claim login completion without interactive consent.');
|
|
197
|
+
} else {
|
|
198
|
+
try {
|
|
199
|
+
const prm = await fetchJson('https://mcp.memoryrouter.ai/.well-known/oauth-protected-resource');
|
|
200
|
+
const issuer = prm.data?.authorization_servers?.[0];
|
|
201
|
+
const metadata = issuer ? await fetchJson(`${issuer}/.well-known/oauth-authorization-server`) : { ok: false };
|
|
202
|
+
const compatible = prm.ok && prm.data?.resource === MCP_ENDPOINT && metadata.ok && metadata.data?.authorization_endpoint && metadata.data?.token_endpoint && metadata.data?.registration_endpoint && metadata.data?.code_challenge_methods_supported?.includes('S256');
|
|
203
|
+
addCheck(checks, 'oauth-discovery', compatible ? 'pass' : 'error', compatible
|
|
204
|
+
? 'MemoryRouter publishes Codex-compatible RFC 9728 discovery, OAuth metadata, dynamic client registration, and PKCE S256.'
|
|
205
|
+
: 'OAuth discovery is incomplete or unreachable.', { prmStatus: prm.status, metadataStatus: metadata.status });
|
|
206
|
+
addCheck(checks, 'oauth-session', 'info', 'Discovery is verified, but browser consent/token issuance is user-specific. Run `codex mcp login memoryrouter`; then `/mcp verbose`. Doctor does not claim login completion without that interactive consent.');
|
|
207
|
+
const protocol = await probeMcpProtocol();
|
|
208
|
+
const negotiated = protocol.data?.result?.protocolVersion;
|
|
209
|
+
addCheck(checks, 'mcp-protocol', negotiated === '2025-11-25' ? 'warn' : protocol.ok ? 'pass' : 'error', negotiated === '2025-11-25'
|
|
210
|
+
? 'MemoryRouter currently negotiates legacy MCP 2025-11-25. Codex 0.146 is dual-era and works, but the hosted server is not yet MCP 2026-07-28 compliant.'
|
|
211
|
+
: protocol.ok ? `MemoryRouter negotiated MCP ${negotiated || 'unknown'}.` : `MCP initialize probe failed (HTTP ${protocol.status}).`);
|
|
212
|
+
} catch (error) { addCheck(checks, 'oauth-discovery', 'error', `OAuth discovery failed: ${error.message}`); }
|
|
213
|
+
}
|
|
214
|
+
} else {
|
|
215
|
+
addCheck(checks, 'oauth-discovery', 'info', 'Local stdio API-key fallback is configured; Codex OAuth is not used. No secret is stored in project files.');
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const nativeEnabled = /\[features\][\s\S]*?\bmemories\s*=\s*true\b/.test(configText);
|
|
219
|
+
const externalDisabled = /\[memories\][\s\S]*?\bdisable_on_external_context\s*=\s*true\b/.test(configText);
|
|
220
|
+
addCheck(checks, 'native-memories', 'info', nativeEnabled
|
|
221
|
+
? `Codex native Memories are enabled${externalDisabled ? ' and external-context generation is disabled.' : '. Consider [memories] disable_on_external_context = true to avoid re-extracting MemoryRouter-assisted chats.'}`
|
|
222
|
+
: 'Codex native Memories are off or configured in another layer. This integration does not overwrite native-memory settings.');
|
|
223
|
+
|
|
224
|
+
const project = resolveProject(layout.root);
|
|
225
|
+
addCheck(checks, 'project-identity', project.portable ? 'pass' : 'warn', project.portable
|
|
226
|
+
? `Project handle ${project.handle} is derived from a canonical git remote and is stable across worktrees/machines.`
|
|
227
|
+
: `Project handle ${project.handle} falls back to a local ${project.source}; add an origin remote for cross-machine identity.`);
|
|
228
|
+
addCheck(checks, 'cloud-surface', layout.scope === 'project' ? 'info' : 'warn', layout.scope === 'project'
|
|
229
|
+
? 'Project config/hooks can travel with the repository, but cloud environments must trust the project, install Node, and provide MEMORYROUTER_API_KEY. ChatGPT web does not consume local Codex config; use a hosted plugin/connector there.'
|
|
230
|
+
: 'User-scope config covers local CLI, desktop app, and IDE on this host. Codex cloud and ChatGPT web do not inherit this machine-local config.');
|
|
231
|
+
|
|
232
|
+
return { layout, codex, checks, ok: !checks.some(check => check.level === 'error') };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
module.exports = { resolveLayout, copyRuntime, preflight, init, uninstall, parseVersion, versionAtLeast, codexInfo, doctor };
|
package/lib/managed.cjs
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { INTEGRATION_VERSION, MCP_ENDPOINT } = require('../runtime/constants.cjs');
|
|
6
|
+
|
|
7
|
+
const TOML_START = '# >>> memoryrouter-codex managed v1 >>>';
|
|
8
|
+
const TOML_END = '# <<< memoryrouter-codex managed v1 <<<';
|
|
9
|
+
const AGENTS_START = '<!-- >>> memoryrouter-codex managed v1 >>> -->';
|
|
10
|
+
const AGENTS_END = '<!-- <<< memoryrouter-codex managed v1 <<< -->';
|
|
11
|
+
|
|
12
|
+
function readText(file) {
|
|
13
|
+
try { return fs.readFileSync(file, 'utf8'); } catch (error) {
|
|
14
|
+
if (error.code === 'ENOENT') return '';
|
|
15
|
+
throw error;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function atomicWrite(file, text) {
|
|
20
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
21
|
+
const temp = `${file}.${process.pid}.tmp`;
|
|
22
|
+
fs.writeFileSync(temp, text, 'utf8');
|
|
23
|
+
fs.renameSync(temp, file);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function replaceBlock(text, start, end, block) {
|
|
27
|
+
const startIndex = text.indexOf(start);
|
|
28
|
+
const endIndex = text.indexOf(end);
|
|
29
|
+
let clean = text;
|
|
30
|
+
if (startIndex >= 0 || endIndex >= 0) {
|
|
31
|
+
if (startIndex < 0 || endIndex < startIndex) throw new Error(`Malformed managed block (${start})`);
|
|
32
|
+
clean = `${text.slice(0, startIndex)}${text.slice(endIndex + end.length)}`;
|
|
33
|
+
}
|
|
34
|
+
clean = clean.replace(/\s+$/, '');
|
|
35
|
+
return block ? `${clean}${clean ? '\n\n' : ''}${start}\n${block.trim()}\n${end}\n` : `${clean}${clean ? '\n' : ''}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function hasUnmanagedMemoryRouterTable(text) {
|
|
39
|
+
const stripped = replaceBlock(text, TOML_START, TOML_END, '');
|
|
40
|
+
return /^\s*\[mcp_servers\.memoryrouter(?:\.|\])/.test(stripped) || /^\s*\[mcp_servers\.memoryrouter(?:\.|\])/m.test(stripped);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function removeUnmanagedMemoryRouterTables(text) {
|
|
44
|
+
const withoutManaged = replaceBlock(text, TOML_START, TOML_END, '');
|
|
45
|
+
const lines = withoutManaged.split('\n');
|
|
46
|
+
const kept = [];
|
|
47
|
+
let skip = false;
|
|
48
|
+
for (const line of lines) {
|
|
49
|
+
const table = line.match(/^\s*\[([^\]]+)\]\s*(?:#.*)?$/)?.[1]?.trim();
|
|
50
|
+
if (table) skip = table === 'mcp_servers.memoryrouter' || table.startsWith('mcp_servers.memoryrouter.');
|
|
51
|
+
if (!skip) kept.push(line);
|
|
52
|
+
}
|
|
53
|
+
return kept.join('\n').replace(/\n{3,}/g, '\n\n');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function tomlString(value) { return JSON.stringify(String(value)); }
|
|
57
|
+
|
|
58
|
+
function mcpBlock({ auth = 'oauth', scope = 'user', runtimeDir }) {
|
|
59
|
+
const lines = [];
|
|
60
|
+
if (auth === 'oauth') {
|
|
61
|
+
lines.push(
|
|
62
|
+
'[mcp_servers.memoryrouter]',
|
|
63
|
+
`url = ${tomlString(MCP_ENDPOINT)}`,
|
|
64
|
+
'auth = "oauth"',
|
|
65
|
+
'enabled = true',
|
|
66
|
+
'required = false',
|
|
67
|
+
'startup_timeout_sec = 20',
|
|
68
|
+
'tool_timeout_sec = 45',
|
|
69
|
+
'default_tools_approval_mode = "writes"',
|
|
70
|
+
'scopes = ["memories:read", "memories:write"]',
|
|
71
|
+
'',
|
|
72
|
+
'[mcp_servers.memoryrouter.tools.store_memory]',
|
|
73
|
+
'approval_mode = "prompt"',
|
|
74
|
+
);
|
|
75
|
+
} else if (scope === 'project') {
|
|
76
|
+
lines.push(
|
|
77
|
+
'[mcp_servers.memoryrouter]',
|
|
78
|
+
'command = "npx"',
|
|
79
|
+
`args = ["-y", "memoryrouter-codex@${INTEGRATION_VERSION}", "mcp-server"]`,
|
|
80
|
+
'enabled = true',
|
|
81
|
+
'required = false',
|
|
82
|
+
'startup_timeout_sec = 20',
|
|
83
|
+
'tool_timeout_sec = 45',
|
|
84
|
+
'default_tools_approval_mode = "writes"',
|
|
85
|
+
'',
|
|
86
|
+
'[mcp_servers.memoryrouter.tools.memory_forget]',
|
|
87
|
+
'approval_mode = "prompt"',
|
|
88
|
+
);
|
|
89
|
+
} else {
|
|
90
|
+
lines.push(
|
|
91
|
+
'[mcp_servers.memoryrouter]',
|
|
92
|
+
'command = "node"',
|
|
93
|
+
`args = [${tomlString(path.join(runtimeDir, 'mcp-stdio.cjs'))}]`,
|
|
94
|
+
'enabled = true',
|
|
95
|
+
'required = false',
|
|
96
|
+
'startup_timeout_sec = 20',
|
|
97
|
+
'tool_timeout_sec = 45',
|
|
98
|
+
'default_tools_approval_mode = "writes"',
|
|
99
|
+
'',
|
|
100
|
+
'[mcp_servers.memoryrouter.tools.memory_forget]',
|
|
101
|
+
'approval_mode = "prompt"',
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
return lines.join('\n');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function installToml(file, options) {
|
|
108
|
+
const current = readText(file);
|
|
109
|
+
if (hasUnmanagedMemoryRouterTable(current) && !options.force) {
|
|
110
|
+
throw new Error(`An unmanaged [mcp_servers.memoryrouter] table already exists in ${file}. Re-run with --force after backing it up, or remove/rename that table.`);
|
|
111
|
+
}
|
|
112
|
+
let base = current;
|
|
113
|
+
let backup = null;
|
|
114
|
+
if (hasUnmanagedMemoryRouterTable(current) && options.force) {
|
|
115
|
+
backup = `${file}.memoryrouter-backup-${Date.now()}`;
|
|
116
|
+
fs.copyFileSync(file, backup);
|
|
117
|
+
base = removeUnmanagedMemoryRouterTables(current);
|
|
118
|
+
}
|
|
119
|
+
const updated = replaceBlock(base, TOML_START, TOML_END, mcpBlock(options));
|
|
120
|
+
atomicWrite(file, updated);
|
|
121
|
+
return { changed: updated !== current, file, ...(backup ? { backup } : {}) };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function uninstallToml(file) {
|
|
125
|
+
const current = readText(file);
|
|
126
|
+
if (!current.includes(TOML_START)) return { changed: false, file };
|
|
127
|
+
const updated = replaceBlock(current, TOML_START, TOML_END, '');
|
|
128
|
+
atomicWrite(file, updated);
|
|
129
|
+
return { changed: true, file };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function agentsBlock() {
|
|
133
|
+
return `## MemoryRouter memory\n\n- MemoryRouter is the durable cross-machine, cross-tool, and team/auditable memory layer. Codex native Memories may remain enabled as the local personal recall layer.\n- Automatic hooks search project-scoped memory before each prompt and capture completed turns when a MemoryRouter API key is configured.\n- For explicit recall, search MemoryRouter before answering questions about prior decisions, preferences, incidents, or unfinished work. Depending on auth mode, the tool is named \`search_memories\` or \`memory_search\`.\n- Store only concise, durable decisions, preferences, facts, and incident outcomes. Depending on auth mode, use \`store_memory\` or \`memory_store\`. Do not duplicate a completed turn that hooks already captured.\n- Keep and cite returned \`mrh_...\` handles when a specific memory must be referenced later.\n- Project scope is derived from the canonical git remote when available, so worktrees and machines share identity without uploading local paths.\n- If MemoryRouter is offline, continue the task without memory; never block work on recall or capture.`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function installAgents(file) {
|
|
137
|
+
const current = readText(file);
|
|
138
|
+
const updated = replaceBlock(current, AGENTS_START, AGENTS_END, agentsBlock());
|
|
139
|
+
atomicWrite(file, updated);
|
|
140
|
+
return { changed: updated !== current, file };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function uninstallAgents(file) {
|
|
144
|
+
const current = readText(file);
|
|
145
|
+
if (!current.includes(AGENTS_START)) return { changed: false, file };
|
|
146
|
+
const updated = replaceBlock(current, AGENTS_START, AGENTS_END, '');
|
|
147
|
+
if (!updated.trim()) fs.rmSync(file, { force: true });
|
|
148
|
+
else atomicWrite(file, updated);
|
|
149
|
+
return { changed: true, file };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
module.exports = {
|
|
153
|
+
TOML_START,
|
|
154
|
+
TOML_END,
|
|
155
|
+
AGENTS_START,
|
|
156
|
+
AGENTS_END,
|
|
157
|
+
readText,
|
|
158
|
+
atomicWrite,
|
|
159
|
+
replaceBlock,
|
|
160
|
+
hasUnmanagedMemoryRouterTable,
|
|
161
|
+
removeUnmanagedMemoryRouterTables,
|
|
162
|
+
mcpBlock,
|
|
163
|
+
installToml,
|
|
164
|
+
uninstallToml,
|
|
165
|
+
agentsBlock,
|
|
166
|
+
installAgents,
|
|
167
|
+
uninstallAgents,
|
|
168
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "memoryrouter-codex",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Automatic, project-scoped MemoryRouter memory for OpenAI Codex",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "commonjs",
|
|
7
|
+
"bin": {
|
|
8
|
+
"memoryrouter-codex": "bin/cli.cjs"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin/",
|
|
12
|
+
"lib/",
|
|
13
|
+
"runtime/",
|
|
14
|
+
"README.md",
|
|
15
|
+
"RESEARCH.md",
|
|
16
|
+
"LICENSE"
|
|
17
|
+
],
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=18"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"test": "node --test test/*.test.cjs",
|
|
23
|
+
"typecheck": "node --check bin/cli.cjs && for f in lib/*.cjs runtime/*.cjs; do node --check \"$f\"; done",
|
|
24
|
+
"pack:dry": "npm pack --dry-run"
|
|
25
|
+
},
|
|
26
|
+
"keywords": [
|
|
27
|
+
"codex",
|
|
28
|
+
"openai",
|
|
29
|
+
"memory",
|
|
30
|
+
"mcp",
|
|
31
|
+
"hooks",
|
|
32
|
+
"memoryrouter"
|
|
33
|
+
],
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "https://github.com/MemoryRouter/MemoryRouter.git",
|
|
37
|
+
"directory": "integrations/codex"
|
|
38
|
+
}
|
|
39
|
+
}
|
package/runtime/api.cjs
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { INTEGRATION_VERSION } = require('./constants.cjs');
|
|
4
|
+
|
|
5
|
+
function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); }
|
|
6
|
+
|
|
7
|
+
async function request(method, endpoint, options = {}) {
|
|
8
|
+
const {
|
|
9
|
+
key,
|
|
10
|
+
body,
|
|
11
|
+
headers = {},
|
|
12
|
+
timeoutMs = 7000,
|
|
13
|
+
retries = method === 'GET' || endpoint.endsWith('/search') ? 2 : 0,
|
|
14
|
+
apiBase = 'https://api.memoryrouter.ai',
|
|
15
|
+
} = options;
|
|
16
|
+
let lastError;
|
|
17
|
+
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
|
18
|
+
const controller = new AbortController();
|
|
19
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
20
|
+
try {
|
|
21
|
+
const response = await fetch(`${String(apiBase).replace(/\/$/, '')}${endpoint}`, {
|
|
22
|
+
method,
|
|
23
|
+
headers: {
|
|
24
|
+
Authorization: `Bearer ${key}`,
|
|
25
|
+
'Content-Type': 'application/json',
|
|
26
|
+
'User-Agent': `memoryrouter-codex/${INTEGRATION_VERSION}`,
|
|
27
|
+
...headers,
|
|
28
|
+
},
|
|
29
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
30
|
+
signal: controller.signal,
|
|
31
|
+
});
|
|
32
|
+
let data = null;
|
|
33
|
+
try { data = await response.json(); } catch { /* non-json */ }
|
|
34
|
+
if (response.ok || response.status < 500 || attempt === retries) {
|
|
35
|
+
return { ok: response.ok, status: response.status, data };
|
|
36
|
+
}
|
|
37
|
+
lastError = new Error(`MemoryRouter returned HTTP ${response.status}`);
|
|
38
|
+
} catch (error) {
|
|
39
|
+
lastError = error;
|
|
40
|
+
if (attempt === retries) throw error;
|
|
41
|
+
} finally {
|
|
42
|
+
clearTimeout(timer);
|
|
43
|
+
}
|
|
44
|
+
await sleep(100 * (2 ** attempt));
|
|
45
|
+
}
|
|
46
|
+
throw lastError || new Error('MemoryRouter request failed');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function search(key, query, limit, options = {}) {
|
|
50
|
+
return request('POST', '/v1/memory/search', {
|
|
51
|
+
...options,
|
|
52
|
+
key,
|
|
53
|
+
body: { query, limit },
|
|
54
|
+
headers: options.sessionId ? { 'X-Session-ID': options.sessionId } : {},
|
|
55
|
+
retries: options.retries ?? 2,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function ingest(key, messages, sessionId, options = {}) {
|
|
60
|
+
return request('POST', '/v1/memory/ingest', {
|
|
61
|
+
...options,
|
|
62
|
+
key,
|
|
63
|
+
body: { messages, session_id: sessionId, model: 'codex' },
|
|
64
|
+
retries: 0,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function stats(key, options = {}) {
|
|
69
|
+
return request('GET', '/v1/memory/stats', { ...options, key, retries: options.retries ?? 1 });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function warmup(key, options = {}) {
|
|
73
|
+
return request('POST', '/v1/memory/warmup', { ...options, key, body: {}, retries: 0 });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function clearVault(key, options = {}) {
|
|
77
|
+
return request('DELETE', '/v1/memory', { ...options, key, retries: 0 });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = { sleep, request, search, ingest, stats, warmup, clearVault };
|