@xultrax-web/agent-memory-mcp 0.8.1 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +64 -3
- package/dist/index.js +308 -10
- package/dist/tui.js +183 -0
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -200,6 +200,9 @@ Custom path:
|
|
|
200
200
|
| `verify_memory` | Re-evaluate a memory's claims. Extracts URLs/dates/file refs, flags stale-date signals, returns type-specific verification heuristics. Pairs with the `audit_stale` prompt. |
|
|
201
201
|
| `find_backlinks` | List memories that link to the given memory via `[[wiki-link]]` syntax in their bodies. Useful for "what references this" views. |
|
|
202
202
|
| `find_related` | Surface memories related to one by combining outbound links, inbound backlinks, shared tags, type match, and content similarity. Navigates the memory graph by association. |
|
|
203
|
+
| `sync_status` | Report git-sync state: remote URL, branch, uncommitted local files, ahead/behind origin. |
|
|
204
|
+
| `sync_push` | Commit local memory changes + push to the configured git remote. Auto-timestamps the commit message if none given. |
|
|
205
|
+
| `sync_pull` | Fast-forward pull from the git remote. Refuses to pull if local changes are uncommitted. |
|
|
203
206
|
|
|
204
207
|
### Prompts
|
|
205
208
|
|
|
@@ -265,8 +268,45 @@ agent-memory save my-mem --type project --description "X" --content "Body" --tag
|
|
|
265
268
|
agent-memory list --tags "production" # filter by tag (intersection)
|
|
266
269
|
agent-memory backlinks deploy-process # memories that link to deploy-process
|
|
267
270
|
agent-memory related deploy-process # ranked discovery: links + tags + similarity
|
|
271
|
+
agent-memory sync init git@github.com:you/agent-memory.git # multi-machine setup (one-time)
|
|
272
|
+
agent-memory sync push # commit + push local changes
|
|
273
|
+
agent-memory sync pull # fast-forward from remote
|
|
274
|
+
agent-memory sync status # local + ahead/behind state
|
|
275
|
+
agent-memory ui # launch the TUI (browse + edit interactively)
|
|
268
276
|
```
|
|
269
277
|
|
|
278
|
+
### Multi-machine memory (git sync)
|
|
279
|
+
|
|
280
|
+
The killer feature for file-based memory: every dev machine has git, and markdown merges cleanly. `agent-memory sync` turns `.agent-memory/` into a git repo pointed at a (private) remote, and your memories follow you across desktop/laptop/server.
|
|
281
|
+
|
|
282
|
+
```bash
|
|
283
|
+
# One-time setup
|
|
284
|
+
agent-memory sync init git@github.com:you/agent-memory.git
|
|
285
|
+
|
|
286
|
+
# End of the day on desktop
|
|
287
|
+
agent-memory sync push
|
|
288
|
+
|
|
289
|
+
# Pick up your laptop before bed
|
|
290
|
+
agent-memory sync pull
|
|
291
|
+
|
|
292
|
+
# Save a new memory while reading in bed
|
|
293
|
+
agent-memory save bedtime-thought --type project --description "..." --content "..."
|
|
294
|
+
agent-memory sync push
|
|
295
|
+
|
|
296
|
+
# Next morning at desktop
|
|
297
|
+
agent-memory sync pull # picks up the bedtime memory
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
What's NOT synced (per-machine state, kept local):
|
|
301
|
+
|
|
302
|
+
- `.lock` — per-process file lock
|
|
303
|
+
- `.events.jsonl` — per-machine audit trail
|
|
304
|
+
- `.trash/` — soft-delete staging
|
|
305
|
+
|
|
306
|
+
What IS synced: every memory file, the `MEMORY.md` index, and any `.gitignore` you add.
|
|
307
|
+
|
|
308
|
+
Commits use the identity `agent-memory <agent-memory@local>` by default — set `GIT_AUTHOR_EMAIL` / `GIT_COMMITTER_EMAIL` in your environment if you want per-machine attribution.
|
|
309
|
+
|
|
270
310
|
### Audit log + structured logging
|
|
271
311
|
|
|
272
312
|
Every mutation appends one JSON line to `.agent-memory/.events.jsonl`:
|
|
@@ -379,12 +419,33 @@ This server is built to be used daily, not to demo well once.
|
|
|
379
419
|
- **`find_backlinks`** tool + `agent-memory backlinks <name>` CLI — "what links to this".
|
|
380
420
|
- **`find_related`** tool + `agent-memory related <name>` CLI — combines outbound + inbound links, shared tags, type match, and content similarity into a ranked discovery view.
|
|
381
421
|
|
|
382
|
-
**
|
|
422
|
+
**Shipped in v0.10 · the visual identity (TUI):**
|
|
423
|
+
|
|
424
|
+
- **`agent-memory ui`** — Ink-based terminal UI for browsing, filtering, searching, and editing memories without leaving the terminal.
|
|
425
|
+
- Type-filter quick-keys (0-4 cycle through all/user/feedback/project/reference)
|
|
426
|
+
- Fuzzy live search with `/`
|
|
427
|
+
- `e` opens the highlighted memory in `$EDITOR` (vim/notepad/nano/whatever) — saves back to disk
|
|
428
|
+
- `d` soft-deletes with `y/n` confirmation
|
|
429
|
+
- Detail pane previews the body of the selected memory
|
|
430
|
+
- Color-coded by type, tag chips inline
|
|
431
|
+
|
|
432
|
+
**Shipped in v0.9 · the moat — multi-machine memory via git:**
|
|
433
|
+
|
|
434
|
+
- **`agent-memory sync init <remote-url>`** — convert `.agent-memory/` into a git repo, push to remote.
|
|
435
|
+
- **`agent-memory sync push`** — auto-commit local changes + push.
|
|
436
|
+
- **`agent-memory sync pull`** — fast-forward from remote.
|
|
437
|
+
- **`agent-memory sync status`** — local state + commits ahead/behind origin.
|
|
438
|
+
- **`agent-memory sync log`** — history of cross-machine memory changes.
|
|
439
|
+
- **`sync_status` / `sync_push` / `sync_pull` MCP tools** — the LLM can do this too.
|
|
440
|
+
- Per-machine state (`.lock`, `.events.jsonl`, `.trash/`) auto-excluded from sync.
|
|
441
|
+
- Default commit identity injected (`agent-memory@local`) so machines without `git config --global user.email` work without setup.
|
|
442
|
+
|
|
443
|
+
**Landing in v0.11+:**
|
|
383
444
|
|
|
384
445
|
- Folder support (`.agent-memory/work/`, `.agent-memory/personal/`)
|
|
385
|
-
- TUI / web UI for browsing + editing memories in a clean interface
|
|
386
|
-
- `agent-memory sync` for git-backed multi-machine memory (the moat)
|
|
387
446
|
- Memory packs for shareable curated bundles
|
|
447
|
+
- Web UI for browser-based memory browsing (companion to the TUI)
|
|
448
|
+
- Auto-context loading (LLM gets relevant memories transparently before each prompt)
|
|
388
449
|
|
|
389
450
|
---
|
|
390
451
|
|
package/dist/index.js
CHANGED
|
@@ -26,9 +26,11 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
26
26
|
import { CallToolRequestSchema, GetPromptRequestSchema, ListPromptsRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
27
27
|
import Fuse from "fuse.js";
|
|
28
28
|
import matter from "gray-matter";
|
|
29
|
+
import { spawnSync } from "node:child_process";
|
|
29
30
|
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync, } from "node:fs";
|
|
30
31
|
import { homedir } from "node:os";
|
|
31
32
|
import { join, resolve } from "node:path";
|
|
33
|
+
import { fileURLToPath } from "node:url";
|
|
32
34
|
import lockfile from "proper-lockfile";
|
|
33
35
|
// -------------------------------------------------------------
|
|
34
36
|
// Storage location resolution
|
|
@@ -42,7 +44,7 @@ function resolveStorageDir() {
|
|
|
42
44
|
}
|
|
43
45
|
return resolve(process.cwd(), ".agent-memory");
|
|
44
46
|
}
|
|
45
|
-
const MEMORY_DIR = resolveStorageDir();
|
|
47
|
+
export const MEMORY_DIR = resolveStorageDir();
|
|
46
48
|
const INDEX_FILE = join(MEMORY_DIR, "MEMORY.md");
|
|
47
49
|
const TRASH_DIR = join(MEMORY_DIR, ".trash");
|
|
48
50
|
const LOCK_FILE = join(MEMORY_DIR, ".lock");
|
|
@@ -177,10 +179,10 @@ const SLUG_PATTERN = /^[a-z0-9][a-z0-9_-]{0,80}$/;
|
|
|
177
179
|
const TAG_PATTERN = /^[a-z0-9][a-z0-9_-]{0,40}$/;
|
|
178
180
|
// Wiki-links: [[memory-name]] · names follow SLUG_PATTERN rules
|
|
179
181
|
const WIKI_LINK_PATTERN = /\[\[([a-z0-9][a-z0-9_-]{0,80})\]\]/g;
|
|
180
|
-
function memoryFilePath(name) {
|
|
182
|
+
export function memoryFilePath(name) {
|
|
181
183
|
return join(MEMORY_DIR, `${name}.md`);
|
|
182
184
|
}
|
|
183
|
-
function readMemory(name) {
|
|
185
|
+
export function readMemory(name) {
|
|
184
186
|
const fp = memoryFilePath(name);
|
|
185
187
|
if (!existsSync(fp))
|
|
186
188
|
return null;
|
|
@@ -196,7 +198,7 @@ function readMemory(name) {
|
|
|
196
198
|
filePath: fp,
|
|
197
199
|
};
|
|
198
200
|
}
|
|
199
|
-
function listMemoryFiles() {
|
|
201
|
+
export function listMemoryFiles() {
|
|
200
202
|
if (!existsSync(MEMORY_DIR))
|
|
201
203
|
return [];
|
|
202
204
|
return readdirSync(MEMORY_DIR)
|
|
@@ -575,7 +577,7 @@ function toolRelevantMemories(args) {
|
|
|
575
577
|
}
|
|
576
578
|
return sections.join("\n");
|
|
577
579
|
}
|
|
578
|
-
function toolDeleteMemory(args) {
|
|
580
|
+
export function toolDeleteMemory(args) {
|
|
579
581
|
const name = String(args.name ?? "").trim();
|
|
580
582
|
if (!SLUG_PATTERN.test(name))
|
|
581
583
|
throw new Error(`Invalid name "${name}".`);
|
|
@@ -913,6 +915,218 @@ function toolFindRelated(args) {
|
|
|
913
915
|
return lines.join("\n");
|
|
914
916
|
}
|
|
915
917
|
// -------------------------------------------------------------
|
|
918
|
+
// Git sync · multi-machine memory via git remote
|
|
919
|
+
// -------------------------------------------------------------
|
|
920
|
+
//
|
|
921
|
+
// The killer feature for file-based memory: every dev machine has git,
|
|
922
|
+
// and markdown files merge cleanly. Convert .agent-memory/ into a git
|
|
923
|
+
// repo, point it at a (private) GitHub repo, and `sync push` / `sync
|
|
924
|
+
// pull` becomes the multi-machine story.
|
|
925
|
+
//
|
|
926
|
+
// Usage flow:
|
|
927
|
+
// agent-memory sync init git@github.com:you/agent-memory.git
|
|
928
|
+
// ... save some memories ...
|
|
929
|
+
// agent-memory sync push # commit + push
|
|
930
|
+
// # later, on another machine:
|
|
931
|
+
// agent-memory sync pull # pull updates
|
|
932
|
+
// agent-memory sync status # ahead/behind/clean
|
|
933
|
+
//
|
|
934
|
+
// Files we EXCLUDE from sync (per-machine state):
|
|
935
|
+
// .lock · proper-lockfile per-process lock
|
|
936
|
+
// .events.jsonl · per-machine audit log
|
|
937
|
+
// .trash/ · per-machine soft-delete staging
|
|
938
|
+
const SYNC_GITIGNORE = "# Per-machine state · do not sync across devices\n" +
|
|
939
|
+
".lock\n" +
|
|
940
|
+
".events.jsonl\n" +
|
|
941
|
+
".trash/\n";
|
|
942
|
+
function git(args) {
|
|
943
|
+
// Inject a default commit identity so machines without `git config
|
|
944
|
+
// --global user.email` can still sync. Env vars are git's highest-
|
|
945
|
+
// precedence identity source, so they override any later config —
|
|
946
|
+
// fine for automated memory-sync where per-commit attribution
|
|
947
|
+
// doesn't matter. Honors operator overrides if set in the environment.
|
|
948
|
+
const result = spawnSync("git", args, {
|
|
949
|
+
cwd: MEMORY_DIR,
|
|
950
|
+
encoding: "utf8",
|
|
951
|
+
env: {
|
|
952
|
+
...process.env,
|
|
953
|
+
GIT_AUTHOR_NAME: process.env.GIT_AUTHOR_NAME ?? "agent-memory",
|
|
954
|
+
GIT_AUTHOR_EMAIL: process.env.GIT_AUTHOR_EMAIL ?? "agent-memory@local",
|
|
955
|
+
GIT_COMMITTER_NAME: process.env.GIT_COMMITTER_NAME ?? "agent-memory",
|
|
956
|
+
GIT_COMMITTER_EMAIL: process.env.GIT_COMMITTER_EMAIL ?? "agent-memory@local",
|
|
957
|
+
},
|
|
958
|
+
});
|
|
959
|
+
return {
|
|
960
|
+
stdout: (result.stdout ?? "").trim(),
|
|
961
|
+
stderr: (result.stderr ?? "").trim(),
|
|
962
|
+
exitCode: result.status ?? -1,
|
|
963
|
+
};
|
|
964
|
+
}
|
|
965
|
+
function isGitRepo() {
|
|
966
|
+
return existsSync(join(MEMORY_DIR, ".git"));
|
|
967
|
+
}
|
|
968
|
+
function requireRepo() {
|
|
969
|
+
if (!isGitRepo()) {
|
|
970
|
+
throw new Error(`${MEMORY_DIR} is not a git repo. Run 'agent-memory sync init <remote-url>' first.`);
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
function toolSyncInit(args) {
|
|
974
|
+
const remoteUrl = String(args.remote ?? args.url ?? "").trim();
|
|
975
|
+
if (!remoteUrl) {
|
|
976
|
+
throw new Error("Usage: agent-memory sync init <remote-url>\nExample: agent-memory sync init git@github.com:you/agent-memory.git");
|
|
977
|
+
}
|
|
978
|
+
ensureStorage();
|
|
979
|
+
if (isGitRepo()) {
|
|
980
|
+
return `${MEMORY_DIR} is already a git repo. Use 'sync push' or 'sync pull'.`;
|
|
981
|
+
}
|
|
982
|
+
const init = git(["init", "-b", "main"]);
|
|
983
|
+
if (init.exitCode !== 0)
|
|
984
|
+
throw new Error(`git init failed: ${init.stderr}`);
|
|
985
|
+
writeFileSync(join(MEMORY_DIR, ".gitignore"), SYNC_GITIGNORE, "utf8");
|
|
986
|
+
const addRemote = git(["remote", "add", "origin", remoteUrl]);
|
|
987
|
+
if (addRemote.exitCode !== 0)
|
|
988
|
+
throw new Error(`git remote add failed: ${addRemote.stderr}`);
|
|
989
|
+
const add = git(["add", "-A"]);
|
|
990
|
+
if (add.exitCode !== 0)
|
|
991
|
+
throw new Error(`git add failed: ${add.stderr}`);
|
|
992
|
+
const commit = git(["commit", "-m", "agent-memory · initial sync"]);
|
|
993
|
+
// commit can fail if there's nothing to commit (empty store) — that's OK
|
|
994
|
+
if (commit.exitCode !== 0 && !commit.stderr.includes("nothing to commit")) {
|
|
995
|
+
log("warn", "initial commit had no changes", { stderr: commit.stderr });
|
|
996
|
+
}
|
|
997
|
+
const push = git(["push", "-u", "origin", "main"]);
|
|
998
|
+
logEvent("sync_init", { remote: remoteUrl, pushed: push.exitCode === 0 });
|
|
999
|
+
const lines = [
|
|
1000
|
+
c(ANSI.green, "✓ initialized memory sync"),
|
|
1001
|
+
` storage : ${MEMORY_DIR}`,
|
|
1002
|
+
` remote : ${remoteUrl}`,
|
|
1003
|
+
` branch : main`,
|
|
1004
|
+
];
|
|
1005
|
+
if (push.exitCode !== 0) {
|
|
1006
|
+
lines.push("");
|
|
1007
|
+
lines.push(c(ANSI.yellow, "Initial push failed (remote may not exist yet):"));
|
|
1008
|
+
lines.push(` ${push.stderr.split("\n")[0]}`);
|
|
1009
|
+
lines.push("");
|
|
1010
|
+
lines.push("Create the empty remote on GitHub (or your git host), then run:");
|
|
1011
|
+
lines.push(" agent-memory sync push");
|
|
1012
|
+
}
|
|
1013
|
+
else {
|
|
1014
|
+
lines.push("");
|
|
1015
|
+
lines.push("Future commands: 'sync push' / 'sync pull' / 'sync status' / 'sync log'");
|
|
1016
|
+
}
|
|
1017
|
+
return lines.join("\n");
|
|
1018
|
+
}
|
|
1019
|
+
function toolSyncStatus(_args) {
|
|
1020
|
+
if (!isGitRepo()) {
|
|
1021
|
+
return `${MEMORY_DIR} is not a git repo. Use 'sync init <remote-url>' to set it up.`;
|
|
1022
|
+
}
|
|
1023
|
+
const remote = git(["remote", "get-url", "origin"]);
|
|
1024
|
+
const branch = git(["branch", "--show-current"]);
|
|
1025
|
+
const fetch = git(["fetch", "origin", "--quiet"]);
|
|
1026
|
+
const offline = fetch.exitCode !== 0;
|
|
1027
|
+
const status = git(["status", "--porcelain"]);
|
|
1028
|
+
const localChanges = status.stdout.split("\n").filter(Boolean).length;
|
|
1029
|
+
const lines = [];
|
|
1030
|
+
lines.push(c(ANSI.bold, "agent-memory sync · status"));
|
|
1031
|
+
lines.push(` storage : ${MEMORY_DIR}`);
|
|
1032
|
+
lines.push(` remote : ${remote.stdout || c(ANSI.yellow, "(none configured)")}`);
|
|
1033
|
+
lines.push(` branch : ${branch.stdout || "(unknown)"}`);
|
|
1034
|
+
if (offline) {
|
|
1035
|
+
lines.push(` fetch : ${c(ANSI.yellow, "offline — couldn't reach remote")}`);
|
|
1036
|
+
}
|
|
1037
|
+
lines.push("");
|
|
1038
|
+
lines.push(c(ANSI.bold, "local state:"));
|
|
1039
|
+
if (localChanges === 0) {
|
|
1040
|
+
lines.push(` ${c(ANSI.green, "✓ clean")} — no uncommitted changes`);
|
|
1041
|
+
}
|
|
1042
|
+
else {
|
|
1043
|
+
lines.push(` ${c(ANSI.yellow, `${localChanges} file(s) uncommitted`)} — run 'sync push' to commit + send`);
|
|
1044
|
+
}
|
|
1045
|
+
if (!offline && branch.stdout) {
|
|
1046
|
+
const ahead = git(["rev-list", "--count", `origin/${branch.stdout}..HEAD`]);
|
|
1047
|
+
const behind = git(["rev-list", "--count", `HEAD..origin/${branch.stdout}`]);
|
|
1048
|
+
const aheadN = Number(ahead.stdout || "0");
|
|
1049
|
+
const behindN = Number(behind.stdout || "0");
|
|
1050
|
+
lines.push("");
|
|
1051
|
+
lines.push(c(ANSI.bold, "vs origin:"));
|
|
1052
|
+
if (aheadN === 0 && behindN === 0) {
|
|
1053
|
+
lines.push(` ${c(ANSI.green, "✓ in sync")}`);
|
|
1054
|
+
}
|
|
1055
|
+
else {
|
|
1056
|
+
if (aheadN > 0)
|
|
1057
|
+
lines.push(` ${c(ANSI.cyan, `↑ ${aheadN} commit(s) ahead`)} — run 'sync push'`);
|
|
1058
|
+
if (behindN > 0)
|
|
1059
|
+
lines.push(` ${c(ANSI.cyan, `↓ ${behindN} commit(s) behind`)} — run 'sync pull'`);
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
return lines.join("\n");
|
|
1063
|
+
}
|
|
1064
|
+
function toolSyncPush(args) {
|
|
1065
|
+
requireRepo();
|
|
1066
|
+
const message = args.message
|
|
1067
|
+
? String(args.message)
|
|
1068
|
+
: `agent-memory · sync ${new Date().toISOString().slice(0, 19).replace("T", " ")}Z`;
|
|
1069
|
+
const add = git(["add", "-A"]);
|
|
1070
|
+
if (add.exitCode !== 0)
|
|
1071
|
+
throw new Error(`git add failed: ${add.stderr}`);
|
|
1072
|
+
const status = git(["status", "--porcelain"]);
|
|
1073
|
+
const hadChanges = status.stdout.length > 0;
|
|
1074
|
+
if (hadChanges) {
|
|
1075
|
+
const commit = git(["commit", "-m", message]);
|
|
1076
|
+
if (commit.exitCode !== 0)
|
|
1077
|
+
throw new Error(`commit failed: ${commit.stderr}`);
|
|
1078
|
+
}
|
|
1079
|
+
const push = git(["push"]);
|
|
1080
|
+
if (push.exitCode !== 0)
|
|
1081
|
+
throw new Error(`push failed: ${push.stderr}`);
|
|
1082
|
+
logEvent("sync_push", { hadChanges, commitMessage: hadChanges ? message : null });
|
|
1083
|
+
return hadChanges
|
|
1084
|
+
? c(ANSI.green, `✓ committed local changes + pushed to remote\n message: ${message}`)
|
|
1085
|
+
: c(ANSI.green, `✓ nothing new locally; pushed any unpushed commits`);
|
|
1086
|
+
}
|
|
1087
|
+
function toolSyncPull(_args) {
|
|
1088
|
+
requireRepo();
|
|
1089
|
+
const status = git(["status", "--porcelain"]);
|
|
1090
|
+
if (status.stdout) {
|
|
1091
|
+
return (c(ANSI.yellow, "Local changes uncommitted.") +
|
|
1092
|
+
"\nRun 'agent-memory sync push' first to commit them, then pull.");
|
|
1093
|
+
}
|
|
1094
|
+
const pull = git(["pull", "--ff-only"]);
|
|
1095
|
+
if (pull.exitCode !== 0) {
|
|
1096
|
+
return (c(ANSI.red, "✗ pull failed:") +
|
|
1097
|
+
`\n ${pull.stderr.split("\n").slice(0, 3).join("\n ")}\n\n` +
|
|
1098
|
+
`Likely diverged history (commits on both sides). Resolve manually:\n` +
|
|
1099
|
+
` cd ${MEMORY_DIR}\n` +
|
|
1100
|
+
` git pull # do the merge by hand`);
|
|
1101
|
+
}
|
|
1102
|
+
logEvent("sync_pull", { output: pull.stdout.split("\n")[0] });
|
|
1103
|
+
return c(ANSI.green, "✓ pulled from remote") + (pull.stdout ? `\n${pull.stdout}` : "");
|
|
1104
|
+
}
|
|
1105
|
+
function toolSyncLog(args) {
|
|
1106
|
+
if (!isGitRepo())
|
|
1107
|
+
return `${MEMORY_DIR} is not a git repo.`;
|
|
1108
|
+
const limit = args.limit ? Number(args.limit) : 20;
|
|
1109
|
+
const log = git(["log", `--max-count=${limit}`, "--pretty=format:%h %ci %s", "--no-decorate"]);
|
|
1110
|
+
if (log.exitCode !== 0)
|
|
1111
|
+
return `git log failed: ${log.stderr}`;
|
|
1112
|
+
if (!log.stdout)
|
|
1113
|
+
return "No sync history yet.";
|
|
1114
|
+
const lines = [];
|
|
1115
|
+
lines.push(c(ANSI.bold, `Recent sync history (last ${limit}):`));
|
|
1116
|
+
lines.push("");
|
|
1117
|
+
for (const line of log.stdout.split("\n")) {
|
|
1118
|
+
// Format: <short-sha> <iso-date> <subject>
|
|
1119
|
+
const m = line.match(/^(\S+)\s+(\S+\s+\S+\s+\S+)\s+(.*)$/);
|
|
1120
|
+
if (m) {
|
|
1121
|
+
lines.push(` ${c(ANSI.cyan, m[1])} ${c(ANSI.dim, m[2])} ${m[3]}`);
|
|
1122
|
+
}
|
|
1123
|
+
else {
|
|
1124
|
+
lines.push(` ${line}`);
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
return lines.join("\n");
|
|
1128
|
+
}
|
|
1129
|
+
// -------------------------------------------------------------
|
|
916
1130
|
// Stats · operator dashboard
|
|
917
1131
|
// -------------------------------------------------------------
|
|
918
1132
|
function toolStats(_args) {
|
|
@@ -1022,7 +1236,7 @@ function actionColor(action) {
|
|
|
1022
1236
|
// -------------------------------------------------------------
|
|
1023
1237
|
// Server wiring
|
|
1024
1238
|
// -------------------------------------------------------------
|
|
1025
|
-
const server = new Server({ name: "agent-memory", version: "0.
|
|
1239
|
+
const server = new Server({ name: "agent-memory", version: "0.10.0" }, { capabilities: { tools: {}, resources: {}, prompts: {} } });
|
|
1026
1240
|
// -------------------------------------------------------------
|
|
1027
1241
|
// Resource URI scheme
|
|
1028
1242
|
// -------------------------------------------------------------
|
|
@@ -1434,6 +1648,29 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
1434
1648
|
required: ["name"],
|
|
1435
1649
|
},
|
|
1436
1650
|
},
|
|
1651
|
+
{
|
|
1652
|
+
name: "sync_status",
|
|
1653
|
+
description: "Report the git-sync state of the memory store: remote URL, branch, local uncommitted files, commits ahead/behind origin. Use this before opening a new session to know if you have stale memories from another machine.",
|
|
1654
|
+
inputSchema: { type: "object", properties: {} },
|
|
1655
|
+
},
|
|
1656
|
+
{
|
|
1657
|
+
name: "sync_push",
|
|
1658
|
+
description: "Commit any local memory changes and push to the configured git remote. Auto-generates a timestamped commit message if none provided. Use at the end of a session to make memories available on your other machines.",
|
|
1659
|
+
inputSchema: {
|
|
1660
|
+
type: "object",
|
|
1661
|
+
properties: {
|
|
1662
|
+
message: {
|
|
1663
|
+
type: "string",
|
|
1664
|
+
description: "Optional commit message. Defaults to a timestamp.",
|
|
1665
|
+
},
|
|
1666
|
+
},
|
|
1667
|
+
},
|
|
1668
|
+
},
|
|
1669
|
+
{
|
|
1670
|
+
name: "sync_pull",
|
|
1671
|
+
description: "Pull memory updates from the configured git remote (fast-forward only). Run at the start of a session to get memories saved on other machines. Refuses to pull if there are uncommitted local changes.",
|
|
1672
|
+
inputSchema: { type: "object", properties: {} },
|
|
1673
|
+
},
|
|
1437
1674
|
],
|
|
1438
1675
|
}));
|
|
1439
1676
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
@@ -1480,6 +1717,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1480
1717
|
case "find_related":
|
|
1481
1718
|
result = toolFindRelated(args);
|
|
1482
1719
|
break;
|
|
1720
|
+
case "sync_status":
|
|
1721
|
+
result = toolSyncStatus(args);
|
|
1722
|
+
break;
|
|
1723
|
+
case "sync_push":
|
|
1724
|
+
result = toolSyncPush(args);
|
|
1725
|
+
break;
|
|
1726
|
+
case "sync_pull":
|
|
1727
|
+
result = toolSyncPull(args);
|
|
1728
|
+
break;
|
|
1483
1729
|
default:
|
|
1484
1730
|
throw new Error(`Unknown tool: ${name}`);
|
|
1485
1731
|
}
|
|
@@ -1515,6 +1761,8 @@ const CLI_COMMANDS = new Set([
|
|
|
1515
1761
|
"verify",
|
|
1516
1762
|
"backlinks",
|
|
1517
1763
|
"related",
|
|
1764
|
+
"sync",
|
|
1765
|
+
"ui",
|
|
1518
1766
|
"import-claude-code",
|
|
1519
1767
|
"help",
|
|
1520
1768
|
"--help",
|
|
@@ -1670,6 +1918,36 @@ async function cliMain(command, rest) {
|
|
|
1670
1918
|
}) + "\n");
|
|
1671
1919
|
return 0;
|
|
1672
1920
|
}
|
|
1921
|
+
case "sync": {
|
|
1922
|
+
const sub = positional[0];
|
|
1923
|
+
if (!sub) {
|
|
1924
|
+
throw new Error("Usage: agent-memory sync <init|push|pull|status|log>\n" +
|
|
1925
|
+
" init <remote-url> set up a new memory-sync repo\n" +
|
|
1926
|
+
" push [--message X] commit + push local changes\n" +
|
|
1927
|
+
" pull fast-forward pull from remote\n" +
|
|
1928
|
+
" status show local + remote state\n" +
|
|
1929
|
+
" log [--limit N] recent sync commit history");
|
|
1930
|
+
}
|
|
1931
|
+
switch (sub) {
|
|
1932
|
+
case "init":
|
|
1933
|
+
process.stdout.write(toolSyncInit({ remote: positional[1] }) + "\n");
|
|
1934
|
+
return 0;
|
|
1935
|
+
case "push":
|
|
1936
|
+
process.stdout.write(toolSyncPush({ message: flags.message }) + "\n");
|
|
1937
|
+
return 0;
|
|
1938
|
+
case "pull":
|
|
1939
|
+
process.stdout.write(toolSyncPull({}) + "\n");
|
|
1940
|
+
return 0;
|
|
1941
|
+
case "status":
|
|
1942
|
+
process.stdout.write(toolSyncStatus({}) + "\n");
|
|
1943
|
+
return 0;
|
|
1944
|
+
case "log":
|
|
1945
|
+
process.stdout.write(toolSyncLog({ limit: flags.limit ? Number(flags.limit) : undefined }) + "\n");
|
|
1946
|
+
return 0;
|
|
1947
|
+
default:
|
|
1948
|
+
throw new Error(`Unknown sync subcommand: ${sub}. Try 'sync' for help.`);
|
|
1949
|
+
}
|
|
1950
|
+
}
|
|
1673
1951
|
case "log": {
|
|
1674
1952
|
process.stdout.write(toolLogEvents({
|
|
1675
1953
|
tail: flags.tail ? Number(flags.tail) : undefined,
|
|
@@ -1677,6 +1955,13 @@ async function cliMain(command, rest) {
|
|
|
1677
1955
|
}) + "\n");
|
|
1678
1956
|
return 0;
|
|
1679
1957
|
}
|
|
1958
|
+
case "ui": {
|
|
1959
|
+
// Dynamic import so Ink + React only load when the TUI runs,
|
|
1960
|
+
// keeping cold-start fast for MCP server + every other CLI command.
|
|
1961
|
+
const { runTui } = await import("./tui.js");
|
|
1962
|
+
await runTui();
|
|
1963
|
+
return 0;
|
|
1964
|
+
}
|
|
1680
1965
|
case "import-claude-code": {
|
|
1681
1966
|
return importClaudeCode({
|
|
1682
1967
|
source: flags.source ? String(flags.source) : undefined,
|
|
@@ -1728,6 +2013,14 @@ COMMANDS
|
|
|
1728
2013
|
backlinks <name> List memories that link to <name> via [[wiki-links]].
|
|
1729
2014
|
related <name> [--max N] Surface related memories via outbound + inbound links,
|
|
1730
2015
|
shared tags, type match, content similarity.
|
|
2016
|
+
sync <init|push|pull|status|log> Multi-machine memory via git remote.
|
|
2017
|
+
sync init <remote-url> Initialize .agent-memory/ as a git repo + push.
|
|
2018
|
+
sync push [--message X] Commit local changes + push to remote.
|
|
2019
|
+
sync pull Fast-forward pull from remote.
|
|
2020
|
+
sync status Show local + ahead/behind state.
|
|
2021
|
+
sync log [--limit N] Recent sync commit history.
|
|
2022
|
+
ui Launch the TUI · browse, filter, search, edit memories
|
|
2023
|
+
in a clean terminal interface (Ink-based).
|
|
1731
2024
|
import-claude-code [--source <path>] [--project <pat>] [--overwrite] [--dry-run]
|
|
1732
2025
|
Walk ~/.claude/projects/*/memory/ and
|
|
1733
2026
|
import each memory into the current store.
|
|
@@ -1850,7 +2143,12 @@ async function main() {
|
|
|
1850
2143
|
await server.connect(transport);
|
|
1851
2144
|
process.stderr.write(`agent-memory-mcp · storage: ${MEMORY_DIR}\n`);
|
|
1852
2145
|
}
|
|
1853
|
-
main().
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
2146
|
+
// Only auto-run main() when invoked directly. Importing this file
|
|
2147
|
+
// (e.g. from src/tui.tsx) should not trigger the dispatch.
|
|
2148
|
+
const isEntryPoint = process.argv[1] && process.argv[1] === fileURLToPath(import.meta.url);
|
|
2149
|
+
if (isEntryPoint) {
|
|
2150
|
+
main().catch((err) => {
|
|
2151
|
+
process.stderr.write(`Fatal: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
2152
|
+
process.exit(1);
|
|
2153
|
+
});
|
|
2154
|
+
}
|
package/dist/tui.js
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* agent-memory-mcp · TUI
|
|
4
|
+
*
|
|
5
|
+
* The visual face of the project — Ink-based terminal UI for browsing,
|
|
6
|
+
* filtering, searching, and editing memories.
|
|
7
|
+
*
|
|
8
|
+
* Layout:
|
|
9
|
+
* ┌─ agent-memory ──────────────────────────────────────────┐
|
|
10
|
+
* │ [all] user feedback project reference · N memories │
|
|
11
|
+
* ├──────────────────────────────────────────────────────────┤
|
|
12
|
+
* │ memory list (scrolling) │
|
|
13
|
+
* │ ▶ highlighted name [type] · tags │
|
|
14
|
+
* │ description │
|
|
15
|
+
* ├──────────────────────────────────────────────────────────┤
|
|
16
|
+
* │ detail pane for highlighted memory │
|
|
17
|
+
* ├──────────────────────────────────────────────────────────┤
|
|
18
|
+
* │ key hints footer │
|
|
19
|
+
* └──────────────────────────────────────────────────────────┘
|
|
20
|
+
*
|
|
21
|
+
* Launched via `agent-memory ui`. Dynamic-imported from index.ts so
|
|
22
|
+
* Ink + React only load when the TUI is actually invoked.
|
|
23
|
+
*/
|
|
24
|
+
import { Box, render, Text, useApp, useInput, useStdout } from "ink";
|
|
25
|
+
import TextInput from "ink-text-input";
|
|
26
|
+
import { spawnSync } from "node:child_process";
|
|
27
|
+
import { useEffect, useMemo, useState } from "react";
|
|
28
|
+
import Fuse from "fuse.js";
|
|
29
|
+
import { listMemoryFiles, MEMORY_DIR, memoryFilePath, readMemory, toolDeleteMemory, } from "./index.js";
|
|
30
|
+
const TYPE_FILTERS = ["all", "user", "feedback", "project", "reference"];
|
|
31
|
+
function loadAllMemories() {
|
|
32
|
+
return listMemoryFiles()
|
|
33
|
+
.map((n) => readMemory(n))
|
|
34
|
+
.filter((m) => m !== null)
|
|
35
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
36
|
+
}
|
|
37
|
+
function filterMemories(memories, typeFilter, query) {
|
|
38
|
+
let list = memories;
|
|
39
|
+
if (typeFilter !== "all")
|
|
40
|
+
list = list.filter((m) => m.type === typeFilter);
|
|
41
|
+
if (query.trim()) {
|
|
42
|
+
const fuse = new Fuse(list, {
|
|
43
|
+
includeScore: true,
|
|
44
|
+
threshold: 0.4,
|
|
45
|
+
ignoreLocation: true,
|
|
46
|
+
keys: [
|
|
47
|
+
{ name: "name", weight: 3 },
|
|
48
|
+
{ name: "description", weight: 2 },
|
|
49
|
+
{ name: "body", weight: 1 },
|
|
50
|
+
],
|
|
51
|
+
});
|
|
52
|
+
return fuse.search(query).map((r) => r.item);
|
|
53
|
+
}
|
|
54
|
+
return list;
|
|
55
|
+
}
|
|
56
|
+
const typeColor = {
|
|
57
|
+
user: "cyan",
|
|
58
|
+
feedback: "yellow",
|
|
59
|
+
project: "green",
|
|
60
|
+
reference: "magenta",
|
|
61
|
+
};
|
|
62
|
+
const App = () => {
|
|
63
|
+
const { exit } = useApp();
|
|
64
|
+
const { stdout } = useStdout();
|
|
65
|
+
const [state, setState] = useState({
|
|
66
|
+
memories: loadAllMemories(),
|
|
67
|
+
selected: 0,
|
|
68
|
+
typeFilter: "all",
|
|
69
|
+
searchMode: false,
|
|
70
|
+
searchQuery: "",
|
|
71
|
+
confirmDelete: null,
|
|
72
|
+
status: null,
|
|
73
|
+
});
|
|
74
|
+
const visible = useMemo(() => filterMemories(state.memories, state.typeFilter, state.searchQuery), [state.memories, state.typeFilter, state.searchQuery]);
|
|
75
|
+
// Keep the selection in range when filters shrink the list
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (state.selected >= visible.length && visible.length > 0) {
|
|
78
|
+
setState((s) => ({ ...s, selected: Math.max(0, visible.length - 1) }));
|
|
79
|
+
}
|
|
80
|
+
else if (visible.length === 0 && state.selected !== 0) {
|
|
81
|
+
setState((s) => ({ ...s, selected: 0 }));
|
|
82
|
+
}
|
|
83
|
+
}, [visible.length, state.selected]);
|
|
84
|
+
const current = visible[state.selected];
|
|
85
|
+
const refresh = () => setState((s) => ({ ...s, memories: loadAllMemories(), status: "refreshed" }));
|
|
86
|
+
useInput((input, key) => {
|
|
87
|
+
if (state.searchMode)
|
|
88
|
+
return; // TextInput component handles search input
|
|
89
|
+
// Delete confirmation flow
|
|
90
|
+
if (state.confirmDelete) {
|
|
91
|
+
if (input === "y" || input === "Y") {
|
|
92
|
+
try {
|
|
93
|
+
toolDeleteMemory({ name: state.confirmDelete });
|
|
94
|
+
setState((s) => ({
|
|
95
|
+
...s,
|
|
96
|
+
memories: loadAllMemories(),
|
|
97
|
+
confirmDelete: null,
|
|
98
|
+
status: `deleted "${state.confirmDelete}" (in .trash/)`,
|
|
99
|
+
}));
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
setState((s) => ({
|
|
103
|
+
...s,
|
|
104
|
+
confirmDelete: null,
|
|
105
|
+
status: `delete failed: ${err.message}`,
|
|
106
|
+
}));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
else if (input === "n" || input === "N" || key.escape) {
|
|
110
|
+
setState((s) => ({ ...s, confirmDelete: null, status: "delete cancelled" }));
|
|
111
|
+
}
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (input === "q" || (key.ctrl && input === "c")) {
|
|
115
|
+
exit();
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (key.upArrow || input === "k") {
|
|
119
|
+
setState((s) => ({ ...s, selected: Math.max(0, s.selected - 1), status: null }));
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (key.downArrow || input === "j") {
|
|
123
|
+
setState((s) => ({
|
|
124
|
+
...s,
|
|
125
|
+
selected: Math.min(Math.max(0, visible.length - 1), s.selected + 1),
|
|
126
|
+
status: null,
|
|
127
|
+
}));
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
// Type filter quick-keys
|
|
131
|
+
const typeKeyMap = {
|
|
132
|
+
"0": "all",
|
|
133
|
+
"1": "user",
|
|
134
|
+
"2": "feedback",
|
|
135
|
+
"3": "project",
|
|
136
|
+
"4": "reference",
|
|
137
|
+
};
|
|
138
|
+
if (typeKeyMap[input]) {
|
|
139
|
+
setState((s) => ({ ...s, typeFilter: typeKeyMap[input], selected: 0, status: null }));
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (input === "/") {
|
|
143
|
+
setState((s) => ({ ...s, searchMode: true, status: null }));
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (input === "r") {
|
|
147
|
+
refresh();
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (input === "e" && current) {
|
|
151
|
+
const editor = process.env.EDITOR || (process.platform === "win32" ? "notepad" : "vi");
|
|
152
|
+
const fp = memoryFilePath(current.name);
|
|
153
|
+
// Suspend Ink rendering, spawn editor, then refresh after exit
|
|
154
|
+
stdout?.write("\x1bc"); // reset terminal to give the editor a clean canvas
|
|
155
|
+
const result = spawnSync(editor, [fp], { stdio: "inherit" });
|
|
156
|
+
setState((s) => ({
|
|
157
|
+
...s,
|
|
158
|
+
memories: loadAllMemories(),
|
|
159
|
+
status: result.status === 0
|
|
160
|
+
? `edited "${current.name}" in ${editor}`
|
|
161
|
+
: `editor exited with code ${result.status}`,
|
|
162
|
+
}));
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (input === "d" && current) {
|
|
166
|
+
setState((s) => ({ ...s, confirmDelete: current.name, status: null }));
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
const onSearchSubmit = () => setState((s) => ({ ...s, searchMode: false, selected: 0 }));
|
|
171
|
+
const onSearchChange = (value) => setState((s) => ({ ...s, searchQuery: value }));
|
|
172
|
+
return (_jsxs(Box, { flexDirection: "column", width: "100%", children: [_jsxs(Box, { borderStyle: "single", borderColor: "gray", paddingX: 1, children: [_jsx(Text, { bold: true, children: "agent-memory" }), _jsx(Text, { dimColor: true, children: " \u00B7 " }), TYPE_FILTERS.map((t, i) => (_jsxs(Text, { bold: state.typeFilter === t, color: state.typeFilter === t ? "green" : "gray", children: [i > 0 ? " " : "", "[", i, "] ", t] }, t))), _jsxs(Text, { dimColor: true, children: [" · ", visible.length, " of ", state.memories.length, state.searchQuery && ` matching "${state.searchQuery}"`] })] }), state.searchMode && (_jsxs(Box, { paddingX: 1, children: [_jsx(Text, { color: "cyan", children: "/ " }), _jsx(TextInput, { value: state.searchQuery, onChange: onSearchChange, onSubmit: onSearchSubmit, placeholder: "fuzzy search \u00B7 enter to confirm" })] })), _jsxs(Box, { flexDirection: "column", paddingX: 1, children: [visible.length === 0 ? (_jsx(Text, { dimColor: true, children: "(no memories match)" })) : (visible.slice(0, 12).map((m, i) => {
|
|
173
|
+
const isSelected = i === state.selected;
|
|
174
|
+
return (_jsxs(Box, { children: [_jsxs(Text, { color: isSelected ? "green" : undefined, bold: isSelected, children: [isSelected ? "▶ " : " ", m.name] }), _jsxs(Text, { color: typeColor[m.type] ?? "gray", children: [" [", m.type, "]"] }), m.tags.length > 0 ? (_jsxs(Text, { dimColor: true, children: [" · ", m.tags.join(" · ")] })) : null] }, m.name));
|
|
175
|
+
})), visible.length > 12 && (_jsxs(Text, { dimColor: true, children: [" ... +", visible.length - 12, " more (filter down with /)"] }))] }), current && (_jsxs(Box, { borderStyle: "single", borderColor: "gray", flexDirection: "column", paddingX: 1, children: [_jsx(Text, { bold: true, children: current.name }), _jsx(Text, { dimColor: true, children: current.description }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [current.body
|
|
176
|
+
.split("\n")
|
|
177
|
+
.slice(0, 10)
|
|
178
|
+
.map((line, i) => (_jsx(Text, { dimColor: line.startsWith("#") ? false : true, children: line || " " }, i))), current.body.split("\n").length > 10 && (_jsx(Text, { dimColor: true, children: "... (truncated \u00B7 press 'e' to open in editor)" }))] })] })), _jsxs(Box, { paddingX: 1, flexDirection: "column", children: [state.confirmDelete ? (_jsxs(Text, { color: "yellow", children: ["Delete \"", state.confirmDelete, "\"? (y/n) \u00B7 soft-delete, recoverable from .trash/"] })) : (_jsx(Text, { dimColor: true, children: "\u2191\u2193/jk navigate \u00B7 0-4 type filter \u00B7 / search \u00B7 e edit \u00B7 d delete \u00B7 r refresh \u00B7 q quit" })), state.status && _jsxs(Text, { color: "cyan", children: ["\u00B7 ", state.status] }), _jsxs(Text, { dimColor: true, children: ["storage: ", MEMORY_DIR] })] })] }));
|
|
179
|
+
};
|
|
180
|
+
export async function runTui() {
|
|
181
|
+
const { waitUntilExit } = render(_jsx(App, {}));
|
|
182
|
+
await waitUntilExit();
|
|
183
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xultrax-web/agent-memory-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"mcpName": "io.github.xultrax-web/agent-memory-mcp",
|
|
5
5
|
"description": "Markdown memory for AI agents. Plain files you can read, edit, grep, and commit. The only MCP memory server that isn't a database.",
|
|
6
6
|
"type": "module",
|
|
@@ -56,11 +56,15 @@
|
|
|
56
56
|
"@modelcontextprotocol/sdk": "^1.0.4",
|
|
57
57
|
"fuse.js": "^7.3.0",
|
|
58
58
|
"gray-matter": "^4.0.3",
|
|
59
|
-
"
|
|
59
|
+
"ink": "^7.0.3",
|
|
60
|
+
"ink-text-input": "^6.0.0",
|
|
61
|
+
"proper-lockfile": "^4.1.2",
|
|
62
|
+
"react": "^19.2.6"
|
|
60
63
|
},
|
|
61
64
|
"devDependencies": {
|
|
62
65
|
"@types/node": "^22.10.2",
|
|
63
66
|
"@types/proper-lockfile": "^4.1.4",
|
|
67
|
+
"@types/react": "^19.2.15",
|
|
64
68
|
"prettier": "^3.8.3",
|
|
65
69
|
"typescript": "^5.7.2",
|
|
66
70
|
"vitest": "^4.1.7"
|