nmem-cli 0.7.9__tar.gz → 0.8.1__tar.gz
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.
- {nmem_cli-0.7.9 → nmem_cli-0.8.1}/PKG-INFO +16 -1
- {nmem_cli-0.7.9 → nmem_cli-0.8.1}/README.md +15 -0
- {nmem_cli-0.7.9 → nmem_cli-0.8.1}/pyproject.toml +1 -1
- {nmem_cli-0.7.9 → nmem_cli-0.8.1}/src/nmem_cli/__init__.py +1 -1
- {nmem_cli-0.7.9 → nmem_cli-0.8.1}/src/nmem_cli/cli.py +495 -11
- {nmem_cli-0.7.9 → nmem_cli-0.8.1}/src/nmem_cli/session_import.py +14 -3
- {nmem_cli-0.7.9 → nmem_cli-0.8.1}/src/nmem_cli/tui/__init__.py +14 -7
- {nmem_cli-0.7.9 → nmem_cli-0.8.1}/src/nmem_cli/tui/__main__.py +1 -1
- {nmem_cli-0.7.9 → nmem_cli-0.8.1}/.gitignore +0 -0
- {nmem_cli-0.7.9 → nmem_cli-0.8.1}/src/nmem_cli/license_payload.py +0 -0
- {nmem_cli-0.7.9 → nmem_cli-0.8.1}/src/nmem_cli/py.typed +0 -0
- {nmem_cli-0.7.9 → nmem_cli-0.8.1}/src/nmem_cli/tui/api_client.py +0 -0
- {nmem_cli-0.7.9 → nmem_cli-0.8.1}/src/nmem_cli/tui/app.py +0 -0
- {nmem_cli-0.7.9 → nmem_cli-0.8.1}/src/nmem_cli/tui/screens/__init__.py +0 -0
- {nmem_cli-0.7.9 → nmem_cli-0.8.1}/src/nmem_cli/tui/screens/dashboard.py +0 -0
- {nmem_cli-0.7.9 → nmem_cli-0.8.1}/src/nmem_cli/tui/screens/graph.py +0 -0
- {nmem_cli-0.7.9 → nmem_cli-0.8.1}/src/nmem_cli/tui/screens/help.py +0 -0
- {nmem_cli-0.7.9 → nmem_cli-0.8.1}/src/nmem_cli/tui/screens/memories.py +0 -0
- {nmem_cli-0.7.9 → nmem_cli-0.8.1}/src/nmem_cli/tui/screens/memory_detail.py +0 -0
- {nmem_cli-0.7.9 → nmem_cli-0.8.1}/src/nmem_cli/tui/screens/settings.py +0 -0
- {nmem_cli-0.7.9 → nmem_cli-0.8.1}/src/nmem_cli/tui/screens/thread_detail.py +0 -0
- {nmem_cli-0.7.9 → nmem_cli-0.8.1}/src/nmem_cli/tui/screens/threads.py +0 -0
- {nmem_cli-0.7.9 → nmem_cli-0.8.1}/src/nmem_cli/tui/widgets/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nmem-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.1
|
|
4
4
|
Summary: CLI and TUI for Nowledge Mem - AI memory management
|
|
5
5
|
Project-URL: Homepage, https://mem.nowledge.co/
|
|
6
6
|
Project-URL: Repository, https://github.com/nowledge-co/
|
|
@@ -180,6 +180,21 @@ Use environment variables when you want a temporary override for the current she
|
|
|
180
180
|
|
|
181
181
|
`nmem config client ...` controls how this machine connects outward to Mem. It is separate from `nmem config access ...`, which controls how a Mem server is exposed to other devices on your network or through Access Anywhere.
|
|
182
182
|
|
|
183
|
+
## MCP Host Configuration
|
|
184
|
+
|
|
185
|
+
Direct HTTP MCP clients do not read `~/.nowledge-mem/config.json` by themselves. The host owns its MCP transport, so remote Mem needs headers in that host's MCP settings.
|
|
186
|
+
|
|
187
|
+
Generate the right snippet from the client config you already saved:
|
|
188
|
+
|
|
189
|
+
```bash
|
|
190
|
+
nmem config mcp show --host codex
|
|
191
|
+
nmem config mcp show --host gemini-cli
|
|
192
|
+
nmem config mcp show --host cursor
|
|
193
|
+
nmem config mcp show --host claude-desktop
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
For a fixed Mem space, add `--space "Research Agent"`. The generated snippet includes your API key when one is configured, so paste it only into the target host's private MCP config.
|
|
197
|
+
|
|
183
198
|
## Environment Variables
|
|
184
199
|
|
|
185
200
|
| Variable | Description | Default |
|
|
@@ -147,6 +147,21 @@ Use environment variables when you want a temporary override for the current she
|
|
|
147
147
|
|
|
148
148
|
`nmem config client ...` controls how this machine connects outward to Mem. It is separate from `nmem config access ...`, which controls how a Mem server is exposed to other devices on your network or through Access Anywhere.
|
|
149
149
|
|
|
150
|
+
## MCP Host Configuration
|
|
151
|
+
|
|
152
|
+
Direct HTTP MCP clients do not read `~/.nowledge-mem/config.json` by themselves. The host owns its MCP transport, so remote Mem needs headers in that host's MCP settings.
|
|
153
|
+
|
|
154
|
+
Generate the right snippet from the client config you already saved:
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
nmem config mcp show --host codex
|
|
158
|
+
nmem config mcp show --host gemini-cli
|
|
159
|
+
nmem config mcp show --host cursor
|
|
160
|
+
nmem config mcp show --host claude-desktop
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
For a fixed Mem space, add `--space "Research Agent"`. The generated snippet includes your API key when one is configured, so paste it only into the target host's private MCP config.
|
|
164
|
+
|
|
150
165
|
## Environment Variables
|
|
151
166
|
|
|
152
167
|
| Variable | Description | Default |
|
|
@@ -1378,6 +1378,88 @@ def _is_wsl_environment() -> bool:
|
|
|
1378
1378
|
return False
|
|
1379
1379
|
|
|
1380
1380
|
|
|
1381
|
+
class _CliInstallContext(NamedTuple):
|
|
1382
|
+
source: str
|
|
1383
|
+
command_path: str | None
|
|
1384
|
+
python_executable: str
|
|
1385
|
+
package_path: str
|
|
1386
|
+
|
|
1387
|
+
|
|
1388
|
+
def _detect_cli_install_context() -> _CliInstallContext:
|
|
1389
|
+
command_path = shutil.which("nmem")
|
|
1390
|
+
python_executable = str(Path(sys.executable).resolve())
|
|
1391
|
+
package_path = str(Path(__file__).resolve())
|
|
1392
|
+
haystack = " ".join(
|
|
1393
|
+
item.lower()
|
|
1394
|
+
for item in [command_path or "", python_executable, package_path]
|
|
1395
|
+
)
|
|
1396
|
+
|
|
1397
|
+
if "python-standalone" in haystack or "nowledge mem.app" in haystack:
|
|
1398
|
+
source = "desktop-bundled"
|
|
1399
|
+
elif "/usr/lib/nowledge-mem" in haystack or "/usr/lib/nowledge mem" in haystack:
|
|
1400
|
+
source = "desktop-bundled"
|
|
1401
|
+
elif "\\nowledge mem\\" in haystack or "/.local/share/nowledge-mem/" in haystack:
|
|
1402
|
+
source = "desktop-bundled"
|
|
1403
|
+
elif "pipx/venvs/nmem-cli" in haystack or ".local/pipx/venvs/nmem-cli" in haystack:
|
|
1404
|
+
source = "pipx"
|
|
1405
|
+
elif "/uv/tools/nmem-cli" in haystack or "\\uv\\tools\\nmem-cli" in haystack:
|
|
1406
|
+
source = "uv"
|
|
1407
|
+
elif "site-packages/nmem_cli" in haystack:
|
|
1408
|
+
source = "python-package"
|
|
1409
|
+
else:
|
|
1410
|
+
source = "unknown"
|
|
1411
|
+
|
|
1412
|
+
return _CliInstallContext(
|
|
1413
|
+
source=source,
|
|
1414
|
+
command_path=command_path,
|
|
1415
|
+
python_executable=python_executable,
|
|
1416
|
+
package_path=package_path,
|
|
1417
|
+
)
|
|
1418
|
+
|
|
1419
|
+
|
|
1420
|
+
def _format_cli_upgrade_hint(*, context: _CliInstallContext, server_newer: bool) -> str:
|
|
1421
|
+
command_detail = (
|
|
1422
|
+
f" first on PATH ({context.command_path})" if context.command_path else ""
|
|
1423
|
+
)
|
|
1424
|
+
if server_newer:
|
|
1425
|
+
if context.source == "desktop-bundled":
|
|
1426
|
+
return (
|
|
1427
|
+
f"The CLI{command_detail} is older than the connected server. "
|
|
1428
|
+
"In the desktop app, open Settings > Preferences > Developer Tools > "
|
|
1429
|
+
"Install CLI, then restart the terminal. If this terminal is using a "
|
|
1430
|
+
"PyPI/pipx install instead, run "
|
|
1431
|
+
"`python -m pip install --upgrade nmem-cli` or `pipx upgrade nmem-cli`."
|
|
1432
|
+
)
|
|
1433
|
+
if context.source == "pipx":
|
|
1434
|
+
return (
|
|
1435
|
+
f"The CLI{command_detail} is older than the connected server. "
|
|
1436
|
+
"Run `pipx upgrade nmem-cli`, or use "
|
|
1437
|
+
"`uvx --from nmem-cli nmem status` for a fresh one-shot CLI."
|
|
1438
|
+
)
|
|
1439
|
+
if context.source == "uv":
|
|
1440
|
+
return (
|
|
1441
|
+
f"The CLI{command_detail} is older than the connected server. "
|
|
1442
|
+
"Use `uvx --from nmem-cli nmem status` for the latest published CLI, "
|
|
1443
|
+
"or reinstall the uv tool."
|
|
1444
|
+
)
|
|
1445
|
+
if context.source == "python-package":
|
|
1446
|
+
return (
|
|
1447
|
+
f"The CLI{command_detail} is older than the connected server. "
|
|
1448
|
+
"Run `python -m pip install --upgrade nmem-cli`, or "
|
|
1449
|
+
"`uvx --from nmem-cli nmem status` without installing."
|
|
1450
|
+
)
|
|
1451
|
+
return (
|
|
1452
|
+
f"The CLI{command_detail} is older than the connected server. "
|
|
1453
|
+
"Upgrade the `nmem-cli` package, or reinstall the desktop CLI from "
|
|
1454
|
+
"Settings > Preferences > Developer Tools."
|
|
1455
|
+
)
|
|
1456
|
+
|
|
1457
|
+
return (
|
|
1458
|
+
"The CLI is newer than the connected server. Update or restart "
|
|
1459
|
+
"Nowledge Mem, or point NMEM_API_URL at the server you intended to use."
|
|
1460
|
+
)
|
|
1461
|
+
|
|
1462
|
+
|
|
1381
1463
|
def _status_version_warning(
|
|
1382
1464
|
*,
|
|
1383
1465
|
cli_version: str,
|
|
@@ -1389,21 +1471,26 @@ def _status_version_warning(
|
|
|
1389
1471
|
if not normalized_server or normalized_server == normalized_cli:
|
|
1390
1472
|
return None
|
|
1391
1473
|
|
|
1392
|
-
message = (
|
|
1393
|
-
|
|
1474
|
+
message = "CLI is v{}, but the server at {} reports v{}.".format(
|
|
1475
|
+
normalized_cli,
|
|
1476
|
+
api_url,
|
|
1477
|
+
normalized_server,
|
|
1394
1478
|
)
|
|
1395
1479
|
|
|
1396
1480
|
parsed = urlsplit(api_url)
|
|
1397
1481
|
if _is_wsl_environment() and _is_loopback_hostname(parsed.hostname):
|
|
1398
1482
|
return (
|
|
1399
1483
|
message,
|
|
1400
|
-
"WSL localhost may be reaching the Windows desktop app instead of
|
|
1401
|
-
"If you expected the WSL server, stop the
|
|
1484
|
+
"WSL localhost may be reaching the Windows desktop app instead of "
|
|
1485
|
+
"the Linux service. If you expected the WSL server, stop the "
|
|
1486
|
+
"Windows app or point NMEM_API_URL at the Linux service explicitly.",
|
|
1402
1487
|
)
|
|
1403
1488
|
|
|
1489
|
+
context = _detect_cli_install_context()
|
|
1490
|
+
server_newer = _compare_versions(normalized_cli, normalized_server)
|
|
1404
1491
|
return (
|
|
1405
1492
|
message,
|
|
1406
|
-
|
|
1493
|
+
_format_cli_upgrade_hint(context=context, server_newer=server_newer),
|
|
1407
1494
|
)
|
|
1408
1495
|
|
|
1409
1496
|
|
|
@@ -1487,6 +1574,18 @@ def cmd_status() -> None:
|
|
|
1487
1574
|
except Exception:
|
|
1488
1575
|
search_index = None
|
|
1489
1576
|
|
|
1577
|
+
version_warning = _status_version_warning(
|
|
1578
|
+
cli_version=result["cli_version"],
|
|
1579
|
+
server_version=result["server_version"],
|
|
1580
|
+
api_url=result["api_url"],
|
|
1581
|
+
)
|
|
1582
|
+
if version_warning:
|
|
1583
|
+
warning_message, warning_hint = version_warning
|
|
1584
|
+
result["version_mismatch"] = {
|
|
1585
|
+
"message": warning_message,
|
|
1586
|
+
"hint": warning_hint,
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1490
1589
|
if is_json_mode():
|
|
1491
1590
|
if agent_data:
|
|
1492
1591
|
result["agent"] = {
|
|
@@ -1535,11 +1634,6 @@ def cmd_status() -> None:
|
|
|
1535
1634
|
console.print(f" search {state_label}")
|
|
1536
1635
|
if search_index.get("message"):
|
|
1537
1636
|
console.print(f" [dim]{search_index['message']}[/dim]")
|
|
1538
|
-
version_warning = _status_version_warning(
|
|
1539
|
-
cli_version=result["cli_version"],
|
|
1540
|
-
server_version=result["server_version"],
|
|
1541
|
-
api_url=result["api_url"],
|
|
1542
|
-
)
|
|
1543
1637
|
if version_warning:
|
|
1544
1638
|
warning_message, warning_hint = version_warning
|
|
1545
1639
|
console.print(f" [bold yellow]! Version mismatch[/bold yellow] {warning_message}")
|
|
@@ -2082,6 +2176,147 @@ def cmd_communities_detect(resolution: float = 1.0) -> None:
|
|
|
2082
2176
|
console.print(f" [dim]{data['message']}[/dim]")
|
|
2083
2177
|
|
|
2084
2178
|
|
|
2179
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
2180
|
+
# Wiki Commands
|
|
2181
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
2182
|
+
|
|
2183
|
+
|
|
2184
|
+
def cmd_wiki_page(kind: str, id_or_name: str) -> None:
|
|
2185
|
+
"""Render one Library wiki page as markdown."""
|
|
2186
|
+
kind_norm = (kind or "").strip().lower()
|
|
2187
|
+
if kind_norm not in {"entity", "crystal", "topic"}:
|
|
2188
|
+
print_error(
|
|
2189
|
+
"Bad kind",
|
|
2190
|
+
f"kind must be one of 'entity', 'crystal', 'topic'; got {kind!r}",
|
|
2191
|
+
)
|
|
2192
|
+
sys.exit(2)
|
|
2193
|
+
encoded = quote(id_or_name, safe="")
|
|
2194
|
+
url = f"{get_api_url()}/library/wiki-page/{kind_norm}/{encoded}"
|
|
2195
|
+
try:
|
|
2196
|
+
response = api_request("GET", url, timeout=30.0)
|
|
2197
|
+
except httpx.ConnectError:
|
|
2198
|
+
if is_json_mode():
|
|
2199
|
+
output_json({
|
|
2200
|
+
"error": "connection_failed",
|
|
2201
|
+
"message": f"Cannot connect to {get_api_url()}",
|
|
2202
|
+
})
|
|
2203
|
+
else:
|
|
2204
|
+
_print_connection_error()
|
|
2205
|
+
sys.exit(1)
|
|
2206
|
+
if response.status_code == 404:
|
|
2207
|
+
if is_json_mode():
|
|
2208
|
+
output_json({
|
|
2209
|
+
"error": "not_found", "kind": kind_norm, "id_or_name": id_or_name,
|
|
2210
|
+
})
|
|
2211
|
+
else:
|
|
2212
|
+
console.print(f"[red]Not found:[/red] {kind_norm} {id_or_name}")
|
|
2213
|
+
sys.exit(1)
|
|
2214
|
+
response.raise_for_status()
|
|
2215
|
+
text = response.text
|
|
2216
|
+
if is_json_mode():
|
|
2217
|
+
output_json({
|
|
2218
|
+
"kind": kind_norm,
|
|
2219
|
+
"id_or_name": id_or_name,
|
|
2220
|
+
"markdown": text,
|
|
2221
|
+
})
|
|
2222
|
+
else:
|
|
2223
|
+
console.print(Markdown(text))
|
|
2224
|
+
|
|
2225
|
+
|
|
2226
|
+
def cmd_wiki_export(output_dir: Optional[str] = None) -> None:
|
|
2227
|
+
"""Build the wiki ZIP and save it locally."""
|
|
2228
|
+
url = f"{get_api_url()}/library/wiki-export"
|
|
2229
|
+
try:
|
|
2230
|
+
if not is_json_mode():
|
|
2231
|
+
with Progress(
|
|
2232
|
+
SpinnerColumn(),
|
|
2233
|
+
TextColumn("[cyan]Building wiki ZIP...[/cyan]"),
|
|
2234
|
+
console=console,
|
|
2235
|
+
transient=True,
|
|
2236
|
+
) as p:
|
|
2237
|
+
p.add_task("", total=None)
|
|
2238
|
+
response = api_request("GET", url, timeout=300.0)
|
|
2239
|
+
else:
|
|
2240
|
+
response = api_request("GET", url, timeout=300.0)
|
|
2241
|
+
except httpx.ConnectError:
|
|
2242
|
+
if is_json_mode():
|
|
2243
|
+
output_json({
|
|
2244
|
+
"error": "connection_failed",
|
|
2245
|
+
"message": f"Cannot connect to {get_api_url()}",
|
|
2246
|
+
})
|
|
2247
|
+
else:
|
|
2248
|
+
_print_connection_error()
|
|
2249
|
+
sys.exit(1)
|
|
2250
|
+
response.raise_for_status()
|
|
2251
|
+
cd = response.headers.get("content-disposition", "")
|
|
2252
|
+
match = re.search(r'filename="([^"]+)"', cd)
|
|
2253
|
+
filename = match.group(1) if match else "nowledge-mem-wiki.zip"
|
|
2254
|
+
target_dir = Path(output_dir).expanduser() if output_dir else Path.cwd()
|
|
2255
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
2256
|
+
target = target_dir / filename
|
|
2257
|
+
target.write_bytes(response.content)
|
|
2258
|
+
if is_json_mode():
|
|
2259
|
+
output_json({
|
|
2260
|
+
"saved_to": str(target),
|
|
2261
|
+
"size_bytes": len(response.content),
|
|
2262
|
+
})
|
|
2263
|
+
else:
|
|
2264
|
+
console.print(
|
|
2265
|
+
f"[green]Saved[/green] {len(response.content):,} bytes to [bold]{target}[/bold]"
|
|
2266
|
+
)
|
|
2267
|
+
console.print(
|
|
2268
|
+
"[dim]Open the folder in Obsidian, Logseq, or any markdown reader; "
|
|
2269
|
+
"wikilinks resolve in all of them.[/dim]"
|
|
2270
|
+
)
|
|
2271
|
+
|
|
2272
|
+
|
|
2273
|
+
def cmd_wiki_topics(limit: int = 20) -> None:
|
|
2274
|
+
"""List wiki topics (community clusters) with their counts."""
|
|
2275
|
+
if not is_json_mode():
|
|
2276
|
+
with Progress(
|
|
2277
|
+
SpinnerColumn(),
|
|
2278
|
+
TextColumn("[cyan]Loading topics...[/cyan]"),
|
|
2279
|
+
console=console,
|
|
2280
|
+
transient=True,
|
|
2281
|
+
) as p:
|
|
2282
|
+
p.add_task("", total=None)
|
|
2283
|
+
data = api_get(
|
|
2284
|
+
"/library/wiki-index",
|
|
2285
|
+
params={"community_limit": limit, "top_per_community": 5},
|
|
2286
|
+
)
|
|
2287
|
+
else:
|
|
2288
|
+
data = api_get(
|
|
2289
|
+
"/library/wiki-index",
|
|
2290
|
+
params={"community_limit": limit, "top_per_community": 5},
|
|
2291
|
+
)
|
|
2292
|
+
if is_json_mode():
|
|
2293
|
+
output_json(data)
|
|
2294
|
+
return
|
|
2295
|
+
communities = data.get("communities", [])
|
|
2296
|
+
console.print()
|
|
2297
|
+
if not communities:
|
|
2298
|
+
console.print(
|
|
2299
|
+
"[dim]No topics yet. Run community detection from the Graph view "
|
|
2300
|
+
"(or `nmem c detect`) to populate them.[/dim]"
|
|
2301
|
+
)
|
|
2302
|
+
console.print()
|
|
2303
|
+
return
|
|
2304
|
+
console.print(f"[bold]Wiki topics[/bold] [dim]({len(communities)} found)[/dim]")
|
|
2305
|
+
console.print()
|
|
2306
|
+
for c in communities:
|
|
2307
|
+
name = c.get("name", "Unnamed")
|
|
2308
|
+
members = c.get("member_count", 0)
|
|
2309
|
+
ent = len(c.get("top_entities", []))
|
|
2310
|
+
cry = len(c.get("top_crystals", []))
|
|
2311
|
+
console.print(
|
|
2312
|
+
f" [bold cyan]{name}[/bold cyan] "
|
|
2313
|
+
f"[dim]({members} members · {cry} crystals · {ent} entities)[/dim]"
|
|
2314
|
+
)
|
|
2315
|
+
cid = c.get("community_id", "?")
|
|
2316
|
+
console.print(f" [dim]nmem wiki page topic {cid}[/dim]")
|
|
2317
|
+
console.print()
|
|
2318
|
+
|
|
2319
|
+
|
|
2085
2320
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
2086
2321
|
# Graph Commands
|
|
2087
2322
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
@@ -4643,6 +4878,7 @@ def cmd_threads_save(
|
|
|
4643
4878
|
{
|
|
4644
4879
|
"messages": thread_data["messages"],
|
|
4645
4880
|
"deduplicate": True,
|
|
4881
|
+
**({"space_id": space_id} if space_id else {}),
|
|
4646
4882
|
},
|
|
4647
4883
|
)
|
|
4648
4884
|
results.append(
|
|
@@ -8719,6 +8955,151 @@ def cmd_config_client_clear(key: str | None = None) -> None:
|
|
|
8719
8955
|
)
|
|
8720
8956
|
|
|
8721
8957
|
|
|
8958
|
+
_MCP_HOST_APPS = {
|
|
8959
|
+
"codex": "Codex",
|
|
8960
|
+
"gemini-cli": "Gemini CLI",
|
|
8961
|
+
"cursor": "Cursor",
|
|
8962
|
+
"claude-desktop": "Claude",
|
|
8963
|
+
}
|
|
8964
|
+
|
|
8965
|
+
|
|
8966
|
+
def _mcp_endpoint_url(api_url: str) -> str:
|
|
8967
|
+
return f"{api_url.rstrip('/')}/mcp/"
|
|
8968
|
+
|
|
8969
|
+
|
|
8970
|
+
def _toml_string(value: str) -> str:
|
|
8971
|
+
return json_module.dumps(str(value), ensure_ascii=False)
|
|
8972
|
+
|
|
8973
|
+
|
|
8974
|
+
def _print_literal_block(text: str) -> None:
|
|
8975
|
+
"""Write copy-paste config without Rich markup or line wrapping."""
|
|
8976
|
+
console.file.write(text)
|
|
8977
|
+
if not text.endswith("\n"):
|
|
8978
|
+
console.file.write("\n")
|
|
8979
|
+
console.file.flush()
|
|
8980
|
+
|
|
8981
|
+
|
|
8982
|
+
def _build_mcp_headers(
|
|
8983
|
+
*,
|
|
8984
|
+
app: str,
|
|
8985
|
+
api_key: str,
|
|
8986
|
+
space_id: str | None = None,
|
|
8987
|
+
) -> dict[str, str]:
|
|
8988
|
+
headers = {"APP": app}
|
|
8989
|
+
if api_key:
|
|
8990
|
+
headers["Authorization"] = f"Bearer {api_key}"
|
|
8991
|
+
headers["X-NMEM-API-Key"] = api_key
|
|
8992
|
+
if space_id:
|
|
8993
|
+
headers["X-Nmem-Space-Id"] = space_id
|
|
8994
|
+
return headers
|
|
8995
|
+
|
|
8996
|
+
|
|
8997
|
+
def _build_host_mcp_config(
|
|
8998
|
+
host: str,
|
|
8999
|
+
*,
|
|
9000
|
+
app: str | None = None,
|
|
9001
|
+
space_id: str | None = None,
|
|
9002
|
+
) -> dict[str, Any]:
|
|
9003
|
+
normalized_host = host.strip().lower()
|
|
9004
|
+
if normalized_host not in _MCP_HOST_APPS:
|
|
9005
|
+
raise ValueError(f"Unsupported MCP host: {host}")
|
|
9006
|
+
|
|
9007
|
+
api_url, api_url_source = _resolve_api_url_and_source()
|
|
9008
|
+
api_key = get_api_key()
|
|
9009
|
+
api_key_source = "env" if os.environ.get("NMEM_API_KEY") is not None else (
|
|
9010
|
+
"config" if api_key else "none"
|
|
9011
|
+
)
|
|
9012
|
+
endpoint = _mcp_endpoint_url(api_url)
|
|
9013
|
+
app_header = (app or _MCP_HOST_APPS[normalized_host]).strip()
|
|
9014
|
+
headers = _build_mcp_headers(app=app_header, api_key=api_key, space_id=space_id)
|
|
9015
|
+
|
|
9016
|
+
if normalized_host == "codex":
|
|
9017
|
+
lines = [
|
|
9018
|
+
"[mcp_servers.nowledge-mem]",
|
|
9019
|
+
f"url = {_toml_string(endpoint)}",
|
|
9020
|
+
"",
|
|
9021
|
+
"[mcp_servers.nowledge-mem.http_headers]",
|
|
9022
|
+
]
|
|
9023
|
+
for key, value in headers.items():
|
|
9024
|
+
toml_key = key if re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", key) else _toml_string(key)
|
|
9025
|
+
lines.append(f"{toml_key} = {_toml_string(value)}")
|
|
9026
|
+
rendered = "\n".join(lines)
|
|
9027
|
+
config: Any = rendered
|
|
9028
|
+
fmt = "toml"
|
|
9029
|
+
else:
|
|
9030
|
+
server: dict[str, Any]
|
|
9031
|
+
if normalized_host == "gemini-cli":
|
|
9032
|
+
server = {
|
|
9033
|
+
"httpUrl": endpoint,
|
|
9034
|
+
"headers": headers,
|
|
9035
|
+
}
|
|
9036
|
+
else:
|
|
9037
|
+
server = {
|
|
9038
|
+
"url": endpoint,
|
|
9039
|
+
"type": "streamableHttp",
|
|
9040
|
+
"headers": headers,
|
|
9041
|
+
}
|
|
9042
|
+
config = {"mcpServers": {"nowledge-mem": server}}
|
|
9043
|
+
rendered = json_module.dumps(config, indent=2, ensure_ascii=False)
|
|
9044
|
+
fmt = "json"
|
|
9045
|
+
|
|
9046
|
+
warnings: list[str] = []
|
|
9047
|
+
if api_url.startswith("https://") and not api_key:
|
|
9048
|
+
warnings.append("Remote HTTPS endpoint has no API key in client config or NMEM_API_KEY.")
|
|
9049
|
+
|
|
9050
|
+
return {
|
|
9051
|
+
"host": normalized_host,
|
|
9052
|
+
"format": fmt,
|
|
9053
|
+
"config": config,
|
|
9054
|
+
"rendered": rendered,
|
|
9055
|
+
"apiUrl": api_url,
|
|
9056
|
+
"endpoint": endpoint,
|
|
9057
|
+
"apiUrlSource": api_url_source,
|
|
9058
|
+
"apiKeyConfigured": bool(api_key),
|
|
9059
|
+
"apiKeySource": api_key_source,
|
|
9060
|
+
"space": space_id,
|
|
9061
|
+
"warnings": warnings,
|
|
9062
|
+
}
|
|
9063
|
+
|
|
9064
|
+
|
|
9065
|
+
def cmd_config_mcp_show(
|
|
9066
|
+
host: str,
|
|
9067
|
+
*,
|
|
9068
|
+
app: str | None = None,
|
|
9069
|
+
space_id: str | None = None,
|
|
9070
|
+
) -> None:
|
|
9071
|
+
"""Print host-specific MCP config from shared client settings."""
|
|
9072
|
+
try:
|
|
9073
|
+
payload = _build_host_mcp_config(host, app=app, space_id=space_id)
|
|
9074
|
+
except ValueError as exc:
|
|
9075
|
+
if is_json_mode():
|
|
9076
|
+
output_json({"error": "unsupported_host", "message": str(exc)})
|
|
9077
|
+
else:
|
|
9078
|
+
print_error("Unsupported Host", str(exc))
|
|
9079
|
+
return
|
|
9080
|
+
|
|
9081
|
+
if is_json_mode():
|
|
9082
|
+
output_json(payload)
|
|
9083
|
+
return
|
|
9084
|
+
|
|
9085
|
+
console.print()
|
|
9086
|
+
console.print(f"[bold]Nowledge Mem MCP config for {payload['host']}[/bold]")
|
|
9087
|
+
console.print(f" Endpoint: {payload['endpoint']}")
|
|
9088
|
+
console.print(f" URL source: {payload['apiUrlSource']}")
|
|
9089
|
+
console.print(f" API key source: {payload['apiKeySource']}")
|
|
9090
|
+
if payload["apiKeyConfigured"]:
|
|
9091
|
+
console.print(" API key: [yellow]included in the snippet below[/yellow]")
|
|
9092
|
+
for warning in payload["warnings"]:
|
|
9093
|
+
console.print(f" [yellow]Warning:[/yellow] {warning}")
|
|
9094
|
+
console.print()
|
|
9095
|
+
_print_literal_block(payload["rendered"])
|
|
9096
|
+
console.print()
|
|
9097
|
+
console.print(
|
|
9098
|
+
"[dim]This command reads ~/.nowledge-mem/config.json plus NMEM_API_URL/NMEM_API_KEY. "
|
|
9099
|
+
"Direct MCP clients do not read that file by themselves; paste this into the host's MCP config when overriding a bundled local endpoint or connecting to remote Mem.[/dim]"
|
|
9100
|
+
)
|
|
9101
|
+
|
|
9102
|
+
|
|
8722
9103
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
8723
9104
|
# Argument Parser
|
|
8724
9105
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
@@ -9642,6 +10023,44 @@ PRIORITY
|
|
|
9642
10023
|
help="Louvain resolution (higher = more communities)"
|
|
9643
10024
|
)
|
|
9644
10025
|
|
|
10026
|
+
# wiki (with alias 'w')
|
|
10027
|
+
for name in ["wiki", "w"]:
|
|
10028
|
+
wiki_parser = subparsers.add_parser(
|
|
10029
|
+
name, help="LLM Wiki — read pages, export, list topics"
|
|
10030
|
+
)
|
|
10031
|
+
wiki_subs = wiki_parser.add_subparsers(dest="action")
|
|
10032
|
+
|
|
10033
|
+
page = wiki_subs.add_parser(
|
|
10034
|
+
"page", help="Render one wiki page as markdown"
|
|
10035
|
+
)
|
|
10036
|
+
page.add_argument(
|
|
10037
|
+
"kind", choices=["entity", "crystal", "topic"],
|
|
10038
|
+
help="What kind of wiki page to render",
|
|
10039
|
+
)
|
|
10040
|
+
page.add_argument(
|
|
10041
|
+
"id_or_name",
|
|
10042
|
+
help=(
|
|
10043
|
+
"Resource identifier: UUID or canonical name for entity, id "
|
|
10044
|
+
"for crystal, integer community_id for topic"
|
|
10045
|
+
),
|
|
10046
|
+
)
|
|
10047
|
+
|
|
10048
|
+
export = wiki_subs.add_parser(
|
|
10049
|
+
"export", help="Build the wiki markdown ZIP and save it locally"
|
|
10050
|
+
)
|
|
10051
|
+
export.add_argument(
|
|
10052
|
+
"output_dir", nargs="?", default=None,
|
|
10053
|
+
help="Output directory (default: current working directory)",
|
|
10054
|
+
)
|
|
10055
|
+
|
|
10056
|
+
topics = wiki_subs.add_parser(
|
|
10057
|
+
"topics", help="List wiki topic clusters"
|
|
10058
|
+
)
|
|
10059
|
+
topics.add_argument(
|
|
10060
|
+
"-n", "--limit", type=int, default=20,
|
|
10061
|
+
help="Max topics to show",
|
|
10062
|
+
)
|
|
10063
|
+
|
|
9645
10064
|
# graph (with alias 'g')
|
|
9646
10065
|
for name in ["graph", "g"]:
|
|
9647
10066
|
graph_parser = subparsers.add_parser(
|
|
@@ -10093,6 +10512,38 @@ examples:
|
|
|
10093
10512
|
help="Setting key: url, api-key, or all (default)",
|
|
10094
10513
|
)
|
|
10095
10514
|
|
|
10515
|
+
# config mcp
|
|
10516
|
+
mcp_parser = cfg_subs.add_parser(
|
|
10517
|
+
"mcp",
|
|
10518
|
+
help="Generate MCP config from this machine's client settings",
|
|
10519
|
+
)
|
|
10520
|
+
mcp_subs = mcp_parser.add_subparsers(dest="action")
|
|
10521
|
+
mcp_show = mcp_subs.add_parser(
|
|
10522
|
+
"show",
|
|
10523
|
+
help="Print host-specific MCP config",
|
|
10524
|
+
epilog="""examples:
|
|
10525
|
+
nmem config mcp show --host codex
|
|
10526
|
+
nmem config mcp show --host gemini-cli
|
|
10527
|
+
nmem config mcp show --host cursor --space Research
|
|
10528
|
+
nmem --json config mcp show --host codex""",
|
|
10529
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
10530
|
+
)
|
|
10531
|
+
mcp_show.add_argument(
|
|
10532
|
+
"--host",
|
|
10533
|
+
required=True,
|
|
10534
|
+
choices=sorted(_MCP_HOST_APPS),
|
|
10535
|
+
help="MCP client config shape to print",
|
|
10536
|
+
)
|
|
10537
|
+
mcp_show.add_argument(
|
|
10538
|
+
"--app",
|
|
10539
|
+
help="Override the APP header value (defaults to the selected host)",
|
|
10540
|
+
)
|
|
10541
|
+
mcp_show.add_argument(
|
|
10542
|
+
"--space",
|
|
10543
|
+
dest="space_id",
|
|
10544
|
+
help="Optional Mem space to send as X-Nmem-Space-Id",
|
|
10545
|
+
)
|
|
10546
|
+
|
|
10096
10547
|
# config settings
|
|
10097
10548
|
set_parser = cfg_subs.add_parser("settings", help="Knowledge processing settings")
|
|
10098
10549
|
set_subs = set_parser.add_subparsers(dest="action")
|
|
@@ -10497,6 +10948,16 @@ def main() -> int:
|
|
|
10497
10948
|
else:
|
|
10498
10949
|
# Default: list
|
|
10499
10950
|
cmd_communities_list(getattr(args, "limit", 20))
|
|
10951
|
+
elif cmd in ("wiki", "w"):
|
|
10952
|
+
action = getattr(args, "action", None)
|
|
10953
|
+
if action == "page":
|
|
10954
|
+
cmd_wiki_page(args.kind, args.id_or_name)
|
|
10955
|
+
elif action == "export":
|
|
10956
|
+
cmd_wiki_export(getattr(args, "output_dir", None))
|
|
10957
|
+
elif action == "topics":
|
|
10958
|
+
cmd_wiki_topics(getattr(args, "limit", 20))
|
|
10959
|
+
else:
|
|
10960
|
+
parser.parse_args([cmd, "--help"])
|
|
10500
10961
|
elif cmd in ("graph", "g"):
|
|
10501
10962
|
action = args.action
|
|
10502
10963
|
if action == "expand":
|
|
@@ -10672,6 +11133,27 @@ def main() -> int:
|
|
|
10672
11133
|
cmd_config_client_clear(getattr(args, "key", "all"))
|
|
10673
11134
|
else:
|
|
10674
11135
|
cmd_config_client_show()
|
|
11136
|
+
elif section == "mcp":
|
|
11137
|
+
action = getattr(args, "action", None)
|
|
11138
|
+
if action == "show" or action is None:
|
|
11139
|
+
host = getattr(args, "host", None)
|
|
11140
|
+
if not host:
|
|
11141
|
+
if is_json_mode():
|
|
11142
|
+
output_json({"error": "missing_host", "message": "Pass --host codex|gemini-cli|cursor|claude-desktop"})
|
|
11143
|
+
else:
|
|
11144
|
+
print_error("Missing Host", "Pass --host codex, gemini-cli, cursor, or claude-desktop.")
|
|
11145
|
+
return 1
|
|
11146
|
+
cmd_config_mcp_show(
|
|
11147
|
+
host,
|
|
11148
|
+
app=getattr(args, "app", None),
|
|
11149
|
+
space_id=getattr(args, "space_id", None),
|
|
11150
|
+
)
|
|
11151
|
+
else:
|
|
11152
|
+
cmd_config_mcp_show(
|
|
11153
|
+
getattr(args, "host", "codex"),
|
|
11154
|
+
app=getattr(args, "app", None),
|
|
11155
|
+
space_id=getattr(args, "space_id", None),
|
|
11156
|
+
)
|
|
10675
11157
|
elif section == "access":
|
|
10676
11158
|
action = getattr(args, "action", None)
|
|
10677
11159
|
if action == "set":
|
|
@@ -10688,10 +11170,12 @@ def main() -> int:
|
|
|
10688
11170
|
cmd_config_settings_show()
|
|
10689
11171
|
else:
|
|
10690
11172
|
if is_json_mode():
|
|
10691
|
-
output_json({"error": "no_section", "sections": ["provider", "access", "settings"]})
|
|
11173
|
+
output_json({"error": "no_section", "sections": ["provider", "client", "mcp", "access", "settings"]})
|
|
10692
11174
|
else:
|
|
10693
11175
|
console.print("[bold]Available config sections:[/bold]")
|
|
10694
11176
|
console.print(" provider — LLM provider configuration")
|
|
11177
|
+
console.print(" client — Client URL / API key settings")
|
|
11178
|
+
console.print(" mcp — Generate host MCP config from client settings")
|
|
10695
11179
|
console.print(" access — CLI / server LAN access settings")
|
|
10696
11180
|
console.print(" settings — Knowledge processing settings")
|
|
10697
11181
|
else:
|
|
@@ -148,7 +148,7 @@ def discover_sessions(
|
|
|
148
148
|
session_id: Optional[str],
|
|
149
149
|
) -> list[SessionCandidate]:
|
|
150
150
|
if client == "claude-code":
|
|
151
|
-
candidates = _discover_claude_sessions(project_path)
|
|
151
|
+
candidates = _discover_claude_sessions(project_path, session_id)
|
|
152
152
|
elif client == "codex":
|
|
153
153
|
candidates = _discover_codex_sessions(project_path, session_id)
|
|
154
154
|
elif client == "gemini-cli":
|
|
@@ -185,7 +185,9 @@ def parse_session(
|
|
|
185
185
|
)
|
|
186
186
|
|
|
187
187
|
|
|
188
|
-
def _discover_claude_sessions(
|
|
188
|
+
def _discover_claude_sessions(
|
|
189
|
+
project_path: str, target_session_id: Optional[str]
|
|
190
|
+
) -> list[SessionCandidate]:
|
|
189
191
|
claude_base, expected_claude_base = _resolve_claude_project_dir(project_path)
|
|
190
192
|
if claude_base is None:
|
|
191
193
|
raise SessionImportError(
|
|
@@ -203,6 +205,7 @@ def _discover_claude_sessions(project_path: str) -> list[SessionCandidate]:
|
|
|
203
205
|
with open(session_file, "r", encoding="utf-8") as handle:
|
|
204
206
|
lines = handle.readlines()
|
|
205
207
|
|
|
208
|
+
file_session_id: Optional[str] = None
|
|
206
209
|
user_messages = 0
|
|
207
210
|
assistant_messages = 0
|
|
208
211
|
last_message_time: Optional[float] = None
|
|
@@ -216,6 +219,10 @@ def _discover_claude_sessions(project_path: str) -> list[SessionCandidate]:
|
|
|
216
219
|
if not isinstance(event, dict):
|
|
217
220
|
continue
|
|
218
221
|
|
|
222
|
+
event_session_id = event.get("sessionId")
|
|
223
|
+
if isinstance(event_session_id, str) and event_session_id.strip():
|
|
224
|
+
file_session_id = event_session_id.strip()
|
|
225
|
+
|
|
219
226
|
event_type = event.get("type")
|
|
220
227
|
if event_type in ["file-history-snapshot", "summary"]:
|
|
221
228
|
continue
|
|
@@ -245,12 +252,16 @@ def _discover_claude_sessions(project_path: str) -> list[SessionCandidate]:
|
|
|
245
252
|
elif event_type == "assistant":
|
|
246
253
|
assistant_messages += 1
|
|
247
254
|
|
|
255
|
+
file_session_id = file_session_id or session_file.stem
|
|
256
|
+
if target_session_id and file_session_id != target_session_id:
|
|
257
|
+
continue
|
|
258
|
+
|
|
248
259
|
if user_messages > 0 or assistant_messages > 1:
|
|
249
260
|
sort_key = last_message_time or session_file.stat().st_mtime
|
|
250
261
|
candidates.append(
|
|
251
262
|
SessionCandidate(
|
|
252
263
|
file=session_file,
|
|
253
|
-
session_id=
|
|
264
|
+
session_id=file_session_id,
|
|
254
265
|
sort_key=sort_key,
|
|
255
266
|
user_messages=user_messages,
|
|
256
267
|
assistant_messages=assistant_messages,
|
|
@@ -4,13 +4,11 @@ Nowledge Mem TUI - Terminal User Interface for memory management.
|
|
|
4
4
|
Launch with: nmem tui
|
|
5
5
|
|
|
6
6
|
For Textual dev mode:
|
|
7
|
-
python -m textual run --dev nmem_cli.tui
|
|
7
|
+
python -m textual run --dev nmem_cli.tui.__main__:app
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
10
|
import logging
|
|
11
11
|
|
|
12
|
-
from .app import NowledgeMemApp
|
|
13
|
-
|
|
14
12
|
__all__ = ["NowledgeMemApp", "run_tui"]
|
|
15
13
|
|
|
16
14
|
# Disable all logging to stdout/stderr - it corrupts the TUI display
|
|
@@ -19,11 +17,20 @@ logging.getLogger("httpx").setLevel(logging.WARNING)
|
|
|
19
17
|
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
|
20
18
|
|
|
21
19
|
|
|
20
|
+
def _load_app_class():
|
|
21
|
+
from .app import NowledgeMemApp
|
|
22
|
+
|
|
23
|
+
return NowledgeMemApp
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def __getattr__(name: str):
|
|
27
|
+
if name == "NowledgeMemApp":
|
|
28
|
+
return _load_app_class()
|
|
29
|
+
raise AttributeError(name)
|
|
30
|
+
|
|
31
|
+
|
|
22
32
|
def run_tui() -> None:
|
|
23
33
|
"""Entry point for the TUI application."""
|
|
34
|
+
NowledgeMemApp = _load_app_class()
|
|
24
35
|
app = NowledgeMemApp()
|
|
25
36
|
app.run()
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
# For textual run --dev compatibility
|
|
29
|
-
app = NowledgeMemApp()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|