nmem-cli 0.8.0__tar.gz → 0.8.3__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.
Files changed (24) hide show
  1. {nmem_cli-0.8.0 → nmem_cli-0.8.3}/PKG-INFO +1 -1
  2. {nmem_cli-0.8.0 → nmem_cli-0.8.3}/pyproject.toml +1 -1
  3. {nmem_cli-0.8.0 → nmem_cli-0.8.3}/src/nmem_cli/__init__.py +1 -1
  4. nmem_cli-0.8.3/src/nmem_cli/claude_paths.py +63 -0
  5. {nmem_cli-0.8.0 → nmem_cli-0.8.3}/src/nmem_cli/cli.py +105 -11
  6. {nmem_cli-0.8.0 → nmem_cli-0.8.3}/src/nmem_cli/session_import.py +75 -68
  7. {nmem_cli-0.8.0 → nmem_cli-0.8.3}/src/nmem_cli/tui/screens/settings.py +1 -1
  8. {nmem_cli-0.8.0 → nmem_cli-0.8.3}/.gitignore +0 -0
  9. {nmem_cli-0.8.0 → nmem_cli-0.8.3}/README.md +0 -0
  10. {nmem_cli-0.8.0 → nmem_cli-0.8.3}/src/nmem_cli/license_payload.py +0 -0
  11. {nmem_cli-0.8.0 → nmem_cli-0.8.3}/src/nmem_cli/py.typed +0 -0
  12. {nmem_cli-0.8.0 → nmem_cli-0.8.3}/src/nmem_cli/tui/__init__.py +0 -0
  13. {nmem_cli-0.8.0 → nmem_cli-0.8.3}/src/nmem_cli/tui/__main__.py +0 -0
  14. {nmem_cli-0.8.0 → nmem_cli-0.8.3}/src/nmem_cli/tui/api_client.py +0 -0
  15. {nmem_cli-0.8.0 → nmem_cli-0.8.3}/src/nmem_cli/tui/app.py +0 -0
  16. {nmem_cli-0.8.0 → nmem_cli-0.8.3}/src/nmem_cli/tui/screens/__init__.py +0 -0
  17. {nmem_cli-0.8.0 → nmem_cli-0.8.3}/src/nmem_cli/tui/screens/dashboard.py +0 -0
  18. {nmem_cli-0.8.0 → nmem_cli-0.8.3}/src/nmem_cli/tui/screens/graph.py +0 -0
  19. {nmem_cli-0.8.0 → nmem_cli-0.8.3}/src/nmem_cli/tui/screens/help.py +0 -0
  20. {nmem_cli-0.8.0 → nmem_cli-0.8.3}/src/nmem_cli/tui/screens/memories.py +0 -0
  21. {nmem_cli-0.8.0 → nmem_cli-0.8.3}/src/nmem_cli/tui/screens/memory_detail.py +0 -0
  22. {nmem_cli-0.8.0 → nmem_cli-0.8.3}/src/nmem_cli/tui/screens/thread_detail.py +0 -0
  23. {nmem_cli-0.8.0 → nmem_cli-0.8.3}/src/nmem_cli/tui/screens/threads.py +0 -0
  24. {nmem_cli-0.8.0 → nmem_cli-0.8.3}/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.8.0
3
+ Version: 0.8.3
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/
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "nmem-cli"
3
- version = "0.8.0"
3
+ version = "0.8.3"
4
4
  description = "CLI and TUI for Nowledge Mem - AI memory management"
5
5
  authors = [
6
6
  {name = "Nowledge Labs"}
@@ -20,7 +20,7 @@ Environment (overrides config file):
20
20
  NMEM_API_KEY Optional API key (Bearer auth)
21
21
  """
22
22
 
23
- __version__ = "0.8.0"
23
+ __version__ = "0.8.3"
24
24
  __author__ = "Nowledge Labs"
25
25
 
26
26
  from .cli import main
@@ -0,0 +1,63 @@
1
+ """Claude Code project-directory path decoding helpers."""
2
+
3
+ import os
4
+
5
+
6
+ def find_valid_claude_path(base: str, remaining: list[str]) -> str | None:
7
+ """Find a valid path by matching Claude-encoded segments to real dirs."""
8
+ seen: set[tuple[str, tuple[str, ...]]] = set()
9
+
10
+ def _walk(current_base: str, parts: tuple[str, ...]) -> str | None:
11
+ state = (os.path.normpath(current_base), parts)
12
+ if state in seen:
13
+ return None
14
+ seen.add(state)
15
+
16
+ if not parts:
17
+ return current_base if os.path.exists(current_base) else None
18
+
19
+ try:
20
+ entries = list(os.scandir(current_base))
21
+ except OSError:
22
+ return None
23
+
24
+ matches: list[tuple[int, str]] = []
25
+ for entry in entries:
26
+ try:
27
+ if not entry.is_dir():
28
+ continue
29
+ except OSError:
30
+ continue
31
+
32
+ for encoded_parts in _encoded_segment_variants(entry.name):
33
+ length = len(encoded_parts)
34
+ if length <= len(parts) and parts[:length] == encoded_parts:
35
+ matches.append((length, entry.path))
36
+ break
37
+
38
+ for consumed, next_base in sorted(
39
+ matches,
40
+ key=lambda candidate: candidate[0],
41
+ reverse=True,
42
+ ):
43
+ result = _walk(next_base, parts[consumed:])
44
+ if result:
45
+ return result
46
+ return None
47
+
48
+ return _walk(base, tuple(remaining))
49
+
50
+
51
+ def _encoded_segment_variants(segment: str) -> list[tuple[str, ...]]:
52
+ variants: list[tuple[str, ...]] = []
53
+
54
+ def _add(encoded: str) -> None:
55
+ parts = tuple(encoded.split("-"))
56
+ if parts not in variants:
57
+ variants.append(parts)
58
+
59
+ _add(segment.replace(".", "-").replace("_", "-"))
60
+ if segment.startswith(".") and len(segment) > 1:
61
+ _add("-" + segment[1:])
62
+ _add(segment)
63
+ return variants
@@ -88,7 +88,7 @@ PROVIDER_DEFAULTS: dict[str, dict[str, str]] = {
88
88
  "anthropic": {"model": "claude-sonnet-4-20250514", "api_base": "https://api.anthropic.com"},
89
89
  "gemini": {"model": "gemini-flash-latest", "api_base": "https://generativelanguage.googleapis.com"},
90
90
  "xai": {"model": "grok-3", "api_base": "https://api.x.ai/v1"},
91
- "deepseek": {"model": "deepseek-chat", "api_base": "https://api.deepseek.com/v1"},
91
+ "deepseek": {"model": "deepseek-v4-flash", "api_base": "https://api.deepseek.com/v1"},
92
92
  "openrouter": {"model": "anthropic/claude-sonnet-4", "api_base": "https://openrouter.ai/api/v1"},
93
93
  "github_copilot": {"model": "gpt-5-mini"},
94
94
  "openai_codex": {"model": "gpt-5.2-codex"},
@@ -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
- f"CLI is v{normalized_cli}, but the server at {api_url} reports v{normalized_server}."
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 the Linux service. "
1401
- "If you expected the WSL server, stop the Windows app or point NMEM_API_URL at the Linux service explicitly.",
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
- "Upgrade or restart the server process you are connected to, or point NMEM_API_URL at the intended server.",
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}")
@@ -25,9 +25,55 @@ from datetime import datetime
25
25
  from pathlib import Path
26
26
  from typing import Any, Optional
27
27
 
28
+ from .claude_paths import find_valid_claude_path
29
+
28
30
 
29
31
  logger = logging.getLogger(__name__)
30
32
 
33
+ _KNOWN_TITLE_SCAFFOLD_PREFIXES = (
34
+ "system:",
35
+ "developer:",
36
+ "you are ",
37
+ "you are codex",
38
+ "you are claude code",
39
+ "you are opencode",
40
+ "you are a coding assistant",
41
+ "you are an ai coding assistant",
42
+ "act as ",
43
+ "# ",
44
+ "## ",
45
+ "### ",
46
+ "instructions:",
47
+ "rules:",
48
+ "constraints:",
49
+ "codebase and user instructions",
50
+ )
51
+
52
+ _KNOWN_HOST_SCAFFOLD_TAG_PREFIXES = (
53
+ "<environment_context>",
54
+ "<permissions instructions>",
55
+ "<apps_instructions>",
56
+ "<skills_instructions>",
57
+ "<plugins_instructions>",
58
+ "<system_instruction>",
59
+ "<system>",
60
+ "<system-prompt>",
61
+ "<instructions>",
62
+ "<context>",
63
+ )
64
+
65
+
66
+ def _is_poor_title_source(content: str) -> bool:
67
+ stripped = content.strip()
68
+ if not stripped:
69
+ return True
70
+ lower = stripped.lower()
71
+ if any(lower.startswith(prefix) for prefix in _KNOWN_TITLE_SCAFFOLD_PREFIXES):
72
+ return True
73
+ if any(lower.startswith(prefix) for prefix in _KNOWN_HOST_SCAFFOLD_TAG_PREFIXES):
74
+ return True
75
+ return len(stripped) > 500
76
+
31
77
 
32
78
  def _project_path_variants(project_path: str) -> list[str]:
33
79
  variants: list[str] = []
@@ -312,9 +358,7 @@ def _discover_codex_sessions(
312
358
  if session_cwd != project_path:
313
359
  continue
314
360
 
315
- filename = session_file.stem
316
- parts = filename.rsplit("-", 1)
317
- file_session_id = parts[1] if len(parts) > 1 else filename
361
+ file_session_id = str(payload.get("id") or session_file.stem)
318
362
  if target_session_id and file_session_id != target_session_id:
319
363
  continue
320
364
 
@@ -706,7 +750,11 @@ def parse_codex_session_streaming(
706
750
  continue
707
751
  seen_message_hashes.add(message_hash)
708
752
 
709
- if role == "user" and not first_user_content:
753
+ if (
754
+ role == "user"
755
+ and not first_user_content
756
+ and not _is_poor_title_source(content_text)
757
+ ):
710
758
  first_user_content = content_text
711
759
 
712
760
  messages.append(
@@ -788,7 +836,11 @@ def parse_gemini_session(file_path: Path) -> dict[str, Any]:
788
836
  if not content_text.strip():
789
837
  continue
790
838
 
791
- if role == "user" and not first_user_content:
839
+ if (
840
+ role == "user"
841
+ and not first_user_content
842
+ and not _is_poor_title_source(content_text)
843
+ ):
792
844
  first_user_content = content_text
793
845
 
794
846
  messages.append(
@@ -923,7 +975,11 @@ def _parse_opencode_sqlite_session(db_path: Path, session_id: str) -> dict[str,
923
975
  if not content:
924
976
  continue
925
977
 
926
- if role == "user" and not first_user_content:
978
+ if (
979
+ role == "user"
980
+ and not first_user_content
981
+ and not _is_poor_title_source(content)
982
+ ):
927
983
  first_user_content = content
928
984
 
929
985
  messages.append(
@@ -1011,7 +1067,11 @@ def _parse_opencode_json_session(session_path: Path) -> dict[str, Any]:
1011
1067
  if not content:
1012
1068
  continue
1013
1069
 
1014
- if role == "user" and not first_user_content:
1070
+ if (
1071
+ role == "user"
1072
+ and not first_user_content
1073
+ and not _is_poor_title_source(content)
1074
+ ):
1015
1075
  first_user_content = content
1016
1076
 
1017
1077
  messages.append(
@@ -1318,7 +1378,11 @@ def parse_claude_code_session_streaming(
1318
1378
  i = max(i + 1, j)
1319
1379
  continue
1320
1380
 
1321
- if role == "user" and not first_user_content:
1381
+ if (
1382
+ role == "user"
1383
+ and not first_user_content
1384
+ and not _is_poor_title_source(content)
1385
+ ):
1322
1386
  first_user_content = content
1323
1387
 
1324
1388
  messages.append(
@@ -1413,40 +1477,13 @@ def _decode_project_path_enhanced(encoded: str) -> Optional[str]:
1413
1477
  return simple
1414
1478
 
1415
1479
  segments = encoded[1:].split("-")
1416
- result = _find_valid_path("/", segments)
1480
+ result = find_valid_claude_path("/", segments)
1417
1481
  if result:
1418
1482
  return result
1419
1483
 
1420
1484
  return simple
1421
1485
 
1422
1486
 
1423
- def _find_valid_path(base: str, remaining: list[str]) -> Optional[str]:
1424
- if not remaining:
1425
- return base if os.path.exists(base) else None
1426
-
1427
- if remaining[0] == "" and len(remaining) >= 2 and remaining[1]:
1428
- for prefix in (".", "-"):
1429
- result = _find_valid_path(base, [prefix + remaining[1], *remaining[2:]])
1430
- if result:
1431
- return result
1432
-
1433
- next_segment = remaining[0]
1434
- with_slash = os.path.join(base, next_segment)
1435
- if os.path.exists(with_slash):
1436
- result = _find_valid_path(with_slash, remaining[1:])
1437
- if result:
1438
- return result
1439
-
1440
- if len(remaining) >= 2:
1441
- for delimiter in ("-", "."):
1442
- combined = remaining[0] + delimiter + remaining[1]
1443
- result = _find_valid_path(base, [combined] + remaining[2:])
1444
- if result:
1445
- return result
1446
-
1447
- return None
1448
-
1449
-
1450
1487
  def _decode_project_path_windows(encoded: str) -> Optional[str]:
1451
1488
  drive_match = re.match(r"^([A-Za-z])-(.*)$", encoded)
1452
1489
  if not drive_match:
@@ -1461,43 +1498,13 @@ def _decode_project_path_windows(encoded: str) -> Optional[str]:
1461
1498
 
1462
1499
  segments = rest.split("-")
1463
1500
  base = f"{drive_letter}:\\"
1464
- result = _find_valid_path_windows(base, segments)
1501
+ result = find_valid_claude_path(base, segments)
1465
1502
  if result:
1466
1503
  return result
1467
1504
 
1468
1505
  return simple
1469
1506
 
1470
1507
 
1471
- def _find_valid_path_windows(base: str, remaining: list[str]) -> Optional[str]:
1472
- if not remaining:
1473
- return base if os.path.exists(base) else None
1474
-
1475
- if remaining[0] == "" and len(remaining) >= 2 and remaining[1]:
1476
- for prefix in (".", "-"):
1477
- result = _find_valid_path_windows(
1478
- base,
1479
- [prefix + remaining[1], *remaining[2:]],
1480
- )
1481
- if result:
1482
- return result
1483
-
1484
- next_segment = remaining[0]
1485
- with_slash = os.path.join(base, next_segment)
1486
- if os.path.exists(with_slash):
1487
- result = _find_valid_path_windows(with_slash, remaining[1:])
1488
- if result:
1489
- return result
1490
-
1491
- if len(remaining) >= 2:
1492
- for delimiter in ("-", "."):
1493
- combined = remaining[0] + delimiter + remaining[1]
1494
- result = _find_valid_path_windows(base, [combined] + remaining[2:])
1495
- if result:
1496
- return result
1497
-
1498
- return None
1499
-
1500
-
1501
1508
  def _encode_claude_project_path_variant(
1502
1509
  project_path: str, *, dot_mode: str
1503
1510
  ) -> str:
@@ -1527,7 +1534,7 @@ def _encode_claude_path_segment(
1527
1534
  segment: str, *, dot_mode: str
1528
1535
  ) -> str:
1529
1536
  if dot_mode == "all_as_hyphen":
1530
- return segment.replace(".", "-")
1537
+ return segment.replace(".", "-").replace("_", "-")
1531
1538
  if dot_mode == "hidden_only" and segment.startswith(".") and len(segment) > 1:
1532
1539
  return "-" + segment[1:]
1533
1540
  return segment
@@ -54,7 +54,7 @@ PROVIDER_DEFAULTS: dict[str, dict[str, str]] = {
54
54
  "anthropic": {"model": "claude-sonnet-4-20250514", "api_base": "https://api.anthropic.com"},
55
55
  "gemini": {"model": "gemini-flash-latest", "api_base": "https://generativelanguage.googleapis.com"},
56
56
  "xai": {"model": "grok-3", "api_base": "https://api.x.ai/v1"},
57
- "deepseek": {"model": "deepseek-chat", "api_base": "https://api.deepseek.com/v1"},
57
+ "deepseek": {"model": "deepseek-v4-flash", "api_base": "https://api.deepseek.com/v1"},
58
58
  "openrouter": {"model": "anthropic/claude-sonnet-4", "api_base": "https://openrouter.ai/api/v1"},
59
59
  "github_copilot": {"model": "gpt-5-mini"},
60
60
  "openai_codex": {"model": "gpt-5.2-codex"},
File without changes
File without changes
File without changes