@lotargo/memory_plugin 1.6.2 → 1.6.3

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/CHANGELOG.md CHANGED
@@ -3,9 +3,36 @@
3
3
  All notable changes to `@lotargo/memory_plugin` are documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
- and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
-
8
- ## [1.6.2] - 2026-08-12
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [1.6.3] - 2026-08-13
11
+
12
+ ### Added
13
+
14
+ - `memory_plugin doctor --codex` validates the configured executable and Node.js
15
+ version, performs direct MCP `initialize` / `tools/list`, and calls both
16
+ `memory_info` and `recall(scope="all")`.
17
+ - Codex regression coverage for Windows paths with spaces, legacy config migration,
18
+ duplicate removal, standalone MCP protocol smoke testing, two-repository project
19
+ isolation, global-only recall outside Git, and prompt idempotency.
20
+
21
+ ### Fixed
22
+
23
+ - Codex setup no longer launches the memory MCP through `npx` or a Windows `.cmd`
24
+ shim. It writes absolute paths for the active Node executable and packaged
25
+ `mcp-server/boot.js` entry point after validating Node.js >= 22.5.0.
26
+ - Existing plugin-owned `[mcp_servers.memory-agent]` sections, including the legacy
27
+ `opencode-memory-plugin` entry, are migrated in place without modifying unrelated
28
+ TOML sections or creating duplicates.
29
+ - Repeated prompt setup now collapses duplicate plugin-owned blocks while preserving
30
+ user-authored AGENTS.md / CLAUDE.md content.
31
+ - README tool counts now match the implementations: 15 MCP tools, 16 native
32
+ OpenCode tools, and 17 unique tool names across both surfaces. The OpenCode tool
33
+ list and the MCP-only status of `batch_query_knowledge_base` are documented explicitly.
34
+
35
+ ## [1.6.2] - 2026-08-12
9
36
 
10
37
  ### Added
11
38
 
package/README.md CHANGED
@@ -77,9 +77,25 @@ memory_plugin setup --opencode
77
77
  # Claude Code
78
78
  memory_plugin setup --claude
79
79
 
80
- # Codex
81
- memory_plugin setup --codex
82
- ```
80
+ # Codex
81
+ memory_plugin setup --codex
82
+ ```
83
+
84
+ Codex is configured with a direct executable chain (`node` → `mcp-server/boot.js`),
85
+ not `npx`. This avoids Windows stdio handshake failures caused by `.cmd` launchers.
86
+ Running setup again safely migrates legacy `npx` entries and preserves unrelated
87
+ sections in `~/.codex/config.toml`.
88
+
89
+ To verify registration, the Node runtime, MCP initialization, tool discovery, and
90
+ real `memory_info` / `recall(scope="all")` calls:
91
+
92
+ ```bash
93
+ memory_plugin doctor --codex
94
+ ```
95
+
96
+ The doctor checks the server process itself. If it passes but a fresh Codex Desktop
97
+ task still does not expose the tools, the remaining fault is in Desktop tool
98
+ exposure rather than the memory MCP launcher.
83
99
 
84
100
  `setup` also accepts `--gemini` (alias for Antigravity) and `--local` (registers the MCP server in the project-local `.agents/` directory). Without a specific flag, all detected environments are configured.
85
101
 
@@ -107,7 +123,7 @@ memory_plugin setup --mode only-cloud
107
123
  - **Project Identity**: Project stores are bound to a **Git-based project identity** — the normalized remote URL (`git:github.com/owner/repo`) or `git:local:<repo basename>` — never to a directory path. Memories follow the repository across machines, OSes, and subdirectories. Legacy path/basename stores can be linked and merged via `link_project_memory`.
108
124
 
109
125
  2. **Layer 2: RAG Knowledge Base (Technical Documents & Codebases)**
110
- - **Tools**: `ingest_document`, `query_knowledge_base`, `manage_knowledge_base`, `reindex_knowledge_base`
126
+ - **Tools**: `ingest_document`, `query_knowledge_base`, `batch_query_knowledge_base`, `manage_knowledge_base`, `reindex_knowledge_base`
111
127
  - **Capabilities**: Ingests raw text files, Markdown, HTML, Web URLs, office documents (PDF, DOCX, XLSX, CSV), and codebases.
112
128
  - **Engine Components**: 3-tier hierarchy chunking (Big / Medium / Small), SQLite FTS5 BM25 search, ONNX dense vector embeddings (`multilingual-e5-small`), Reciprocal Rank Fusion (RRF / RSF), cross-encoder reranking (optional), and GraphRAG Lite code symbol extraction.
113
129
 
@@ -147,11 +163,22 @@ Cloud authentication tokens are never written to `config.json`. They are stored
147
163
 
148
164
  ---
149
165
 
150
- ## Available MCP Tools
151
-
152
- The MCP server registers **14 MCP tools** accessible across all connected AI environments, plus **2 OpenCode-plugin helper tools** available only inside OpenCode:
153
-
154
- ### 1. Memory Notebook Tools (Layer 1)
166
+ ## Available Tools
167
+
168
+ The package currently exposes **17 unique tool names** across its two integration surfaces:
169
+
170
+ | Integration surface | Tool count | Composition |
171
+ | :------------------ | ---------: | :---------- |
172
+ | **MCP server** (Codex, Claude Code, Antigravity / Gemini CLI and other MCP clients) | **15** | 6 Notebook + 4 identity/graph + 5 RAG tools |
173
+ | **Native OpenCode plugin** | **16** | 14 shared memory/identity/RAG tools + 2 OpenCode-only helpers |
174
+
175
+ The two surfaces are intentionally counted separately. The OpenCode plugin adds
176
+ `list-mcp-tools` and `mcp-reminder`, but currently does not register the MCP-only
177
+ `batch_query_knowledge_base` tool.
178
+
179
+ ### MCP Server Tools (15)
180
+
181
+ #### 1. Memory Notebook Tools (Layer 1)
155
182
 
156
183
  | Tool | Scope / Target | Key Parameters | Description |
157
184
  | :--- | :------------- | :------------- | :---------- |
@@ -162,7 +189,7 @@ The MCP server registers **14 MCP tools** accessible across all connected AI env
162
189
  | `forget` | `project` / `global` | `id` / `range` / `query`, `scope`, `force` | Remove a fact by index number, ID, range (e.g. `"3-30"`), or query. Requires `force: true` for protected (`[KEEP]`) facts. |
163
190
  | `memory_info` | - | - | Show storage paths, fact counts, RAG statistics, git identity bindings, and package version. |
164
191
 
165
- ### 2. Project Identity Tools
192
+ #### 2. Project Identity Tools
166
193
 
167
194
  | Tool | Key Parameters | Description |
168
195
  | :--- | :------------- | :---------- |
@@ -170,7 +197,7 @@ The MCP server registers **14 MCP tools** accessible across all connected AI env
170
197
  | `unlink_project_memory` | `directory`, `purge` | Remove a path alias binding for a directory. Optionally purge the project identity entry if `purge: true`. |
171
198
  | `relink_project_memory` | `directory`, `remote` | Switch a project's primary identity to a new remote URL and merge all stored facts into the target store with fact-text deduplication. |
172
199
 
173
- ### 3. RAG Knowledge Base & Graph Tools (Layers 2 & 3)
200
+ #### 3. RAG Knowledge Base & Graph Tools (Layers 2 & 3)
174
201
 
175
202
  | Tool | Key Parameters | Description |
176
203
  | :--- | :------------- | :---------- |
@@ -181,12 +208,21 @@ The MCP server registers **14 MCP tools** accessible across all connected AI env
181
208
  | `reindex_knowledge_base` | `model`, `dimension` | Re-embed all stored vectors with the active (or specified) embedding model and vector dimension. Use after switching the embedding model or vector dimension so previously indexed documents remain retrievable. Preserves documents, FTS index, graph edges, and fact links. |
182
209
  | `link_knowledge` | `action`, `factText`, `docId`, `scope`, `startLine`, `endLine`, `relationType` | Create, list, or retrieve semantic graph links connecting Notebook facts to Knowledge Base documents, sections, or line ranges. Actions: `link`, `list_links`, `get_doc_links`. |
183
210
 
184
- ### 4. Agent & OpenCode Helpers (OpenCode plugin only, not exposed by the MCP server)
185
-
186
- | Tool | Key Parameters | Description |
187
- | :--- | :------------- | :---------- |
188
- | `list-mcp-tools` | - | Discover all connected MCP servers and their available tool definitions. |
189
- | `mcp-reminder` | `task` | Recommends the appropriate MCP tool or server for a specific developer task. |
211
+ ### Native OpenCode Plugin Tools (16)
212
+
213
+ | Group | Count | Tools |
214
+ | :---- | ----: | :---- |
215
+ | **Memory Notebook** | 6 | `remember`, `recall`, `get_fact`, `forget`, `update_fact`, `memory_info` |
216
+ | **Project Identity & Knowledge Graph** | 4 | `link_knowledge`, `link_project_memory`, `unlink_project_memory`, `relink_project_memory` |
217
+ | **RAG Knowledge Base** | 4 | `ingest_document`, `query_knowledge_base`, `manage_knowledge_base`, `reindex_knowledge_base` |
218
+ | **OpenCode-only helpers** | 2 | `list-mcp-tools`, `mcp-reminder` |
219
+
220
+ The OpenCode-only helpers have the following purpose:
221
+
222
+ | Tool | Key Parameters | Description |
223
+ | :--- | :------------- | :---------- |
224
+ | `list-mcp-tools` | - | Discover connected MCP servers and their intended use cases. |
225
+ | `mcp-reminder` | `task` | Recommend the appropriate MCP tool or server for a developer task. |
190
226
 
191
227
  ---
192
228
 
@@ -216,7 +252,8 @@ Both binaries accept the same commands. `memory_plugin` with **no** command star
216
252
  | **`identity`** | `--dir <path>` | Inspects Git project identity key, primary remote, name, and toplevel path for a directory. |
217
253
  | **`migrate_titles`** | `--key <key>` | Auto-generates `**Title**` prefixes for legacy facts without titles. |
218
254
  | **`enable-prompt`** | - | Injects memory agent instructions into client agent files (`AGENTS.md`, `CLAUDE.md`). |
219
- | **`disable-prompt`** | - | Removes memory agent instructions from client agent files. |
255
+ | **`disable-prompt`** | - | Removes memory agent instructions from client agent files. |
256
+ | **`doctor`** | `--codex` | Validates Codex config, direct Node runtime, MCP initialize/tools/list, and live `memory_info` + `recall` calls. |
220
257
  | **`login`** | `--api-token`, `--from-env`, `--db-url <URL> --db-token`, `$TURSO_API_TOKEN`, `$TURSO_DB_TOKEN` | Authenticates with Turso Cloud via API token, direct DB token, or environment variables. Token values are read from the environment or a hidden stdin prompt. |
221
258
  | **`logout`** | `--api-key` | Signs out of Turso Cloud or removes stored API key while retaining DB session. |
222
259
  | **`auth-status`** | - | Displays authentication source, endpoint URL, username, organization, database, and sync mode. |
@@ -295,7 +332,7 @@ During `ingest_document`, code symbols are extracted from code blocks using fast
295
332
  | Platform | Status | Configuration Mechanism |
296
333
  | :--- | :--- | :--- |
297
334
  | **Antigravity / Gemini CLI** | Supported | MCP Server (`~/.gemini/config/mcp_config.json` & `.agents/mcp_config.json`) |
298
- | **OpenCode** | Native | Native plugin + MCP Server (`~/.config/opencode/opencode.json`) |
335
+ | **OpenCode** | Native | Native plugin with 16 tools (`~/.config/opencode/opencode.json`) |
299
336
  | **Claude Code** | Supported | MCP Server (`~/.claude.json`) |
300
337
  | **Codex** | Supported | MCP Server (`~/.codex/config.toml`) |
301
338
  | **Google Jules** | Supported | MCP Server via global install + setup (`memory_plugin setup`) |
@@ -339,7 +376,7 @@ The engine is configured through `<memory-dir>/config.json` (created with defaul
339
376
  To run the automated test suite and benchmarks locally, from the repository root:
340
377
 
341
378
  ```bash
342
- # Unit + integration + cloud suites (12 files) — fast and fully offline
379
+ # Unit + integration + cloud suites (16 files) — fast and fully offline
343
380
  npm test
344
381
 
345
382
  # End-to-end smoke test with REAL ONNX embeddings — run before a release
@@ -11,7 +11,16 @@ import {
11
11
  } from "../memory.js";
12
12
  import { factBody } from "../fact_format.js";
13
13
 
14
- export async function handleDirectCommands(cliArgs) {
14
+ export async function handleDirectCommands(cliArgs) {
15
+ if (cliArgs[0] === "doctor" && cliArgs.includes("--codex")) {
16
+ const { runCodexDoctor } = await import("../codex_diagnostics.js");
17
+ console.log("\nCodex memory-agent diagnostics\n");
18
+ const result = await runCodexDoctor();
19
+ console.log("");
20
+ if (!result.ok) process.exitCode = 1;
21
+ return true;
22
+ }
23
+
15
24
  if (cliArgs[0] === "link") {
16
25
  const dirIdx = cliArgs.indexOf("--dir");
17
26
  const dir = dirIdx >= 0 && cliArgs[dirIdx + 1] ? cliArgs[dirIdx + 1] : process.cwd();
package/mcp-server/cli.js CHANGED
@@ -23,7 +23,8 @@ Usage:
23
23
  memory-cli auth-status
24
24
  memory-cli link|unlink|relink|identity [--dir <path>] [--remote <url>]
25
25
  memory-cli migrate_titles [--key <key>]
26
- memory-cli enable-prompt | disable-prompt
26
+ memory-cli enable-prompt | disable-prompt
27
+ memory-cli doctor --codex
27
28
 
28
29
  Options:
29
30
  -h, --help Show this help text`);
@@ -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
+ }
@@ -36,7 +36,8 @@ const CLI_COMMANDS = new Set([
36
36
  "identity",
37
37
  "migrate_titles",
38
38
  "enable-prompt",
39
- "disable-prompt",
39
+ "disable-prompt",
40
+ "doctor",
40
41
  ]);
41
42
 
42
43
  function printUsage() {
@@ -51,7 +52,8 @@ Usage:
51
52
  memory_plugin auth-status
52
53
  memory_plugin link|unlink|relink|identity [--dir <path>] [--remote <url>]
53
54
  memory_plugin migrate_titles [--key <key>]
54
- memory_plugin enable-prompt | disable-prompt
55
+ memory_plugin enable-prompt | disable-prompt
56
+ memory_plugin doctor --codex
55
57
 
56
58
  Options:
57
59
  -h, --help Show this help text
@@ -99,17 +99,21 @@ function buildIncludeBlock(promptFile) {
99
99
  ${END_MARKER}`;
100
100
  }
101
101
 
102
- function stripPromptBlock(content) {
103
- const startIndex = content.indexOf(START_MARKER);
104
- const endIndex = content.indexOf(END_MARKER);
105
-
106
- if (startIndex !== -1 && endIndex !== -1 && endIndex > startIndex) {
107
- const before = content.substring(0, startIndex);
108
- const after = content.substring(endIndex + END_MARKER.length);
109
- return (before + after).replace(/\n{3,}/g, "\n\n").trim();
110
- }
111
- return content.trim();
112
- }
102
+ export function stripPromptBlock(content) {
103
+ let clean = String(content || "");
104
+ while (true) {
105
+ const startIndex = clean.indexOf(START_MARKER);
106
+ const endIndex = clean.indexOf(END_MARKER, startIndex + START_MARKER.length);
107
+ if (startIndex === -1 || endIndex === -1 || endIndex <= startIndex) break;
108
+ clean = clean.substring(0, startIndex) + clean.substring(endIndex + END_MARKER.length);
109
+ }
110
+ return clean.replace(/\r?\n(?:[ \t]*\r?\n){2,}/g, "\n\n").trim();
111
+ }
112
+
113
+ export function upsertPromptBlock(content, block = PROMPT_BLOCK) {
114
+ const clean = stripPromptBlock(content);
115
+ return clean ? `${clean}\n\n${block}\n` : `${block}\n`;
116
+ }
113
117
 
114
118
  export async function enableGlobalPrompt() {
115
119
  const promptFile = await syncPromptFile();
@@ -126,12 +130,10 @@ export async function enableGlobalPrompt() {
126
130
 
127
131
  const existed = existsSync(target.filePath);
128
132
  const existing = existed ? await readFile(target.filePath, "utf-8") : "";
129
- const clean = stripPromptBlock(existing);
130
-
131
- const block = target.includeSupported
132
- ? buildIncludeBlock(promptFile)
133
- : PROMPT_BLOCK;
134
- const updated = clean ? `${clean}\n\n${block}\n` : `${block}\n`;
133
+ const block = target.includeSupported
134
+ ? buildIncludeBlock(promptFile)
135
+ : PROMPT_BLOCK;
136
+ const updated = upsertPromptBlock(existing, block);
135
137
 
136
138
  const key = target.filePath;
137
139
  const prev = state[key];
@@ -187,27 +187,43 @@ export async function runSetup() {
187
187
  }
188
188
  }
189
189
 
190
- // 4. Codex (~/.codex/config.toml)
191
- if (doCodex) {
192
- try {
193
- const codexDir = join(home, ".codex");
194
- const codexConfig = join(codexDir, "config.toml");
195
- if (existsSync(codexDir)) {
196
- let content = existsSync(codexConfig) ? await readFile(codexConfig, "utf-8") : "";
197
- if (!content.includes("memory-agent")) {
198
- const tomlSnippet = `\n[mcp_servers.memory-agent]\ncommand = "npx"\nargs = ["-y", "@lotargo/memory_plugin"]\n`;
199
- content += tomlSnippet;
200
- await writeFile(codexConfig, content);
201
- console.log(" [OK] Codex: added mcp_servers.memory-agent to ~/.codex/config.toml");
202
- configuredCount++;
203
- } else {
204
- console.log(" [INFO] Codex: already configured");
205
- }
206
- }
207
- } catch (err) {
208
- console.log(" [SKIP] Codex setup skipped:", err.message);
209
- }
210
- }
190
+ // 4. Codex (~/.codex/config.toml)
191
+ if (doCodex) {
192
+ try {
193
+ const {
194
+ updateCodexMemoryAgentConfig,
195
+ validateCodexRuntime,
196
+ } = await import("./codex_config.js");
197
+ const codexDir = join(home, ".codex");
198
+ const codexConfig = join(codexDir, "config.toml");
199
+ const nodePath = process.execPath;
200
+ const bootPath = fileURLToPath(new URL("./boot.js", import.meta.url));
201
+ const runtime = validateCodexRuntime({ nodePath, nodeVersion: process.versions.node, bootPath });
202
+ if (!runtime.ok) {
203
+ throw new Error(`Codex direct launcher validation failed: ${runtime.errors.join("; ")}`);
204
+ }
205
+
206
+ await mkdir(codexDir, { recursive: true });
207
+ const content = existsSync(codexConfig) ? await readFile(codexConfig, "utf-8") : "";
208
+ const update = updateCodexMemoryAgentConfig(content, { nodePath, bootPath });
209
+ if (update.status === "conflict") {
210
+ throw new Error(update.reason);
211
+ }
212
+ if (update.changed) {
213
+ await writeFile(codexConfig, update.content, "utf-8");
214
+ console.log(
215
+ update.status === "added"
216
+ ? " [OK] Codex: added direct Node.js memory-agent launcher to ~/.codex/config.toml"
217
+ : " [OK] Codex: migrated memory-agent to a direct Node.js launcher in ~/.codex/config.toml"
218
+ );
219
+ configuredCount++;
220
+ } else {
221
+ console.log(" [INFO] Codex: direct Node.js memory-agent launcher already configured");
222
+ }
223
+ } catch (err) {
224
+ console.log(" [FAIL] Codex setup failed:", err.message);
225
+ }
226
+ }
211
227
 
212
228
  // 5. Global Prompt Instructions (Antigravity, Codex, Claude Code)
213
229
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotargo/memory_plugin",
3
- "version": "1.6.2",
3
+ "version": "1.6.3",
4
4
  "description": "100% local hybrid RAG memory for AI coding agents (OpenCode, Claude Code, Codex, Antigravity). MCP server + plugin: persistent user facts, document ingestion, vector + SQLite FTS5 retrieval across sessions.",
5
5
  "type": "module",
6
6
  "main": "opencode-plugin/index.js",
@@ -33,8 +33,10 @@
33
33
  "mcp-server/tools",
34
34
  "mcp-server/boot.js",
35
35
  "mcp-server/cli_boot.js",
36
- "mcp-server/cli.js",
37
- "mcp-server/index.js",
36
+ "mcp-server/cli.js",
37
+ "mcp-server/codex_config.js",
38
+ "mcp-server/codex_diagnostics.js",
39
+ "mcp-server/index.js",
38
40
  "mcp-server/fact_format.js",
39
41
  "mcp-server/identity.js",
40
42
  "mcp-server/logger.js",