@llmnesia/mcp 0.1.3 → 0.1.5
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/README.md +28 -17
- package/dist/{chunk-7BW63ZTP.js → chunk-3JQ7CGCP.js} +6 -1
- package/dist/{chunk-EBVGUZX2.js → chunk-YVBAQ5VA.js} +1 -1
- package/dist/{cli-JFZO44NP.js → cli-TVE63AWC.js} +220 -47
- package/dist/cli.js +104 -9
- package/dist/llmnesia-claude-host.mjs +226 -36
- package/dist/mcp.js +77 -7
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -12,22 +12,36 @@ over stdio.
|
|
|
12
12
|
|
|
13
13
|
Requires **Node.js ≥ 22.13.0** (for the built-in `node:sqlite` module). The
|
|
14
14
|
installer checks this before changing anything and prints the exact recovery
|
|
15
|
-
steps when the installed version is too old.
|
|
15
|
+
steps when the installed version is too old. If that happens in an interactive
|
|
16
|
+
terminal, it offers the official Node.js installer through an OS-specific
|
|
17
|
+
LLMnesia download link for you.
|
|
16
18
|
|
|
17
19
|
## Install
|
|
18
20
|
|
|
21
|
+
On macOS or Linux:
|
|
22
|
+
|
|
19
23
|
```bash
|
|
20
24
|
npx -y @llmnesia/mcp@latest install
|
|
21
25
|
```
|
|
22
26
|
|
|
23
|
-
|
|
27
|
+
On Windows PowerShell:
|
|
28
|
+
|
|
29
|
+
```powershell
|
|
30
|
+
npx.cmd -y @llmnesia/mcp@latest install
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Windows uses `npx.cmd` because PowerShell commonly blocks npm's `npx.ps1`
|
|
34
|
+
wrapper even when Node.js installed correctly.
|
|
24
35
|
It requires Node.js 22.13 or newer; there is no global package install and no
|
|
25
36
|
client config to edit by hand in the normal path.
|
|
26
37
|
|
|
27
|
-
If
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
38
|
+
If your installed Node.js is too old, LLMnesia offers to open the right official
|
|
39
|
+
installer; install it, reopen Terminal/PowerShell, and run the command again. If
|
|
40
|
+
`npx` is not recognised at all, Node.js is not
|
|
41
|
+
installed yet, so the command cannot start far enough to show that prompt. In
|
|
42
|
+
that case, install the current LTS release from
|
|
43
|
+
[Mac installer](https://www.llmnesia.com/download/node/macos) on macOS, or the
|
|
44
|
+
[Windows installer](https://www.llmnesia.com/download/node/windows) on Windows.
|
|
31
45
|
|
|
32
46
|
The installer:
|
|
33
47
|
|
|
@@ -63,9 +77,7 @@ The copy is self-contained (no `node_modules` beside it) and re-running
|
|
|
63
77
|
|
|
64
78
|
### Updating
|
|
65
79
|
|
|
66
|
-
|
|
67
|
-
npx -y @llmnesia/mcp@latest install
|
|
68
|
-
```
|
|
80
|
+
Run the same OS-specific install command above again.
|
|
69
81
|
|
|
70
82
|
Your corpus is untouched; only the server copy is replaced.
|
|
71
83
|
|
|
@@ -88,12 +100,13 @@ If nothing has written to the corpus yet, search results will be empty.
|
|
|
88
100
|
|
|
89
101
|
## Tools
|
|
90
102
|
|
|
91
|
-
Once registered, your MCP client gains
|
|
103
|
+
Once registered, your MCP client gains six tools over the corpus:
|
|
92
104
|
|
|
93
105
|
| Tool | Purpose |
|
|
94
106
|
| --- | --- |
|
|
95
107
|
| `search_conversations` | Full-text search across every past conversation. Returns ranked matches with highlighted snippets. Supports `platform`, `title`, `date_from`/`date_to` filters and a `match_mode` (`any` / `all` / `phrase`) for when a wrong hit matters. |
|
|
96
108
|
| `get_conversation` | Fetch the full transcript of one conversation by its id (the `id` field from search results). |
|
|
109
|
+
| `list_conversations` | List conversations by chronology, including oldest-first for questions about the user's earliest chat. |
|
|
97
110
|
| `list_recent` | List the most recently updated conversations with title, summary, and platform. |
|
|
98
111
|
| `corpus_status` | Report when the corpus last received data and how many conversations it holds. Use this when an expected conversation cannot be found, to tell a genuinely missing chat apart from a corpus that has stopped receiving updates. |
|
|
99
112
|
| `save_conversation` | Add or update a conversation in the corpus, so MCP is also an ingestion route. |
|
|
@@ -108,7 +121,7 @@ received data — check it before concluding a chat does not exist.
|
|
|
108
121
|
| Claude Code | Runs `claude mcp add --scope user` with the absolute Node and installed runtime paths, making it available in every project (detects `claude` on PATH). |
|
|
109
122
|
| Claude Desktop | Edits `claude_desktop_config.json` at the standard OS-specific path. |
|
|
110
123
|
| Cursor | Edits `~/.cursor/mcp.json`. |
|
|
111
|
-
| Codex | Runs `codex mcp add` with the absolute runtime paths.
|
|
124
|
+
| Codex | Runs `codex mcp add` with the absolute runtime paths when available. If Windows does not expose the app's bundled CLI, the installer safely edits `~/.codex/config.toml` itself and backs up existing settings first. Read-only LLMnesia tools are configured to run without repeated approval prompts; writes still ask. |
|
|
112
125
|
| Others (Windsurf, Zed, LM Studio, …) | Prints the generic stdio block for you to paste. |
|
|
113
126
|
|
|
114
127
|
**Cloud-only clients cannot reach a local stdio server** (web ChatGPT, Lovable,
|
|
@@ -117,9 +130,8 @@ close it.
|
|
|
117
130
|
|
|
118
131
|
## Diagnose an install
|
|
119
132
|
|
|
120
|
-
|
|
121
|
-
npx -y @llmnesia/mcp@latest doctor
|
|
122
|
-
```
|
|
133
|
+
Use `npx -y @llmnesia/mcp@latest doctor` on macOS/Linux, or
|
|
134
|
+
`npx.cmd -y @llmnesia/mcp@latest doctor` in Windows PowerShell.
|
|
123
135
|
|
|
124
136
|
Checks whether the native host is installed, the server runtime is present, the
|
|
125
137
|
corpus is receiving data, and each detected client is correctly wired —
|
|
@@ -132,9 +144,8 @@ reminder).
|
|
|
132
144
|
|
|
133
145
|
## Uninstall
|
|
134
146
|
|
|
135
|
-
|
|
136
|
-
npx -y @llmnesia/mcp@latest uninstall
|
|
137
|
-
```
|
|
147
|
+
Use `npx -y @llmnesia/mcp@latest uninstall` on macOS/Linux, or
|
|
148
|
+
`npx.cmd -y @llmnesia/mcp@latest uninstall` in Windows PowerShell.
|
|
138
149
|
|
|
139
150
|
Removes native-host manifests and registry keys, the copied server in
|
|
140
151
|
`~/.llmnesia/bin`, and client registrations. The shared config file and your
|
|
@@ -643,10 +643,15 @@ var SearchIndex = class {
|
|
|
643
643
|
}
|
|
644
644
|
/** Most-recently-updated conversations (metadata only), newest first. */
|
|
645
645
|
listRecent(n) {
|
|
646
|
+
return this.listConversations(n, "recent");
|
|
647
|
+
}
|
|
648
|
+
/** Conversation metadata in a caller-selected, deterministic chronology. */
|
|
649
|
+
listConversations(n, sort) {
|
|
646
650
|
const limit = Math.max(1, n);
|
|
651
|
+
const orderBy = sort === "oldest" ? "createdAt ASC, updatedAt ASC" : sort === "newest" ? "createdAt DESC, updatedAt DESC" : "updatedAt DESC, createdAt DESC";
|
|
647
652
|
const rows = this.db.prepare(
|
|
648
653
|
`SELECT docId, platform, title, url, createdAt, updatedAt, messageCount, preview, pinned
|
|
649
|
-
FROM conversations ORDER BY
|
|
654
|
+
FROM conversations ORDER BY ${orderBy} LIMIT ?`
|
|
650
655
|
).all(limit);
|
|
651
656
|
return rows.map((r) => ({ ...r, pinned: r.pinned === 1 }));
|
|
652
657
|
}
|
|
@@ -9,13 +9,13 @@ import {
|
|
|
9
9
|
ingestFile,
|
|
10
10
|
openSearchIndex,
|
|
11
11
|
resolveCorpusDir
|
|
12
|
-
} from "./chunk-
|
|
12
|
+
} from "./chunk-3JQ7CGCP.js";
|
|
13
13
|
import {
|
|
14
14
|
VERSION,
|
|
15
15
|
__commonJS,
|
|
16
16
|
__require,
|
|
17
17
|
__toESM
|
|
18
|
-
} from "./chunk-
|
|
18
|
+
} from "./chunk-YVBAQ5VA.js";
|
|
19
19
|
|
|
20
20
|
// ../node_modules/isexe/windows.js
|
|
21
21
|
var require_windows = __commonJS({
|
|
@@ -533,6 +533,7 @@ var HOST_NAME = "com.llmnesia.claude_code";
|
|
|
533
533
|
var STORE_EXTENSION_ID = "leekfgbdojiaabifbjbbgiiclannjdkf";
|
|
534
534
|
var here = dirname(fileURLToPath(import.meta.url));
|
|
535
535
|
var home = homedir();
|
|
536
|
+
var CLAUDE_PROJECTS_DIR = join(home, ".claude", "projects");
|
|
536
537
|
var osPlatform = process.env.LLMNESIA_FAKE_PLATFORM || platform();
|
|
537
538
|
function expandHome(p) {
|
|
538
539
|
if (p === "~") {
|
|
@@ -547,6 +548,11 @@ function resolveCorpusDir2(explicit) {
|
|
|
547
548
|
const raw = explicit ?? process.env.LLMNESIA_CORPUS_DIR ?? "~/.llmnesia/corpus";
|
|
548
549
|
return expandHome(raw);
|
|
549
550
|
}
|
|
551
|
+
function resolveCodexHome(explicit) {
|
|
552
|
+
const candidates = [explicit, process.env.CODEX_HOME, "~/.codex"];
|
|
553
|
+
const raw = candidates.find((value) => typeof value === "string" && value.trim() !== "");
|
|
554
|
+
return expandHome(raw.trim());
|
|
555
|
+
}
|
|
550
556
|
async function exists(path) {
|
|
551
557
|
try {
|
|
552
558
|
await stat(path);
|
|
@@ -573,21 +579,24 @@ async function readExistingConfig(configPath) {
|
|
|
573
579
|
if (err && typeof err === "object" && err.code === "ENOENT") {
|
|
574
580
|
return null;
|
|
575
581
|
}
|
|
576
|
-
throw new Error(`
|
|
582
|
+
throw new Error(`LLMnesia couldn't read its settings at ${configPath}: ${err instanceof Error ? err.message : String(err)}. Nothing was changed.`);
|
|
577
583
|
}
|
|
578
584
|
let parsed;
|
|
579
585
|
try {
|
|
580
586
|
parsed = JSON.parse(raw);
|
|
581
587
|
} catch (err) {
|
|
582
588
|
throw new Error(
|
|
583
|
-
`
|
|
589
|
+
`LLMnesia found invalid JSON in its settings at ${configPath}: ${err instanceof Error ? err.message : String(err)}. Nothing was changed; repair or move that file aside, then retry.`
|
|
584
590
|
);
|
|
585
591
|
}
|
|
586
592
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
587
|
-
throw new Error(`
|
|
593
|
+
throw new Error(`LLMnesia settings at ${configPath} must contain a JSON object. Nothing was changed.`);
|
|
588
594
|
}
|
|
589
595
|
if ("corpusRoot" in parsed && typeof parsed.corpusRoot !== "string") {
|
|
590
|
-
throw new Error(`
|
|
596
|
+
throw new Error(`LLMnesia settings at ${configPath} contain an invalid conversation-data folder. Nothing was changed.`);
|
|
597
|
+
}
|
|
598
|
+
if ("codexHome" in parsed && typeof parsed.codexHome !== "string") {
|
|
599
|
+
throw new Error(`LLMnesia settings at ${configPath} contain an invalid Codex data folder. Nothing was changed.`);
|
|
591
600
|
}
|
|
592
601
|
return parsed;
|
|
593
602
|
}
|
|
@@ -620,7 +629,7 @@ async function installPosix(manifestBase, hostPath, log) {
|
|
|
620
629
|
await mkdir(dir, { recursive: true });
|
|
621
630
|
await writeFile(join(dir, `${HOST_NAME}.json`), `${JSON.stringify(manifest, null, 2)}
|
|
622
631
|
`, "utf8");
|
|
623
|
-
log(
|
|
632
|
+
log(`\u2713 ${label} connected`);
|
|
624
633
|
installed += 1;
|
|
625
634
|
}
|
|
626
635
|
return installed;
|
|
@@ -639,12 +648,11 @@ async function installWindows(manifestBase, hostPath, installDir, log) {
|
|
|
639
648
|
await writeFile(batPath, `@echo off\r
|
|
640
649
|
"${process.execPath}" "${hostPath}" %*\r
|
|
641
650
|
`, "utf8");
|
|
642
|
-
log(
|
|
651
|
+
log("\u2713 Windows launcher ready");
|
|
643
652
|
const manifest = { ...manifestBase, path: batPath };
|
|
644
653
|
const manifestPath = join(installDir, `${HOST_NAME}.json`);
|
|
645
654
|
await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}
|
|
646
655
|
`, "utf8");
|
|
647
|
-
log(`Manifest \u2192 ${manifestPath}`);
|
|
648
656
|
let installed = 0;
|
|
649
657
|
for (const [label, userDataDir, vendorKey] of windowsBrowsers()) {
|
|
650
658
|
if (!await exists(userDataDir)) {
|
|
@@ -653,23 +661,27 @@ async function installWindows(manifestBase, hostPath, installDir, log) {
|
|
|
653
661
|
const key = `HKCU\\${vendorKey}\\NativeMessagingHosts\\${HOST_NAME}`;
|
|
654
662
|
const regExe = join(process.env.SystemRoot || "C:\\Windows", "System32", "reg.exe");
|
|
655
663
|
await execFileAsync(regExe, ["add", key, "/ve", "/t", "REG_SZ", "/d", manifestPath, "/f"]);
|
|
656
|
-
log(
|
|
664
|
+
log(`\u2713 ${label} connected`);
|
|
657
665
|
installed += 1;
|
|
658
666
|
}
|
|
659
667
|
return installed;
|
|
660
668
|
}
|
|
661
|
-
async function installNativeHost({ ids = [], corpus, force = false, quiet = false } = {}) {
|
|
669
|
+
async function installNativeHost({ ids = [], corpus, codexHome, force = false, quiet = false } = {}) {
|
|
662
670
|
const log = quiet ? () => {
|
|
663
671
|
} : console.log;
|
|
664
672
|
const isWindows2 = osPlatform === "win32";
|
|
665
673
|
const invalidId = ids.find((id) => !/^[a-p]{32}$/.test(id));
|
|
666
674
|
if (invalidId) {
|
|
667
|
-
throw new Error(
|
|
675
|
+
throw new Error(
|
|
676
|
+
`${JSON.stringify(invalidId)} is not a Chromium extension ID. Only unpacked/development builds need an ID; it must be 32 letters from a through p. Run with --help for an example.`
|
|
677
|
+
);
|
|
668
678
|
}
|
|
669
679
|
const allIds = [.../* @__PURE__ */ new Set([STORE_EXTENSION_ID, ...ids])];
|
|
670
680
|
const allowedOrigins = allIds.map((id) => `chrome-extension://${id}/`);
|
|
671
681
|
if (await supportedBrowserCount(isWindows2) === 0) {
|
|
672
|
-
throw new Error(
|
|
682
|
+
throw new Error(
|
|
683
|
+
"LLMnesia couldn't find Chrome, Brave, Edge, or Chromium. Open your browser once so it creates a profile, then retry. Safari and Firefox are not supported yet. Nothing was installed."
|
|
684
|
+
);
|
|
673
685
|
}
|
|
674
686
|
const installDir = join(home, ".llmnesia");
|
|
675
687
|
const configPath = join(installDir, "config.json");
|
|
@@ -683,38 +695,68 @@ async function installNativeHost({ ids = [], corpus, force = false, quiet = fals
|
|
|
683
695
|
if (!isWindows2) {
|
|
684
696
|
await chmod(hostPath, 493);
|
|
685
697
|
}
|
|
686
|
-
log(
|
|
698
|
+
log("\u2713 Helper installed");
|
|
687
699
|
const manifestBase = {
|
|
688
700
|
name: HOST_NAME,
|
|
689
|
-
description: "LLMnesia \u2014 reads local Claude Code sessions",
|
|
701
|
+
description: "LLMnesia \u2014 reads local Claude Code and Codex sessions",
|
|
690
702
|
type: "stdio",
|
|
691
703
|
allowed_origins: allowedOrigins
|
|
692
704
|
};
|
|
693
705
|
const installedCount = isWindows2 ? await installWindows(manifestBase, hostPath, installDir, log) : await installPosix(manifestBase, hostPath, log);
|
|
694
706
|
const corpusRoot = resolveCorpusDir2(corpus);
|
|
707
|
+
const resolvedCodexHome = resolveCodexHome(codexHome);
|
|
695
708
|
const existingRoot = existing && typeof existing.corpusRoot === "string" ? existing.corpusRoot : null;
|
|
696
709
|
let corpusRootChanged = true;
|
|
697
710
|
if (existingRoot && existingRoot !== corpusRoot && !force) {
|
|
698
711
|
corpusRootChanged = false;
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
712
|
+
await writeFile(
|
|
713
|
+
configPath,
|
|
714
|
+
`${JSON.stringify({ ...existing, corpusRoot: existingRoot, codexHome: resolvedCodexHome }, null, 2)}
|
|
715
|
+
`,
|
|
716
|
+
{ encoding: "utf8", mode: 384 }
|
|
717
|
+
);
|
|
718
|
+
await chmod(configPath, 384);
|
|
719
|
+
log("\u2713 Kept your existing conversation-data folder");
|
|
720
|
+
log(` Conversation data: ${existingRoot}`);
|
|
721
|
+
log(` To change it: re-run with --corpus <dir> --force`);
|
|
722
|
+
log(` Codex data: ${resolvedCodexHome}`);
|
|
702
723
|
} else {
|
|
703
|
-
await writeFile(
|
|
704
|
-
|
|
724
|
+
await writeFile(
|
|
725
|
+
configPath,
|
|
726
|
+
`${JSON.stringify({ ...existing ?? {}, corpusRoot, codexHome: resolvedCodexHome }, null, 2)}
|
|
727
|
+
`,
|
|
728
|
+
{ encoding: "utf8", mode: 384 }
|
|
729
|
+
);
|
|
705
730
|
await chmod(configPath, 384);
|
|
706
|
-
log(
|
|
707
|
-
log(`
|
|
731
|
+
log("\u2713 Settings saved");
|
|
732
|
+
log(` Conversation data: ${corpusRoot}`);
|
|
733
|
+
log(` Codex data: ${resolvedCodexHome}`);
|
|
734
|
+
}
|
|
735
|
+
const [hasClaudeCode, hasCodex] = await Promise.all([
|
|
736
|
+
exists(CLAUDE_PROJECTS_DIR),
|
|
737
|
+
exists(join(resolvedCodexHome, "sessions"))
|
|
738
|
+
]);
|
|
739
|
+
log("");
|
|
740
|
+
log(
|
|
741
|
+
hasClaudeCode ? `\u2713 Found Claude Code sessions at ${CLAUDE_PROJECTS_DIR}` : `\u2013 No Claude Code sessions found yet (fine if you don't use Claude Code).`
|
|
742
|
+
);
|
|
743
|
+
log(
|
|
744
|
+
hasCodex ? `\u2713 Found Codex sessions at ${resolvedCodexHome}` : `\u2013 No Codex sessions found yet (fine if you don't use Codex).`
|
|
745
|
+
);
|
|
746
|
+
const extraIdCount = allIds.length - 1;
|
|
747
|
+
if (extraIdCount > 0) {
|
|
748
|
+
log(`\u2713 Also authorized ${extraIdCount} unpacked extension ${extraIdCount === 1 ? "build" : "builds"}`);
|
|
708
749
|
}
|
|
709
|
-
log(
|
|
710
|
-
|
|
711
|
-
log("
|
|
750
|
+
log("\nAll set \u2014 LLMnesia can now read your local coding-agent sessions.");
|
|
751
|
+
log("Next: fully quit your browser (not just close its window), reopen it, then open LLMnesia Settings.");
|
|
752
|
+
log("If it doesn't connect, run: npx -y @llmnesia/local-agent-host@latest doctor");
|
|
712
753
|
return {
|
|
713
754
|
installedCount,
|
|
714
755
|
allowedIds: allIds,
|
|
715
756
|
configPath,
|
|
716
757
|
corpusRoot: existingRoot && !corpusRootChanged ? existingRoot : corpusRoot,
|
|
717
|
-
corpusRootChanged
|
|
758
|
+
corpusRootChanged,
|
|
759
|
+
codexHome: resolvedCodexHome
|
|
718
760
|
};
|
|
719
761
|
}
|
|
720
762
|
async function uninstallNativeHost() {
|
|
@@ -2570,6 +2612,7 @@ import { dirname as dirname4, join as join4 } from "path";
|
|
|
2570
2612
|
import { promisify as promisify2 } from "util";
|
|
2571
2613
|
var execFileAsync2 = promisify2(execFile2);
|
|
2572
2614
|
var SERVER_NAME = "llmnesia";
|
|
2615
|
+
var CODEX_APPROVAL_MODE = "writes";
|
|
2573
2616
|
async function commandOnPath(name) {
|
|
2574
2617
|
try {
|
|
2575
2618
|
const { stdout } = await execFileAsync2(process.platform === "win32" ? "where" : "which", [name]);
|
|
@@ -2912,7 +2955,7 @@ claude mcp add --scope user ${SERVER_NAME} -- ${serve.command} ${serve.args.join
|
|
|
2912
2955
|
function sameServeCommand(left, right) {
|
|
2913
2956
|
return left.command === right.command && JSON.stringify(left.args) === JSON.stringify(right.args);
|
|
2914
2957
|
}
|
|
2915
|
-
async function registerCodex(serve, cli, configPath, confirm) {
|
|
2958
|
+
async function registerCodex(serve, cli, configPath, confirm, now) {
|
|
2916
2959
|
if (!await cli.detect()) {
|
|
2917
2960
|
if (!await clientDirExists(configPath)) {
|
|
2918
2961
|
return {
|
|
@@ -2921,12 +2964,34 @@ async function registerCodex(serve, cli, configPath, confirm) {
|
|
|
2921
2964
|
detail: `Not installed (no Codex CLI and no ${dirname4(configPath)}).`
|
|
2922
2965
|
};
|
|
2923
2966
|
}
|
|
2924
|
-
|
|
2925
|
-
|
|
2926
|
-
|
|
2927
|
-
|
|
2928
|
-
|
|
2929
|
-
}
|
|
2967
|
+
try {
|
|
2968
|
+
const raw = await readFile2(configPath, "utf8");
|
|
2969
|
+
if (renderCodexConfig(raw, serve) === raw) {
|
|
2970
|
+
return { client: "Codex", action: "skipped", detail: `Already registered at ${configPath}.` };
|
|
2971
|
+
}
|
|
2972
|
+
} catch (err) {
|
|
2973
|
+
if (!(isErrnoException(err) && err.code === "ENOENT")) {
|
|
2974
|
+
return { client: "Codex", action: "error", detail: `Could not inspect ${configPath}: ${errorMessage(err)}` };
|
|
2975
|
+
}
|
|
2976
|
+
}
|
|
2977
|
+
const ok2 = await confirm(
|
|
2978
|
+
`Codex: update ${configPath} to register LLMnesia? Existing settings are preserved and backed up.
|
|
2979
|
+
Add:
|
|
2980
|
+
${codexBlock(serve)}`
|
|
2981
|
+
);
|
|
2982
|
+
if (!ok2) {
|
|
2983
|
+
return { client: "Codex", action: "declined", detail: "Skipped by user." };
|
|
2984
|
+
}
|
|
2985
|
+
try {
|
|
2986
|
+
const update = await writeCodexConfig(configPath, serve, now);
|
|
2987
|
+
return {
|
|
2988
|
+
client: "Codex",
|
|
2989
|
+
action: update.changed ? "registered" : "skipped",
|
|
2990
|
+
detail: update.changed ? `Wrote ${configPath}${update.backupPath ? ` (backup: ${update.backupPath})` : ""}.` : `Already registered at ${configPath}.`
|
|
2991
|
+
};
|
|
2992
|
+
} catch (err) {
|
|
2993
|
+
return { client: "Codex", action: "error", detail: `Could not update ${configPath}: ${errorMessage(err)}` };
|
|
2994
|
+
}
|
|
2930
2995
|
}
|
|
2931
2996
|
let existing;
|
|
2932
2997
|
try {
|
|
@@ -2935,7 +3000,20 @@ ${codexBlock(serve)}Then fully quit and reopen Codex.`
|
|
|
2935
3000
|
return { client: "Codex", action: "error", detail: `Could not inspect Codex MCP settings: ${errorMessage(err)}` };
|
|
2936
3001
|
}
|
|
2937
3002
|
if (existing?.serve && sameServeCommand(existing.serve, serve)) {
|
|
2938
|
-
|
|
3003
|
+
try {
|
|
3004
|
+
const update = await writeCodexConfig(configPath, serve, now);
|
|
3005
|
+
return {
|
|
3006
|
+
client: "Codex",
|
|
3007
|
+
action: update.changed ? "registered" : "skipped",
|
|
3008
|
+
detail: update.changed ? `Already registered; updated tool approval policy in ${configPath}.` : "Already registered."
|
|
3009
|
+
};
|
|
3010
|
+
} catch (err) {
|
|
3011
|
+
return {
|
|
3012
|
+
client: "Codex",
|
|
3013
|
+
action: "error",
|
|
3014
|
+
detail: `Already registered, but could not set its tool approval policy: ${errorMessage(err)}`
|
|
3015
|
+
};
|
|
3016
|
+
}
|
|
2939
3017
|
}
|
|
2940
3018
|
if (existing && !existing.serve) {
|
|
2941
3019
|
return {
|
|
@@ -2975,6 +3053,15 @@ codex mcp add ${SERVER_NAME} -- ${serve.command} ${serve.args.join(" ")}`
|
|
|
2975
3053
|
}
|
|
2976
3054
|
return { client: "Codex", action: "error", detail: `codex mcp add failed: ${errorMessage(err)}` };
|
|
2977
3055
|
}
|
|
3056
|
+
try {
|
|
3057
|
+
await writeCodexConfig(configPath, serve, now);
|
|
3058
|
+
} catch (err) {
|
|
3059
|
+
return {
|
|
3060
|
+
client: "Codex",
|
|
3061
|
+
action: "error",
|
|
3062
|
+
detail: `Registered the server, but could not set its tool approval policy in ${configPath}: ${errorMessage(err)}`
|
|
3063
|
+
};
|
|
3064
|
+
}
|
|
2978
3065
|
return {
|
|
2979
3066
|
client: "Codex",
|
|
2980
3067
|
action: "registered",
|
|
@@ -3035,8 +3122,82 @@ function codexBlock(serve) {
|
|
|
3035
3122
|
return `[mcp_servers.${SERVER_NAME}]
|
|
3036
3123
|
command = ${JSON.stringify(serve.command)}
|
|
3037
3124
|
args = [${argsToml}]
|
|
3125
|
+
default_tools_approval_mode = ${JSON.stringify(CODEX_APPROVAL_MODE)}
|
|
3038
3126
|
`;
|
|
3039
3127
|
}
|
|
3128
|
+
function codexHeaders(raw) {
|
|
3129
|
+
const headers = [];
|
|
3130
|
+
const pattern = /^[ \t]*\[([^\]\r\n]+)\][ \t]*(?:#.*)?$/gm;
|
|
3131
|
+
for (let match = pattern.exec(raw); match; match = pattern.exec(raw)) {
|
|
3132
|
+
headers.push({ start: match.index, name: match[1].replace(/[ \t]/g, "") });
|
|
3133
|
+
}
|
|
3134
|
+
return headers;
|
|
3135
|
+
}
|
|
3136
|
+
function isCodexRootHeader(name) {
|
|
3137
|
+
return /^mcp_servers\.(?:llmnesia|"llmnesia"|'llmnesia')$/.test(name);
|
|
3138
|
+
}
|
|
3139
|
+
function isCodexChildHeader(name) {
|
|
3140
|
+
return /^mcp_servers\.(?:llmnesia|"llmnesia"|'llmnesia')\./.test(name);
|
|
3141
|
+
}
|
|
3142
|
+
function codexSectionRange(raw) {
|
|
3143
|
+
const headers = codexHeaders(raw);
|
|
3144
|
+
const roots = headers.filter((header) => isCodexRootHeader(header.name));
|
|
3145
|
+
if (roots.length > 1) {
|
|
3146
|
+
throw new Error(`found more than one [mcp_servers.${SERVER_NAME}] table; the file was left unchanged`);
|
|
3147
|
+
}
|
|
3148
|
+
const root = roots[0];
|
|
3149
|
+
if (!root) {
|
|
3150
|
+
if (headers.some((header) => isCodexChildHeader(header.name))) {
|
|
3151
|
+
throw new Error(
|
|
3152
|
+
`found LLMnesia MCP subtables without a [mcp_servers.${SERVER_NAME}] table; the file was left unchanged`
|
|
3153
|
+
);
|
|
3154
|
+
}
|
|
3155
|
+
return null;
|
|
3156
|
+
}
|
|
3157
|
+
const rootIndex = headers.indexOf(root);
|
|
3158
|
+
let end = raw.length;
|
|
3159
|
+
for (let i = rootIndex + 1; i < headers.length; i += 1) {
|
|
3160
|
+
if (!isCodexChildHeader(headers[i].name)) {
|
|
3161
|
+
end = headers[i].start;
|
|
3162
|
+
break;
|
|
3163
|
+
}
|
|
3164
|
+
}
|
|
3165
|
+
return { start: root.start, end };
|
|
3166
|
+
}
|
|
3167
|
+
function renderCodexConfig(raw, serve) {
|
|
3168
|
+
const newline = raw.includes("\r\n") ? "\r\n" : "\n";
|
|
3169
|
+
const block = codexBlock(serve).replace(/\n/g, newline);
|
|
3170
|
+
const range = codexSectionRange(raw);
|
|
3171
|
+
if (range) {
|
|
3172
|
+
return `${raw.slice(0, range.start)}${block}${raw.slice(range.end)}`;
|
|
3173
|
+
}
|
|
3174
|
+
const base = raw.replace(/[ \t\r\n]+$/g, "");
|
|
3175
|
+
return `${base}${base ? `${newline}${newline}` : ""}${block}`;
|
|
3176
|
+
}
|
|
3177
|
+
async function writeCodexConfig(configPath, serve, now) {
|
|
3178
|
+
let raw = "";
|
|
3179
|
+
let existed = true;
|
|
3180
|
+
try {
|
|
3181
|
+
raw = await readFile2(configPath, "utf8");
|
|
3182
|
+
} catch (err) {
|
|
3183
|
+
if (isErrnoException(err) && err.code === "ENOENT") {
|
|
3184
|
+
existed = false;
|
|
3185
|
+
} else {
|
|
3186
|
+
throw err;
|
|
3187
|
+
}
|
|
3188
|
+
}
|
|
3189
|
+
const next = renderCodexConfig(raw, serve);
|
|
3190
|
+
if (next === raw) {
|
|
3191
|
+
return { changed: false };
|
|
3192
|
+
}
|
|
3193
|
+
let backupPath;
|
|
3194
|
+
if (existed) {
|
|
3195
|
+
backupPath = `${configPath}.bak-${now()}`;
|
|
3196
|
+
await writeFile2(backupPath, raw, "utf8");
|
|
3197
|
+
}
|
|
3198
|
+
await writeFile2(configPath, next, { encoding: "utf8", mode: 384 });
|
|
3199
|
+
return { changed: true, ...backupPath ? { backupPath } : {} };
|
|
3200
|
+
}
|
|
3040
3201
|
function genericBlock(serve) {
|
|
3041
3202
|
return JSON.stringify({ mcpServers: { [SERVER_NAME]: { command: serve.command, args: serve.args } } }, null, 2);
|
|
3042
3203
|
}
|
|
@@ -3069,7 +3230,10 @@ async function registerClients(options) {
|
|
|
3069
3230
|
}
|
|
3070
3231
|
results.push(await safe("Cursor", () => registerJsonClient("Cursor", cursor, options.serve, options.confirm, now)));
|
|
3071
3232
|
results.push(
|
|
3072
|
-
await safe(
|
|
3233
|
+
await safe(
|
|
3234
|
+
"Codex",
|
|
3235
|
+
() => registerCodex(options.serve, options.codexCli ?? realCodexCli, codex, options.confirm, now)
|
|
3236
|
+
)
|
|
3073
3237
|
);
|
|
3074
3238
|
results.push({
|
|
3075
3239
|
client: "Other MCP clients (Windsurf, Zed, LM Studio, ...)",
|
|
@@ -3111,20 +3275,27 @@ async function unregisterClaudeCode(cli) {
|
|
|
3111
3275
|
};
|
|
3112
3276
|
}
|
|
3113
3277
|
}
|
|
3114
|
-
async function unregisterCodex(cli, configPath) {
|
|
3278
|
+
async function unregisterCodex(cli, configPath, now) {
|
|
3115
3279
|
if (!await cli.detect()) {
|
|
3116
3280
|
try {
|
|
3117
|
-
const
|
|
3118
|
-
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
action: "error",
|
|
3122
|
-
detail: `A registration remains in ${configPath}, but the Codex CLI is unavailable to remove it safely. Restore the CLI and retry. The runtime was retained.`
|
|
3123
|
-
};
|
|
3281
|
+
const raw = await readFile2(configPath, "utf8");
|
|
3282
|
+
const range = codexSectionRange(raw);
|
|
3283
|
+
if (!range) {
|
|
3284
|
+
return { client: "Codex", action: "skipped", detail: "`codex` not found and no registration was detected." };
|
|
3124
3285
|
}
|
|
3286
|
+
const backupPath = `${configPath}.bak-${now()}`;
|
|
3287
|
+
await writeFile2(backupPath, raw, "utf8");
|
|
3288
|
+
const next = `${raw.slice(0, range.start)}${raw.slice(range.end)}`.replace(/[ \t\r\n]+$/g, "");
|
|
3289
|
+
await writeFile2(configPath, next ? `${next}
|
|
3290
|
+
` : "", "utf8");
|
|
3291
|
+
return {
|
|
3292
|
+
client: "Codex",
|
|
3293
|
+
action: "registered",
|
|
3294
|
+
detail: `Removed from ${configPath} (backup: ${backupPath}).`
|
|
3295
|
+
};
|
|
3125
3296
|
} catch (err) {
|
|
3126
3297
|
if (!(isErrnoException(err) && err.code === "ENOENT")) {
|
|
3127
|
-
return { client: "Codex", action: "error", detail: `Could not
|
|
3298
|
+
return { client: "Codex", action: "error", detail: `Could not update ${configPath}: ${errorMessage(err)}` };
|
|
3128
3299
|
}
|
|
3129
3300
|
}
|
|
3130
3301
|
return { client: "Codex", action: "skipped", detail: "`codex` not found and no registration was detected." };
|
|
@@ -3192,7 +3363,7 @@ async function unregisterClients(options = {}) {
|
|
|
3192
3363
|
results.push(await safe("Claude Desktop", () => unregisterJsonClient("Claude Desktop", claudeDesktop, now)));
|
|
3193
3364
|
}
|
|
3194
3365
|
results.push(await safe("Cursor", () => unregisterJsonClient("Cursor", cursor, now)));
|
|
3195
|
-
results.push(await safe("Codex", () => unregisterCodex(options.codexCli ?? realCodexCli, codex)));
|
|
3366
|
+
results.push(await safe("Codex", () => unregisterCodex(options.codexCli ?? realCodexCli, codex, now)));
|
|
3196
3367
|
results.push({
|
|
3197
3368
|
client: "Other MCP clients",
|
|
3198
3369
|
action: "skipped",
|
|
@@ -3671,9 +3842,11 @@ async function runInstall(positionals, options, flags) {
|
|
|
3671
3842
|
printRegistrationResults(results);
|
|
3672
3843
|
hadProblems ||= results.some((result) => result.action === "error");
|
|
3673
3844
|
process.stdout.write(
|
|
3674
|
-
"\
|
|
3845
|
+
"\nDesktop helper installed. Fully quit and reopen your MCP apps and browser.\nBrowser sync is required or your MCP history will be empty. If you did not start in LLMnesia Settings, open it and click \u2018Enable automatic sync\u2019 once. LLMnesia will finish the first sync automatically.\n"
|
|
3675
3846
|
);
|
|
3676
|
-
|
|
3847
|
+
const npxCommand = process.platform === "win32" ? "npx.cmd" : "npx";
|
|
3848
|
+
process.stdout.write(`Then run \`${npxCommand} -y @llmnesia/mcp@latest doctor\` to check what's wired up.
|
|
3849
|
+
`);
|
|
3677
3850
|
if (hadProblems) {
|
|
3678
3851
|
process.stderr.write(
|
|
3679
3852
|
"\nInstall completed with problems. Review the error messages above; no failure was silently treated as success.\n"
|
package/dist/cli.js
CHANGED
|
@@ -3,7 +3,19 @@ import { createRequire as __llmnesiaCreateRequire } from "node:module";
|
|
|
3
3
|
const require = __llmnesiaCreateRequire(import.meta.url);
|
|
4
4
|
import {
|
|
5
5
|
VERSION
|
|
6
|
-
} from "./chunk-
|
|
6
|
+
} from "./chunk-YVBAQ5VA.js";
|
|
7
|
+
|
|
8
|
+
// src/nodeDownload.ts
|
|
9
|
+
var NODE_DOWNLOAD_URLS = {
|
|
10
|
+
darwin: "https://www.llmnesia.com/download/node/macos",
|
|
11
|
+
win32: "https://www.llmnesia.com/download/node/windows",
|
|
12
|
+
other: "https://nodejs.org/en/download"
|
|
13
|
+
};
|
|
14
|
+
function nodeDownloadUrl(platform = process.platform) {
|
|
15
|
+
if (platform === "darwin") return NODE_DOWNLOAD_URLS.darwin;
|
|
16
|
+
if (platform === "win32") return NODE_DOWNLOAD_URLS.win32;
|
|
17
|
+
return NODE_DOWNLOAD_URLS.other;
|
|
18
|
+
}
|
|
7
19
|
|
|
8
20
|
// src/nodeVersion.ts
|
|
9
21
|
var MINIMUM_NODE_VERSION = "22.13.0";
|
|
@@ -24,15 +36,98 @@ function supportsNodeVersion(version) {
|
|
|
24
36
|
}
|
|
25
37
|
return true;
|
|
26
38
|
}
|
|
27
|
-
function unsupportedNodeMessage(version) {
|
|
28
|
-
return `LLMnesia MCP requires Node.js ${MINIMUM_NODE_VERSION} or newer, but this command is running on Node.js ${version}.
|
|
29
39
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
40
|
+
// src/nodeUpgrade.ts
|
|
41
|
+
import { spawn } from "child_process";
|
|
42
|
+
import { createInterface } from "readline/promises";
|
|
43
|
+
var INSTALL_COMMAND = "npx -y @llmnesia/mcp@latest install";
|
|
44
|
+
function nodeDownloadOpenCommand(platform) {
|
|
45
|
+
const downloadUrl = nodeDownloadUrl(platform);
|
|
46
|
+
switch (platform) {
|
|
47
|
+
case "darwin":
|
|
48
|
+
return { command: "open", args: [downloadUrl] };
|
|
49
|
+
case "win32":
|
|
50
|
+
return { command: "cmd.exe", args: ["/d", "/s", "/c", "start", "", downloadUrl] };
|
|
51
|
+
case "linux":
|
|
52
|
+
return { command: "xdg-open", args: [downloadUrl] };
|
|
53
|
+
default:
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
async function openNodeDownloadPage(platform = process.platform) {
|
|
58
|
+
const spec = nodeDownloadOpenCommand(platform);
|
|
59
|
+
if (!spec) {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
return new Promise((resolve) => {
|
|
63
|
+
let settled = false;
|
|
64
|
+
const finish = (opened) => {
|
|
65
|
+
if (!settled) {
|
|
66
|
+
settled = true;
|
|
67
|
+
resolve(opened);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
try {
|
|
71
|
+
const child = spawn(spec.command, spec.args, {
|
|
72
|
+
detached: true,
|
|
73
|
+
stdio: "ignore",
|
|
74
|
+
windowsHide: true
|
|
75
|
+
});
|
|
76
|
+
child.once("error", () => finish(false));
|
|
77
|
+
child.once("spawn", () => {
|
|
78
|
+
child.unref();
|
|
79
|
+
finish(true);
|
|
80
|
+
});
|
|
81
|
+
} catch {
|
|
82
|
+
finish(false);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
async function askInTerminal(prompt) {
|
|
87
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
88
|
+
try {
|
|
89
|
+
return await rl.question(prompt);
|
|
90
|
+
} finally {
|
|
91
|
+
rl.close();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function nextSteps(opened, platform) {
|
|
95
|
+
const downloadUrl = nodeDownloadUrl(platform);
|
|
96
|
+
return (opened ? `Opened ${downloadUrl}
|
|
97
|
+
` : `Download the current Node.js LTS release: ${downloadUrl}
|
|
98
|
+
`) + `After installing it, reopen Terminal or PowerShell and run:
|
|
99
|
+
${INSTALL_COMMAND}
|
|
34
100
|
`;
|
|
35
101
|
}
|
|
102
|
+
async function guideUnsupportedNodeVersion(version, options = {}) {
|
|
103
|
+
const interactive = options.interactive ?? Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
104
|
+
const platform = options.platform ?? process.platform;
|
|
105
|
+
const ask = options.ask ?? askInTerminal;
|
|
106
|
+
const openDownload = options.openDownload ?? openNodeDownloadPage;
|
|
107
|
+
const writeOut = options.writeOut ?? ((text) => process.stdout.write(text));
|
|
108
|
+
const writeErr = options.writeErr ?? ((text) => process.stderr.write(text));
|
|
109
|
+
writeErr(
|
|
110
|
+
`LLMnesia MCP requires Node.js ${MINIMUM_NODE_VERSION} or newer, but this command is running on Node.js ${version}.
|
|
111
|
+
`
|
|
112
|
+
);
|
|
113
|
+
if (!interactive) {
|
|
114
|
+
writeOut(nextSteps(false, platform));
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
let answer = "";
|
|
118
|
+
try {
|
|
119
|
+
answer = await ask("Open the Node.js installer now? [Y/n] ");
|
|
120
|
+
} catch {
|
|
121
|
+
writeOut(nextSteps(false, platform));
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (answer.trim() !== "" && !/^y(es)?$/i.test(answer.trim())) {
|
|
125
|
+
writeOut(nextSteps(false, platform));
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const opened = await openDownload(platform);
|
|
129
|
+
writeOut(nextSteps(opened, platform));
|
|
130
|
+
}
|
|
36
131
|
|
|
37
132
|
// src/bootstrap.ts
|
|
38
133
|
async function bootstrap() {
|
|
@@ -43,10 +138,10 @@ async function bootstrap() {
|
|
|
43
138
|
return 0;
|
|
44
139
|
}
|
|
45
140
|
if (!supportsNodeVersion(process.versions.node)) {
|
|
46
|
-
|
|
141
|
+
await guideUnsupportedNodeVersion(process.versions.node);
|
|
47
142
|
return 1;
|
|
48
143
|
}
|
|
49
|
-
const { main } = await import("./cli-
|
|
144
|
+
const { main } = await import("./cli-TVE63AWC.js");
|
|
50
145
|
return main();
|
|
51
146
|
}
|
|
52
147
|
bootstrap().then((code) => {
|
|
@@ -1,26 +1,31 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// LLMnesia native messaging host — reads local
|
|
3
|
-
// ~/.claude/projects
|
|
4
|
-
//
|
|
5
|
-
// corpus's inbox/ directory (see
|
|
2
|
+
// LLMnesia native messaging host — reads local coding-agent sessions (Claude
|
|
3
|
+
// Code from ~/.claude/projects, Codex from the configured Codex home) and
|
|
4
|
+
// streams them to the LLMnesia browser extension, and writes corpus sync deltas
|
|
5
|
+
// the extension sends back into the LLMnesia corpus's inbox/ directory (see
|
|
6
|
+
// packages/../companion/src/paths.ts).
|
|
6
7
|
//
|
|
7
|
-
// It reads .jsonl session files under
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
8
|
+
// It reads .jsonl session files under those two roots, and writes only inside
|
|
9
|
+
// the corpus root named in its own config (~/.llmnesia/config.json) — never a
|
|
10
|
+
// path the caller supplies. It makes no network calls. The browser launches it
|
|
11
|
+
// on demand (see the installed com.llmnesia.claude_code.json manifest) and
|
|
12
|
+
// talks to it over stdio using Chrome's native-messaging framing: a
|
|
13
|
+
// little-endian uint32 byte-length prefix followed by that many bytes of UTF-8
|
|
14
|
+
// JSON, in both directions.
|
|
14
15
|
//
|
|
15
16
|
// The write path only ever activates for a user who has explicitly run the
|
|
16
17
|
// installer with --corpus (see install.mjs) to opt into the MCP companion;
|
|
17
18
|
// without ~/.llmnesia/config.json this host is exactly as read-only as it
|
|
18
|
-
// has always been for plain
|
|
19
|
+
// has always been for plain session indexing.
|
|
19
20
|
//
|
|
20
|
-
// Protocol (extension → host):
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
21
|
+
// Protocol v2 (extension → host). v1 was Claude-Code-only: `list` carried no
|
|
22
|
+
// `source` and `get` took a bare sessionId. Both remain accepted, so an
|
|
23
|
+
// extension older than this host keeps working; an extension NEWER than the
|
|
24
|
+
// host detects v1 from the pong and syncs Claude Code only.
|
|
25
|
+
// { cmd: "ping" } → { type: "pong", version: 2 }
|
|
26
|
+
// { cmd: "list" } → { type: "list", sessions:
|
|
27
|
+
// [{source, sessionId, mtime, project?, title?}] }
|
|
28
|
+
// { cmd: "get", source, sessionId } → one or more { type: "chunk", sessionId, seq, total, data }
|
|
24
29
|
// then { type: "sessionDone", sessionId }
|
|
25
30
|
// (or { type: "session", sessionId, notFound: true })
|
|
26
31
|
// { cmd: "corpusInfo" } → { type: "corpusInfo", present, root? }
|
|
@@ -28,13 +33,25 @@
|
|
|
28
33
|
// seq, total, data } → { type: "chunkAck", seq }
|
|
29
34
|
// (writes <root>/inbox/<filename> once
|
|
30
35
|
// every chunk 0..total-1 has arrived)
|
|
36
|
+
//
|
|
37
|
+
// The extension never names a path in either direction: it asks for a
|
|
38
|
+
// (source, sessionId) pair and the host resolves it against its own roots. That
|
|
39
|
+
// is what keeps a compromised or buggy caller from reading arbitrary files.
|
|
31
40
|
|
|
32
41
|
import { chmod, mkdir, readdir, readFile, rename, stat, writeFile } from "node:fs/promises";
|
|
33
42
|
import { homedir } from "node:os";
|
|
34
43
|
import { join } from "node:path";
|
|
35
44
|
|
|
45
|
+
const PROTOCOL_VERSION = 2;
|
|
36
46
|
const PROJECTS_DIR = join(homedir(), ".claude", "projects");
|
|
37
47
|
const CONFIG_FILE = join(homedir(), ".llmnesia", "config.json");
|
|
48
|
+
// Codex's home, resolved at install time (--codex-home > CODEX_HOME > ~/.codex)
|
|
49
|
+
// and persisted into config.json. The env var is read here too so a host
|
|
50
|
+
// installed before Codex support still honours a non-default CODEX_HOME.
|
|
51
|
+
const DEFAULT_CODEX_HOME = process.env.CODEX_HOME || join(homedir(), ".codex");
|
|
52
|
+
// Codex nests rollouts as sessions/<year>/<month>/<day>/*.jsonl. Bounded so a
|
|
53
|
+
// misconfigured codexHome can't turn into an unbounded filesystem walk.
|
|
54
|
+
const CODEX_MAX_DEPTH = 3;
|
|
38
55
|
// Conservative chunk size (chars). After JSON-escaping + any multibyte chars the
|
|
39
56
|
// on-wire message stays well under native messaging's 1 MB per-message ceiling.
|
|
40
57
|
const CHUNK_CHARS = 256 * 1024;
|
|
@@ -43,6 +60,15 @@ const CHUNK_CHARS = 256 * 1024;
|
|
|
43
60
|
// than the browser profile it was copied out of.
|
|
44
61
|
const DIR_MODE = 0o700;
|
|
45
62
|
const FILE_MODE = 0o600;
|
|
63
|
+
// Defend the host process from malformed or compromised callers. The extension
|
|
64
|
+
// currently sends 256 KiB chunks and waits for each ack, so these limits leave
|
|
65
|
+
// ample headroom without permitting unbounded arrays or abandoned transfers.
|
|
66
|
+
const MAX_PUT_CHUNK_CHARS = 512 * 1024;
|
|
67
|
+
const MAX_PUT_CHUNKS = 256;
|
|
68
|
+
const MAX_PUT_TRANSFER_CHARS = 64 * 1024 * 1024;
|
|
69
|
+
const MAX_PENDING_WRITE_CHARS = 64 * 1024 * 1024;
|
|
70
|
+
const MAX_PENDING_WRITES = 8;
|
|
71
|
+
const PENDING_WRITE_TTL_MS = 5 * 60 * 1000;
|
|
46
72
|
|
|
47
73
|
function send(message) {
|
|
48
74
|
const body = Buffer.from(JSON.stringify(message), "utf8");
|
|
@@ -52,7 +78,11 @@ function send(message) {
|
|
|
52
78
|
process.stdout.write(body);
|
|
53
79
|
}
|
|
54
80
|
|
|
55
|
-
|
|
81
|
+
// --- session discovery ----------------------------------------------------
|
|
82
|
+
// One entry per session: { source, sessionId, mtime, project?, title?, path }.
|
|
83
|
+
// `path` is stripped before anything goes on the wire.
|
|
84
|
+
|
|
85
|
+
async function listClaudeCodeSessions() {
|
|
56
86
|
const sessions = [];
|
|
57
87
|
let projectDirs;
|
|
58
88
|
try {
|
|
@@ -79,6 +109,7 @@ async function listSessions() {
|
|
|
79
109
|
try {
|
|
80
110
|
const info = await stat(path);
|
|
81
111
|
sessions.push({
|
|
112
|
+
source: "claude_code",
|
|
82
113
|
sessionId: file.replace(/\.jsonl$/, ""),
|
|
83
114
|
mtime: Math.floor(info.mtimeMs),
|
|
84
115
|
project: entry.name,
|
|
@@ -92,14 +123,128 @@ async function listSessions() {
|
|
|
92
123
|
return sessions;
|
|
93
124
|
}
|
|
94
125
|
|
|
126
|
+
// Codex names rollouts `rollout-<ISO timestamp>-<uuid>.jsonl`; the trailing
|
|
127
|
+
// UUID is the thread id (it matches session_meta.payload.id). Mirrors
|
|
128
|
+
// codexSessionIdFromFilename in the extension.
|
|
129
|
+
const CODEX_UUID_RE = /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i;
|
|
130
|
+
|
|
131
|
+
function codexSessionId(filename) {
|
|
132
|
+
const base = filename.replace(/\.jsonl$/i, "");
|
|
133
|
+
const match = CODEX_UUID_RE.exec(base);
|
|
134
|
+
return match ? match[1].toLowerCase() : base;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function walkCodexDir(dir, depth, out) {
|
|
138
|
+
let entries;
|
|
139
|
+
try {
|
|
140
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
141
|
+
} catch {
|
|
142
|
+
return out; // missing or unreadable — nothing to add
|
|
143
|
+
}
|
|
144
|
+
for (const entry of entries) {
|
|
145
|
+
const path = join(dir, entry.name);
|
|
146
|
+
if (entry.isDirectory()) {
|
|
147
|
+
if (depth > 0) {
|
|
148
|
+
await walkCodexDir(path, depth - 1, out);
|
|
149
|
+
}
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (!entry.name.endsWith(".jsonl")) {
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
const info = await stat(path);
|
|
157
|
+
out.push({
|
|
158
|
+
source: "codex",
|
|
159
|
+
sessionId: codexSessionId(entry.name),
|
|
160
|
+
mtime: Math.floor(info.mtimeMs),
|
|
161
|
+
path
|
|
162
|
+
});
|
|
163
|
+
} catch {
|
|
164
|
+
// Skip unreadable files.
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return out;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Codex's user-facing thread names, so search results read as the titles the
|
|
171
|
+
// user sees in the app rather than a derived "<project> · <first prompt>".
|
|
172
|
+
async function readCodexThreadNames(codexHome) {
|
|
173
|
+
const titles = new Map();
|
|
174
|
+
let raw;
|
|
175
|
+
try {
|
|
176
|
+
raw = await readFile(join(codexHome, "session_index.jsonl"), "utf8");
|
|
177
|
+
} catch {
|
|
178
|
+
return titles; // absent is normal — the extension falls back to a derived title
|
|
179
|
+
}
|
|
180
|
+
for (const line of raw.split("\n")) {
|
|
181
|
+
const trimmed = line.trim();
|
|
182
|
+
if (!trimmed) {
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
try {
|
|
186
|
+
const row = JSON.parse(trimmed);
|
|
187
|
+
if (row && typeof row.id === "string" && typeof row.thread_name === "string") {
|
|
188
|
+
const name = row.thread_name.trim();
|
|
189
|
+
if (name) {
|
|
190
|
+
titles.set(row.id.toLowerCase(), name);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
} catch {
|
|
194
|
+
// Skip malformed lines (including a truncated final one).
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return titles;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function listCodexSessions(codexHome) {
|
|
201
|
+
const sessions = await walkCodexDir(join(codexHome, "sessions"), CODEX_MAX_DEPTH, []);
|
|
202
|
+
if (sessions.length === 0) {
|
|
203
|
+
return sessions;
|
|
204
|
+
}
|
|
205
|
+
const titles = await readCodexThreadNames(codexHome);
|
|
206
|
+
for (const session of sessions) {
|
|
207
|
+
const title = titles.get(session.sessionId);
|
|
208
|
+
if (title) {
|
|
209
|
+
session.title = title;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return sessions;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Every session across every source. Sources are listed independently and
|
|
217
|
+
* failures are contained per source (each helper swallows its own errors), so
|
|
218
|
+
* an absent or unreadable Codex home can never stop Claude Code from syncing,
|
|
219
|
+
* or vice versa.
|
|
220
|
+
*/
|
|
221
|
+
async function listSessions() {
|
|
222
|
+
const config = await loadConfig();
|
|
223
|
+
const codexHome = config?.codexHome || DEFAULT_CODEX_HOME;
|
|
224
|
+
const [claude, codex] = await Promise.all([
|
|
225
|
+
listClaudeCodeSessions().catch(() => []),
|
|
226
|
+
listCodexSessions(codexHome).catch(() => [])
|
|
227
|
+
]);
|
|
228
|
+
return [...claude, ...codex];
|
|
229
|
+
}
|
|
230
|
+
|
|
95
231
|
async function loadConfig() {
|
|
96
232
|
try {
|
|
97
233
|
const raw = await readFile(CONFIG_FILE, "utf8");
|
|
98
234
|
const parsed = JSON.parse(raw);
|
|
99
|
-
if (parsed
|
|
100
|
-
return
|
|
235
|
+
if (!parsed || typeof parsed !== "object") {
|
|
236
|
+
return null;
|
|
101
237
|
}
|
|
102
|
-
|
|
238
|
+
const corpusRoot =
|
|
239
|
+
typeof parsed.corpusRoot === "string" && parsed.corpusRoot.length > 0 ? parsed.corpusRoot : null;
|
|
240
|
+
const codexHome =
|
|
241
|
+
typeof parsed.codexHome === "string" && parsed.codexHome.length > 0 ? parsed.codexHome : null;
|
|
242
|
+
// A config that names only codexHome is still useful for listing, but the
|
|
243
|
+
// corpus write path requires corpusRoot — callers check that field.
|
|
244
|
+
if (!corpusRoot && !codexHome) {
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
return { corpusRoot, codexHome };
|
|
103
248
|
} catch {
|
|
104
249
|
return null; // no config yet, or unreadable/invalid — treat as "not installed"
|
|
105
250
|
}
|
|
@@ -121,14 +266,22 @@ function sanitizeFilename(name) {
|
|
|
121
266
|
return name;
|
|
122
267
|
}
|
|
123
268
|
|
|
124
|
-
// filename → { chunks:
|
|
125
|
-
// multi-chunk putChunk transfer is in flight.
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
// silently call a partial transfer "complete" the moment the last-received
|
|
129
|
-
// (not last-index) chunk lands.
|
|
269
|
+
// filename → { chunks: Map<number, string>, total, receivedChars, updatedAt }
|
|
270
|
+
// while a multi-chunk putChunk transfer is in flight. A Map avoids allocating
|
|
271
|
+
// from caller-controlled `total`, and lets duplicate chunks replace their old
|
|
272
|
+
// contribution without inflating the byte/character budget.
|
|
130
273
|
const pendingWrites = new Map();
|
|
131
274
|
|
|
275
|
+
function prunePendingWrites(now) {
|
|
276
|
+
for (const [filename, entry] of pendingWrites) {
|
|
277
|
+
if (now - entry.updatedAt > PENDING_WRITE_TTL_MS) {
|
|
278
|
+
pendingWrites.delete(filename);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
setInterval(() => prunePendingWrites(Date.now()), 60 * 1000).unref();
|
|
284
|
+
|
|
132
285
|
async function handlePutChunk(message) {
|
|
133
286
|
const { filename, seq, total, data } = message;
|
|
134
287
|
const safeName = sanitizeFilename(filename);
|
|
@@ -136,25 +289,56 @@ async function handlePutChunk(message) {
|
|
|
136
289
|
send({ type: "error", error: `putChunk: rejected filename ${JSON.stringify(filename)}` });
|
|
137
290
|
return;
|
|
138
291
|
}
|
|
139
|
-
if (
|
|
292
|
+
if (
|
|
293
|
+
!Number.isInteger(seq) ||
|
|
294
|
+
!Number.isInteger(total) ||
|
|
295
|
+
seq < 0 ||
|
|
296
|
+
total < 1 ||
|
|
297
|
+
total > MAX_PUT_CHUNKS ||
|
|
298
|
+
seq >= total
|
|
299
|
+
) {
|
|
140
300
|
send({ type: "error", error: `putChunk: invalid seq/total for ${safeName}` });
|
|
141
301
|
return;
|
|
142
302
|
}
|
|
303
|
+
if (typeof data !== "string" || data.length > MAX_PUT_CHUNK_CHARS) {
|
|
304
|
+
send({ type: "error", error: `putChunk: invalid or oversized data for ${safeName}` });
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
143
307
|
const config = await loadConfig();
|
|
144
|
-
if (!config) {
|
|
308
|
+
if (!config?.corpusRoot) {
|
|
145
309
|
send({ type: "error", error: "putChunk: no corpus configured (run the installer)" });
|
|
146
310
|
return;
|
|
147
311
|
}
|
|
148
312
|
|
|
313
|
+
const now = Date.now();
|
|
314
|
+
prunePendingWrites(now);
|
|
149
315
|
let entry = pendingWrites.get(safeName);
|
|
150
316
|
if (!entry || entry.total !== total) {
|
|
151
|
-
entry
|
|
317
|
+
if (!entry && pendingWrites.size >= MAX_PENDING_WRITES) {
|
|
318
|
+
send({ type: "error", error: "putChunk: too many transfers in progress" });
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
entry = { chunks: new Map(), total, receivedChars: 0, updatedAt: now };
|
|
152
322
|
pendingWrites.set(safeName, entry);
|
|
153
323
|
}
|
|
154
|
-
entry.chunks
|
|
155
|
-
entry.
|
|
324
|
+
const previous = entry.chunks.get(seq);
|
|
325
|
+
const nextReceivedChars = entry.receivedChars - (previous?.length ?? 0) + data.length;
|
|
326
|
+
if (nextReceivedChars > MAX_PUT_TRANSFER_CHARS) {
|
|
327
|
+
pendingWrites.delete(safeName);
|
|
328
|
+
send({ type: "error", error: `putChunk: transfer too large for ${safeName}` });
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
const pendingChars = [...pendingWrites.values()].reduce((sum, pending) => sum + pending.receivedChars, 0);
|
|
332
|
+
if (pendingChars - (previous?.length ?? 0) + data.length > MAX_PENDING_WRITE_CHARS) {
|
|
333
|
+
pendingWrites.delete(safeName);
|
|
334
|
+
send({ type: "error", error: "putChunk: pending transfer budget exceeded" });
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
entry.chunks.set(seq, data);
|
|
338
|
+
entry.receivedChars = nextReceivedChars;
|
|
339
|
+
entry.updatedAt = now;
|
|
156
340
|
|
|
157
|
-
if (entry.
|
|
341
|
+
if (entry.chunks.size < total) {
|
|
158
342
|
send({ type: "chunkAck", seq });
|
|
159
343
|
return;
|
|
160
344
|
}
|
|
@@ -165,7 +349,8 @@ async function handlePutChunk(message) {
|
|
|
165
349
|
await chmod(inboxDir, DIR_MODE).catch(() => {}); // mkdir's mode is masked by umask
|
|
166
350
|
const tmpPath = join(inboxDir, `${safeName}.part-${process.pid}`);
|
|
167
351
|
const finalPath = join(inboxDir, safeName);
|
|
168
|
-
|
|
352
|
+
const text = Array.from({ length: total }, (_, index) => entry.chunks.get(index) ?? "").join("");
|
|
353
|
+
await writeFile(tmpPath, text, { encoding: "utf8", mode: FILE_MODE });
|
|
169
354
|
await rename(tmpPath, finalPath);
|
|
170
355
|
send({ type: "chunkAck", seq });
|
|
171
356
|
}
|
|
@@ -175,12 +360,12 @@ async function handle(message) {
|
|
|
175
360
|
return;
|
|
176
361
|
}
|
|
177
362
|
if (message.cmd === "ping") {
|
|
178
|
-
send({ type: "pong", version:
|
|
363
|
+
send({ type: "pong", version: PROTOCOL_VERSION });
|
|
179
364
|
return;
|
|
180
365
|
}
|
|
181
366
|
if (message.cmd === "corpusInfo") {
|
|
182
367
|
const config = await loadConfig();
|
|
183
|
-
if (!config) {
|
|
368
|
+
if (!config?.corpusRoot) {
|
|
184
369
|
send({ type: "corpusInfo", present: false });
|
|
185
370
|
return;
|
|
186
371
|
}
|
|
@@ -198,7 +383,12 @@ async function handle(message) {
|
|
|
198
383
|
}
|
|
199
384
|
if (message.cmd === "get") {
|
|
200
385
|
const sessions = await listSessions();
|
|
201
|
-
|
|
386
|
+
// Session ids are only unique WITHIN a source, so a v2 caller names both.
|
|
387
|
+
// A v1 caller sends no source; everything it knows about is Claude Code, so
|
|
388
|
+
// that's what an absent source resolves to — never a bare id match that
|
|
389
|
+
// could hand back the wrong source's file.
|
|
390
|
+
const wantSource = typeof message.source === "string" ? message.source : "claude_code";
|
|
391
|
+
const match = sessions.find((s) => s.source === wantSource && s.sessionId === message.sessionId);
|
|
202
392
|
if (!match) {
|
|
203
393
|
send({ type: "session", sessionId: message.sessionId, notFound: true });
|
|
204
394
|
return;
|
package/dist/mcp.js
CHANGED
|
@@ -7,13 +7,13 @@ import {
|
|
|
7
7
|
openSearchIndex,
|
|
8
8
|
pendingInboxFiles,
|
|
9
9
|
resolveCorpusDir
|
|
10
|
-
} from "./chunk-
|
|
10
|
+
} from "./chunk-3JQ7CGCP.js";
|
|
11
11
|
import {
|
|
12
12
|
VERSION,
|
|
13
13
|
__commonJS,
|
|
14
14
|
__export,
|
|
15
15
|
__toESM
|
|
16
|
-
} from "./chunk-
|
|
16
|
+
} from "./chunk-YVBAQ5VA.js";
|
|
17
17
|
|
|
18
18
|
// ../node_modules/ajv/dist/compile/codegen/code.js
|
|
19
19
|
var require_code = __commonJS({
|
|
@@ -21300,8 +21300,12 @@ var CompanionService = class _CompanionService {
|
|
|
21300
21300
|
}
|
|
21301
21301
|
/** Recent conversations enriched with their cached summary when available. */
|
|
21302
21302
|
async listRecent(n) {
|
|
21303
|
+
return this.listConversations(n, "recent");
|
|
21304
|
+
}
|
|
21305
|
+
/** Conversations in the requested chronology, enriched with cached summaries. */
|
|
21306
|
+
async listConversations(n, sort) {
|
|
21303
21307
|
await this.refresh();
|
|
21304
|
-
const recent = this.index.
|
|
21308
|
+
const recent = this.index.listConversations(n, sort);
|
|
21305
21309
|
const entries = [];
|
|
21306
21310
|
for (const row of recent) {
|
|
21307
21311
|
const record2 = await this.corpus.get(row.docId);
|
|
@@ -21393,14 +21397,14 @@ async function runServe() {
|
|
|
21393
21397
|
const server = new McpServer(
|
|
21394
21398
|
{ name: "llmnesia", version: VERSION },
|
|
21395
21399
|
{
|
|
21396
|
-
instructions: "
|
|
21400
|
+
instructions: "LLMnesia is the user's searchable archive of past AI chats. Whenever the user asks about 'my chats', 'my conversations', or anything they previously said, discussed, decided, or worked on, use LLMnesia before built-in task/thread lists, local file scans, or memory, unless they explicitly ask only about Codex tasks. Use list_conversations with sort='oldest' for their earliest chat, search_conversations to find relevant prior chats, get_conversation to read a full transcript, and list_recent to see what they worked on lately. Search and list results carry a `corpus` block saying when the corpus last received data; if a chat the user expects is missing, check corpus_status before concluding it does not exist."
|
|
21397
21401
|
}
|
|
21398
21402
|
);
|
|
21399
21403
|
server.registerTool(
|
|
21400
21404
|
"search_conversations",
|
|
21401
21405
|
{
|
|
21402
21406
|
title: "Search conversations",
|
|
21403
|
-
description: "Full-text search across the user's past AI conversations. Returns matching conversations with a highlighted snippet, ranked by relevance. Defaults to recall-first matching (any term); pass match_mode to answer questions where a wrong hit matters, such as whether or how often something was discussed.",
|
|
21407
|
+
description: "Full-text search across the user's past AI conversations. Call this whenever the user asks what they said, thought, discussed, decided, or worked on previously \u2014 even if the question doesn't say 'search' \u2014 rather than answering from conversation memory alone or claiming no record exists. Returns matching conversations with a highlighted snippet, ranked by relevance. Defaults to recall-first matching (any term); pass match_mode to answer questions where a wrong hit matters, such as whether or how often something was discussed.",
|
|
21404
21408
|
inputSchema: {
|
|
21405
21409
|
query: external_exports.string().describe("Search terms (natural words; FTS operators are not needed)."),
|
|
21406
21410
|
match_mode: external_exports.enum(["any", "all", "phrase"]).optional().describe(
|
|
@@ -21411,6 +21415,12 @@ async function runServe() {
|
|
|
21411
21415
|
date_from: external_exports.string().optional().describe("Only conversations created on/after this date (ISO or epoch ms)."),
|
|
21412
21416
|
date_to: external_exports.string().optional().describe("Only conversations created on/before this date (ISO or epoch ms)."),
|
|
21413
21417
|
limit: external_exports.number().int().min(1).max(50).optional().describe("Max results (default 10).")
|
|
21418
|
+
},
|
|
21419
|
+
annotations: {
|
|
21420
|
+
readOnlyHint: true,
|
|
21421
|
+
destructiveHint: false,
|
|
21422
|
+
idempotentHint: true,
|
|
21423
|
+
openWorldHint: false
|
|
21414
21424
|
}
|
|
21415
21425
|
},
|
|
21416
21426
|
async (args) => {
|
|
@@ -21449,6 +21459,12 @@ async function runServe() {
|
|
|
21449
21459
|
description: "Return the full transcript of one conversation by its id (the `id` from search results).",
|
|
21450
21460
|
inputSchema: {
|
|
21451
21461
|
id: external_exports.string().describe("The conversation id (docId), e.g. 'claude:abc-123'.")
|
|
21462
|
+
},
|
|
21463
|
+
annotations: {
|
|
21464
|
+
readOnlyHint: true,
|
|
21465
|
+
destructiveHint: false,
|
|
21466
|
+
idempotentHint: true,
|
|
21467
|
+
openWorldHint: false
|
|
21452
21468
|
}
|
|
21453
21469
|
},
|
|
21454
21470
|
async (args) => {
|
|
@@ -21474,6 +21490,12 @@ async function runServe() {
|
|
|
21474
21490
|
description: "List the user's most recently updated conversations (title + summary), newest first.",
|
|
21475
21491
|
inputSchema: {
|
|
21476
21492
|
limit: external_exports.number().int().min(1).max(100).optional().describe("How many to return (default 10).")
|
|
21493
|
+
},
|
|
21494
|
+
annotations: {
|
|
21495
|
+
readOnlyHint: true,
|
|
21496
|
+
destructiveHint: false,
|
|
21497
|
+
idempotentHint: true,
|
|
21498
|
+
openWorldHint: false
|
|
21477
21499
|
}
|
|
21478
21500
|
},
|
|
21479
21501
|
async (args) => {
|
|
@@ -21494,12 +21516,54 @@ async function runServe() {
|
|
|
21494
21516
|
});
|
|
21495
21517
|
}
|
|
21496
21518
|
);
|
|
21519
|
+
server.registerTool(
|
|
21520
|
+
"list_conversations",
|
|
21521
|
+
{
|
|
21522
|
+
title: "List conversations by date",
|
|
21523
|
+
description: "List conversation metadata in chronological order. Use sort='oldest' for the user's earliest or oldest chat, sort='newest' for most recently created chats, and sort='recent' for most recently updated chats. This is the complete corpus chronology, so do not scan LLMnesia's private files with shell commands.",
|
|
21524
|
+
inputSchema: {
|
|
21525
|
+
sort: external_exports.enum(["recent", "oldest", "newest"]).optional().describe("Chronology: recent (default, updated), oldest (created), or newest (created)."),
|
|
21526
|
+
limit: external_exports.number().int().min(1).max(100).optional().describe("How many to return (default 10).")
|
|
21527
|
+
},
|
|
21528
|
+
annotations: {
|
|
21529
|
+
readOnlyHint: true,
|
|
21530
|
+
destructiveHint: false,
|
|
21531
|
+
idempotentHint: true,
|
|
21532
|
+
openWorldHint: false
|
|
21533
|
+
}
|
|
21534
|
+
},
|
|
21535
|
+
async (args) => {
|
|
21536
|
+
const entries = await service.listConversations(args.limit ?? 10, args.sort ?? "recent");
|
|
21537
|
+
const status = await service.corpusStatus({ refresh: false });
|
|
21538
|
+
return jsonResult({
|
|
21539
|
+
count: entries.length,
|
|
21540
|
+
sort: args.sort ?? "recent",
|
|
21541
|
+
corpus: corpusField(status),
|
|
21542
|
+
conversations: entries.map((e) => ({
|
|
21543
|
+
id: e.docId,
|
|
21544
|
+
title: e.title,
|
|
21545
|
+
platform: e.platform,
|
|
21546
|
+
createdAt: e.createdAt,
|
|
21547
|
+
updatedAt: e.updatedAt,
|
|
21548
|
+
messageCount: e.messageCount,
|
|
21549
|
+
pinned: e.pinned,
|
|
21550
|
+
summary: e.summary ?? e.preview
|
|
21551
|
+
}))
|
|
21552
|
+
});
|
|
21553
|
+
}
|
|
21554
|
+
);
|
|
21497
21555
|
server.registerTool(
|
|
21498
21556
|
"corpus_status",
|
|
21499
21557
|
{
|
|
21500
21558
|
title: "Corpus status",
|
|
21501
21559
|
description: "Report how current the local corpus is: when browser data last reached it, how many conversations it holds, and the newest conversation it knows about. Use this when a conversation the user is sure they had cannot be found, to tell a genuinely missing chat apart from a corpus that has stopped receiving updates from the browser extension.",
|
|
21502
|
-
inputSchema: {}
|
|
21560
|
+
inputSchema: {},
|
|
21561
|
+
annotations: {
|
|
21562
|
+
readOnlyHint: true,
|
|
21563
|
+
destructiveHint: false,
|
|
21564
|
+
idempotentHint: true,
|
|
21565
|
+
openWorldHint: false
|
|
21566
|
+
}
|
|
21503
21567
|
},
|
|
21504
21568
|
async () => {
|
|
21505
21569
|
const status = await service.corpusStatus();
|
|
@@ -21511,7 +21575,13 @@ async function runServe() {
|
|
|
21511
21575
|
{
|
|
21512
21576
|
title: "Save conversation",
|
|
21513
21577
|
description: "Add or update a conversation in the user's local corpus. Provide platform, title, and the messages; identity is keyed on docId (or platform:sourceId), so re-saving the same conversation updates it in place.",
|
|
21514
|
-
inputSchema: saveConversationSchema.shape
|
|
21578
|
+
inputSchema: saveConversationSchema.shape,
|
|
21579
|
+
annotations: {
|
|
21580
|
+
readOnlyHint: false,
|
|
21581
|
+
destructiveHint: false,
|
|
21582
|
+
idempotentHint: false,
|
|
21583
|
+
openWorldHint: false
|
|
21584
|
+
}
|
|
21515
21585
|
},
|
|
21516
21586
|
async (args) => {
|
|
21517
21587
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@llmnesia/mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"comment:mcpName": "The MCP Registry's ownership marker: it must equal server.json's `name`, and the namespace must match how you authenticate with mcp-publisher. `com.llmnesia/...` requires DNS auth (a TXT record on llmnesia.com) — see docs/MCP_INSTALLER_BOARD.md.",
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
},
|
|
49
49
|
"comment:deps": "This package publishes with ZERO runtime dependencies — everything is bundled into dist by tsup (see noExternal in tsup.config.ts). That is not just an optimisation: `install` copies dist/ to ~/.llmnesia/bin so MCP clients can spawn it by absolute path, and nothing puts a node_modules beside that copy, so any unbundled import would fail there with ERR_MODULE_NOT_FOUND. Listing them as runtime deps would also be false comfort — npm would install them next to the package, not next to the copy that actually runs. The workspace packages additionally must not appear in the install graph at all: shared-types does not exist on the registry, and the published claude-code-host is an older build without the exports this package needs.",
|
|
50
50
|
"devDependencies": {
|
|
51
|
-
"@llmnesia/
|
|
51
|
+
"@llmnesia/local-agent-host": "*",
|
|
52
52
|
"@llmnesia/shared-types": "*",
|
|
53
53
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
54
54
|
"@types/node": "^22.13.0",
|