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,207 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const api = require('./api.cjs');
|
|
5
|
+
const { ENVELOPE_HEADER } = require('./constants.cjs');
|
|
6
|
+
const { loadConfig, getApiKey, forgetHandle, recordAudit } = require('./config.cjs');
|
|
7
|
+
const { resolveProject } = require('./project.cjs');
|
|
8
|
+
|
|
9
|
+
function normalizeContent(content) {
|
|
10
|
+
return String(content || '').replace(/\r\n/g, '\n').replace(/[ \t]+/g, ' ').replace(/\n{3,}/g, '\n\n').trim();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function memoryHandle(projectHandle, kind, content) {
|
|
14
|
+
const digest = crypto.createHash('sha256')
|
|
15
|
+
.update(`memoryrouter-codex-memory-v1\0${projectHandle}\0${kind}\0${normalizeContent(content)}`)
|
|
16
|
+
.digest('hex');
|
|
17
|
+
return `mrh_${digest.slice(0, 24)}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function scopeStamp(projectHandle, handle) {
|
|
21
|
+
return `[MR_SCOPE project=${projectHandle} handle=${handle}]`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function stampBody(content, projectHandle, handle, interval = 800) {
|
|
25
|
+
const text = normalizeContent(content);
|
|
26
|
+
if (!text) return text;
|
|
27
|
+
const stamp = scopeStamp(projectHandle, handle);
|
|
28
|
+
const parts = [];
|
|
29
|
+
for (let offset = 0; offset < text.length; offset += interval) {
|
|
30
|
+
parts.push(`${stamp}\n${text.slice(offset, offset + interval)}`);
|
|
31
|
+
}
|
|
32
|
+
return parts.join('\n');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function envelope({ projectHandle, handle, kind = 'note', content, capturedAt = new Date().toISOString(), tags = [] }) {
|
|
36
|
+
const safeTags = tags.map(value => String(value).replace(/[\n,]/g, '-')).filter(Boolean).slice(0, 20);
|
|
37
|
+
return [
|
|
38
|
+
ENVELOPE_HEADER,
|
|
39
|
+
`project: ${projectHandle}`,
|
|
40
|
+
'scope: project',
|
|
41
|
+
`handle: ${handle}`,
|
|
42
|
+
`kind: ${kind}`,
|
|
43
|
+
`captured_at: ${capturedAt}`,
|
|
44
|
+
safeTags.length ? `tags: ${safeTags.join(',')}` : null,
|
|
45
|
+
'---',
|
|
46
|
+
stampBody(content, projectHandle, handle),
|
|
47
|
+
].filter(Boolean).join('\n');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function parseEnvelope(content) {
|
|
51
|
+
const text = String(content || '');
|
|
52
|
+
const headerProject = text.match(/^project:\s*(proj_[a-f0-9]+)\s*$/m)?.[1];
|
|
53
|
+
const headerHandle = text.match(/^handle:\s*(mrh_[a-f0-9]+)\s*$/m)?.[1];
|
|
54
|
+
const stamp = text.match(/\[MR_SCOPE project=(proj_[a-f0-9]+) handle=(mrh_[a-f0-9]+)\]/);
|
|
55
|
+
const project = headerProject || stamp?.[1];
|
|
56
|
+
const handle = headerHandle || stamp?.[2];
|
|
57
|
+
if (!project || !handle) return null;
|
|
58
|
+
const kind = text.match(/^kind:\s*([^\n]+)$/m)?.[1]?.trim();
|
|
59
|
+
const separator = text.indexOf('\n---\n');
|
|
60
|
+
const rawBody = separator >= 0 ? text.slice(separator + 5) : text;
|
|
61
|
+
const body = rawBody.replace(/^\[MR_SCOPE project=proj_[a-f0-9]+ handle=mrh_[a-f0-9]+\]\n?/gm, '');
|
|
62
|
+
return { project, handle, kind, body };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function filterMemories(memories, projectHandle, config) {
|
|
66
|
+
const scope = config.retrievalScope || 'project';
|
|
67
|
+
const forgotten = new Set(config.forgottenHandles || []);
|
|
68
|
+
return (Array.isArray(memories) ? memories : []).filter(memory => {
|
|
69
|
+
const parsed = parseEnvelope(memory.content);
|
|
70
|
+
if (parsed && forgotten.has(parsed.handle)) return false;
|
|
71
|
+
if (scope === 'vault') return true;
|
|
72
|
+
return parsed?.project === projectHandle;
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function formatContext(memories, projectHandle, maxChars) {
|
|
77
|
+
const prefix = [
|
|
78
|
+
'MemoryRouter recalled the following project-scoped context from prior work.',
|
|
79
|
+
`Project handle: ${projectHandle}. Memory handles are stable references.`,
|
|
80
|
+
'Use this as recollection; current repository state and explicit user directions take precedence.',
|
|
81
|
+
].join('\n');
|
|
82
|
+
let output = prefix;
|
|
83
|
+
for (const memory of memories) {
|
|
84
|
+
const parsed = parseEnvelope(memory.content);
|
|
85
|
+
const body = parsed ? parsed.body : String(memory.content || '');
|
|
86
|
+
const handle = parsed?.handle || memory.id || 'unhandled';
|
|
87
|
+
const timestamp = memory.timestampHuman || (memory.timestamp ? new Date(memory.timestamp).toISOString() : 'unknown time');
|
|
88
|
+
const chunk = `\n\n[${handle} | ${timestamp}]\n${body.slice(0, 5000)}`;
|
|
89
|
+
if (output.length + chunk.length > maxChars) break;
|
|
90
|
+
output += chunk;
|
|
91
|
+
}
|
|
92
|
+
return output === prefix ? '' : output;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function searchScoped(query, options = {}) {
|
|
96
|
+
const config = options.config || loadConfig(options.env);
|
|
97
|
+
const key = options.key || getApiKey(options.env, config);
|
|
98
|
+
if (!key) {
|
|
99
|
+
recordAudit('search', { ok: false, reason: 'no-api-key' }, options.env);
|
|
100
|
+
return { ok: false, reason: 'no-api-key', memories: [], context: '' };
|
|
101
|
+
}
|
|
102
|
+
const project = options.project || resolveProject(options.cwd);
|
|
103
|
+
const limit = Math.max(1, Math.min(50, Number(options.limit || config.maxSearchResults || 12)));
|
|
104
|
+
const response = await api.search(
|
|
105
|
+
key,
|
|
106
|
+
`${normalizeContent(query)}\nMemoryRouter project handle ${project.handle}`,
|
|
107
|
+
limit,
|
|
108
|
+
{
|
|
109
|
+
apiBase: config.apiBase,
|
|
110
|
+
sessionId: options.sessionId,
|
|
111
|
+
timeoutMs: options.timeoutMs || 5500,
|
|
112
|
+
retries: options.retries,
|
|
113
|
+
},
|
|
114
|
+
);
|
|
115
|
+
if (!response.ok) {
|
|
116
|
+
recordAudit('search', { ok: false, status: response.status, project: project.handle }, options.env);
|
|
117
|
+
return { ok: false, status: response.status, memories: [], context: '' };
|
|
118
|
+
}
|
|
119
|
+
const memories = filterMemories(response.data?.memories, project.handle, config);
|
|
120
|
+
recordAudit('search', { ok: true, project: project.handle, candidates: response.data?.memories?.length || 0, returned: memories.length }, options.env);
|
|
121
|
+
return {
|
|
122
|
+
ok: true,
|
|
123
|
+
project,
|
|
124
|
+
memories,
|
|
125
|
+
context: formatContext(memories, project.handle, Number(config.contextChars || 12000)),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function storeScoped(content, options = {}) {
|
|
130
|
+
const config = options.config || loadConfig(options.env);
|
|
131
|
+
const key = options.key || getApiKey(options.env, config);
|
|
132
|
+
if (!key) {
|
|
133
|
+
recordAudit('store', { ok: false, reason: 'no-api-key' }, options.env);
|
|
134
|
+
return { ok: false, reason: 'no-api-key' };
|
|
135
|
+
}
|
|
136
|
+
const project = options.project || resolveProject(options.cwd);
|
|
137
|
+
const normalized = normalizeContent(content);
|
|
138
|
+
if (!normalized) return { ok: false, reason: 'empty-content' };
|
|
139
|
+
const kind = options.kind || 'note';
|
|
140
|
+
const handle = options.handle || memoryHandle(project.handle, kind, normalized);
|
|
141
|
+
|
|
142
|
+
if (!options.skipRemoteDedupe) {
|
|
143
|
+
try {
|
|
144
|
+
const duplicate = await api.search(key, handle, 5, {
|
|
145
|
+
apiBase: config.apiBase,
|
|
146
|
+
timeoutMs: options.searchTimeoutMs || 3500,
|
|
147
|
+
retries: 1,
|
|
148
|
+
});
|
|
149
|
+
const found = duplicate.data?.memories?.find(memory => parseEnvelope(memory.content)?.handle === handle);
|
|
150
|
+
if (found) {
|
|
151
|
+
recordAudit('store', { ok: true, stored: false, deduplicated: true, project: project.handle, handle, kind }, options.env);
|
|
152
|
+
return { ok: true, stored: false, deduplicated: true, handle, project };
|
|
153
|
+
}
|
|
154
|
+
} catch { /* write can proceed when dedupe lookup is offline */ }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const wrapped = envelope({
|
|
158
|
+
projectHandle: project.handle,
|
|
159
|
+
handle,
|
|
160
|
+
kind,
|
|
161
|
+
content: normalized,
|
|
162
|
+
tags: options.tags || [],
|
|
163
|
+
});
|
|
164
|
+
const sessionId = options.sessionId || `mrcodex:${project.handle}:manual`;
|
|
165
|
+
const response = await api.ingest(key, [{ role: 'user', content: wrapped }], sessionId, {
|
|
166
|
+
apiBase: config.apiBase,
|
|
167
|
+
timeoutMs: options.timeoutMs || 12000,
|
|
168
|
+
});
|
|
169
|
+
const stored = Boolean(response.ok && response.data?.accepted !== false);
|
|
170
|
+
recordAudit('store', { ok: response.ok, status: response.status, stored, deduplicated: false, project: project.handle, handle, kind }, options.env);
|
|
171
|
+
return {
|
|
172
|
+
ok: response.ok,
|
|
173
|
+
status: response.status,
|
|
174
|
+
stored,
|
|
175
|
+
deduplicated: false,
|
|
176
|
+
handle,
|
|
177
|
+
project,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function forgetLocally(handle, options = {}) {
|
|
182
|
+
if (!/^mrh_[a-f0-9]{8,64}$/.test(String(handle || ''))) {
|
|
183
|
+
return { ok: false, reason: 'invalid-handle' };
|
|
184
|
+
}
|
|
185
|
+
forgetHandle(handle, options.env);
|
|
186
|
+
recordAudit('forget-local', { ok: true, handle, deleted: false }, options.env);
|
|
187
|
+
return {
|
|
188
|
+
ok: true,
|
|
189
|
+
handle,
|
|
190
|
+
deleted: false,
|
|
191
|
+
message: 'Suppressed from MemoryRouter Codex retrieval on this machine. The server record was not deleted because the current MemoryRouter API has no per-memory delete endpoint.',
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
module.exports = {
|
|
196
|
+
normalizeContent,
|
|
197
|
+
memoryHandle,
|
|
198
|
+
scopeStamp,
|
|
199
|
+
stampBody,
|
|
200
|
+
envelope,
|
|
201
|
+
parseEnvelope,
|
|
202
|
+
filterMemories,
|
|
203
|
+
formatContext,
|
|
204
|
+
searchScoped,
|
|
205
|
+
storeScoped,
|
|
206
|
+
forgetLocally,
|
|
207
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const crypto = require('crypto');
|
|
5
|
+
const { execFileSync } = require('child_process');
|
|
6
|
+
|
|
7
|
+
function git(cwd, args) {
|
|
8
|
+
try {
|
|
9
|
+
return execFileSync('git', ['-C', cwd, ...args], {
|
|
10
|
+
encoding: 'utf8',
|
|
11
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
12
|
+
timeout: 2000,
|
|
13
|
+
}).trim();
|
|
14
|
+
} catch { return ''; }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function normalizeRemote(remote) {
|
|
18
|
+
let value = String(remote || '').trim();
|
|
19
|
+
value = value.replace(/^([^@/]+)@([^:]+):/, 'ssh://$2/');
|
|
20
|
+
try {
|
|
21
|
+
const url = new URL(value);
|
|
22
|
+
url.username = '';
|
|
23
|
+
url.password = '';
|
|
24
|
+
value = `${url.protocol}//${url.host}${url.pathname}`;
|
|
25
|
+
} 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']);
|
|
34
|
+
const canonical = remote
|
|
35
|
+
? `remote:${remote}`
|
|
36
|
+
: commonDir
|
|
37
|
+
? `git:${path.resolve(root, commonDir)}`
|
|
38
|
+
: `path:${root}`;
|
|
39
|
+
const digest = crypto.createHash('sha256')
|
|
40
|
+
.update(`memoryrouter-codex-project-v1\0${canonical}`)
|
|
41
|
+
.digest('hex');
|
|
42
|
+
return {
|
|
43
|
+
handle: `proj_${digest.slice(0, 20)}`,
|
|
44
|
+
root,
|
|
45
|
+
source: remote ? 'git-remote' : commonDir ? 'git-common-dir' : 'path',
|
|
46
|
+
portable: Boolean(remote),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = { git, normalizeRemote, resolveProject };
|