@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 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
- **Landing in v0.9+:**
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.8.1" }, { capabilities: { tools: {}, resources: {}, prompts: {} } });
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().catch((err) => {
1854
- process.stderr.write(`Fatal: ${err instanceof Error ? err.message : String(err)}\n`);
1855
- process.exit(1);
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.8.1",
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
- "proper-lockfile": "^4.1.2"
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"