Skip to content

Commit 7ec5f43

Browse files
authored
Add me serve command: local web UI for memories (#47)
* add me serve command * render frontmatter * improved root node * sort by temporal start * improved tree * make tree sidebar resizable * collapsible filter pane * uniform control size * expand top-level tree nodes by default * track tree expansion state separately per filter mode Split expandedPaths into expandedBrowse (default collapsed) and collapsedSearch (default expanded). Applying or clearing a filter no longer perturbs browse-mode expansion — pruning is scoped to the corresponding bucket. A selectIsExpanded helper centralizes the asymmetric membership semantics. * allow expand/collapse while search filter is active Drop the forceOpen override that pinned every search-matched path open. Rows now read straight from the store: in search context the default is expanded (membership in collapsedSearch flips a path closed), so new matches are still visible without any pre-seeding, and user clicks land in the search bucket where they're kept separate from browse state. * improve caret sizing and vertical alignment in tree rows Replace the \u25b8 unicode glyph (small, font-dependent baseline) with a pixel-consistent inline SVG chevron in a 16\u00d716 box. The leaf bullet gets the same flex-centered 16\u00d716 wrapper so it lands in the same column as the caret above and stays centered regardless of line height. * preserve right-edge padding when tree panel is narrow Flex items default to min-width: auto, so a long label forced the count (or trailing edge) to overflow past the button's padding-right. Add min-w-0 flex-1 to the title spans so truncate actually kicks in, and shrink-0 to the count so it never gets squeezed out of its column. * shorten memory context menu label to 'Delete…' * stop context-menu bubbling so inner tree rows win Right-clicking a MemoryRow landed on the outer PathRow handler too, which overwrote the context-menu target with { kind: 'path' } and rendered the wrong menu items. Call stopPropagation in both handlers so the innermost row determines the menu. * build-all: build embedded web UI before compile A fresh checkout fails with 'Could not resolve ./web-assets.generated.ts' because scripts/build-all.ts calls `bun build --compile` directly, bypassing the CLI package's `build` script (which chains build:web). Run build:web up front so the generated assets exist when every platform compile starts. * ci: generate embedded-web-assets stub before typecheck/lint/test web-assets.generated.ts is gitignored and transitively imported by packages/cli/serve. Running scripts/bundle-web-assets.ts with no web dist/ emits a valid empty-map stub, which lets CI's lint/typecheck/tests resolve the module without the multi-second Vite build. * avoid duplicating types * directly reuse the client lib * don't commit plan doc
1 parent d5578fc commit 7ec5f43

61 files changed

Lines changed: 5854 additions & 18 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@ jobs:
1313
- uses: actions/checkout@v6
1414
- name: Install dependencies
1515
run: ./bun install
16+
# web-assets.generated.ts is produced by `build:web` and is gitignored.
17+
# Lint/typecheck/tests transitively import it through packages/cli/serve/.
18+
# Running the bundler alone (no Vite) is enough for CI — it emits an
19+
# empty map when packages/web/dist/ is absent, which satisfies the type
20+
# shape without the multi-second Vite build.
21+
- name: Prepare embedded web assets (stub)
22+
run: ./bun scripts/bundle-web-assets.ts
1623
- name: Lint
1724
run: ./bun run lint
1825
- name: Typecheck
@@ -27,6 +34,8 @@ jobs:
2734
- uses: actions/checkout@v6
2835
- name: Install dependencies
2936
run: ./bun install
37+
- name: Prepare embedded web assets (stub)
38+
run: ./bun scripts/bundle-web-assets.ts
3039
- name: Build Postgres image
3140
run: docker build -t me-postgres -f docker/Dockerfile.postgres docker/
3241
- name: Start Postgres

.github/workflows/release.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,13 @@ jobs:
1818
- name: Install dependencies
1919
run: ./bun install
2020

21+
# web-assets.generated.ts is produced by `build:web` and is gitignored.
22+
# Lint/typecheck/tests transitively import it through packages/cli/serve/.
23+
# Running the bundler alone (no Vite) emits an empty-map stub, which
24+
# satisfies the type shape without a full UI build.
25+
- name: Prepare embedded web assets (stub)
26+
run: ./bun scripts/bundle-web-assets.ts
27+
2128
- name: Typecheck
2229
run: ./bun run typecheck
2330

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
2828
.cache
2929
*.tsbuildinfo
3030

31+
# generated TypeScript modules (scripts/bundle-web-assets.ts, etc.)
32+
*.generated.ts
33+
3134
# IntelliJ based IDEs
3235
.idea
3336

bun.lock

Lines changed: 572 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/cli/me-serve.md

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# me serve
2+
3+
Run a local web UI for viewing and managing memories.
4+
5+
## Usage
6+
7+
```
8+
me serve [--port <port>] [--host <host>] [--no-open]
9+
```
10+
11+
## Description
12+
13+
Starts a local HTTP server that:
14+
15+
- Serves a React-based UI for browsing, searching, viewing, editing, and deleting memories.
16+
- Proxies JSON-RPC calls from the browser to the configured engine, injecting your stored API key so the key never leaves the machine.
17+
18+
By default the server binds to `127.0.0.1:3000`; if 3000 is busy it tries 3001, 3002, … up to 3019 before giving up. Passing `--port` explicitly is strict — it does not auto-increment.
19+
20+
The browser opens automatically on startup unless `--no-open` is passed. Press `Ctrl+C` to stop.
21+
22+
The UI talks to whichever engine is active for the current server — same resolution as every other `me` command (`--server` flag > `ME_SERVER` env > stored `default_server`; within the server, the active engine is picked via `me engine use`). Run `me whoami` to confirm.
23+
24+
## Options
25+
26+
| Option | Description |
27+
|--------|-------------|
28+
| `--port <port>` | Port to bind. Default `3000`, auto-incrementing only when the default is busy. |
29+
| `--host <host>` | Host to bind. Default `127.0.0.1` (loopback only). |
30+
| `--no-open` | Do not auto-open the browser after the server starts. |
31+
32+
## Global Options
33+
34+
| Option | Description |
35+
|--------|-------------|
36+
| `--server <url>` | Server URL (overrides `ME_SERVER` env and stored default) |
37+
| `--json` | Output the startup banner as JSON instead of text |
38+
| `--yaml` | Output the startup banner as YAML instead of text |
39+
40+
## UI overview
41+
42+
```
43+
┌───────────────────────────────────────────────────────────────┐
44+
│ Search [Simple | Advanced] [Clear] │
45+
├────────────────────┬──────────────────────────────────────────┤
46+
│ │ tree breadcrumb [Edit] [Save] [Delete]│
47+
│ TreeView │ │
48+
│ . (root) │ rendered markdown / Monaco editor │
49+
│ ├── work │ │
50+
│ │ └── 📄 …│ │
51+
│ └── personal ├──────────────────────────────────────────┤
52+
│ │ id, embedding, timestamps (read-only) │
53+
└────────────────────┴──────────────────────────────────────────┘
54+
```
55+
56+
- **Tree** (left): ltree paths as collapsible nodes, memories as leaves. Right-click a node for a context menu (delete memory / delete subtree).
57+
- **Search** (top): simple hybrid search by default; flip to Advanced for every field accepted by `memory.search` (semantic, fulltext, grep, tree, meta, temporal, limit, candidateLimit, weights, orderBy).
58+
- **Viewer / Editor** (right): rendered Markdown with syntax highlighting, or the Monaco editor with YAML frontmatter + body. Save is disabled until you make a valid change. The read-only metadata panel sits below.
59+
- **URL state**: filter fields and the selected memory id are reflected in the URL, so any view can be shared or bookmarked.
60+
61+
## Security notes
62+
63+
- The server binds to `127.0.0.1` only — no LAN exposure. The browser never sees your API key or session token; `me serve` injects them into RPC calls on the way out.
64+
- No authentication is required on the local server. Do not `--host 0.0.0.0` or tunnel the port unless you understand the implications.
65+
66+
## Examples
67+
68+
```bash
69+
# Simplest invocation — picks a port, opens the browser.
70+
me serve
71+
72+
# Use a specific port and skip auto-open (handy when iterating in dev).
73+
me serve --port 8080 --no-open
74+
75+
# Point at a specific engine server.
76+
me serve --server https://api.memory.build
77+
```
78+
79+
## See also
80+
81+
- [`me engine use`](me-engine.md) — pick the active engine that `me serve` will connect to.
82+
- [`me memory search`](me-memory.md#search) — the CLI equivalent of the UI's search bar.

docs/getting-started.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,16 @@ me memory search --semantic "UUID generation" --fulltext "PostgreSQL 18"
4646
me memory tree
4747
```
4848

49+
## Browse in the web UI
50+
51+
For a richer, visual experience:
52+
53+
```bash
54+
me serve
55+
```
56+
57+
Starts a local web UI on `http://127.0.0.1:3000` (or the next free port) with a tree explorer, hybrid / advanced search, rendered Markdown viewer, and a Monaco-based editor for content + metadata. See [`me serve`](cli/me-serve.md) for details.
58+
4959
## Connect to AI tools
5060

5161
Register Memory Engine as an MCP server with your AI coding tools:

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ nav:
6262
- me codex: cli/me-codex.md
6363
- me gemini: cli/me-gemini.md
6464
- me opencode: cli/me-opencode.md
65+
- me serve: cli/me-serve.md
6566
- Agent session imports: cli/agent-session-imports.md
6667
- me user: cli/me-user.md
6768
- me role: cli/me-role.md

packages/cli/commands/serve.ts

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
/**
2+
* me serve — run a local web UI for viewing/managing memories.
3+
*
4+
* Launches a local HTTP server that:
5+
* - serves the embedded Vite-built React app
6+
* - proxies POST /rpc to the configured engine, injecting the stored API key
7+
*
8+
* Usage:
9+
* me serve [--port <port>] [--host <host>] [--no-open]
10+
*
11+
* Respects the global --server flag (falls back to ME_SERVER env and
12+
* stored default_server, same as every other command).
13+
*/
14+
import * as clack from "@clack/prompts";
15+
import { Command } from "commander";
16+
import { resolveCredentials } from "../credentials.ts";
17+
import { getOutputFormat, output } from "../output.ts";
18+
import { findAvailablePort, startHttpServer } from "../serve/http-server.ts";
19+
import { requireEngine } from "../util.ts";
20+
21+
const DEFAULT_PORT = 3000;
22+
const DEFAULT_HOST = "127.0.0.1";
23+
const MAX_PORT_ATTEMPTS = 20;
24+
25+
export function createServeCommand(): Command {
26+
return new Command("serve")
27+
.description("run a local web UI for viewing/managing memories")
28+
.option(
29+
"--port <port>",
30+
`port to bind (default ${DEFAULT_PORT}; auto-increments when unspecified and default is busy)`,
31+
)
32+
.option(
33+
"--host <host>",
34+
`host to bind (default ${DEFAULT_HOST})`,
35+
DEFAULT_HOST,
36+
)
37+
.option("--no-open", "do not auto-open the browser")
38+
.action(async (opts, cmd) => {
39+
const globalOpts = cmd.optsWithGlobals();
40+
const fmt = getOutputFormat(globalOpts);
41+
42+
const creds = resolveCredentials(globalOpts.server);
43+
requireEngine(creds, fmt);
44+
45+
const host: string = opts.host ?? DEFAULT_HOST;
46+
const explicitPortFlag = opts.port !== undefined;
47+
const requestedPort = explicitPortFlag
48+
? parsePort(opts.port, fmt)
49+
: DEFAULT_PORT;
50+
51+
// Port discovery: explicit --port is strict; default auto-increments.
52+
let port: number;
53+
if (explicitPortFlag) {
54+
port = requestedPort;
55+
} else {
56+
try {
57+
port = await findAvailablePort(
58+
host,
59+
requestedPort,
60+
MAX_PORT_ATTEMPTS,
61+
);
62+
} catch (err) {
63+
const msg = err instanceof Error ? err.message : String(err);
64+
if (fmt === "text") {
65+
clack.log.error(msg);
66+
} else {
67+
output({ error: msg }, fmt, () => {});
68+
}
69+
process.exit(1);
70+
}
71+
}
72+
73+
let running: ReturnType<typeof startHttpServer>;
74+
try {
75+
running = startHttpServer({
76+
server: creds.server,
77+
apiKey: creds.apiKey,
78+
engineSlug: creds.activeEngine ?? "",
79+
host,
80+
port,
81+
});
82+
} catch (err) {
83+
const msg = err instanceof Error ? err.message : String(err);
84+
const hint = msg.includes("EADDRINUSE")
85+
? ` (port ${port} is already in use)`
86+
: "";
87+
if (fmt === "text") {
88+
clack.log.error(`Failed to start server${hint}: ${msg}`);
89+
} else {
90+
output({ error: msg, port, host }, fmt, () => {});
91+
}
92+
process.exit(1);
93+
}
94+
95+
if (fmt === "text") {
96+
clack.log.success(`Memory Engine UI running at ${running.url}`);
97+
console.log(` Remote server: ${creds.server}`);
98+
if (creds.activeEngine) {
99+
console.log(` Active engine: ${creds.activeEngine}`);
100+
}
101+
console.log(" Press Ctrl+C to stop.");
102+
} else {
103+
output(
104+
{
105+
url: running.url,
106+
host,
107+
port: port,
108+
server: creds.server,
109+
engine: creds.activeEngine,
110+
},
111+
fmt,
112+
() => {},
113+
);
114+
}
115+
116+
if (opts.open !== false) {
117+
openBrowser(running.url).catch((err) => {
118+
if (fmt === "text") {
119+
clack.log.warn(
120+
`Could not open browser automatically: ${err instanceof Error ? err.message : String(err)}`,
121+
);
122+
}
123+
});
124+
}
125+
126+
// Keep the process alive until Ctrl+C.
127+
await new Promise<void>((resolve) => {
128+
const shutdown = () => {
129+
if (fmt === "text") {
130+
console.log("");
131+
clack.log.info("Shutting down…");
132+
}
133+
running.server.stop(true);
134+
resolve();
135+
};
136+
process.once("SIGINT", shutdown);
137+
process.once("SIGTERM", shutdown);
138+
});
139+
});
140+
}
141+
142+
/**
143+
* Parse the --port flag value. Exits on invalid input.
144+
*/
145+
function parsePort(
146+
value: unknown,
147+
fmt: ReturnType<typeof getOutputFormat>,
148+
): number {
149+
const n =
150+
typeof value === "number" ? value : Number.parseInt(String(value), 10);
151+
if (!Number.isInteger(n) || n < 1 || n > 65535) {
152+
const msg = `Invalid --port value: ${String(value)}. Expected an integer 1..65535.`;
153+
if (fmt === "text") {
154+
clack.log.error(msg);
155+
} else {
156+
output({ error: msg }, fmt, () => {});
157+
}
158+
process.exit(1);
159+
}
160+
return n;
161+
}
162+
163+
/**
164+
* Open the given URL in the user's default browser. Best-effort; failures
165+
* are non-fatal (the URL is already printed to stdout).
166+
*/
167+
async function openBrowser(url: string): Promise<void> {
168+
const platform = process.platform;
169+
const cmd =
170+
platform === "darwin"
171+
? ["open", url]
172+
: platform === "win32"
173+
? ["cmd", "/c", "start", "", url]
174+
: ["xdg-open", url];
175+
176+
const proc = Bun.spawn(cmd, {
177+
stdout: "ignore",
178+
stderr: "ignore",
179+
stdin: "ignore",
180+
});
181+
// Don't await full exit — the OS handler may daemonize. A small delay
182+
// ensures the spawn actually dispatches before the event loop continues.
183+
await Promise.race([proc.exited, new Promise((r) => setTimeout(r, 200))]);
184+
}

packages/cli/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { createOrgCommand } from "./commands/org.ts";
2525
import { createOwnerCommand } from "./commands/owner.ts";
2626
import { createPackCommand } from "./commands/pack.ts";
2727
import { createRoleCommand } from "./commands/role.ts";
28+
import { createServeCommand } from "./commands/serve.ts";
2829
import { createUserCommand } from "./commands/user.ts";
2930
import { createWhoamiCommand } from "./commands/whoami.ts";
3031
import { setExpanded } from "./output.ts";
@@ -78,6 +79,9 @@ program.addCommand(createOpenCodeCommand());
7879
program.addCommand(createGeminiCommand());
7980
program.addCommand(createCodexCommand());
8081

82+
// Local web UI
83+
program.addCommand(createServeCommand());
84+
8185
// Engine-level RBAC commands
8286
program.addCommand(createUserCommand());
8387
program.addCommand(createGrantCommand());

packages/cli/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
},
1010
"scripts": {
1111
"dev": "../../bun run index.ts",
12-
"build": "../../bun build --compile index.ts --outfile dist/me"
12+
"build:web": "../../bun --cwd=../web run build && ../../bun ../../scripts/bundle-web-assets.ts",
13+
"build": "../../bun run build:web && ../../bun build --compile index.ts --outfile dist/me"
1314
},
1415
"dependencies": {
1516
"@bomb.sh/tab": "^0.0.12",

0 commit comments

Comments
 (0)