@lotargo/memory_plugin 1.6.2 → 1.6.4

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.
@@ -0,0 +1,171 @@
1
+ import { existsSync } from "node:fs";
2
+
3
+ export const MIN_NODE_VERSION = Object.freeze({ major: 22, minor: 5, patch: 0 });
4
+ export const DEFAULT_CODEX_STARTUP_TIMEOUT_SEC = 60;
5
+
6
+ function parseVersion(version) {
7
+ const match = String(version || "").trim().replace(/^v/i, "").match(/^(\d+)\.(\d+)(?:\.(\d+))?/);
8
+ if (!match) return null;
9
+ return {
10
+ major: Number(match[1]),
11
+ minor: Number(match[2]),
12
+ patch: Number(match[3] || 0),
13
+ };
14
+ }
15
+
16
+ export function isSupportedNodeVersion(version) {
17
+ const parsed = parseVersion(version);
18
+ if (!parsed) return false;
19
+ if (parsed.major !== MIN_NODE_VERSION.major) return parsed.major > MIN_NODE_VERSION.major;
20
+ if (parsed.minor !== MIN_NODE_VERSION.minor) return parsed.minor > MIN_NODE_VERSION.minor;
21
+ return parsed.patch >= MIN_NODE_VERSION.patch;
22
+ }
23
+
24
+ export function validateCodexRuntime({
25
+ nodePath,
26
+ nodeVersion = process.versions.node,
27
+ bootPath,
28
+ pathExists = existsSync,
29
+ } = {}) {
30
+ const errors = [];
31
+ if (!nodePath || !pathExists(nodePath)) {
32
+ errors.push(`Node executable not found: ${nodePath || "<empty>"}`);
33
+ }
34
+ if (!isSupportedNodeVersion(nodeVersion)) {
35
+ errors.push(
36
+ `@lotargo/memory_plugin requires Node.js >= ${MIN_NODE_VERSION.major}.${MIN_NODE_VERSION.minor}.0; detected ${nodeVersion || "unknown"}`
37
+ );
38
+ }
39
+ if (!bootPath || !pathExists(bootPath)) {
40
+ errors.push(`MCP boot entry point not found: ${bootPath || "<empty>"}`);
41
+ }
42
+ return { ok: errors.length === 0, errors, nodePath, nodeVersion, bootPath };
43
+ }
44
+
45
+ export function escapeTomlBasicString(value) {
46
+ return String(value)
47
+ .replace(/\\/g, "\\\\")
48
+ .replace(/"/g, '\\"')
49
+ .replace(/\u0008/g, "\\b")
50
+ .replace(/\t/g, "\\t")
51
+ .replace(/\n/g, "\\n")
52
+ .replace(/\f/g, "\\f")
53
+ .replace(/\r/g, "\\r");
54
+ }
55
+
56
+ export function buildCodexMemoryAgentSection({
57
+ nodePath,
58
+ bootPath,
59
+ startupTimeoutSec = DEFAULT_CODEX_STARTUP_TIMEOUT_SEC,
60
+ } = {}) {
61
+ if (!nodePath || !bootPath) throw new Error("nodePath and bootPath are required");
62
+ if (!Number.isInteger(startupTimeoutSec) || startupTimeoutSec <= 0) {
63
+ throw new Error("startupTimeoutSec must be a positive integer");
64
+ }
65
+ return [
66
+ "[mcp_servers.memory-agent]",
67
+ `command = "${escapeTomlBasicString(nodePath)}"`,
68
+ `args = ["${escapeTomlBasicString(bootPath)}"]`,
69
+ `startup_timeout_sec = ${startupTimeoutSec}`,
70
+ ].join("\n");
71
+ }
72
+
73
+ function tableHeaderName(line) {
74
+ const match = String(line).match(/^\s*\[\s*([^\[\]]+?)\s*\]\s*(?:#.*)?$/);
75
+ return match ? match[1] : null;
76
+ }
77
+
78
+ function isMemoryAgentHeader(name, { exact = false } = {}) {
79
+ if (!name) return false;
80
+ const suffix = exact ? "$" : "(?:\\s*\\.|$)";
81
+ return new RegExp(
82
+ `^mcp_servers\\s*\\.\\s*(?:memory-agent|"memory-agent"|'memory-agent')${suffix}`,
83
+ "i"
84
+ ).test(name);
85
+ }
86
+
87
+ export function isMemoryPluginOwnedSection(sectionText) {
88
+ return /@lotargo[\\/]memory_plugin|opencode-memory-plugin|(?:^|[\\/])memory_plugin(?:[\\/]|\b)|mcp-server[\\/]+boot\.js/i.test(
89
+ String(sectionText || "")
90
+ );
91
+ }
92
+
93
+ function sectionRanges(lines) {
94
+ const headers = [];
95
+ for (let i = 0; i < lines.length; i++) {
96
+ const name = tableHeaderName(lines[i]);
97
+ if (name) headers.push({ start: i, name });
98
+ }
99
+ return headers.map((header, index) => ({
100
+ ...header,
101
+ end: index + 1 < headers.length ? headers[index + 1].start : lines.length,
102
+ }));
103
+ }
104
+
105
+ export function getCodexMemoryAgentSections(content) {
106
+ const lines = String(content || "").split(/\r?\n/);
107
+ return sectionRanges(lines)
108
+ .filter((range) => isMemoryAgentHeader(range.name))
109
+ .map((range) => ({
110
+ ...range,
111
+ exact: isMemoryAgentHeader(range.name, { exact: true }),
112
+ text: lines.slice(range.start, range.end).join("\n").trimEnd(),
113
+ }));
114
+ }
115
+
116
+ export function updateCodexMemoryAgentConfig(content, options) {
117
+ const source = String(content || "");
118
+ const eol = source.includes("\r\n") ? "\r\n" : "\n";
119
+ const lines = source.split(/\r?\n/);
120
+ const desired = buildCodexMemoryAgentSection(options).split("\n");
121
+ const ranges = sectionRanges(lines);
122
+ const targets = ranges.filter((range) => isMemoryAgentHeader(range.name));
123
+ const exactTargets = targets.filter((range) => isMemoryAgentHeader(range.name, { exact: true }));
124
+
125
+ const unowned = exactTargets.filter((range) => {
126
+ const text = lines.slice(range.start, range.end).join("\n");
127
+ return !isMemoryPluginOwnedSection(text);
128
+ });
129
+ if (unowned.length > 0) {
130
+ return {
131
+ content: source,
132
+ changed: false,
133
+ status: "conflict",
134
+ reason: "Existing [mcp_servers.memory-agent] section is not recognized as owned by @lotargo/memory_plugin",
135
+ };
136
+ }
137
+
138
+ if (targets.length === 0) {
139
+ const prefix = source.length === 0 ? "" : source.replace(/[\r\n]+$/, "") + eol + eol;
140
+ return { content: prefix + desired.join(eol) + eol, changed: true, status: "added" };
141
+ }
142
+
143
+ const targetStarts = new Map(targets.map((target) => [target.start, target]));
144
+ const targetLineIndexes = new Set();
145
+ for (const target of targets) {
146
+ for (let i = target.start; i < target.end; i++) targetLineIndexes.add(i);
147
+ }
148
+
149
+ const firstStart = Math.min(...targets.map((target) => target.start));
150
+ const result = [];
151
+ for (let i = 0; i < lines.length; i++) {
152
+ if (i === firstStart) {
153
+ while (result.length > 0 && result[result.length - 1] === "") result.pop();
154
+ if (result.length > 0) result.push("");
155
+ result.push(...desired, "");
156
+ }
157
+ if (targetLineIndexes.has(i)) continue;
158
+ if (targetStarts.has(i)) continue;
159
+ result.push(lines[i]);
160
+ }
161
+
162
+ while (result.length > 1 && result[result.length - 1] === "" && result[result.length - 2] === "") {
163
+ result.pop();
164
+ }
165
+ const updated = result.join(eol);
166
+ return {
167
+ content: updated,
168
+ changed: updated !== source,
169
+ status: updated === source ? "unchanged" : "migrated",
170
+ };
171
+ }
@@ -0,0 +1,263 @@
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { homedir } from "node:os";
5
+ import { getCodexMemoryAgentSections, isSupportedNodeVersion } from "./codex_config.js";
6
+
7
+ function decodeTomlString(raw) {
8
+ const value = String(raw || "").trim();
9
+ if (value.startsWith('"')) {
10
+ try {
11
+ return JSON.parse(value);
12
+ } catch {
13
+ return null;
14
+ }
15
+ }
16
+ if (value.startsWith("'") && value.endsWith("'")) return value.slice(1, -1).replace(/''/g, "'");
17
+ return null;
18
+ }
19
+
20
+ export function parseCodexMemoryAgentConfig(content) {
21
+ const exact = getCodexMemoryAgentSections(content).filter((section) => section.exact);
22
+ if (exact.length !== 1) {
23
+ return {
24
+ ok: false,
25
+ error: exact.length === 0
26
+ ? "[mcp_servers.memory-agent] section not found"
27
+ : `Found ${exact.length} memory-agent sections`,
28
+ sectionCount: exact.length,
29
+ };
30
+ }
31
+
32
+ const text = exact[0].text;
33
+ const commandMatch = text.match(/^\s*command\s*=\s*(.+?)\s*$/m);
34
+ const argsMatch = text.match(/^\s*args\s*=\s*\[([\s\S]*?)\]\s*$/m);
35
+ const timeoutMatch = text.match(/^\s*startup_timeout_sec\s*=\s*(\d+)\s*$/m);
36
+ const command = decodeTomlString(commandMatch?.[1]);
37
+ const args = [];
38
+ if (argsMatch) {
39
+ const stringPattern = /"(?:\\.|[^"\\])*"|'(?:''|[^'])*'/g;
40
+ for (const match of argsMatch[1].matchAll(stringPattern)) {
41
+ const decoded = decodeTomlString(match[0]);
42
+ if (decoded === null) return { ok: false, error: "Unable to parse memory-agent args", sectionCount: 1 };
43
+ args.push(decoded);
44
+ }
45
+ }
46
+
47
+ if (!command) return { ok: false, error: "Unable to parse memory-agent command", sectionCount: 1 };
48
+ if (!argsMatch) return { ok: false, error: "Unable to parse memory-agent args", sectionCount: 1 };
49
+ return {
50
+ ok: true,
51
+ command,
52
+ args,
53
+ startupTimeoutSec: timeoutMatch ? Number(timeoutMatch[1]) : null,
54
+ sectionCount: 1,
55
+ text,
56
+ };
57
+ }
58
+
59
+ function toolText(result) {
60
+ return (result?.content || [])
61
+ .filter((item) => item.type === "text")
62
+ .map((item) => item.text)
63
+ .join("\n");
64
+ }
65
+
66
+ export async function runDirectMcpSmoke({
67
+ command,
68
+ args = [],
69
+ cwd = process.cwd(),
70
+ env = process.env,
71
+ timeoutMs = 30_000,
72
+ } = {}) {
73
+ if (!command) throw new Error("MCP command is required");
74
+ const child = spawn(command, args, { cwd, env: { ...env }, stdio: ["pipe", "pipe", "pipe"] });
75
+ child.stdout.setEncoding("utf8");
76
+ child.stderr.setEncoding("utf8");
77
+
78
+ let nextId = 0;
79
+ let stdoutBuffer = "";
80
+ let stderr = "";
81
+ let settled = false;
82
+ const pending = new Map();
83
+
84
+ const rejectAll = (error) => {
85
+ for (const { reject } of pending.values()) reject(error);
86
+ pending.clear();
87
+ };
88
+ child.stderr.on("data", (chunk) => { stderr += chunk; });
89
+ child.stdout.on("data", (chunk) => {
90
+ stdoutBuffer += chunk;
91
+ let newline;
92
+ while ((newline = stdoutBuffer.indexOf("\n")) >= 0) {
93
+ const line = stdoutBuffer.slice(0, newline).trim();
94
+ stdoutBuffer = stdoutBuffer.slice(newline + 1);
95
+ if (!line) continue;
96
+ let message;
97
+ try {
98
+ message = JSON.parse(line);
99
+ } catch (error) {
100
+ rejectAll(new Error(`Invalid JSON-RPC output: ${error.message}; line=${line.slice(0, 200)}`));
101
+ continue;
102
+ }
103
+ const waiter = pending.get(message.id);
104
+ if (!waiter) continue;
105
+ pending.delete(message.id);
106
+ if (message.error) waiter.reject(new Error(message.error.message || JSON.stringify(message.error)));
107
+ else waiter.resolve(message.result);
108
+ }
109
+ });
110
+ child.on("error", (error) => rejectAll(error));
111
+ child.on("exit", (code, signal) => {
112
+ if (!settled && pending.size > 0) {
113
+ rejectAll(new Error(`MCP server exited before completing diagnostics (code=${code}, signal=${signal}): ${stderr.trim()}`));
114
+ }
115
+ });
116
+
117
+ const request = (method, params = {}) => {
118
+ const id = ++nextId;
119
+ return new Promise((resolve, reject) => {
120
+ pending.set(id, { resolve, reject });
121
+ child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
122
+ });
123
+ };
124
+ const withTimeout = (promise, label) => new Promise((resolve, reject) => {
125
+ const timer = setTimeout(() => reject(new Error(`Timed out waiting for ${label} after ${timeoutMs}ms`)), timeoutMs);
126
+ promise.then(
127
+ (value) => { clearTimeout(timer); resolve(value); },
128
+ (error) => { clearTimeout(timer); reject(error); }
129
+ );
130
+ });
131
+
132
+ try {
133
+ await withTimeout(request("initialize", {
134
+ protocolVersion: "2024-11-05",
135
+ capabilities: {},
136
+ clientInfo: { name: "memory-plugin-codex-doctor", version: "1.0.0" },
137
+ }), "initialize");
138
+ child.stdin.write(JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized", params: {} }) + "\n");
139
+
140
+ const toolsResult = await withTimeout(request("tools/list"), "tools/list");
141
+ const toolNames = (toolsResult?.tools || []).map((tool) => tool.name);
142
+ for (const required of ["memory_info", "recall"]) {
143
+ if (!toolNames.includes(required)) throw new Error(`Required MCP tool is missing: ${required}`);
144
+ }
145
+ const infoResult = await withTimeout(
146
+ request("tools/call", { name: "memory_info", arguments: {} }),
147
+ "memory_info"
148
+ );
149
+ const recallResult = await withTimeout(
150
+ request("tools/call", { name: "recall", arguments: { scope: "all" } }),
151
+ "recall(scope=all)"
152
+ );
153
+ settled = true;
154
+ return {
155
+ ok: true,
156
+ toolNames,
157
+ memoryInfo: toolText(infoResult),
158
+ recall: toolText(recallResult),
159
+ stderr: stderr.trim(),
160
+ };
161
+ } finally {
162
+ settled = true;
163
+ rejectAll(new Error("MCP diagnostics finished"));
164
+ if (child.exitCode === null) child.kill();
165
+ }
166
+ }
167
+
168
+ function countOccurrences(text, needle) {
169
+ let count = 0;
170
+ let offset = 0;
171
+ while ((offset = text.indexOf(needle, offset)) !== -1) {
172
+ count++;
173
+ offset += needle.length;
174
+ }
175
+ return count;
176
+ }
177
+
178
+ export async function runCodexDoctor({
179
+ home = homedir(),
180
+ cwd = process.cwd(),
181
+ env = process.env,
182
+ output = console,
183
+ } = {}) {
184
+ const checks = [];
185
+ const record = (level, label, detail = "") => {
186
+ checks.push({ level, label, detail });
187
+ output.log(`[${level}] ${label}${detail ? `: ${detail}` : ""}`);
188
+ };
189
+
190
+ const configPath = join(home, ".codex", "config.toml");
191
+ if (!existsSync(configPath)) {
192
+ record("FAIL", "Codex config found", configPath);
193
+ return { ok: false, checks, configPath };
194
+ }
195
+ record("OK", "Codex config found", configPath);
196
+ const config = readFileSync(configPath, "utf8");
197
+ const parsed = parseCodexMemoryAgentConfig(config);
198
+ if (!parsed.ok) {
199
+ record("FAIL", "memory-agent configuration", parsed.error);
200
+ return { ok: false, checks, configPath };
201
+ }
202
+ record("OK", "Single memory-agent section found");
203
+ record(parsed.command.toLowerCase().includes("npx") ? "FAIL" : "OK", "Direct executable launcher", parsed.command);
204
+ record(existsSync(parsed.command) ? "OK" : "FAIL", "Command executable exists", parsed.command);
205
+
206
+ const bootPath = parsed.args[0];
207
+ record(bootPath && existsSync(bootPath) ? "OK" : "FAIL", "boot.js exists", bootPath || "missing first arg");
208
+ record(parsed.args.length === 1 ? "OK" : "FAIL", "boot.js is the only launcher argument", JSON.stringify(parsed.args));
209
+
210
+ let detectedVersion = null;
211
+ try {
212
+ detectedVersion = await new Promise((resolve, reject) => {
213
+ const child = spawn(parsed.command, ["--version"], { cwd, env: { ...env }, stdio: ["ignore", "pipe", "pipe"] });
214
+ let stdout = "";
215
+ let stderr = "";
216
+ child.stdout.setEncoding("utf8");
217
+ child.stderr.setEncoding("utf8");
218
+ child.stdout.on("data", (chunk) => { stdout += chunk; });
219
+ child.stderr.on("data", (chunk) => { stderr += chunk; });
220
+ child.on("error", reject);
221
+ child.on("exit", (code) => code === 0 ? resolve(stdout.trim()) : reject(new Error(stderr.trim() || `exit ${code}`)));
222
+ });
223
+ record(isSupportedNodeVersion(detectedVersion) ? "OK" : "FAIL", "Node.js version >= 22.5.0", detectedVersion);
224
+ } catch (error) {
225
+ record("FAIL", "Node.js version check", error.message);
226
+ }
227
+
228
+ try {
229
+ const smoke = await runDirectMcpSmoke({ command: parsed.command, args: parsed.args, cwd, env });
230
+ record("OK", "MCP initialize");
231
+ record("OK", "MCP tools/list", `${smoke.toolNames.length} tools`);
232
+ record("OK", "memory_info tool call");
233
+ record("OK", "recall(scope=all) tool call");
234
+ } catch (error) {
235
+ record("FAIL", "Direct MCP protocol smoke test", error.message);
236
+ }
237
+
238
+ try {
239
+ const { resolveProjectIdentity } = await import("./identity.js");
240
+ const identity = await resolveProjectIdentity(cwd);
241
+ record("INFO", "Current project identity", identity?.key || "none (global memory only)");
242
+ } catch (error) {
243
+ record("WARN", "Current project identity", error.message);
244
+ }
245
+
246
+ const agentsPath = join(home, ".codex", "AGENTS.md");
247
+ if (existsSync(agentsPath)) {
248
+ const agents = readFileSync(agentsPath, "utf8");
249
+ const starts = countOccurrences(agents, "<!-- START MEMORY AGENT PROMPT -->");
250
+ const ends = countOccurrences(agents, "<!-- END MEMORY AGENT PROMPT -->");
251
+ record(starts === 1 && ends === 1 ? "OK" : "WARN", "Codex memory prompt block count", `start=${starts}, end=${ends}`);
252
+ } else {
253
+ record("WARN", "Codex memory prompt", `${agentsPath} not found`);
254
+ }
255
+
256
+ record(
257
+ "INFO",
258
+ "Codex Desktop exposure",
259
+ "Cannot be proven by the MCP server; start a new task after setup and verify that memory-agent tools are listed"
260
+ );
261
+ const ok = !checks.some((check) => check.level === "FAIL");
262
+ return { ok, checks, configPath, parsed, detectedVersion };
263
+ }
@@ -17,7 +17,6 @@ export const DEFAULT_CONFIG = {
17
17
  onnxThreads: 0, // ONNX WASM threads: 0 = auto-detect CPU cores, or 1-16
18
18
  executionDevice: "cpu", // "cpu" | "webgpu"
19
19
  mode: "only-local", // "only-local" | "only-cloud" | "hybrid-sync"
20
- injectLimit: 10,
21
20
  conflictStrategy: "merge", // "merge" | "cloud-wins" | "local-wins"
22
21
  tursoUrl: "", // Connection endpoint URL for Turso DB
23
22
  failoverUrl: "", // Failover connection endpoint URL (Fly.io + LiteFS)
@@ -138,8 +138,8 @@ const MIGRATIONS = [
138
138
  `);
139
139
  },
140
140
  },
141
- {
142
- version: 5,
141
+ {
142
+ version: 5,
143
143
  name: "005_retrieval_policy",
144
144
  up: async (db) => {
145
145
  try {
@@ -151,9 +151,30 @@ const MIGRATIONS = [
151
151
  await db.exec(`
152
152
  CREATE INDEX IF NOT EXISTS idx_micro_chunks_retrieval_policy ON micro_chunks(retrieval_policy);
153
153
  `);
154
- },
155
- },
156
- ];
154
+ },
155
+ },
156
+ {
157
+ version: 6,
158
+ name: "006_project_scoped_rag",
159
+ up: async (db) => {
160
+ await db.exec(`
161
+ CREATE TABLE IF NOT EXISTS document_scopes (
162
+ doc_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
163
+ scope_key TEXT NOT NULL,
164
+ created_at INTEGER NOT NULL,
165
+ PRIMARY KEY (doc_id, scope_key)
166
+ );
167
+ `);
168
+ await db.exec(`
169
+ CREATE INDEX IF NOT EXISTS idx_document_scopes_scope ON document_scopes(scope_key, doc_id);
170
+ `);
171
+ await db.exec(`
172
+ INSERT OR IGNORE INTO document_scopes (doc_id, scope_key, created_at)
173
+ SELECT id, 'global', created_at FROM documents;
174
+ `);
175
+ },
176
+ },
177
+ ];
157
178
 
158
179
  export async function runMigrations(db) {
159
180
  let currentVersion = 0;