@xultrax-web/agent-memory-mcp 0.9.0 → 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 +14 -2
- package/dist/index.js +26 -10
- package/dist/tui.js +183 -0
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -272,6 +272,7 @@ agent-memory sync init git@github.com:you/agent-memory.git # multi-machine se
|
|
|
272
272
|
agent-memory sync push # commit + push local changes
|
|
273
273
|
agent-memory sync pull # fast-forward from remote
|
|
274
274
|
agent-memory sync status # local + ahead/behind state
|
|
275
|
+
agent-memory ui # launch the TUI (browse + edit interactively)
|
|
275
276
|
```
|
|
276
277
|
|
|
277
278
|
### Multi-machine memory (git sync)
|
|
@@ -418,6 +419,16 @@ This server is built to be used daily, not to demo well once.
|
|
|
418
419
|
- **`find_backlinks`** tool + `agent-memory backlinks <name>` CLI — "what links to this".
|
|
419
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.
|
|
420
421
|
|
|
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
|
+
|
|
421
432
|
**Shipped in v0.9 · the moat — multi-machine memory via git:**
|
|
422
433
|
|
|
423
434
|
- **`agent-memory sync init <remote-url>`** — convert `.agent-memory/` into a git repo, push to remote.
|
|
@@ -429,11 +440,12 @@ This server is built to be used daily, not to demo well once.
|
|
|
429
440
|
- Per-machine state (`.lock`, `.events.jsonl`, `.trash/`) auto-excluded from sync.
|
|
430
441
|
- Default commit identity injected (`agent-memory@local`) so machines without `git config --global user.email` work without setup.
|
|
431
442
|
|
|
432
|
-
**Landing in v0.
|
|
443
|
+
**Landing in v0.11+:**
|
|
433
444
|
|
|
434
|
-
- TUI / web UI for browsing + editing memories in a clean interface
|
|
435
445
|
- Folder support (`.agent-memory/work/`, `.agent-memory/personal/`)
|
|
436
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)
|
|
437
449
|
|
|
438
450
|
---
|
|
439
451
|
|
package/dist/index.js
CHANGED
|
@@ -30,6 +30,7 @@ import { spawnSync } from "node:child_process";
|
|
|
30
30
|
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync, } from "node:fs";
|
|
31
31
|
import { homedir } from "node:os";
|
|
32
32
|
import { join, resolve } from "node:path";
|
|
33
|
+
import { fileURLToPath } from "node:url";
|
|
33
34
|
import lockfile from "proper-lockfile";
|
|
34
35
|
// -------------------------------------------------------------
|
|
35
36
|
// Storage location resolution
|
|
@@ -43,7 +44,7 @@ function resolveStorageDir() {
|
|
|
43
44
|
}
|
|
44
45
|
return resolve(process.cwd(), ".agent-memory");
|
|
45
46
|
}
|
|
46
|
-
const MEMORY_DIR = resolveStorageDir();
|
|
47
|
+
export const MEMORY_DIR = resolveStorageDir();
|
|
47
48
|
const INDEX_FILE = join(MEMORY_DIR, "MEMORY.md");
|
|
48
49
|
const TRASH_DIR = join(MEMORY_DIR, ".trash");
|
|
49
50
|
const LOCK_FILE = join(MEMORY_DIR, ".lock");
|
|
@@ -178,10 +179,10 @@ const SLUG_PATTERN = /^[a-z0-9][a-z0-9_-]{0,80}$/;
|
|
|
178
179
|
const TAG_PATTERN = /^[a-z0-9][a-z0-9_-]{0,40}$/;
|
|
179
180
|
// Wiki-links: [[memory-name]] · names follow SLUG_PATTERN rules
|
|
180
181
|
const WIKI_LINK_PATTERN = /\[\[([a-z0-9][a-z0-9_-]{0,80})\]\]/g;
|
|
181
|
-
function memoryFilePath(name) {
|
|
182
|
+
export function memoryFilePath(name) {
|
|
182
183
|
return join(MEMORY_DIR, `${name}.md`);
|
|
183
184
|
}
|
|
184
|
-
function readMemory(name) {
|
|
185
|
+
export function readMemory(name) {
|
|
185
186
|
const fp = memoryFilePath(name);
|
|
186
187
|
if (!existsSync(fp))
|
|
187
188
|
return null;
|
|
@@ -197,7 +198,7 @@ function readMemory(name) {
|
|
|
197
198
|
filePath: fp,
|
|
198
199
|
};
|
|
199
200
|
}
|
|
200
|
-
function listMemoryFiles() {
|
|
201
|
+
export function listMemoryFiles() {
|
|
201
202
|
if (!existsSync(MEMORY_DIR))
|
|
202
203
|
return [];
|
|
203
204
|
return readdirSync(MEMORY_DIR)
|
|
@@ -576,7 +577,7 @@ function toolRelevantMemories(args) {
|
|
|
576
577
|
}
|
|
577
578
|
return sections.join("\n");
|
|
578
579
|
}
|
|
579
|
-
function toolDeleteMemory(args) {
|
|
580
|
+
export function toolDeleteMemory(args) {
|
|
580
581
|
const name = String(args.name ?? "").trim();
|
|
581
582
|
if (!SLUG_PATTERN.test(name))
|
|
582
583
|
throw new Error(`Invalid name "${name}".`);
|
|
@@ -1235,7 +1236,7 @@ function actionColor(action) {
|
|
|
1235
1236
|
// -------------------------------------------------------------
|
|
1236
1237
|
// Server wiring
|
|
1237
1238
|
// -------------------------------------------------------------
|
|
1238
|
-
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: {} } });
|
|
1239
1240
|
// -------------------------------------------------------------
|
|
1240
1241
|
// Resource URI scheme
|
|
1241
1242
|
// -------------------------------------------------------------
|
|
@@ -1761,6 +1762,7 @@ const CLI_COMMANDS = new Set([
|
|
|
1761
1762
|
"backlinks",
|
|
1762
1763
|
"related",
|
|
1763
1764
|
"sync",
|
|
1765
|
+
"ui",
|
|
1764
1766
|
"import-claude-code",
|
|
1765
1767
|
"help",
|
|
1766
1768
|
"--help",
|
|
@@ -1953,6 +1955,13 @@ async function cliMain(command, rest) {
|
|
|
1953
1955
|
}) + "\n");
|
|
1954
1956
|
return 0;
|
|
1955
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
|
+
}
|
|
1956
1965
|
case "import-claude-code": {
|
|
1957
1966
|
return importClaudeCode({
|
|
1958
1967
|
source: flags.source ? String(flags.source) : undefined,
|
|
@@ -2010,6 +2019,8 @@ COMMANDS
|
|
|
2010
2019
|
sync pull Fast-forward pull from remote.
|
|
2011
2020
|
sync status Show local + ahead/behind state.
|
|
2012
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).
|
|
2013
2024
|
import-claude-code [--source <path>] [--project <pat>] [--overwrite] [--dry-run]
|
|
2014
2025
|
Walk ~/.claude/projects/*/memory/ and
|
|
2015
2026
|
import each memory into the current store.
|
|
@@ -2132,7 +2143,12 @@ async function main() {
|
|
|
2132
2143
|
await server.connect(transport);
|
|
2133
2144
|
process.stderr.write(`agent-memory-mcp · storage: ${MEMORY_DIR}\n`);
|
|
2134
2145
|
}
|
|
2135
|
-
main().
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
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"
|